diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1f7dcd4..b193cde 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,16 +19,11 @@ jobs: with: channel: stable cache: true - - name: Install Melos - run: flutter pub global activate melos - - name: Setup Melos - run: melos bootstrap - - name: Get Dependencies - run: melos deps + - name: Install dependencies + run: flutter pub get - name: Build Example - run: | - cd packages/flutter_box_transform/example - flutter build web --release + working-directory: packages/flutter_box_transform/example + run: flutter build web --release - name: Deploy example uses: FirebaseExtended/action-hosting-deploy@v0 with: @@ -38,9 +33,8 @@ jobs: projectId: box-transform-example target: example - name: Build Playground - run: | - cd packages/flutter_box_transform/playground - flutter build web --release + working-directory: packages/flutter_box_transform/playground + run: flutter build web --release - name: Deploy playground uses: FirebaseExtended/action-hosting-deploy@v0 with: diff --git a/.github/workflows/pr_build.yml b/.github/workflows/pr_build.yml index 31263a1..9a5a7ab 100644 --- a/.github/workflows/pr_build.yml +++ b/.github/workflows/pr_build.yml @@ -19,11 +19,8 @@ jobs: uses: subosito/flutter-action@v2 with: channel: stable - - name: Install Melos - run: flutter pub global activate melos - - name: Bootstrap - run: melos bootstrap + - name: Install dependencies + run: flutter pub get - name: Build example app - run: | - cd packages/flutter_box_transform/example - flutter build web --release \ No newline at end of file + working-directory: packages/flutter_box_transform/example + run: flutter build web --release diff --git a/.github/workflows/pr_quality.yml b/.github/workflows/pr_quality.yml index 472965d..e3dddb8 100644 --- a/.github/workflows/pr_quality.yml +++ b/.github/workflows/pr_quality.yml @@ -20,13 +20,11 @@ jobs: with: channel: stable cache: true - - name: Install Melos - run: flutter pub global activate melos - - name: Bootstrap - run: melos bootstrap + - name: Install dependencies + run: flutter pub get - name: Check formatting - run: melos check-format + run: dart format --output none --set-exit-if-changed . - name: Analyze - run: melos analyze + run: dart analyze . --fatal-infos --fatal-warnings - name: Run tests - run: melos test + run: flutter test diff --git a/.github/workflows/pr_test.yml b/.github/workflows/pr_test.yml index 9f39a9e..13656b8 100644 --- a/.github/workflows/pr_test.yml +++ b/.github/workflows/pr_test.yml @@ -24,16 +24,15 @@ jobs: cache: true - name: Flutter version run: flutter --version - - name: Install melos - run: dart pub global activate melos - - name: Setup melos - run: melos bootstrap - name: Install dependencies - run: melos deps + run: flutter pub get - name: Run tests with coverage - run: melos run lcov + working-directory: packages/box_transform + run: | + flutter pub run coverage:test_with_coverage + flutter pub run coverage:format_coverage --packages=../../.dart_tool/package_config.json --lcov -i coverage/coverage.json -o coverage/lcov.info - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 with: files: ./packages/box_transform/coverage/lcov.info - name: codecov-box-transform \ No newline at end of file + name: codecov-box-transform diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index af34d0a..9edf0cd 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -25,15 +25,15 @@ jobs: with: channel: stable - run: flutter --version - - name: Install Melos - run: flutter pub global activate melos - - name: Bootstrap - run: melos bootstrap - name: Install dependencies - run: melos deps + run: flutter pub get - name: Check code formatting - run: melos check-format + run: dart format --output none --set-exit-if-changed . - name: Analyze code - run: melos analyze - - name: Publish - run: melos publish --yes --no-dry-run \ No newline at end of file + run: dart analyze . --fatal-infos --fatal-warnings + - name: Publish box_transform + working-directory: packages/box_transform + run: dart pub publish --force + - name: Publish flutter_box_transform + working-directory: packages/flutter_box_transform + run: flutter pub publish --force diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 57214bf..77f173c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,16 +24,15 @@ jobs: cache: true - name: Flutter version run: flutter --version - - name: Install melos - run: dart pub global activate melos - - name: Setup melos - run: melos bootstrap - name: Install dependencies - run: melos deps + run: flutter pub get - name: Run tests with coverage - run: melos run lcov + working-directory: packages/box_transform + run: | + flutter pub run coverage:test_with_coverage + flutter pub run coverage:format_coverage --packages=../../.dart_tool/package_config.json --lcov -i coverage/coverage.json -o coverage/lcov.info - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 with: files: ./packages/box_transform/coverage/lcov.info - name: codecov-box-transform \ No newline at end of file + name: codecov-box-transform diff --git a/.gitignore b/.gitignore index 10c8728..6bd351e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ pubspec_overrides.yaml **/*.iml **/coverage .dart_tool +**/build/ firebase-debug.log diff --git a/.run/Example.run.xml b/.run/Example.run.xml new file mode 100644 index 0000000..cb3823c --- /dev/null +++ b/.run/Example.run.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.run/Test Recorder.run.xml b/.run/Test Recorder.run.xml deleted file mode 100644 index 43fb84d..0000000 --- a/.run/Test Recorder.run.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index e73f3aa..6e2a76c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,48 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## Unreleased — Rotation support + +--- + +Packages with breaking changes: + +- [`box_transform` - `Unreleased`](#box_transform---unreleased): the default `bindingStrategy` on `BoxTransformer.move/resize/rotate` flips from `originalBox` to `boundingBox`. The old behavior is preserved for callers that pass the strategy explicitly. The list-based LP inspection API (`LinearInequality`, `ProjectionResult`, `buildCornerInequalities`, `buildCenterInequalities`, `projectOntoFeasibleRegion`) is removed; production callers go through the flat-buffer hot path (`IneqBuffer` + `*Into` builders + `projectOntoFeasibleRegionFlat`), and tests use a private test helper at `test/lp_inspect.dart`. +- [`flutter_box_transform` - `Unreleased`](#flutter_box_transform---unreleased): the default `bindingStrategy` on `TransformableBox`, `TransformableBoxController`, and `UIBoxTransform.*` flips from `originalBox` to `boundingBox`. Existing apps will see a behavior change at non-zero rotation: the rendered footprint is now the containment surface, not the unrotated logical rect. Top-level rotated-layout helpers (`rotateOffsetAround`, `handleCornerInParent`, `rotatedCornerInWorld`, `anchorInHandle`, `handleTopLeftInWorld`, `sideHandleRectInWorld`, `computeEffectiveContainmentRect`) are relocated as static methods on `RotatedLayout`; the file-private default handle builders are promoted to `HandleBuilders.defaultCorner` / `HandleBuilders.defaultSide`. The top-level `defaultResizeModeResolver()` is relocated to `TransformableBoxController.defaultResizeModeResolver()`. + +Packages with other changes: + +- [`box_transform` - `Unreleased`](#box_transform---unreleased) +- [`flutter_box_transform` - `Unreleased`](#flutter_box_transform---unreleased) + +--- + +#### `box_transform` - `Unreleased` + +- Add first-class rotation support around a box's center via `Box.rotation` and a new `BoxTransformer.rotate(...)` entry point. +- Add `BindingStrategy` enum: `originalBox` keeps the unrotated logical rect inside the clamp; `boundingBox` keeps the rendered rotated polygon (and therefore its axis-aligned bounding box) fully inside. +- **[BREAKING]** Default `bindingStrategy` flipped from `originalBox` to `boundingBox` on `BoxTransformer.move/resize/rotate`. Pass `BindingStrategy.originalBox` explicitly to restore the previous behavior. +- `BoxTransformer.move()`, `.resize()`, `.rotate()` are all rotation-aware. Freeform, scale, symmetric, symmetricScale, side-handle, and force-flip resize paths each honor rotation under both binding strategies. +- Rotation gestures slide-then-freeze: when the requested angle would push the rect outside the clamp, the engine first tries to translate into available slack; if no translation rescues it, the rotation caps at the last feasible angle. +- Force-flip on a rotated rect falls back to natural direction when the flipped state can't fit clamp + constraints. The rect tracks the cursor by clamp-pinning at the natural wall instead of leaking the clamp or freezing. +- Add `feasible` field to `RotateResult` and `ResizeResult`. `false` means the engine could not honor the requested target without leaking; consumers (controllers) use it to hold the last feasible state. +- Solver: dedicated rotated-clamping LP with corner-, side-, and center-anchored inequality builders, an L2 projector with 1D fallback at saturation, and a unified violator-priority loop. `FlatProjection` now exposes `feasible` + `worstResidual`. +- Tests: extensive rotated-resize, rotated-move, rotated-flip, side-handle, scale, symmetric, symmetricScale, and clamp-invariant coverage. New `clamp_invariants_test.dart` asserts engine invariants (clamp containment, side-handle scope, constraint compliance) on recorded playground scenarios. + +#### `flutter_box_transform` - `Unreleased` + +- **[BREAKING]** Top-level rotated-layout helpers are now static methods on `RotatedLayout` (`rotateOffsetAround`, `handleCornerInParent`, `rotatedCornerInWorld`, `anchorInHandle`, `handleTopLeftInWorld`, `sideHandleRectInWorld`, and `computeEffectiveContainmentRect`). The previous top-level forms are removed. +- **[BREAKING]** Default handle builders moved from file-private functions to public statics on `HandleBuilders` (`HandleBuilders.defaultCorner`, `HandleBuilders.defaultSide`). +- Add `rotation`, `rotatable`, and `bindingStrategy` parameters on `TransformableBox`. +- **[BREAKING]** Default `bindingStrategy` flipped from `originalBox` to `boundingBox` on `TransformableBox`, `TransformableBoxController`, and `UIBoxTransform.move/resize/rotate`. Existing apps that relied on the unrotated-logical-rect semantic must pass `BindingStrategy.originalBox` explicitly. +- Add per-corner rotation gesture: an outer ring around each corner-handle captures rotation when `rotatable: true`. Sized via `rotationHandleGestureSize` (default 64 px). Add rotation callbacks: `onRotationStart` / `onRotationUpdate` / `onRotationEnd` / `onRotationCancel`. +- `TransformableBoxController` adds `rotation`, `initialRotation`, `bindingStrategy`, `onRotateStart` / `onRotateUpdate` / `onRotateEnd` / `onRotateCancel`. `onResizeUpdate` and `onRotateUpdate` skip state writes on `result.feasible == false` and override `result.rect`/`result.rotation` to the controller's last feasible value, so consumers binding visible state via callbacks (`box.rect = result.rect`) stay clamp-pinned at the last feasible position rather than snapping back to gesture-start. +- Side handles render rotated under non-zero rotation and translate gesture coordinates into the box's un-rotated frame so resize tracks the cursor visually. +- Hit-testing gates corner taps to the rotated polygon (not the AABB) so rotated boxes don't capture clicks in their AABB wedges. +- Playground gains a rotation slider, `bindingStrategy` toggle, debug overlays for rotated/unrotated bounds, and a tick-by-tick test recorder that captures full gesture sequences (rotation + bindingStrategy aware) for regression replay. +- Tests: rotated controller gestures, rotated layout, rotated drag hit-gating, rotated gesture integration, clamp-shrink continuity under rotation, and a force-flip fallback regression test. +- **[BREAKING]** `defaultResizeModeResolver()` is now `TransformableBoxController.defaultResizeModeResolver()`. The top-level function has been removed; consumers that referenced it directly (e.g. as a default for `resizeModeResolver`) must update to the qualified static. + ## 2025-03-26 --- diff --git a/README.md b/README.md index a449308..b1b3bde 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # Box Transform -[![melos](https://img.shields.io/badge/maintained%20with-melos-f700ff.svg?style=flat-square)](https://github.com/invertase/melos) [![Build](https://github.com/hyper-designed/box_transform/actions/workflows/build.yml/badge.svg)](https://github.com/hyper-designed/box_transform/actions/workflows/build.yml) [![Tests](https://github.com/hyper-designed/box_transform/workflows/Tests/badge.svg?branch=main)](https://github.com/hyper-designed/box_transform/actions) [![codecov](https://codecov.io/gh/BirjuVachhani/box_transform/branch/main/graph/badge.svg?token=SX5FXDUD7A)](https://codecov.io/gh/BirjuVachhani/box_transform) +[![Build](https://github.com/hyper-designed/box_transform/actions/workflows/build.yml/badge.svg)](https://github.com/hyper-designed/box_transform/actions/workflows/build.yml) [![Tests](https://github.com/hyper-designed/box_transform/workflows/Tests/badge.svg?branch=main)](https://github.com/hyper-designed/box_transform/actions) [![codecov](https://codecov.io/gh/BirjuVachhani/box_transform/branch/main/graph/badge.svg?token=SX5FXDUD7A)](https://codecov.io/gh/BirjuVachhani/box_transform) [Box Transform][github] provides packages that allows you to programmatically handle box resizing and dragging. It provides highly flexible, programmatically resizable and draggable boxes that can be used in any Dart or Flutter @@ -24,6 +24,7 @@ project. * 🎨 **Flexible Resizing Modes:** Choose from four different resizing modes for more flexibility in how boxes are resized. * 📍 **Customizable Anchor Points:** Define resizing corner-handles to anchor different parts of the box when resizing. * 🎨 **Customizable Handles:** Use default resizing handles or define your own custom handles. +* 🌀 **Rotation Support:** Rotate boxes around their center, with full rotated-aware drag, resize, flip, and clamping. Choose between `BindingStrategy.originalBox` (the unrotated logical rect stays in the clamp; rotated corners may extend outside) and `BindingStrategy.boundingBox` (the rendered axis-aligned footprint stays inside). Rotation gestures slide-then-freeze: when the requested angle would force the rect outside the clamp, the box slides into available slack first, then caps at the last feasible angle. Rotated force-flip also falls back to the natural direction when the flipped state can't fit, so the rect stays clamp-pinned instead of jumping or freezing. * 🚀 **Easy Integration:** Integrate Box Transform into your Dart or Flutter project with ease. ## Getting Started diff --git a/development.md b/development.md index 107276c..48b24c3 100644 --- a/development.md +++ b/development.md @@ -1,50 +1,85 @@ ## Setting up project for development -This project uses [melos](https://melos.invertase.dev/) to manage the monorepo. To get started, clone the repository -and run the following commands: +This project is a [Dart pub workspace](https://dart.dev/tools/pub/workspaces). +A single `pub get` at the repository root resolves dependencies for every +package in the monorepo and links them together. Requires Dart `>=3.6.0` +(Flutter `>=3.27.0`). -1. Install melos. Make sure you have `.pub-cache/bin` in your path. +1. Clone the repository and fetch dependencies for the whole workspace: ```bash -dart pub global activate melos +flutter pub get ``` -2. Setup the project with melos. +You can now run the example or playground apps directly. Local changes to +`box_transform` and `flutter_box_transform` are picked up automatically +because they are workspace members. + +### Running tests + +Run from the repo root to test all packages: ```bash -melos bootstrap +flutter test ``` -This will create dependency overrides for all the packages in the monorepo. +> Use `flutter test` (not `dart test`) — the workspace contains Flutter +> packages, so the Dart-only resolver can't be used. -3. Run `pub get` with melos. +### Running tests with coverage report + +1. Install genhtml: ```bash -melos run deps +brew install lcov ``` -Now you can run the example app. See `melos.yaml` for more commands. - -### Running test +2. Run tests and generate report: ```bash -melos run test +cd packages/box_transform +flutter pub run coverage:test_with_coverage +flutter pub run coverage:format_coverage --packages=../../.dart_tool/package_config.json --lcov -i coverage/coverage.json -o coverage/lcov.info +genhtml coverage/lcov.info -o coverage/html ``` -### Running tests with coverage report. +3. Open `packages/box_transform/coverage/html/index.html` in your browser to + view the report. -1. Install genhtml: +### Formatting and analysis + +Run from the repo root to cover every package: ```bash -brew install lcov +dart format . +dart analyze . --fatal-infos --fatal-warnings ``` -2. Run tests and generate report +### Test layout -```bash -melos run cov -``` +The test suites use two complementary prefixes: + +* `rotated__test.dart` covers an existing feature *under rotation* + (e.g. `rotated_freeform_test.dart`, `rotated_side_handle_test.dart`). +* `rotation__test.dart` covers the rotation feature itself + (e.g. `rotation_primitives_test.dart`, `rotation_integration_test.dart`). + +`clamp_*_test.dart` files are targeted regression tests for clamp behavior +(`clamp_invariants_test.dart`, `clamp_at_saturation_move_test.dart`); the +older `clamped_*_resizing_test.dart` family covers the legacy +"resize-with-clamping-enabled" suites. + +`packages/flutter_box_transform/test` mirrors the same scheme for widget / +controller tests (`rotated_*` for features, `rotation_*` for rotation +itself). -3. Open `coverage/index.html` in browser to view the report. +### Playground test recorder -You can run `melos run open_cov` on macOS to open the report in browser. \ No newline at end of file +The playground has a built-in test recorder accessible from its side panel. +It captures every `onResizeUpdate` tick of a gesture (cursor positions + +rect outputs) along with the active `rotation` and `bindingStrategy`, and +exports a tick-by-tick replay test with per-tick clamp invariants. The +exported file uses `BoxTransformer.resize(...)` directly, so it lives in +`packages/box_transform/test/`. Use it whenever you find a visual bug that +only manifests under continuous gesture motion — a single direct jump from +start to end often won't reproduce the bug the recorder catches. diff --git a/docs.json b/docs.json index 75e053a..85e4389 100644 --- a/docs.json +++ b/docs.json @@ -44,6 +44,10 @@ [ "Resize Modes", "/resize_modes" + ], + [ + "Binding Strategies", + "/binding_strategies" ] ] ], @@ -62,6 +66,10 @@ "Resizing", "/flutter_resizing" ], + [ + "Rotating", + "/flutter_rotating" + ], [ "Handles", "/handles" @@ -71,7 +79,7 @@ "/flutter_controller" ], [ - "API Reference \uD83D\uDD17", + "API Reference 🔗", "https://pub.dev/documentation/flutter_box_transform/latest/" ] ] @@ -92,11 +100,19 @@ "/resizing" ], [ - "API Reference \uD83D\uDD17", + "Rotating", + "/rotating" + ], + [ + "API Reference 🔗", "https://pub.dev/documentation/box_transform/latest/" ] ] ], + [ + "Benchmarks", + "/benchmarks" + ], [ "License", "/license" diff --git a/docs/benchmarks.mdx b/docs/benchmarks.mdx new file mode 100644 index 0000000..8e3f83a --- /dev/null +++ b/docs/benchmarks.mdx @@ -0,0 +1,297 @@ +--- +title: Benchmarks +--- + +# Benchmarks + +> **Disclaimer.** This entire benchmark page, including the harness in +> `packages/box_transform/benchmark/comprehensive_bench.dart`, the +> measurement run, the curated tables below, and the prose around them, +> was generated end to end by an AI agent. The numbers are real (the +> harness exists in the repo and you can rerun it yourself, see the +> [Reproducing](#reproducing) section), but the synthesis, framing, and +> conclusions are AI work. Treat them as a starting point for your own +> profiling, not as an authoritative spec. + +These numbers exist to give you a feel for what the engine does at run +time. They are **not** a head to head comparison against any other +library, and they should not be read as a target. They tell you roughly +how many `BoxTransformer` calls you can do per frame on a modern CPU, +where the cost goes when rotation is involved, and how the binding +strategy choice trades off against runtime work. + +The full report is generated by +`packages/box_transform/benchmark/comprehensive_bench.dart`, which +iterates the orthogonal axes of the engine: operation × rotation × +clamping state × constraints × binding strategy × pointer delta +pattern. The `RESIZE` matrix alone has **432 cells**; this page +surfaces representative slices. + +## Environment + +* **Dart:** 3.11.4 stable +* **OS:** macOS 26.4.1 on Apple Silicon (arm64) +* **Mode:** AOT-compiled (`dart compile exe`) + +## Methodology + +The harness follows established microbenchmarking practice (see +[Sources](#sources)): + +* **Warmup, then measure.** 4000 warmup calls precede every scenario so + the AOT code reaches steady state and cold-cache effects don't skew + the first batch. +* **Batched timing.** 25 batches × 4000 calls per scenario. Per-call + wall time is computed as `batch_us × 1000 / batchSize`. Batching + amortizes `Stopwatch.elapsedMicroseconds` overhead (roughly 30 to 100 + ns per start/stop on modern CPUs) below 0.1% of the measurement. +* **Sink fold.** Every result XORs into a global `sink` integer that + is printed at the end. Without this the AOT compiler is free to + delete the entire benchmark loop because nothing reads its output; + this is the same pattern JMH's `Blackhole` solves on the JVM. +* **Percentiles.** We report min, p50 (median), mean, p95, p99, and + max across batches, plus an ops/sec derived from the mean. The mean + alone hides the tail; p95/p99 are where you see GC pauses, OS + context switches, and slow-path branches. +* **Axis-grid generation.** Scenarios are generated combinatorially + rather than hand picked, which guarantees coverage and makes the + matrix self documenting. +* **Pointer delta patterns:** + * **smooth:** slow drift (~0.1 px/call). Models a controlled drag. + * **subpix:** sub-pixel drift (~0.001 px/call). Models hover-style + fine adjustment. + * **jitter:** slow drift plus ±1 px noise (deterministic seed). + Models a real cursor on real hardware. + * **saturated:** pointer ranges far outside the clamp every call. + Forces the projector or interval clamp to pin every iteration. + +The `Stopwatch` clock-source itself is not perfectly free, which is +why batching matters; Aleksey Shipilëv's +[Nanotrusting the Nanotime](https://shipilev.net/blog/2014/nanotrusting-nanotime/) +is the canonical writeup of why naive `start; op; stop` loops lie. + +## Headline numbers (mean ns/op) + +| Category | Min | Median | Max | Cells | +|-------------|----:|-------:|-----:|------:| +| **MOVE** | 59 | 97 | 211 | 36 | +| **RESIZE** | 67 | 287 | 1298 | 432 | +| **ROTATE** | 151 | 156 | 177 | 4 | +| **RECLAMP** | 56 | 84 | 102 | 6 | + +### What "ns/op" means in human terms + +Translating the median figures into a per-frame budget at 60 Hz (16.6 +ms per frame) and 120 Hz (8.3 ms per frame): + +| Operation | ns/op | Calls per 16.6 ms frame | Calls per 8.3 ms frame | +|------------------------|------:|------------------------:|-----------------------:| +| Axis-aligned move | 60 | ~277,000 | ~138,000 | +| Rotated move (orig) | 97 | ~171,000 | ~85,000 | +| Axis-aligned freeform resize | 130 | ~128,000 | ~64,000 | +| Rotated freeform (orig) | 290 | ~57,000 | ~28,000 | +| Rotated freeform (bbox) | 370 | ~44,000 | ~22,000 | +| Worst cell (`symscale, θ=0, saturated`) | 1,300 | ~12,700 | ~6,300 | + +The shape of any sane Flutter UI is a single `TransformableBox` (or a +small handful) updating once per pointer event, which means the engine +costs **roughly one ten-thousandth** of a frame at 60 Hz. The numbers +in this page only become operationally interesting in two situations: +many thousands of boxes per frame (layout solvers, simulation), or +deeply nested rebuild chains where the engine cost adds up across +unrelated `setState` calls. + +## MOVE + +`BoxTransformer.move(...)` translates a box, optionally clamped. + +| Scenario | mean ns/op | ops/sec | +|----------------------------------------------|-----------:|--------:| +| `clamp=saturated, delta=saturated, θ=0` | 59 | 16.9 M | +| `clamp=loose, delta=subpix, θ=0` | 63 | 15.8 M | +| `clamp=loose, delta=smooth, θ=π/6, orig` | 89 | 11.3 M | +| `clamp=loose, delta=jitter, θ=π/6, orig` | 110 | 9.1 M | +| `clamp=loose, delta=smooth, θ=π/6, bbox` | 128 | 7.8 M | +| `clamp=loose, delta=jitter, θ=π/6, bbox` | 143 | 7.0 M | + +What this slice tells you: + +* The axis-aligned (`θ=0`) path is dominated by simple per-axis clamp + arithmetic. **60 to 90 ns/op** is the clock-source plus a handful of + branches. +* Rotating to `θ=π/6` (~30°) under `BindingStrategy.originalBox` adds + roughly **30 to 50 ns** because the solver builds and clamps a joint + translation interval against four corner constraints instead of two + per-axis bounds. +* Switching to `BindingStrategy.boundingBox` adds another **~30 to 40 + ns** on top of that. The cost buys guaranteed AABB containment of the + rotated rect (see the [Binding Strategies](/binding_strategies) page + for what each strategy enforces). +* Pointer delta pattern barely matters for `move()`. The work is + dominated by clamp construction, not the delta itself, which is why + `smooth` and `jitter` numbers cluster within a handful of ns. + +## RESIZE + +`BoxTransformer.resize(...)` is the largest matrix because it crosses +four resize modes (`freeform`, `scale`, `symmetric`, `symmetricScale`) +with rotation, clamping, constraints, binding strategy, and pointer +patterns. The numbers below pin one delta pattern (`saturated`) and +walk the resize modes; the spread across other delta patterns is +small (typically within 5 to 10%). + +### Axis-aligned (θ=0) + +| Mode | clamp=none, cons=none | clamp=loose, cons=loose | clamp=loose, cons=tight | +|----------------|----------------------:|------------------------:|------------------------:| +| freeform | 68 ns (14.5 M) | 142 ns (7.1 M) | 129 ns (7.7 M) | +| scale | 166 ns (6.0 M) | 185 ns (5.4 M)* | 175 ns (5.7 M)* | +| symmetric | 118 ns (8.5 M) | 140 ns (7.2 M)* | 135 ns (7.4 M)* | +| symmetricScale | **1081 ns (0.92 M)** | ~1180 ns (0.85 M) | ~1166 ns (0.86 M) | + +Asterisked cells are interpolated from neighbouring rows. + +### Rotated (θ=π/6) + +`originalBox` keeps clamping/constraints on the unrotated rect; the +LP runs against four corner inequalities. `boundingBox` adds the +rotated quad's corners on top, a strict superset. + +| Mode | strat=orig (mean ns) | strat=bbox (mean ns) | +|------------------|---------------------:|---------------------:| +| freeform | 282 (3.5 M) | 355 (2.8 M) | +| scale | 255 (3.9 M) | 331 (3.0 M) | +| symmetric | 206 (4.9 M) | 320 (3.1 M) | +| symmetricScale | 201 (5.0 M) | 305 (3.3 M) | + +What this slice tells you: + +* The freeform axis-aligned path is the cheapest at **~70 ns/op** + without clamp + constraints, climbing to **~140 ns** with both + engaged. Constraints add work because the legacy axis-aligned path + uses per-axis branches that each evaluate min/max tests. +* `symmetricScale` at `θ=0` is the engine's slowest cell, around + **1100 ns/op**. The mode does an iterative aspect-locked clamp + walk; with both axes locked together and constraints in play, it + needs several passes to converge. **At `θ ≠ 0`, the rotated LP + path replaces that walk and the cost actually drops to ~200 to 330 + ns.** This is the single inversion in the matrix where rotation is + *cheaper* than the axis-aligned equivalent. +* Rotated `originalBox` typically runs **~3× slower** than `θ=0` + freeform; rotated `boundingBox` ≈ **4×**. The `bbox` overhead is + the extra rotated-corner constraint set in the LP. +* Tight constraints add ~50 to 80 ns at `θ=0`; they barely move the + rotated cells because the LP solver pins to bounds in the same loop + it would run anyway. + +## ROTATE + +`BoxTransformer.rotate(...)` computes an angle delta plus the slide +then freeze translation that keeps the rect inside the clamp. + +| Scenario | mean ns/op | ops/sec | +|-------------------|-----------:|--------:| +| `delta=subpix` | 151 | 6.6 M | +| `delta=smooth` | 155 | 6.4 M | +| `delta=saturated` | 156 | 6.4 M | +| `delta=jitter` | 177 | 5.6 M | + +What this slice tells you: + +* Rotation is dominated by the slide-then-freeze interval solver. The + delta pattern barely matters, which is why all four cells cluster + within ~25 ns of each other. +* The 6+ M ops/sec floor means rotation is free at any realistic + call rate. A 60 Hz drag fires a few hundred ticks per second; the + engine consumes microseconds in total. + +## RECLAMP + +A controller-style "the parent container shrinks while the box is +inside it", modeled as a zero-delta `move()` against a clamp that +shrinks every tick. + +| Scenario | mean ns/op | ops/sec | +|-----------------------------------------|-----------:|--------:| +| `pattern=shrink-loose, θ=0` | 56 | 17.8 M | +| `pattern=shrink-touch, θ=0` | 66 | 15.2 M | +| `pattern=shrink-cross, θ=0` | 66 | 15.2 M | +| `pattern=shrink-loose, θ=π/6` | 84 | 11.9 M | +| `pattern=shrink-cross, θ=π/6` | 97 | 10.3 M | +| `pattern=shrink-touch, θ=π/6` | 102 | 9.8 M | + +What this slice tells you: + +* Reclamping (clamp changes, no pointer motion) is among the cheapest + operations in the engine. Even the rotated cases stay under 110 ns. +* The collapse-to-midpoint sanitizer that handles the + infeasible-interval case (`shrink-cross`) adds only a handful of + nanoseconds over the simpler `shrink-loose` path. This matters in + practice because layout reflows often produce briefly infeasible + clamps and you don't want that to spike per-tick latency. + +## What all of this means in practice + +If you're building a typical resizable / draggable interaction (one or +a handful of `TransformableBox` widgets), **the engine is not your +bottleneck**, and the difference between strategies and modes is not +something a user can perceive. Pick whichever semantics fit your UX +and ignore the cost. + +The numbers become operationally relevant in three patterns: + +1. **Bulk operations.** If you iterate the engine over many objects + per frame (layout solvers, snap-to-grid logic, alignment guides + computed against every box), the constant factor matters. Prefer + `BindingStrategy.originalBox` if you don't need AABB containment; + skip `symmetricScale` at `θ=0` if any other mode fits your + gesture. +2. **Long replay sessions.** Tick-by-tick replay (test recorder + playback, undo stacks, animation rigs) can run thousands of engine + calls in quick succession. The 60 to 100 ns floor is a real budget + if you're driving an animation. +3. **Worst-case latency budgets.** The p95 and p99 cells in the full + harness output (rerun it locally and inspect any cell) are where + you find the cells that violate predictability. The mean is the + advertised number; the p99 is the user-perceived worst frame. + +For everything else, treat the engine as free. + +## Reproducing + +```bash +cd packages/box_transform +dart compile exe benchmark/comprehensive_bench.dart -o /tmp/bbench +/tmp/bbench +``` + +The benchmark prints a self-contained Markdown report with every +scenario's full percentile envelope. Pipe to a file and search by +axis (`grep mode=scale`, `grep θ=π/6 | grep bbox`, …) to inspect any +slice in detail. + +> Numbers will differ on other CPUs and OSes. The relative ordering +> between cells should hold. + +## Sources + +The harness design (warmup, batched measurement, sink to defeat dead +code elimination, percentile reporting) follows established +microbenchmarking practice: + +* [JMH (Java Microbenchmark Harness)](https://openjdk.org/projects/code-tools/jmh/): + warmup, batched measurement, `Blackhole` sink, and percentile reporting. +* [Aleksey Shipilëv, "Nanotrusting the Nanotime"](https://shipilev.net/blog/2014/nanotrusting-nanotime/): + clock-source overhead, why naive `start; op; stop` loops lie, and the + case for amortizing measurement cost via batching. +* [Oracle, "Avoiding Benchmarking Pitfalls on the JVM"](https://www.oracle.com/technical-resources/articles/java/architect-benchmarking.html): + the dead-code-elimination pitfall and the rationale for sinks / + blackholes. +* [Vyacheslav Egorov (mrale.ph), "Microbenchmarking Dart, Part 1"](https://mrale.ph/blog/2021/01/21/microbenchmarking-dart-part-1.html): + Dart-specific measurement loops, dead-code elimination, and AOT + versus JIT considerations. +* [`package:benchmark_harness` (Dart)](https://pub.dev/packages/benchmark_harness): + the warmup-then-measure idiom on the Dart side. +* [HdrHistogram](https://github.com/HdrHistogram/HdrHistogram) and Gil + Tene's coordinated-omission writeups: report p95/p99/max alongside + the mean rather than collapsing the distribution to a single number. diff --git a/docs/binding_strategies.mdx b/docs/binding_strategies.mdx new file mode 100644 index 0000000..01dffe6 --- /dev/null +++ b/docs/binding_strategies.mdx @@ -0,0 +1,102 @@ +--- +title: Binding Strategies +--- + +# Binding Strategies + +When `Box.rotation` is non-zero, "what does it mean for the box to be +inside the clamp?" has two answers, and Box Transform exposes that +choice as the `BindingStrategy` enum. The strategy applies to *both* +clamping and constraints (`minWidth`/`minHeight`/`maxWidth`/`maxHeight`). + +```dart + enum BindingStrategy { originalBox, boundingBox } +``` + +At rotation `0` both strategies are equivalent. The distinction only +matters under rotation. + +`BindingStrategy.boundingBox` is the default everywhere in the public +API (`BoxTransformer.move/resize/rotate`, `TransformableBox`, and +`TransformableBoxController`). Pass `BindingStrategy.originalBox` +explicitly when you want the logical-rect semantic. + +## BindingStrategy.originalBox + +Constraints and clamping apply to the box's **unrotated logical** +`width` and `height` and to its four unrotated (axis-aligned) corners. +The rotated corners may extend beyond the clamping rect if rotation +makes them do so. + +* **Use when:** you care about logical dimensions. *"My image is 100×100; + keep that logical 100×100 inside the clamp."* +* **Visible result:** the unrotated rect stays in the clamp; the rotated + rendering may poke out at the corners. + +## BindingStrategy.boundingBox + +Constraints and clamping apply to the **rotated rect's four rendered +corners** (and therefore to the rendered axis-aligned bounding box, +which is the smallest AABB enclosing those corners). The unrotated +stored rect (`rect.left`/`top`/`right`/`bottom`) is invisible storage +and is not additionally constrained. + +* **Use when:** you care about the visible footprint. *"The rendered box + must fit inside this 200×200 region no matter what angle."* +* **Visible result:** as you rotate toward `π/4`, the rect must shrink + (or the angle must cap, via slide-then-freeze) so the rendered AABB + stays contained. + +> Why doesn't `boundingBox` also constrain the unrotated stored rect? +> Because the stored rect is just storage for `(width, height)` plus a +> rotation; nothing about it is rendered. For stretched rotated rects +> (`W ≫ H` at θ near π/4), the unrotated rect can extend further on +> one axis than the rendered AABB does (`W·|cos θ| + H·|sin θ| < W` +> when `H < W·(1 − cos θ) / sin θ`). Constraining both would make the +> unrotated rect the binding constraint and steal slack the rendered +> footprint genuinely has, which contradicts the strategy's intent. + +## Comparison at a glance + +| Aspect | `originalBox` | `boundingBox` | +|---------------------------------|----------------------------------------|----------------------------------------------------------------| +| What stays in the clamp | Unrotated logical rect | Rotated rendered polygon (and therefore its AABB) | +| Effect of rotation on max size | None | Smaller available room as angle moves away from cardinal | +| Effect on `minWidth`/`minHeight`| Applies to unrotated dimensions | Applies to unrotated dimensions; AABB may force a smaller cap | +| Use case | "Keep my logical image on-screen" | "Keep my rendered footprint on-screen" | + +## Choosing per use case + +* **Image cropper / canvas:** `boundingBox` is usually right; users + expect the visible box not to leak past the canvas. +* **Logical layout container:** `originalBox` keeps dimensions + predictable for downstream layout, even if rendered corners poke out. + +## Switching strategies at runtime + +When you change a controller's `bindingStrategy` (Flutter side), the +controller **reconciles** the current rect against the new strategy. If +the current rect is feasible under the new strategy, nothing happens. +If it isn't (e.g. switching from `originalBox` to a tighter +`boundingBox`), the controller translates the rect into clamp slack so +the new constraint is satisfied. There's no resize on switch, only a +translation. + +## What gets enforced internally + +| Builder (LP inequality set) | `originalBox` enforces | `boundingBox` enforces | +|-------------------------------|------------------------------|------------------------------| +| Corner-anchored | Unrotated rect's 4 corners | Rotated rect's 4 corners | +| Side-anchored | Unrotated rect's 4 corners | Rotated rect's 4 corners | +| Center-anchored (symmetric) | Unrotated rect's 4 corners | Rotated rect's 4 corners | + +The two strategies enforce **different** corner sets at `θ ≠ 0`. They +are not subsets of each other: for a stretched rotated rect (e.g. +W=100, H=500 at θ=π/4), the unrotated rect's half-extent on the long +axis is `H/2 = 250`, while the rotated AABB's half-extent on that axis +is `(W·|cos θ| + H·|sin θ|) / 2 ≈ 212`. On that axis `boundingBox` is +the looser constraint and `originalBox` is the tighter one. On the +short axis the relationship reverses: the unrotated half-extent is +`W/2 = 50`, the rotated AABB's is the same `≈ 212`, so `boundingBox` +is the tighter constraint and `originalBox` is the looser one. At +`θ = 0` both collapse to the same axis-aligned constraints. diff --git a/docs/flutter_controller.mdx b/docs/flutter_controller.mdx index 12f3df5..84691a5 100644 --- a/docs/flutter_controller.mdx +++ b/docs/flutter_controller.mdx @@ -82,6 +82,38 @@ You can limit the movements of the controller using the `setClampingBox` method. controller.setClampingRect(Rect.fromLTWH(0, 0, 1000, 1000)); ``` +### Rotation + +The controller carries a `rotation` field (radians around the box's center) plus an `initialRotation` snapshot +captured at gesture start, and a `bindingStrategy` (`originalBox` vs `boundingBox`). It exposes the rotation gesture +lifecycle directly: + +```dart title="Rotation lifecycle on the controller" +controller.onRotateStart(localPosition); +final result = controller.onRotateUpdate(localPosition, handle); +controller.onRotateEnd(); +``` + +`onRotateUpdate` returns a `UIRotateResult` with a `feasible` field. When `feasible == false`, the engine could +not honor the requested angle — the controller **does not** advance its internal `rotation` or `rect`, and overrides +`result.rect`/`result.rotation` to its last feasible state. Consumers that bind their visible state via callbacks +(`box.rotation = result.rotation`) stay clamp-pinned at the last feasible angle rather than snapping back to +gesture-start. + +```dart title="Programmatic rotation control" +// Set rotation directly (radians, around the box's center). +controller.setRotation(0.4); + +// Toggle the binding strategy at runtime — the controller reconciles the +// current rect against the new strategy by translating into clamp slack +// if needed. +controller.setBindingStrategy(BindingStrategy.boundingBox); +``` + +`onResizeUpdate` follows the same `feasible`-aware semantic: when the rotated freeform/symmetric resize signals it +cannot satisfy clamp + constraints, the controller holds the last feasible state and overrides the result's +`rect`/`flip` accordingly. See the [Rotating](/flutter_rotating) page for the full story. + ### Other things you can do with a controller The `TransformableBoxController` almost mirrors the constructor parameters of the `TransformableBox`. You can do things @@ -102,4 +134,10 @@ controller.setClampingRect(clampingRect); // Disable flipping the rect while resizing controller.setAllowFlippingWhileResizing(false); + +// Change rotation programmatically +controller.setRotation(0.4); + +// Change binding strategy +controller.setBindingStrategy(BindingStrategy.boundingBox); ``` diff --git a/docs/flutter_moving.mdx b/docs/flutter_moving.mdx index 8d6f6db..e76bc9a 100644 --- a/docs/flutter_moving.mdx +++ b/docs/flutter_moving.mdx @@ -53,3 +53,12 @@ Setting `TransformableBox.movable` to `false` will completely disable moving/dra ... ); ``` + +## Moving a rotated box + +When `TransformableBox.rotation` is non-zero, the move solver computes the joint feasible translation interval that +keeps the rect inside the clamp under the active `bindingStrategy` (rotated AABB and unrotated rect for +`boundingBox`; just the unrotated rect for `originalBox`). If the requested delta would push the box past the clamp, +it slides as far as the clamp allows and stops — the gesture never produces a leaky position. + +See the [Rotating](/flutter_rotating) and [Binding Strategies](/binding_strategies) pages for the full picture. diff --git a/docs/flutter_resizing.mdx b/docs/flutter_resizing.mdx index d84d526..32719f0 100644 --- a/docs/flutter_resizing.mdx +++ b/docs/flutter_resizing.mdx @@ -45,7 +45,7 @@ The `resizeModeResolver` is a callback that is called whenever a resize operatio given [Transformable Box][transformableBox]. This allows you to define the resize behavior at the time of the resize operation. This is useful when you want to change the resize behavior based on the state of the application. -The most common use case for this is **keyboard shortcuts**. A `defaultResizeModeResolver` function is used by +The most common use case for this is **keyboard shortcuts**. A `TransformableBoxController.defaultResizeModeResolver` static method is used by default when resizing, and it's job is to listen to keyboard meta keys to change the `ResizeMode`. ### Default Resize Mode: @@ -112,6 +112,15 @@ This behavior can be disabled by setting `allowContentFlipping` to `false`. You can set both `flipRectWhileResizing` and `allowContentFlipping` to `false` to disable flipping completely. The result will be a box that stops shrinking once it reaches a size of zero, as if it hit a wall. +### Force-flip on a rotated box + +When the box is rotated and the user drags a corner across its anchor with `flipRectWhileResizing: true`, the engine +attempts a flip. If the flipped state would not satisfy the clamp + constraints, the engine falls back to the natural +(un-flipped) direction and clamp-pins the rect at the natural wall instead of leaking the clamp or freezing. The +gesture continues to track the cursor — only when no direction can produce a valid rect does the engine signal the +gesture as infeasible (the controller then holds the last feasible state). See the [Rotating](/flutter_rotating) page +for the full rotation story. + ## Constraints Resizing can be constrained by providing any desirable combination of `minHeight`, `minWidth`, `maxHeight` and diff --git a/docs/flutter_rotating.mdx b/docs/flutter_rotating.mdx new file mode 100644 index 0000000..e43ec26 --- /dev/null +++ b/docs/flutter_rotating.mdx @@ -0,0 +1,166 @@ +--- +title: Rotating +--- + +# Rotating + +`TransformableBox` supports rotation around the box's center. The same +clamp + constraints + flip semantics that work on an unrotated box continue +to work after rotation: drag, resize (freeform / scale / symmetric / +symmetricScale), side handles, and force-flip all stay rotation-aware. + +```dart title="A rotated TransformableBox" + Stack( + children: [ + TransformableBox( + rect: rect, + rotation: rotation, // radians, around the box's center + rotatable: true, // enable per-corner rotation gestures + bindingStrategy: BindingStrategy.boundingBox, + onChanged: (event, _) { + setState(() => rect = event.rect); + }, + onRotationUpdate: (event, _) { + setState(() => rotation = event.rotation); + }, + contentBuilder: (context, rect, flip) => Image.asset( + 'assets/images/landscape.jpg', + width: rect.width, + height: rect.height, + fit: BoxFit.fill, + ), + ), + ], + ); +``` + +## Enabling rotation gestures + +When `rotatable: true`, an outer ring around each corner-handle captures +rotation. Resize still happens inside the inner area; rotation kicks in +when the pointer is on the outside ring. Size that ring with +`rotationHandleGestureSize` (default `64` pixels). + +```dart title="Tuning the rotation gesture ring" + TransformableBox( + rect: rect, + rotation: rotation, + rotatable: true, + rotationHandleGestureSize: 96, // bigger capture area + onRotationUpdate: (event, _) => setState(() => rotation = event.rotation), + contentBuilder: (context, rect, flip) {...}, + ); +``` + +Listen to the full rotation lifecycle if you need more granular control: + +```dart title="Rotation callbacks" + TransformableBox( + rect: rect, + rotation: rotation, + rotatable: true, + onRotationStart: (handle, event) {...}, + onRotationUpdate: (event, _) { + setState(() => rotation = event.rotation); + }, + onRotationEnd: (handle, event) {...}, + onRotationCancel: (handle, event) {...}, + contentBuilder: (context, rect, flip) {...}, + ); +``` + +## Binding strategies + +A rotated box has two competing notions of "size": + +* The **unrotated logical rect**: the `Rect` you passed in. +* The **rendered axis-aligned bounding box**: the on-screen footprint + the rotated rect actually occupies. This is always larger than the + unrotated rect when `rotation` is non-cardinal. + +`bindingStrategy` chooses which one the clamp + constraints apply to: + +* `BindingStrategy.originalBox`: clamp + constraints apply to the + unrotated logical rect. Rotated corners may extend outside the clamp. + Pick this when you care about logical dimensions + (*"my image is 100×100; keep the logical image inside the clamp"*). +* `BindingStrategy.boundingBox`: clamp + constraints apply to the + rendered axis-aligned bounding box (and to the unrotated rect, so a + rotation that shrinks the AABB can't leak the unrotated bounds past + the clamp either). Pick this when you care about the visible + footprint (*"the rendered box must stay 100px wide regardless of + angle"*). + +At rotation `0` both strategies are equivalent. + +See the [Binding Strategies](/binding_strategies) page for a deeper +treatment. + +## Slide-then-freeze + +When the requested angle would push the rect outside the clamp, the +engine first tries to **slide** the rect into available clamp slack to +honor the angle. If a translation can rescue the rotation, the box +slides to a feasible position and the gesture continues. If no +translation works, the rotation **freezes** at the last feasible angle. +The rect doesn't snap back to gesture-start, it holds steady at the cap. +Reversing the rotation gesture unfreezes naturally. + +```dart title="Detecting a frozen rotation" + TransformableBox( + rect: rect, + rotation: rotation, + rotatable: true, + onRotationUpdate: (event, _) { + // event.feasible == false means the engine could not honor the + // requested angle; the controller is holding the last feasible + // rotation. The visible box is already correct, you usually + // don't have to do anything special, but this hook lets you e.g. + // change the cursor or flash a UI hint. + if (!event.feasible) { + // optional UI feedback + } + setState(() => rotation = event.rotation); + }, + contentBuilder: (context, rect, flip) {...}, + ); +``` + +## Force-flip on a rotated rect + +Resizing a rotated box across its anchor (force-flip) follows the same +rules as the unrotated case, with one extra step: when the flipped state +would not fit clamp + constraints, the engine **falls back** to the +natural (un-flipped) direction so the rect can still track the cursor by +clamp-pinning at the natural wall. Only when no direction fits does the +engine signal `feasible: false` and freeze. + +This means a rotated resize gesture never snaps back to gesture-start +mid-drag: it either flips, clamp-pins naturally, or holds at the last +feasible state. + +## When you also use a controller + +If you pass a `TransformableBoxController`, move `rotation`, +`rotatable`, and `bindingStrategy` to the controller's constructor and +drop them from the `TransformableBox`. The controller gains +`rotation`, `initialRotation`, `bindingStrategy`, and rotation lifecycle +methods (`onRotateStart`, `onRotateUpdate`, `onRotateEnd`, +`onRotateCancel`). The controller also wraps engine results so +`result.rect` and `result.rotation` reflect the last feasible state on +infeasible ticks: your `onChanged` / `onRotationUpdate` callbacks see +the held rect, not the engine's gesture-start sentinel. + +```dart title="Rotation through a controller" + late final TransformableBoxController controller; + + @override + void initState() { + super.initState(); + controller = TransformableBoxController( + rect: rect, + rotation: 0.0, + bindingStrategy: BindingStrategy.boundingBox, + ); + } +``` diff --git a/docs/get_started.mdx b/docs/get_started.mdx index 3e87c21..5c385ce 100644 --- a/docs/get_started.mdx +++ b/docs/get_started.mdx @@ -62,4 +62,24 @@ You can use `BoxTransformer.move` to move a box. result.position; // the new position of the box ``` +You can use `BoxTransformer.rotate` to rotate a box around its center. + +```dart title="Rotating a box" + final Box rect = Box.fromLTWH(0, 0, 100, 100); + final RotateResult result = BoxTransformer.rotate( + initialRect: rect, + initialLocalPosition: Vector2(100, 50), // pointer at gesture start + localPosition: Vector2(50, 100), // pointer now + initialRotation: 0.0, + ); + + result.rotation; // new rotation, in radians + result.rect; // rect, possibly translated to fit clamp at this angle + result.feasible; // false when the angle could not be honored +``` + +`Box.rotation`, `BoxTransformer.rotate`, and the `BindingStrategy` enum (which controls whether clamp + constraints +apply to the unrotated logical rect or to the rendered axis-aligned bounding box) are documented in detail on the +[Rotating](/rotating) and [Binding Strategies](/binding_strategies) pages. + [boxTransform]: https://github.com/hyper-designed/box_transform \ No newline at end of file diff --git a/docs/index.mdx b/docs/index.mdx index 758cb4d..c6b174f 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -22,11 +22,12 @@ project. * 🎨 **Flexible Resizing Modes:** Choose from four different resizing modes for more flexibility in how boxes are resized. * 📍 **Customizable Anchor Points:** Define resizing corner-handles to anchor different parts of the box when resizing. * 🎨 **Customizable Handles:** Use default resizing handles or define your own custom handles. +* 🌀 **Rotation Support:** Rotate boxes around their center. Move, resize, scale, symmetric, side-handle, and force-flip stay rotation-aware. Pick `BindingStrategy.originalBox` (logical rect inside the clamp) or `BindingStrategy.boundingBox` (rendered AABB inside the clamp). Rotation gestures slide-then-freeze; force-flip falls back to the natural direction when the flipped state can't fit, so the rect never snaps back to gesture-start mid-drag. * 🚀 **Easy Integration:** Integrate Box Transform into your Dart or Flutter project with ease. | Moving/Dragging | Resizing | Rotation | |-----------------|----------|----------| -| ✅ | ✅ | 🚧 | +| ✅ | ✅ | ✅ | ## Getting Started diff --git a/docs/moving.mdx b/docs/moving.mdx index 9475342..2847852 100644 --- a/docs/moving.mdx +++ b/docs/moving.mdx @@ -35,3 +35,22 @@ area. result.position; // the new position of the box ``` + +## Moving a rotated box + +`BoxTransformer.move` accepts `rotation` (radians) and `bindingStrategy` parameters. The solver computes the joint +feasible translation interval that keeps the rect inside the clamp under the active strategy and slides into it; if +the requested delta would push the box past the clamp, the result is clamped to the wall. + +```dart title="Moving a rotated box" + final MoveResult result = BoxTransformer.move( + initialRect: Box.fromLTWH(50, 50, 100, 100, rotation: 0.4), + initialLocalPosition: startCursor, + localPosition: currentCursor, + rotation: 0.4, + bindingStrategy: BindingStrategy.boundingBox, + clampingRect: clamp, + ); +``` + +See [Rotating](/rotating) and [Binding Strategies](/binding_strategies) for the rotation-specific semantics. diff --git a/docs/resizing.mdx b/docs/resizing.mdx index 053039b..a232bcb 100644 --- a/docs/resizing.mdx +++ b/docs/resizing.mdx @@ -84,6 +84,35 @@ This will disallow the box from growing or shrinking beyond 100x100 and 500x500 `ResizeResult.minWidthReached`, `ResizeResult.minHeightReached`, `ResizeResult.maxWidthReached` and `ResizeResult.maxHeightReached` are `bool` values that indicate whether the resizing was constrained or not. +## Resizing a rotated box + +`BoxTransformer.resize` accepts `rotation` (radians around the box's center) and `bindingStrategy` parameters and +dispatches to dedicated rotated paths internally. Freeform, scale, symmetric, symmetricScale, side handles, and +force-flip all stay correct under arbitrary rotation: + +```dart title="Resizing under rotation" + final ResizeResult result = BoxTransformer.resize( + handle: HandlePosition.bottomRight, + initialRect: Box.fromLTWH(50, 50, 100, 100, rotation: 0.4), + initialLocalPosition: gestureStartCursor, + localPosition: currentCursor, + resizeMode: ResizeMode.freeform, + initialFlip: Flip.none, + rotation: 0.4, + bindingStrategy: BindingStrategy.boundingBox, + clampingRect: clamp, + constraints: constraints, + allowFlipping: true, + ); +``` + +`ResizeResult.feasible` is `false` when the engine could not satisfy the requested target — the caller should hold +its last feasible state instead of writing the returned rect (which is a gesture-start sentinel). Force-flip into +an infeasible state automatically falls back to the natural (un-flipped) direction so the rect tracks the cursor by +clamp-pinning at the natural wall instead of leaking. + +See [Rotating](/rotating) and [Binding Strategies](/binding_strategies) for the rotation-specific semantics. + [boxTransform]: https://github.com/hyper-designed/box_transform/tree/main/packages/box_transform [flutterBoxTransform]: https://github.com/hyper-designed/box_transform/tree/main/packages/flutter_box_transform [flutter]: https://flutter.dev diff --git a/docs/rotating.mdx b/docs/rotating.mdx new file mode 100644 index 0000000..205a07c --- /dev/null +++ b/docs/rotating.mdx @@ -0,0 +1,136 @@ +--- +title: Rotating +--- + +# Rotating + +`Box` carries a `rotation` field (radians, around the box's center). Every +existing `BoxTransformer` operation is rotation-aware: `move`, `resize`, +and the new `rotate` entry point all honor `Box.rotation` and either of +the two binding strategies. + +## Storing rotation on a Box + +```dart title="A rotated Box" + final Box rect = Box.fromLTWH(50, 50, 100, 100, rotation: 0.4); +``` + +The unrotated `left`/`top`/`right`/`bottom` are the box's logical bounds +in the rect's own coordinate frame; rotation is applied around the box's +center when the rect is rendered or clamped. + +## BoxTransformer.rotate + +Use `BoxTransformer.rotate(...)` to compute a rotation result from a +gesture: + +```dart title="Rotating a box" + final Box rect = Box.fromLTWH(0, 0, 100, 100); + final RotateResult result = BoxTransformer.rotate( + initialRect: rect, + initialLocalPosition: Vector2(100, 50), // pointer at gesture start + localPosition: Vector2(50, 100), // pointer now + initialRotation: 0.0, + clampingRect: Box.fromLTWH(0, 0, 200, 200), + bindingStrategy: BindingStrategy.boundingBox, + ); + + result.rotation; // new rotation (radians) + result.rect; // rect, possibly translated to honor the angle + result.feasible; // false when the rotation could not be honored +``` + +The angle delta is the signed angle between the vectors from the box's +center to `initialLocalPosition` and `localPosition`. The result is +applied **additively** on top of `initialRotation`. + +## Slide-then-freeze + +When the requested angle would push the box outside the clamp, the +engine first tries to **translate** the rect into available clamp slack +so the angle can be honored. If translating fits, `result.rect` reflects +the slid position and `result.feasible == true`. If no translation +works, the engine **freezes**: it returns the gesture-start rect and +rotation, with `result.feasible == false` so the caller can hold the +last feasible state instead of writing this one. + +```dart title="Holding last-feasible state on freeze" + Box rect = startRect; + double rotation = startRotation; + + // ...inside a gesture loop: + final result = BoxTransformer.rotate( + initialRect: startRect, + initialLocalPosition: startCursor, + localPosition: currentCursor, + initialRotation: startRotation, + clampingRect: clamp, + bindingStrategy: BindingStrategy.boundingBox, + ); + if (result.feasible) { + rect = result.rect; + rotation = result.rotation; + } + // If !feasible, rect/rotation keep their last feasible values, the + // gesture just doesn't advance this tick. Reversing the cursor will + // unfreeze naturally because the engine recomputes from the gesture + // start every tick. +``` + +## Resizing a rotated box + +`BoxTransformer.resize(...)` accepts `rotation` and `bindingStrategy` +parameters and dispatches to dedicated rotated paths internally: +freeform, scale, symmetric, symmetricScale, side-handle, and force-flip +all stay correct under arbitrary rotation: + +```dart title="Resizing under rotation" + final ResizeResult result = BoxTransformer.resize( + handle: HandlePosition.bottomRight, + initialRect: Box.fromLTWH(50, 50, 100, 100, rotation: 0.4), + initialLocalPosition: gestureStartCursor, + localPosition: currentCursor, + resizeMode: ResizeMode.freeform, + initialFlip: Flip.none, + rotation: 0.4, + bindingStrategy: BindingStrategy.boundingBox, + clampingRect: clamp, + constraints: constraints, + allowFlipping: true, + ); +``` + +`ResizeResult.feasible` mirrors `RotateResult.feasible`: it's `false` +when the engine could not satisfy the requested target, indicating the +caller should hold the last feasible state. + +### Force-flip fallback + +When the cursor crosses the rect's anchor (force-flip) but the flipped +state can't fit clamp + constraints, the engine retries the LP with +natural-direction signs. The rect tracks the cursor by clamp-pinning at +the natural wall instead of leaking the clamp or freezing. Only when +neither force-flip *nor* natural direction works does the engine signal +`feasible: false` (a degenerate state in practice). + +## Moving a rotated box + +`BoxTransformer.move(...)` honors rotation transparently: + +```dart title="Moving a rotated box" + final MoveResult result = BoxTransformer.move( + initialRect: Box.fromLTWH(50, 50, 100, 100, rotation: 0.4), + initialLocalPosition: startCursor, + localPosition: currentCursor, + rotation: 0.4, + bindingStrategy: BindingStrategy.boundingBox, + clampingRect: clamp, + ); +``` + +Under `boundingBox`, the rotated AABB *and* the unrotated rect are both +kept inside the clamp; the `move()` solver computes the joint feasible +translation interval and slides into it. + +See [Binding Strategies](/binding_strategies) for how the strategy +choice shapes both move and resize semantics. diff --git a/melos.yaml b/melos.yaml deleted file mode 100644 index 98a71a8..0000000 --- a/melos.yaml +++ /dev/null @@ -1,74 +0,0 @@ -name: box_transform - -packages: - - packages/* - - packages/** - -command: - bootstrap: - # It seems so that running "pub get" in parallel has some issues (like - # https://github.com/dart-lang/pub/issues/3404). Disabling this feature - # makes the CI much more stable. - runPubGetInParallel: false - -ide: - intellij: true - -scripts: - deps: - run: | - melos exec dart pub get - description: Run `pub get` for all packages. - format: - run: melos exec dart format . --fix - description: Run `dart format` for all packages. - check-format: - run: melos exec dart format --output none --set-exit-if-changed . - description: Check formatting of Dart code. - format:check: - description: Check formatting of Dart code. - run: dart format --output none --set-exit-if-changed . - analyze: - description: Analyze Dart code. - run: dart analyze . --fatal-infos --fatal-warnings - clean: - run: melos exec flutter clean && melos exec dart pub get - dry-run: - run: melos exec flutter pub publish --dry-run - build-example: - run: cd packages/flutter_box_transform/example && flutter build web - build-playground: - run: cd packages/flutter_box_transform/playground && flutter build web - test: - run: | - cd packages/box_transform - dart test . - env: - MELOS_TEST: true - archive-example: - run: | - cd packages/flutter_box_transform/example - flutter build web - tar --directory build/web -cvf ../../../artifact.tar . - archive-playground: - run: | - cd packages/flutter_box_transform/playground - flutter build web - tar --directory build/web -cvf ../../../artifact.tar . - lcov: - run: | - cd packages/box_transform - dart pub run coverage:test_with_coverage - dart pub run coverage:format_coverage --packages=.dart_tool/package_config.json --lcov -i coverage/coverage.json -o coverage/lcov.info - env: - MELOS_TEST: true - cov: - run: | - cd packages/box_transform - dart pub global run coverage:test_with_coverage - dart pub global run coverage:format_coverage --packages=.dart_tool/package_config.json --lcov -i coverage/coverage.json -o coverage/lcov.info - genhtml coverage/lcov.info -o coverage/html - env: - MELOS_TEST: true - open_cov: - run: open packages/box_transform/coverage/html/index.html \ No newline at end of file diff --git a/packages/box_transform/.gitignore b/packages/box_transform/.gitignore index 0e342c7..d24ca33 100644 --- a/packages/box_transform/.gitignore +++ b/packages/box_transform/.gitignore @@ -6,6 +6,9 @@ # https://dart.dev/guides/libraries/private-files#pubspeclock. pubspec.lock +# Generated when this package runs under a Flutter workspace via `flutter test`. +build/ + **/**.iml .idea/ diff --git a/packages/box_transform/CHANGELOG.md b/packages/box_transform/CHANGELOG.md index 5161f35..3c7af83 100644 --- a/packages/box_transform/CHANGELOG.md +++ b/packages/box_transform/CHANGELOG.md @@ -1,3 +1,43 @@ +## Unreleased + +- Add first-class rotation support around a box's center via `Box.rotation` and a new `BoxTransformer.rotate(...)` entry point. +- Add `BindingStrategy` enum: `originalBox` keeps the unrotated logical rect inside the clamp; `boundingBox` keeps the rendered rotated polygon (and therefore its axis-aligned bounding box) fully inside. +- **[BREAKING]** Default `bindingStrategy` flipped from `originalBox` to `boundingBox` on `BoxTransformer.move/resize/rotate`. Pass `BindingStrategy.originalBox` explicitly to restore the previous behavior. +- **[BREAKING]** Removed the list-based LP inspection API: `LinearInequality`, `ProjectionResult`, `buildCornerInequalities`, `buildCenterInequalities`, `projectOntoFeasibleRegion`. These were thin allocating wrappers around the flat-buffer hot path with no `lib/` consumers. Use `IneqBuffer` + `RotatedClampingSolver.buildCornerIneqsInto` / `RotatedClampingSolver.buildSideHandleIneqsInto` / `RotatedClampingSolver.buildCenterIneqsInto` and `FlatProjection` + `RotatedClampingSolver.projectOntoFeasibleRegionFlat` directly. Test inspection helpers live in the package's `test/lp_inspect.dart`. +- **[BREAKING]** Relocated the rotated-clamping LP entry points into the `RotatedClampingSolver` static surface. The four functions are now class-scoped statics; existing callsites must be qualified: + - `buildCornerIneqsInto(...)` → `RotatedClampingSolver.buildCornerIneqsInto(...)` + - `buildSideHandleIneqsInto(...)` → `RotatedClampingSolver.buildSideHandleIneqsInto(...)` + - `buildCenterIneqsInto(...)` → `RotatedClampingSolver.buildCenterIneqsInto(...)` + - `projectOntoFeasibleRegionFlat(...)` → `RotatedClampingSolver.projectOntoFeasibleRegionFlat(...)` + The scratch types `IneqBuffer` and `FlatProjection` are unchanged. +- `BoxTransformer.move()`, `.resize()`, `.rotate()` are all rotation-aware. Freeform, scale, symmetric, symmetricScale, side-handle, and force-flip resize paths each honor rotation under both binding strategies. +- Rotation gestures slide-then-freeze: when the requested angle would push the rect outside the clamp, the engine first tries to translate into available slack; if no translation rescues it, the rotation caps at the last feasible angle. +- Force-flip on a rotated rect falls back to natural direction when the flipped state can't fit clamp + constraints. The rect tracks the cursor by clamp-pinning at the natural wall instead of leaking the clamp or freezing. +- Add `feasible` field to `RotateResult` and `ResizeResult`. `false` means the engine could not honor the requested target without leaking; consumers use it to hold the last feasible state instead of snapping back to gesture-start. +- Solver: dedicated rotated-clamping LP with corner-, side-, and center-anchored inequality builders, an L2 projector with 1D fallback at saturation, and a unified violator-priority loop. `FlatProjection` now exposes `feasible` + `worstResidual` so callers can detect cap-exit residuals instead of consuming a leaky best-effort projection. +- Tests: extensive rotated-resize, rotated-move, rotated-flip, side-handle, scale, symmetric, and symmetricScale coverage. New `clamp_invariants_test.dart` asserts engine invariants (clamp containment, side-handle scope, constraint compliance) on recorded playground scenarios. +- **[BREAKING]** All clamp/handle/flip/line-geometry helpers in `lib/src/helpers.dart` are now `static` members of `abstract final class ClampHelpers` instead of top-level functions. Migration is mechanical: prepend `ClampHelpers.` to each call. Renames: + - `flipRect(...)` → `ClampHelpers.flipRect(...)` + - `getFlipForRect(...)` → `ClampHelpers.getFlipForRect(...)` + - `scaledSymmetricClampingRect(...)` → `ClampHelpers.scaledSymmetricClampingRect(...)` + - `getClosestEdge(...)` → `ClampHelpers.getClosestEdge(...)` + - `getClampingRectForSideHandle(...)` → `ClampHelpers.getClampingRectForSideHandle(...)` + - `intersectionBetweenTwoLines(...)` → `ClampHelpers.intersectionBetweenTwoLines(...)` + - `extendLineToRect(...)` → `ClampHelpers.extendLineToRect(...)` + - `extendLinePointsToRectPoints(...)` → `ClampHelpers.extendLinePointsToRectPoints(...)` + - `intersectionBetweenRects(...)` → `ClampHelpers.intersectionBetweenRects(...)` + - `findLineIntersection(...)` → `ClampHelpers.findLineIntersection(...)` + - `getAvailableAreaForHandle(...)` → `ClampHelpers.getAvailableAreaForHandle(...)` + - `getClampingRectForHandle(...)` → `ClampHelpers.getClampingRectForHandle(...)` + - `getClampingRectForCornerHandle(...)` → `ClampHelpers.getClampingRectForCornerHandle(...)` + - `constrainAvailableAreaForScaling(...)` → `ClampHelpers.constrainAvailableAreaForScaling(...)` + - `getMinRectForScaling(...)` → `ClampHelpers.getMinRectForScaling(...)` + - `isValidRect(...)` → `ClampHelpers.isValidRect(...)` + - `calculateBoundingRect(...)` → `ClampHelpers.calculateBoundingRect(...)` + - `rotatePointAround(...)` → `ClampHelpers.rotatePointAround(...)` + - `worldToUnrotated(...)` → `ClampHelpers.worldToUnrotated(...)` + - `unrotatedToWorld(...)` → `ClampHelpers.unrotatedToWorld(...)` + ## 0.4.4 - Update root pubspec.yaml name from box_transform to melos_box_transform. diff --git a/packages/box_transform/README.md b/packages/box_transform/README.md index 99aaa96..a349714 100644 --- a/packages/box_transform/README.md +++ b/packages/box_transform/README.md @@ -2,7 +2,7 @@ # Box Transform -[![melos](https://img.shields.io/badge/maintained%20with-melos-f700ff.svg?style=flat-square)](https://github.com/invertase/melos) [![Build](https://github.com/hyper-designed/box_transform/actions/workflows/build.yml/badge.svg)](https://github.com/hyper-designed/box_transform/actions/workflows/build.yml) [![Tests](https://github.com/hyper-designed/box_transform/workflows/Tests/badge.svg?branch=main)](https://github.com/hyper-designed/box_transform/actions) [![Pub Version](https://img.shields.io/pub/v/box_transform?label=Pub)](https://pub.dev/packages/box_transform) +[![Build](https://github.com/hyper-designed/box_transform/actions/workflows/build.yml/badge.svg)](https://github.com/hyper-designed/box_transform/actions/workflows/build.yml) [![Tests](https://github.com/hyper-designed/box_transform/workflows/Tests/badge.svg?branch=main)](https://github.com/hyper-designed/box_transform/actions) [![Pub Version](https://img.shields.io/pub/v/box_transform?label=Pub)](https://pub.dev/packages/box_transform) [Box Transform](https://github.com/hyper-designed/box_transform) is a pure-Dart base package that allows you to programmatically handle box resizing and dragging without relying on Flutter. It provides highly flexible, @@ -17,6 +17,13 @@ programmatically resizable and draggable boxes that can be used in any Dart proj * 🎨 **Flexible Resizing Modes:** Choose from four different resizing modes for more flexibility in how boxes are resized. * 📍 **Customizable Anchor Points:** Define resizing corner-handles to anchor different parts of the box when resizing. +* 🌀 **Rotation Support:** First-class rotation around the box's center via `Box.rotation` plus a dedicated + `BoxTransformer.rotate(...)` entry point. Move, resize, scale, symmetric, side-handle, and force-flip all work + under arbitrary rotation. `BindingStrategy.originalBox` keeps the unrotated logical rect inside the clamp, while + `BindingStrategy.boundingBox` keeps the rendered AABB (rotated corners + unrotated rect) fully contained. Rotation + gestures use slide-then-freeze: the box slides into available clamp slack to honor the requested angle, capping at + the last feasible angle when no translation rescues it. `RotateResult.feasible` and `ResizeResult.feasible` expose + this signal to callers (`false` ⇒ engine could not honor the gesture; the controller holds the last feasible state). * 🚀 **Easy Integration:** Integrate Box Transform into your Dart or Flutter project with ease. ## Getting started diff --git a/packages/box_transform/benchmark/comprehensive_bench.dart b/packages/box_transform/benchmark/comprehensive_bench.dart new file mode 100644 index 0000000..940949d --- /dev/null +++ b/packages/box_transform/benchmark/comprehensive_bench.dart @@ -0,0 +1,532 @@ +// Comprehensive combinatorial benchmark suite for box_transform. +// +// Methodology: +// +// * Each scenario runs `kBatches` batches of `kBatchSize` calls. Per-call +// wall time is computed as batch_us * 1000 / batchSize, then we report +// percentiles across batches (min/p50/mean/p95/p99/max). Batching amortizes +// Stopwatch overhead (~30-100 ns/start-stop) below 1% of measurement. See +// Shipilëv, "Nanotrusting the Nanotime", https://shipilev.net/blog/2014/nanotrusting-nanotime/ +// for the canonical writeup of why naive start;op;stop loops lie. +// +// * `kWarmup` calls precede measurement to reach steady state. Matters even +// in AOT for cold-cache effects, and in JIT for tier-up. The warmup-then- +// measure pattern is JMH's idiom and is the same pattern Dart's official +// `package:benchmark_harness` uses (https://pub.dev/packages/benchmark_harness). +// +// * A global `sink` accumulates result hashes so AOT cannot elide the work. +// Direct adaptation of JMH's `Blackhole` sink; see Oracle, "Avoiding +// Benchmarking Pitfalls on the JVM", https://www.oracle.com/technical-resources/articles/java/architect-benchmarking.html +// for the dead-code-elimination problem this solves. Dart-side guidance +// from Egorov, "Microbenchmarking Dart, Part 1", +// https://mrale.ph/blog/2021/01/21/microbenchmarking-dart-part-1.html. +// +// * We report p95/p99/max alongside the mean so the tail is visible (GC +// pauses, OS context switches, slow-path branches). Reporting only the +// mean hides those; see Gil Tene's coordinated-omission writeups and +// HdrHistogram (https://github.com/HdrHistogram/HdrHistogram). +// +// * Scenarios are generated from axis grids, not hand-rolled. Guarantees +// coverage and makes the matrix self-documenting. +// +// Run with AOT for production-equivalent numbers: +// dart compile exe benchmark/comprehensive_bench.dart -o /tmp/bbench +// /tmp/bbench + +import 'dart:io'; +import 'dart:math' as math; + +import 'package:box_transform/box_transform.dart'; +import 'package:vector_math/vector_math_64.dart'; + +// Warmup count and batch dimensions adapted from JMH's warmup-then-measure +// shape, collapsed into a single process (Dart's AOT does not need JMH's +// fork isolation). See https://openjdk.org/projects/code-tools/jmh/. +const int kWarmup = 4000; +const int kBatches = 25; +const int kBatchSize = 4000; + +/// Sink to defeat dead-code elimination. Each benchmark XORs result bits +/// into this. Print at end so AOT must keep the work. +/// +/// Same pattern as JMH's `Blackhole.consume(...)`. Without it, the AOT +/// compiler is free to delete the measured loop because no observable +/// state depends on the result. See Oracle's "Avoiding Benchmarking +/// Pitfalls on the JVM" and Egorov's "Microbenchmarking Dart, Part 1" +/// (linked at the top of this file) for the underlying rationale. +int sink = 0; + +void main() { + _printHeader(); + + final results = []; + results.addAll(_moveScenarios()); + results.addAll(_resizeScenarios()); + results.addAll(_rotateScenarios()); + results.addAll(_reclampScenarios()); + + _printResults(results); + + // Force the compiler to keep all work. + stderr.writeln('# sink=${sink.toRadixString(16)}'); +} + +// --------------------------------------------------------------------------- +// Stats / harness +// --------------------------------------------------------------------------- + +/// Per-scenario statistics. p95/p99/max are reported alongside the mean +/// so the tail (GC pauses, context switches, slow-path branches) stays +/// visible. Collapsing the distribution to a single number is the +/// failure mode Gil Tene calls coordinated omission; see +/// https://github.com/HdrHistogram/HdrHistogram. +class Stats { + final double minNs, p50Ns, p95Ns, p99Ns, maxNs, meanNs, stddevNs; + Stats(this.minNs, this.p50Ns, this.p95Ns, this.p99Ns, this.maxNs, this.meanNs, + this.stddevNs); + + static Stats from(List nsPerOp) { + final s = List.from(nsPerOp)..sort(); + final n = s.length; + final mean = s.reduce((a, b) => a + b) / n; + var sq = 0.0; + for (final v in s) { + sq += (v - mean) * (v - mean); + } + final stddev = math.sqrt(sq / n); + // Compute percentiles by index into the sorted batch list. We sample + // p50, p95, p99 (plus min/max from the sorted ends) so the tail is + // visible alongside the mean. HdrHistogram convention; the tail is + // where coordinated-omission failures hide and where users feel + // slow frames. + double pct(double p) { + final idx = ((n - 1) * p).round(); + return s[idx]; + } + + return Stats( + s.first, pct(0.50), pct(0.95), pct(0.99), s.last, mean, stddev); + } +} + +class BenchResult { + final String category; + final String scenario; + final Stats stats; + BenchResult(this.category, this.scenario, this.stats); + double get opsPerSec => 1e9 / stats.meanNs; +} + +/// Run one warmup pass, then [kBatches] timed batches of [kBatchSize] calls. +/// `Stopwatch.elapsedMicroseconds` has ~30-100 ns of start/stop overhead on +/// modern CPUs; batching amortizes that below 0.1% of the per-op figure. +/// Pattern adapted from JMH and `package:benchmark_harness`; see +/// top-of-file references. +BenchResult _bench(String category, String scenario, void Function(int) fn) { + // Warmup: drive the AOT code through any cold-cache and tier-up + // effects before the first measured batch. JMH idiom; see + // `package:benchmark_harness` for the Dart equivalent. + fn(kWarmup); + final batchTimes = []; + for (int b = 0; b < kBatches; b++) { + // One Stopwatch start/stop per batch (not per op): ~30-100 ns of + // clock-source overhead is amortized across kBatchSize calls, + // dropping below 0.1% of the per-op figure. This is exactly the + // amortization Shipilëv argues for in "Nanotrusting the Nanotime"; + // a per-op start;op;stop loop would inflate every measurement by + // its own clock cost. + final sw = Stopwatch()..start(); + fn(kBatchSize); + sw.stop(); + // ns/op derived from microsecond resolution: micros × 1000 / N. + // Stopwatch.elapsedMicroseconds is the highest-resolution clock + // the Dart VM exposes that's portable across AOT and JIT. + batchTimes.add(sw.elapsedMicroseconds * 1000.0 / kBatchSize); + } + return BenchResult(category, scenario, Stats.from(batchTimes)); +} + +// --------------------------------------------------------------------------- +// Delta patterns +// +// Each pattern returns a (start: Vector2, fn: i -> Vector2) pair. The fn +// is what the loop calls; start is what we feed as initialLocalPosition. +// --------------------------------------------------------------------------- + +typedef DeltaPattern = ({Vector2 start, Vector2 Function(int i) at}); + +DeltaPattern _smooth(Vector2 origin) => ( + start: origin, + at: (i) => Vector2(origin.x + i * 0.1, origin.y + i * 0.07) + ); + +DeltaPattern _subpixel(Vector2 origin) => ( + start: origin, + at: (i) => Vector2(origin.x + i * 0.001, origin.y + i * 0.0007) + ); + +// PRNG with stable seed: ±1px jitter on top of slow drift. +DeltaPattern _jitter(Vector2 origin) { + final rng = math.Random(0xb0a); + return ( + start: origin, + at: (i) => Vector2( + origin.x + i * 0.05 + (rng.nextDouble() - 0.5) * 2, + origin.y + i * 0.03 + (rng.nextDouble() - 0.5) * 2, + ) + ); +} + +// Edge-saturated: pointer ranges WAY outside the clamp, forcing the +// projector/interval-clamp to pin every call. +DeltaPattern _saturated(Vector2 origin) => ( + start: origin, + at: (i) => + Vector2(origin.x + 5000.0 + i * 0.1, origin.y + 5000.0 + i * 0.1) + ); + +const _patterns = { + 'smooth': _smooth, + 'subpix': _subpixel, + 'jitter': _jitter, + 'saturated': _saturated, +}; + +// --------------------------------------------------------------------------- +// Geometry helpers for scenario building +// --------------------------------------------------------------------------- + +const _baseW = 100.0; +const _baseH = 100.0; +const _cx = 500.0; +const _cy = 500.0; + +Box _makeRect(double rotation) => + Box.fromLTWH(_cx - _baseW / 2, _cy - _baseH / 2, _baseW, _baseH, + rotation: rotation); + +Box _makeClamp(String mode, double rotation) { + // 'none' uses Box.largest. 'loose' = generous container. 'saturated' = + // exactly the bounding rect of the rotated box (single feasible point + // for move; minimal slack for resize). + switch (mode) { + case 'none': + return Box.largest; + case 'loose': + return Box.fromLTRB(0, 0, 1000, 1000); + case 'saturated': + final rect = _makeRect(rotation); + final br = ClampHelpers.calculateBoundingRect(rect); + return Box.fromLTRB(br.left, br.top, br.right, br.bottom); + default: + throw ArgumentError(mode); + } +} + +Constraints _makeConstraints(String mode) { + switch (mode) { + case 'none': + return const Constraints.unconstrained(); + case 'loose': + return const Constraints( + minWidth: 10, maxWidth: 1000, minHeight: 10, maxHeight: 1000); + case 'tight': + // Centered tightly around current size (100×100); exercises min/max + // hit paths since the projector pins to bounds frequently. + return const Constraints( + minWidth: 90, maxWidth: 110, minHeight: 90, maxHeight: 110); + default: + throw ArgumentError(mode); + } +} + +// --------------------------------------------------------------------------- +// MOVE scenarios +// --------------------------------------------------------------------------- + +Iterable _moveScenarios() sync* { + final rotations = <(String, double)>[ + ('θ=0', 0.0), + ('θ=π/6', math.pi / 6), + ]; + final clamps = ['none', 'loose', 'saturated']; + final strategies = <(String, BindingStrategy)>[ + ('orig', BindingStrategy.originalBox), + ('bbox', BindingStrategy.boundingBox), + ]; + for (final (rotLabel, rotation) in rotations) { + for (final clampMode in clamps) { + for (final (stratLabel, strat) in strategies) { + // Strategy is meaningless at θ=0; only run once. + if (rotation == 0.0 && stratLabel != 'orig') continue; + for (final patternName in _patterns.keys) { + final pattern = _patterns[patternName]!(Vector2(_cx, _cy)); + final rect = _makeRect(rotation); + final clamp = _makeClamp(clampMode, rotation); + + final label = rotation == 0.0 + ? 'clamp=$clampMode | delta=$patternName | $rotLabel' + : 'clamp=$clampMode | delta=$patternName | $rotLabel | strat=$stratLabel'; + + yield _bench('MOVE', label, (n) { + for (int i = 0; i < n; i++) { + final r = BoxTransformer.move( + initialRect: rect, + initialLocalPosition: pattern.start, + localPosition: pattern.at(i), + clampingRect: clamp, + rotation: rotation, + bindingStrategy: strat, + ); + // XOR result bits into the global `sink` so AOT cannot + // dead-code-eliminate the whole loop. JMH `Blackhole` + // pattern; see top-of-file references. + sink ^= r.rect.left.toInt(); + } + }); + } + } + } + } +} + +// --------------------------------------------------------------------------- +// RESIZE scenarios: mode × rotation × clamping × constraints × strategy × delta. +// --------------------------------------------------------------------------- + +Iterable _resizeScenarios() sync* { + final modes = <(String, ResizeMode)>[ + ('free', ResizeMode.freeform), + ('scale', ResizeMode.scale), + ('sym', ResizeMode.symmetric), + ('symscale', ResizeMode.symmetricScale), + ]; + final rotations = <(String, double)>[ + ('θ=0', 0.0), + ('θ=π/6', math.pi / 6), + ]; + final clamps = ['none', 'loose', 'saturated']; + final cons = ['none', 'loose', 'tight']; + final strategies = <(String, BindingStrategy)>[ + ('orig', BindingStrategy.originalBox), + ('bbox', BindingStrategy.boundingBox), + ]; + + // Pointer originates at the unrotated bottom-right corner of the rect. + final start = Vector2(_cx + _baseW / 2, _cy + _baseH / 2); + + for (final (modeLabel, mode) in modes) { + for (final (rotLabel, rotation) in rotations) { + for (final clampMode in clamps) { + for (final consMode in cons) { + for (final (stratLabel, strat) in strategies) { + if (rotation == 0.0 && stratLabel != 'orig') continue; + for (final patternName in _patterns.keys) { + final pattern = _patterns[patternName]!(start); + final rect = _makeRect(rotation); + final clamp = _makeClamp(clampMode, rotation); + final constraints = _makeConstraints(consMode); + + final label = rotation == 0.0 + ? 'mode=$modeLabel | clamp=$clampMode | cons=$consMode | delta=$patternName | $rotLabel' + : 'mode=$modeLabel | clamp=$clampMode | cons=$consMode | delta=$patternName | $rotLabel | strat=$stratLabel'; + + yield _bench('RESIZE', label, (n) { + for (int i = 0; i < n; i++) { + final r = BoxTransformer.resize( + initialRect: rect, + initialLocalPosition: pattern.start, + localPosition: pattern.at(i), + handle: HandlePosition.bottomRight, + resizeMode: mode, + initialFlip: Flip.none, + clampingRect: clamp, + constraints: constraints, + rotation: rotation, + bindingStrategy: strat, + ); + sink ^= + r.rect.right.toInt(); // sink fold; see _moveScenarios. + } + }); + } + } + } + } + } + } +} + +// --------------------------------------------------------------------------- +// ROTATE scenarios: single op, no real combinatorics, vary delta only. +// --------------------------------------------------------------------------- + +Iterable _rotateScenarios() sync* { + final rect = _makeRect(0.0); + final start = Vector2(_cx + _baseW, _cy); + for (final patternName in _patterns.keys) { + final pattern = _patterns[patternName]!(start); + yield _bench('ROTATE', 'delta=$patternName', (n) { + for (int i = 0; i < n; i++) { + final r = BoxTransformer.rotate( + initialRect: rect, + initialLocalPosition: pattern.start, + localPosition: pattern.at(i), + initialRotation: 0.0, + ); + sink ^= r.rotation.toInt(); // sink fold; see _moveScenarios. + } + }); + } +} + +// --------------------------------------------------------------------------- +// RECLAMP: "the parent container shrinks while a box is inside it". +// We model this as zero-delta `move()` with a clamp that shrinks each tick. +// This is what a UI controller does when the parent's available space changes. +// --------------------------------------------------------------------------- + +Iterable _reclampScenarios() sync* { + final rotations = <(String, double)>[ + ('θ=0', 0.0), + ('θ=π/6', math.pi / 6), + ]; + // Three patterns of clamp-shrinkage: + // * 'shrink-loose': clamp starts huge, shrinks 1px/tick, never crosses box. + // * 'shrink-touch': clamp shrinks until it kisses the bounding rect. + // * 'shrink-cross': clamp shrinks past the bounding rect (infeasibility). + final patterns = { + 'shrink-loose': (i, rot) => Box.fromLTRB( + 200.0 - i * 0.1, + 200.0 - i * 0.1, + 800.0 + i * 0.1, + 800.0 + i * 0.1, + ), + 'shrink-touch': (i, rot) { + // Linearly interpolate from huge clamp toward exact bounding rect. + final rect = _makeRect(rot); + final br = ClampHelpers.calculateBoundingRect(rect); + final t = (i % 1000) / 1000.0; // 0..1 cycling + return Box.fromLTRB( + 50 + (br.left - 50) * t, + 50 + (br.top - 50) * t, + 950 + (br.right - 950) * t, + 950 + (br.bottom - 950) * t, + ); + }, + 'shrink-cross': (i, rot) { + // Past the bounding rect (infeasible global interval); exercises the + // collapse-to-midpoint sanitiser. + final rect = _makeRect(rot); + final br = ClampHelpers.calculateBoundingRect(rect); + final shrinkPx = (i % 100) * 0.1; // up to 10px of infeasibility + return Box.fromLTRB( + br.left + shrinkPx, + br.top + shrinkPx, + br.right - shrinkPx, + br.bottom - shrinkPx, + ); + }, + }; + + for (final (rotLabel, rotation) in rotations) { + final rect = _makeRect(rotation); + final start = Vector2(_cx, _cy); + for (final entry in patterns.entries) { + final patternName = entry.key; + final clampFn = entry.value; + yield _bench('RECLAMP', 'pattern=$patternName | $rotLabel', (n) { + for (int i = 0; i < n; i++) { + final clamp = clampFn(i, rotation); + final r = BoxTransformer.move( + initialRect: rect, + initialLocalPosition: start, + localPosition: start, + clampingRect: clamp, + rotation: rotation, + ); + sink ^= r.rect.left.toInt(); // sink fold; see _moveScenarios. + } + }); + } + } +} + +// --------------------------------------------------------------------------- +// Output formatting +// --------------------------------------------------------------------------- + +void _printHeader() { + print('# box_transform: comprehensive benchmark report'); + print(''); + print('## Environment'); + print('- Dart: ${Platform.version}'); + print('- OS: ${Platform.operatingSystem} ${Platform.operatingSystemVersion}'); + print('- Mode: ${_isAot() ? 'AOT' : 'JIT'}'); + print(''); + print('## Methodology'); + print('- $kBatches batches × $kBatchSize calls per scenario after $kWarmup ' + 'warmup calls.'); + print('- ns/op = batch_us × 1000 / batchSize. Stopwatch overhead amortizes ' + 'to <0.1% of measurement.'); + print('- Reported: min, p50, mean, p95, p99, max across batches; ops/sec ' + 'derived from mean.'); + print('- Sink XOR-fold of every result blocks dead-code elimination.'); + print('- Scenarios cover the orthogonal axes of box_transform: operation × ' + 'rotation × clamping state × constraints × binding strategy × pointer ' + 'delta pattern.'); + print(''); +} + +void _printResults(List all) { + // Group by category. + final byCat = >{}; + for (final r in all) { + byCat.putIfAbsent(r.category, () => []).add(r); + } + + // Headline summary first. + print('## Headline numbers (mean ns/op)'); + print(''); + print('| Category | Min | Median | Max | Cells |'); + print('|---|---:|---:|---:|---:|'); + for (final cat in byCat.keys) { + final means = byCat[cat]!.map((r) => r.stats.meanNs).toList()..sort(); + final n = means.length; + final mn = means.first.toStringAsFixed(0); + final med = means[n ~/ 2].toStringAsFixed(0); + final mx = means.last.toStringAsFixed(0); + print('| $cat | $mn | $med | $mx | $n |'); + } + print(''); + + for (final cat in byCat.keys) { + print('## $cat: ${byCat[cat]!.length} scenarios'); + print(''); + print('| Scenario | min | p50 | mean | p95 | p99 | max | ops/sec |'); + print('|---|---:|---:|---:|---:|---:|---:|---:|'); + final list = byCat[cat]!.toList() + ..sort((a, b) => a.scenario.compareTo(b.scenario)); + for (final r in list) { + final s = r.stats; + print('| ${r.scenario} ' + '| ${s.minNs.toStringAsFixed(0)} ' + '| ${s.p50Ns.toStringAsFixed(0)} ' + '| ${s.meanNs.toStringAsFixed(0)} ' + '| ${s.p95Ns.toStringAsFixed(0)} ' + '| ${s.p99Ns.toStringAsFixed(0)} ' + '| ${s.maxNs.toStringAsFixed(0)} ' + '| ${r.opsPerSec.toStringAsFixed(0)} |'); + } + print(''); + } +} + +bool _isAot() { + bool aot = true; + assert(() { + aot = false; + return true; + }()); + return aot; +} diff --git a/packages/box_transform/lib/box_transform.dart b/packages/box_transform/lib/box_transform.dart index c4a5689..975e2ac 100644 --- a/packages/box_transform/lib/box_transform.dart +++ b/packages/box_transform/lib/box_transform.dart @@ -1,3 +1,8 @@ +/// A pure Dart implementation of advanced 2D box transformation. +/// +/// Provides primitives for moving, resizing, scaling, flipping, and rotating +/// rectangles, with support for clamping rects, constraints, and binding +/// strategies for rotated boxes. library; export 'src/enums.dart'; @@ -5,4 +10,5 @@ export 'src/extensions.dart' hide DoubleExt; export 'src/geometry.dart'; export 'src/helpers.dart'; export 'src/result.dart'; +export 'src/rotated_clamping_solver.dart'; export 'src/transformer.dart'; diff --git a/packages/box_transform/lib/src/enums.dart b/packages/box_transform/lib/src/enums.dart index aa0bac5..8d601e8 100644 --- a/packages/box_transform/lib/src/enums.dart +++ b/packages/box_transform/lib/src/enums.dart @@ -1,4 +1,4 @@ -import 'package:vector_math/vector_math.dart'; +import 'package:vector_math/vector_math_64.dart'; import 'geometry.dart'; @@ -303,7 +303,7 @@ enum Flip { /// Creates a new [Flip] by combining this [Flip] with the given. /// See [influencedBy] for more details. - operator *(Flip other) => influencedBy(other); + Flip operator *(Flip other) => influencedBy(other); /// Creates a new [Flip] by combining this [Flip] with the given /// [other] flip making the resulting flip influenced by [other]. @@ -413,3 +413,25 @@ enum ResizeMode { /// w.r.t the center. bool get hasSymmetry => isSymmetric || isSymmetricScale; } + +/// Controls whether size and clamping constraints apply to the box's unrotated +/// dimensions or to its rendered axis-aligned bounding box. +/// +/// This only matters when [Box.rotation] is non-zero. +enum BindingStrategy { + /// Constraints and clamping apply to the box's unrotated [Box.width] and + /// [Box.height]. The rotated corners may extend beyond the clamping rect + /// if rotation makes them do so. + /// + /// Typical intent: "my image is intrinsically 100x100; keep the logical + /// image on-screen." + originalBox, + + /// Constraints and clamping apply to the rendered axis-aligned bounding + /// box of the rotated box. The rotated corners are guaranteed to stay + /// within the clamping rect. + /// + /// Typical intent: "I want the rendered footprint to stay 100px wide + /// regardless of rotation angle." + boundingBox, +} diff --git a/packages/box_transform/lib/src/extensions.dart b/packages/box_transform/lib/src/extensions.dart index a7dc0bd..40b24ed 100644 --- a/packages/box_transform/lib/src/extensions.dart +++ b/packages/box_transform/lib/src/extensions.dart @@ -1,26 +1,61 @@ -import 'package:vector_math/vector_math.dart'; +import 'dart:math' as math; + +import 'package:vector_math/vector_math_64.dart'; import 'geometry.dart'; import 'result.dart'; /// Provides convenient getters for [TransformResult]. extension RawTransformResultExt on RawTransformResult { - /// Convenient getter for [box.size]. + /// Convenient getter for the size of [rect]. Dimension get size => rect.size; - /// Convenient getter for [box.topLeft]. + /// Convenient getter for the top-left of [rect]. Vector2 get position => rect.topLeft; - /// Convenient getter for [oldBox.size]. + /// Convenient getter for the size of [oldRect]. Dimension get oldSize => oldRect.size; - /// Convenient getter for [oldBox.topLeft]. + /// Convenient getter for the top-left of [oldRect]. Vector2 get oldPosition => oldRect.topLeft; } +/// Pre-computed `10^n` for `n ∈ [0..9]`. Avoids both `math.pow` (which +/// promotes int→double on each call) and recomputation in the hot path. +const List _kPowersOfTen = [ + 1.0, + 10.0, + 100.0, + 1000.0, + 10000.0, + 100000.0, + 1000000.0, + 10000000.0, + 100000000.0, + 1000000000.0, +]; + /// Extensions for double. extension DoubleExt on double { - /// Rounds a double to the given precision. - double roundToPrecision(int precision) => - double.parse((this).toStringAsFixed(precision)); + /// Rounds this double to [precision] decimal places. + /// + /// Implementation: scale by `10^precision`, round to the nearest integer + /// (Dart's [num.round] rounds half-away-from-zero), then descale. This is + /// ~25× faster than the previous `double.parse(toStringAsFixed(p))` form + /// — the string round-trip dominated `isValidRect` in the AA-resize hot + /// path. Note: differs from `toStringAsFixed` at exact halves + /// (banker's-rounding vs away-from-zero), which is irrelevant to the + /// tolerance-style comparisons this is used for. + double roundToPrecision(int precision) { + // Pass through non-finite (Infinity, -Infinity, NaN). `isValidRect` calls + // this on `Constraints.maxWidth`/`maxHeight` which sentinel-encode "no + // upper bound" as `double.infinity`; the old string round-trip handled + // these gracefully via `double.parse("Infinity")`, but `infinity.round()` + // throws. + if (!isFinite) return this; + final double mod = (precision >= 0 && precision < _kPowersOfTen.length) + ? _kPowersOfTen[precision] + : math.pow(10.0, precision).toDouble(); + return (this * mod).round() / mod; + } } diff --git a/packages/box_transform/lib/src/geometry.dart b/packages/box_transform/lib/src/geometry.dart index 9e59cec..aa83e63 100644 --- a/packages/box_transform/lib/src/geometry.dart +++ b/packages/box_transform/lib/src/geometry.dart @@ -1,6 +1,6 @@ import 'dart:math' as math; -import 'package:vector_math/vector_math.dart'; +import 'package:vector_math/vector_math_64.dart'; import '../box_transform.dart'; @@ -95,10 +95,15 @@ class Constraints { int get hashCode => Object.hash(minWidth, maxWidth, minHeight, maxHeight); } -/// Linearly interpolate between two doubles. -/// -/// Same as [lerpDouble] but specialized for non-null `double` type. -double _lerpDouble(double a, double b, double t) => a * (1.0 - t) + b * t; +/// Internal geometry helpers. +abstract final class Geometry { + const Geometry._(); + + /// Linearly interpolate between two doubles. + /// + /// Same as [lerpDouble] but specialized for non-null `double` type. + static double _lerp(double a, double b, double t) => a * (1.0 - t) + b * t; +} /// A 2D size with [width] and [height]. class Dimension { @@ -162,11 +167,6 @@ class Dimension { static const Dimension zero = Dimension(0.0, 0.0); /// A size whose [width] and [height] are infinite. - /// - /// See also: - /// - /// * [isInfinite], which checks whether either dimension is infinite. - /// * [isFinite], which checks whether both dimensions are finite. static const Dimension infinite = Dimension(double.infinity, double.infinity); /// Removes [other] from this size. @@ -319,10 +319,10 @@ class Dimension { /// meaning that the interpolation is at the relevant point on the timeline /// between `a` and `b`. The interpolation can be extrapolated beyond 0.0 and /// 1.0, so negative values and values greater than 1.0 are valid (and can - /// easily be generated by curves such as [Curves.elasticInOut]). + /// easily be generated by curves such as `Curves.elasticInOut`). /// - /// Values for `t` are usually obtained from an [Animation], such as - /// an [AnimationController]. + /// Values for `t` are usually obtained from an `Animation`, such as + /// an `AnimationController`. static Dimension? lerp(Dimension? a, Dimension? b, double t) { if (b == null) { if (a == null) { @@ -334,8 +334,8 @@ class Dimension { if (a == null) { return b * t; } else { - return Dimension(_lerpDouble(a.width, b.width, t), - _lerpDouble(a.height, b.height, t)); + return Dimension(Geometry._lerp(a.width, b.width, t), + Geometry._lerp(a.height, b.height, t)); } } } @@ -365,24 +365,42 @@ class Dimension { /// ``` class Box { /// Construct a rectangle from its left, top, right, and bottom edges. - const Box.fromLTRB(this.left, this.top, this.right, this.bottom); + /// + /// [rotation] is in radians, clockwise, around the box center. Defaults + /// to 0 (axis-aligned). + const Box.fromLTRB( + this.left, + this.top, + this.right, + this.bottom, { + this.rotation = 0.0, + }); /// Construct a rectangle from its left and top edges, its width, and its /// height. /// - /// To construct a [Box] from an [Vector2] and a [Dimension], you can use the - /// rectangle constructor operator `&`. See [Vector2.&]. - const Box.fromLTWH(double left, double top, double width, double height) - : this.fromLTRB(left, top, left + width, top + height); + /// To construct a [Box] from a [Vector2] and a [Dimension], you can use the + /// rectangle constructor operator `&`. See `Vector2.&`. + const Box.fromLTWH( + double left, + double top, + double width, + double height, { + double rotation = 0.0, + }) : this.fromLTRB(left, top, left + width, top + height, rotation: rotation); /// Construct a rectangle that bounds the given circle. /// /// The `center` argument is assumed to be an Vector2 from the origin. - Box.fromCircle({required Vector2 center, required double radius}) - : this.fromCenter( + Box.fromCircle({ + required Vector2 center, + required double radius, + double rotation = 0.0, + }) : this.fromCenter( center: center, width: radius * 2, height: radius * 2, + rotation: rotation, ); /// Constructs a rectangle from its center point, width, and height. @@ -392,47 +410,63 @@ class Box { required Vector2 center, required double width, required double height, + double rotation = 0.0, }) : this.fromLTRB( center.x - width / 2, center.y - height / 2, center.x + width / 2, center.y + height / 2, + rotation: rotation, ); /// Construct the smallest rectangle that encloses the given Vector2s, treating /// them as vectors from the origin. - Box.fromPoints(Vector2 a, Vector2 b) + Box.fromPoints(Vector2 a, Vector2 b, {double rotation = 0.0}) : this.fromLTRB( math.min(a.x, b.x), math.min(a.y, b.y), math.max(a.x, b.x), math.max(a.y, b.y), + rotation: rotation, ); /// Construct a rectangle from given handle and its origin. factory Box.fromHandle( - Vector2 origin, HandlePosition handle, double width, double height) { + Vector2 origin, + HandlePosition handle, + double width, + double height, { + double rotation = 0.0, + }) { switch (handle) { case HandlePosition.none: throw ArgumentError('HandlePosition.none is not supported!'); case HandlePosition.topLeft: - return Box.fromLTWH(origin.x - width, origin.y - height, width, height); + return Box.fromLTWH(origin.x - width, origin.y - height, width, height, + rotation: rotation); case HandlePosition.topRight: - return Box.fromLTWH(origin.x, origin.y - height, width, height); + return Box.fromLTWH(origin.x, origin.y - height, width, height, + rotation: rotation); case HandlePosition.bottomLeft: - return Box.fromLTWH(origin.x - width, origin.y, width, height); + return Box.fromLTWH(origin.x - width, origin.y, width, height, + rotation: rotation); case HandlePosition.bottomRight: - return Box.fromLTWH(origin.x, origin.y, width, height); + return Box.fromLTWH(origin.x, origin.y, width, height, + rotation: rotation); case HandlePosition.left: return Box.fromLTWH( - origin.x - width, origin.y - height / 2, width, height); + origin.x - width, origin.y - height / 2, width, height, + rotation: rotation); case HandlePosition.top: return Box.fromLTWH( - origin.x - width / 2, origin.y - height, width, height); + origin.x - width / 2, origin.y - height, width, height, + rotation: rotation); case HandlePosition.right: - return Box.fromLTWH(origin.x, origin.y - height / 2, width, height); + return Box.fromLTWH(origin.x, origin.y - height / 2, width, height, + rotation: rotation); case HandlePosition.bottom: - return Box.fromLTWH(origin.x - width / 2, origin.y, width, height); + return Box.fromLTWH(origin.x - width / 2, origin.y, width, height, + rotation: rotation); } } @@ -448,6 +482,9 @@ class Box { /// The Vector2 of the bottom edge of this rectangle from the y axis. final double bottom; + /// Rotation in radians, clockwise, around the box center. Zero = axis-aligned. + final double rotation; + /// The distance between the left and right edges of this rectangle. double get width => right - left; @@ -494,8 +531,13 @@ class Box { /// /// To translate a rectangle by separate x and y components rather than by an /// [Vector2], consider [translate]. - Box shift(Vector2 vec) => - Box.fromLTRB(left + vec.x, top + vec.y, right + vec.x, bottom + vec.y); + Box shift(Vector2 vec) => Box.fromLTRB( + left + vec.x, + top + vec.y, + right + vec.x, + bottom + vec.y, + rotation: rotation, + ); /// Returns a new rectangle with translateX added to the x components and /// translateY added to the y components. @@ -507,11 +549,17 @@ class Box { top + translateY, right + translateX, bottom + translateY, + rotation: rotation, ); /// Returns a new rectangle with edges moved outwards by the given delta. - Box inflate(double delta) => - Box.fromLTRB(left - delta, top - delta, right + delta, bottom + delta); + Box inflate(double delta) => Box.fromLTRB( + left - delta, + top - delta, + right + delta, + bottom + delta, + rotation: rotation, + ); /// Returns a new rectangle with edges moved inwards by the given delta. Box deflate(double delta) => inflate(-delta); @@ -520,6 +568,9 @@ class Box { /// rectangle and this rectangle. The two rectangles must overlap /// for this to be meaningful. If the two rectangles do not overlap, /// then the resulting Box will have a negative width or height. + /// + /// Note: this is an axis-aligned intersection. The returned box has + /// `rotation == 0` regardless of either operand's rotation. Box intersect(Box other) => Box.fromLTRB( math.max(left, other.left), math.max(top, other.top), @@ -528,6 +579,9 @@ class Box { /// Returns a new rectangle which is the bounding box containing this /// rectangle and the given rectangle. + /// + /// Note: this is an axis-aligned union. The returned box has + /// `rotation == 0` regardless of either operand's rotation. Box expandToInclude(Box other) => Box.fromLTRB( math.min(left, other.left), math.min(top, other.top), @@ -617,9 +671,7 @@ class Box { /// Constrains this box instance to the given [constraints]. /// - /// [constraints] the constraints to apply to this box. - /// - /// [returns] a new box instance. + /// Returns a new box instance. Box constrainBy(Constraints constraints) => constraints.constrainRect(this); /// Constrains the given [child] box instance within the bounds of this box. @@ -628,6 +680,9 @@ class Box { /// is specified. /// /// [child] the child box to clamp inside this box. + /// + /// Note: this is an axis-aligned containment operation. The returned box + /// has `rotation == 0` regardless of either operand's rotation. Box containOther(Box child) { final double xSign = child.width.sign; final double ySign = child.height.sign; @@ -652,20 +707,24 @@ class Box { ); } - /// Returns a new box with all the values ceiling rounded. + /// Returns a new box with all the values ceiling rounded. Preserves + /// [rotation]. Box ceil() => Box.fromLTRB( left.ceilToDouble(), top.ceilToDouble(), right.ceilToDouble(), bottom.ceilToDouble(), + rotation: rotation, ); - /// Returns a new box with all the values floor rounded. + /// Returns a new box with all the values floor rounded. Preserves + /// [rotation]. Box floor() => Box.fromLTRB( left.floorToDouble(), top.floorToDouble(), right.floorToDouble(), bottom.floorToDouble(), + rotation: rotation, ); /// Linearly interpolate between two rectangles. @@ -679,10 +738,10 @@ class Box { /// meaning that the interpolation is at the relevant point on the timeline /// between `a` and `b`. The interpolation can be extrapolated beyond 0.0 and /// 1.0, so negative values and values greater than 1.0 are valid (and can - /// easily be generated by curves such as [Curves.elasticInOut]). + /// easily be generated by curves such as `Curves.elasticInOut`). /// - /// Values for `t` are usually obtained from an [Animation], such as - /// an [AnimationController]. + /// Values for `t` are usually obtained from an `Animation`, such as + /// an `AnimationController`. static Box? lerp(Box? a, Box? b, double t) { if (b == null) { if (a == null) { @@ -696,10 +755,10 @@ class Box { return Box.fromLTRB(b.left * t, b.top * t, b.right * t, b.bottom * t); } else { return Box.fromLTRB( - _lerpDouble(a.left, b.left, t), - _lerpDouble(a.top, b.top, t), - _lerpDouble(a.right, b.right, t), - _lerpDouble(a.bottom, b.bottom, t), + Geometry._lerp(a.left, b.left, t), + Geometry._lerp(a.top, b.top, t), + Geometry._lerp(a.right, b.right, t), + Geometry._lerp(a.bottom, b.bottom, t), ); } } @@ -713,13 +772,15 @@ class Box { other.left == left && other.top == top && other.right == right && - other.bottom == bottom; + other.bottom == bottom && + other.rotation == rotation; } @override - int get hashCode => Object.hash(left, top, right, bottom); + int get hashCode => Object.hash(left, top, right, bottom, rotation); @override - String toString() => - 'Box.fromLTRB(${left.toStringAsFixed(1)}, ${top.toStringAsFixed(1)}, ${right.toStringAsFixed(1)}, ${bottom.toStringAsFixed(1)})'; + String toString() => rotation == 0.0 + ? 'Box.fromLTRB(${left.toStringAsFixed(1)}, ${top.toStringAsFixed(1)}, ${right.toStringAsFixed(1)}, ${bottom.toStringAsFixed(1)})' + : 'Box.fromLTRB(${left.toStringAsFixed(1)}, ${top.toStringAsFixed(1)}, ${right.toStringAsFixed(1)}, ${bottom.toStringAsFixed(1)}, rotation: ${rotation.toStringAsFixed(4)})'; } diff --git a/packages/box_transform/lib/src/helpers.dart b/packages/box_transform/lib/src/helpers.dart index 6285e5c..c437ffe 100644 --- a/packages/box_transform/lib/src/helpers.dart +++ b/packages/box_transform/lib/src/helpers.dart @@ -1,569 +1,676 @@ import 'dart:math'; -import 'package:vector_math/vector_math.dart'; +import 'package:vector_math/vector_math_64.dart'; import 'enums.dart'; import 'extensions.dart'; import 'geometry.dart'; -/// Flips the given [rect] with given [flip] with [handle] being the -/// pivot point. -Box flipRect(Box rect, Flip flip, HandlePosition handle) { - switch (handle) { - case HandlePosition.none: - throw ArgumentError('HandlePosition.none is not supported!'); - case HandlePosition.topLeft: - return rect.translate( - flip.isHorizontal ? rect.width : 0, - flip.isVertical ? rect.height : 0, - ); - case HandlePosition.topRight: - return rect.translate( - flip.isHorizontal ? -rect.width : 0, - flip.isVertical ? rect.height : 0, - ); - case HandlePosition.bottomLeft: - return rect.translate( - flip.isHorizontal ? rect.width : 0, - flip.isVertical ? -rect.height : 0, - ); - case HandlePosition.bottomRight: - return rect.translate( - flip.isHorizontal ? -rect.width : 0, - flip.isVertical ? -rect.height : 0, - ); - case HandlePosition.left: - return rect.translate( - flip.isHorizontal ? rect.width : 0, - 0, - ); - case HandlePosition.top: - return rect.translate( - 0, - flip.isVertical ? rect.height : 0, - ); - case HandlePosition.right: - return rect.translate( - flip.isHorizontal ? -rect.width : 0, - 0, - ); - case HandlePosition.bottom: - return rect.translate( - 0, - flip.isVertical ? -rect.height : 0, - ); +/// Static helpers for axis-aligned box clamping, handle resolution, +/// flip-aware resizing, and 2-D line / rect intersection geometry. +/// +/// All members are `static`. The class cannot be constructed or extended. +abstract final class ClampHelpers { + const ClampHelpers._(); + + /// Flips the given [rect] with given [flip] with [handle] being the + /// pivot point. + static Box flipRect(Box rect, Flip flip, HandlePosition handle) { + switch (handle) { + case HandlePosition.none: + throw ArgumentError('HandlePosition.none is not supported!'); + case HandlePosition.topLeft: + return rect.translate( + flip.isHorizontal ? rect.width : 0, + flip.isVertical ? rect.height : 0, + ); + case HandlePosition.topRight: + return rect.translate( + flip.isHorizontal ? -rect.width : 0, + flip.isVertical ? rect.height : 0, + ); + case HandlePosition.bottomLeft: + return rect.translate( + flip.isHorizontal ? rect.width : 0, + flip.isVertical ? -rect.height : 0, + ); + case HandlePosition.bottomRight: + return rect.translate( + flip.isHorizontal ? -rect.width : 0, + flip.isVertical ? -rect.height : 0, + ); + case HandlePosition.left: + return rect.translate( + flip.isHorizontal ? rect.width : 0, + 0, + ); + case HandlePosition.top: + return rect.translate( + 0, + flip.isVertical ? rect.height : 0, + ); + case HandlePosition.right: + return rect.translate( + flip.isHorizontal ? -rect.width : 0, + 0, + ); + case HandlePosition.bottom: + return rect.translate( + 0, + flip.isVertical ? -rect.height : 0, + ); + } } -} -/// Calculates flip state of the given [rect] w.r.t [localPosition] and -/// [handle]. It uses [handle] and [localPosition] to determine the quadrant -/// of the [rect] and then checks if the [rect] is flipped in that quadrant. -Flip getFlipForRect( - Box rect, - Vector2 localPosition, - HandlePosition handle, - ResizeMode resizeMode, -) { - final double widthFactor = resizeMode.hasSymmetry ? 0.5 : 1; - final double heightFactor = resizeMode.hasSymmetry ? 0.5 : 1; + /// Calculates flip state of the given [rect] w.r.t [localPosition] and + /// [handle]. It uses [handle] and [localPosition] to determine the quadrant + /// of the [rect] and then checks if the [rect] is flipped in that quadrant. + static Flip getFlipForRect( + Box rect, + Vector2 localPosition, + HandlePosition handle, + ResizeMode resizeMode, + ) { + final double widthFactor = resizeMode.hasSymmetry ? 0.5 : 1; + final double heightFactor = resizeMode.hasSymmetry ? 0.5 : 1; - final double effectiveWidth = rect.width * widthFactor; - final double effectiveHeight = rect.height * heightFactor; + final double effectiveWidth = rect.width * widthFactor; + final double effectiveHeight = rect.height * heightFactor; - final double handleXFactor = handle.influencesLeft ? -1 : 1; - final double handleYFactor = handle.influencesTop ? -1 : 1; + final double handleXFactor = handle.influencesLeft ? -1 : 1; + final double handleYFactor = handle.influencesTop ? -1 : 1; - final double posX = effectiveWidth + localPosition.x * handleXFactor; - final double posY = effectiveHeight + localPosition.y * handleYFactor; + final double posX = effectiveWidth + localPosition.x * handleXFactor; + final double posY = effectiveHeight + localPosition.y * handleYFactor; - return Flip.fromValue(posX, posY); -} - -/// Returns a clamping rect for [ResizeMode.scaledSymmetric]. -Box scaledSymmetricClampingRect(Box initialRect, Box clampingRect) { - final closestHandle = getClosestEdge(initialRect, clampingRect); - - final initialAspectRatio = initialRect.width / initialRect.height; - - double width; - double height; - switch (closestHandle) { - case HandlePosition.top: - height = (initialRect.center.y - clampingRect.top) * 2; - width = height * initialAspectRatio; - break; - case HandlePosition.right: - width = (clampingRect.right - initialRect.center.x) * 2; - height = width / initialAspectRatio; - break; - case HandlePosition.bottom: - height = (clampingRect.bottom - initialRect.center.y) * 2; - width = height * initialAspectRatio; - break; - case HandlePosition.left: - width = (initialRect.center.x - clampingRect.left) * 2; - height = width / initialAspectRatio; - break; - default: - throw Exception('Unknown handle'); + return Flip.fromValue(posX, posY); } - return Box.fromCenter( - center: initialRect.center, - width: width, - height: height, - ); -} - -/// Returns the handle/edge of the [initialRect] that is closest to one of the -/// edge of [clampingRect] for [ResizeMode.scale]. -HandlePosition getClosestEdge( - Box initialRect, - Box clampingRect, { - HandlePosition? excludeHandle, -}) { - return intersectionBetweenRects( - outerRect: clampingRect, - innerRect: initialRect, - excludeHandle: excludeHandle, - ); -} - -/// Returns a clamping rect for given side handle that preserves aspect ratio. -Box getClampingRectForSideHandle({ - required Box initialRect, - required Box availableArea, - required HandlePosition handle, -}) { - final closestEdge = getClosestEdge( - initialRect, - availableArea, - excludeHandle: handle.opposite, - ); - - final initialAspectRatio = initialRect.width / initialRect.height; - - double width; - double height; - - switch (closestEdge) { - case HandlePosition.left: - width = (initialRect.center.x - availableArea.left) * 2; - height = width / initialAspectRatio; - break; - case HandlePosition.top: - height = (initialRect.center.y - availableArea.top) * 2; - width = height * initialAspectRatio; - break; - case HandlePosition.right: - width = (availableArea.right - initialRect.center.x) * 2; - height = width / initialAspectRatio; - break; - case HandlePosition.bottom: - height = (availableArea.bottom - initialRect.center.y) * 2; - width = height * initialAspectRatio; - break; - default: - throw Exception('Unsupported handle'); - } + /// Returns a clamping rect for [ResizeMode.symmetricScale]. + static Box scaledSymmetricClampingRect(Box initialRect, Box clampingRect) { + final closestHandle = getClosestEdge(initialRect, clampingRect); + + final initialAspectRatio = initialRect.width / initialRect.height; + + double width; + double height; + switch (closestHandle) { + case HandlePosition.top: + height = (initialRect.center.y - clampingRect.top) * 2; + width = height * initialAspectRatio; + break; + case HandlePosition.right: + width = (clampingRect.right - initialRect.center.x) * 2; + height = width / initialAspectRatio; + break; + case HandlePosition.bottom: + height = (clampingRect.bottom - initialRect.center.y) * 2; + width = height * initialAspectRatio; + break; + case HandlePosition.left: + width = (initialRect.center.x - clampingRect.left) * 2; + height = width / initialAspectRatio; + break; + default: + throw Exception('Unknown handle'); + } - switch (handle) { - case HandlePosition.none: - throw ArgumentError('HandlePosition.none is not supported!'); - case HandlePosition.left: - final maxWidth = min(width, initialRect.right - availableArea.left); - final maxHeight = maxWidth / initialAspectRatio; - return Box.fromLTWH( - initialRect.right - maxWidth, - initialRect.center.y - maxHeight / 2, - maxWidth, - maxHeight, - ); - case HandlePosition.top: - final maxHeight = min(height, initialRect.bottom - availableArea.top); - final maxWidth = maxHeight * initialAspectRatio; - return Box.fromLTWH( - initialRect.center.x - maxWidth / 2, - initialRect.bottom - maxHeight, - maxWidth, - maxHeight, - ); - case HandlePosition.right: - final maxWidth = min(width, availableArea.right - initialRect.left); - final maxHeight = maxWidth / initialAspectRatio; - - return Box.fromLTWH( - initialRect.centerLeft.x, - initialRect.centerLeft.y - maxHeight / 2, - maxWidth, - maxHeight, - ); - case HandlePosition.bottom: - final maxHeight = min(height, availableArea.bottom - initialRect.top); - final maxWidth = maxHeight * initialAspectRatio; - return Box.fromLTWH( - initialRect.center.x - maxWidth / 2, - initialRect.top, - maxWidth, - maxHeight, - ); - default: - throw Exception('Unsupported handle'); + return Box.fromCenter( + center: initialRect.center, + width: width, + height: height, + ); } -} -/// Returns a vector of the point of intersection between two given lines. -/// First two vectors [p1] and [p2] are the first line, and the second two -/// vectors [p3] and [p4] are the second line. -/// -/// [returns] The point of intersection. IF there is no intersection, then -/// the function returns null. -Vector2? intersectionBetweenTwoLines( - Vector2 p1, Vector2 p2, Vector2 p3, Vector2 p4) { - final double t = - ((p1.x - p3.x) * (p3.y - p4.y) - (p1.y - p3.y) * (p3.x - p4.x)) / - ((p1.x - p2.x) * (p3.y - p4.y) - (p1.y - p2.y) * (p3.x - p4.x)); - final double u = - ((p1.x - p3.x) * (p1.y - p2.y) - (p1.y - p3.y) * (p1.x - p2.x)) / - ((p1.x - p2.x) * (p3.y - p4.y) - (p1.y - p2.y) * (p3.x - p4.x)); - - if (0 <= t && t <= 1 && 0 <= u && u <= 1) { - return Vector2(p1.x + t * (p2.x - p1.x), p1.y + t * (p2.y - p1.y)); - } else { - return null; + /// Returns the handle/edge of the [initialRect] that is closest to one of + /// the edge of [clampingRect] for [ResizeMode.scale]. + static HandlePosition getClosestEdge( + Box initialRect, + Box clampingRect, { + HandlePosition? excludeHandle, + }) { + return intersectionBetweenRects( + outerRect: clampingRect, + innerRect: initialRect, + excludeHandle: excludeHandle, + ); } -} -/// Extends given line to the given rectangle such that it touches the -/// rectangle. -(Vector2, Vector2)? extendLineToRect(Box rect, Vector2 p1, Vector2 p2) { - final (double, double, double, double)? result = extendLinePointsToRectPoints( - rect.left, rect.top, rect.right, rect.bottom, p1.x, p1.y, p2.x, p2.y); + /// Returns a clamping rect for given side handle that preserves aspect + /// ratio. + static Box getClampingRectForSideHandle({ + required Box initialRect, + required Box availableArea, + required HandlePosition handle, + }) { + final closestEdge = getClosestEdge( + initialRect, + availableArea, + excludeHandle: handle.opposite, + ); - if (result == null) return null; + final initialAspectRatio = initialRect.width / initialRect.height; + + double width; + double height; + + switch (closestEdge) { + case HandlePosition.left: + width = (initialRect.center.x - availableArea.left) * 2; + height = width / initialAspectRatio; + break; + case HandlePosition.top: + height = (initialRect.center.y - availableArea.top) * 2; + width = height * initialAspectRatio; + break; + case HandlePosition.right: + width = (availableArea.right - initialRect.center.x) * 2; + height = width / initialAspectRatio; + break; + case HandlePosition.bottom: + height = (availableArea.bottom - initialRect.center.y) * 2; + width = height * initialAspectRatio; + break; + default: + throw Exception('Unsupported handle'); + } - return (Vector2(result.$1, result.$2), Vector2(result.$3, result.$4)); -} + switch (handle) { + case HandlePosition.none: + throw ArgumentError('HandlePosition.none is not supported!'); + case HandlePosition.left: + final maxWidth = min(width, initialRect.right - availableArea.left); + final maxHeight = maxWidth / initialAspectRatio; + return Box.fromLTWH( + initialRect.right - maxWidth, + initialRect.center.y - maxHeight / 2, + maxWidth, + maxHeight, + ); + case HandlePosition.top: + final maxHeight = min(height, initialRect.bottom - availableArea.top); + final maxWidth = maxHeight * initialAspectRatio; + return Box.fromLTWH( + initialRect.center.x - maxWidth / 2, + initialRect.bottom - maxHeight, + maxWidth, + maxHeight, + ); + case HandlePosition.right: + final maxWidth = min(width, availableArea.right - initialRect.left); + final maxHeight = maxWidth / initialAspectRatio; + + return Box.fromLTWH( + initialRect.centerLeft.x, + initialRect.centerLeft.y - maxHeight / 2, + maxWidth, + maxHeight, + ); + case HandlePosition.bottom: + final maxHeight = min(height, availableArea.bottom - initialRect.top); + final maxWidth = maxHeight * initialAspectRatio; + return Box.fromLTWH( + initialRect.center.x - maxWidth / 2, + initialRect.top, + maxWidth, + maxHeight, + ); + default: + throw Exception('Unsupported handle'); + } + } -/// Extends given line to the given rectangle such that it touches the -/// rectangle points. -(double, double, double, double)? extendLinePointsToRectPoints( - double left, - double top, - double right, - double bottom, - double x1, - double y1, - double x2, - double y2, -) { - if (y1 == y2) { - return (left, y1, right, y1); + /// Returns a vector of the point of intersection between two given lines. + /// First two vectors [p1] and [p2] are the first line, and the second two + /// vectors [p3] and [p4] are the second line. + /// + /// Returns the point of intersection. If there is no intersection, then + /// the function returns null. + static Vector2? intersectionBetweenTwoLines( + Vector2 p1, Vector2 p2, Vector2 p3, Vector2 p4) { + final double t = + ((p1.x - p3.x) * (p3.y - p4.y) - (p1.y - p3.y) * (p3.x - p4.x)) / + ((p1.x - p2.x) * (p3.y - p4.y) - (p1.y - p2.y) * (p3.x - p4.x)); + final double u = + ((p1.x - p3.x) * (p1.y - p2.y) - (p1.y - p3.y) * (p1.x - p2.x)) / + ((p1.x - p2.x) * (p3.y - p4.y) - (p1.y - p2.y) * (p3.x - p4.x)); + + if (0 <= t && t <= 1 && 0 <= u && u <= 1) { + return Vector2(p1.x + t * (p2.x - p1.x), p1.y + t * (p2.y - p1.y)); + } else { + return null; + } } - if (x1 == x2) { - return (x1, top, x1, bottom); + + /// Extends given line to the given rectangle such that it touches the + /// rectangle. + static (Vector2, Vector2)? extendLineToRect( + Box rect, Vector2 p1, Vector2 p2) { + final ( + double, + double, + double, + double + )? result = extendLinePointsToRectPoints( + rect.left, rect.top, rect.right, rect.bottom, p1.x, p1.y, p2.x, p2.y); + + if (result == null) return null; + + return (Vector2(result.$1, result.$2), Vector2(result.$3, result.$4)); } - double yForLeft = y1 + (y2 - y1) * (left - x1) / (x2 - x1); - double yForRight = y1 + (y2 - y1) * (right - x1) / (x2 - x1); - - double xForTop = x1 + (x2 - x1) * (top - y1) / (y2 - y1); - double xForBottom = x1 + (x2 - x1) * (bottom - y1) / (y2 - y1); - - if (top <= yForLeft && - yForLeft <= bottom && - top <= yForRight && - yForRight <= bottom) { - return (left, yForLeft, right, yForRight); - } else if (top <= yForLeft && yForLeft <= bottom) { - if (left <= xForBottom && xForBottom <= right) { - return (left, yForLeft, xForBottom, bottom); - } else if (left <= xForTop && xForTop <= right) { - return (left, yForLeft, xForTop, top); - } - } else if (top <= yForRight && yForRight <= bottom) { - if (left <= xForTop && xForTop <= right) { - return (xForTop, top, right, yForRight); + /// Extends given line to the given rectangle such that it touches the + /// rectangle points. + static (double, double, double, double)? extendLinePointsToRectPoints( + double left, + double top, + double right, + double bottom, + double x1, + double y1, + double x2, + double y2, + ) { + if (y1 == y2) { + return (left, y1, right, y1); } - if (left <= xForBottom && xForBottom <= right) { - return (xForBottom, bottom, right, yForRight); + if (x1 == x2) { + return (x1, top, x1, bottom); } - } else if (left <= xForTop && - xForTop <= right && - left <= xForBottom && - xForBottom <= right) { - return (xForTop, top, xForBottom, bottom); - } - return null; -} -/// Returns the intersection between the given rectangles with assumption that -/// [innerRect] is completely inside [outerRect]. The intersection is calculated -/// using the center of the [innerRect] and the corners of the [outerRect]. -/// Returns the closest edge/handle of [outerRect] to the [innerRect]. -HandlePosition intersectionBetweenRects({ - required Box outerRect, - required Box innerRect, - HandlePosition? excludeHandle, -}) { - final line1 = - extendLineToRect(outerRect, innerRect.center, innerRect.bottomRight)!; - - final line2 = - extendLineToRect(outerRect, innerRect.center, innerRect.topRight)!; - - final intersections1 = findLineIntersection( - line1.$1, - line1.$2, - outerRect, - innerRect, - ); - - intersections1.remove(excludeHandle); - - final intersections2 = findLineIntersection( - line2.$1, - line2.$2, - outerRect, - innerRect, - ); - - intersections2.remove(excludeHandle); - - final List> intersections = [ - ...intersections1.entries, - ...intersections2.entries, - ]; - - final closest = intersections.reduce((value, element) { - final valueDistance = value.value.distanceToSquared(innerRect.center); - final elementDistance = element.value.distanceToSquared(innerRect.center); - - if (valueDistance < elementDistance) { - return value; - } else { - return element; + double yForLeft = y1 + (y2 - y1) * (left - x1) / (x2 - x1); + double yForRight = y1 + (y2 - y1) * (right - x1) / (x2 - x1); + + double xForTop = x1 + (x2 - x1) * (top - y1) / (y2 - y1); + double xForBottom = x1 + (x2 - x1) * (bottom - y1) / (y2 - y1); + + if (top <= yForLeft && + yForLeft <= bottom && + top <= yForRight && + yForRight <= bottom) { + return (left, yForLeft, right, yForRight); + } else if (top <= yForLeft && yForLeft <= bottom) { + if (left <= xForBottom && xForBottom <= right) { + return (left, yForLeft, xForBottom, bottom); + } else if (left <= xForTop && xForTop <= right) { + return (left, yForLeft, xForTop, top); + } + } else if (top <= yForRight && yForRight <= bottom) { + if (left <= xForTop && xForTop <= right) { + return (xForTop, top, right, yForRight); + } + if (left <= xForBottom && xForBottom <= right) { + return (xForBottom, bottom, right, yForRight); + } + } else if (left <= xForTop && + xForTop <= right && + left <= xForBottom && + xForBottom <= right) { + return (xForTop, top, xForBottom, bottom); } - }); + return null; + } - return closest.key; -} + /// Returns the intersection between the given rectangles with assumption + /// that [innerRect] is completely inside [outerRect]. The intersection is + /// calculated using the center of the [innerRect] and the corners of the + /// [outerRect]. Returns the closest edge/handle of [outerRect] to the + /// [innerRect]. + static HandlePosition intersectionBetweenRects({ + required Box outerRect, + required Box innerRect, + HandlePosition? excludeHandle, + }) { + final line1 = + extendLineToRect(outerRect, innerRect.center, innerRect.bottomRight)!; + + final line2 = + extendLineToRect(outerRect, innerRect.center, innerRect.topRight)!; + + final intersections1 = findLineIntersection( + line1.$1, + line1.$2, + outerRect, + innerRect, + ); -/// Finds the intersection between the given line and the given rectangle and -/// returns distance between the intersection and the [inner] point. -Map findLineIntersection( - Vector2 inner, Vector2 outer, Box rect, Box initialRect) { - final Vector2 topLeft = rect.topLeft; - final Vector2 topRight = rect.topRight; - final Vector2 bottomLeft = rect.bottomLeft; - final Vector2 bottomRight = rect.bottomRight; - - final Vector2? top = - intersectionBetweenTwoLines(inner, outer, topLeft, topRight); - final Vector2? bottom = - intersectionBetweenTwoLines(inner, outer, bottomLeft, bottomRight); - final Vector2? left = - intersectionBetweenTwoLines(inner, outer, topLeft, bottomLeft); - final Vector2? right = - intersectionBetweenTwoLines(inner, outer, topRight, bottomRight); - - final Map sides = { - HandlePosition.top: top, - HandlePosition.bottom: bottom, - HandlePosition.left: left, - HandlePosition.right: right, - }; - - return { - for (final entry in sides.entries) - if (entry.value != null) entry.key: entry.value! - }; -} + intersections1.remove(excludeHandle); -/// Returns the available area for the given handle. -Box getAvailableAreaForHandle({ - required Box rect, - required Box clampingRect, - required HandlePosition handle, - Constraints constraints = const Constraints.unconstrained(), -}) { - if (handle.isSide) { - final opposite = handle.opposite; - return Box.fromLTRB( - opposite.influencesLeft ? rect.left : clampingRect.left, - opposite.influencesTop ? rect.top : clampingRect.top, - opposite.influencesRight ? rect.right : clampingRect.right, - opposite.influencesBottom ? rect.bottom : clampingRect.bottom, - ); - } else { - return Box.fromLTRB( - handle.influencesLeft ? clampingRect.left : rect.left, - handle.influencesTop ? clampingRect.top : rect.top, - handle.influencesRight ? clampingRect.right : rect.right, - handle.influencesBottom ? clampingRect.bottom : rect.bottom, + final intersections2 = findLineIntersection( + line2.$1, + line2.$2, + outerRect, + innerRect, ); + + intersections2.remove(excludeHandle); + + final List> intersections = [ + ...intersections1.entries, + ...intersections2.entries, + ]; + + final closest = intersections.reduce((value, element) { + final valueDistance = value.value.distanceToSquared(innerRect.center); + final elementDistance = element.value.distanceToSquared(innerRect.center); + + if (valueDistance < elementDistance) { + return value; + } else { + return element; + } + }); + + return closest.key; } -} -/// Returns the clamping rect for the given handle for [ResizeMode.scale]. -Box getClampingRectForHandle({ - required Box initialRect, - required Box availableArea, - required HandlePosition handle, -}) { - switch (handle) { - case HandlePosition.none: - throw ArgumentError('HandlePosition.none is not supported!'); - case HandlePosition.topLeft: - case HandlePosition.topRight: - case HandlePosition.bottomLeft: - case HandlePosition.bottomRight: - return getClampingRectForCornerHandle( - initialRect: initialRect, - availableArea: availableArea, - handle: handle, + /// Finds the intersection between the given line and the given rectangle + /// and returns distance between the intersection and the [inner] point. + static Map findLineIntersection( + Vector2 inner, Vector2 outer, Box rect, Box initialRect) { + final Vector2 topLeft = rect.topLeft; + final Vector2 topRight = rect.topRight; + final Vector2 bottomLeft = rect.bottomLeft; + final Vector2 bottomRight = rect.bottomRight; + + final Vector2? top = + intersectionBetweenTwoLines(inner, outer, topLeft, topRight); + final Vector2? bottom = + intersectionBetweenTwoLines(inner, outer, bottomLeft, bottomRight); + final Vector2? left = + intersectionBetweenTwoLines(inner, outer, topLeft, bottomLeft); + final Vector2? right = + intersectionBetweenTwoLines(inner, outer, topRight, bottomRight); + + final Map sides = { + HandlePosition.top: top, + HandlePosition.bottom: bottom, + HandlePosition.left: left, + HandlePosition.right: right, + }; + + return { + for (final entry in sides.entries) + if (entry.value != null) entry.key: entry.value! + }; + } + + /// Returns the available area for the given handle. + static Box getAvailableAreaForHandle({ + required Box rect, + required Box clampingRect, + required HandlePosition handle, + Constraints constraints = const Constraints.unconstrained(), + }) { + if (handle.isSide) { + final opposite = handle.opposite; + return Box.fromLTRB( + opposite.influencesLeft ? rect.left : clampingRect.left, + opposite.influencesTop ? rect.top : clampingRect.top, + opposite.influencesRight ? rect.right : clampingRect.right, + opposite.influencesBottom ? rect.bottom : clampingRect.bottom, ); - case HandlePosition.left: - case HandlePosition.top: - case HandlePosition.right: - case HandlePosition.bottom: - return getClampingRectForSideHandle( - initialRect: initialRect, - availableArea: availableArea, - handle: handle, + } else { + return Box.fromLTRB( + handle.influencesLeft ? clampingRect.left : rect.left, + handle.influencesTop ? clampingRect.top : rect.top, + handle.influencesRight ? clampingRect.right : rect.right, + handle.influencesBottom ? clampingRect.bottom : rect.bottom, ); + } } -} -/// Returns the clamping rect for the given corner handle for [ResizeMode.scale]. -Box getClampingRectForCornerHandle({ - required Box initialRect, - required Box availableArea, - required HandlePosition handle, -}) { - final initialAspectRatio = initialRect.safeAspectRatio; - - final double areaAspectRatio = availableArea.safeAspectRatio; - - double maxWidth; - double maxHeight; - - if (areaAspectRatio > 1) { - // Clamping area: Landscape - // Box: Landscape - // Limiting factor: Width - if (initialAspectRatio > areaAspectRatio) { - maxWidth = availableArea.width; - maxHeight = maxWidth / initialAspectRatio; - } else { - // Clamping area: Landscape - // Box: Portrait - // Limiting factor: Height - maxHeight = availableArea.height; - maxWidth = maxHeight * initialAspectRatio; + /// Returns the clamping rect for the given handle for [ResizeMode.scale]. + static Box getClampingRectForHandle({ + required Box initialRect, + required Box availableArea, + required HandlePosition handle, + }) { + switch (handle) { + case HandlePosition.none: + throw ArgumentError('HandlePosition.none is not supported!'); + case HandlePosition.topLeft: + case HandlePosition.topRight: + case HandlePosition.bottomLeft: + case HandlePosition.bottomRight: + return getClampingRectForCornerHandle( + initialRect: initialRect, + availableArea: availableArea, + handle: handle, + ); + case HandlePosition.left: + case HandlePosition.top: + case HandlePosition.right: + case HandlePosition.bottom: + return getClampingRectForSideHandle( + initialRect: initialRect, + availableArea: availableArea, + handle: handle, + ); } - } else { - // Clamping area: Portrait - // Box: Landscape - // Limiting factor: Width - if (initialAspectRatio > areaAspectRatio) { - maxWidth = availableArea.width; - maxHeight = maxWidth / initialAspectRatio; + } + + /// Returns the clamping rect for the given corner handle for + /// [ResizeMode.scale]. + static Box getClampingRectForCornerHandle({ + required Box initialRect, + required Box availableArea, + required HandlePosition handle, + }) { + final initialAspectRatio = initialRect.safeAspectRatio; + + final double areaAspectRatio = availableArea.safeAspectRatio; + + double maxWidth; + double maxHeight; + + if (areaAspectRatio > 1) { + // Clamping area: Landscape + // Box: Landscape + // Limiting factor: Width + if (initialAspectRatio > areaAspectRatio) { + maxWidth = availableArea.width; + maxHeight = maxWidth / initialAspectRatio; + } else { + // Clamping area: Landscape + // Box: Portrait + // Limiting factor: Height + maxHeight = availableArea.height; + maxWidth = maxHeight * initialAspectRatio; + } } else { // Clamping area: Portrait - // Box: Portrait - // Limiting factor: Height - maxHeight = availableArea.height; - maxWidth = maxHeight * initialAspectRatio; + // Box: Landscape + // Limiting factor: Width + if (initialAspectRatio > areaAspectRatio) { + maxWidth = availableArea.width; + maxHeight = maxWidth / initialAspectRatio; + } else { + // Clamping area: Portrait + // Box: Portrait + // Limiting factor: Height + maxHeight = availableArea.height; + maxWidth = maxHeight * initialAspectRatio; + } } + + return Box.fromHandle( + handle.anchor(initialRect), + handle, + maxWidth, + maxHeight, + ); } - return Box.fromHandle( - handle.anchor(initialRect), - handle, - maxWidth, - maxHeight, - ); -} + /// Constrains available area for [ResizeMode.scale]. + static Box constrainAvailableAreaForScaling({ + required Box area, + required Box initialRect, + required HandlePosition handle, + required Constraints constraints, + }) { + if (constraints.isUnconstrained) return area; + + final maxWidth = min(constraints.maxWidth, area.width); + final maxHeight = min(constraints.maxHeight, area.height); + + final constrainedRect = Box.fromHandle( + handle.anchor(initialRect), + handle, + maxWidth, + maxHeight, + ); -/// Constrains available area for [ResizeMode.scale]. -Box constrainAvailableAreaForScaling({ - required Box area, - required Box initialRect, - required HandlePosition handle, - required Constraints constraints, -}) { - if (constraints.isUnconstrained) return area; - - final maxWidth = min(constraints.maxWidth, area.width); - final maxHeight = min(constraints.maxHeight, area.height); - - final constrainedRect = Box.fromHandle( - handle.anchor(initialRect), - handle, - maxWidth, - maxHeight, - ); - - return Box.fromLTRB( - max(constrainedRect.left, area.left), - max(constrainedRect.top, area.top), - min(constrainedRect.right, area.right), - min(constrainedRect.bottom, area.bottom), - ); -} + return Box.fromLTRB( + max(constrainedRect.left, area.left), + max(constrainedRect.top, area.top), + min(constrainedRect.right, area.right), + min(constrainedRect.bottom, area.bottom), + ); + } -/// Returns a minimum Rect for given constraints when [ResizeMode.scale]. -Box getMinRectForScaling({ - required Box initialRect, - required HandlePosition handle, - required Constraints constraints, -}) { - final double minWidth; - final double minHeight; - - if (!constraints.isUnconstrained) { - if (initialRect.safeAspectRatio < 1) { - minWidth = constraints.minWidth; - minHeight = minWidth / initialRect.safeAspectRatio; + /// Returns a minimum Rect for given constraints when [ResizeMode.scale]. + static Box getMinRectForScaling({ + required Box initialRect, + required HandlePosition handle, + required Constraints constraints, + }) { + final double minWidth; + final double minHeight; + + if (!constraints.isUnconstrained) { + if (initialRect.safeAspectRatio < 1) { + minWidth = constraints.minWidth; + minHeight = minWidth / initialRect.safeAspectRatio; + } else { + minHeight = constraints.minHeight; + minWidth = minHeight * initialRect.safeAspectRatio; + } } else { - minHeight = constraints.minHeight; - minWidth = minHeight * initialRect.safeAspectRatio; + minWidth = 0; + minHeight = 0; } - } else { - minWidth = 0; - minHeight = 0; + + return Box.fromHandle( + handle.anchor(initialRect), + handle, + minWidth, + minHeight, + ); } - return Box.fromHandle( - handle.anchor(initialRect), - handle, - minWidth, - minHeight, - ); -} + /// Returns whether the given [rect] is properly confined within its + /// [constraints] but at the same time is not outside of the [clampingRect]. + static bool isValidRect(Box rect, Constraints constraints, Box clampingRect) { + if (clampingRect.left.roundToPrecision(4) > rect.left.roundToPrecision(4) || + clampingRect.top.roundToPrecision(4) > rect.top.roundToPrecision(4) || + clampingRect.right.roundToPrecision(4) < + rect.right.roundToPrecision(4) || + clampingRect.bottom.roundToPrecision(4) < + rect.bottom.roundToPrecision(4)) { + return false; + } + if (!constraints.isUnconstrained) { + if (rect.width.roundToPrecision(4) < + constraints.minWidth.roundToPrecision(4) || + rect.width.roundToPrecision(4) > + constraints.maxWidth.roundToPrecision(4) || + rect.height.roundToPrecision(4) < + constraints.minHeight.roundToPrecision(4) || + rect.height.roundToPrecision(4) > + constraints.maxHeight.roundToPrecision(4)) { + return false; + } + } + return true; + } -/// [returns] whether the given [rect] is properly confined within its -/// [constraints] but at the same time is not outside of the [clampingRect]. -bool isValidRect(Box rect, Constraints constraints, Box clampingRect) { - if (clampingRect.left.roundToPrecision(4) > rect.left.roundToPrecision(4) || - clampingRect.top.roundToPrecision(4) > rect.top.roundToPrecision(4) || - clampingRect.right.roundToPrecision(4) < rect.right.roundToPrecision(4) || - clampingRect.bottom.roundToPrecision(4) < - rect.bottom.roundToPrecision(4)) { - return false; + // Per-isolate memo for cos/sin keyed by the most recent theta. A single + // rotated transform op invokes [rotatePointAround] 4-5 times with the + // same [theta]; caching skips redundant trig. NaN inputs miss cleanly + // because `NaN == NaN` is false in Dart, forcing recomputation. + static double _cachedTheta = double.nan; + static double _cachedCos = 0.0; + static double _cachedSin = 0.0; + + /// Rotates [point] by [theta] radians around [pivot] (clockwise). + /// + /// When [theta] is 0, returns a copy of [point] unchanged (fast-path). + static Vector2 rotatePointAround(Vector2 point, Vector2 pivot, double theta) { + if (theta == 0.0) return Vector2.copy(point); + final dx = point.x - pivot.x; + final dy = point.y - pivot.y; + double c; + double s; + if (theta == _cachedTheta) { + c = _cachedCos; + s = _cachedSin; + } else { + c = cos(theta); + s = sin(theta); + _cachedTheta = theta; + _cachedCos = c; + _cachedSin = s; + } + return Vector2( + pivot.x + dx * c - dy * s, + pivot.y + dx * s + dy * c, + ); } - if (!constraints.isUnconstrained) { - if (rect.width.roundToPrecision(4) < - constraints.minWidth.roundToPrecision(4) || - rect.width.roundToPrecision(4) > - constraints.maxWidth.roundToPrecision(4) || - rect.height.roundToPrecision(4) < - constraints.minHeight.roundToPrecision(4) || - rect.height.roundToPrecision(4) > - constraints.maxHeight.roundToPrecision(4)) { - return false; + + /// Converts [worldPoint] from world space into the box's unrotated-local + /// frame. + /// + /// The box is rotated by [theta] around [pivot] (the pivot is in world + /// coords). Inverse of [unrotatedToWorld]. + static Vector2 worldToUnrotated( + Vector2 worldPoint, Vector2 pivot, double theta) => + rotatePointAround(worldPoint, pivot, -theta); + + /// Converts [localPoint] from the box's unrotated-local frame into world + /// space. + /// + /// The box is rotated by [theta] around [pivot] (the pivot is in world + /// coords). Inverse of [worldToUnrotated]. + static Vector2 unrotatedToWorld( + Vector2 localPoint, Vector2 pivot, double theta) => + rotatePointAround(localPoint, pivot, theta); + + /// Returns the axis-aligned bounding rectangle of [box] after applying + /// its [Box.rotation] around its center. + /// + /// For `box.rotation == 0`, returns an AABB copy of [box]. The returned + /// [Box] has `rotation = 0`. + static Box calculateBoundingRect(Box box) { + if (box.rotation == 0.0) { + return Box.fromLTRB(box.left, box.top, box.right, box.bottom); } + final w = box.width; + final h = box.height; + final cx = box.left + w / 2; + final cy = box.top + h / 2; + final theta = box.rotation; + final double c; + final double s; + if (theta == _cachedTheta) { + c = _cachedCos; + s = _cachedSin; + } else { + c = cos(theta); + s = sin(theta); + _cachedTheta = theta; + _cachedCos = c; + _cachedSin = s; + } + final absCos = c.abs(); + final absSin = s.abs(); + final boundingW = w * absCos + h * absSin; + final boundingH = w * absSin + h * absCos; + return Box.fromLTWH( + cx - boundingW / 2, + cy - boundingH / 2, + boundingW, + boundingH, + ); } - return true; } diff --git a/packages/box_transform/lib/src/resizers/freeform_resizing.dart b/packages/box_transform/lib/src/resizers/freeform_resizing.dart index 811fc7c..bc590bf 100644 --- a/packages/box_transform/lib/src/resizers/freeform_resizing.dart +++ b/packages/box_transform/lib/src/resizers/freeform_resizing.dart @@ -15,7 +15,7 @@ final class FreeformResizer extends Resizer { required Flip flip, }) { final flippedHandle = handle.flip(flip); - Box effectiveInitialRect = flipRect(initialRect, flip, handle); + Box effectiveInitialRect = ClampHelpers.flipRect(initialRect, flip, handle); Box newRect = Box.fromLTRB( max(explodedRect.left, clampingRect.left), @@ -38,7 +38,7 @@ final class FreeformResizer extends Resizer { constrainedHeight, ); - isValid = isValidRect(newRect, constraints, clampingRect); + isValid = ClampHelpers.isValidRect(newRect, constraints, clampingRect); if (!isValid) { newRect = Box.fromHandle( handle.anchor(initialRect), @@ -54,7 +54,7 @@ final class FreeformResizer extends Resizer { } // Not used but calculating it for returning correct largest box. - final Box area = getAvailableAreaForHandle( + final Box area = ClampHelpers.getAvailableAreaForHandle( rect: isValid ? effectiveInitialRect : initialRect, handle: isValid ? flippedHandle : handle, clampingRect: clampingRect, diff --git a/packages/box_transform/lib/src/resizers/scale_resizing.dart b/packages/box_transform/lib/src/resizers/scale_resizing.dart index 7d52a27..659cd12 100644 --- a/packages/box_transform/lib/src/resizers/scale_resizing.dart +++ b/packages/box_transform/lib/src/resizers/scale_resizing.dart @@ -65,7 +65,8 @@ final class ScaleResizer extends Resizer { required Flip flip, }) { final flippedHandle = handle.flip(flip); - final effectiveInitialRect = flipRect(initialRect, flip, handle); + final effectiveInitialRect = + ClampHelpers.flipRect(initialRect, flip, handle); ({Box rect, Box largest, bool hasValidFlip}) result; @@ -119,14 +120,14 @@ final class ScaleResizer extends Resizer { Constraints constraints, Flip flip, ) { - Box area = getAvailableAreaForHandle( + Box area = ClampHelpers.getAvailableAreaForHandle( rect: initialRect, clampingRect: clampingRect, handle: handle, constraints: constraints, ); - area = constrainAvailableAreaForScaling( + area = ClampHelpers.constrainAvailableAreaForScaling( area: area, initialRect: initialRect, handle: handle, @@ -146,13 +147,13 @@ final class ScaleResizer extends Resizer { rectHeight = rectWidth / initialAspectRatio; } - final maxRect = getClampingRectForHandle( + final maxRect = ClampHelpers.getClampingRectForHandle( initialRect: initialRect, availableArea: area, handle: handle, ); - final minRect = getMinRectForScaling( + final minRect = ClampHelpers.getMinRectForScaling( initialRect: initialRect, handle: handle, constraints: constraints, @@ -174,7 +175,7 @@ final class ScaleResizer extends Resizer { largest = maxRect; } - final isValid = isValidRect(rect, constraints, clampingRect); + final isValid = ClampHelpers.isValidRect(rect, constraints, clampingRect); return (rect: rect, largest: largest, hasValidFlip: isValid); } @@ -190,14 +191,14 @@ final class ScaleResizer extends Resizer { ) { final initialAspectRatio = initialRect.safeAspectRatio; - Box area = getAvailableAreaForHandle( + Box area = ClampHelpers.getAvailableAreaForHandle( rect: initialRect, clampingRect: clampingRect, handle: handle, constraints: constraints, ); - area = constrainAvailableAreaForScaling( + area = ClampHelpers.constrainAvailableAreaForScaling( area: area, initialRect: initialRect, handle: handle, @@ -217,13 +218,13 @@ final class ScaleResizer extends Resizer { ); } - final maxRect = getClampingRectForHandle( + final maxRect = ClampHelpers.getClampingRectForHandle( initialRect: initialRect, availableArea: area, handle: handle, ); - final minRect = getMinRectForScaling( + final minRect = ClampHelpers.getMinRectForScaling( initialRect: initialRect, handle: handle, constraints: constraints, @@ -250,7 +251,7 @@ final class ScaleResizer extends Resizer { largest = maxRect; } - final isValid = isValidRect(rect, constraints, clampingRect); + final isValid = ClampHelpers.isValidRect(rect, constraints, clampingRect); return (rect: rect, largest: largest, hasValidFlip: isValid); } @@ -266,14 +267,14 @@ final class ScaleResizer extends Resizer { ) { final initialAspectRatio = initialRect.safeAspectRatio; - Box area = getAvailableAreaForHandle( + Box area = ClampHelpers.getAvailableAreaForHandle( rect: initialRect, clampingRect: clampingRect, handle: handle, constraints: constraints, ); - area = constrainAvailableAreaForScaling( + area = ClampHelpers.constrainAvailableAreaForScaling( area: area, initialRect: initialRect, handle: handle, @@ -293,13 +294,13 @@ final class ScaleResizer extends Resizer { ); } - final maxRect = getClampingRectForHandle( + final maxRect = ClampHelpers.getClampingRectForHandle( initialRect: initialRect, availableArea: area, handle: handle, ); - final minRect = getMinRectForScaling( + final minRect = ClampHelpers.getMinRectForScaling( initialRect: initialRect, handle: handle, constraints: constraints, @@ -326,7 +327,7 @@ final class ScaleResizer extends Resizer { largest = maxRect; } - final isValid = isValidRect(rect, constraints, clampingRect); + final isValid = ClampHelpers.isValidRect(rect, constraints, clampingRect); return (rect: rect, largest: largest, hasValidFlip: isValid); } @@ -342,14 +343,14 @@ final class ScaleResizer extends Resizer { ) { final initialAspectRatio = initialRect.safeAspectRatio; - Box area = getAvailableAreaForHandle( + Box area = ClampHelpers.getAvailableAreaForHandle( rect: initialRect, clampingRect: clampingRect, handle: handle, constraints: constraints, ); - area = constrainAvailableAreaForScaling( + area = ClampHelpers.constrainAvailableAreaForScaling( area: area, initialRect: initialRect, handle: handle, @@ -369,13 +370,13 @@ final class ScaleResizer extends Resizer { ); } - final maxRect = getClampingRectForHandle( + final maxRect = ClampHelpers.getClampingRectForHandle( initialRect: initialRect, availableArea: area, handle: handle, ); - final minRect = getMinRectForScaling( + final minRect = ClampHelpers.getMinRectForScaling( initialRect: initialRect, handle: handle, constraints: constraints, @@ -402,7 +403,7 @@ final class ScaleResizer extends Resizer { largest = maxRect; } - final isValid = isValidRect(rect, constraints, clampingRect); + final isValid = ClampHelpers.isValidRect(rect, constraints, clampingRect); return (rect: rect, largest: largest, hasValidFlip: isValid); } @@ -418,14 +419,14 @@ final class ScaleResizer extends Resizer { ) { final initialAspectRatio = initialRect.safeAspectRatio; - Box area = getAvailableAreaForHandle( + Box area = ClampHelpers.getAvailableAreaForHandle( rect: initialRect, clampingRect: clampingRect, handle: handle, constraints: constraints, ); - area = constrainAvailableAreaForScaling( + area = ClampHelpers.constrainAvailableAreaForScaling( area: area, initialRect: initialRect, handle: handle, @@ -445,13 +446,13 @@ final class ScaleResizer extends Resizer { ); } - final maxRect = getClampingRectForHandle( + final maxRect = ClampHelpers.getClampingRectForHandle( initialRect: initialRect, availableArea: area, handle: handle, ); - final minRect = getMinRectForScaling( + final minRect = ClampHelpers.getMinRectForScaling( initialRect: initialRect, handle: handle, constraints: constraints, @@ -478,7 +479,7 @@ final class ScaleResizer extends Resizer { largest = maxRect; } - final isValid = isValidRect(rect, constraints, clampingRect); + final isValid = ClampHelpers.isValidRect(rect, constraints, clampingRect); return (rect: rect, largest: largest, hasValidFlip: isValid); } @@ -493,14 +494,14 @@ final class ScaleResizer extends Resizer { Constraints constraints, Flip flip, ) { - Box area = getAvailableAreaForHandle( + Box area = ClampHelpers.getAvailableAreaForHandle( rect: initialRect, clampingRect: clampingRect, handle: handle, constraints: constraints, ); - area = constrainAvailableAreaForScaling( + area = ClampHelpers.constrainAvailableAreaForScaling( area: area, initialRect: initialRect, handle: handle, @@ -520,13 +521,13 @@ final class ScaleResizer extends Resizer { rectHeight = rectWidth / initialAspectRatio; } - final maxRect = getClampingRectForHandle( + final maxRect = ClampHelpers.getClampingRectForHandle( initialRect: initialRect, availableArea: area, handle: handle, ); - final minRect = getMinRectForScaling( + final minRect = ClampHelpers.getMinRectForScaling( initialRect: initialRect, handle: handle, constraints: constraints, @@ -548,7 +549,7 @@ final class ScaleResizer extends Resizer { largest = maxRect; } - final isValid = isValidRect(rect, constraints, clampingRect); + final isValid = ClampHelpers.isValidRect(rect, constraints, clampingRect); return (rect: rect, largest: largest, hasValidFlip: isValid); } @@ -563,14 +564,14 @@ final class ScaleResizer extends Resizer { Constraints constraints, Flip flip, ) { - Box area = getAvailableAreaForHandle( + Box area = ClampHelpers.getAvailableAreaForHandle( rect: initialRect, clampingRect: clampingRect, handle: handle, constraints: constraints, ); - area = constrainAvailableAreaForScaling( + area = ClampHelpers.constrainAvailableAreaForScaling( area: area, initialRect: initialRect, handle: handle, @@ -590,13 +591,13 @@ final class ScaleResizer extends Resizer { rectHeight = rectWidth / initialAspectRatio; } - final maxRect = getClampingRectForHandle( + final maxRect = ClampHelpers.getClampingRectForHandle( initialRect: initialRect, availableArea: area, handle: handle, ); - final minRect = getMinRectForScaling( + final minRect = ClampHelpers.getMinRectForScaling( initialRect: initialRect, handle: handle, constraints: constraints, @@ -618,7 +619,7 @@ final class ScaleResizer extends Resizer { largest = maxRect; } - final isValid = isValidRect(rect, constraints, clampingRect); + final isValid = ClampHelpers.isValidRect(rect, constraints, clampingRect); return (rect: rect, largest: largest, hasValidFlip: isValid); } @@ -633,14 +634,14 @@ final class ScaleResizer extends Resizer { Constraints constraints, Flip flip, ) { - Box area = getAvailableAreaForHandle( + Box area = ClampHelpers.getAvailableAreaForHandle( rect: initialRect, clampingRect: clampingRect, handle: handle, constraints: constraints, ); - area = constrainAvailableAreaForScaling( + area = ClampHelpers.constrainAvailableAreaForScaling( area: area, initialRect: initialRect, handle: handle, @@ -660,13 +661,13 @@ final class ScaleResizer extends Resizer { rectHeight = rectWidth / initialAspectRatio; } - final maxRect = getClampingRectForHandle( + final maxRect = ClampHelpers.getClampingRectForHandle( initialRect: initialRect, availableArea: area, handle: handle, ); - final minRect = getMinRectForScaling( + final minRect = ClampHelpers.getMinRectForScaling( initialRect: initialRect, handle: handle, constraints: constraints, @@ -688,7 +689,7 @@ final class ScaleResizer extends Resizer { largest = maxRect; } - final isValid = isValidRect(rect, constraints, clampingRect); + final isValid = ClampHelpers.isValidRect(rect, constraints, clampingRect); return (rect: rect, largest: largest, hasValidFlip: isValid); } diff --git a/packages/box_transform/lib/src/resizers/symmetric_scale_resizing.dart b/packages/box_transform/lib/src/resizers/symmetric_scale_resizing.dart index d5ef597..817a1b6 100644 --- a/packages/box_transform/lib/src/resizers/symmetric_scale_resizing.dart +++ b/packages/box_transform/lib/src/resizers/symmetric_scale_resizing.dart @@ -70,7 +70,8 @@ final class SymmetricScaleResizer extends Resizer { availableArea = clampingRect; } - final maxRect = scaledSymmetricClampingRect(initialRect, availableArea); + final maxRect = + ClampHelpers.scaledSymmetricClampingRect(initialRect, availableArea); final Box minRect; if (!constraints.isUnconstrained) { @@ -151,7 +152,8 @@ final class SymmetricScaleResizer extends Resizer { availableArea = clampingRect; } - final maxRect = scaledSymmetricClampingRect(initialRect, availableArea); + final maxRect = + ClampHelpers.scaledSymmetricClampingRect(initialRect, availableArea); final Box minRect; if (!constraints.isUnconstrained) { diff --git a/packages/box_transform/lib/src/result.dart b/packages/box_transform/lib/src/result.dart index 28cde93..ff94474 100644 --- a/packages/box_transform/lib/src/result.dart +++ b/packages/box_transform/lib/src/result.dart @@ -1,4 +1,4 @@ -import 'package:vector_math/vector_math.dart'; +import 'package:vector_math/vector_math_64.dart'; import 'enums.dart'; import 'geometry.dart'; @@ -16,6 +16,9 @@ typedef RawResizeResult = ResizeResult; /// [Dimension] as the generic types that is used by [BoxTransformer]. typedef RawTransformResult = TransformResult; +/// A convenient typedef for a rotate result with core Dart types. +typedef RawRotateResult = RotateResult; + /// An abstract class that represents the result of a transform operation. abstract class RectResult { /// Creates a [RectResult]. @@ -38,7 +41,7 @@ abstract class RectResult { /// usually [Box] or [Rect]. It represents the bounds of a rectangle. /// /// V is the type of the [Vector2] that is used by the [BoxTransformer]. This is -/// usually [Vector2] or [Offset]. It represents the delta of the transform. +/// usually [Vector2] or `Offset`. It represents the delta of the transform. /// /// D is the type of the [Dimension] that is used by the [BoxTransformer]. This /// is usually [Dimension] or [Size]. It represents the size of the rect. @@ -59,9 +62,9 @@ class TransformResult /// The [ResizeMode] of the node after the resize. final ResizeMode resizeMode; - /// The new [Dimension] of the node after the resize. Unlike [newRect], this + /// The new [Dimension] of the node after the resize. Unlike [rect], this /// reflects flip state. For example, if the node is flipped horizontally, - /// the width of the [newSize] will be negative. + /// the width of [rawSize] will be negative. final D rawSize; /// Whether the resizing rect hit its maximum possible width. @@ -83,6 +86,17 @@ class TransformResult /// Handle used to resize the rect. final HandlePosition handle; + /// Rotation (radians) of the box after the transform. Zero = axis-aligned. + final double rotation; + + /// Axis-aligned bounding box of the rotated [rect]. When [rotation] is 0, + /// this equals [rect]. + final B boundingRect; + + /// Axis-aligned bounding box of the rotated [oldRect]. When [rotation] is 0, + /// this equals [oldRect]. + final B oldBoundingRect; + /// Creates a [ResizeResult] object. const TransformResult({ required this.rect, @@ -97,7 +111,11 @@ class TransformResult required this.maxHeightReached, required this.largestRect, required this.handle, - }); + this.rotation = 0.0, + B? boundingRect, + B? oldBoundingRect, + }) : boundingRect = boundingRect ?? rect, + oldBoundingRect = oldBoundingRect ?? oldRect; @override bool operator ==(Object other) { @@ -115,7 +133,10 @@ class TransformResult other.minHeightReached == minHeightReached && other.maxHeightReached == maxHeightReached && other.largestRect == largestRect && - other.handle == handle; + other.handle == handle && + other.rotation == rotation && + other.boundingRect == boundingRect && + other.oldBoundingRect == oldBoundingRect; } @override @@ -132,6 +153,9 @@ class TransformResult maxHeightReached, largestRect, handle, + rotation, + boundingRect, + oldBoundingRect, ); @override @@ -147,7 +171,10 @@ class TransformResult 'minHeightReached: $minHeightReached, ' 'maxHeightReached: $maxHeightReached, ' 'largestBox: $largestRect, ' - 'handle: $handle' + 'handle: $handle, ' + 'rotation: $rotation, ' + 'boundingRect: $boundingRect, ' + 'oldBoundingRect: $oldBoundingRect' ')'; } @@ -162,6 +189,9 @@ class MoveResult required super.delta, required super.rawSize, required super.largestRect, + super.rotation = 0.0, + super.boundingRect, + super.oldBoundingRect, }) : super( flip: Flip.none, resizeMode: ResizeMode.freeform, @@ -186,6 +216,14 @@ class MoveResult /// An object that represents the result of a resize operation. class ResizeResult extends TransformResult { + /// Whether the requested resize could be honored under the supplied + /// `clampingRect`, `constraints`, and `bindingStrategy`. When `false`, + /// [rect] and [flip] equal the gesture-start values: the engine cannot + /// satisfy the target without leaking the clamp. Callers (controllers) + /// should skip their state update on `false`, holding the rect at the + /// last feasible state instead of snapping back to gesture start. + final bool feasible; + /// Creates a [ResizeResult] object. const ResizeResult({ required super.rect, @@ -200,8 +238,19 @@ class ResizeResult required super.maxHeightReached, required super.largestRect, required super.handle, + super.rotation = 0.0, + super.boundingRect, + super.oldBoundingRect, + this.feasible = true, }); + @override + bool operator ==(Object other) => + super == other && other is ResizeResult && other.feasible == feasible; + + @override + int get hashCode => Object.hash(super.hashCode, feasible); + @override String toString() => 'ResizeResult(' 'rect: $rect, ' @@ -215,6 +264,53 @@ class ResizeResult 'minHeightReached: $minHeightReached, ' 'maxHeightReached: $maxHeightReached, ' 'largestBox: $largestRect, ' - 'handle: $handle' + 'handle: $handle, ' + 'feasible: $feasible' ')'; } + +/// Result of a rotate-only operation. +class RotateResult + extends TransformResult { + /// Whether the requested rotation could be honored under the supplied + /// `clampingRect` and `bindingStrategy`. When `false`, [rotation] equals + /// the gesture-start rotation and [rect] equals the gesture-start rect: + /// the rotation was rejected because no translation could keep the box + /// inside the clamp at the desired angle. Callers (e.g. controllers) + /// should skip their state update on `false` so the rotation effectively + /// freezes at the cap. + final bool feasible; + + /// Creates a [RotateResult]. + const RotateResult({ + required super.rect, + required super.oldRect, + required super.delta, + required super.rawSize, + required super.largestRect, + required super.rotation, + super.boundingRect, + super.oldBoundingRect, + this.feasible = true, + }) : super( + flip: Flip.none, + resizeMode: ResizeMode.freeform, + minWidthReached: false, + maxWidthReached: false, + minHeightReached: false, + maxHeightReached: false, + handle: HandlePosition.none, + ); + + @override + bool operator ==(Object other) => + super == other && other is RotateResult && other.feasible == feasible; + + @override + int get hashCode => Object.hash(super.hashCode, feasible); + + @override + String toString() => + 'RotateResult(rect: $rect, oldRect: $oldRect, rotation: $rotation, ' + 'feasible: $feasible)'; +} diff --git a/packages/box_transform/lib/src/rotated_clamping_solver.dart b/packages/box_transform/lib/src/rotated_clamping_solver.dart new file mode 100644 index 0000000..e397b41 --- /dev/null +++ b/packages/box_transform/lib/src/rotated_clamping_solver.dart @@ -0,0 +1,687 @@ +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:vector_math/vector_math_64.dart'; + +import 'enums.dart'; +import 'geometry.dart'; + +// --------------------------------------------------------------------------- +// Flat-buffer LP +// --------------------------------------------------------------------------- +// +// The rotated-clamping LP runs on a flat [Float64List] of (a, b, c) triples +// with no per-call allocation. Builders write into a reused [IneqBuffer]; +// the projector consumes them and writes its result into a reused +// [FlatProjection]. +// +// Layout: index `3*i + 0` = a_i, `3*i + 1` = b_i, `3*i + 2` = c_i. +// Builders emit up to 4 corners × up to 4 axis-aligned edges per corner = +// 16 inequalities; trivially-zero rows (a≈0 and b≈0) are dropped in-place +// so the projector skips no-op constraints. + +/// Capacity (number of inequalities) of an [IneqBuffer] data slab. +/// +/// 16 is the worst-case row count emitted by any builder +/// ([RotatedClampingSolver.buildCornerIneqsInto], +/// [RotatedClampingSolver.buildSideHandleIneqsInto], +/// [RotatedClampingSolver.buildCenterIneqsInto]): 4 corners × up to 4 +/// axis-aligned edges per corner. +const int _kMaxIneqs = 16; + +/// Mutable inequality batch reused by the rotated-clamping hot path. +/// +/// Builders ([RotatedClampingSolver.buildCornerIneqsInto], +/// [RotatedClampingSolver.buildSideHandleIneqsInto], +/// [RotatedClampingSolver.buildCenterIneqsInto]) write coefficient +/// triples into [data] and set [count]; +/// [RotatedClampingSolver.projectOntoFeasibleRegionFlat] consumes them. +/// Single-isolate scratch reuse keeps the steady-state path +/// allocation-free. +class IneqBuffer { + /// Flat (a, b, c) triples, length [_kMaxIneqs] * 3. + final Float64List data; + + /// Number of inequalities populated in [data] (0..[_kMaxIneqs]). + int count; + + /// Creates a fresh empty buffer with capacity [_kMaxIneqs]. + IneqBuffer() + : data = Float64List(_kMaxIneqs * 3), + count = 0; + + /// Resets the buffer to empty. + void clear() { + count = 0; + } +} + +/// Holds the result of an in-place flat projection. Reused across calls +/// (caller-owned scalar bag) to avoid per-call allocation. +class FlatProjection { + /// Feasible width. + double w = 0; + + /// Feasible height. + double h = 0; + + /// Whether [w] is pinned to the minimum bound. + bool wMinHit = false; + + /// Whether [w] is pinned to the maximum bound. + bool wMaxHit = false; + + /// Whether [h] is pinned to the minimum bound. + bool hMinHit = false; + + /// Whether [h] is pinned to the maximum bound. + bool hMaxHit = false; + + /// True iff the final (w, h) satisfies every inequality within tolerance. + /// When false, the projector returned its closest-feasible best-effort + /// — using that (w, h) directly produces a rect that leaks past the + /// clamp by `worstResidual` pixels. Callers must treat this as a freeze + /// signal (return initialRect) instead of using w/h. + bool feasible = true; + + /// Worst over-violation across all inequalities at the returned (w, h). + /// Zero when feasible; in pixels otherwise. + double worstResidual = 0; + + /// Creates an empty projection result. All fields default to a + /// zero-feasible state: `w = h = 0`, no axes pinned, [feasible] true, + /// [worstResidual] zero. Reuse one instance across calls to avoid + /// per-tick allocation; the projector overwrites every field. + FlatProjection(); +} + +/// Static surface for the rotated-clamping LP entry points. +/// +/// All four operations write into / read from caller-owned scratch +/// buffers ([IneqBuffer], [FlatProjection]) so the steady-state path +/// allocates nothing. Builders fill the inequality buffer with `a*w + b*h +/// <= c` rows; the projector finds the feasible `(w, h)` closest to a +/// target. +abstract final class RotatedClampingSolver { + const RotatedClampingSolver._(); + + /// Fills [buf] with the corner-anchored inequalities. + /// + /// Used for **corner-anchored** resizes (freeform, scale). The anchor is + /// the fixed corner (the handle's opposite corner), expressed in world + /// coords. [widthSign] and [heightSign] encode which side of [anchor] the + /// box extends on (±1 each). + /// + /// Which corners are enforced depends on [bindingStrategy]: + /// + /// * [BindingStrategy.boundingBox]: the rotated rect's four corners + /// only (rendered footprint stays in the clamp). + /// + /// C0 = anchor + /// C1 = anchor + sx*w*(cos, sin) + /// C2 = anchor + sx*w*(cos, sin) + sy*h*(-sin, cos) + /// C3 = anchor + sy*h*(-sin, cos) + /// + /// * [BindingStrategy.originalBox]: the unrotated rect's four + /// axis-aligned corners only. The rendered rotated corners may extend + /// beyond the clamp by design. + /// + /// center = anchor + (sx*w/2)*(cos, sin) + (sy*h/2)*(-sin, cos) + /// corner = center + (sgnX*w/2, sgnY*h/2) for sgnX, sgnY ∈ {-1, +1} + /// + /// At θ = 0 both strategies collapse to the same axis-aligned + /// constraints; at θ ≠ 0 they enforce different geometries and neither + /// set is a subset of the other. Coefficients common across the four + /// corners (e.g. `sx*cos`, `sx*sin`) are factored out of the loop. + static void buildCornerIneqsInto( + IneqBuffer buf, { + required Vector2 anchor, + required double theta, + required Box clampingRect, + required BindingStrategy bindingStrategy, + double widthSign = 1.0, + double heightSign = 1.0, + }) { + final cos = math.cos(theta); + final sin = math.sin(theta); + final ax = anchor.x; + final ay = anchor.y; + final lc = clampingRect.left; + final tc = clampingRect.top; + final rc = clampingRect.right; + final bc = clampingRect.bottom; + final sx = widthSign; + final sy = heightSign; + + // Pre-compute constants used by both strategies. + final sxCos = sx * cos; + final sxSin = sx * sin; + final syCos = sy * cos; + final negSySin = -sy * sin; + + final cRight = rc - ax; // for Cx <= rc -> aW*w + aH*h <= rc - ax + final cLeft = ax - lc; // for Cx >= lc -> -aW*w - aH*h <= ax - lc + final cBot = bc - ay; // for Cy <= bc + final cTop = ay - tc; // for Cy >= tc + + final data = buf.data; + int n = 0; + void addEdges(double aW, double aH, double bW, double bH) { + // Skip the trivially-zero row (corner C0 in boundingBox case has + // aW=aH=0, bW=bH=0 and produces no useful constraint). We replicate + // the original behavior of dropping rows where both a and b are + // negligible. + if (aW.abs() <= 1e-12 && aH.abs() <= 1e-12) { + // x-edges have a=b=0; skip both of them. + } else { + // Cx <= rc + data[3 * n] = aW; + data[3 * n + 1] = aH; + data[3 * n + 2] = cRight; + n++; + // Cx >= lc + data[3 * n] = -aW; + data[3 * n + 1] = -aH; + data[3 * n + 2] = cLeft; + n++; + } + if (bW.abs() <= 1e-12 && bH.abs() <= 1e-12) { + // y-edges have a=b=0; skip both of them. + } else { + // Cy <= bc + data[3 * n] = bW; + data[3 * n + 1] = bH; + data[3 * n + 2] = cBot; + n++; + // Cy >= tc + data[3 * n] = -bW; + data[3 * n + 1] = -bH; + data[3 * n + 2] = cTop; + n++; + } + } + + if (bindingStrategy == BindingStrategy.boundingBox) { + // Rotated rect corners only: C0 (anchor, all-zero coefficients), + // C1, C2, C3. The rendered rotated polygon must stay in the clamp; + // the unrotated stored rect is invisible storage and need not be in + // the clamp. C0: aW=aH=bW=bH=0 → all four edges are degenerate, so + // addEdges produces nothing (still legal). We pass through to keep + // code uniform. + addEdges(0, 0, 0, 0); // C0 + addEdges(sxCos, 0, sxSin, 0); // C1 = anchor + sx*w*e1 + addEdges(sxCos, negSySin, sxSin, syCos); // C2 = + sx*w*e1 + sy*h*e2 + addEdges(0, negSySin, 0, syCos); // C3 = + sy*h*e2 + } else { + // originalBox: unrotated rect corners, anchored via the rect's center. + // center = anchor + (sx*w/2)*(cos, sin) + (sy*h/2)*(-sin, cos) + // corner = center + (sgnX*w/2, sgnY*h/2) + // corner.x = ax + w*(sx*cos/2 + sgnX/2) + h*(-sy*sin/2) + // corner.y = ay + w*(sx*sin/2) + h*(sy*cos/2 + sgnY/2) + final halfSxCos = sxCos * 0.5; // sx*cos/2 + final halfSxSin = sxSin * 0.5; // sx*sin/2 + final halfSyCos = syCos * 0.5; // sy*cos/2 + final halfNegSySin = negSySin * 0.5; // -sy*sin/2 + + // Four (sgnX, sgnY) combinations: (-1,-1), (-1,+1), (+1,-1), (+1,+1). + // We unroll for clarity and to avoid loop overhead. + // sgnX = -1, sgnY = -1 + addEdges(halfSxCos - 0.5, halfNegSySin, halfSxSin, halfSyCos - 0.5); + // sgnX = -1, sgnY = +1 + addEdges(halfSxCos - 0.5, halfNegSySin, halfSxSin, halfSyCos + 0.5); + // sgnX = +1, sgnY = -1 + addEdges(halfSxCos + 0.5, halfNegSySin, halfSxSin, halfSyCos - 0.5); + // sgnX = +1, sgnY = +1 + addEdges(halfSxCos + 0.5, halfNegSySin, halfSxSin, halfSyCos + 0.5); + } + + buf.count = n; + } + + /// Fills [buf] with side-handle inequalities for a rect anchored at the + /// midpoint of the side opposite [handle]. + /// + /// Unlike the corner builder ([buildCornerIneqsInto]), the rect's anchor + /// is *not* one of its corners — it's a side midpoint. The default + /// (unflipped) rect spans: + /// + /// * `bottom`: rect-local `x ∈ [-w/2, w/2]`, `y ∈ [0, h]` (anchor = topCenter) + /// * `top`: `x ∈ [-w/2, w/2]`, `y ∈ [-h, 0]` (anchor = bottomCenter) + /// * `right`: `x ∈ [0, w]`, `y ∈ [-h/2, h/2]` (anchor = centerLeft) + /// * `left`: `x ∈ [-w, 0]`, `y ∈ [-h/2, h/2]` (anchor = centerRight) + /// + /// [widthSign] and [heightSign] flip the active-axis extent under + /// force-flip (drag past anchor): `right` with `widthSign=-1` becomes + /// `x ∈ [-w, 0]`, `top` with `heightSign=+1` becomes `y ∈ [0, h]`, etc. + /// The convention matches [buildCornerIneqsInto]: "+1 means rect extends + /// in the +e1/+e2 direction from anchor". Unflipped values are right→+1, + /// left→−1, top→−1, bottom→+1. + /// + /// Which corners are enforced depends on [bindingStrategy]: + /// + /// * [BindingStrategy.boundingBox]: the rotated rect's four corners + /// only (rendered footprint stays inside the clamp). Up to 16 edge + /// inequalities. + /// * [BindingStrategy.originalBox]: the unrotated rect's four + /// axis-aligned corners only (the unrotated stored rect stays inside + /// the clamp; rotated corners may extend outside). Up to 16 edge + /// inequalities. + /// + /// At θ = 0 both strategies collapse to identical axis-aligned + /// constraints. At θ ≠ 0 they enforce different geometries. + static void buildSideHandleIneqsInto( + IneqBuffer buf, { + required Vector2 anchor, + required double theta, + required Box clampingRect, + required HandlePosition handle, + required BindingStrategy bindingStrategy, + double widthSign = 1.0, + double heightSign = 1.0, + }) { + final cos = math.cos(theta); + final sin = math.sin(theta); + final ax = anchor.x; + final ay = anchor.y; + final lc = clampingRect.left; + final tc = clampingRect.top; + final rc = clampingRect.right; + final bc = clampingRect.bottom; + + final cRight = rc - ax; + final cLeft = ax - lc; + final cBot = bc - ay; + final cTop = ay - tc; + + final data = buf.data; + int n = 0; + void addEdges(double aW, double aH, double bW, double bH) { + if (aW.abs() > 1e-12 || aH.abs() > 1e-12) { + data[3 * n] = aW; + data[3 * n + 1] = aH; + data[3 * n + 2] = cRight; + n++; + data[3 * n] = -aW; + data[3 * n + 1] = -aH; + data[3 * n + 2] = cLeft; + n++; + } + if (bW.abs() > 1e-12 || bH.abs() > 1e-12) { + data[3 * n] = bW; + data[3 * n + 1] = bH; + data[3 * n + 2] = cBot; + n++; + data[3 * n] = -bW; + data[3 * n + 1] = -bH; + data[3 * n + 2] = cTop; + n++; + } + } + + // For each side handle the rect extends from [anchor] by either + // widthSign*w along rect-local x (horizontal-side handles: left/right) + // or heightSign*h along rect-local y (vertical-side handles: top/ + // bottom). The perpendicular axis is symmetric ±h/2 (or ±w/2) about + // anchor — the user can't drag in that direction for a side handle. + // + // widthSign/heightSign use the same convention as corner-anchored + // resizes: "+1 means rect extends in +e1 / +e2 from anchor". For an + // unflipped right handle widthSign=+1; for unflipped top handle + // heightSign=-1. Force-flip (drag past anchor) flips the relevant sign. + // + // Each corner is parameterized by (α, β, γ, δ) such that rel_x = α*w + + // β*h and rel_y = γ*w + δ*h in rect-local coords. + final List<({double a, double b, double g, double d})> corners; + final bool isHorizontalSide = + handle.influencesLeft || handle.influencesRight; + if (isHorizontalSide) { + // Active axis x. Anchor-side corners at rel_x=0; far-side at + // rel_x = widthSign*w. Both axes' y at ±h/2. + corners = [ + (a: 0, b: 0, g: 0, d: -0.5), // anchor-side, top + (a: widthSign, b: 0, g: 0, d: -0.5), // far-side, top + (a: 0, b: 0, g: 0, d: 0.5), // anchor-side, bottom + (a: widthSign, b: 0, g: 0, d: 0.5), // far-side, bottom + ]; + } else { + // Active axis y (top/bottom). + corners = [ + (a: -0.5, b: 0, g: 0, d: 0), // anchor-side, left + (a: 0.5, b: 0, g: 0, d: 0), // anchor-side, right + (a: -0.5, b: 0, g: 0, d: heightSign), // far-side, left + (a: 0.5, b: 0, g: 0, d: heightSign), // far-side, right + ]; + } + + if (bindingStrategy == BindingStrategy.boundingBox) { + // Emit the rotated rect-local corners: the rendered polygon must + // stay in the clamp. With rel_x = α*w + β*h, rel_y = γ*w + δ*h: + // world_x_offset = cos*rel_x - sin*rel_y + // = (α*cos - γ*sin)*w + (β*cos - δ*sin)*h + // world_y_offset = sin*rel_x + cos*rel_y + // = (α*sin + γ*cos)*w + (β*sin + δ*cos)*h + for (final c in corners) { + final aW = cos * c.a - sin * c.g; + final aH = cos * c.b - sin * c.d; + final bW = sin * c.a + cos * c.g; + final bH = sin * c.b + cos * c.d; + addEdges(aW, aH, bW, bH); + } + } else { + // originalBox: emit the unrotated rect's corners (axis-aligned in + // world). The center sits at anchor + R(theta) * sideOff: + // horizontal-side: sideOff = (widthSign*w/2, 0) + // vertical-side: sideOff = (0, heightSign*h/2) + // Corners are at center + (sgnX*w/2, sgnY*h/2). + final double centerAW, centerAH, centerBW, centerBH; + if (isHorizontalSide) { + centerAW = widthSign * cos / 2; + centerAH = 0; + centerBW = widthSign * sin / 2; + centerBH = 0; + } else { + centerAW = 0; + centerAH = -heightSign * sin / 2; + centerBW = 0; + centerBH = heightSign * cos / 2; + } + for (final sgnX in const [-1.0, 1.0]) { + for (final sgnY in const [-1.0, 1.0]) { + addEdges( + centerAW + sgnX / 2, + centerAH, + centerBW, + centerBH + sgnY / 2, + ); + } + } + } + + buf.count = n; + } + + /// Fills [buf] with the center-anchored (symmetric-mode) inequalities. + /// + /// Which corners are enforced depends on [bindingStrategy]: + /// + /// * [BindingStrategy.boundingBox]: the rotated rect's four corners + /// only (`center + sx*(w/2)*e1 + sy*(h/2)*e2`). Rendered footprint + /// stays in the clamp. + /// * [BindingStrategy.originalBox]: the unrotated rect's four + /// axis-aligned corners only (`center + (sx*w/2, sy*h/2)`). The + /// rendered rotated corners may extend beyond the clamp. + static void buildCenterIneqsInto( + IneqBuffer buf, { + required Vector2 center, + required double theta, + required Box clampingRect, + required BindingStrategy bindingStrategy, + }) { + // For [BindingStrategy.originalBox], the unrotated rect's corners are + // `center ± (w/2, h/2)` regardless of theta — equivalent to cos=1, sin=0. + final cx = center.x; + final cy = center.y; + final lc = clampingRect.left; + final tc = clampingRect.top; + final rc = clampingRect.right; + final bc = clampingRect.bottom; + + final cRight = rc - cx; + final cLeft = cx - lc; + final cBot = bc - cy; + final cTop = cy - tc; + + final data = buf.data; + int n = 0; + // Emits the (≤4) edge constraints for one corner with rect-local + // coefficients (aW, aH, bW, bH) such that + // corner.x = cx + aW*w + aH*h + // corner.y = cy + bW*w + bH*h + // and skips trivially-zero rows. + void emit(double aW, double aH, double bW, double bH) { + if (!(aW.abs() <= 1e-12 && aH.abs() <= 1e-12)) { + data[3 * n] = aW; + data[3 * n + 1] = aH; + data[3 * n + 2] = cRight; + n++; + data[3 * n] = -aW; + data[3 * n + 1] = -aH; + data[3 * n + 2] = cLeft; + n++; + } + if (!(bW.abs() <= 1e-12 && bH.abs() <= 1e-12)) { + data[3 * n] = bW; + data[3 * n + 1] = bH; + data[3 * n + 2] = cBot; + n++; + data[3 * n] = -bW; + data[3 * n + 1] = -bH; + data[3 * n + 2] = cTop; + n++; + } + } + + if (bindingStrategy == BindingStrategy.boundingBox) { + // Rotated corners only: corner = center + R(theta)*(sx*w/2, sy*h/2). + // aW = sx*cos/2, aH = -sy*sin/2, + // bW = sx*sin/2, bH = sy*cos/2 + final halfCos = math.cos(theta) * 0.5; + final halfSin = math.sin(theta) * 0.5; + for (final sxSign in const [1, -1]) { + for (final sySign in const [1, -1]) { + emit( + sxSign * halfCos, + -sySign * halfSin, + sxSign * halfSin, + sySign * halfCos, + ); + } + } + } else { + // originalBox: unrotated corners (axis-aligned in world): + // corner = center ± (w/2, h/2). (aW = sx/2, bH = sy/2; aH = bW = 0.) + for (final sxSign in const [1, -1]) { + for (final sySign in const [1, -1]) { + emit(sxSign * 0.5, 0, 0, sySign * 0.5); + } + } + } + + buf.count = n; + } + + /// Projects `(targetW, targetH)` onto the feasible region described by + /// the flat inequality buffer [buf], intersected with `[minW, maxW] × + /// [minH, maxH]`. Writes results into [out] without allocating. + /// + /// The projection is L∞ (Chebyshev): each dimension is clamped + /// independently where possible, and coupled-inequality violations are + /// resolved by reducing the dimension whose current contribution to the + /// violation is largest. Bounded in iterations; converges in practice in + /// ≤4 passes for 4 corner inequalities. + static void projectOntoFeasibleRegionFlat( + IneqBuffer buf, { + required FlatProjection out, + required double targetW, + required double targetH, + required double minW, + required double maxW, + required double minH, + required double maxH, + }) { + final data = buf.data; + final n = buf.count; + + double w = targetW; + double h = targetH; + + // Step 1: user min/max. + if (w < minW) { + w = minW; + } else if (w > maxW) { + w = maxW; + } + if (h < minH) { + h = minH; + } else if (h > maxH) { + h = maxH; + } + + // Step 2: unified violation loop. + // + // Each iteration finds the maximally-violated inequality across ALL rows + // (w-only, h-only, and coupled) and applies the correct fix: + // + // * w-only (b≈0): set w = c/a (with sign handling), clamp. + // * h-only (a≈0): set h = c/b, clamp. + // * coupled: L2 (orthogonal) projection onto the constraint line, + // clamp. + // + // The unified order matters: applying w/h-only constraints *before* the + // coupled L2 step (as the previous two-step structure did) caused the L2 + // projection to start from a different point as the desired (w, h) + // crossed an h-only bound, producing a non-monotonic projected dimension + // as the cursor swept past the constraint corner. Driving everything off + // the same violator-priority queue avoids that. + // + // For locked dimensions (minX == maxX) the L2 step degenerates to 1D + // reduction of the unlocked axis. + final bool wLocked = (maxW - minW).abs() < 1e-12; + final bool hLocked = (maxH - minH).abs() < 1e-12; + const maxIterations = 32; + for (int iter = 0; iter < maxIterations; iter++) { + int violatorIdx = -1; + double maxOvershoot = 0; + for (int i = 0; i < n; i++) { + final a = data[3 * i]; + final b = data[3 * i + 1]; + final c = data[3 * i + 2]; + final over = a * w + b * h - c; + // Sub-1e-9 residual is expected float-precision noise after an L2 + // projection; tighter thresholds re-trigger iteration and drift the + // result. + if (over > maxOvershoot + 1e-9) { + maxOvershoot = over; + violatorIdx = i; + } + } + if (violatorIdx < 0) break; + + final a = data[3 * violatorIdx]; + final b = data[3 * violatorIdx + 1]; + final c = data[3 * violatorIdx + 2]; + final aAbs = a.abs(); + final bAbs = b.abs(); + + if (bAbs < 1e-12) { + // w-only. + var newW = c / a; + if (newW < minW) { + newW = minW; + } else if (newW > maxW) { + newW = maxW; + } + w = newW; + } else if (aAbs < 1e-12) { + // h-only. + var newH = c / b; + if (newH < minH) { + newH = minH; + } else if (newH > maxH) { + newH = maxH; + } + h = newH; + } else if (wLocked && hLocked) { + break; + } else if (wLocked) { + var newH = (c - a * w) / b; + if (newH < minH) { + newH = minH; + } else if (newH > maxH) { + newH = maxH; + } + h = newH; + } else if (hLocked) { + var newW = (c - b * h) / a; + if (newW < minW) { + newW = minW; + } else if (newW > maxW) { + newW = maxW; + } + w = newW; + } else { + final d = (a * w + b * h - c) / (a * a + b * b); + var newW = w - d * a; + var newH = h - d * b; + bool wClamped = false; + bool hClamped = false; + if (newW < minW) { + newW = minW; + wClamped = true; + } else if (newW > maxW) { + newW = maxW; + wClamped = true; + } + if (newH < minH) { + newH = minH; + hClamped = true; + } else if (newH > maxH) { + newH = maxH; + hClamped = true; + } + // If the L2 step pushed one dim past its bound and got clamped, the + // constraint may still be violated. Without a fallback, the next + // iteration's L2 step has the saturated dim parked at its bound and + // the projection becomes effectively 1D — but L2 with one dim + // saturated converges slowly (~ 0.85^iter for typical geometries), + // exceeding the 32-iter cap on harsh-delta force-flips. Resolve + // exactly by 1D-projecting the unclamped dim. + if (wClamped != hClamped && (a * newW + b * newH - c) > 1e-9) { + if (wClamped && b.abs() > 1e-12) { + var solvedH = (c - a * newW) / b; + if (solvedH < minH) { + solvedH = minH; + } else if (solvedH > maxH) { + solvedH = maxH; + } + newH = solvedH; + } else if (hClamped && a.abs() > 1e-12) { + var solvedW = (c - b * newH) / a; + if (solvedW < minW) { + solvedW = minW; + } else if (solvedW > maxW) { + solvedW = maxW; + } + newW = solvedW; + } + } + w = newW; + h = newH; + } + } + + // Compute the worst residual across all inequalities at the final + // (w, h). The projection loop converges to ≤1e-9 when feasible; a + // non-trivial residual here means the target (w, h) lies strictly + // outside the feasible region — using (w, h) to build a rect would + // leak the clamp by `residual` pixels. Callers read `out.feasible` to + // decide whether to use the result or freeze the gesture. + double residual = 0; + for (int i = 0; i < n; i++) { + final a = data[3 * i]; + final b = data[3 * i + 1]; + final c = data[3 * i + 2]; + final over = a * w + b * h - c; + if (over > residual) residual = over; + } + out.feasible = residual <= 1e-3; + out.worstResidual = residual; + + out.w = w; + out.h = h; + out.wMinHit = (w - minW).abs() < 1e-9; + out.wMaxHit = maxW.isFinite && (w - maxW).abs() < 1e-9; + out.hMinHit = (h - minH).abs() < 1e-9; + out.hMaxHit = maxH.isFinite && (h - maxH).abs() < 1e-9; + } +} diff --git a/packages/box_transform/lib/src/transformer.dart b/packages/box_transform/lib/src/transformer.dart index 06872a2..b1e56f2 100644 --- a/packages/box_transform/lib/src/transformer.dart +++ b/packages/box_transform/lib/src/transformer.dart @@ -1,45 +1,184 @@ import 'dart:developer'; import 'dart:math' hide log; -import 'package:vector_math/vector_math.dart'; +import 'package:vector_math/vector_math_64.dart'; import 'enums.dart'; import 'geometry.dart'; import 'helpers.dart'; import 'resizers/resizer.dart'; import 'result.dart'; +import 'rotated_clamping_solver.dart'; /// A class that transforms a [Box] in several different supported forms. class BoxTransformer { /// A private constructor to prevent instantiation. const BoxTransformer._(); - /// Calculates the new position of the [initialRect] based on the - /// [initialLocalPosition] of the mouse cursor and wherever [localPosition] - /// of the mouse cursor is currently at. + /// Rotations smaller than this in absolute value are treated as zero and + /// take the axis-aligned fast path. At this magnitude the difference + /// between the rotated and unrotated rect is sub-ULP for any practical + /// box dimensions, so the visual outcome is identical and we save the LP + /// solver cost. Float intermediates from animation tweens routinely land + /// in this range when interpolating to or through zero. + static const double _kRotationEpsilon = 1e-9; + + // Per-isolate scratch buffers reused by the rotated-resize hot path. Both + // the inequality batch and the projection result are mutable bags reused + // across calls so the solver never allocates on the steady-state path. + // Resize is called O(60-120 Hz) on a single isolate; sharing scratch + // is safe (Dart isolates are single-threaded) and saves ~256 B/call. + static final IneqBuffer _ineqScratch = IneqBuffer(); + static final FlatProjection _projScratch = FlatProjection(); + + /// Moves the given [initialRect] with given [initialLocalPosition] of the + /// mouse cursor and wherever [localPosition] of the mouse cursor is + /// currently at. /// /// The [clampingRect] is the rect that the [initialRect] is not allowed /// to go outside of when dragging or resizing. + /// + /// [bindingStrategy] controls which corners are enforced against the + /// clamp at nonzero [rotation]: + /// + /// * [BindingStrategy.boundingBox]: the four rotated rect corners stay + /// inside the clamp (the rendered footprint cannot poke out). + /// * [BindingStrategy.originalBox]: the four unrotated rect corners stay + /// inside the clamp (the logical rect stays in; rotated corners may + /// extend outside). + /// + /// Ignored when [rotation] is 0 — the axis-aligned branch always clamps + /// against the unrotated rect. static RawMoveResult move({ required Box initialRect, required Vector2 initialLocalPosition, required Vector2 localPosition, Box clampingRect = Box.largest, + double rotation = 0.0, + BindingStrategy bindingStrategy = BindingStrategy.boundingBox, }) { final Vector2 delta = localPosition - initialLocalPosition; - final Box unclampedRect = initialRect.translate(delta.x, delta.y); - final Box clampedRect = clampingRect.containOther(unclampedRect); - final Vector2 clampedDelta = clampedRect.topLeft - initialRect.topLeft; - - final Box newRect = initialRect.translate(clampedDelta.x, clampedDelta.y); + if (rotation.abs() < _kRotationEpsilon) { + final Box unclampedRect = initialRect.translate(delta.x, delta.y); + final Box clampedRect = clampingRect.containOther(unclampedRect); + final Vector2 clampedDelta = clampedRect.topLeft - initialRect.topLeft; + final Box newRect = initialRect.translate(clampedDelta.x, clampedDelta.y); + return MoveResult( + rect: newRect, + oldRect: initialRect, + delta: delta, + rawSize: newRect.size, + largestRect: clampingRect, + rotation: rotation, + boundingRect: rotation == 0.0 + ? null + : ClampHelpers.calculateBoundingRect(newRect), + oldBoundingRect: rotation == 0.0 + ? null + : ClampHelpers.calculateBoundingRect(initialRect), + ); + } + // Rotated move. Translation of a rotated rect preserves rotation; + // clamping reduces to "find tx, ty such that each of the 4 corners + // stays in clampingRect". Which four corners are enforced depends on + // [bindingStrategy]: + // + // * boundingBox: the rendered rotated rect's four corners must stay + // in the clamp. Offsets use the rotated basis (cos, sin). + // * originalBox: the unrotated rect's four corners must stay in the + // clamp. The rendered rotated corners MAY extend outside. Offsets + // are axis-aligned (cos=1, sin=0 effectively). + // + // Per-corner bounds on (tx, ty) are linear in either case. + final w = initialRect.width; + final h = initialRect.height; + final cx0 = initialRect.left + w / 2; + final cy0 = initialRect.top + h / 2; + // boundingBox: enforce the rotated quad's four corners only. The + // rendered footprint stays in the clamp; the unrotated stored rect + // is invisible storage and need not also be in the clamp. (Adding + // the unrotated corners as a second constraint set was an earlier + // attempt to guard against degenerate post-flip geometries where + // h≈0 or w≈0; for non-degenerate boxes, which `minWidth` and + // `minHeight` enforce, the unrotated corners are strictly tighter + // than the rotated AABB on whichever axis they extend further + // along, which contradicts the boundingBox semantic of "rendered + // AABB stays in the clamp".) + // + // originalBox: enforce the unrotated rect's four axis-aligned + // corners. Rotated corners may extend outside the clamp. + final List offsets; + if (bindingStrategy == BindingStrategy.boundingBox) { + final c = cos(rotation); + final s = sin(rotation); + offsets = [ + Vector2(-w / 2 * c - (-h / 2) * s, -w / 2 * s + (-h / 2) * c), + Vector2(w / 2 * c - (-h / 2) * s, w / 2 * s + (-h / 2) * c), + Vector2(w / 2 * c - h / 2 * s, w / 2 * s + h / 2 * c), + Vector2(-w / 2 * c - h / 2 * s, -w / 2 * s + h / 2 * c), + ]; + } else { + offsets = [ + Vector2(-w / 2, -h / 2), + Vector2(w / 2, -h / 2), + Vector2(w / 2, h / 2), + Vector2(-w / 2, h / 2), + ]; + } + // Compute the GLOBAL feasible interval for (tx, ty) rather than + // applying per-corner constraints sequentially. Sequential max/min is + // order-dependent when the constraint system is empty (the clamp is + // tighter than the rotated bounding rect, even by a hair of float + // precision): opposite corners push tx in opposing directions and the + // last corner visited wins, flicking the box past the clamp. + double txMin = double.negativeInfinity; + double txMax = double.infinity; + double tyMin = double.negativeInfinity; + double tyMax = double.infinity; + for (final off in offsets) { + final left = clampingRect.left - (cx0 + off.x); + final right = clampingRect.right - (cx0 + off.x); + final top = clampingRect.top - (cy0 + off.y); + final bottom = clampingRect.bottom - (cy0 + off.y); + if (left > txMin) txMin = left; + if (right < txMax) txMax = right; + if (top > tyMin) tyMin = top; + if (bottom < tyMax) tyMax = bottom; + } + // Sanitise empty intervals: at saturation the feasible region can be a + // single point. Float precision may invert the bounds; collapse to the + // midpoint so the delta.clamp below is well-ordered and semantically + // correct (no order-dependent flick). + if (txMin > txMax) { + final mid = (txMin + txMax) / 2; + txMin = mid; + txMax = mid; + } + if (tyMin > tyMax) { + final mid = (tyMin + tyMax) / 2; + tyMin = mid; + tyMax = mid; + } + final double tx = delta.x.clamp(txMin, txMax).toDouble(); + final double ty = delta.y.clamp(tyMin, tyMax).toDouble(); + final clampedRect = Box.fromLTRB( + initialRect.left + tx, + initialRect.top + ty, + initialRect.right + tx, + initialRect.bottom + ty, + rotation: rotation, + ); return MoveResult( - rect: newRect, + rect: clampedRect, oldRect: initialRect, - delta: delta, - rawSize: newRect.size, + delta: Vector2(tx, ty), + rawSize: clampedRect.size, largestRect: clampingRect, + rotation: rotation, + boundingRect: ClampHelpers.calculateBoundingRect(clampedRect), + oldBoundingRect: ClampHelpers.calculateBoundingRect(initialRect), ); } @@ -57,16 +196,6 @@ class BoxTransformer { /// /// The [constraints] is the constraints that the [initialRect] is not allowed /// to shrink or grow beyond. - /// - /// [allowResizeOverflow] decides whether to allow the rect to overflow the - /// resize operation to its opposite side to continue the resize operation - /// until its constrained on both sides. - /// - /// If this is set to false, the rect will cease the resize operation the - /// instant it hits an edge of the [clampingRect]. - /// - /// If this is set to true, the rect will continue the resize operation until - /// it is constrained to both sides of the [clampingRect]. static RawResizeResult resize({ required Box initialRect, required Vector2 initialLocalPosition, @@ -77,7 +206,74 @@ class BoxTransformer { Box clampingRect = Box.largest, Constraints constraints = const Constraints.unconstrained(), bool allowFlipping = true, + double rotation = 0.0, + BindingStrategy bindingStrategy = BindingStrategy.boundingBox, }) { + // NOTE: this uses strict `!= 0.0` rather than the rotation epsilon used + // by `move()`. The AA path is now actually *faster* than the LP path + // post-roundToPrecision fix, but extending the epsilon bypass here would + // also require re-wrapping the resizer's rotation=0 result Box with the + // input rotation to preserve the contract `result.rect.rotation == input + // rotation`. Left as a future optimization for sub-epsilon rotations, + // which only arise from animation tweens rounding through zero. + if (rotation != 0.0) { + if (handle == HandlePosition.none) handle = HandlePosition.bottomRight; + switch (resizeMode) { + case ResizeMode.freeform: + return _resizeRotatedFreeform( + initialRect: initialRect, + initialLocalPosition: initialLocalPosition, + localPosition: localPosition, + handle: handle, + initialFlip: initialFlip, + clampingRect: clampingRect, + constraints: constraints, + rotation: rotation, + bindingStrategy: bindingStrategy, + allowFlipping: allowFlipping, + ); + case ResizeMode.scale: + return _resizeRotatedScale( + initialRect: initialRect, + initialLocalPosition: initialLocalPosition, + localPosition: localPosition, + handle: handle, + initialFlip: initialFlip, + clampingRect: clampingRect, + constraints: constraints, + rotation: rotation, + bindingStrategy: bindingStrategy, + allowFlipping: allowFlipping, + ); + case ResizeMode.symmetric: + return _resizeRotatedSymmetric( + initialRect: initialRect, + initialLocalPosition: initialLocalPosition, + localPosition: localPosition, + handle: handle, + initialFlip: initialFlip, + clampingRect: clampingRect, + constraints: constraints, + rotation: rotation, + bindingStrategy: bindingStrategy, + allowFlipping: allowFlipping, + ); + case ResizeMode.symmetricScale: + return _resizeRotatedSymmetricScale( + initialRect: initialRect, + initialLocalPosition: initialLocalPosition, + localPosition: localPosition, + handle: handle, + initialFlip: initialFlip, + clampingRect: clampingRect, + constraints: constraints, + rotation: rotation, + bindingStrategy: bindingStrategy, + allowFlipping: allowFlipping, + ); + } + } + if (handle == HandlePosition.none) { log('Using bottomRight handle instead of none.'); handle = HandlePosition.bottomRight; @@ -89,7 +285,7 @@ class BoxTransformer { // to flip based on the current local position of the mouse cursor. final Flip currentFlip = !allowFlipping ? Flip.none - : getFlipForRect(initialRect, delta, handle, resizeMode); + : ClampHelpers.getFlipForRect(initialRect, delta, handle, resizeMode); // This sets the constraints such that it reflects flipRect state. if (allowFlipping && @@ -300,4 +496,893 @@ class BoxTransformer { max(top, bottom), ); } + + /// Computes a rotation update from gesture pointer positions. + /// + /// Returns a [RawRotateResult] whose [RotateResult.rotation] is + /// [initialRotation] plus the signed angle between the vectors from the + /// box center to [initialLocalPosition] and [localPosition]. + static RawRotateResult rotate({ + required Box initialRect, + required Vector2 initialLocalPosition, + required Vector2 localPosition, + required double initialRotation, + Box clampingRect = Box.largest, + BindingStrategy bindingStrategy = BindingStrategy.boundingBox, + }) { + final center = Vector2( + (initialRect.left + initialRect.right) / 2, + (initialRect.top + initialRect.bottom) / 2, + ); + final fromVec = initialLocalPosition - center; + final toVec = localPosition - center; + // Signed angle in (-π, π] via atan2(cross, dot). Computing as the raw + // atan2 difference yields (-2π, 2π) and jumps by ±2π whenever the + // pointer-vs-center bearing crosses the negative-x ray — a common + // event for corner-anchored gestures spanning ~180°. + final deltaAngle = atan2( + fromVec.x * toVec.y - fromVec.y * toVec.x, + fromVec.x * toVec.x + fromVec.y * toVec.y, + ); + final newRotation = initialRotation + deltaAngle; + + // Slide-then-freeze: compute the feasible (tx, ty) interval that keeps + // the rotated rect inside [clampingRect] under [bindingStrategy], then + // either translate by the smallest tx/ty in that interval (slide-to-fit) + // or — if the interval is empty — reject the rotation. + final fit = _fitRotatedRect( + initialRect: initialRect, + rotation: newRotation, + clampingRect: clampingRect, + bindingStrategy: bindingStrategy, + ); + if (fit == null) { + return RotateResult( + rect: initialRect, + oldRect: initialRect, + // Report the attempted gesture motion so consumers can observe the + // user's intent without first checking [feasible]. Mirrors how + // `move()` reports its clamped delta. + delta: localPosition - initialLocalPosition, + rawSize: Dimension(initialRect.width, initialRect.height), + largestRect: clampingRect, + rotation: initialRotation, + boundingRect: ClampHelpers.calculateBoundingRect(initialRect), + oldBoundingRect: ClampHelpers.calculateBoundingRect(initialRect), + feasible: false, + ); + } + + final (double tx, double ty) = fit; + final rotatedRect = Box.fromLTRB( + initialRect.left + tx, + initialRect.top + ty, + initialRect.right + tx, + initialRect.bottom + ty, + rotation: newRotation, + ); + return RotateResult( + rect: rotatedRect, + oldRect: initialRect, + delta: localPosition - initialLocalPosition, + rawSize: Dimension(rotatedRect.width, rotatedRect.height), + largestRect: clampingRect, + rotation: newRotation, + boundingRect: ClampHelpers.calculateBoundingRect(rotatedRect), + oldBoundingRect: ClampHelpers.calculateBoundingRect(initialRect), + feasible: true, + ); + } + + /// Reconstructs the un-rotated rect for a side-handle resize. + /// + /// Side handles' anchor is the midpoint of the opposite side, not a + /// corner of the rect. The rect is positioned such that the new rect's + /// corresponding side midpoint sits at [anchorLocal] (so the + /// post-correction world-anchor invariant holds): the perpendicular + /// dimension extends symmetrically about that anchor; the dragged + /// dimension may flip across the anchor if the pointer crossed it + /// (reflected via [pointerRightOfAnchor] / [pointerBelowAnchor]). + static Box _buildSideRotatedRect({ + required HandlePosition handle, + required Vector2 anchorLocal, + required double w, + required double h, + required bool pointerRightOfAnchor, + required bool pointerBelowAnchor, + required double rotation, + }) { + if (handle.influencesLeft || handle.influencesRight) { + final dx = pointerRightOfAnchor ? w : -w; + return Box.fromLTRB( + min(anchorLocal.x, anchorLocal.x + dx), + anchorLocal.y - h / 2, + max(anchorLocal.x, anchorLocal.x + dx), + anchorLocal.y + h / 2, + rotation: rotation, + ); + } + final dy = pointerBelowAnchor ? h : -h; + return Box.fromLTRB( + anchorLocal.x - w / 2, + min(anchorLocal.y, anchorLocal.y + dy), + anchorLocal.x + w / 2, + max(anchorLocal.y, anchorLocal.y + dy), + rotation: rotation, + ); + } + + /// Returns `(tx, ty)` such that translating [initialRect] by it places the + /// rect at rotation [rotation] fully inside [clampingRect] (per + /// [bindingStrategy]'s 4-corner feasibility) with minimum motion from the + /// rect's current center, or `null` if no such translation exists. + /// + /// Mirrors the per-corner interval-intersection used by [move]; the only + /// difference is the empty-interval response — `move` collapses to the + /// midpoint, here we surface the infeasibility so the caller can reject. + static (double, double)? _fitRotatedRect({ + required Box initialRect, + required double rotation, + required Box clampingRect, + required BindingStrategy bindingStrategy, + }) { + final w = initialRect.width; + final h = initialRect.height; + final cx0 = initialRect.left + w / 2; + final cy0 = initialRect.top + h / 2; + // boundingBox: rotated quad corners only (rendered footprint inside + // the clamp). originalBox: unrotated rect corners (axis-aligned). + final List offsets; + if (bindingStrategy == BindingStrategy.boundingBox) { + final c = cos(rotation); + final s = sin(rotation); + offsets = [ + Vector2(-w / 2 * c - (-h / 2) * s, -w / 2 * s + (-h / 2) * c), + Vector2(w / 2 * c - (-h / 2) * s, w / 2 * s + (-h / 2) * c), + Vector2(w / 2 * c - h / 2 * s, w / 2 * s + h / 2 * c), + Vector2(-w / 2 * c - h / 2 * s, -w / 2 * s + h / 2 * c), + ]; + } else { + offsets = [ + Vector2(-w / 2, -h / 2), + Vector2(w / 2, -h / 2), + Vector2(w / 2, h / 2), + Vector2(-w / 2, h / 2), + ]; + } + double txMin = double.negativeInfinity; + double txMax = double.infinity; + double tyMin = double.negativeInfinity; + double tyMax = double.infinity; + for (final off in offsets) { + final left = clampingRect.left - (cx0 + off.x); + final right = clampingRect.right - (cx0 + off.x); + final top = clampingRect.top - (cy0 + off.y); + final bottom = clampingRect.bottom - (cy0 + off.y); + if (left > txMin) txMin = left; + if (right < txMax) txMax = right; + if (top > tyMin) tyMin = top; + if (bottom < tyMax) tyMax = bottom; + } + if (txMin > txMax || tyMin > tyMax) return null; + final tx = 0.0.clamp(txMin, txMax).toDouble(); + final ty = 0.0.clamp(tyMin, tyMax).toDouble(); + return (tx, ty); + } + + // --------------------------------------------------------------------------- + // Rotated resize implementations. + // + // Approach: work in the box's unrotated-local frame with the handle's + // opposite corner as anchor. Transform the pointer into that frame, solve + // the resize against a feasibility polygon (up to 32 linear inequalities + // for boundingBox — rotated and unrotated corners — or 16 for + // originalBox; plus min/max constraints), then place the result in world + // space such that the anchor's world position is preserved. + // --------------------------------------------------------------------------- + + static RawResizeResult _resizeRotatedFreeform({ + required Box initialRect, + required Vector2 initialLocalPosition, + required Vector2 localPosition, + required HandlePosition handle, + required Flip initialFlip, + required Box clampingRect, + required Constraints constraints, + required double rotation, + required BindingStrategy bindingStrategy, + required bool allowFlipping, + }) { + // 1. Box center + anchor (both in world-unrotated frame). + final anchorLocal = handle.anchor(initialRect); + final center = Vector2( + (initialRect.left + initialRect.right) / 2, + (initialRect.top + initialRect.bottom) / 2, + ); + final anchorWorld = + ClampHelpers.rotatePointAround(anchorLocal, center, rotation); + + // 2. Un-rotate pointer positions around the BOX CENTRE to land them in + // the box's own unrotated coordinate frame. Delta in that frame maps + // directly onto rect edges via handle.influences*. + final initialPointerLocal = + ClampHelpers.worldToUnrotated(initialLocalPosition, center, rotation); + final currentPointerLocal = + ClampHelpers.worldToUnrotated(localPosition, center, rotation); + final delta = currentPointerLocal - initialPointerLocal; + + // 3. Apply delta to edges, compute desired (w, h). This mirrors the + // non-rotated `_applyDelta` semantics. + final double newL = + initialRect.left + (handle.influencesLeft ? delta.x : 0.0); + final double newT = + initialRect.top + (handle.influencesTop ? delta.y : 0.0); + final double newR = + initialRect.right + (handle.influencesRight ? delta.x : 0.0); + final double newB = + initialRect.bottom + (handle.influencesBottom ? delta.y : 0.0); + final desiredW = (newR - newL).abs(); + final desiredH = (newB - newT).abs(); + // Pointer sign relative to anchor (for placing the new local corner). + final bool pointerRightOfAnchor = + handle.influencesRight ? newR >= anchorLocal.x : newL >= anchorLocal.x; + final bool pointerBelowAnchor = + handle.influencesBottom ? newB >= anchorLocal.y : newT >= anchorLocal.y; + + // 4. Build feasibility polygon and project. Reinterpret the legacy + // "Constraints.unconstrained()" sentinel (minWidth/minHeight = infinity) + // as effective min = 0 for the LP. + // + // The sign of (w, h) direction from anchor depends on which handle is + // being dragged: bottomRight → (+w, +h), topRight → (+w, -h), + // bottomLeft → (-w, +h), topLeft → (-w, -h). Without these signs, the + // solver would describe a mirrored non-existent rectangle on the wrong + // side of the anchor and produce nonsensical clamping. + final effMinW = constraints.minWidth.isFinite ? constraints.minWidth : 0.0; + final effMinH = + constraints.minHeight.isFinite ? constraints.minHeight : 0.0; + final double widthSign = pointerRightOfAnchor ? 1.0 : -1.0; + final double heightSign = pointerBelowAnchor ? 1.0 : -1.0; + // "Natural" signs are the direction the rect occupies in the + // gesture-start state — i.e. what the rect's geometry looks like + // before any force-flip. For a topLeft handle the rect grows up-left + // from the bottomRight anchor (-, -); for bottomRight it grows + // down-right (+, +). Force-flip swaps these when the cursor crosses + // the anchor. + final double naturalWidthSign = handle.influencesRight ? 1.0 : -1.0; + final double naturalHeightSign = handle.influencesBottom ? 1.0 : -1.0; + bool effectiveRightOfAnchor = pointerRightOfAnchor; + bool effectiveBelowAnchor = pointerBelowAnchor; + final bool inForceFlipTerritory = + widthSign != naturalWidthSign || heightSign != naturalHeightSign; + + final ineqs = _ineqScratch; + void buildIneqs(double wSign, double hSign) { + if (handle.isSide) { + RotatedClampingSolver.buildSideHandleIneqsInto( + ineqs, + anchor: anchorWorld, + theta: rotation, + clampingRect: clampingRect, + handle: handle, + bindingStrategy: bindingStrategy, + widthSign: wSign, + heightSign: hSign, + ); + } else { + RotatedClampingSolver.buildCornerIneqsInto( + ineqs, + anchor: anchorWorld, + theta: rotation, + clampingRect: clampingRect, + bindingStrategy: bindingStrategy, + widthSign: wSign, + heightSign: hSign, + ); + } + } + + buildIneqs(widthSign, heightSign); + + // Side handles lock the perpendicular dimension at initialRect's value; + // the LP must not vary it. Pin min=max for that axis. + final bool horizontalSide = + handle.isSide && (handle.influencesLeft || handle.influencesRight); + final bool verticalSide = + handle.isSide && (handle.influencesTop || handle.influencesBottom); + final double lpMinW = verticalSide ? initialRect.width : effMinW; + final double lpMaxW = + verticalSide ? initialRect.width : constraints.maxWidth; + final double lpMinH = horizontalSide ? initialRect.height : effMinH; + final double lpMaxH = + horizontalSide ? initialRect.height : constraints.maxHeight; + final projection = _projScratch; + RotatedClampingSolver.projectOntoFeasibleRegionFlat( + ineqs, + out: projection, + targetW: desiredW, + targetH: desiredH, + minW: lpMinW, + maxW: lpMaxW, + minH: lpMinH, + maxH: lpMaxH, + ); + + // Force-flip fallback: when the cursor is past the anchor but the + // flipped state can't fit clamp + constraints, retry the LP with + // natural-direction signs. The rect then tracks the cursor by + // clamp-pinning at the natural wall instead of freezing — the + // "expectantly clamped" behavior. Force-flip only engages when it's + // actually feasible. If both directions are infeasible, freeze. + // + // Crucially, the fallback target on each *flipped* axis is 0 — not + // the original desired magnitude. The cursor's distance past the + // anchor in the flip direction has no meaning in the natural- + // direction LP; using it would grow the rect in the *opposite* + // direction from where the user dragged. Zeroing makes the LP + // collapse the flipped axis toward minimum, while axes that were + // already natural keep tracking their cursor-derived target. + if (!projection.feasible && inForceFlipTerritory) { + final double fallbackDesiredW = + widthSign != naturalWidthSign ? 0.0 : desiredW; + final double fallbackDesiredH = + heightSign != naturalHeightSign ? 0.0 : desiredH; + buildIneqs(naturalWidthSign, naturalHeightSign); + RotatedClampingSolver.projectOntoFeasibleRegionFlat( + ineqs, + out: projection, + targetW: fallbackDesiredW, + targetH: fallbackDesiredH, + minW: lpMinW, + maxW: lpMaxW, + minH: lpMinH, + maxH: lpMaxH, + ); + if (projection.feasible) { + effectiveRightOfAnchor = handle.influencesRight; + effectiveBelowAnchor = handle.influencesBottom; + } + } + + if (!projection.feasible) { + // Even the natural direction cannot fit — gesture is truly stuck. + // Return a sentinel rect equal to gesture-start. The controller + // will hold its last feasible state instead of writing this one. + return ResizeResult( + rect: initialRect, + oldRect: initialRect, + delta: localPosition - initialLocalPosition, + flip: initialFlip, + resizeMode: ResizeMode.freeform, + rawSize: Dimension(initialRect.width, initialRect.height), + minWidthReached: false, + maxWidthReached: false, + minHeightReached: false, + maxHeightReached: false, + largestRect: clampingRect, + handle: handle, + rotation: rotation, + boundingRect: ClampHelpers.calculateBoundingRect(initialRect), + oldBoundingRect: ClampHelpers.calculateBoundingRect(initialRect), + feasible: false, + ); + } + + // 5. Reconstruct the unrotated rect anchored at anchorLocal with + // projected dimensions. Side handles use a different construction + // than corners because their anchor is the midpoint of the + // opposite side (not a corner of the rect): the rect spans the + // perpendicular axis symmetrically around the anchor. + final Box newRect; + if (handle.isSide) { + // Freeform side: lock the perpendicular dimension at initialRect's + // value (the LP can't change it because newR/newL or newT/newB stay + // pinned to the initial rect). + final bool horizontal = handle.influencesLeft || handle.influencesRight; + newRect = _buildSideRotatedRect( + handle: handle, + anchorLocal: anchorLocal, + w: horizontal ? projection.w : initialRect.width, + h: horizontal ? initialRect.height : projection.h, + pointerRightOfAnchor: effectiveRightOfAnchor, + pointerBelowAnchor: effectiveBelowAnchor, + rotation: rotation, + ); + } else { + final dx = effectiveRightOfAnchor ? projection.w : -projection.w; + final dy = effectiveBelowAnchor ? projection.h : -projection.h; + final newLocalCorner = Vector2(anchorLocal.x + dx, anchorLocal.y + dy); + newRect = Box.fromLTRB( + min(anchorLocal.x, newLocalCorner.x), + min(anchorLocal.y, newLocalCorner.y), + max(anchorLocal.x, newLocalCorner.x), + max(anchorLocal.y, newLocalCorner.y), + rotation: rotation, + ); + } + + // 6. Shift the new rect in world space so that its rotated anchor lands + // exactly back on anchorWorld. + final newCenter = Vector2( + (newRect.left + newRect.right) / 2, + (newRect.top + newRect.bottom) / 2, + ); + final newAnchorInWorld = + ClampHelpers.rotatePointAround(anchorLocal, newCenter, rotation); + final correction = anchorWorld - newAnchorInWorld; + final shifted = Box.fromLTRB( + newRect.left + correction.x, + newRect.top + correction.y, + newRect.right + correction.x, + newRect.bottom + correction.y, + rotation: rotation, + ); + + // Flip transition: the pointer crossed the handle's "natural" direction + // in the un-rotated frame. For corner/side handles, flip on each axis + // independently. Pure-side handles only flip on their active axis. + // Uses the EFFECTIVE side-of-anchor: when force-flip fell back to + // natural-direction (because the flipped state was infeasible), the + // rect is unflipped, so report no flip even if the cursor is past + // the anchor in raw coordinates. + final bool xFlipped = handle.influencesRight + ? !effectiveRightOfAnchor + : (handle.influencesLeft ? effectiveRightOfAnchor : false); + final bool yFlipped = handle.influencesBottom + ? !effectiveBelowAnchor + : (handle.influencesTop ? effectiveBelowAnchor : false); + final Flip currentFlip = allowFlipping + ? Flip.fromValue(xFlipped ? -1 : 1, yFlipped ? -1 : 1) + : Flip.none; + + return ResizeResult( + rect: shifted, + oldRect: initialRect, + delta: localPosition - initialLocalPosition, + flip: currentFlip * initialFlip, + resizeMode: ResizeMode.freeform, + rawSize: Dimension(shifted.width, shifted.height), + minWidthReached: projection.wMinHit, + maxWidthReached: projection.wMaxHit, + minHeightReached: projection.hMinHit, + maxHeightReached: projection.hMaxHit, + largestRect: clampingRect, + handle: handle, + rotation: rotation, + boundingRect: ClampHelpers.calculateBoundingRect(shifted), + oldBoundingRect: ClampHelpers.calculateBoundingRect(initialRect), + ); + } + + static RawResizeResult _resizeRotatedScale({ + required Box initialRect, + required Vector2 initialLocalPosition, + required Vector2 localPosition, + required HandlePosition handle, + required Flip initialFlip, + required Box clampingRect, + required Constraints constraints, + required double rotation, + required BindingStrategy bindingStrategy, + required bool allowFlipping, + }) { + final anchorLocal = handle.anchor(initialRect); + final center = Vector2( + (initialRect.left + initialRect.right) / 2, + (initialRect.top + initialRect.bottom) / 2, + ); + final anchorWorld = + ClampHelpers.rotatePointAround(anchorLocal, center, rotation); + + // Un-rotate pointers around the box centre (not anchorWorld) so delta + // is in the box's own unrotated frame. + final initialPointerLocal = + ClampHelpers.worldToUnrotated(initialLocalPosition, center, rotation); + final currentPointerLocal = + ClampHelpers.worldToUnrotated(localPosition, center, rotation); + final delta = currentPointerLocal - initialPointerLocal; + + final double newL = + initialRect.left + (handle.influencesLeft ? delta.x : 0.0); + final double newR = + initialRect.right + (handle.influencesRight ? delta.x : 0.0); + final double newT = + initialRect.top + (handle.influencesTop ? delta.y : 0.0); + final double newB = + initialRect.bottom + (handle.influencesBottom ? delta.y : 0.0); + // Aspect ratio k = h / w from the initial rect. + final k = initialRect.height / initialRect.width; + // For top/bottom side handles in scale mode, the user drags vertically; + // height is the active dimension. Convert to an equivalent w via k so + // the rest of the LP (which substitutes h = k*w) still applies. + final bool isVerticalSide = + handle.isSide && (handle.influencesTop || handle.influencesBottom); + final desiredW = + isVerticalSide ? (newB - newT).abs() / k : (newR - newL).abs(); + // Pointer sign relative to anchor (same logic as freeform). + final bool pointerRightOfAnchor = + handle.influencesRight ? newR >= anchorLocal.x : newL >= anchorLocal.x; + final bool pointerBelowAnchor = + handle.influencesBottom ? newB >= anchorLocal.y : newT >= anchorLocal.y; + + // Reinterpret unconstrained-sentinel infinities as zero mins. + final effMinW = constraints.minWidth.isFinite ? constraints.minWidth : 0.0; + final effMinH = + constraints.minHeight.isFinite ? constraints.minHeight : 0.0; + + final double widthSign = pointerRightOfAnchor ? 1.0 : -1.0; + final double heightSign = pointerBelowAnchor ? 1.0 : -1.0; + final ineqs = _ineqScratch; + if (handle.isSide) { + // Same anchor-geometry fix as in freeform: side handles need the + // side-anchored inequalities, not the corner ones. Pass widthSign / + // heightSign so force-flipped side handles get the right LP geometry. + RotatedClampingSolver.buildSideHandleIneqsInto( + ineqs, + anchor: anchorWorld, + theta: rotation, + clampingRect: clampingRect, + handle: handle, + bindingStrategy: bindingStrategy, + widthSign: widthSign, + heightSign: heightSign, + ); + } else { + RotatedClampingSolver.buildCornerIneqsInto( + ineqs, + anchor: anchorWorld, + theta: rotation, + clampingRect: clampingRect, + bindingStrategy: bindingStrategy, + widthSign: widthSign, + heightSign: heightSign, + ); + } + + // Substitute h = k * w into each inequality: (a + b*k)*w <= c. + double wUpper = constraints.maxWidth; + if (constraints.maxHeight.isFinite) { + wUpper = min(wUpper, constraints.maxHeight / k); + } + double wLower = effMinW; + if (effMinH > 0) { + wLower = max(wLower, effMinH / k); + } + final ineqData = ineqs.data; + final ineqCount = ineqs.count; + for (int i = 0; i < ineqCount; i++) { + final a = ineqData[3 * i]; + final b = ineqData[3 * i + 1]; + final coef = a + b * k; + if (coef.abs() < 1e-12) continue; + final bound = ineqData[3 * i + 2] / coef; + if (coef > 0 && bound < wUpper) wUpper = bound; + if (coef < 0 && bound > wLower) wLower = bound; + } + // Sanitize: at constraint-saturation boundaries the feasible interval for + // w can degenerate to a single point (e.g. minH/k == maxW == 500). Finite + // arithmetic can leave wLower above wUpper by a few ULP; treat that case + // as the intended point. If they're separated by more than a reasonable + // epsilon, the feasible region genuinely is empty — fall back to the + // current size rather than throwing. + if (wLower > wUpper) { + if ((wLower - wUpper) <= 1e-6 * max(1.0, wUpper.abs())) { + final mid = (wLower + wUpper) / 2; + wLower = mid; + wUpper = mid; + } else { + wLower = desiredW; + wUpper = desiredW; + } + } + final w = desiredW.clamp(wLower, wUpper).toDouble(); + final h = w * k; + + // Side handles use a side-anchored construction (rect is centered on + // the perpendicular axis around anchorLocal); corners use the existing + // anchor-corner span via min/max. + final Box newRect; + if (handle.isSide) { + newRect = _buildSideRotatedRect( + handle: handle, + anchorLocal: anchorLocal, + w: w, + h: h, + pointerRightOfAnchor: pointerRightOfAnchor, + pointerBelowAnchor: pointerBelowAnchor, + rotation: rotation, + ); + } else { + final dx = widthSign * w; + final dy = heightSign * h; + final newLocalCorner = Vector2(anchorLocal.x + dx, anchorLocal.y + dy); + newRect = Box.fromLTRB( + min(anchorLocal.x, newLocalCorner.x), + min(anchorLocal.y, newLocalCorner.y), + max(anchorLocal.x, newLocalCorner.x), + max(anchorLocal.y, newLocalCorner.y), + rotation: rotation, + ); + } + final newCenter = Vector2( + (newRect.left + newRect.right) / 2, + (newRect.top + newRect.bottom) / 2, + ); + final correction = anchorWorld - + ClampHelpers.rotatePointAround(anchorLocal, newCenter, rotation); + final shifted = Box.fromLTRB( + newRect.left + correction.x, + newRect.top + correction.y, + newRect.right + correction.x, + newRect.bottom + correction.y, + rotation: rotation, + ); + + final bool xFlipped = handle.influencesRight + ? !pointerRightOfAnchor + : (handle.influencesLeft ? pointerRightOfAnchor : false); + final bool yFlipped = handle.influencesBottom + ? !pointerBelowAnchor + : (handle.influencesTop ? pointerBelowAnchor : false); + final Flip currentFlip = allowFlipping + ? Flip.fromValue(xFlipped ? -1 : 1, yFlipped ? -1 : 1) + : Flip.none; + + return ResizeResult( + rect: shifted, + oldRect: initialRect, + delta: localPosition - initialLocalPosition, + flip: currentFlip * initialFlip, + resizeMode: ResizeMode.scale, + rawSize: Dimension(shifted.width, shifted.height), + minWidthReached: (w - wLower).abs() < 1e-9, + maxWidthReached: wUpper.isFinite && (w - wUpper).abs() < 1e-9, + minHeightReached: effMinH > 0 && (h - effMinH).abs() < 1e-9, + maxHeightReached: constraints.maxHeight.isFinite && + (h - constraints.maxHeight).abs() < 1e-9, + largestRect: clampingRect, + handle: handle, + rotation: rotation, + boundingRect: ClampHelpers.calculateBoundingRect(shifted), + oldBoundingRect: ClampHelpers.calculateBoundingRect(initialRect), + ); + } + + static RawResizeResult _resizeRotatedSymmetric({ + required Box initialRect, + required Vector2 initialLocalPosition, + required Vector2 localPosition, + required HandlePosition handle, + required Flip initialFlip, + required Box clampingRect, + required Constraints constraints, + required double rotation, + required BindingStrategy bindingStrategy, + required bool allowFlipping, + }) { + final centerWorld = Vector2( + (initialRect.left + initialRect.right) / 2, + (initialRect.top + initialRect.bottom) / 2, + ); + final currentLocal = + ClampHelpers.worldToUnrotated(localPosition, centerWorld, rotation); + + // Desired (w, h): twice the distance from center to the dragged corner. + // Side handles only drive one axis; lock the perpendicular dimension at + // the initial value so symmetric expansion doesn't collapse it. + final bool horizontalSide = + handle.isSide && (handle.influencesLeft || handle.influencesRight); + final bool verticalSide = + handle.isSide && (handle.influencesTop || handle.influencesBottom); + final desiredW = verticalSide + ? initialRect.width + : (currentLocal.x - centerWorld.x).abs() * 2; + final desiredH = horizontalSide + ? initialRect.height + : (currentLocal.y - centerWorld.y).abs() * 2; + + final effMinW = constraints.minWidth.isFinite ? constraints.minWidth : 0.0; + final effMinH = + constraints.minHeight.isFinite ? constraints.minHeight : 0.0; + + // Side handles lock the perpendicular dimension at initialRect's value; + // pin min=max for that axis so the LP can't trade it away to satisfy + // coupled corner inequalities near a tight clamp. Mirrors the freeform + // path's locking above. + final double lpMinW = verticalSide ? initialRect.width : effMinW; + final double lpMaxW = + verticalSide ? initialRect.width : constraints.maxWidth; + final double lpMinH = horizontalSide ? initialRect.height : effMinH; + final double lpMaxH = + horizontalSide ? initialRect.height : constraints.maxHeight; + + final ineqs = _ineqScratch; + RotatedClampingSolver.buildCenterIneqsInto( + ineqs, + center: centerWorld, + theta: rotation, + clampingRect: clampingRect, + bindingStrategy: bindingStrategy, + ); + final projection = _projScratch; + RotatedClampingSolver.projectOntoFeasibleRegionFlat( + ineqs, + out: projection, + targetW: desiredW, + targetH: desiredH, + minW: lpMinW, + maxW: lpMaxW, + minH: lpMinH, + maxH: lpMaxH, + ); + if (!projection.feasible) { + return ResizeResult( + rect: initialRect, + oldRect: initialRect, + delta: localPosition - initialLocalPosition, + flip: initialFlip, + resizeMode: ResizeMode.symmetric, + rawSize: Dimension(initialRect.width, initialRect.height), + minWidthReached: false, + maxWidthReached: false, + minHeightReached: false, + maxHeightReached: false, + largestRect: clampingRect, + handle: handle, + rotation: rotation, + boundingRect: ClampHelpers.calculateBoundingRect(initialRect), + oldBoundingRect: ClampHelpers.calculateBoundingRect(initialRect), + feasible: false, + ); + } + final w = projection.w; + final h = projection.h; + + final newRect = Box.fromLTRB( + centerWorld.x - w / 2, + centerWorld.y - h / 2, + centerWorld.x + w / 2, + centerWorld.y + h / 2, + rotation: rotation, + ); + + // Symmetric flip: pointer crossed centerWorld on the handle's natural axis. + final bool xFlipped = handle.influencesRight + ? currentLocal.x < centerWorld.x + : (handle.influencesLeft ? currentLocal.x > centerWorld.x : false); + final bool yFlipped = handle.influencesBottom + ? currentLocal.y < centerWorld.y + : (handle.influencesTop ? currentLocal.y > centerWorld.y : false); + final Flip currentFlip = allowFlipping + ? Flip.fromValue(xFlipped ? -1 : 1, yFlipped ? -1 : 1) + : Flip.none; + + return ResizeResult( + rect: newRect, + oldRect: initialRect, + delta: localPosition - initialLocalPosition, + flip: currentFlip * initialFlip, + resizeMode: ResizeMode.symmetric, + rawSize: Dimension(w, h), + minWidthReached: projection.wMinHit, + maxWidthReached: projection.wMaxHit, + minHeightReached: projection.hMinHit, + maxHeightReached: projection.hMaxHit, + largestRect: clampingRect, + handle: handle, + rotation: rotation, + boundingRect: ClampHelpers.calculateBoundingRect(newRect), + oldBoundingRect: ClampHelpers.calculateBoundingRect(initialRect), + ); + } + + static RawResizeResult _resizeRotatedSymmetricScale({ + required Box initialRect, + required Vector2 initialLocalPosition, + required Vector2 localPosition, + required HandlePosition handle, + required Flip initialFlip, + required Box clampingRect, + required Constraints constraints, + required double rotation, + required BindingStrategy bindingStrategy, + required bool allowFlipping, + }) { + final centerWorld = Vector2( + (initialRect.left + initialRect.right) / 2, + (initialRect.top + initialRect.bottom) / 2, + ); + final currentLocal = + ClampHelpers.worldToUnrotated(localPosition, centerWorld, rotation); + final k = initialRect.height / initialRect.width; + final desiredW = (currentLocal.x - centerWorld.x).abs() * 2; + + final effMinW = constraints.minWidth.isFinite ? constraints.minWidth : 0.0; + final effMinH = + constraints.minHeight.isFinite ? constraints.minHeight : 0.0; + + final ineqs = _ineqScratch; + RotatedClampingSolver.buildCenterIneqsInto( + ineqs, + center: centerWorld, + theta: rotation, + clampingRect: clampingRect, + bindingStrategy: bindingStrategy, + ); + double wUpper = constraints.maxWidth; + if (constraints.maxHeight.isFinite) { + wUpper = min(wUpper, constraints.maxHeight / k); + } + double wLower = effMinW; + if (effMinH > 0) { + wLower = max(wLower, effMinH / k); + } + final ineqData = ineqs.data; + final ineqCount = ineqs.count; + for (int i = 0; i < ineqCount; i++) { + final a = ineqData[3 * i]; + final b = ineqData[3 * i + 1]; + final coef = a + b * k; + if (coef.abs() < 1e-12) continue; + final bound = ineqData[3 * i + 2] / coef; + if (coef > 0 && bound < wUpper) wUpper = bound; + if (coef < 0 && bound > wLower) wLower = bound; + } + // Sanitize: at constraint-saturation boundaries the feasible interval for + // w can degenerate to a single point (e.g. minH/k == maxW == 500). Finite + // arithmetic can leave wLower above wUpper by a few ULP; treat that case + // as the intended point. If they're separated by more than a reasonable + // epsilon, the feasible region genuinely is empty — fall back to the + // current size rather than throwing. + if (wLower > wUpper) { + if ((wLower - wUpper) <= 1e-6 * max(1.0, wUpper.abs())) { + final mid = (wLower + wUpper) / 2; + wLower = mid; + wUpper = mid; + } else { + wLower = desiredW; + wUpper = desiredW; + } + } + final w = desiredW.clamp(wLower, wUpper).toDouble(); + final h = w * k; + + final newRect = Box.fromLTRB( + centerWorld.x - w / 2, + centerWorld.y - h / 2, + centerWorld.x + w / 2, + centerWorld.y + h / 2, + rotation: rotation, + ); + // SymmetricScale flip: pointer crossed centerWorld on the handle's + // natural axis (same logic as symmetric). + final bool xFlipped = handle.influencesRight + ? currentLocal.x < centerWorld.x + : (handle.influencesLeft ? currentLocal.x > centerWorld.x : false); + final bool yFlipped = handle.influencesBottom + ? currentLocal.y < centerWorld.y + : (handle.influencesTop ? currentLocal.y > centerWorld.y : false); + final Flip currentFlip = allowFlipping + ? Flip.fromValue(xFlipped ? -1 : 1, yFlipped ? -1 : 1) + : Flip.none; + + return ResizeResult( + rect: newRect, + oldRect: initialRect, + delta: localPosition - initialLocalPosition, + flip: currentFlip * initialFlip, + resizeMode: ResizeMode.symmetricScale, + rawSize: Dimension(w, h), + minWidthReached: (w - wLower).abs() < 1e-9, + maxWidthReached: wUpper.isFinite && (w - wUpper).abs() < 1e-9, + minHeightReached: effMinH > 0 && (h - effMinH).abs() < 1e-9, + maxHeightReached: constraints.maxHeight.isFinite && + (h - constraints.maxHeight).abs() < 1e-9, + largestRect: clampingRect, + handle: handle, + rotation: rotation, + boundingRect: ClampHelpers.calculateBoundingRect(newRect), + oldBoundingRect: ClampHelpers.calculateBoundingRect(initialRect), + ); + } } diff --git a/packages/box_transform/pubspec.yaml b/packages/box_transform/pubspec.yaml index 0d2d084..e5d27be 100644 --- a/packages/box_transform/pubspec.yaml +++ b/packages/box_transform/pubspec.yaml @@ -9,13 +9,15 @@ issue_tracker: https://github.com/hyper-designed/box_transform/issues documentation: https://docs.page/hyper-designed/box_transform environment: - sdk: '>=3.0.0 <4.0.0' + sdk: ^3.6.0 + +resolution: workspace dependencies: vector_math: ^2.1.4 dev_dependencies: - lints: ^5.1.1 + lints: ^6.0.0 test: ^1.25.15 coverage: ^1.11.1 diff --git a/packages/box_transform/test/alternating_constraints_resizing_test.dart b/packages/box_transform/test/alternating_constraints_resizing_test.dart index 97f18cc..1dcaa23 100644 --- a/packages/box_transform/test/alternating_constraints_resizing_test.dart +++ b/packages/box_transform/test/alternating_constraints_resizing_test.dart @@ -1,6 +1,6 @@ import 'package:box_transform/box_transform.dart'; import 'package:test/test.dart'; -import 'package:vector_math/vector_math.dart'; +import 'package:vector_math/vector_math_64.dart'; import 'utils.dart'; diff --git a/packages/box_transform/test/available_area_clamping_test.dart b/packages/box_transform/test/available_area_clamping_test.dart index 55626fb..9dc98b6 100644 --- a/packages/box_transform/test/available_area_clamping_test.dart +++ b/packages/box_transform/test/available_area_clamping_test.dart @@ -1,6 +1,6 @@ import 'package:box_transform/box_transform.dart'; import 'package:test/test.dart'; -import 'package:vector_math/vector_math.dart'; +import 'package:vector_math/vector_math_64.dart'; import 'utils.dart'; diff --git a/packages/box_transform/test/box_dragging_test.dart b/packages/box_transform/test/box_dragging_test.dart index bf52fd1..f6dd888 100644 --- a/packages/box_transform/test/box_dragging_test.dart +++ b/packages/box_transform/test/box_dragging_test.dart @@ -1,6 +1,6 @@ import 'package:box_transform/box_transform.dart'; import 'package:test/test.dart'; -import 'package:vector_math/vector_math.dart'; +import 'package:vector_math/vector_math_64.dart'; void main() { test('Drag a box', () { diff --git a/packages/box_transform/test/clamp_at_saturation_move_test.dart b/packages/box_transform/test/clamp_at_saturation_move_test.dart new file mode 100644 index 0000000..b1e60ec --- /dev/null +++ b/packages/box_transform/test/clamp_at_saturation_move_test.dart @@ -0,0 +1,127 @@ +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:box_transform/box_transform.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math_64.dart'; + +/// Regression: when the clamping rect is exactly the size of the rotated +/// box's bounding rect (the minimum that still contains the box), a +/// zero-delta `move()` should leave the box in place. Before this fix, the +/// per-corner `tx`/`ty` intervals were applied sequentially — at saturation +/// they degenerate to a single point, and float precision could invert the +/// order, making the sequential loop produce a `tx` that violates earlier +/// corners. Visible symptom in the playground: at the very last tick of +/// shrinking the clamping rect, the box "flicks" to an offset outside the +/// clamp before restoring when the user gives it breathing room. +void main() { + /// Quantise via Float32List to simulate the residual drift that could have + /// crept in through `Vector2` math in historical builds. + double asFloat32(double v) { + final buf = Float32List(1); + buf[0] = v; + return buf[0]; + } + + test('rotated move with clamp at exact bounding-rect saturation: no jump', + () { + const theta = math.pi / 6; + final rect = Box.fromLTWH(200, 200, 100, 100, rotation: theta); + final bounding = ClampHelpers.calculateBoundingRect(rect); + // Clamping rect exactly equal to the rotated box's bounding rect — + // the feasible tx/ty interval is a single point (0, 0). + final clamp = Box.fromLTRB( + bounding.left, + bounding.top, + bounding.right, + bounding.bottom, + ); + + final result = BoxTransformer.move( + initialRect: rect, + initialLocalPosition: Vector2(250, 250), + localPosition: Vector2(250, 250), + clampingRect: clamp, + rotation: theta, + bindingStrategy: BindingStrategy.boundingBox, + ); + + expect(result.rect.left, closeTo(rect.left, 1e-9), + reason: 'zero-delta move at clamp saturation must not shift left'); + expect(result.rect.top, closeTo(rect.top, 1e-9), + reason: 'zero-delta move at clamp saturation must not shift top'); + expect(result.rect.width, closeTo(rect.width, 1e-9)); + expect(result.rect.height, closeTo(rect.height, 1e-9)); + }); + + test('rotated move with infeasible clamp: no order-dependent "flick"', () { + // Reproduces the user's playground report: when the clamp shrinks to a + // size TIGHTER than the rotated box's bounding rect (even by a few + // pixels), the box "flicks" to an offset that's outside the clamp on + // one side instead of staying close to its last valid position. + // + // Root cause: the per-corner tx/ty constraints are applied sequentially + // (`tx = max(tx, leftLimit); tx = min(tx, rightLimit)` per corner), + // which is order-dependent when the global intersection is empty. Two + // opposite corners push tx in opposite directions; the LAST corner + // visited wins, producing a shift that violates earlier corners. + // + // Example values: 100x100 box at theta=pi/6 centered at (250,250). + // Clamp shrunk symmetrically by 5px on each side (10px infeasibility). + // True global intersection of tx is [5, -5] (empty); the correct fallback + // is tx=0 (midpoint of the collapsed point). Sequential max/min gives + // tx=5 (the last-visited corner's lower bound), shifting the box 5px + // outside the clamp's right edge. + const theta = math.pi / 6; + final rect = Box.fromLTWH(200, 200, 100, 100, rotation: theta); + final bounding = ClampHelpers.calculateBoundingRect(rect); + final clamp = Box.fromLTRB( + bounding.left + 5, + bounding.top + 5, + bounding.right - 5, + bounding.bottom - 5, + ); + + final result = BoxTransformer.move( + initialRect: rect, + initialLocalPosition: Vector2(250, 250), + localPosition: Vector2(250, 250), + clampingRect: clamp, + rotation: theta, + bindingStrategy: BindingStrategy.boundingBox, + ); + + // Correct: tx/ty should be ~0 (midpoint of the empty [+5, -5] interval). + // Current buggy sequential max/min produces tx=5 (last corner wins), + // flicking the box to the right. Assert we're within 1 px of the initial + // position — the sequential bug would shift by ~5 px. + expect(result.rect.left, closeTo(rect.left, 1.0), + reason: 'box must not flick by more than the infeasibility gap'); + expect(result.rect.top, closeTo(rect.top, 1.0)); + }); + + test('rotated move with float-drifted saturation: no throw, no jump', () { + // Same guard against Float32-ULP drift remnants — even post vector_math_64 + // migration the solver must tolerate eps-scale precision wiggles. + const theta = math.pi / 6; + final rect = Box.fromLTWH(200, 200, 100, 100, rotation: theta); + final bounding = ClampHelpers.calculateBoundingRect(rect); + final clamp = Box.fromLTRB( + asFloat32(bounding.left + 1e-6), + asFloat32(bounding.top + 1e-6), + asFloat32(bounding.right - 1e-6), + asFloat32(bounding.bottom - 1e-6), + ); + + final result = BoxTransformer.move( + initialRect: rect, + initialLocalPosition: Vector2(250, 250), + localPosition: Vector2(250, 250), + clampingRect: clamp, + rotation: theta, + bindingStrategy: BindingStrategy.boundingBox, + ); + expect(result.rect.left, closeTo(rect.left, 1e-3)); + expect(result.rect.top, closeTo(rect.top, 1e-3)); + }); +} diff --git a/packages/box_transform/test/clamp_invariants_test.dart b/packages/box_transform/test/clamp_invariants_test.dart new file mode 100644 index 0000000..fc0db52 --- /dev/null +++ b/packages/box_transform/test/clamp_invariants_test.dart @@ -0,0 +1,295 @@ +import 'package:box_transform/box_transform.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math_64.dart'; + +// Stress-tests for clamp containment under gestures that historically +// produced visibly wrong on-screen states: force-flip into infeasible +// regions, side handles hugging clamp edges, and gestures that the +// recorder captured as "successful" but where the resulting rect either +// leaked the clamp or changed dimensions a side handle should never touch. +// +// Each test asserts engine *invariants* — clamp containment, side-handle +// scope, constraint compliance — rather than specific recorded values, so +// the tests survive engine refinement that produces a different (but +// still correct) rect. + +void main() { + void expectInsideClamp(Box rect, Box clamp, {String? reason}) { + expect(rect.left, greaterThanOrEqualTo(clamp.left - 1e-3), + reason: '${reason ?? ''}: rect.left=${rect.left} ' + 'clamp.left=${clamp.left}'); + expect(rect.top, greaterThanOrEqualTo(clamp.top - 1e-3), + reason: '${reason ?? ''}: rect.top=${rect.top} ' + 'clamp.top=${clamp.top}'); + expect(rect.right, lessThanOrEqualTo(clamp.right + 1e-3), + reason: '${reason ?? ''}: rect.right=${rect.right} ' + 'clamp.right=${clamp.right}'); + expect(rect.bottom, lessThanOrEqualTo(clamp.bottom + 1e-3), + reason: '${reason ?? ''}: rect.bottom=${rect.bottom} ' + 'clamp.bottom=${clamp.bottom}'); + } + + void expectRespectsConstraints(Box rect, Constraints c, {String? reason}) { + expect(rect.width, greaterThanOrEqualTo(c.minWidth - 1e-3), + reason: '${reason ?? ''}: width=${rect.width} < ' + 'minWidth=${c.minWidth}'); + expect(rect.width, lessThanOrEqualTo(c.maxWidth + 1e-3), + reason: '${reason ?? ''}: width=${rect.width} > ' + 'maxWidth=${c.maxWidth}'); + expect(rect.height, greaterThanOrEqualTo(c.minHeight - 1e-3), + reason: '${reason ?? ''}: height=${rect.height} < ' + 'minHeight=${c.minHeight}'); + expect(rect.height, lessThanOrEqualTo(c.maxHeight + 1e-3), + reason: '${reason ?? ''}: height=${rect.height} > ' + 'maxHeight=${c.maxHeight}'); + } + + // Recorded playground scenarios that captured visibly buggy on-screen + // states. The recorder used those states as expected values; here we + // re-run the same gesture inputs and assert the engine produces output + // that respects clamp containment, side-handle scope, and constraints. + group('recorded playground scenarios — engine respects invariants', () { + final clamp = Box.fromLTWH(264, 228, 655, 440); + const constraints = Constraints( + minWidth: 100, + minHeight: 100, + maxWidth: 500, + maxHeight: 500, + ); + + test( + 'topLeft corner force-flip into infeasible diagonal stays in clamp', + () { + // Drag topLeft handle by (+313, +382). Cursor crosses bottomRight + // anchor on both axes → diagonal flip requested. Min size 100×100 + // anchored at (761, 614) facing down-right would have bottom=714, + // past clamp.bottom=668 — infeasible. Engine must NOT leak. + final result = BoxTransformer.resize( + resizeMode: ResizeMode.freeform, + initialFlip: Flip.none, + initialRect: Box.fromLTWH(517, 494, 244, 120), + handle: HandlePosition.topLeft, + initialLocalPosition: Vector2(13, 14), + allowFlipping: true, + localPosition: Vector2(326, 396), + clampingRect: clamp, + constraints: constraints, + ); + expectInsideClamp(result.rect, clamp); + expectRespectsConstraints(result.rect, constraints); + }, + ); + + test( + 'left side handle past clamp.left changes only width', + () { + // Side handles must never touch the perpendicular axis. Recorded + // expected leaked top/bottom/right; engine must keep them pinned. + const initial = (left: 344.0, top: 516.0, w: 167.0, h: 118.0); + final result = BoxTransformer.resize( + resizeMode: ResizeMode.freeform, + initialFlip: Flip.none, + initialRect: + Box.fromLTWH(initial.left, initial.top, initial.w, initial.h), + handle: HandlePosition.left, + initialLocalPosition: Vector2(10, 51), + allowFlipping: true, + localPosition: Vector2(-168, 126), + clampingRect: clamp, + constraints: constraints, + ); + expectInsideClamp(result.rect, clamp); + expectRespectsConstraints(result.rect, constraints); + expect(result.rect.top, closeTo(initial.top, 1e-3), + reason: 'left handle moved top axis'); + expect(result.rect.bottom, closeTo(initial.top + initial.h, 1e-3), + reason: 'left handle moved bottom axis'); + expect(result.rect.right, closeTo(initial.left + initial.w, 1e-3), + reason: 'left handle moved right axis (anchor)'); + }, + ); + + test( + 'top side handle vertical force-flip stays in clamp', + () { + // Top handle dragged DOWN past bottomCenter anchor → vertical + // flip. Recorded expected leaked clamp.bottom by 32 px; must not. + final result = BoxTransformer.resize( + resizeMode: ResizeMode.freeform, + initialFlip: Flip.none, + initialRect: Box.fromLTWH(277, 498, 239, 118), + handle: HandlePosition.top, + initialLocalPosition: Vector2(103, 17), + allowFlipping: true, + localPosition: Vector2(467, 409), + clampingRect: clamp, + constraints: constraints, + ); + expectInsideClamp(result.rect, clamp); + expectRespectsConstraints(result.rect, constraints); + }, + ); + + test( + 'multi-step gesture sequence stays in clamp at every step', + () { + // Replays the original recorded sequence using the engine's actual + // output as each step's input — i.e. what the user would actually + // see frame-to-frame. The recorded values fed each step's *buggy* + // output forward as the next step's input; this version uses real + // engine output so we exercise the chain from valid intermediates. + var step = BoxTransformer.resize( + resizeMode: ResizeMode.freeform, + initialFlip: Flip.none, + initialRect: Box.fromLTWH(344, 516, 167, 118), + handle: HandlePosition.left, + initialLocalPosition: Vector2(10, 51), + allowFlipping: true, + localPosition: Vector2(-168, 126), + clampingRect: clamp, + constraints: constraints, + ); + expectInsideClamp(step.rect, clamp, reason: 'step 1 (left)'); + expectRespectsConstraints(step.rect, constraints, + reason: 'step 1 (left)'); + + step = BoxTransformer.resize( + resizeMode: ResizeMode.freeform, + initialFlip: step.flip, + initialRect: step.rect, + handle: HandlePosition.top, + initialLocalPosition: Vector2(103, 17), + allowFlipping: true, + localPosition: Vector2(467, 409), + clampingRect: clamp, + constraints: constraints, + ); + expectInsideClamp(step.rect, clamp, reason: 'step 2 (top flip)'); + expectRespectsConstraints(step.rect, constraints, + reason: 'step 2 (top flip)'); + + step = BoxTransformer.resize( + resizeMode: ResizeMode.freeform, + initialFlip: step.flip, + initialRect: step.rect, + handle: HandlePosition.bottomLeft, + initialLocalPosition: Vector2(16, 12), + allowFlipping: true, + localPosition: Vector2(210, 147), + clampingRect: clamp, + constraints: constraints, + ); + expectInsideClamp(step.rect, clamp, reason: 'step 3 (bottomLeft)'); + expectRespectsConstraints(step.rect, constraints, + reason: 'step 3 (bottomLeft)'); + }, + ); + }); + + // Force-flip fallback: when the cursor is past the anchor but the + // flipped state can't fit clamp + constraints, the engine retries the + // LP with natural-direction signs. The rect then tracks the cursor by + // clamp-pinning at the natural wall — feasible=true, flip=none. The + // engine only signals feasible=false when no direction works. + group('force-flip fallback: natural direction when flip is infeasible', () { + test( + 'flip-impossible cursor reverts to natural direction (no flip, ' + 'clamp-pinned), feasible=true', + () { + const theta = 0.3; // ~17° + final result = BoxTransformer.resize( + resizeMode: ResizeMode.freeform, + initialFlip: Flip.none, + initialRect: Box.fromLTWH(40, 40, 100, 100, rotation: theta), + handle: HandlePosition.topLeft, + initialLocalPosition: Vector2(40, 40), + allowFlipping: true, + // Drag topLeft cursor far past bottomRight anchor diagonally. + // Min 100×100 flipped at this rotation cannot fit clamp. + localPosition: Vector2(400, 400), + rotation: theta, + bindingStrategy: BindingStrategy.boundingBox, + clampingRect: Box.fromLTRB(0, 0, 200, 200), + constraints: const Constraints( + minWidth: 100, + minHeight: 100, + maxWidth: 500, + maxHeight: 500, + ), + ); + // The engine falls back to the natural (un-flipped) direction. + expect(result.feasible, isTrue, + reason: 'natural-direction fallback must be feasible'); + expect(result.flip, equals(Flip.none), + reason: 'fallback rect is NOT flipped (force-flip aborted)'); + expectInsideClamp(result.rect, Box.fromLTRB(0, 0, 200, 200), + reason: 'fallback rect must stay inside clamp'); + }, + ); + + test( + 'cursor far past anchor in flip direction does NOT grow rect in ' + 'the opposite direction (regression: fallback used cursor distance ' + 'as natural-direction target, growing rect away from cursor)', + () { + // bottomRight handle, anchor = topLeft world. Drag the cursor far + // past the anchor in the flip direction (-x, -y). Force-flip is + // infeasible (would extend past clamp.left/top). Without the + // axis-zeroing fix, the natural-direction fallback would use + // |cursor - anchor| as the natural target, producing a rect that + // grows toward (+x, +y) — the OPPOSITE of where the cursor went. + const theta = 0.3; + final result = BoxTransformer.resize( + resizeMode: ResizeMode.freeform, + initialFlip: Flip.none, + initialRect: Box.fromLTWH(20, 20, 80, 80, rotation: theta), + handle: HandlePosition.bottomRight, + // Cursor starts at the rotated bottomRight world position. + initialLocalPosition: Vector2(100, 100), + allowFlipping: true, + // Drag cursor far past topLeft anchor in -x, -y direction. + localPosition: Vector2(-2000, -2000), + rotation: theta, + bindingStrategy: BindingStrategy.boundingBox, + clampingRect: Box.fromLTRB(0, 0, 200, 200), + constraints: const Constraints( + minWidth: 60, + minHeight: 60, + maxWidth: 500, + maxHeight: 500, + ), + ); + expect(result.feasible, isTrue); + expect(result.flip, equals(Flip.none)); + // The rect must NOT have grown larger than initial in the natural + // direction. With cursor far in flip territory, the fallback + // collapses each flipped axis to minimum, so the rect should be + // at most the gesture-start size on each axis (often smaller). + expect(result.rect.width, lessThanOrEqualTo(80 + 1e-3), + reason: 'rect must not grow in opposite direction of cursor'); + expect(result.rect.height, lessThanOrEqualTo(80 + 1e-3), + reason: 'rect must not grow in opposite direction of cursor'); + }, + ); + + test( + 'rotated freeform: small feasible target returns feasible=true', + () { + const theta = 0.3; + final result = BoxTransformer.resize( + resizeMode: ResizeMode.freeform, + initialFlip: Flip.none, + initialRect: Box.fromLTWH(50, 50, 80, 80, rotation: theta), + handle: HandlePosition.bottomRight, + initialLocalPosition: Vector2(130, 130), + allowFlipping: true, + localPosition: Vector2(140, 140), + rotation: theta, + bindingStrategy: BindingStrategy.boundingBox, + clampingRect: Box.fromLTRB(0, 0, 500, 500), + ); + expect(result.feasible, isTrue); + }, + ); + }); +} diff --git a/packages/box_transform/test/clamp_saturation_regression_test.dart b/packages/box_transform/test/clamp_saturation_regression_test.dart new file mode 100644 index 0000000..88ca015 --- /dev/null +++ b/packages/box_transform/test/clamp_saturation_regression_test.dart @@ -0,0 +1,119 @@ +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:box_transform/box_transform.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math_64.dart'; + +/// Regression for the playground crash: +/// +/// Invalid argument(s): 500.00001525878906 +/// #0 double.clamp +/// #1 BoxTransformer._resizeRotatedScale (transformer.dart:662) +/// #2 BoxTransformer.resize (transformer.dart:146) +/// #4 TransformableBoxController.recalculateSize (controller.dart:441) +/// +/// Chain (per Codex audit): `vector_math` backs `Vector2` in a Float32List, +/// so every read/write quantises to 32-bit. Rotation helpers and corner +/// offset math round-trip through `Vector2`, drifting the returned Box's +/// edges. On the next rebuild, `recalculateSize` computes +/// `k = rect.height / rect.width` from drifted edges (so k ≠ exactly 0.2), +/// then `effMinH / k` over-shoots `maxWidth` by ~1e-5, producing an empty +/// interval [wLower > wUpper] that throws in `.clamp`. +/// +/// These tests force the scenario by constructing a rect whose edges match +/// what the Float32 pipeline produces after saturation. +void main() { + /// Rounds [v] through a Float32List to simulate Vector2's storage. + double asFloat32(double v) { + final buf = Float32List(1); + buf[0] = v; + return buf[0]; + } + + test('rotated scale at aspect-saturation: zero-delta resize does not throw', + () { + // At saturation with maxW=500 and h = minH = 100, the feasible w is a + // mathematical point at exactly 500. But a rect whose height has been + // round-tripped through Float32 will have h = Float32(100)*something or + // near-100 with drift in either direction. Here we simulate h drifting + // *below* 100 (Float32 step down), which makes k = h/w < 0.2, which in + // turn makes effMinH / k > 500 > maxWidth → wLower > wUpper. + const double maxW = 500.0; + const double minH = 100.0; + // One Float32-ULP below 100. Dart parses this as the nearest double. + final double driftedHeight = asFloat32(100.0 - 1e-5); + expect(driftedHeight < 100.0, isTrue, + reason: 'test pre-condition: need drift below exact 100'); + + final Box initialRect = Box.fromLTWH( + 0, + 0, + maxW, + driftedHeight, + rotation: math.pi / 6, + ); + final constraints = Constraints( + minWidth: 100, + minHeight: minH, + maxWidth: maxW, + maxHeight: maxW, + ); + + expect( + () => BoxTransformer.resize( + initialRect: initialRect, + initialLocalPosition: Vector2(maxW, driftedHeight), + localPosition: Vector2(maxW, driftedHeight), + handle: HandlePosition.bottomRight, + resizeMode: ResizeMode.scale, + initialFlip: Flip.none, + clampingRect: Box.fromLTRB(-1e6, -1e6, 1e6, 1e6), + constraints: constraints, + allowFlipping: false, + rotation: math.pi / 6, + ), + returnsNormally, + reason: 'zero-delta resize at aspect-saturation must not throw on ' + 'float-precision drift', + ); + }); + + test('rotated symmetric-scale at aspect-saturation: no throw', () { + // Same crash class exists at transformer.dart:823. + const double maxW = 500.0; + const double minH = 100.0; + final double driftedHeight = asFloat32(100.0 - 1e-5); + + final Box initialRect = Box.fromLTWH( + 0, + 0, + maxW, + driftedHeight, + rotation: math.pi / 6, + ); + final constraints = Constraints( + minWidth: 100, + minHeight: minH, + maxWidth: maxW, + maxHeight: maxW, + ); + + expect( + () => BoxTransformer.resize( + initialRect: initialRect, + initialLocalPosition: Vector2(maxW / 2, driftedHeight / 2), + localPosition: Vector2(maxW / 2, driftedHeight / 2), + handle: HandlePosition.bottomRight, + resizeMode: ResizeMode.symmetricScale, + initialFlip: Flip.none, + clampingRect: Box.fromLTRB(-1e6, -1e6, 1e6, 1e6), + constraints: constraints, + allowFlipping: false, + rotation: math.pi / 6, + ), + returnsNormally, + reason: 'symmetric-scale zero-delta at saturation must not throw', + ); + }); +} diff --git a/packages/box_transform/test/clamped_freeform_resizing_test.dart b/packages/box_transform/test/clamped_freeform_resizing_test.dart index abdbf22..9f34d4d 100644 --- a/packages/box_transform/test/clamped_freeform_resizing_test.dart +++ b/packages/box_transform/test/clamped_freeform_resizing_test.dart @@ -1,6 +1,6 @@ import 'package:box_transform/box_transform.dart'; import 'package:test/test.dart'; -import 'package:vector_math/vector_math.dart'; +import 'package:vector_math/vector_math_64.dart'; import 'utils.dart'; diff --git a/packages/box_transform/test/clamped_scaled_resizing_test.dart b/packages/box_transform/test/clamped_scaled_resizing_test.dart index 10c5143..85a827e 100644 --- a/packages/box_transform/test/clamped_scaled_resizing_test.dart +++ b/packages/box_transform/test/clamped_scaled_resizing_test.dart @@ -1,6 +1,6 @@ import 'package:box_transform/box_transform.dart'; import 'package:test/test.dart'; -import 'package:vector_math/vector_math.dart'; +import 'package:vector_math/vector_math_64.dart'; import 'utils.dart'; diff --git a/packages/box_transform/test/clamped_symmetric_resizing_test.dart b/packages/box_transform/test/clamped_symmetric_resizing_test.dart index f1f2cb1..606c8db 100644 --- a/packages/box_transform/test/clamped_symmetric_resizing_test.dart +++ b/packages/box_transform/test/clamped_symmetric_resizing_test.dart @@ -1,6 +1,6 @@ import 'package:box_transform/box_transform.dart'; import 'package:test/test.dart'; -import 'package:vector_math/vector_math.dart'; +import 'package:vector_math/vector_math_64.dart'; import 'utils.dart'; diff --git a/packages/box_transform/test/clamped_symmetrically_scaled_resizing_test.dart b/packages/box_transform/test/clamped_symmetrically_scaled_resizing_test.dart index a8488a0..eccc9c9 100644 --- a/packages/box_transform/test/clamped_symmetrically_scaled_resizing_test.dart +++ b/packages/box_transform/test/clamped_symmetrically_scaled_resizing_test.dart @@ -1,6 +1,6 @@ import 'package:box_transform/box_transform.dart'; import 'package:test/test.dart'; -import 'package:vector_math/vector_math.dart'; +import 'package:vector_math/vector_math_64.dart'; import 'utils.dart'; diff --git a/packages/box_transform/test/enums_test.dart b/packages/box_transform/test/enums_test.dart index c054d1a..888afe1 100644 --- a/packages/box_transform/test/enums_test.dart +++ b/packages/box_transform/test/enums_test.dart @@ -2,7 +2,7 @@ import 'dart:math'; import 'package:box_transform/box_transform.dart'; import 'package:test/test.dart'; -import 'package:vector_math/vector_math.dart'; +import 'package:vector_math/vector_math_64.dart'; void main() { group('HandlePosition tests', () { @@ -441,4 +441,16 @@ void main() { expect(ResizeMode.symmetricScale.hasSymmetry, isTrue); }); }); + + group('BindingStrategy tests', () { + test('has two values with correct names', () { + expect(BindingStrategy.values.length, 2); + expect(BindingStrategy.values, contains(BindingStrategy.originalBox)); + expect(BindingStrategy.values, contains(BindingStrategy.boundingBox)); + }); + + test('originalBox is the documented default (first value)', () { + expect(BindingStrategy.values.first, BindingStrategy.originalBox); + }); + }); } diff --git a/packages/box_transform/test/extensions_test.dart b/packages/box_transform/test/extensions_test.dart index f9eb860..1118133 100644 --- a/packages/box_transform/test/extensions_test.dart +++ b/packages/box_transform/test/extensions_test.dart @@ -1,6 +1,6 @@ import 'package:box_transform/box_transform.dart'; import 'package:test/test.dart'; -import 'package:vector_math/vector_math.dart'; +import 'package:vector_math/vector_math_64.dart'; void main() { test('RawTransformResult extension tests', () { diff --git a/packages/box_transform/test/freeform_constrained_resizing_test.dart b/packages/box_transform/test/freeform_constrained_resizing_test.dart index 0200105..f21504c 100644 --- a/packages/box_transform/test/freeform_constrained_resizing_test.dart +++ b/packages/box_transform/test/freeform_constrained_resizing_test.dart @@ -1,6 +1,6 @@ import 'package:box_transform/box_transform.dart'; import 'package:test/test.dart'; -import 'package:vector_math/vector_math.dart'; +import 'package:vector_math/vector_math_64.dart'; import 'utils.dart'; diff --git a/packages/box_transform/test/freeform_resizing_test.dart b/packages/box_transform/test/freeform_resizing_test.dart index cf10695..8d8668e 100644 --- a/packages/box_transform/test/freeform_resizing_test.dart +++ b/packages/box_transform/test/freeform_resizing_test.dart @@ -1,6 +1,6 @@ import 'package:box_transform/box_transform.dart'; import 'package:test/test.dart'; -import 'package:vector_math/vector_math.dart'; +import 'package:vector_math/vector_math_64.dart'; import 'utils.dart'; diff --git a/packages/box_transform/test/geometry_test.dart b/packages/box_transform/test/geometry_test.dart index c04501e..abd7a86 100644 --- a/packages/box_transform/test/geometry_test.dart +++ b/packages/box_transform/test/geometry_test.dart @@ -1,6 +1,6 @@ import 'package:box_transform/box_transform.dart'; import 'package:test/test.dart'; -import 'package:vector_math/vector_math.dart'; +import 'package:vector_math/vector_math_64.dart'; void main() { group('Dimension tests', () { @@ -786,4 +786,35 @@ void main() { Box.fromLTRB(100, 100, 400, 300)); }); }); + + group('Box rotation field', () { + test('defaults to 0.0 for all constructors', () { + expect(Box.fromLTRB(0, 0, 100, 100).rotation, 0.0); + expect(Box.fromLTWH(0, 0, 100, 100).rotation, 0.0); + expect( + Box.fromCenter(center: Vector2.zero(), width: 10, height: 10) + .rotation, + 0.0); + expect(Box.fromPoints(Vector2.zero(), Vector2(10, 10)).rotation, 0.0); + expect(Box.zero.rotation, 0.0); + expect(Box.largest.rotation, 0.0); + }); + + test('can be constructed with explicit rotation', () { + final b = Box.fromLTRB(0, 0, 100, 100, rotation: 1.23); + expect(b.rotation, 1.23); + }); + + test('equality distinguishes rotation', () { + final a = Box.fromLTRB(0, 0, 100, 100); + final b = Box.fromLTRB(0, 0, 100, 100, rotation: 0.5); + expect(a, isNot(equals(b))); + expect(a.hashCode, isNot(equals(b.hashCode))); + }); + + test('translate preserves rotation', () { + final b = Box.fromLTRB(0, 0, 100, 100, rotation: 0.5); + expect(b.translate(10, 20).rotation, 0.5); + }); + }); } diff --git a/packages/box_transform/test/helpers_test.dart b/packages/box_transform/test/helpers_test.dart index 04e9d29..93a401c 100644 --- a/packages/box_transform/test/helpers_test.dart +++ b/packages/box_transform/test/helpers_test.dart @@ -1,70 +1,86 @@ import 'package:box_transform/box_transform.dart'; import 'package:test/test.dart'; -import 'package:vector_math/vector_math.dart'; +import 'package:vector_math/vector_math_64.dart'; void main() { group('flipBox tests', () { test('flipBox test with bottom-right handle', () { final box = Box.fromLTWH(500, 500, 400, 300); - Box flipped = flipRect(box, Flip.none, HandlePosition.bottomRight); + Box flipped = + ClampHelpers.flipRect(box, Flip.none, HandlePosition.bottomRight); expect(box, flipped); - flipped = flipRect(box, Flip.horizontal, HandlePosition.bottomRight); + flipped = ClampHelpers.flipRect( + box, Flip.horizontal, HandlePosition.bottomRight); expect(flipped.topLeft, Vector2(100, 500)); - flipped = flipRect(box, Flip.vertical, HandlePosition.bottomRight); + flipped = + ClampHelpers.flipRect(box, Flip.vertical, HandlePosition.bottomRight); expect(flipped.topLeft, Vector2(500, 200)); - flipped = flipRect(box, Flip.diagonal, HandlePosition.bottomRight); + flipped = + ClampHelpers.flipRect(box, Flip.diagonal, HandlePosition.bottomRight); expect(flipped.topLeft, Vector2(100, 200)); }); test('flipBox test with top-right handle', () { final box = Box.fromLTWH(500, 500, 400, 300); - Box flipped = flipRect(box, Flip.none, HandlePosition.topRight); + Box flipped = + ClampHelpers.flipRect(box, Flip.none, HandlePosition.topRight); expect(box, flipped); - flipped = flipRect(box, Flip.horizontal, HandlePosition.topRight); + flipped = + ClampHelpers.flipRect(box, Flip.horizontal, HandlePosition.topRight); expect(flipped.topLeft, Vector2(100, 500)); - flipped = flipRect(box, Flip.vertical, HandlePosition.topRight); + flipped = + ClampHelpers.flipRect(box, Flip.vertical, HandlePosition.topRight); expect(flipped.topLeft, Vector2(500, 800)); - flipped = flipRect(box, Flip.diagonal, HandlePosition.topRight); + flipped = + ClampHelpers.flipRect(box, Flip.diagonal, HandlePosition.topRight); expect(flipped.topLeft, Vector2(100, 800)); }); test('flipBox test with top-left handle', () { final box = Box.fromLTWH(500, 500, 400, 300); - Box flipped = flipRect(box, Flip.none, HandlePosition.topLeft); + Box flipped = + ClampHelpers.flipRect(box, Flip.none, HandlePosition.topLeft); expect(box, flipped); - flipped = flipRect(box, Flip.horizontal, HandlePosition.topLeft); + flipped = + ClampHelpers.flipRect(box, Flip.horizontal, HandlePosition.topLeft); expect(flipped.topLeft, Vector2(900, 500)); - flipped = flipRect(box, Flip.vertical, HandlePosition.topLeft); + flipped = + ClampHelpers.flipRect(box, Flip.vertical, HandlePosition.topLeft); expect(flipped.topLeft, Vector2(500, 800)); - flipped = flipRect(box, Flip.diagonal, HandlePosition.topLeft); + flipped = + ClampHelpers.flipRect(box, Flip.diagonal, HandlePosition.topLeft); expect(flipped.topLeft, Vector2(900, 800)); }); test('flipBox test with bottom-left handle', () { final box = Box.fromLTWH(500, 500, 400, 300); - Box flipped = flipRect(box, Flip.none, HandlePosition.bottomLeft); + Box flipped = + ClampHelpers.flipRect(box, Flip.none, HandlePosition.bottomLeft); expect(box, flipped); - flipped = flipRect(box, Flip.horizontal, HandlePosition.bottomLeft); + flipped = ClampHelpers.flipRect( + box, Flip.horizontal, HandlePosition.bottomLeft); expect(flipped.topLeft, Vector2(900, 500)); - flipped = flipRect(box, Flip.vertical, HandlePosition.bottomLeft); + flipped = + ClampHelpers.flipRect(box, Flip.vertical, HandlePosition.bottomLeft); expect(flipped.topLeft, Vector2(500, 200)); - flipped = flipRect(box, Flip.diagonal, HandlePosition.bottomLeft); + flipped = + ClampHelpers.flipRect(box, Flip.diagonal, HandlePosition.bottomLeft); expect(flipped.topLeft, Vector2(900, 200)); }); }); @@ -73,7 +89,7 @@ void main() { test('getFlipForBox test for bottom-right handle', () { final box = Box.fromLTWH(500, 500, 400, 300); - Flip flip = getFlipForRect( + Flip flip = ClampHelpers.getFlipForRect( box, Vector2(-500, -400), HandlePosition.bottomRight, @@ -81,7 +97,7 @@ void main() { ); expect(flip, Flip.diagonal); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(-500, 10), HandlePosition.bottomRight, @@ -89,7 +105,7 @@ void main() { ); expect(flip, Flip.horizontal); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(10, -400), HandlePosition.bottomRight, @@ -97,7 +113,7 @@ void main() { ); expect(flip, Flip.vertical); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(10, 10), HandlePosition.bottomRight, @@ -105,7 +121,7 @@ void main() { ); expect(flip, Flip.none); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(500, 500), HandlePosition.topLeft, @@ -113,7 +129,7 @@ void main() { ); expect(flip, Flip.diagonal); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(500, -10), HandlePosition.topLeft, @@ -121,7 +137,7 @@ void main() { ); expect(flip, Flip.horizontal); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(-10, 500), HandlePosition.topLeft, @@ -129,7 +145,7 @@ void main() { ); expect(flip, Flip.vertical); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(-10, -10), HandlePosition.topLeft, @@ -137,7 +153,7 @@ void main() { ); expect(flip, Flip.none); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(10, -10), HandlePosition.topRight, @@ -145,7 +161,7 @@ void main() { ); expect(flip, Flip.none); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(10, 500), HandlePosition.topRight, @@ -153,7 +169,7 @@ void main() { ); expect(flip, Flip.vertical); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(-500, 500), HandlePosition.topRight, @@ -161,7 +177,7 @@ void main() { ); expect(flip, Flip.diagonal); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(-500, -10), HandlePosition.topRight, @@ -169,7 +185,7 @@ void main() { ); expect(flip, Flip.horizontal); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(500, 10), HandlePosition.bottomLeft, @@ -177,7 +193,7 @@ void main() { ); expect(flip, Flip.horizontal); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(-10, 10), HandlePosition.bottomLeft, @@ -185,7 +201,7 @@ void main() { ); expect(flip, Flip.none); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(-10, -500), HandlePosition.bottomLeft, @@ -193,7 +209,7 @@ void main() { ); expect(flip, Flip.vertical); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(500, -500), HandlePosition.bottomLeft, @@ -205,7 +221,7 @@ void main() { test('getFlipForBox test for top-left handle', () { final box = Box.fromLTWH(0, 0, 400, 300); - Flip flip = getFlipForRect( + Flip flip = ClampHelpers.getFlipForRect( box, Vector2(500, 500), HandlePosition.topLeft, @@ -213,7 +229,7 @@ void main() { ); expect(flip, Flip.diagonal); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(500, -10), HandlePosition.topLeft, @@ -221,7 +237,7 @@ void main() { ); expect(flip, Flip.horizontal); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(-10, 500), HandlePosition.topLeft, @@ -229,7 +245,7 @@ void main() { ); expect(flip, Flip.vertical); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(-10, -10), HandlePosition.topLeft, @@ -237,7 +253,7 @@ void main() { ); expect(flip, Flip.none); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(-10, -10), HandlePosition.topLeft, @@ -245,7 +261,7 @@ void main() { ); expect(flip, Flip.none); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(500, 500), HandlePosition.topLeft, @@ -253,7 +269,7 @@ void main() { ); expect(flip, Flip.diagonal); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(500, -10), HandlePosition.topLeft, @@ -261,7 +277,7 @@ void main() { ); expect(flip, Flip.horizontal); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(-10, 500), HandlePosition.topLeft, @@ -269,7 +285,7 @@ void main() { ); expect(flip, Flip.vertical); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(-10, -10), HandlePosition.topLeft, @@ -277,7 +293,7 @@ void main() { ); expect(flip, Flip.none); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(300, 200), HandlePosition.topLeft, @@ -285,7 +301,7 @@ void main() { ); expect(flip, Flip.diagonal); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(800, -10), HandlePosition.topLeft, @@ -293,7 +309,7 @@ void main() { ); expect(flip, Flip.horizontal); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(-10, 500), HandlePosition.topLeft, @@ -301,7 +317,7 @@ void main() { ); expect(flip, Flip.vertical); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(-10, -10), HandlePosition.topLeft, @@ -309,7 +325,7 @@ void main() { ); expect(flip, Flip.none); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(800, 500), HandlePosition.topLeft, @@ -317,7 +333,7 @@ void main() { ); expect(flip, Flip.diagonal); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(300, 0), HandlePosition.topLeft, @@ -325,7 +341,7 @@ void main() { ); expect(flip, Flip.horizontal); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(0, 200), HandlePosition.topLeft, @@ -337,7 +353,7 @@ void main() { test('getFlipForBox test for top-right handle', () { final box = Box.fromLTWH(0, 0, 400, 300); - Flip flip = getFlipForRect( + Flip flip = ClampHelpers.getFlipForRect( box, Vector2(500, 500), HandlePosition.topRight, @@ -345,7 +361,7 @@ void main() { ); expect(flip, Flip.vertical); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(-500, 500), HandlePosition.topRight, @@ -353,7 +369,7 @@ void main() { ); expect(flip, Flip.diagonal); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(-500, 0), HandlePosition.topRight, @@ -361,7 +377,7 @@ void main() { ); expect(flip, Flip.horizontal); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(-10, -10), HandlePosition.topRight, @@ -369,7 +385,7 @@ void main() { ); expect(flip, Flip.none); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(-10, -10), HandlePosition.topRight, @@ -377,7 +393,7 @@ void main() { ); expect(flip, Flip.none); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(500, 500), HandlePosition.topRight, @@ -385,7 +401,7 @@ void main() { ); expect(flip, Flip.vertical); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(-500, 500), HandlePosition.topRight, @@ -393,7 +409,7 @@ void main() { ); expect(flip, Flip.diagonal); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(-500, 0), HandlePosition.topRight, @@ -401,7 +417,7 @@ void main() { ); expect(flip, Flip.horizontal); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(-10, -10), HandlePosition.topRight, @@ -409,7 +425,7 @@ void main() { ); expect(flip, Flip.none); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(100, 200), HandlePosition.topRight, @@ -417,7 +433,7 @@ void main() { ); expect(flip, Flip.vertical); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(-300, 200), HandlePosition.topRight, @@ -425,7 +441,7 @@ void main() { ); expect(flip, Flip.diagonal); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(-500, 0), HandlePosition.topRight, @@ -433,7 +449,7 @@ void main() { ); expect(flip, Flip.horizontal); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(-10, -10), HandlePosition.topRight, @@ -441,7 +457,7 @@ void main() { ); expect(flip, Flip.none); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(500, 500), HandlePosition.topRight, @@ -449,7 +465,7 @@ void main() { ); expect(flip, Flip.vertical); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(-500, 0), HandlePosition.topRight, @@ -457,7 +473,7 @@ void main() { ); expect(flip, Flip.horizontal); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(-20, 20), HandlePosition.topRight, @@ -469,7 +485,7 @@ void main() { test('getFlipForBox test for bottom-left handle', () { final box = Box.fromLTWH(0, 0, 400, 300); - Flip flip = getFlipForRect( + Flip flip = ClampHelpers.getFlipForRect( box, Vector2(500, -400), HandlePosition.bottomLeft, @@ -477,7 +493,7 @@ void main() { ); expect(flip, Flip.diagonal); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(500, 500), HandlePosition.bottomLeft, @@ -485,7 +501,7 @@ void main() { ); expect(flip, Flip.horizontal); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(0, -400), HandlePosition.bottomLeft, @@ -493,7 +509,7 @@ void main() { ); expect(flip, Flip.vertical); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(20, -20), HandlePosition.bottomLeft, @@ -501,7 +517,7 @@ void main() { ); expect(flip, Flip.none); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(20, -20), HandlePosition.bottomLeft, @@ -509,7 +525,7 @@ void main() { ); expect(flip, Flip.none); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(500, -400), HandlePosition.bottomLeft, @@ -517,7 +533,7 @@ void main() { ); expect(flip, Flip.diagonal); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(500, 500), HandlePosition.bottomLeft, @@ -525,7 +541,7 @@ void main() { ); expect(flip, Flip.horizontal); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(0, -400), HandlePosition.bottomLeft, @@ -533,7 +549,7 @@ void main() { ); expect(flip, Flip.vertical); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(20, -20), HandlePosition.bottomLeft, @@ -541,7 +557,7 @@ void main() { ); expect(flip, Flip.none); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(300, -200), HandlePosition.bottomLeft, @@ -549,7 +565,7 @@ void main() { ); expect(flip, Flip.diagonal); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(800, 500), HandlePosition.bottomLeft, @@ -557,7 +573,7 @@ void main() { ); expect(flip, Flip.horizontal); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(0, -200), HandlePosition.bottomLeft, @@ -565,7 +581,7 @@ void main() { ); expect(flip, Flip.vertical); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(20, -20), HandlePosition.bottomLeft, @@ -573,7 +589,7 @@ void main() { ); expect(flip, Flip.none); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(300, -200), HandlePosition.bottomLeft, @@ -581,7 +597,7 @@ void main() { ); expect(flip, Flip.diagonal); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(800, 500), HandlePosition.bottomLeft, @@ -589,7 +605,7 @@ void main() { ); expect(flip, Flip.horizontal); - flip = getFlipForRect( + flip = ClampHelpers.getFlipForRect( box, Vector2(0, -200), HandlePosition.bottomLeft, @@ -604,7 +620,7 @@ void main() { final Box rect = Box.fromLTRB(400, 600, 400, 300); expect( - getAvailableAreaForHandle( + ClampHelpers.getAvailableAreaForHandle( rect: rect, clampingRect: clampingRect, handle: HandlePosition.bottomRight), @@ -612,7 +628,7 @@ void main() { ); expect( - getAvailableAreaForHandle( + ClampHelpers.getAvailableAreaForHandle( rect: rect, clampingRect: clampingRect, handle: HandlePosition.topLeft), @@ -620,7 +636,7 @@ void main() { ); expect( - getAvailableAreaForHandle( + ClampHelpers.getAvailableAreaForHandle( rect: rect, clampingRect: clampingRect, handle: HandlePosition.bottomLeft), @@ -629,7 +645,7 @@ void main() { ); expect( - getAvailableAreaForHandle( + ClampHelpers.getAvailableAreaForHandle( rect: rect, clampingRect: clampingRect, handle: HandlePosition.topRight), @@ -638,7 +654,7 @@ void main() { ); expect( - getAvailableAreaForHandle( + ClampHelpers.getAvailableAreaForHandle( rect: rect, clampingRect: clampingRect, handle: HandlePosition.bottom), @@ -647,14 +663,14 @@ void main() { ); expect( - getAvailableAreaForHandle( + ClampHelpers.getAvailableAreaForHandle( rect: rect, clampingRect: clampingRect, handle: HandlePosition.right), Box.fromLTRB( rect.left, clampingRect.top, clampingRect.right, clampingRect.bottom), ); expect( - getAvailableAreaForHandle( + ClampHelpers.getAvailableAreaForHandle( rect: rect, clampingRect: clampingRect, handle: HandlePosition.left), Box.fromLTRB( clampingRect.left, clampingRect.top, rect.right, clampingRect.bottom), @@ -665,21 +681,26 @@ void main() { Box rect = Box.fromLTWH(200, 300, 400, 300); Box clampingRect = Box.fromLTWH(100, 100, 1000, 1000); - expect(getClosestEdge(rect, clampingRect), HandlePosition.left); + expect( + ClampHelpers.getClosestEdge(rect, clampingRect), HandlePosition.left); rect = Box.fromLTWH(200, 150, 400, 300); - expect(getClosestEdge(rect, clampingRect), HandlePosition.top); + expect(ClampHelpers.getClosestEdge(rect, clampingRect), HandlePosition.top); rect = Box.fromLTWH(200, 900, 400, 300); - expect(getClosestEdge(rect, clampingRect), HandlePosition.bottom); + expect( + ClampHelpers.getClosestEdge(rect, clampingRect), HandlePosition.bottom); rect = Box.fromLTWH(900, 300, 400, 300); - expect(getClosestEdge(rect, clampingRect), HandlePosition.right); + expect( + ClampHelpers.getClosestEdge(rect, clampingRect), HandlePosition.right); rect = Box.fromLTWH(900, 900, 400, 300); - expect(getClosestEdge(rect, clampingRect), HandlePosition.right); + expect( + ClampHelpers.getClosestEdge(rect, clampingRect), HandlePosition.right); rect = Box.fromLTWH(900, 150, 400, 300); - expect(getClosestEdge(rect, clampingRect), HandlePosition.right); + expect( + ClampHelpers.getClosestEdge(rect, clampingRect), HandlePosition.right); }); } diff --git a/packages/box_transform/test/lp_inspect.dart b/packages/box_transform/test/lp_inspect.dart new file mode 100644 index 0000000..6f51ada --- /dev/null +++ b/packages/box_transform/test/lp_inspect.dart @@ -0,0 +1,185 @@ +// Test-only inspection helpers for the rotated-clamping LP. +// +// Production callers use the flat-buffer hot path directly: +// * `IneqBuffer` + `RotatedClampingSolver.buildCornerIneqsInto` / +// `RotatedClampingSolver.buildSideHandleIneqsInto` / +// `RotatedClampingSolver.buildCenterIneqsInto` to build inequalities +// into a reused buffer. +// * `FlatProjection` + `RotatedClampingSolver.projectOntoFeasibleRegionFlat` +// to project a target (w, h) onto the feasible region without +// allocation. +// +// Tests want an inspectable, allocation-friendly view: a list of +// `LinearInequality` triples and a `ProjectionResult` value type. +// Those representations live here, never in `lib/`. + +import 'package:box_transform/box_transform.dart'; +import 'package:vector_math/vector_math_64.dart'; + +/// A linear inequality of the form `a*w + b*h <= c`. +class LinearInequality { + /// Coefficient of `w`. + final double a; + + /// Coefficient of `h`. + final double b; + + /// Upper bound. + final double c; + + /// Creates a [LinearInequality] `a*w + b*h <= c`. + const LinearInequality({required this.a, required this.b, required this.c}); + + /// Evaluates `a*w + b*h`. + double evaluate(double w, double h) => a * w + b * h; + + /// Returns true iff `a*w + b*h <= c` (with a small epsilon tolerance). + bool isSatisfied(double w, double h, {double epsilon = 1e-9}) => + evaluate(w, h) <= c + epsilon; + + /// True if this inequality depends only on `w`. + bool get isWOnly => b.abs() < 1e-12 && a.abs() > 1e-12; + + /// True if this inequality depends only on `h`. + bool get isHOnly => a.abs() < 1e-12 && b.abs() > 1e-12; + + /// True if this inequality couples `w` and `h` (both coefficients nonzero). + bool get isCoupled => a.abs() > 1e-12 && b.abs() > 1e-12; + + @override + String toString() => 'LinearInequality(${a}w + ${b}h <= $c)'; +} + +/// Output of [projectOntoFeasibleRegion]: a feasible `(w, h)` pair, plus +/// flags indicating whether each dimension hit a min or max bound. +class ProjectionResult { + /// Feasible unrotated width. + final double w; + + /// Feasible unrotated height. + final double h; + + /// True if [w] was pinned to the minimum-width bound. + final bool wMinHit; + + /// True if [w] was pinned to the maximum-width bound. + final bool wMaxHit; + + /// True if [h] was pinned to the minimum-height bound. + final bool hMinHit; + + /// True if [h] was pinned to the maximum-height bound. + final bool hMaxHit; + + /// Creates a [ProjectionResult]. + const ProjectionResult({ + required this.w, + required this.h, + required this.wMinHit, + required this.wMaxHit, + required this.hMinHit, + required this.hMaxHit, + }); +} + +/// Snapshots an [IneqBuffer] into a `List` so tests can +/// inspect coefficient triples directly. +List bufToList(IneqBuffer buf) { + final data = buf.data; + final n = buf.count; + return List.generate( + n, + (i) => LinearInequality( + a: data[3 * i], + b: data[3 * i + 1], + c: data[3 * i + 2], + ), + ); +} + +/// Test-side wrapper around +/// [RotatedClampingSolver.buildCornerIneqsInto]. +List buildCornerInequalities({ + required Vector2 anchor, + required double theta, + required Box clampingRect, + required BindingStrategy bindingStrategy, + double widthSign = 1.0, + double heightSign = 1.0, +}) { + final buf = IneqBuffer(); + RotatedClampingSolver.buildCornerIneqsInto( + buf, + anchor: anchor, + theta: theta, + clampingRect: clampingRect, + bindingStrategy: bindingStrategy, + widthSign: widthSign, + heightSign: heightSign, + ); + return bufToList(buf); +} + +/// Test-side wrapper around +/// [RotatedClampingSolver.buildCenterIneqsInto]. +List buildCenterInequalities({ + required Vector2 center, + required double theta, + required Box clampingRect, + required BindingStrategy bindingStrategy, +}) { + final buf = IneqBuffer(); + RotatedClampingSolver.buildCenterIneqsInto( + buf, + center: center, + theta: theta, + clampingRect: clampingRect, + bindingStrategy: bindingStrategy, + ); + return bufToList(buf); +} + +/// Test-side wrapper around +/// [RotatedClampingSolver.projectOntoFeasibleRegionFlat]. +ProjectionResult projectOntoFeasibleRegion( + List inequalities, { + required double targetW, + required double targetH, + required double minW, + required double maxW, + required double minH, + required double maxH, +}) { + final buf = IneqBuffer(); + final n = inequalities.length <= buf.data.length ~/ 3 + ? inequalities.length + : buf.data.length ~/ 3; + final data = buf.data; + for (int i = 0; i < n; i++) { + final ineq = inequalities[i]; + data[3 * i] = ineq.a; + data[3 * i + 1] = ineq.b; + data[3 * i + 2] = ineq.c; + } + buf.count = n; + + final out = FlatProjection(); + RotatedClampingSolver.projectOntoFeasibleRegionFlat( + buf, + out: out, + targetW: targetW, + targetH: targetH, + minW: minW, + maxW: maxW, + minH: minH, + maxH: maxH, + ); + return ProjectionResult( + w: out.w, + h: out.h, + wMinHit: out.wMinHit, + wMaxHit: out.wMaxHit, + hMinHit: out.hMinHit, + hMaxHit: out.hMaxHit, + ); +} diff --git a/packages/box_transform/test/no_flip_constrained_resizing_test.dart b/packages/box_transform/test/no_flip_constrained_resizing_test.dart index 312eee6..fc7e38c 100644 --- a/packages/box_transform/test/no_flip_constrained_resizing_test.dart +++ b/packages/box_transform/test/no_flip_constrained_resizing_test.dart @@ -1,6 +1,6 @@ import 'package:box_transform/box_transform.dart'; import 'package:test/test.dart'; -import 'package:vector_math/vector_math.dart'; +import 'package:vector_math/vector_math_64.dart'; import 'utils.dart'; diff --git a/packages/box_transform/test/no_flip_resizing_test.dart b/packages/box_transform/test/no_flip_resizing_test.dart index af2121a..0c3c1ca 100644 --- a/packages/box_transform/test/no_flip_resizing_test.dart +++ b/packages/box_transform/test/no_flip_resizing_test.dart @@ -1,6 +1,6 @@ import 'package:box_transform/box_transform.dart'; import 'package:test/test.dart'; -import 'package:vector_math/vector_math.dart'; +import 'package:vector_math/vector_math_64.dart'; import 'utils.dart'; diff --git a/packages/box_transform/test/portrait_constrained_resizing_test.dart b/packages/box_transform/test/portrait_constrained_resizing_test.dart index 1257b3c..d51dbf0 100644 --- a/packages/box_transform/test/portrait_constrained_resizing_test.dart +++ b/packages/box_transform/test/portrait_constrained_resizing_test.dart @@ -1,6 +1,6 @@ import 'package:box_transform/box_transform.dart'; import 'package:test/test.dart'; -import 'package:vector_math/vector_math.dart'; +import 'package:vector_math/vector_math_64.dart'; import 'utils.dart'; diff --git a/packages/box_transform/test/portrait_test.dart b/packages/box_transform/test/portrait_test.dart index c718214..d104647 100644 --- a/packages/box_transform/test/portrait_test.dart +++ b/packages/box_transform/test/portrait_test.dart @@ -1,6 +1,6 @@ import 'package:box_transform/box_transform.dart'; import 'package:test/test.dart'; -import 'package:vector_math/vector_math.dart'; +import 'package:vector_math/vector_math_64.dart'; import 'utils.dart'; diff --git a/packages/box_transform/test/recorded_force_flip_fallback_test.dart b/packages/box_transform/test/recorded_force_flip_fallback_test.dart new file mode 100644 index 0000000..8f58a29 --- /dev/null +++ b/packages/box_transform/test/recorded_force_flip_fallback_test.dart @@ -0,0 +1,129 @@ +import 'package:box_transform/box_transform.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math_64.dart'; + +// Regression coverage for two scenarios captured by the playground's test +// recorder while reproducing the "rotated force-flip leaks the clamp" bug. +// +// Both gestures drag a topLeft corner past the bottomRight anchor under +// non-trivial rotation with min/max constraints. The pre-fix engine would +// return a flipped rect that leaked the clamp by tens of pixels; the +// post-fix engine signals the flipped state is infeasible and falls back +// to the natural (un-flipped) direction so the rect stays inside the clamp. +// +// Asserted invariants per gesture: +// * result.rect stays inside clampingRect +// * result.rect respects min/max constraints +// * result.flip == Flip.none (force-flip aborted because flipped state +// was infeasible — the engine fell back rather than emitting a leaky +// diagonal flip) +// +// File kept under a recorder-style name to mark its origin; the assertions +// describe the *intended* behavior, not the buggy state the recorder once +// captured. + +void main() { + void expectInsideClamp(Box rect, Box clamp, {String? reason}) { + expect(rect.left, greaterThanOrEqualTo(clamp.left - 1e-3), + reason: '${reason ?? ''}: rect.left=${rect.left} ' + 'clamp.left=${clamp.left}'); + expect(rect.top, greaterThanOrEqualTo(clamp.top - 1e-3), + reason: '${reason ?? ''}: rect.top=${rect.top} ' + 'clamp.top=${clamp.top}'); + expect(rect.right, lessThanOrEqualTo(clamp.right + 1e-3), + reason: '${reason ?? ''}: rect.right=${rect.right} ' + 'clamp.right=${clamp.right}'); + expect(rect.bottom, lessThanOrEqualTo(clamp.bottom + 1e-3), + reason: '${reason ?? ''}: rect.bottom=${rect.bottom} ' + 'clamp.bottom=${clamp.bottom}'); + } + + void expectRespectsConstraints(Box rect, Constraints c, {String? reason}) { + expect(rect.width, greaterThanOrEqualTo(c.minWidth - 1e-3), + reason: '${reason ?? ''}: width=${rect.width} ' + '< minWidth=${c.minWidth}'); + expect(rect.width, lessThanOrEqualTo(c.maxWidth + 1e-3), + reason: '${reason ?? ''}: width=${rect.width} ' + '> maxWidth=${c.maxWidth}'); + expect(rect.height, greaterThanOrEqualTo(c.minHeight - 1e-3), + reason: '${reason ?? ''}: height=${rect.height} ' + '< minHeight=${c.minHeight}'); + expect(rect.height, lessThanOrEqualTo(c.maxHeight + 1e-3), + reason: '${reason ?? ''}: height=${rect.height} ' + '> maxHeight=${c.maxHeight}'); + } + + group('Recorded force-flip fallback regressions', () { + const constraints = Constraints( + minWidth: 100, + minHeight: 100, + maxWidth: 500, + maxHeight: 500, + ); + + test('topLeft drag past anchor at theta=0.59 stays inside clamp', () { + final clamp = Box.fromLTWH(208.74, 182.10, 713.90, 517.23); + final result = BoxTransformer.resize( + resizeMode: ResizeMode.freeform, + initialFlip: Flip.none, + initialRect: + Box.fromLTWH(289.67, 579.95, 100.00, 100.00, rotation: 0.5910843), + handle: HandlePosition.topLeft, + initialLocalPosition: Vector2(16.08, 13.30), + allowFlipping: true, + rotation: 0.5910843, + bindingStrategy: BindingStrategy.boundingBox, + localPosition: Vector2(69.20, 240.04), + clampingRect: clamp, + constraints: constraints, + ); + expect(result.flip, equals(Flip.none), + reason: 'force-flip aborted (fallback engaged)'); + expectInsideClamp(result.rect, clamp); + expectRespectsConstraints(result.rect, constraints); + }); + + test('two-step gesture at theta=0.35: each tick stays inside clamp', () { + // Step 1: small pull that grows the rect inside the clamp (feasible). + final clamp = Box.fromLTWH(144.46, 139.27, 787.62, 556.81); + var step = BoxTransformer.resize( + resizeMode: ResizeMode.freeform, + initialFlip: Flip.none, + initialRect: + Box.fromLTWH(351.89, 412.78, 400.00, 224.73, rotation: 0.3510944), + handle: HandlePosition.topLeft, + initialLocalPosition: Vector2(10.58, 17.55), + allowFlipping: true, + rotation: 0.3510944, + bindingStrategy: BindingStrategy.boundingBox, + localPosition: Vector2(103.86, 53.10), + clampingRect: clamp, + constraints: constraints, + ); + expect(step.flip, equals(Flip.none)); + expectInsideClamp(step.rect, clamp, reason: 'step 1'); + expectRespectsConstraints(step.rect, constraints, reason: 'step 1'); + + // Step 2: from the step-1 rect, drag past the bottomRight anchor. + // Under boundingBox with these min/max constraints the flipped state + // does not fit — the engine must fall back to natural direction. + step = BoxTransformer.resize( + resizeMode: ResizeMode.freeform, + initialFlip: step.flip, + initialRect: step.rect, + handle: HandlePosition.topLeft, + initialLocalPosition: Vector2(12.75, 19.64), + allowFlipping: true, + rotation: 0.3510944, + bindingStrategy: BindingStrategy.boundingBox, + localPosition: Vector2(239.31, 427.13), + clampingRect: clamp, + constraints: constraints, + ); + expect(step.flip, equals(Flip.none), + reason: 'force-flip aborted (fallback engaged)'); + expectInsideClamp(step.rect, clamp, reason: 'step 2'); + expectRespectsConstraints(step.rect, constraints, reason: 'step 2'); + }); + }); +} diff --git a/packages/box_transform/test/rect_result_test.dart b/packages/box_transform/test/rect_result_test.dart index e44c16e..029e319 100644 --- a/packages/box_transform/test/rect_result_test.dart +++ b/packages/box_transform/test/rect_result_test.dart @@ -1,6 +1,6 @@ import 'package:box_transform/box_transform.dart'; import 'package:test/test.dart'; -import 'package:vector_math/vector_math.dart'; +import 'package:vector_math/vector_math_64.dart'; void main() { test('RawTransformResult equality tests', () { @@ -118,4 +118,52 @@ void main() { expect(result1.hashCode, result2.hashCode); expect(result1 == result2, isTrue); }); + + group('TransformResult rotation/boundingRect fields', () { + test('default rotation is 0.0 and boundingRect equals rect for theta=0', + () { + final r = Box.fromLTWH(0, 0, 100, 100); + final result = ResizeResult( + rect: r, + oldRect: r, + delta: Vector2.zero(), + flip: Flip.none, + resizeMode: ResizeMode.freeform, + rawSize: Dimension(100, 100), + minWidthReached: false, + maxWidthReached: false, + minHeightReached: false, + maxHeightReached: false, + largestRect: r, + handle: HandlePosition.bottomRight, + ); + expect(result.rotation, 0.0); + expect(result.boundingRect, equals(r)); + expect(result.oldBoundingRect, equals(r)); + }); + + test('boundingRect is exposed when explicitly provided', () { + final r = Box.fromLTWH(0, 0, 100, 100, rotation: 0.5); + final bounding = Box.fromLTWH(-10, -10, 120, 120); + final result = ResizeResult( + rect: r, + oldRect: r, + delta: Vector2.zero(), + flip: Flip.none, + resizeMode: ResizeMode.freeform, + rawSize: Dimension(100, 100), + minWidthReached: false, + maxWidthReached: false, + minHeightReached: false, + maxHeightReached: false, + largestRect: r, + handle: HandlePosition.bottomRight, + rotation: 0.5, + boundingRect: bounding, + oldBoundingRect: bounding, + ); + expect(result.rotation, 0.5); + expect(result.boundingRect, equals(bounding)); + }); + }); } diff --git a/packages/box_transform/test/rotate_entry_test.dart b/packages/box_transform/test/rotate_entry_test.dart new file mode 100644 index 0000000..ad3929b --- /dev/null +++ b/packages/box_transform/test/rotate_entry_test.dart @@ -0,0 +1,33 @@ +import 'dart:math' as math; + +import 'package:box_transform/box_transform.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math_64.dart'; + +void main() { + group('BoxTransformer.rotate', () { + test('drag pointer quarter-turn produces +pi/2 rotation', () { + final initial = Box.fromLTWH(-50, -50, 100, 100); + // Center = (0,0). Initial pointer at (100,0), drag to (0,100). + // Angle from +X to +Y is +pi/2 in screen coords (y-down). + final result = BoxTransformer.rotate( + initialRect: initial, + initialLocalPosition: Vector2(100, 0), + localPosition: Vector2(0, 100), + initialRotation: 0.0, + ); + expect(result.rotation, closeTo(math.pi / 2, 1e-6)); + }); + + test('initialRotation is preserved additively', () { + final initial = Box.fromLTWH(-50, -50, 100, 100, rotation: 0.3); + final result = BoxTransformer.rotate( + initialRect: initial, + initialLocalPosition: Vector2(100, 0), + localPosition: Vector2(100, 0), // no delta + initialRotation: 0.3, + ); + expect(result.rotation, closeTo(0.3, 1e-6)); + }); + }); +} diff --git a/packages/box_transform/test/rotated_bounding_box_clamp_semantic_test.dart b/packages/box_transform/test/rotated_bounding_box_clamp_semantic_test.dart new file mode 100644 index 0000000..bd67147 --- /dev/null +++ b/packages/box_transform/test/rotated_bounding_box_clamp_semantic_test.dart @@ -0,0 +1,119 @@ +import 'dart:math' as math; + +import 'package:box_transform/box_transform.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math_64.dart'; + +// `BindingStrategy.boundingBox` must constrain the rendered axis-aligned +// bounding box of the rotated rect, NOT the unrotated stored rect's +// corners. For stretched rotated rects the unrotated rect can extend +// further than the rotated AABB on one axis (W·|cos θ| + H·|sin θ| can be +// less than W when H is much smaller than W, and vice versa); enforcing +// both makes the unrotated rect the binding constraint and steals slack +// the rendered footprint genuinely has. +// +// Reproduces the playground report: a slim/tall rotated box flush with +// the clamp's top edge could not move down even though the rotated AABB +// had ~30 px of slack from the clamp. + +void main() { + group('boundingBox: rendered AABB is the binding constraint', () { + test( + 'stretched (100×500) rotated rect can move into rotated-AABB slack ' + 'past the unrotated rect bound', + () { + // Geometry: 100×500 slim-tall rect at θ=π/4. Stored bounds are + // flush with clamp.top and clamp.bottom; the rotated AABB has + // ~38 px of slack on each side. + final initial = Box.fromLTWH(450, 0, 100, 500, rotation: math.pi / 4); + final clamp = Box.fromLTRB(0, 0, 1000, 500); + // Pre-checks: rotated AABB has slack to clamp.top/bottom. + final aabb = ClampHelpers.calculateBoundingRect(initial); + expect(aabb.top, greaterThan(clamp.top + 30), + reason: 'rotated AABB must have meaningful slack to clamp.top ' + '(setup invariant)'); + expect(aabb.bottom, lessThan(clamp.bottom - 30), + reason: 'rotated AABB must have meaningful slack to clamp.bottom ' + '(setup invariant)'); + // The unrotated rect is flush with the clamp by construction. + expect(initial.top, closeTo(clamp.top, 1e-9)); + expect(initial.bottom, closeTo(clamp.bottom, 1e-9)); + + final result = BoxTransformer.move( + initialRect: initial, + initialLocalPosition: Vector2(500, 250), + // Request 30 px down. Easily inside the AABB's vertical slack. + localPosition: Vector2(500, 280), + rotation: math.pi / 4, + bindingStrategy: BindingStrategy.boundingBox, + clampingRect: clamp, + ); + + expect(result.delta.y, closeTo(30, 1e-3), + reason: 'boundingBox must let the rect move into the rotated ' + 'AABB slack (this is the user-reported regression)'); + // And the rotated AABB after the move must stay inside the clamp. + final movedAabb = ClampHelpers.calculateBoundingRect(result.rect); + expect(movedAabb.top, greaterThanOrEqualTo(clamp.top - 1e-3)); + expect(movedAabb.bottom, lessThanOrEqualTo(clamp.bottom + 1e-3)); + }, + ); + + test( + 'wide (500×100) rotated rect can move into rotated-AABB slack ' + 'past the unrotated rect bound (x-axis variant)', + () { + // Mirror scenario, swapped axes: 500×100 wide-flat rect at θ=π/4. + // Stored bounds flush with clamp.left/right; rotated AABB has + // slack on the x-axis. + final initial = Box.fromLTWH(0, 450, 500, 100, rotation: math.pi / 4); + final clamp = Box.fromLTRB(0, 0, 500, 1000); + final aabb = ClampHelpers.calculateBoundingRect(initial); + expect(aabb.left, greaterThan(clamp.left + 30)); + expect(aabb.right, lessThan(clamp.right - 30)); + expect(initial.left, closeTo(clamp.left, 1e-9)); + expect(initial.right, closeTo(clamp.right, 1e-9)); + + final result = BoxTransformer.move( + initialRect: initial, + initialLocalPosition: Vector2(250, 500), + localPosition: Vector2(280, 500), + rotation: math.pi / 4, + bindingStrategy: BindingStrategy.boundingBox, + clampingRect: clamp, + ); + + expect(result.delta.x, closeTo(30, 1e-3), + reason: 'boundingBox must let the rect move into the rotated ' + 'AABB slack on the x-axis'); + final movedAabb = ClampHelpers.calculateBoundingRect(result.rect); + expect(movedAabb.left, greaterThanOrEqualTo(clamp.left - 1e-3)); + expect(movedAabb.right, lessThanOrEqualTo(clamp.right + 1e-3)); + }, + ); + + test( + 'originalBox keeps its semantic: stretched rotated rect cannot move ' + 'past the unrotated rect bound', + () { + // Same setup as the first test, but originalBox enforces the + // unrotated rect. The rect IS flush with the clamp on the + // unrotated bounds, so it cannot move at all on y. + final initial = Box.fromLTWH(450, 0, 100, 500, rotation: math.pi / 4); + final clamp = Box.fromLTRB(0, 0, 1000, 500); + final result = BoxTransformer.move( + initialRect: initial, + initialLocalPosition: Vector2(500, 250), + localPosition: Vector2(500, 280), + rotation: math.pi / 4, + bindingStrategy: BindingStrategy.originalBox, + clampingRect: clamp, + ); + expect(result.delta.y, closeTo(0, 1e-3), + reason: 'originalBox must keep the unrotated rect inside the ' + 'clamp; with rect flush to clamp.bottom no movement is ' + 'allowed'); + }, + ); + }); +} diff --git a/packages/box_transform/test/rotated_clamping_solver_test.dart b/packages/box_transform/test/rotated_clamping_solver_test.dart new file mode 100644 index 0000000..512aea0 --- /dev/null +++ b/packages/box_transform/test/rotated_clamping_solver_test.dart @@ -0,0 +1,275 @@ +import 'dart:math' as math; + +import 'package:box_transform/box_transform.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math_64.dart'; + +import 'lp_inspect.dart'; + +void main() { + group('LinearInequality', () { + test('evaluates aw + bh <= c correctly', () { + final ineq = LinearInequality(a: 1, b: 2, c: 10); + expect(ineq.evaluate(3, 2), 7); // 3 + 4 = 7 + expect(ineq.isSatisfied(3, 2), isTrue); + expect(ineq.isSatisfied(5, 3), isFalse); // 11 > 10 + }); + }); + + group('buildCornerInequalities (originalBox)', () { + test('theta=0 with anchor topLeft at origin produces w <= 200 ineq', () { + final ineqs = buildCornerInequalities( + anchor: Vector2(0, 0), + theta: 0.0, + clampingRect: Box.fromLTRB(-50, -50, 200, 200), + bindingStrategy: BindingStrategy.originalBox, + ); + // Locate the w-only upper-bound inequality. + final wUpper = ineqs + .firstWhere((i) => i.a.abs() > 0 && i.b.abs() < 1e-12 && i.a > 0); + expect(wUpper.c / wUpper.a, closeTo(200, 1e-9)); + }); + + test('all inequalities satisfied when (w,h) fits clamp', () { + final ineqs = buildCornerInequalities( + anchor: Vector2(50, 50), + theta: math.pi / 4, + clampingRect: Box.fromLTRB(0, 0, 300, 300), + bindingStrategy: BindingStrategy.originalBox, + ); + for (final i in ineqs) { + expect(i.isSatisfied(10, 10), isTrue, + reason: 'Inequality $i violated at (10,10)'); + } + }); + }); + + group('projectOntoFeasibleRegion', () { + test('returns target unchanged when inside feasible region', () { + final ineqs = buildCornerInequalities( + anchor: Vector2(0, 0), + theta: 0.0, + clampingRect: Box.fromLTRB(-10, -10, 500, 500), + bindingStrategy: BindingStrategy.originalBox, + ); + final result = projectOntoFeasibleRegion( + ineqs, + targetW: 100, + targetH: 50, + minW: 1, + minH: 1, + maxW: 1e9, + maxH: 1e9, + ); + expect(result.w, closeTo(100, 1e-9)); + expect(result.h, closeTo(50, 1e-9)); + }); + + test('clamps w when upper bound binds (theta=0)', () { + final ineqs = buildCornerInequalities( + anchor: Vector2(0, 0), + theta: 0.0, + clampingRect: Box.fromLTRB(-10, -10, 200, 500), + bindingStrategy: BindingStrategy.originalBox, + ); + final result = projectOntoFeasibleRegion( + ineqs, + targetW: 500, + targetH: 50, + minW: 1, + minH: 1, + maxW: 1e9, + maxH: 1e9, + ); + expect(result.w, closeTo(200, 1e-9)); + expect(result.h, closeTo(50, 1e-9)); + }); + + test('min constraint prevents shrinking past minimum', () { + final ineqs = buildCornerInequalities( + anchor: Vector2(0, 0), + theta: 0.0, + clampingRect: Box.fromLTRB(-10, -10, 500, 500), + bindingStrategy: BindingStrategy.originalBox, + ); + final result = projectOntoFeasibleRegion( + ineqs, + targetW: 5, + targetH: 5, + minW: 20, + minH: 20, + maxW: 1e9, + maxH: 1e9, + ); + expect(result.w, closeTo(20, 1e-9)); + expect(result.h, closeTo(20, 1e-9)); + }); + + test('coupled inequality at theta=pi/4 stays feasible', () { + final ineqs = buildCornerInequalities( + anchor: Vector2(0, 0), + theta: math.pi / 4, + clampingRect: Box.fromLTRB(-200, -200, 141.42, 500), + bindingStrategy: BindingStrategy.originalBox, + ); + final result = projectOntoFeasibleRegion( + ineqs, + targetW: 500, + targetH: 50, + minW: 1, + minH: 1, + maxW: 1e9, + maxH: 1e9, + ); + for (final i in ineqs) { + expect(i.isSatisfied(result.w, result.h), isTrue, + reason: 'Inequality violated: $i at (${result.w}, ${result.h})'); + } + }); + }); + + group('buildCornerInequalities (boundingBox)', () { + test( + 'boundingBox strategy produces feasible ineqs when small box fits ' + 'clamp with room around the anchor', () { + // Anchor well inside clamp so rotated corners can extend in any + // direction without violating the clamp. + final ineqs = buildCornerInequalities( + anchor: Vector2(50, 50), + theta: math.pi / 4, + clampingRect: Box.fromLTRB(-200, -200, 200, 200), + bindingStrategy: BindingStrategy.boundingBox, + ); + for (final i in ineqs) { + expect(i.isSatisfied(10, 10), isTrue, + reason: 'Small box should be feasible at pi/4: $i'); + } + }); + + test( + 'boundingBox clamping marks oversized rotated boxes infeasible ' + '(corner ineqs suffice since they already enforce AABB extent)', () { + final ineqs = buildCornerInequalities( + anchor: Vector2(0, 0), + theta: math.pi / 4, + clampingRect: Box.fromLTRB(-100, -100, 100, 100), + bindingStrategy: BindingStrategy.boundingBox, + ); + // (200, 200) at pi/4 has corners at roughly ±141, well outside clamp. + final someViolated = ineqs.any((i) => !i.isSatisfied(200, 200)); + expect(someViolated, isTrue); + }); + }); + + group('originalBox vs boundingBox coefficients diverge at nonzero theta', () { + // Verifies the Phase 2 semantic fix: the two strategies produce + // different linear-coefficient tables for corner-anchored inequalities + // whenever theta != 0. (They coincide at theta = 0 because cos=1, sin=0 + // collapses the unrotated-corner formula onto the rotated-corner one.) + test( + 'corner-anchored: at theta != 0 the strategies emit different ' + 'inequality sets', + () { + // boundingBox enforces the rotated quad's four corners (the + // rendered footprint stays in the clamp). originalBox enforces + // the unrotated rect's four axis-aligned corners (the logical + // stored rect stays in the clamp; rotated corners may extend + // outside). At theta != 0 these are different geometries with + // different coefficients; at theta == 0 they collapse onto the + // same axis-aligned constraints. + final common = { + 'anchor': Vector2(0, 0), + 'theta': math.pi / 6, + 'clampingRect': Box.fromLTRB(-100, -100, 200, 200), + }; + final original = buildCornerInequalities( + anchor: common['anchor'] as Vector2, + theta: common['theta'] as double, + clampingRect: common['clampingRect'] as Box, + bindingStrategy: BindingStrategy.originalBox, + ); + final bounding = buildCornerInequalities( + anchor: common['anchor'] as Vector2, + theta: common['theta'] as double, + clampingRect: common['clampingRect'] as Box, + bindingStrategy: BindingStrategy.boundingBox, + ); + expect(original, isNotEmpty); + expect(bounding, isNotEmpty); + bool same(LinearInequality a, LinearInequality b) => + (a.a - b.a).abs() < 1e-9 && + (a.b - b.b).abs() < 1e-9 && + (a.c - b.c).abs() < 1e-9; + // The unrotated corners (originalBox) and the rotated quad + // corners (boundingBox) at theta=π/6 are geometrically distinct + // axes — every row in one must NOT appear in the other. + for (final o in original) { + expect(bounding.any((b) => same(o, b)), isFalse, + reason: 'originalBox row $o must not appear in boundingBox ' + 'at theta != 0; the strategies enforce different ' + 'corners.'); + } + }, + ); + + test('corner-anchored: at theta=0 both strategies coincide', () { + // Sanity: theta=0 collapses both formulas to axis-aligned corners, + // so the projection solutions for any feasible target must match. + final ineqsO = buildCornerInequalities( + anchor: Vector2(0, 0), + theta: 0.0, + clampingRect: Box.fromLTRB(-10, -10, 200, 200), + bindingStrategy: BindingStrategy.originalBox, + ); + final ineqsB = buildCornerInequalities( + anchor: Vector2(0, 0), + theta: 0.0, + clampingRect: Box.fromLTRB(-10, -10, 200, 200), + bindingStrategy: BindingStrategy.boundingBox, + ); + final resO = projectOntoFeasibleRegion( + ineqsO, + targetW: 100, + targetH: 50, + minW: 1, + minH: 1, + maxW: 1e9, + maxH: 1e9, + ); + final resB = projectOntoFeasibleRegion( + ineqsB, + targetW: 100, + targetH: 50, + minW: 1, + minH: 1, + maxW: 1e9, + maxH: 1e9, + ); + expect(resO.w, closeTo(resB.w, 1e-9)); + expect(resO.h, closeTo(resB.h, 1e-9)); + }); + + test('center-anchored: originalBox at nonzero theta is angle-independent', + () { + // originalBox center-anchored coefficients must equal theta=0 case. + final ineqsRotated = buildCenterInequalities( + center: Vector2(0, 0), + theta: math.pi / 3, + clampingRect: Box.fromLTRB(-50, -50, 50, 50), + bindingStrategy: BindingStrategy.originalBox, + ); + final ineqsZero = buildCenterInequalities( + center: Vector2(0, 0), + theta: 0.0, + clampingRect: Box.fromLTRB(-50, -50, 50, 50), + bindingStrategy: BindingStrategy.originalBox, + ); + expect(ineqsRotated.length, ineqsZero.length); + for (int i = 0; i < ineqsRotated.length; i++) { + expect(ineqsRotated[i].a, closeTo(ineqsZero[i].a, 1e-9)); + expect(ineqsRotated[i].b, closeTo(ineqsZero[i].b, 1e-9)); + expect(ineqsRotated[i].c, closeTo(ineqsZero[i].c, 1e-9)); + } + }); + }); +} diff --git a/packages/box_transform/test/rotated_clamping_strategy_divergence_test.dart b/packages/box_transform/test/rotated_clamping_strategy_divergence_test.dart new file mode 100644 index 0000000..467726b --- /dev/null +++ b/packages/box_transform/test/rotated_clamping_strategy_divergence_test.dart @@ -0,0 +1,200 @@ +import 'dart:math' as math; + +import 'package:box_transform/box_transform.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math_64.dart'; + +import 'lp_inspect.dart'; + +/// Cross-strategy divergence tests. These prove that `BindingStrategy. +/// originalBox` and `BindingStrategy.boundingBox` produce materially +/// different resize results at non-zero rotation — i.e. Phase 2's semantic +/// fix is active. Each test picks a geometry where the two modes MUST +/// disagree and asserts the specific direction of the difference. +void main() { + group('Divergence: corner-anchored feasibility region at pi/6', () { + test('projections diverge for a tight clamp and nonzero theta', () { + // Solver-level proof: build corner ineqs under each strategy, project + // the same target onto both feasible regions, assert different (w, h). + // Geometry: anchor at origin, theta = pi/6, clamp = asymmetric rect + // that binds differently for rotated vs unrotated corners. + const theta = math.pi / 6; + final anchor = Vector2(0, 0); + final clamp = Box.fromLTRB(-50, -50, 150, 150); + + final ineqO = buildCornerInequalities( + anchor: anchor, + theta: theta, + clampingRect: clamp, + bindingStrategy: BindingStrategy.originalBox, + ); + final ineqB = buildCornerInequalities( + anchor: anchor, + theta: theta, + clampingRect: clamp, + bindingStrategy: BindingStrategy.boundingBox, + ); + + final resO = projectOntoFeasibleRegion( + ineqO, + targetW: 300, + targetH: 100, + minW: 1, + minH: 1, + maxW: 1e9, + maxH: 1e9, + ); + final resB = projectOntoFeasibleRegion( + ineqB, + targetW: 300, + targetH: 100, + minW: 1, + minH: 1, + maxW: 1e9, + maxH: 1e9, + ); + + // Both strategies must produce feasible (w, h) under their own ineqs. + for (final i in ineqO) { + expect(i.isSatisfied(resO.w, resO.h), isTrue, + reason: 'originalBox projection violates $i'); + } + for (final i in ineqB) { + expect(i.isSatisfied(resB.w, resB.h), isTrue, + reason: 'boundingBox projection violates $i'); + } + // The two projections must differ measurably. If the coefficient + // tables were identical (pre-fix state) these would match exactly. + final dw = (resO.w - resB.w).abs(); + final dh = (resO.h - resB.h).abs(); + expect(dw + dh, greaterThan(1.0), + reason: 'projections should diverge when strategies differ; ' + 'got original=(${resO.w}, ${resO.h}) ' + 'bounding=(${resB.w}, ${resB.h})'); + }); + }); + + group('Divergence: center-anchored feasibility region at pi/6', () { + test('projections diverge for symmetric geometry at nonzero theta', () { + // Analogous to the corner-anchored case, but using the center-anchored + // builder. Same target, same clamp, different bindingStrategy. + const theta = math.pi / 6; + final center = Vector2(0, 0); + final clamp = Box.fromLTRB(-80, -80, 80, 80); + + final ineqO = buildCenterInequalities( + center: center, + theta: theta, + clampingRect: clamp, + bindingStrategy: BindingStrategy.originalBox, + ); + final ineqB = buildCenterInequalities( + center: center, + theta: theta, + clampingRect: clamp, + bindingStrategy: BindingStrategy.boundingBox, + ); + + final resO = projectOntoFeasibleRegion( + ineqO, + targetW: 200, + targetH: 80, + minW: 1, + minH: 1, + maxW: 1e9, + maxH: 1e9, + ); + final resB = projectOntoFeasibleRegion( + ineqB, + targetW: 200, + targetH: 80, + minW: 1, + minH: 1, + maxW: 1e9, + maxH: 1e9, + ); + + for (final i in ineqO) { + expect(i.isSatisfied(resO.w, resO.h), isTrue, + reason: 'originalBox projection violates $i'); + } + for (final i in ineqB) { + expect(i.isSatisfied(resB.w, resB.h), isTrue, + reason: 'boundingBox projection violates $i'); + } + final dw = (resO.w - resB.w).abs(); + final dh = (resO.h - resB.h).abs(); + expect(dw + dh, greaterThan(1.0), + reason: 'center-anchored projections should diverge; ' + 'got original=(${resO.w}, ${resO.h}) ' + 'bounding=(${resB.w}, ${resB.h})'); + }); + }); + + group('Divergence: scale-aspect resize at pi/4', () { + test('originalBox allows scale to grow past the boundingBox cap', () { + // Scale mode preserves aspect — exercises the scalable engine's + // `h = k*w` reduction and sanity-check that Phase 2 ripples through + // the aspect-preserving path too. + final initial = Box.fromLTWH(0, 0, 100, 100, rotation: math.pi / 4); + final half = 100 * math.sqrt2 / 2; + final clamp = Box.fromLTRB(50 - half, 50 - half, 50 + half, 50 + half); + final initialCorner = _worldCorner(initial, HandlePosition.bottomRight); + + final resO = BoxTransformer.resize( + initialRect: initial, + initialLocalPosition: initialCorner, + localPosition: Vector2(500, 500), + handle: HandlePosition.bottomRight, + resizeMode: ResizeMode.scale, + initialFlip: Flip.none, + clampingRect: clamp, + constraints: const Constraints.unconstrained(), + allowFlipping: false, + rotation: math.pi / 4, + bindingStrategy: BindingStrategy.originalBox, + ); + final resB = BoxTransformer.resize( + initialRect: initial, + initialLocalPosition: initialCorner, + localPosition: Vector2(500, 500), + handle: HandlePosition.bottomRight, + resizeMode: ResizeMode.scale, + initialFlip: Flip.none, + clampingRect: clamp, + constraints: const Constraints.unconstrained(), + allowFlipping: false, + rotation: math.pi / 4, + bindingStrategy: BindingStrategy.boundingBox, + ); + + // Aspect ratio preserved in both. + expect(resO.rect.width, closeTo(resO.rect.height, 1e-4)); + expect(resB.rect.width, closeTo(resB.rect.height, 1e-4)); + // Divergence. + expect(resB.rect.width, closeTo(100, 1.0)); + expect(resO.rect.width, greaterThan(resB.rect.width + 5.0)); + }); + }); +} + +/// Test helper: world position of a specific corner of a rotated [box]. +Vector2 _worldCorner(Box box, HandlePosition handle) { + final cx = (box.left + box.right) / 2; + final cy = (box.top + box.bottom) / 2; + final center = Vector2(cx, cy); + late final Vector2 unrotated; + switch (handle) { + case HandlePosition.topLeft: + unrotated = Vector2(box.left, box.top); + case HandlePosition.topRight: + unrotated = Vector2(box.right, box.top); + case HandlePosition.bottomRight: + unrotated = Vector2(box.right, box.bottom); + case HandlePosition.bottomLeft: + unrotated = Vector2(box.left, box.bottom); + default: + throw UnsupportedError('handle $handle not supported'); + } + return ClampHelpers.rotatePointAround(unrotated, center, box.rotation); +} diff --git a/packages/box_transform/test/rotated_flip_test.dart b/packages/box_transform/test/rotated_flip_test.dart new file mode 100644 index 0000000..f1d543e --- /dev/null +++ b/packages/box_transform/test/rotated_flip_test.dart @@ -0,0 +1,228 @@ +// Tests for flip-state computation under rotation. +// +// Bug: when the box has nonzero rotation and the user drags a handle past +// its opposite anchor (in the un-rotated frame), the rect repositions to +// the other side correctly but `result.flip` stays at `initialFlip`. The +// AA path computes `currentFlip * initialFlip` via `getFlipForRect`; the +// rotated path skipped that step entirely, so the playground's content +// mirroring (which keys off `controller.flip`) never engages under rotation. + +import 'dart:math' as math; + +import 'package:box_transform/box_transform.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math_64.dart'; + +void main() { + group('Rotated freeform resize updates flip state', () { + test( + 'dragging bottomRight past topLeft anchor (in un-rotated frame) ' + 'produces Flip.diagonal at rotation=π/6', + () { + final result = _resizeRotated( + handle: HandlePosition.bottomRight, + mode: ResizeMode.freeform, + // Un-rotated target (100, 100) is past anchor (150, 150) on both + // axes → diagonal flip. + unrotatedTarget: Vector2(100, 100), + ); + expect(result.flip, equals(Flip.diagonal)); + }, + ); + + test( + 'dragging bottomRight past anchor on x only produces Flip.horizontal', + () { + final result = _resizeRotated( + handle: HandlePosition.bottomRight, + mode: ResizeMode.freeform, + // Un-rotated target (100, 250): past anchor on x, still below it + // on y → horizontal flip only. + unrotatedTarget: Vector2(100, 250), + ); + expect(result.flip, equals(Flip.horizontal)); + }, + ); + + test( + 'dragging bottomRight past anchor on y only produces Flip.vertical', + () { + final result = _resizeRotated( + handle: HandlePosition.bottomRight, + mode: ResizeMode.freeform, + unrotatedTarget: Vector2(250, 100), + ); + expect(result.flip, equals(Flip.vertical)); + }, + ); + + test('dragging within bounds keeps Flip.none', () { + final result = _resizeRotated( + handle: HandlePosition.bottomRight, + mode: ResizeMode.freeform, + unrotatedTarget: Vector2(220, 220), + ); + expect(result.flip, equals(Flip.none)); + }); + + test( + 'initialFlip=horizontal + new horizontal flip during gesture cancels ' + 'to Flip.none', + () { + final result = _resizeRotated( + handle: HandlePosition.bottomRight, + mode: ResizeMode.freeform, + unrotatedTarget: Vector2(100, 250), + initialFlip: Flip.horizontal, + ); + expect(result.flip, equals(Flip.none)); + }, + ); + }); + + group('Rotated scale resize updates flip state', () { + test( + 'dragging bottomRight past anchor diagonally produces Flip.diagonal', + () { + final result = _resizeRotated( + handle: HandlePosition.bottomRight, + mode: ResizeMode.scale, + unrotatedTarget: Vector2(100, 100), + ); + expect(result.flip, equals(Flip.diagonal)); + }, + ); + }); + + group('Rotated symmetric resize updates flip state', () { + test( + 'pulling bottomRight past center produces Flip.diagonal', + () { + // Symmetric: anchor is the center (200, 200). Crossing center on + // both axes is a diagonal flip. + final result = _resizeRotated( + handle: HandlePosition.bottomRight, + mode: ResizeMode.symmetric, + unrotatedTarget: Vector2(150, 150), + ); + expect(result.flip, equals(Flip.diagonal)); + }, + ); + }); + + group('allowFlipping=false under rotation', () { + test( + 'freeform: allowFlipping=false suppresses flip even when pointer ' + 'crosses anchor', + () { + final result = _resizeRotated( + handle: HandlePosition.bottomRight, + mode: ResizeMode.freeform, + // Same target as the diagonal-flip test — would normally produce + // Flip.diagonal. + unrotatedTarget: Vector2(100, 100), + allowFlipping: false, + ); + expect( + result.flip, + equals(Flip.none), + reason: 'AA path returns Flip.none when allowFlipping=false; ' + 'rotated path must do the same.', + ); + }, + ); + + test( + 'symmetric: allowFlipping=false suppresses flip even when pointer ' + 'crosses center', + () { + final result = _resizeRotated( + handle: HandlePosition.bottomRight, + mode: ResizeMode.symmetric, + unrotatedTarget: Vector2(150, 150), + allowFlipping: false, + ); + expect(result.flip, equals(Flip.none)); + }, + ); + }); + + group('Symmetric boundary at exact center', () { + test('pointer exactly at center stays Flip.none (symmetric)', () { + final result = _resizeRotated( + handle: HandlePosition.bottomRight, + mode: ResizeMode.symmetric, + // Exactly the center of the test rect (200, 200). Strict `<` + // boundary in the flip math means this is *not* a flip transition. + unrotatedTarget: Vector2(200, 200), + ); + expect(result.flip, equals(Flip.none)); + }); + + test('pointer exactly at center stays Flip.none (symmetricScale)', () { + final result = _resizeRotated( + handle: HandlePosition.bottomRight, + mode: ResizeMode.symmetricScale, + unrotatedTarget: Vector2(200, 200), + ); + expect(result.flip, equals(Flip.none)); + }); + }); + + group('Rotated symmetricScale resize updates flip state', () { + test( + 'pulling bottomRight past center produces Flip.diagonal', + () { + final result = _resizeRotated( + handle: HandlePosition.bottomRight, + mode: ResizeMode.symmetricScale, + unrotatedTarget: Vector2(150, 150), + ); + expect(result.flip, equals(Flip.diagonal)); + }, + ); + }); +} + +/// Test helper: drives `BoxTransformer.resize` at rotation π/6 with a target +/// expressed in the box's un-rotated frame. +/// +/// The box is fixed at `Box.fromLTWH(150, 150, 100, 100)` with rotation +/// π/6 — center (200, 200), corners (150, 150), (250, 150), (250, 250), +/// (150, 250). Pointer positions are constructed by rotating the supplied +/// un-rotated coordinates back into world space, mirroring how a real +/// gesture's world-frame pointer would un-rotate inside the resize math. +RawResizeResult _resizeRotated({ + required HandlePosition handle, + required ResizeMode mode, + required Vector2 unrotatedTarget, + Flip initialFlip = Flip.none, + bool allowFlipping = true, +}) { + const theta = math.pi / 6; + final initial = Box.fromLTWH(150, 150, 100, 100, rotation: theta); + final center = Vector2(200, 200); + final initialPointerLocal = switch (handle) { + HandlePosition.topLeft => Vector2(initial.left, initial.top), + HandlePosition.topRight => Vector2(initial.right, initial.top), + HandlePosition.bottomRight => Vector2(initial.right, initial.bottom), + HandlePosition.bottomLeft => Vector2(initial.left, initial.bottom), + _ => throw UnsupportedError('handle $handle not used in this suite'), + }; + final initialPointerWorld = + ClampHelpers.rotatePointAround(initialPointerLocal, center, theta); + final targetPointerWorld = + ClampHelpers.rotatePointAround(unrotatedTarget, center, theta); + + return BoxTransformer.resize( + initialRect: initial, + initialLocalPosition: initialPointerWorld, + localPosition: targetPointerWorld, + handle: handle, + resizeMode: mode, + initialFlip: initialFlip, + rotation: theta, + bindingStrategy: BindingStrategy.originalBox, + allowFlipping: allowFlipping, + ); +} diff --git a/packages/box_transform/test/rotated_freeform_test.dart b/packages/box_transform/test/rotated_freeform_test.dart new file mode 100644 index 0000000..3c1f57e --- /dev/null +++ b/packages/box_transform/test/rotated_freeform_test.dart @@ -0,0 +1,141 @@ +import 'dart:math' as math; + +import 'package:box_transform/box_transform.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math_64.dart'; + +void main() { + group('resize() accepts rotation and bindingStrategy parameters', () { + test('theta=0 call with new params produces same result as old call', () { + final initial = Box.fromLTWH(0, 0, 100, 100); + final clamp = Box.fromLTRB(-500, -500, 500, 500); + final oldResult = BoxTransformer.resize( + initialRect: initial, + initialLocalPosition: Vector2(100, 100), + localPosition: Vector2(150, 120), + handle: HandlePosition.bottomRight, + resizeMode: ResizeMode.freeform, + initialFlip: Flip.none, + clampingRect: clamp, + constraints: const Constraints.unconstrained(), + allowFlipping: true, + ); + final newResult = BoxTransformer.resize( + initialRect: initial, + initialLocalPosition: Vector2(100, 100), + localPosition: Vector2(150, 120), + handle: HandlePosition.bottomRight, + resizeMode: ResizeMode.freeform, + initialFlip: Flip.none, + clampingRect: clamp, + constraints: const Constraints.unconstrained(), + allowFlipping: true, + rotation: 0.0, + bindingStrategy: BindingStrategy.originalBox, + ); + expect(newResult.rect, equals(oldResult.rect)); + expect(newResult.flip, equals(oldResult.flip)); + expect(newResult.rotation, 0.0); + }); + }); + + group('Freeform resize with rotation', () { + test('theta=pi/4 bottomRight drag stays within clamp rect (boundingBox)', + () { + final initial = Box.fromLTWH(0, 0, 100, 100, rotation: math.pi / 4); + final initialCorner = _worldCorner(initial, HandlePosition.bottomRight); + final result = BoxTransformer.resize( + initialRect: initial, + initialLocalPosition: initialCorner, + localPosition: Vector2(300, 300), + handle: HandlePosition.bottomRight, + resizeMode: ResizeMode.freeform, + initialFlip: Flip.none, + clampingRect: Box.fromLTRB(-200, -200, 200, 200), + constraints: const Constraints.unconstrained(), + allowFlipping: false, + rotation: math.pi / 4, + bindingStrategy: BindingStrategy.boundingBox, + ); + final corners = _allWorldCorners(result.rect); + for (final c in corners) { + expect(c.x, greaterThanOrEqualTo(-200 - 1e-4)); + expect(c.x, lessThanOrEqualTo(200 + 1e-4)); + expect(c.y, greaterThanOrEqualTo(-200 - 1e-4)); + expect(c.y, lessThanOrEqualTo(200 + 1e-4)); + } + expect(result.rect.rotation, closeTo(math.pi / 4, 1e-12)); + }); + + test('anchor (opposite corner) world position is preserved', () { + final initial = Box.fromLTWH(0, 0, 100, 100, rotation: math.pi / 6); + final initialAnchor = _worldCorner(initial, HandlePosition.topLeft); + final initialCorner = _worldCorner(initial, HandlePosition.bottomRight); + final result = BoxTransformer.resize( + initialRect: initial, + initialLocalPosition: initialCorner, + localPosition: Vector2(50, 150), + handle: HandlePosition.bottomRight, + resizeMode: ResizeMode.freeform, + initialFlip: Flip.none, + clampingRect: Box.fromLTRB(-500, -500, 500, 500), + constraints: const Constraints.unconstrained(), + allowFlipping: false, + rotation: math.pi / 6, + ); + final anchorAfter = _worldCorner(result.rect, HandlePosition.topLeft); + expect(anchorAfter.x, closeTo(initialAnchor.x, 1e-4)); + expect(anchorAfter.y, closeTo(initialAnchor.y, 1e-4)); + }); + + test('minWidth respected under rotation', () { + final initial = Box.fromLTWH(0, 0, 100, 100, rotation: math.pi / 4); + final initialCorner = _worldCorner(initial, HandlePosition.bottomRight); + final result = BoxTransformer.resize( + initialRect: initial, + initialLocalPosition: initialCorner, + localPosition: Vector2(0, 0), // try to shrink past zero + handle: HandlePosition.bottomRight, + resizeMode: ResizeMode.freeform, + initialFlip: Flip.none, + clampingRect: Box.fromLTRB(-500, -500, 500, 500), + constraints: const Constraints(minWidth: 30, minHeight: 30), + allowFlipping: false, + rotation: math.pi / 4, + ); + expect(result.rect.width, greaterThanOrEqualTo(30 - 1e-4)); + expect(result.rect.height, greaterThanOrEqualTo(30 - 1e-4)); + }); + }); +} + +/// Test helper: world position of a specific corner of a rotated [box]. +Vector2 _worldCorner(Box box, HandlePosition handle) { + final cx = (box.left + box.right) / 2; + final cy = (box.top + box.bottom) / 2; + final center = Vector2(cx, cy); + late final Vector2 unrotated; + switch (handle) { + case HandlePosition.topLeft: + unrotated = Vector2(box.left, box.top); + case HandlePosition.topRight: + unrotated = Vector2(box.right, box.top); + case HandlePosition.bottomRight: + unrotated = Vector2(box.right, box.bottom); + case HandlePosition.bottomLeft: + unrotated = Vector2(box.left, box.bottom); + default: + throw UnsupportedError('handle $handle not supported'); + } + return ClampHelpers.rotatePointAround(unrotated, center, box.rotation); +} + +/// Test helper: all four world-space rotated corners of [box]. +List _allWorldCorners(Box box) { + return [ + HandlePosition.topLeft, + HandlePosition.topRight, + HandlePosition.bottomRight, + HandlePosition.bottomLeft, + ].map((h) => _worldCorner(box, h)).toList(); +} diff --git a/packages/box_transform/test/rotated_move_strategy_divergence_test.dart b/packages/box_transform/test/rotated_move_strategy_divergence_test.dart new file mode 100644 index 0000000..d898d95 --- /dev/null +++ b/packages/box_transform/test/rotated_move_strategy_divergence_test.dart @@ -0,0 +1,79 @@ +import 'dart:math' as math; + +import 'package:box_transform/box_transform.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math_64.dart'; + +/// Cross-strategy divergence tests for BoxTransformer.move at nonzero +/// rotation. Proves that `originalBox` and `boundingBox` produce materially +/// different translation results — the Phase 2 move() fix is active. +void main() { + group('Divergence: move() at nonzero theta', () { + test( + 'translation beyond rotated bounding is allowed in originalBox ' + 'but blocked in boundingBox', () { + // 100x100 box at pi/4 centered at (50, 50). Rotated bounding spans + // ~(−20.71, −20.71, 120.71, 120.71). Clamp sized exactly to that + // bounding so boundingBox has zero slack; originalBox sees the + // unrotated rect (0, 0, 100, 100) which has 20.71px slack on the + // right edge (120.71 − 100) before the unrotated-corner hits clamp. + final initial = Box.fromLTWH(0, 0, 100, 100, rotation: math.pi / 4); + final half = 100 * math.sqrt2 / 2; + final clamp = Box.fromLTRB(50 - half, 50 - half, 50 + half, 50 + half); + + // User wants to translate rightward by 50px. + final resB = BoxTransformer.move( + initialRect: initial, + initialLocalPosition: Vector2(0, 0), + localPosition: Vector2(50, 0), + clampingRect: clamp, + rotation: math.pi / 4, + bindingStrategy: BindingStrategy.boundingBox, + ); + final resO = BoxTransformer.move( + initialRect: initial, + initialLocalPosition: Vector2(0, 0), + localPosition: Vector2(50, 0), + clampingRect: clamp, + rotation: math.pi / 4, + bindingStrategy: BindingStrategy.originalBox, + ); + + // boundingBox: rotated bounding already at clamp edge, no slack. + expect(resB.delta.x, closeTo(0, 0.5), + reason: 'boundingBox must block translation — saturated'); + // originalBox: unrotated rect (0,0,100,100) has ~20.71 slack before + // the right unrotated edge (x=100) hits clamp.right (120.71). + expect(resO.delta.x, greaterThan(10), + reason: 'originalBox must allow at least some translation'); + // And the two deltas must differ measurably. + expect((resO.delta.x - resB.delta.x).abs(), greaterThan(5), + reason: 'originalBox and boundingBox must diverge on translation'); + }); + + test('at theta=0 the two strategies produce identical translation', () { + // Sanity: rotation=0 takes the non-rotated branch and strategy is + // moot. Both modes must match. + final initial = Box.fromLTWH(0, 0, 100, 100); + final clamp = Box.fromLTRB(-100, -100, 200, 200); + final resB = BoxTransformer.move( + initialRect: initial, + initialLocalPosition: Vector2(50, 50), + localPosition: Vector2(150, 150), + clampingRect: clamp, + rotation: 0.0, + bindingStrategy: BindingStrategy.boundingBox, + ); + final resO = BoxTransformer.move( + initialRect: initial, + initialLocalPosition: Vector2(50, 50), + localPosition: Vector2(150, 150), + clampingRect: clamp, + rotation: 0.0, + bindingStrategy: BindingStrategy.originalBox, + ); + expect(resO.rect.left, closeTo(resB.rect.left, 1e-9)); + expect(resO.rect.top, closeTo(resB.rect.top, 1e-9)); + }); + }); +} diff --git a/packages/box_transform/test/rotated_move_test.dart b/packages/box_transform/test/rotated_move_test.dart new file mode 100644 index 0000000..2a965ba --- /dev/null +++ b/packages/box_transform/test/rotated_move_test.dart @@ -0,0 +1,47 @@ +import 'dart:math' as math; + +import 'package:box_transform/box_transform.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math_64.dart'; + +void main() { + group('Rotation-aware move', () { + test('rotated box translation keeps all corners in clamp', () { + final initial = Box.fromLTWH(0, 0, 100, 100, rotation: math.pi / 4); + final result = BoxTransformer.move( + initialRect: initial, + initialLocalPosition: Vector2(50, 50), + localPosition: Vector2(300, 300), + clampingRect: Box.fromLTRB(-100, -100, 200, 200), + rotation: math.pi / 4, + bindingStrategy: BindingStrategy.boundingBox, + ); + final cx = (result.rect.left + result.rect.right) / 2; + final cy = (result.rect.top + result.rect.bottom) / 2; + final corners = [ + Vector2(result.rect.left, result.rect.top), + Vector2(result.rect.right, result.rect.top), + Vector2(result.rect.right, result.rect.bottom), + Vector2(result.rect.left, result.rect.bottom), + ].map((p) => + ClampHelpers.rotatePointAround(p, Vector2(cx, cy), math.pi / 4)); + for (final c in corners) { + expect(c.x, inInclusiveRange(-100 - 1e-4, 200 + 1e-4)); + expect(c.y, inInclusiveRange(-100 - 1e-4, 200 + 1e-4)); + } + expect(result.rect.rotation, closeTo(math.pi / 4, 1e-12)); + }); + + test('theta=0 move is unchanged from existing behavior', () { + final initial = Box.fromLTWH(0, 0, 100, 100); + final result = BoxTransformer.move( + initialRect: initial, + initialLocalPosition: Vector2(50, 50), + localPosition: Vector2(70, 80), + clampingRect: Box.fromLTRB(-500, -500, 500, 500), + ); + expect(result.rect.left, closeTo(20, 1e-9)); + expect(result.rect.top, closeTo(30, 1e-9)); + }); + }); +} diff --git a/packages/box_transform/test/rotated_scale_test.dart b/packages/box_transform/test/rotated_scale_test.dart new file mode 100644 index 0000000..8b5a7fa --- /dev/null +++ b/packages/box_transform/test/rotated_scale_test.dart @@ -0,0 +1,84 @@ +import 'dart:math' as math; + +import 'package:box_transform/box_transform.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math_64.dart'; + +void main() { + group('Scale resize with rotation', () { + test('preserves aspect ratio under rotation', () { + final initial = Box.fromLTWH(0, 0, 200, 100, rotation: math.pi / 6); + final initialAspect = initial.width / initial.height; + final initialCorner = _worldCorner(initial, HandlePosition.bottomRight); + final result = BoxTransformer.resize( + initialRect: initial, + initialLocalPosition: initialCorner, + localPosition: Vector2(400, 300), + handle: HandlePosition.bottomRight, + resizeMode: ResizeMode.scale, + initialFlip: Flip.none, + clampingRect: Box.fromLTRB(-500, -500, 500, 500), + constraints: const Constraints(), + allowFlipping: false, + rotation: math.pi / 6, + ); + expect( + result.rect.width / result.rect.height, closeTo(initialAspect, 1e-4)); + }); + + test( + 'clamps rotated corners within bounds while preserving aspect ' + '(boundingBox)', () { + final initial = Box.fromLTWH(0, 0, 100, 100, rotation: math.pi / 4); + final initialCorner = _worldCorner(initial, HandlePosition.bottomRight); + final result = BoxTransformer.resize( + initialRect: initial, + initialLocalPosition: initialCorner, + localPosition: Vector2(1000, 1000), + handle: HandlePosition.bottomRight, + resizeMode: ResizeMode.scale, + initialFlip: Flip.none, + clampingRect: Box.fromLTRB(-100, -100, 100, 100), + constraints: const Constraints(), + allowFlipping: false, + rotation: math.pi / 4, + bindingStrategy: BindingStrategy.boundingBox, + ); + expect(result.rect.width, closeTo(result.rect.height, 1e-4)); + final corners = _allWorldCorners(result.rect); + for (final c in corners) { + expect(c.x, inInclusiveRange(-100 - 1e-4, 100 + 1e-4)); + expect(c.y, inInclusiveRange(-100 - 1e-4, 100 + 1e-4)); + } + }); + }); +} + +Vector2 _worldCorner(Box box, HandlePosition handle) { + final cx = (box.left + box.right) / 2; + final cy = (box.top + box.bottom) / 2; + final center = Vector2(cx, cy); + late final Vector2 unrotated; + switch (handle) { + case HandlePosition.topLeft: + unrotated = Vector2(box.left, box.top); + case HandlePosition.topRight: + unrotated = Vector2(box.right, box.top); + case HandlePosition.bottomRight: + unrotated = Vector2(box.right, box.bottom); + case HandlePosition.bottomLeft: + unrotated = Vector2(box.left, box.bottom); + default: + throw UnsupportedError('handle $handle not supported'); + } + return ClampHelpers.rotatePointAround(unrotated, center, box.rotation); +} + +List _allWorldCorners(Box box) { + return [ + HandlePosition.topLeft, + HandlePosition.topRight, + HandlePosition.bottomRight, + HandlePosition.bottomLeft, + ].map((h) => _worldCorner(box, h)).toList(); +} diff --git a/packages/box_transform/test/rotated_side_handle_test.dart b/packages/box_transform/test/rotated_side_handle_test.dart new file mode 100644 index 0000000..da87fec --- /dev/null +++ b/packages/box_transform/test/rotated_side_handle_test.dart @@ -0,0 +1,1462 @@ +// Tests for side-handle resize under rotation. +// +// Bug: side handles (top/right/bottom/left) are gated to rotation == 0 in +// the Flutter widget layer because the rotated resize methods' rect +// reconstruction assumes `anchorLocal` is a corner. For side handles the +// anchor is the midpoint of the opposite side, so the existing math +// produces a translated rect with the right dimensions on the wrong axis +// position. +// +// Required behavior under rotation, by handle: +// - right: width grows/shrinks. Height unchanged. centerLeft (world) +// preserved. +// - left: width grows/shrinks from the other side. centerRight preserved. +// - bottom: height grows/shrinks. Width unchanged. topCenter preserved. +// - top: height grows/shrinks from the other side. bottomCenter preserved. +// +// All under any rotation. The handle's anchor (the midpoint of the opposite +// side) must hold its world-space position across the gesture, mirroring the +// corner-anchor invariant. + +import 'dart:math' as math; + +import 'package:box_transform/box_transform.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math_64.dart'; + +void main() { + const theta = math.pi / 6; + + group('Right side handle under rotation', () { + test('drag outward: width grows, height fixed, centerLeft preserved', () { + // 100×100 box at (200, 200), rotated π/6. Drag right handle 50 px + // outward in the unrotated frame. + final initial = Box.fromLTWH(200, 200, 100, 100, rotation: theta); + final result = _resize( + initial: initial, + handle: HandlePosition.right, + // initial pointer at the right-edge midpoint (in unrotated frame) + initialUnrotated: Vector2(300, 250), + // target pointer 50 px further along local +x + targetUnrotated: Vector2(350, 250), + ); + + expect(result.rect.width, closeTo(150, 1e-9)); + expect(result.rect.height, closeTo(100, 1e-9)); + expect(result.rect.rotation, closeTo(theta, 1e-12)); + _expectAnchorPreserved(initial, result.rect, HandlePosition.right); + }); + }); + + group('Left side handle under rotation', () { + test('drag outward: width grows, height fixed, centerRight preserved', () { + final initial = Box.fromLTWH(200, 200, 100, 100, rotation: theta); + final result = _resize( + initial: initial, + handle: HandlePosition.left, + initialUnrotated: Vector2(200, 250), + targetUnrotated: Vector2(150, 250), + ); + + expect(result.rect.width, closeTo(150, 1e-9)); + expect(result.rect.height, closeTo(100, 1e-9)); + expect(result.rect.rotation, closeTo(theta, 1e-12)); + _expectAnchorPreserved(initial, result.rect, HandlePosition.left); + }); + }); + + group('Bottom side handle under rotation', () { + test('drag outward: height grows, width fixed, topCenter preserved', () { + final initial = Box.fromLTWH(200, 200, 100, 100, rotation: theta); + final result = _resize( + initial: initial, + handle: HandlePosition.bottom, + initialUnrotated: Vector2(250, 300), + targetUnrotated: Vector2(250, 350), + ); + + expect(result.rect.width, closeTo(100, 1e-9)); + expect(result.rect.height, closeTo(150, 1e-9)); + expect(result.rect.rotation, closeTo(theta, 1e-12)); + _expectAnchorPreserved(initial, result.rect, HandlePosition.bottom); + }); + }); + + group('Top side handle under rotation', () { + test('drag outward: height grows, width fixed, bottomCenter preserved', () { + final initial = Box.fromLTWH(200, 200, 100, 100, rotation: theta); + final result = _resize( + initial: initial, + handle: HandlePosition.top, + initialUnrotated: Vector2(250, 200), + targetUnrotated: Vector2(250, 150), + ); + + expect(result.rect.width, closeTo(100, 1e-9)); + expect(result.rect.height, closeTo(150, 1e-9)); + expect(result.rect.rotation, closeTo(theta, 1e-12)); + _expectAnchorPreserved(initial, result.rect, HandlePosition.top); + }); + }); + + group('Right side handle under rotation — scale mode', () { + test('aspect ratio preserved, anchor (centerLeft) world-fixed', () { + // Aspect ratio is 1:1 here, so width and height grow equally. + final initial = Box.fromLTWH(200, 200, 100, 100, rotation: theta); + final result = _resize( + initial: initial, + handle: HandlePosition.right, + initialUnrotated: Vector2(300, 250), + targetUnrotated: Vector2(350, 250), + mode: ResizeMode.scale, + ); + + expect(result.rect.width, closeTo(150, 1e-9)); + expect(result.rect.height, closeTo(150, 1e-9)); + _expectAnchorPreserved(initial, result.rect, HandlePosition.right); + }); + }); + + group('Right side handle under rotation — symmetric mode', () { + test('width grows symmetrically about center, height fixed', () { + final initial = Box.fromLTWH(200, 200, 100, 100, rotation: theta); + final result = _resize( + initial: initial, + handle: HandlePosition.right, + initialUnrotated: Vector2(300, 250), + targetUnrotated: Vector2(325, 250), + mode: ResizeMode.symmetric, + ); + + // Pointer moved 25 px outward → both sides extend 25 px → width 150. + expect(result.rect.width, closeTo(150, 1e-9)); + expect(result.rect.height, closeTo(100, 1e-9)); + // Center stays at the rotated rect center (preserved across symmetric + // resizes). + final initCenter = Vector2( + (initial.left + initial.right) / 2, + (initial.top + initial.bottom) / 2, + ); + final resCenter = Vector2( + (result.rect.left + result.rect.right) / 2, + (result.rect.top + result.rect.bottom) / 2, + ); + expect(resCenter.x, closeTo(initCenter.x, 1e-6)); + expect(resCenter.y, closeTo(initCenter.y, 1e-6)); + }); + }); + + group('Side handles respect clampingRect under rotation', () { + test( + 'bottom handle drag past clamp: rotated AABB stays inside clamp', + () { + // Slight rotation, plenty of room above, very little below. Bottom + // handle dragged way past the clamp's bottom edge: the rect must + // grow only as far as the clamp allows; it must NOT extend past + // the clamp on the opposite (top) side. + const theta = math.pi / 12; + final initial = Box.fromLTWH(150, 150, 100, 100, rotation: theta); + final clamp = Box.fromLTRB(0, 0, 400, 400); + final result = _resize( + initial: initial, + handle: HandlePosition.bottom, + initialUnrotated: Vector2(200, 250), + // Pointer dragged way down to attempt extreme growth. + targetUnrotated: Vector2(200, 5000), + mode: ResizeMode.freeform, + bindingStrategy: BindingStrategy.boundingBox, + clampingRect: clamp, + ); + + // Compute rotated AABB of result.rect. + final res = result.rect; + final aabb = ClampHelpers.calculateBoundingRect(res); + expect( + aabb.left, + greaterThanOrEqualTo(clamp.left - 1e-6), + reason: 'rect leaked past clamp.left', + ); + expect( + aabb.top, + greaterThanOrEqualTo(clamp.top - 1e-6), + reason: 'rect leaked past clamp.top', + ); + expect( + aabb.right, + lessThanOrEqualTo(clamp.right + 1e-6), + reason: 'rect leaked past clamp.right', + ); + expect( + aabb.bottom, + lessThanOrEqualTo(clamp.bottom + 1e-6), + reason: 'rect leaked past clamp.bottom', + ); + }, + ); + }); + + group('Corner handle: vertical cursor drag does not shrink width', () { + test( + 'bottomRight handle, slight rotation: dragging cursor straight down ' + 'past clamp.bottom keeps width = initial width', + () { + // User reports: bottomRight corner under slight rotation, dragging + // cursor straight down past clamp.bottom — the rect's WIDTH changes + // even though cursor only moves vertically. + const theta = math.pi / 12; + final initial = Box.fromLTWH(50, 50, 100, 100, rotation: theta); + final clamp = Box.fromLTRB(0, 0, 400, 200); + final center = Vector2(100, 100); + // Pointer starts at the rotated bottomRight corner. + final initialPointerWorld = ClampHelpers.rotatePointAround( + Vector2(150, 150), + center, + theta, + ); + // Sweep cursor straight down (in screen / world coords) past clamp. + for (int i = 1; i <= 50; i++) { + // Pure vertical screen motion. + final targetPointerWorld = Vector2( + initialPointerWorld.x, + initialPointerWorld.y + i * 5.0, + ); + final result = BoxTransformer.resize( + initialRect: initial, + initialLocalPosition: initialPointerWorld, + localPosition: targetPointerWorld, + handle: HandlePosition.bottomRight, + resizeMode: ResizeMode.freeform, + initialFlip: Flip.none, + rotation: theta, + bindingStrategy: BindingStrategy.boundingBox, + clampingRect: clamp, + allowFlipping: false, + ); + // With pure vertical cursor motion, width should equal the + // un-rotated cursor projection onto the box's local x-axis. For + // bottomRight, that means width *can* grow (because the rotated + // cursor delta has a positive box-local x component) — but it + // must NOT shrink below the initial width. + expect( + result.rect.width, + greaterThanOrEqualTo(initial.width - 1e-6), + reason: 'tick $i: width shrank below initial ' + '(${result.rect.width} < ${initial.width})', + ); + } + }, + ); + }); + + group('Corner handle: monotonic LP projection at constraint corner', () { + test( + 'bottomRight cursor sweep past constraint corner — both axes stay ' + 'monotonic (regression for the old two-step LP non-monotonicity)', + () { + const theta = math.pi / 12; + final initial = Box.fromLTWH(50, 50, 100, 100, rotation: theta); + final clamp = Box.fromLTRB(0, 0, 800, 200); + final center = Vector2(100, 100); + final initialPointerWorld = ClampHelpers.rotatePointAround( + Vector2(150, 150), + center, + theta, + ); + // Phase 1: cursor goes straight down past clamp.bottom. + // Phase 2: cursor sweeps right (cursor still below clamp.bottom). + final widths = []; + final heights = []; + for (int i = 0; i <= 60; i++) { + // For i ≤ 30, drag straight down; for i > 30, drag right. + final downPart = i <= 30 ? i * 5.0 : 150.0; + final rightPart = i > 30 ? (i - 30) * 5.0 : 0.0; + final targetPointerWorld = Vector2( + initialPointerWorld.x + rightPart, + initialPointerWorld.y + downPart, + ); + final result = BoxTransformer.resize( + initialRect: initial, + initialLocalPosition: initialPointerWorld, + localPosition: targetPointerWorld, + handle: HandlePosition.bottomRight, + resizeMode: ResizeMode.freeform, + initialFlip: Flip.none, + rotation: theta, + bindingStrategy: BindingStrategy.boundingBox, + clampingRect: clamp, + allowFlipping: false, + ); + widths.add(result.rect.width); + heights.add(result.rect.height); + } + // Width must be monotonic non-decreasing across the whole sweep + // (cursor never moves left). Tolerance accommodates float precision + // along the L2 projection's constraint line. + for (int i = 1; i < widths.length; i++) { + expect( + widths[i], + greaterThanOrEqualTo(widths[i - 1] - 1e-6), + reason: 'tick $i: width regressed ' + '(${widths[i - 1]} → ${widths[i]})', + ); + } + // Height during phase 1 (cursor going down) must be non-decreasing. + for (int i = 1; i <= 30; i++) { + expect( + heights[i], + greaterThanOrEqualTo(heights[i - 1] - 1e-6), + reason: 'tick $i (phase 1): height regressed ' + '(${heights[i - 1]} → ${heights[i]})', + ); + } + }, + ); + }); + + group('Side handle: tight clamp at every tick of a sweep', () { + test( + 'right handle: rect close to clamp.bottom — drag tick-by-tick keeps ' + 'all rotated corners inside clamp', + () { + // Position the box so the rotated bottom-right corner is already + // close to clamp.bottom. Drag right handle outward in tiny ticks. + // At every tick, all rotated corners must stay inside clamp. + const theta = math.pi / 12; + final initial = Box.fromLTWH(50, 200, 100, 100, rotation: theta); + final clamp = Box.fromLTRB(0, 0, 400, 320); + final center = Vector2(100, 250); + final initialPointerWorld = ClampHelpers.rotatePointAround( + Vector2(150, 250), + center, + theta, + ); + + for (int i = 0; i <= 60; i++) { + // Tiny 0.5 px ticks. + final targetUnrotated = Vector2(150 + i * 0.5, 250); + final targetPointerWorld = ClampHelpers.rotatePointAround( + targetUnrotated, + center, + theta, + ); + final result = BoxTransformer.resize( + initialRect: initial, + initialLocalPosition: initialPointerWorld, + localPosition: targetPointerWorld, + handle: HandlePosition.right, + resizeMode: ResizeMode.freeform, + initialFlip: Flip.none, + rotation: theta, + bindingStrategy: BindingStrategy.boundingBox, + clampingRect: clamp, + allowFlipping: false, + ); + final r = result.rect; + final rcenter = Vector2( + (r.left + r.right) / 2, + (r.top + r.bottom) / 2, + ); + final corners = [ + Vector2(r.left, r.top), + Vector2(r.right, r.top), + Vector2(r.right, r.bottom), + Vector2(r.left, r.bottom), + ] + .map( + (p) => ClampHelpers.rotatePointAround(p, rcenter, r.rotation)) + .toList(); + for (int k = 0; k < 4; k++) { + final c = corners[k]; + expect( + c.x, + greaterThanOrEqualTo(clamp.left - 1e-4), + reason: 'tick $i corner $k: x=${c.x} leaked past clamp.left', + ); + expect( + c.y, + greaterThanOrEqualTo(clamp.top - 1e-4), + reason: 'tick $i corner $k: y=${c.y} leaked past clamp.top', + ); + expect( + c.x, + lessThanOrEqualTo(clamp.right + 1e-4), + reason: 'tick $i corner $k: x=${c.x} leaked past clamp.right', + ); + expect( + c.y, + lessThanOrEqualTo(clamp.bottom + 1e-4), + reason: 'tick $i corner $k: y=${c.y} leaked past clamp.bottom', + ); + } + } + }, + ); + }); + + group('Force-flip during rotated resize keeps rect inside clamp', () { + test( + 'bottomRight handle: drag pointer past topLeft anchor and beyond ' + 'clamp.left — rotated rect stays inside clamp', + () { + // User drags the bottomRight corner of a rotated rect past the + // topLeft anchor (force-flipping the rect to the LEFT of anchor), + // and continues dragging past the clamp's left edge. + const theta = math.pi / 12; + final initial = Box.fromLTWH(150, 100, 100, 100, rotation: theta); + final clamp = Box.fromLTRB(0, 0, 400, 400); + final center = Vector2(200, 150); + final initialPointerWorld = ClampHelpers.rotatePointAround( + Vector2(250, 200), + center, + theta, + ); + // Drag pointer far to the left + down to force a horizontal flip + // past the clamp. + final targetUnrotated = Vector2(-200, 200); + final targetPointerWorld = ClampHelpers.rotatePointAround( + targetUnrotated, + center, + theta, + ); + final result = BoxTransformer.resize( + initialRect: initial, + initialLocalPosition: initialPointerWorld, + localPosition: targetPointerWorld, + handle: HandlePosition.bottomRight, + resizeMode: ResizeMode.freeform, + initialFlip: Flip.none, + rotation: theta, + bindingStrategy: BindingStrategy.boundingBox, + clampingRect: clamp, + allowFlipping: true, + ); + // The resulting rect's rotated AABB must stay entirely inside + // the clamp despite the flip. + final r = result.rect; + final rcenter = Vector2( + (r.left + r.right) / 2, + (r.top + r.bottom) / 2, + ); + final corners = [ + Vector2(r.left, r.top), + Vector2(r.right, r.top), + Vector2(r.right, r.bottom), + Vector2(r.left, r.bottom), + ] + .map((p) => ClampHelpers.rotatePointAround(p, rcenter, r.rotation)) + .toList(); + for (int k = 0; k < 4; k++) { + expect(corners[k].x, greaterThanOrEqualTo(clamp.left - 1e-4), + reason: 'corner $k leaked past clamp.left'); + expect(corners[k].y, greaterThanOrEqualTo(clamp.top - 1e-4), + reason: 'corner $k leaked past clamp.top'); + expect(corners[k].x, lessThanOrEqualTo(clamp.right + 1e-4), + reason: 'corner $k leaked past clamp.right'); + expect(corners[k].y, lessThanOrEqualTo(clamp.bottom + 1e-4), + reason: 'corner $k leaked past clamp.bottom'); + } + }, + ); + }); + + group('Force-flip multi-tick: clamp respected throughout', () { + test( + 'sweep cursor across anchor and past clamp.left — every tick stays ' + 'inside clamp', + () { + const theta = math.pi / 12; + final initial = Box.fromLTWH(150, 100, 100, 100, rotation: theta); + final clamp = Box.fromLTRB(0, 0, 400, 400); + final center = Vector2(200, 150); + final initialPointerWorld = ClampHelpers.rotatePointAround( + Vector2(250, 200), + center, + theta, + ); + // Sweep from right of anchor past anchor past clamp.left. + for (int i = 0; i <= 80; i++) { + // Cursor goes from x=250 to x=-200 in unrotated frame (across + // anchor at x=150, then past clamp.left at x=0). + final unrotatedX = 250.0 - i * 6.0; + final targetUnrotated = Vector2(unrotatedX, 200); + final targetPointerWorld = ClampHelpers.rotatePointAround( + targetUnrotated, + center, + theta, + ); + final result = BoxTransformer.resize( + initialRect: initial, + initialLocalPosition: initialPointerWorld, + localPosition: targetPointerWorld, + handle: HandlePosition.bottomRight, + resizeMode: ResizeMode.freeform, + initialFlip: Flip.none, + rotation: theta, + bindingStrategy: BindingStrategy.boundingBox, + clampingRect: clamp, + allowFlipping: true, + ); + final r = result.rect; + final rcenter = Vector2( + (r.left + r.right) / 2, + (r.top + r.bottom) / 2, + ); + final corners = [ + Vector2(r.left, r.top), + Vector2(r.right, r.top), + Vector2(r.right, r.bottom), + Vector2(r.left, r.bottom), + ] + .map( + (p) => ClampHelpers.rotatePointAround(p, rcenter, r.rotation)) + .toList(); + for (int k = 0; k < 4; k++) { + expect(corners[k].x, greaterThanOrEqualTo(clamp.left - 1e-3), + reason: 'tick $i corner $k: x=${corners[k].x} ' + 'leaked past clamp.left'); + expect(corners[k].y, greaterThanOrEqualTo(clamp.top - 1e-3), + reason: 'tick $i corner $k: y=${corners[k].y} ' + 'leaked past clamp.top'); + expect(corners[k].x, lessThanOrEqualTo(clamp.right + 1e-3), + reason: 'tick $i corner $k: x=${corners[k].x} ' + 'leaked past clamp.right'); + expect(corners[k].y, lessThanOrEqualTo(clamp.bottom + 1e-3), + reason: 'tick $i corner $k: y=${corners[k].y} ' + 'leaked past clamp.bottom'); + } + } + }, + ); + }); + + group('Force-flip at large rotation: clamp respected', () { + test( + 'theta=π/4, drag past anchor in extreme amounts — no leak', + () { + const theta = math.pi / 4; + final initial = Box.fromLTWH(150, 150, 100, 100, rotation: theta); + final clamp = Box.fromLTRB(0, 0, 400, 400); + final center = Vector2(200, 200); + final initialPointerWorld = ClampHelpers.rotatePointAround( + Vector2(250, 250), + center, + theta, + ); + // Sweep cursor in many directions, including past anchor and past + // clamp. + for (final pair in const [ + [-300, 250], // far left + [250, -300], // far up + [-300, -300], // diagonal up-left (far) + [600, 600], // diagonal far down-right + [-100, 250], // mild left flip + [50, 50], // past anchor diagonally, modest amount + [50, 250], // past anchor x only + [250, 50], // past anchor y only + ]) { + final targetUnrotated = + Vector2(pair[0].toDouble(), pair[1].toDouble()); + final targetPointerWorld = ClampHelpers.rotatePointAround( + targetUnrotated, + center, + theta, + ); + final result = BoxTransformer.resize( + initialRect: initial, + initialLocalPosition: initialPointerWorld, + localPosition: targetPointerWorld, + handle: HandlePosition.bottomRight, + resizeMode: ResizeMode.freeform, + initialFlip: Flip.none, + rotation: theta, + bindingStrategy: BindingStrategy.boundingBox, + clampingRect: clamp, + allowFlipping: true, + ); + final r = result.rect; + final rcenter = Vector2( + (r.left + r.right) / 2, + (r.top + r.bottom) / 2, + ); + final corners = [ + Vector2(r.left, r.top), + Vector2(r.right, r.top), + Vector2(r.right, r.bottom), + Vector2(r.left, r.bottom), + ] + .map( + (p) => ClampHelpers.rotatePointAround(p, rcenter, r.rotation)) + .toList(); + for (int k = 0; k < 4; k++) { + expect(corners[k].x, greaterThanOrEqualTo(clamp.left - 1e-3), + reason: 'pair=$pair corner $k: leaked clamp.left ' + '(x=${corners[k].x})'); + expect(corners[k].y, greaterThanOrEqualTo(clamp.top - 1e-3), + reason: 'pair=$pair corner $k: leaked clamp.top ' + '(y=${corners[k].y})'); + expect(corners[k].x, lessThanOrEqualTo(clamp.right + 1e-3), + reason: 'pair=$pair corner $k: leaked clamp.right'); + expect(corners[k].y, lessThanOrEqualTo(clamp.bottom + 1e-3), + reason: 'pair=$pair corner $k: leaked clamp.bottom'); + } + } + }, + ); + }); + + group('Side-handle force-flip near clamp edge', () { + test( + 'right handle, box near clamp.left: drag past anchor + past clamp ' + '— rotated polygon stays in clamp', + () { + // Box near clamp.left. anchor for right handle = centerLeft. + // Drag pointer far to the left, past anchor (force x-flip) AND + // past clamp.left=0. With widthSign aware of the flip, the LP + // should constrain the actually-flipped rect's rendered polygon + // to stay in clamp. Note: the unrotated stored bounds may extend + // past the clamp on stretched/flipped geometries — that's + // expected under boundingBox semantics ("rendered AABB stays + // in the clamp"); the stored rect is invisible storage. + const theta = math.pi / 12; + final initial = Box.fromLTWH(40, 200, 100, 100, rotation: theta); + final clamp = Box.fromLTRB(0, 0, 400, 400); + final center = Vector2(90, 250); + // Pointer starts at the rotated right-edge midpoint (centerRight). + final initialPointerWorld = ClampHelpers.rotatePointAround( + Vector2(140, 250), + center, + theta, + ); + // Drag pointer way past anchor on the left, past clamp.left. + final targetPointerWorld = ClampHelpers.rotatePointAround( + Vector2(-200, 250), + center, + theta, + ); + final result = BoxTransformer.resize( + initialRect: initial, + initialLocalPosition: initialPointerWorld, + localPosition: targetPointerWorld, + handle: HandlePosition.right, + resizeMode: ResizeMode.freeform, + initialFlip: Flip.none, + rotation: theta, + bindingStrategy: BindingStrategy.boundingBox, + clampingRect: clamp, + allowFlipping: true, + ); + final r = result.rect; + // Rotated polygon corners in clamp (the boundingBox contract). + final rcenter = Vector2( + (r.left + r.right) / 2, + (r.top + r.bottom) / 2, + ); + final corners = [ + Vector2(r.left, r.top), + Vector2(r.right, r.top), + Vector2(r.right, r.bottom), + Vector2(r.left, r.bottom), + ] + .map((p) => ClampHelpers.rotatePointAround(p, rcenter, r.rotation)) + .toList(); + for (int k = 0; k < 4; k++) { + expect(corners[k].x, greaterThanOrEqualTo(clamp.left - 1e-3), + reason: 'rotated corner $k leaked clamp.left ' + '(x=${corners[k].x})'); + expect(corners[k].x, lessThanOrEqualTo(clamp.right + 1e-3), + reason: 'rotated corner $k leaked clamp.right ' + '(x=${corners[k].x})'); + expect(corners[k].y, greaterThanOrEqualTo(clamp.top - 1e-3), + reason: 'rotated corner $k leaked clamp.top ' + '(y=${corners[k].y})'); + expect(corners[k].y, lessThanOrEqualTo(clamp.bottom + 1e-3), + reason: 'rotated corner $k leaked clamp.bottom ' + '(y=${corners[k].y})'); + } + }, + ); + + test( + 'top handle, box near clamp.bottom: drag past anchor + past clamp ' + '— rotated polygon stays in clamp', + () { + // Symmetric scenario for vertical-axis flip with top side handle. + const theta = math.pi / 12; + final initial = Box.fromLTWH(150, 280, 100, 100, rotation: theta); + final clamp = Box.fromLTRB(0, 0, 400, 400); + final center = Vector2(200, 330); + // Initial pointer at rotated topCenter. + final initialPointerWorld = ClampHelpers.rotatePointAround( + Vector2(200, 280), + center, + theta, + ); + // Drag pointer way down past anchor (bottomCenter at y=380) and + // past clamp.bottom=400. + final targetPointerWorld = ClampHelpers.rotatePointAround( + Vector2(200, 600), + center, + theta, + ); + final result = BoxTransformer.resize( + initialRect: initial, + initialLocalPosition: initialPointerWorld, + localPosition: targetPointerWorld, + handle: HandlePosition.top, + resizeMode: ResizeMode.freeform, + initialFlip: Flip.none, + rotation: theta, + bindingStrategy: BindingStrategy.boundingBox, + clampingRect: clamp, + allowFlipping: true, + ); + final r = result.rect; + final rcenter = Vector2( + (r.left + r.right) / 2, + (r.top + r.bottom) / 2, + ); + final corners = [ + Vector2(r.left, r.top), + Vector2(r.right, r.top), + Vector2(r.right, r.bottom), + Vector2(r.left, r.bottom), + ] + .map((p) => ClampHelpers.rotatePointAround(p, rcenter, r.rotation)) + .toList(); + for (int k = 0; k < 4; k++) { + expect(corners[k].y, greaterThanOrEqualTo(clamp.top - 1e-3), + reason: 'rotated corner $k leaked clamp.top'); + expect(corners[k].y, lessThanOrEqualTo(clamp.bottom + 1e-3), + reason: 'rotated corner $k leaked clamp.bottom'); + expect(corners[k].x, greaterThanOrEqualTo(clamp.left - 1e-3)); + expect(corners[k].x, lessThanOrEqualTo(clamp.right + 1e-3)); + } + }, + ); + }); + + group('User screenshot: 45° box near top clamp, drag bottomRight up', () { + test( + 'box rotated π/4 near clamp.top, drag bottomRight cursor far up past ' + 'clamp.top — flipped rect must stay inside clamp', + () { + // Clamping rect ≈ a thin band at the top (matches the screenshot). + // Box rotated 45° centered just below the clamp band. User grabs + // the bottomRight corner and drags cursor far up past clamp.top. + const theta = math.pi / 4; + final initial = Box.fromLTWH(100, 50, 100, 100, rotation: theta); + final clamp = Box.fromLTRB(0, 0, 400, 400); + final center = Vector2(150, 100); + // Initial pointer = rotated bottomRight in world. + final initialPointerWorld = ClampHelpers.rotatePointAround( + Vector2(200, 150), + center, + theta, + ); + // Drag cursor far upward past clamp.top=0 — force diagonal flip + // past topLeft anchor and past the clamping rect top edge. + final targetPointerWorld = ClampHelpers.rotatePointAround( + Vector2(50, -300), + center, + theta, + ); + final result = BoxTransformer.resize( + initialRect: initial, + initialLocalPosition: initialPointerWorld, + localPosition: targetPointerWorld, + handle: HandlePosition.bottomRight, + resizeMode: ResizeMode.freeform, + initialFlip: Flip.none, + rotation: theta, + bindingStrategy: BindingStrategy.boundingBox, + clampingRect: clamp, + allowFlipping: true, + ); + final r = result.rect; + // ignore: avoid_print + print('User scenario result rect: $r'); + // ignore: avoid_print + print('User scenario result flip: ${result.flip}'); + // Rotated quad corners must be inside clamp (boundingBox contract). + final rcenter = Vector2( + (r.left + r.right) / 2, + (r.top + r.bottom) / 2, + ); + final corners = [ + Vector2(r.left, r.top), + Vector2(r.right, r.top), + Vector2(r.right, r.bottom), + Vector2(r.left, r.bottom), + ] + .map((p) => ClampHelpers.rotatePointAround(p, rcenter, r.rotation)) + .toList(); + for (int k = 0; k < 4; k++) { + // ignore: avoid_print + print('Rotated corner $k: ${corners[k]}'); + expect(corners[k].x, greaterThanOrEqualTo(clamp.left - 1e-3), + reason: 'rotated corner $k leaked clamp.left'); + expect(corners[k].y, greaterThanOrEqualTo(clamp.top - 1e-3), + reason: 'rotated corner $k leaked clamp.top ' + '(y=${corners[k].y})'); + expect(corners[k].x, lessThanOrEqualTo(clamp.right + 1e-3), + reason: 'rotated corner $k leaked clamp.right'); + expect(corners[k].y, lessThanOrEqualTo(clamp.bottom + 1e-3), + reason: 'rotated corner $k leaked clamp.bottom'); + } + }, + ); + }); + + group('User screenshot: harsh-delta force-flip outside clamp', () { + // Sweep over many drag directions and corner handles, asserting that + // every result keeps the box inside the clamp. + test( + 'all 4 corner handles × 6 drag directions × 2 rotation angles — ' + 'no leak', + () { + final clamp = Box.fromLTRB(0, 0, 400, 400); + final dragsUnrotated = const >[ + [-300, 250], // far left + [250, -300], // far up + [-300, -300], // far up-left (diagonal) + [600, 250], // far right + [250, 600], // far down + [600, 600], // far down-right + ]; + final corners = const [ + HandlePosition.topLeft, + HandlePosition.topRight, + HandlePosition.bottomLeft, + HandlePosition.bottomRight, + ]; + // Use a few rotation angles, including 45° (matches screenshot). + final thetas = [math.pi / 12, math.pi / 4, -math.pi / 4]; + for (final theta in thetas) { + // Position near various corners of clamp. + final positions = const [ + [50, 50], // near top-left + [250, 50], // near top-right + [50, 250], // near bottom-left + [250, 250], // near bottom-right + ]; + for (final pos in positions) { + final initial = Box.fromLTWH( + pos[0].toDouble(), + pos[1].toDouble(), + 100, + 100, + rotation: theta, + ); + final center = Vector2(pos[0] + 50, pos[1] + 50); + for (final handle in corners) { + final local = handle.anchor(initial); // OPPOSITE corner + // The handle's position (in unrotated frame) is the corner + // OPPOSITE the anchor. + final initialPointerLocal = Vector2( + local.x == initial.left ? initial.right : initial.left, + local.y == initial.top ? initial.bottom : initial.top, + ); + final initialPointerWorld = ClampHelpers.rotatePointAround( + initialPointerLocal, center, theta); + for (final drag in dragsUnrotated) { + final targetPointerWorld = ClampHelpers.rotatePointAround( + Vector2(drag[0], drag[1]), + center, + theta, + ); + final result = BoxTransformer.resize( + initialRect: initial, + initialLocalPosition: initialPointerWorld, + localPosition: targetPointerWorld, + handle: handle, + resizeMode: ResizeMode.freeform, + initialFlip: Flip.none, + rotation: theta, + bindingStrategy: BindingStrategy.boundingBox, + clampingRect: clamp, + allowFlipping: true, + ); + final r = result.rect; + final scenario = + 'theta=$theta pos=$pos handle=$handle drag=$drag'; + final rcenter = + Vector2((r.left + r.right) / 2, (r.top + r.bottom) / 2); + final rotatedCorners = [ + Vector2(r.left, r.top), + Vector2(r.right, r.top), + Vector2(r.right, r.bottom), + Vector2(r.left, r.bottom), + ] + .map((p) => + ClampHelpers.rotatePointAround(p, rcenter, r.rotation)) + .toList(); + for (int k = 0; k < 4; k++) { + expect(rotatedCorners[k].x, + greaterThanOrEqualTo(clamp.left - 1e-3), + reason: '$scenario: rot corner $k.x leaked'); + expect(rotatedCorners[k].y, + greaterThanOrEqualTo(clamp.top - 1e-3), + reason: '$scenario: rot corner $k.y leaked'); + expect(rotatedCorners[k].x, + lessThanOrEqualTo(clamp.right + 1e-3), + reason: '$scenario: rot corner $k.x leaked'); + expect(rotatedCorners[k].y, + lessThanOrEqualTo(clamp.bottom + 1e-3), + reason: '$scenario: rot corner $k.y leaked'); + } + } + } + } + } + }, + ); + }); + + group('Thin clamping rect: harsh-delta resize stays inside', () { + // Mirrors the screenshot setup: clamping rect is a thin band, box + // sitting partly inside it, user drags corner far outside. + test( + 'thin horizontal clamp + various rotations + force-flip drags ' + '— rect stays inside clamp', + () { + final clamp = Box.fromLTRB(0, 0, 800, 150); // thin strip + final thetas = [ + math.pi / 12, + math.pi / 6, // ≈ 30°, matches screenshot + math.pi / 4, + -math.pi / 6, + ]; + // Drag targets in the unrotated frame — far outside the strip. + final targets = const >[ + [400, -300], // far up + [400, 600], // far down + [-300, 75], // far left + [1100, 75], // far right + [-300, 600], // far down-left + [1100, 600], // far down-right + [-300, -300], // far up-left + [1100, -300], // far up-right + ]; + // Use a small box so its rotated AABB fits inside the thin clamp + // for all tested rotations. 50×50 → max AABB 50*√2 ≈ 70.7 px. + const boxSize = 50.0; + final corners = const [ + HandlePosition.topLeft, + HandlePosition.topRight, + HandlePosition.bottomLeft, + HandlePosition.bottomRight, + ]; + for (final theta in thetas) { + final rect = Box.fromLTWH( + 375, + 50, + boxSize, + boxSize, + rotation: theta, + ); + final center = Vector2( + (rect.left + rect.right) / 2, + (rect.top + rect.bottom) / 2, + ); + for (final handle in corners) { + final localCorner = handle.anchor(rect); + // The dragged corner is OPPOSITE to the anchor. + final draggedLocal = Vector2( + localCorner.x == rect.left ? rect.right : rect.left, + localCorner.y == rect.top ? rect.bottom : rect.top, + ); + final initialPointerWorld = ClampHelpers.rotatePointAround( + draggedLocal, + center, + theta, + ); + for (final target in targets) { + final targetPointerWorld = ClampHelpers.rotatePointAround( + Vector2(target[0], target[1]), + center, + theta, + ); + final result = BoxTransformer.resize( + initialRect: rect, + initialLocalPosition: initialPointerWorld, + localPosition: targetPointerWorld, + handle: handle, + resizeMode: ResizeMode.freeform, + initialFlip: Flip.none, + rotation: theta, + bindingStrategy: BindingStrategy.boundingBox, + clampingRect: clamp, + allowFlipping: true, + ); + final r = result.rect; + final scenario = 'theta=${theta.toStringAsFixed(4)} ' + 'handle=$handle target=$target'; + // Rotated polygon corners must stay inside clamp; the + // unrotated stored bounds may extend past on stretched/ + // flipped geometries (boundingBox = "rendered AABB inside", + // not "stored bounds inside"). + final rcenter = Vector2( + (r.left + r.right) / 2, + (r.top + r.bottom) / 2, + ); + final corners = [ + Vector2(r.left, r.top), + Vector2(r.right, r.top), + Vector2(r.right, r.bottom), + Vector2(r.left, r.bottom), + ] + .map((p) => + ClampHelpers.rotatePointAround(p, rcenter, r.rotation)) + .toList(); + for (int k = 0; k < 4; k++) { + expect(corners[k].x, greaterThanOrEqualTo(clamp.left - 1e-3), + reason: '$scenario: rotated corner $k leaked clamp.left'); + expect(corners[k].x, lessThanOrEqualTo(clamp.right + 1e-3), + reason: '$scenario: rotated corner $k leaked clamp.right'); + expect(corners[k].y, greaterThanOrEqualTo(clamp.top - 1e-3), + reason: '$scenario: rotated corner $k leaked clamp.top'); + expect(corners[k].y, lessThanOrEqualTo(clamp.bottom + 1e-3), + reason: '$scenario: rotated corner $k leaked clamp.bottom'); + } + } + } + } + }, + ); + }); + + group('Force-flip via topLeft handle past bottomRight anchor', () { + test( + 'box near clamp.right; drag topLeft cursor past bottomRight to ' + 'force x-flip — rect extends past clamp.right?', + () { + // Box near clamp.right edge. Drag topLeft cursor far to the right + // (past anchor at bottomRight, past clamp.right). + const theta = math.pi / 12; + final initial = Box.fromLTWH(250, 100, 100, 100, rotation: theta); + final clamp = Box.fromLTRB(0, 0, 400, 400); + final center = Vector2(300, 150); + final initialPointerWorld = ClampHelpers.rotatePointAround( + Vector2(250, 100), + center, + theta, + ); + // Drag topLeft cursor far to right past bottomRight. + final targetPointerWorld = ClampHelpers.rotatePointAround( + Vector2(800, 100), + center, + theta, + ); + final result = BoxTransformer.resize( + initialRect: initial, + initialLocalPosition: initialPointerWorld, + localPosition: targetPointerWorld, + handle: HandlePosition.topLeft, + resizeMode: ResizeMode.freeform, + initialFlip: Flip.none, + rotation: theta, + bindingStrategy: BindingStrategy.boundingBox, + clampingRect: clamp, + allowFlipping: true, + ); + // ignore: avoid_print + print('topLeft flip result rect: ${result.rect}'); + // ignore: avoid_print + print('topLeft flip result flip: ${result.flip}'); + final r = result.rect; + final rcenter = Vector2( + (r.left + r.right) / 2, + (r.top + r.bottom) / 2, + ); + final corners = [ + Vector2(r.left, r.top), + Vector2(r.right, r.top), + Vector2(r.right, r.bottom), + Vector2(r.left, r.bottom), + ] + .map((p) => ClampHelpers.rotatePointAround(p, rcenter, r.rotation)) + .toList(); + for (int k = 0; k < 4; k++) { + // ignore: avoid_print + print('Corner $k: ${corners[k]}'); + } + for (int k = 0; k < 4; k++) { + expect(corners[k].x, lessThanOrEqualTo(clamp.right + 1e-3), + reason: 'corner $k leaked clamp.right (x=${corners[k].x})'); + expect(corners[k].y, greaterThanOrEqualTo(clamp.top - 1e-3), + reason: 'corner $k leaked clamp.top (y=${corners[k].y})'); + expect(corners[k].y, lessThanOrEqualTo(clamp.bottom + 1e-3), + reason: 'corner $k leaked clamp.bottom (y=${corners[k].y})'); + expect(corners[k].x, greaterThanOrEqualTo(clamp.left - 1e-3), + reason: 'corner $k leaked clamp.left'); + } + }, + ); + }); + + group('Force-flip near clamp edge (user scenario)', () { + test( + 'box near clamp.right; drag bottomRight outwards to right past ' + 'clamp.right then flip — stays in clamp', + () { + // Box right edge is 20px from clamp.right. Drag bottomRight cursor + // far to the right past clamp.right (no flip needed yet, just + // clamping). Then continue dragging far enough to flip past anchor. + const theta = math.pi / 12; + final initial = Box.fromLTWH(280, 200, 100, 100, rotation: theta); + final clamp = Box.fromLTRB(0, 0, 400, 400); + final center = Vector2(330, 250); + final initialPointerWorld = ClampHelpers.rotatePointAround( + Vector2(380, 300), + center, + theta, + ); + // Drag cursor far up-and-left to force diagonal flip. + final targetPointerWorld = ClampHelpers.rotatePointAround( + Vector2(-200, -200), + center, + theta, + ); + final result = BoxTransformer.resize( + initialRect: initial, + initialLocalPosition: initialPointerWorld, + localPosition: targetPointerWorld, + handle: HandlePosition.bottomRight, + resizeMode: ResizeMode.freeform, + initialFlip: Flip.none, + rotation: theta, + bindingStrategy: BindingStrategy.boundingBox, + clampingRect: clamp, + allowFlipping: true, + ); + // Print diagnostic + // ignore: avoid_print + print('Result rect: ${result.rect}'); + // ignore: avoid_print + print('Result flip: ${result.flip}'); + final r = result.rect; + final rcenter = Vector2( + (r.left + r.right) / 2, + (r.top + r.bottom) / 2, + ); + final corners = [ + Vector2(r.left, r.top), + Vector2(r.right, r.top), + Vector2(r.right, r.bottom), + Vector2(r.left, r.bottom), + ] + .map((p) => ClampHelpers.rotatePointAround(p, rcenter, r.rotation)) + .toList(); + // ignore: avoid_print + for (int k = 0; k < 4; k++) { + // ignore: avoid_print + print('Corner $k: ${corners[k]}'); + } + for (int k = 0; k < 4; k++) { + expect(corners[k].x, greaterThanOrEqualTo(clamp.left - 1e-3), + reason: 'corner $k leaked clamp.left'); + expect(corners[k].y, greaterThanOrEqualTo(clamp.top - 1e-3), + reason: 'corner $k leaked clamp.top'); + } + }, + ); + + test( + 'box near clamp.left; drag bottomRight INNER corner outwards (right) ' + 'far enough to flip past anchor on the right side', + () { + // Box near LEFT edge. inner-most corner from a left-near box is + // the bottomRight (or topRight). User drags it AWAY from anchor + // = far to the LEFT past the topLeft anchor. The flipped rect + // would extend to the LEFT of anchor, possibly past clamp.left. + const theta = math.pi / 12; + final initial = Box.fromLTWH(20, 200, 100, 100, rotation: theta); + final clamp = Box.fromLTRB(0, 0, 400, 400); + final center = Vector2(70, 250); + final initialPointerWorld = ClampHelpers.rotatePointAround( + Vector2(120, 300), + center, + theta, + ); + // Drag cursor far left (past anchor at x=20, past clamp.left=0). + final targetPointerWorld = ClampHelpers.rotatePointAround( + Vector2(-300, 250), + center, + theta, + ); + final result = BoxTransformer.resize( + initialRect: initial, + initialLocalPosition: initialPointerWorld, + localPosition: targetPointerWorld, + handle: HandlePosition.bottomRight, + resizeMode: ResizeMode.freeform, + initialFlip: Flip.none, + rotation: theta, + bindingStrategy: BindingStrategy.boundingBox, + clampingRect: clamp, + allowFlipping: true, + ); + // ignore: avoid_print + print('Left-edge box result rect: ${result.rect}'); + // ignore: avoid_print + print('Left-edge box result flip: ${result.flip}'); + final r = result.rect; + final rcenter = Vector2( + (r.left + r.right) / 2, + (r.top + r.bottom) / 2, + ); + final corners = [ + Vector2(r.left, r.top), + Vector2(r.right, r.top), + Vector2(r.right, r.bottom), + Vector2(r.left, r.bottom), + ] + .map((p) => ClampHelpers.rotatePointAround(p, rcenter, r.rotation)) + .toList(); + for (int k = 0; k < 4; k++) { + // ignore: avoid_print + print('Corner $k: ${corners[k]}'); + } + for (int k = 0; k < 4; k++) { + expect(corners[k].x, greaterThanOrEqualTo(clamp.left - 1e-3), + reason: 'corner $k leaked clamp.left'); + } + }, + ); + }); + + group('Force-flip with constraints: clamp respected', () { + test( + 'diagonal flip with min/max constraints stays inside clamp', + () { + const theta = math.pi / 12; + final initial = Box.fromLTWH(150, 150, 100, 100, rotation: theta); + final clamp = Box.fromLTRB(0, 0, 400, 400); + final center = Vector2(200, 200); + final constraints = const Constraints( + minWidth: 50, + maxWidth: 400, + minHeight: 50, + maxHeight: 400, + ); + final initialPointerWorld = ClampHelpers.rotatePointAround( + Vector2(250, 250), + center, + theta, + ); + // Drag bottomRight cursor past topLeft anchor on BOTH axes + // (force diagonal flip), then keep dragging past clamp.left and + // clamp.top. + for (int i = 0; i <= 80; i++) { + final ux = 250.0 - i * 7.0; + final uy = 250.0 - i * 7.0; + final targetPointerWorld = ClampHelpers.rotatePointAround( + Vector2(ux, uy), + center, + theta, + ); + final result = BoxTransformer.resize( + initialRect: initial, + initialLocalPosition: initialPointerWorld, + localPosition: targetPointerWorld, + handle: HandlePosition.bottomRight, + resizeMode: ResizeMode.freeform, + initialFlip: Flip.none, + rotation: theta, + bindingStrategy: BindingStrategy.boundingBox, + clampingRect: clamp, + constraints: constraints, + allowFlipping: true, + ); + final r = result.rect; + final rcenter = Vector2( + (r.left + r.right) / 2, + (r.top + r.bottom) / 2, + ); + final corners = [ + Vector2(r.left, r.top), + Vector2(r.right, r.top), + Vector2(r.right, r.bottom), + Vector2(r.left, r.bottom), + ] + .map( + (p) => ClampHelpers.rotatePointAround(p, rcenter, r.rotation)) + .toList(); + for (int k = 0; k < 4; k++) { + expect(corners[k].x, greaterThanOrEqualTo(clamp.left - 1e-3), + reason: 'tick $i corner $k: leaked past clamp.left'); + expect(corners[k].y, greaterThanOrEqualTo(clamp.top - 1e-3), + reason: 'tick $i corner $k: leaked past clamp.top'); + expect(corners[k].x, lessThanOrEqualTo(clamp.right + 1e-3), + reason: 'tick $i corner $k: leaked past clamp.right'); + expect(corners[k].y, lessThanOrEqualTo(clamp.bottom + 1e-3), + reason: 'tick $i corner $k: leaked past clamp.bottom'); + } + } + }, + ); + }); + + group('Side handle clamp leak: monotonic projection at boundary', () { + test( + 'right handle: tiny outward deltas near clamp give monotonic width', + () { + // The user reports: dragging the right side handle in tiny ticks, + // when the rotated bottom-right corner intersects clamp.bottom, + // the width briefly *decreases* (or grows backward) before + // settling. Symptom of a non-monotonic projection at the + // feasibility boundary. + const theta = math.pi / 12; + // Position the rect so the bottom-right corner is close to the + // clamp's bottom edge at the initial size, ready to bind. + final initial = Box.fromLTWH(150, 250, 100, 100, rotation: theta); + final clamp = Box.fromLTRB(0, 0, 400, 400); + final center = Vector2(200, 300); + // Sweep pointer rightward in 5 px ticks past the cap. + const startUnrotatedX = 250.0; // initial right edge midpoint.x + final pointerYUnrotated = 300.0; // anchor.y in unrotated + final widths = []; + for (int i = 0; i <= 20; i++) { + final targetUnrotatedX = startUnrotatedX + i * 5.0; + final targetUnrotated = Vector2(targetUnrotatedX, pointerYUnrotated); + final initialUnrotated = Vector2(startUnrotatedX, pointerYUnrotated); + final initialPointerWorld = + ClampHelpers.rotatePointAround(initialUnrotated, center, theta); + final targetPointerWorld = + ClampHelpers.rotatePointAround(targetUnrotated, center, theta); + final result = BoxTransformer.resize( + initialRect: initial, + initialLocalPosition: initialPointerWorld, + localPosition: targetPointerWorld, + handle: HandlePosition.right, + resizeMode: ResizeMode.freeform, + initialFlip: Flip.none, + rotation: theta, + bindingStrategy: BindingStrategy.boundingBox, + clampingRect: clamp, + allowFlipping: false, + ); + widths.add(result.rect.width); + // At every tick, the 4 rotated corners must stay in clamp. + final r = result.rect; + final rcx = (r.left + r.right) / 2; + final rcy = (r.top + r.bottom) / 2; + final rcenter = Vector2(rcx, rcy); + final corners = [ + Vector2(r.left, r.top), + Vector2(r.right, r.top), + Vector2(r.right, r.bottom), + Vector2(r.left, r.bottom), + ] + .map( + (p) => ClampHelpers.rotatePointAround(p, rcenter, r.rotation)) + .toList(); + for (final c in corners) { + expect( + c.x, + greaterThanOrEqualTo(clamp.left - 1e-4), + reason: 'tick $i: corner.x leaked left of clamp', + ); + expect( + c.y, + greaterThanOrEqualTo(clamp.top - 1e-4), + reason: 'tick $i: corner.y leaked above clamp', + ); + expect( + c.x, + lessThanOrEqualTo(clamp.right + 1e-4), + reason: 'tick $i: corner.x leaked right of clamp', + ); + expect( + c.y, + lessThanOrEqualTo(clamp.bottom + 1e-4), + reason: 'tick $i: corner.y leaked below clamp', + ); + } + } + // Width must be non-decreasing across the sweep. + for (int i = 1; i < widths.length; i++) { + expect( + widths[i], + greaterThanOrEqualTo(widths[i - 1] - 1e-6), + reason: + 'width regressed at tick $i: ${widths[i - 1]} → ${widths[i]}', + ); + } + }, + ); + }); + + group('Right side handle under rotation — symmetricScale mode', () { + test( + 'aspect ratio preserved + symmetric about center (both axes extend)', + () { + final initial = Box.fromLTWH(200, 200, 100, 100, rotation: theta); + final result = _resize( + initial: initial, + handle: HandlePosition.right, + initialUnrotated: Vector2(300, 250), + targetUnrotated: Vector2(325, 250), + mode: ResizeMode.symmetricScale, + ); + + // Width grew 50 → height also grew 50 (1:1 aspect) → 150×150. + expect(result.rect.width, closeTo(150, 1e-9)); + expect(result.rect.height, closeTo(150, 1e-9)); + }, + ); + }); +} + +RawResizeResult _resize({ + required Box initial, + required HandlePosition handle, + required Vector2 initialUnrotated, + required Vector2 targetUnrotated, + ResizeMode mode = ResizeMode.freeform, + BindingStrategy bindingStrategy = BindingStrategy.originalBox, + Box? clampingRect, +}) { + final center = Vector2( + (initial.left + initial.right) / 2, + (initial.top + initial.bottom) / 2, + ); + final initialPointerWorld = ClampHelpers.rotatePointAround( + initialUnrotated, center, initial.rotation); + final targetPointerWorld = + ClampHelpers.rotatePointAround(targetUnrotated, center, initial.rotation); + + return BoxTransformer.resize( + initialRect: initial, + initialLocalPosition: initialPointerWorld, + localPosition: targetPointerWorld, + handle: handle, + resizeMode: mode, + initialFlip: Flip.none, + rotation: initial.rotation, + bindingStrategy: bindingStrategy, + clampingRect: clampingRect ?? Box.largest, + allowFlipping: true, + ); +} + +/// Asserts that [handle]'s anchor (midpoint of the OPPOSITE side) is at +/// the same world-space point in [initial] and [result]. +void _expectAnchorPreserved(Box initial, Box result, HandlePosition handle) { + final initCenter = Vector2( + (initial.left + initial.right) / 2, + (initial.top + initial.bottom) / 2, + ); + final resCenter = Vector2( + (result.left + result.right) / 2, + (result.top + result.bottom) / 2, + ); + final initialAnchorLocal = handle.anchor(initial); + final resultAnchorLocal = handle.anchor(result); + final initialAnchorWorld = ClampHelpers.rotatePointAround( + initialAnchorLocal, initCenter, initial.rotation); + final resultAnchorWorld = ClampHelpers.rotatePointAround( + resultAnchorLocal, resCenter, result.rotation); + expect(resultAnchorWorld.x, closeTo(initialAnchorWorld.x, 1e-6)); + expect(resultAnchorWorld.y, closeTo(initialAnchorWorld.y, 1e-6)); +} diff --git a/packages/box_transform/test/rotated_symmetric_scale_test.dart b/packages/box_transform/test/rotated_symmetric_scale_test.dart new file mode 100644 index 0000000..0b18c8d --- /dev/null +++ b/packages/box_transform/test/rotated_symmetric_scale_test.dart @@ -0,0 +1,35 @@ +import 'dart:math' as math; + +import 'package:box_transform/box_transform.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math_64.dart'; + +void main() { + group('Symmetric-scale resize with rotation', () { + test('preserves aspect ratio and grows from center', () { + final initial = Box.fromLTWH(0, 0, 200, 100, rotation: math.pi / 5); + final initialAspect = initial.width / initial.height; + final initialCenter = Vector2(100, 50); + final result = BoxTransformer.resize( + initialRect: initial, + initialLocalPosition: Vector2(200, 100), + localPosition: Vector2(300, 200), + handle: HandlePosition.bottomRight, + resizeMode: ResizeMode.symmetricScale, + initialFlip: Flip.none, + clampingRect: Box.fromLTRB(-1000, -1000, 1000, 1000), + constraints: const Constraints(), + allowFlipping: false, + rotation: math.pi / 5, + ); + expect( + result.rect.width / result.rect.height, closeTo(initialAspect, 1e-4)); + final resultCenter = Vector2( + (result.rect.left + result.rect.right) / 2, + (result.rect.top + result.rect.bottom) / 2, + ); + expect(resultCenter.x, closeTo(initialCenter.x, 1e-4)); + expect(resultCenter.y, closeTo(initialCenter.y, 1e-4)); + }); + }); +} diff --git a/packages/box_transform/test/rotated_symmetric_test.dart b/packages/box_transform/test/rotated_symmetric_test.dart new file mode 100644 index 0000000..ef3506f --- /dev/null +++ b/packages/box_transform/test/rotated_symmetric_test.dart @@ -0,0 +1,62 @@ +import 'dart:math' as math; + +import 'package:box_transform/box_transform.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math_64.dart'; + +void main() { + group('Symmetric resize with rotation', () { + test('grows symmetrically around center', () { + final initial = Box.fromLTWH(0, 0, 100, 100, rotation: math.pi / 6); + final initialCenter = Vector2(50, 50); + final result = BoxTransformer.resize( + initialRect: initial, + initialLocalPosition: Vector2(100, 100), + localPosition: Vector2(150, 150), + handle: HandlePosition.bottomRight, + resizeMode: ResizeMode.symmetric, + initialFlip: Flip.none, + clampingRect: Box.fromLTRB(-500, -500, 500, 500), + constraints: const Constraints(), + allowFlipping: false, + rotation: math.pi / 6, + ); + final resultCenter = Vector2( + (result.rect.left + result.rect.right) / 2, + (result.rect.top + result.rect.bottom) / 2, + ); + expect(resultCenter.x, closeTo(initialCenter.x, 1e-4)); + expect(resultCenter.y, closeTo(initialCenter.y, 1e-4)); + }); + + test('clamp rect constrains symmetric growth under rotation', () { + final initial = Box.fromLTWH(0, 0, 50, 50, rotation: math.pi / 4); + final result = BoxTransformer.resize( + initialRect: initial, + initialLocalPosition: Vector2(50, 50), + localPosition: Vector2(500, 500), + handle: HandlePosition.bottomRight, + resizeMode: ResizeMode.symmetric, + initialFlip: Flip.none, + clampingRect: Box.fromLTRB(-100, -100, 100, 100), + constraints: const Constraints(), + allowFlipping: false, + rotation: math.pi / 4, + bindingStrategy: BindingStrategy.boundingBox, + ); + final cx = (result.rect.left + result.rect.right) / 2; + final cy = (result.rect.top + result.rect.bottom) / 2; + final corners = [ + Vector2(result.rect.left, result.rect.top), + Vector2(result.rect.right, result.rect.top), + Vector2(result.rect.right, result.rect.bottom), + Vector2(result.rect.left, result.rect.bottom), + ].map((p) => ClampHelpers.rotatePointAround( + p, Vector2(cx, cy), result.rect.rotation)); + for (final c in corners) { + expect(c.x, inInclusiveRange(-100 - 1e-4, 100 + 1e-4)); + expect(c.y, inInclusiveRange(-100 - 1e-4, 100 + 1e-4)); + } + }); + }); +} diff --git a/packages/box_transform/test/rotation_clamp_test.dart b/packages/box_transform/test/rotation_clamp_test.dart new file mode 100644 index 0000000..40d5a6b --- /dev/null +++ b/packages/box_transform/test/rotation_clamp_test.dart @@ -0,0 +1,215 @@ +// Tests for rotation that respects the clamping rect. +// +// Bug: BoxTransformer.rotate() does not take a clampingRect, so the rotated +// AABB can extend beyond the clamp. The user's symptom: rotating a box +// flush against a clamp boundary leaks the rendered footprint outside, and +// the next move() call snaps it back in. The fix is to make rotation aware +// of the clamp via slide-then-freeze: +// +// 1. If the rotated rect can be translated such that all relevant corners +// fit in the clamp (per bindingStrategy), slide to the closest such +// position. Rotation is accepted, `feasible` is true. +// 2. If no translation can satisfy the clamp at the desired rotation, +// reject the rotation entirely: return `rotation == initialRotation`, +// `rect == initialRect`, `feasible == false`. The controller skips +// its state update in this case, freezing rotation at the cap. + +import 'dart:math' as math; + +import 'package:box_transform/box_transform.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math_64.dart'; + +void main() { + group('BoxTransformer.rotate respects clampingRect', () { + test('slides box into clamp when rotation has slack room', () { + // 100×100 box flush against the top-left corner of a 200×200 clamp. + // Rotating by π/12 inflates the AABB beyond the box, poking out by + // ~11 px on each axis. The box must slide into the clamp. + final initial = Box.fromLTWH(0, 0, 100, 100); + final clamp = Box.fromLTRB(0, 0, 200, 200); + const theta = math.pi / 12; + final center = Vector2(50, 50); + // Pointer moves from east of center (angle 0) to angle +theta. + final initialPointer = Vector2(center.x + 50, center.y); + final targetPointer = Vector2( + center.x + 50 * math.cos(theta), + center.y + 50 * math.sin(theta), + ); + + final result = BoxTransformer.rotate( + initialRect: initial, + initialLocalPosition: initialPointer, + localPosition: targetPointer, + initialRotation: 0.0, + clampingRect: clamp, + bindingStrategy: BindingStrategy.boundingBox, + ); + + expect( + result.feasible, + isTrue, + reason: 'There is room to slide the box into the clamp.', + ); + expect(result.rotation, closeTo(theta, 1e-9)); + // The rotated AABB must fit entirely inside the clamp. + final aabb = ClampHelpers.calculateBoundingRect(result.rect); + expect(aabb.left, greaterThanOrEqualTo(clamp.left - 1e-6)); + expect(aabb.top, greaterThanOrEqualTo(clamp.top - 1e-6)); + expect(aabb.right, lessThanOrEqualTo(clamp.right + 1e-6)); + expect(aabb.bottom, lessThanOrEqualTo(clamp.bottom + 1e-6)); + }); + + test('rejects rotation when clamp is too tight to fit any translation', () { + // 100×100 box exactly filling a 100×100 clamp — at θ=π/4 the rotated + // AABB needs ~141×141, which cannot fit even with translation. + final initial = Box.fromLTWH(0, 0, 100, 100); + final clamp = Box.fromLTRB(0, 0, 100, 100); + const theta = math.pi / 4; + final center = Vector2(50, 50); + final initialPointer = Vector2(center.x + 50, center.y); + final targetPointer = Vector2( + center.x + 50 * math.cos(theta), + center.y + 50 * math.sin(theta), + ); + + final result = BoxTransformer.rotate( + initialRect: initial, + initialLocalPosition: initialPointer, + localPosition: targetPointer, + initialRotation: 0.0, + clampingRect: clamp, + bindingStrategy: BindingStrategy.boundingBox, + ); + + expect(result.feasible, isFalse); + expect(result.rotation, equals(0.0), + reason: 'Rejected rotation must report initialRotation.'); + expect(result.rect, equals(initial), + reason: 'Rejected rotation must leave rect unchanged.'); + }); + + test('rejected rotation reports the attempted delta, not zero', () { + // R3: delta on infeasible should reflect the user's gesture motion + // so consumers (telemetry, UI) can read it without first checking + // `feasible`. Mirrors how `move()` reports clamped delta. + final initial = Box.fromLTWH(0, 0, 100, 100); + final clamp = Box.fromLTRB(0, 0, 100, 100); // tight + const theta = math.pi / 4; + final center = Vector2(50, 50); + final initialPointer = Vector2(center.x + 50, center.y); + final targetPointer = Vector2( + center.x + 50 * math.cos(theta), + center.y + 50 * math.sin(theta), + ); + + final result = BoxTransformer.rotate( + initialRect: initial, + initialLocalPosition: initialPointer, + localPosition: targetPointer, + initialRotation: 0.0, + clampingRect: clamp, + bindingStrategy: BindingStrategy.boundingBox, + ); + + expect(result.feasible, isFalse); + final expected = targetPointer - initialPointer; + expect(result.delta.x, closeTo(expected.x, 1e-9)); + expect(result.delta.y, closeTo(expected.y, 1e-9)); + }); + + test( + 'originalBox keeps unrotated rect inside clamp; rotated corners may ' + 'extend outside', + () { + // R4: originalBox lets the rotated quad poke out as long as the + // unrotated rect stays in clamp. With a 100×100 box exactly filling + // a 100×100 clamp, originalBox should *accept* π/4 rotation (the + // unrotated corners are still at clamp corners) but the rotated + // AABB extends beyond — that's by design. + final initial = Box.fromLTWH(0, 0, 100, 100); + final clamp = Box.fromLTRB(0, 0, 100, 100); + const theta = math.pi / 4; + final center = Vector2(50, 50); + final initialPointer = Vector2(center.x + 50, center.y); + final targetPointer = Vector2( + center.x + 50 * math.cos(theta), + center.y + 50 * math.sin(theta), + ); + + final result = BoxTransformer.rotate( + initialRect: initial, + initialLocalPosition: initialPointer, + localPosition: targetPointer, + initialRotation: 0.0, + clampingRect: clamp, + bindingStrategy: BindingStrategy.originalBox, + ); + + expect(result.feasible, isTrue); + expect(result.rotation, closeTo(theta, 1e-9)); + // Rotated AABB at π/4 needs ~141×141 — must extend beyond the clamp. + final aabb = ClampHelpers.calculateBoundingRect(result.rect); + expect(aabb.width, greaterThan(clamp.width + 1.0)); + }, + ); + + test('negative rotation is symmetric to positive', () { + // R5: rotation cap should kick in at the same magnitude regardless + // of sign. Use a small negative rotation that fits with sliding. + final initial = Box.fromLTWH(0, 0, 100, 100); + final clamp = Box.fromLTRB(0, 0, 200, 200); + const theta = -math.pi / 12; + final center = Vector2(50, 50); + final initialPointer = Vector2(center.x + 50, center.y); + final targetPointer = Vector2( + center.x + 50 * math.cos(theta), + center.y + 50 * math.sin(theta), + ); + + final result = BoxTransformer.rotate( + initialRect: initial, + initialLocalPosition: initialPointer, + localPosition: targetPointer, + initialRotation: 0.0, + clampingRect: clamp, + bindingStrategy: BindingStrategy.boundingBox, + ); + + expect(result.feasible, isTrue); + expect(result.rotation, closeTo(theta, 1e-9)); + final aabb = ClampHelpers.calculateBoundingRect(result.rect); + expect(aabb.left, greaterThanOrEqualTo(clamp.left - 1e-6)); + expect(aabb.top, greaterThanOrEqualTo(clamp.top - 1e-6)); + expect(aabb.right, lessThanOrEqualTo(clamp.right + 1e-6)); + expect(aabb.bottom, lessThanOrEqualTo(clamp.bottom + 1e-6)); + }); + + test('default behavior (no clampingRect) is unchanged', () { + // Pre-fix call sites that omit clampingRect/bindingStrategy must see + // the same rotation/rect they used to get. + final initial = Box.fromLTWH(0, 0, 100, 100); + const theta = math.pi / 6; + final center = Vector2(50, 50); + final initialPointer = Vector2(center.x + 50, center.y); + final targetPointer = Vector2( + center.x + 50 * math.cos(theta), + center.y + 50 * math.sin(theta), + ); + + final result = BoxTransformer.rotate( + initialRect: initial, + initialLocalPosition: initialPointer, + localPosition: targetPointer, + initialRotation: 0.0, + ); + + expect(result.feasible, isTrue); + expect(result.rotation, closeTo(theta, 1e-9)); + // Position unchanged (Box.largest leaves tx/ty interval unconstrained, + // so 0 is feasible and the slide is a no-op). + expect(result.rect.left, closeTo(initial.left, 1e-9)); + expect(result.rect.top, closeTo(initial.top, 1e-9)); + }); + }); +} diff --git a/packages/box_transform/test/rotation_integration_test.dart b/packages/box_transform/test/rotation_integration_test.dart new file mode 100644 index 0000000..6717722 --- /dev/null +++ b/packages/box_transform/test/rotation_integration_test.dart @@ -0,0 +1,167 @@ +import 'dart:math' as math; + +import 'package:box_transform/box_transform.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math_64.dart'; + +void main() { + const theta = math.pi / 4; + + group('Figma-parity behaviors at rotation=pi/4', () { + test('1. Aspect-ratio lock (ResizeMode.scale) preserves aspect', () { + final initial = Box.fromLTWH(0, 0, 200, 100, rotation: theta); + final initialCorner = _worldCorner(initial, HandlePosition.bottomRight); + final result = BoxTransformer.resize( + initialRect: initial, + initialLocalPosition: initialCorner, + localPosition: Vector2(200, 200), + handle: HandlePosition.bottomRight, + resizeMode: ResizeMode.scale, + initialFlip: Flip.none, + clampingRect: Box.fromLTRB(-500, -500, 500, 500), + constraints: const Constraints(), + allowFlipping: false, + rotation: theta, + ); + expect(result.rect.width / result.rect.height, closeTo(2.0, 1e-4)); + }); + + test('2. Symmetric resize grows from center', () { + final initial = Box.fromLTWH(0, 0, 100, 100, rotation: theta); + final initialCorner = _worldCorner(initial, HandlePosition.bottomRight); + final result = BoxTransformer.resize( + initialRect: initial, + initialLocalPosition: initialCorner, + localPosition: Vector2(120, 120), + handle: HandlePosition.bottomRight, + resizeMode: ResizeMode.symmetric, + initialFlip: Flip.none, + clampingRect: Box.fromLTRB(-500, -500, 500, 500), + constraints: const Constraints(), + allowFlipping: false, + rotation: theta, + ); + final cx = (result.rect.left + result.rect.right) / 2; + final cy = (result.rect.top + result.rect.bottom) / 2; + expect(cx, closeTo(50, 1e-4)); + expect(cy, closeTo(50, 1e-4)); + }); + + test('3. Symmetric-scale combines both', () { + final initial = Box.fromLTWH(0, 0, 100, 50, rotation: theta); + final initialCorner = _worldCorner(initial, HandlePosition.bottomRight); + final result = BoxTransformer.resize( + initialRect: initial, + initialLocalPosition: initialCorner, + localPosition: Vector2(150, 75), + handle: HandlePosition.bottomRight, + resizeMode: ResizeMode.symmetricScale, + initialFlip: Flip.none, + clampingRect: Box.fromLTRB(-500, -500, 500, 500), + constraints: const Constraints(), + allowFlipping: false, + rotation: theta, + ); + expect(result.rect.width / result.rect.height, closeTo(2.0, 1e-4)); + final cx = (result.rect.left + result.rect.right) / 2; + expect(cx, closeTo(50, 1e-4)); + }); + + test('4. clamp rect constrains the rotated rect (all four corners in)', () { + final initial = Box.fromLTWH(0, 0, 50, 50, rotation: theta); + final clamp = Box.fromLTRB(-100, -100, 100, 100); + final initialCorner = _worldCorner(initial, HandlePosition.bottomRight); + final result = BoxTransformer.resize( + initialRect: initial, + initialLocalPosition: initialCorner, + localPosition: Vector2(1000, 1000), + handle: HandlePosition.bottomRight, + resizeMode: ResizeMode.freeform, + initialFlip: Flip.none, + clampingRect: clamp, + constraints: const Constraints(), + allowFlipping: false, + rotation: theta, + bindingStrategy: BindingStrategy.boundingBox, + ); + final corners = _allWorldCorners(result.rect); + for (final c in corners) { + expect(c.x, inInclusiveRange(-100 - 1e-4, 100 + 1e-4)); + expect(c.y, inInclusiveRange(-100 - 1e-4, 100 + 1e-4)); + } + }); + + test('5. min/max constraints honored under rotation', () { + final initial = Box.fromLTWH(0, 0, 100, 100, rotation: theta); + final initialCorner = _worldCorner(initial, HandlePosition.bottomRight); + final result = BoxTransformer.resize( + initialRect: initial, + initialLocalPosition: initialCorner, + localPosition: Vector2(0, 0), + handle: HandlePosition.bottomRight, + resizeMode: ResizeMode.freeform, + initialFlip: Flip.none, + clampingRect: Box.fromLTRB(-500, -500, 500, 500), + constraints: const Constraints( + minWidth: 20, minHeight: 20, maxWidth: 200, maxHeight: 200), + allowFlipping: false, + rotation: theta, + ); + expect(result.rect.width, greaterThanOrEqualTo(20 - 1e-4)); + expect(result.rect.height, greaterThanOrEqualTo(20 - 1e-4)); + }); + + test('6. BindingStrategy.boundingBox constrains AABB extent', () { + final initial = Box.fromLTWH(0, 0, 50, 50, rotation: theta); + final clamp = Box.fromLTRB(-70.71, -70.71, 70.71, 70.71); + final initialCorner = _worldCorner(initial, HandlePosition.bottomRight); + final result = BoxTransformer.resize( + initialRect: initial, + initialLocalPosition: initialCorner, + localPosition: Vector2(1000, 1000), + handle: HandlePosition.bottomRight, + resizeMode: ResizeMode.freeform, + initialFlip: Flip.none, + clampingRect: clamp, + constraints: const Constraints(), + allowFlipping: false, + rotation: theta, + bindingStrategy: BindingStrategy.boundingBox, + ); + expect(result.boundingRect.left, greaterThanOrEqualTo(clamp.left - 1e-4)); + expect(result.boundingRect.right, lessThanOrEqualTo(clamp.right + 1e-4)); + expect(result.boundingRect.top, greaterThanOrEqualTo(clamp.top - 1e-4)); + expect( + result.boundingRect.bottom, lessThanOrEqualTo(clamp.bottom + 1e-4)); + }); + }); +} + +Vector2 _worldCorner(Box box, HandlePosition handle) { + final cx = (box.left + box.right) / 2; + final cy = (box.top + box.bottom) / 2; + final center = Vector2(cx, cy); + late final Vector2 unrotated; + switch (handle) { + case HandlePosition.topLeft: + unrotated = Vector2(box.left, box.top); + case HandlePosition.topRight: + unrotated = Vector2(box.right, box.top); + case HandlePosition.bottomRight: + unrotated = Vector2(box.right, box.bottom); + case HandlePosition.bottomLeft: + unrotated = Vector2(box.left, box.bottom); + default: + throw UnsupportedError('handle $handle not supported'); + } + return ClampHelpers.rotatePointAround(unrotated, center, box.rotation); +} + +List _allWorldCorners(Box box) { + return [ + HandlePosition.topLeft, + HandlePosition.topRight, + HandlePosition.bottomRight, + HandlePosition.bottomLeft, + ].map((h) => _worldCorner(box, h)).toList(); +} diff --git a/packages/box_transform/test/rotation_primitives_test.dart b/packages/box_transform/test/rotation_primitives_test.dart new file mode 100644 index 0000000..07e85dc --- /dev/null +++ b/packages/box_transform/test/rotation_primitives_test.dart @@ -0,0 +1,80 @@ +import 'dart:math' as math; + +import 'package:box_transform/box_transform.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math_64.dart'; + +void main() { + group('rotatePointAround', () { + test('identity at theta=0', () { + final p = Vector2(10, 5); + final result = ClampHelpers.rotatePointAround(p, Vector2.zero(), 0.0); + expect(result.x, closeTo(10, 1e-12)); + expect(result.y, closeTo(5, 1e-12)); + }); + + test('rotates pi/2 around origin maps (1,0) -> (0,1)', () { + final result = ClampHelpers.rotatePointAround( + Vector2(1, 0), Vector2.zero(), math.pi / 2); + expect(result.x, closeTo(0, 1e-12)); + expect(result.y, closeTo(1, 1e-12)); + }); + + test('rotates around a non-origin pivot', () { + // Rotate (2,0) by pi around pivot (1,0). Expected: (0,0). + final result = + ClampHelpers.rotatePointAround(Vector2(2, 0), Vector2(1, 0), math.pi); + expect(result.x, closeTo(0, 1e-12)); + expect(result.y, closeTo(0, 1e-12)); + }); + }); + + group('worldToUnrotated / unrotatedToWorld round-trip', () { + test('round-trips at arbitrary theta', () { + // vector_math_64 gives full double precision; tolerance ~1e-12. + final pivot = Vector2(5, 7); + const theta = 0.6435; + final original = Vector2(42, -3); + final local = ClampHelpers.worldToUnrotated(original, pivot, theta); + final back = ClampHelpers.unrotatedToWorld(local, pivot, theta); + expect(back.x, closeTo(original.x, 1e-12)); + expect(back.y, closeTo(original.y, 1e-12)); + }); + + test('identity at theta=0', () { + final pivot = Vector2(5, 7); + final p = Vector2(1, 2); + expect(ClampHelpers.worldToUnrotated(p, pivot, 0.0).x, closeTo(1, 1e-12)); + expect(ClampHelpers.worldToUnrotated(p, pivot, 0.0).y, closeTo(2, 1e-12)); + }); + }); + + group('calculateBoundingRect', () { + test('equals rect when theta=0', () { + final r = Box.fromLTWH(10, 20, 100, 50); + final bounding = ClampHelpers.calculateBoundingRect(r); + expect(bounding.left, closeTo(10, 1e-12)); + expect(bounding.top, closeTo(20, 1e-12)); + expect(bounding.right, closeTo(110, 1e-12)); + expect(bounding.bottom, closeTo(70, 1e-12)); + }); + + test('100x100 rotated pi/4 has bounding ~141x141 centered at box center', + () { + final r = Box.fromLTWH(0, 0, 100, 100, rotation: math.pi / 4); + final bounding = ClampHelpers.calculateBoundingRect(r); + final expectedSide = 100 * math.sqrt(2); + expect(bounding.width, closeTo(expectedSide, 1e-9)); + expect(bounding.height, closeTo(expectedSide, 1e-9)); + expect((bounding.left + bounding.right) / 2, closeTo(50, 1e-9)); + expect((bounding.top + bounding.bottom) / 2, closeTo(50, 1e-9)); + }); + + test('rotation pi/2 swaps width/height', () { + final r = Box.fromLTWH(0, 0, 200, 50, rotation: math.pi / 2); + final bounding = ClampHelpers.calculateBoundingRect(r); + expect(bounding.width, closeTo(50, 1e-9)); + expect(bounding.height, closeTo(200, 1e-9)); + }); + }); +} diff --git a/packages/box_transform/test/scaled_constrained_resizing_test.dart b/packages/box_transform/test/scaled_constrained_resizing_test.dart index 8369869..97c5f64 100644 --- a/packages/box_transform/test/scaled_constrained_resizing_test.dart +++ b/packages/box_transform/test/scaled_constrained_resizing_test.dart @@ -1,6 +1,6 @@ import 'package:box_transform/box_transform.dart'; import 'package:test/test.dart'; -import 'package:vector_math/vector_math.dart'; +import 'package:vector_math/vector_math_64.dart'; import 'utils.dart'; diff --git a/packages/box_transform/test/scaled_resizing_test.dart b/packages/box_transform/test/scaled_resizing_test.dart index 8882111..9372343 100644 --- a/packages/box_transform/test/scaled_resizing_test.dart +++ b/packages/box_transform/test/scaled_resizing_test.dart @@ -1,6 +1,6 @@ import 'package:box_transform/box_transform.dart'; import 'package:test/test.dart'; -import 'package:vector_math/vector_math.dart'; +import 'package:vector_math/vector_math_64.dart'; import 'utils.dart'; diff --git a/packages/box_transform/test/symmetric_constrained_resizing_test.dart b/packages/box_transform/test/symmetric_constrained_resizing_test.dart index 5182d44..e8d1a49 100644 --- a/packages/box_transform/test/symmetric_constrained_resizing_test.dart +++ b/packages/box_transform/test/symmetric_constrained_resizing_test.dart @@ -1,6 +1,6 @@ import 'package:box_transform/box_transform.dart'; import 'package:test/test.dart'; -import 'package:vector_math/vector_math.dart'; +import 'package:vector_math/vector_math_64.dart'; import 'utils.dart'; diff --git a/packages/box_transform/test/symmetric_resizing_test.dart b/packages/box_transform/test/symmetric_resizing_test.dart index 0c6e909..8d443cc 100644 --- a/packages/box_transform/test/symmetric_resizing_test.dart +++ b/packages/box_transform/test/symmetric_resizing_test.dart @@ -1,6 +1,6 @@ import 'package:box_transform/box_transform.dart'; import 'package:test/test.dart'; -import 'package:vector_math/vector_math.dart'; +import 'package:vector_math/vector_math_64.dart'; import 'utils.dart'; diff --git a/packages/box_transform/test/symmetrically_scaled_constrained_resizing_test.dart b/packages/box_transform/test/symmetrically_scaled_constrained_resizing_test.dart index ed90ac3..6ca8f3c 100644 --- a/packages/box_transform/test/symmetrically_scaled_constrained_resizing_test.dart +++ b/packages/box_transform/test/symmetrically_scaled_constrained_resizing_test.dart @@ -1,6 +1,6 @@ import 'package:box_transform/box_transform.dart'; import 'package:test/test.dart'; -import 'package:vector_math/vector_math.dart'; +import 'package:vector_math/vector_math_64.dart'; import 'utils.dart'; diff --git a/packages/box_transform/test/symmetrically_scaled_resizing_test.dart b/packages/box_transform/test/symmetrically_scaled_resizing_test.dart index 0a83738..360b3c2 100644 --- a/packages/box_transform/test/symmetrically_scaled_resizing_test.dart +++ b/packages/box_transform/test/symmetrically_scaled_resizing_test.dart @@ -1,6 +1,6 @@ import 'package:box_transform/box_transform.dart'; import 'package:test/test.dart'; -import 'package:vector_math/vector_math.dart'; +import 'package:vector_math/vector_math_64.dart'; import 'utils.dart'; diff --git a/packages/flutter_box_transform/CHANGELOG.md b/packages/flutter_box_transform/CHANGELOG.md index a0c2ce2..185863a 100644 --- a/packages/flutter_box_transform/CHANGELOG.md +++ b/packages/flutter_box_transform/CHANGELOG.md @@ -1,3 +1,31 @@ +## Unreleased + +- **[BREAKING]** `defaultResizeModeResolver()` is now `TransformableBoxController.defaultResizeModeResolver()`. The top-level function has been removed; consumers that referenced it directly (e.g. as a default for `resizeModeResolver`) must update to the qualified static. +- **[BREAKING]** Top-level rotated-layout helpers are now static methods on `RotatedLayout`: + - `rotateOffsetAround(...)` → `RotatedLayout.rotateOffsetAround(...)` + - `handleCornerInParent(...)` → `RotatedLayout.handleCornerInParent(...)` + - `rotatedCornerInWorld(...)` → `RotatedLayout.rotatedCornerInWorld(...)` + - `anchorInHandle(...)` → `RotatedLayout.anchorInHandle(...)` + - `handleTopLeftInWorld(...)` → `RotatedLayout.handleTopLeftInWorld(...)` + - `sideHandleRectInWorld(...)` → `RotatedLayout.sideHandleRectInWorld(...)` + - `computeEffectiveContainmentRect(...)` (formerly in `extensions.dart`) → `RotatedLayout.computeEffectiveContainmentRect(...)` +- **[BREAKING]** Default handle builders moved from file-private functions to public statics on `HandleBuilders`. Use `HandleBuilders.defaultCorner` / `HandleBuilders.defaultSide` if you need to reference them explicitly. +- Add `rotation`, `rotatable`, and `bindingStrategy` parameters on `TransformableBox`. +- **[BREAKING]** Default `bindingStrategy` flipped from `originalBox` to `boundingBox` on `TransformableBox`, `TransformableBoxController`, and `UIBoxTransform.move/resize/rotate`. Existing apps that relied on the unrotated-logical-rect semantic must pass `BindingStrategy.originalBox` explicitly. +- Add per-corner rotation gesture: an outer ring around each corner-handle captures rotation when `rotatable: true`. Size via `rotationHandleGestureSize` (default 64 px). New rotation callbacks: `onRotationStart` / `onRotationUpdate` / `onRotationEnd` / `onRotationCancel`. +- `TransformableBoxController` adds `rotation`, `initialRotation`, `bindingStrategy`, and rotation lifecycle methods (`onRotateStart` / `onRotateUpdate` / `onRotateEnd` / `onRotateCancel`). +- `onResizeUpdate` and `onRotateUpdate` skip state writes when `result.feasible == false` and override `result.rect` / `result.rotation` to the controller's last feasible value. Consumers binding their visible state via callbacks (`box.rect = result.rect`) now stay clamp-pinned at the last feasible position rather than snapping back to gesture-start when a gesture exceeds what clamp + constraints permit. +- Side handles render rotated under non-zero rotation; gesture coordinates are translated into the box's un-rotated frame so resize tracks the cursor visually. +- Hit-testing gates corner taps to the rotated polygon (not the AABB) so rotated boxes don't capture clicks in their AABB wedges. +- Playground gains a rotation slider, a `bindingStrategy` toggle, debug overlays for rotated/unrotated bounds, and a tick-by-tick test recorder that captures full gesture sequences (rotation + bindingStrategy aware) for regression replay. +- Bumps `box_transform` dependency to the rotation-aware release. +- **[BREAKING]** The rotated-clamping LP entry points re-exported from `box_transform` were relocated into `RotatedClampingSolver` (static surface). Apps that called these directly must qualify the names: + - `buildCornerIneqsInto(...)` → `RotatedClampingSolver.buildCornerIneqsInto(...)` + - `buildSideHandleIneqsInto(...)` → `RotatedClampingSolver.buildSideHandleIneqsInto(...)` + - `buildCenterIneqsInto(...)` → `RotatedClampingSolver.buildCenterIneqsInto(...)` + - `projectOntoFeasibleRegionFlat(...)` → `RotatedClampingSolver.projectOntoFeasibleRegionFlat(...)` +- **[BREAKING]** Re-exported clamp/handle/flip/line-geometry helpers from `box_transform` are now `static` members of `ClampHelpers` instead of top-level functions. See the `box_transform` CHANGELOG for the full rename list. Migration is mechanical: prepend `ClampHelpers.` to each call (e.g. `flipRect(...)` → `ClampHelpers.flipRect(...)`). + ## 0.4.7 - Fix taps should pass through when onTap is not set. [Issue#31](https://github.com/hyper-designed/box_transform/issues/31) diff --git a/packages/flutter_box_transform/README.md b/packages/flutter_box_transform/README.md index 349522d..e01bad6 100644 --- a/packages/flutter_box_transform/README.md +++ b/packages/flutter_box_transform/README.md @@ -2,7 +2,7 @@ # Flutter Box Transform -[![melos](https://img.shields.io/badge/maintained%20with-melos-f700ff.svg?style=flat-square)](https://github.com/invertase/melos) [![Build](https://github.com/hyper-designed/box_transform/actions/workflows/build.yml/badge.svg)](https://github.com/hyper-designed/box_transform/actions/workflows/build.yml) [![Pub Version](https://img.shields.io/pub/v/box_transform?label=Pub)](https://pub.dev/packages/box_transform) +[![Build](https://github.com/hyper-designed/box_transform/actions/workflows/build.yml/badge.svg)](https://github.com/hyper-designed/box_transform/actions/workflows/build.yml) [![Pub Version](https://img.shields.io/pub/v/box_transform?label=Pub)](https://pub.dev/packages/box_transform) [Flutter Box Transform](https://github.com/hyper-designed/box_transform) is a [Flutter](https://flutter.dev) package that allows you to programmatically handle box resizing and dragging using Flutter widgets. It provides highly @@ -20,6 +20,13 @@ This package uses [Box Transform](https://pub.dev/packages/box_transform) APIs i resized. * 📍 **Customizable Anchor Points:** Define resizing corner-handles to anchor different parts of the box when resizing. * 🎨 **Customizable Handles:** Use default resizing handles or define your own custom handles. +* 🌀 **Rotation Support:** `TransformableBox.rotation` rotates the rendered box around its center. Set + `rotatable: true` to enable per-corner rotation gestures (an outer ring around each corner-handle captures + rotation; size with `rotationHandleGestureSize`). Wire `onRotationStart` / `onRotationUpdate` / `onRotationEnd` + callbacks to react. `bindingStrategy` (`BindingStrategy.originalBox` vs `BindingStrategy.boundingBox`) chooses + whether the unrotated logical rect or the rendered AABB stays in the clamp. The controller does slide-then-freeze + on rotation and natural-direction fallback on rotated force-flip, holding the last feasible state instead of + snapping back to gesture-start when a gesture exceeds what clamp + constraints permit. * 🚀 **Easy Integration:** Integrate Box Transform into your Flutter project with ease using widgets. ## Getting started diff --git a/packages/flutter_box_transform/example/.gitignore b/packages/flutter_box_transform/example/.gitignore index 24476c5..6c31954 100644 --- a/packages/flutter_box_transform/example/.gitignore +++ b/packages/flutter_box_transform/example/.gitignore @@ -5,9 +5,11 @@ *.swp .DS_Store .atom/ +.build/ .buildlog/ .history .svn/ +.swiftpm/ migrate_working_dir/ # IntelliJ related diff --git a/packages/flutter_box_transform/example/macos/Podfile b/packages/flutter_box_transform/example/macos/Podfile index c795730..b52666a 100644 --- a/packages/flutter_box_transform/example/macos/Podfile +++ b/packages/flutter_box_transform/example/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '10.14' +platform :osx, '10.15' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/packages/flutter_box_transform/example/macos/Runner.xcodeproj/project.pbxproj b/packages/flutter_box_transform/example/macos/Runner.xcodeproj/project.pbxproj index ab0b554..0ee97bb 100644 --- a/packages/flutter_box_transform/example/macos/Runner.xcodeproj/project.pbxproj +++ b/packages/flutter_box_transform/example/macos/Runner.xcodeproj/project.pbxproj @@ -535,7 +535,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -614,7 +614,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -661,7 +661,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; diff --git a/packages/flutter_box_transform/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/flutter_box_transform/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 15368ec..ac78810 100644 --- a/packages/flutter_box_transform/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/packages/flutter_box_transform/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -59,6 +59,7 @@ ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" debugServiceExtension = "internal" + enableGPUValidationMode = "1" allowLocationSimulation = "YES"> diff --git a/packages/flutter_box_transform/example/macos/Runner/AppDelegate.swift b/packages/flutter_box_transform/example/macos/Runner/AppDelegate.swift index d53ef64..b3c1761 100644 --- a/packages/flutter_box_transform/example/macos/Runner/AppDelegate.swift +++ b/packages/flutter_box_transform/example/macos/Runner/AppDelegate.swift @@ -1,9 +1,13 @@ import Cocoa import FlutterMacOS -@NSApplicationMain +@main class AppDelegate: FlutterAppDelegate { override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return true } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } } diff --git a/packages/flutter_box_transform/example/pubspec.yaml b/packages/flutter_box_transform/example/pubspec.yaml index 3244ae8..a287b9e 100644 --- a/packages/flutter_box_transform/example/pubspec.yaml +++ b/packages/flutter_box_transform/example/pubspec.yaml @@ -5,8 +5,10 @@ publish_to: 'none' version: 1.0.0+1 environment: - sdk: '>=3.0.0 <4.0.0' - flutter: '>=3.10.0' + sdk: ^3.6.0 + flutter: '>=3.27.0' + +resolution: workspace dependencies: flutter: @@ -17,7 +19,7 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter - flutter_lints: ^5.0.0 + flutter_lints: ^6.0.0 flutter: uses-material-design: true diff --git a/packages/flutter_box_transform/lib/flutter_box_transform.dart b/packages/flutter_box_transform/lib/flutter_box_transform.dart index dfcb577..8036549 100644 --- a/packages/flutter_box_transform/lib/flutter_box_transform.dart +++ b/packages/flutter_box_transform/lib/flutter_box_transform.dart @@ -1,9 +1,14 @@ +/// Flutter widgets for [box_transform](https://pub.dev/packages/box_transform): +/// rotation-aware drag, resize, flip, and rotate of axis-aligned rectangles +/// with corner and side handles, plus optional clamping bounds. library; export 'package:box_transform/box_transform.dart'; export 'src/extensions.dart'; +export 'src/handle_builders.dart'; export 'src/handles.dart'; +export 'src/rotated_layout.dart'; export 'src/transformable_box.dart'; export 'src/transformable_box_controller.dart'; export 'src/typedefs.dart'; diff --git a/packages/flutter_box_transform/lib/src/extensions.dart b/packages/flutter_box_transform/lib/src/extensions.dart index afd4b32..d21ee63 100644 --- a/packages/flutter_box_transform/lib/src/extensions.dart +++ b/packages/flutter_box_transform/lib/src/extensions.dart @@ -2,7 +2,7 @@ import 'dart:ui'; import 'package:box_transform/box_transform.dart'; import 'package:flutter/rendering.dart' as widgets; -import 'package:vector_math/vector_math.dart'; +import 'package:vector_math/vector_math_64.dart'; import 'ui_result.dart'; @@ -26,7 +26,6 @@ extension ResizeResultExt on RawResizeResult { /// Converts a `ResizeResult` from `rect_resizer` to a `UIResizeResult` UIResizeResult toUI() { return UIResizeResult( - /// Creates a new `UIResizeResult` instance with the converted data rect: rect.toRect(), oldRect: oldRect.toRect(), flip: flip, @@ -39,6 +38,10 @@ extension ResizeResultExt on RawResizeResult { maxHeightReached: maxHeightReached, largestRect: largestRect.toRect(), handle: handle, + rotation: rotation, + boundingRect: boundingRect.toRect(), + oldBoundingRect: oldBoundingRect.toRect(), + feasible: feasible, ); } } @@ -48,12 +51,32 @@ extension MoveResultExt on RawMoveResult { /// Converts a `MoveResult` from `rect_resizer` to a `UIMoveResult` UIMoveResult toUI() { return UIMoveResult( - /// Creates a new `UIMoveResult` instance with the converted data rect: rect.toRect(), oldRect: oldRect.toRect(), delta: delta.toOffset(), rawSize: rawSize.toSize(), largestRect: largestRect.toRect(), + rotation: rotation, + boundingRect: boundingRect.toRect(), + oldBoundingRect: oldBoundingRect.toRect(), + ); + } +} + +/// Provides convenient methods for [RawRotateResult]. +extension RotateResultExt on RawRotateResult { + /// Converts a `RotateResult` from `box_transform` to a `UIRotateResult`. + UIRotateResult toUI() { + return UIRotateResult( + rect: rect.toRect(), + oldRect: oldRect.toRect(), + delta: delta.toOffset(), + rawSize: rawSize.toSize(), + largestRect: largestRect.toRect(), + rotation: rotation, + boundingRect: boundingRect.toRect(), + oldBoundingRect: oldBoundingRect.toRect(), + feasible: feasible, ); } } @@ -66,8 +89,10 @@ extension BoxExt on Box { /// Provides convenient methods for [Rect]. extension RectExt on Rect { - /// Converts a `Rect` to a `Box` from `rect_resizer` - Box toBox() => Box.fromLTRB(left, top, right, bottom); + /// Converts a `Rect` to a `Box`. Optionally pass a [rotation] (radians) + /// to construct a rotated [Box]. + Box toBox({double rotation = 0.0}) => + Box.fromLTRB(left, top, right, bottom, rotation: rotation); } /// Provides convenient methods for [Vector2]. diff --git a/packages/flutter_box_transform/lib/src/handle_builders.dart b/packages/flutter_box_transform/lib/src/handle_builders.dart index ae776d6..6f64e9f 100644 --- a/packages/flutter_box_transform/lib/src/handle_builders.dart +++ b/packages/flutter_box_transform/lib/src/handle_builders.dart @@ -1,13 +1,41 @@ +import 'dart:math' as math; import 'dart:ui'; import 'package:box_transform/box_transform.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'handles.dart'; import 'typedefs.dart'; +/// Default [HandleBuilder] implementations used by `TransformableBox` when +/// the consumer does not supply their own. +abstract final class HandleBuilders { + const HandleBuilders._(); + + /// A default implementation of the corner [HandleBuilder] callback. + /// Renders a [DefaultCornerHandle] for the given [handle] position. + static Widget defaultCorner( + BuildContext context, + HandlePosition handle, + ) => + DefaultCornerHandle(handle: handle); + + /// A default implementation of the side [HandleBuilder] callback. + /// Renders a [DefaultSideHandle] for the given [handle] position. + static Widget defaultSide( + BuildContext context, + HandlePosition handle, + ) => + DefaultSideHandle(handle: handle); +} + /// Creates a new corner handle widget, with its appropriate gesture splash /// zone. +/// +/// When [rotatable] is true, the corner exposes an outer rotation-capture +/// ring of size [rotationHandleGestureSize] surrounding the inner resize zone +/// of size [handleTapSize]. @protected class CornerHandleWidget extends StatelessWidget { /// The position of the handle. @@ -16,30 +44,61 @@ class CornerHandleWidget extends StatelessWidget { /// The builder that is used to build the handle widget. final HandleBuilder builder; - /// The size of the handle's gesture response area. + /// The size of the resize gesture response area (inner). final double handleTapSize; + /// The size of the rotation gesture response ring (outer). Ignored when + /// [rotatable] is false. + final double rotationHandleGestureSize; + + /// Whether the handle should also capture rotation gestures (in the outer + /// ring around the resize zone). + final bool rotatable; + + /// The current rotation angle of the parent box (radians). Used by the + /// rotation indicator painter so the visible arc points outward from the + /// box regardless of rotation. + final double rotation; + /// The kind of devices that are allowed to be recognized. final Set supportedDevices; - /// Called when the handle dragging starts. + /// Called when the handle resize dragging starts. final GestureDragStartCallback? onPanStart; - /// Called when the handle dragging is updated. + /// Called when the handle resize dragging is updated. final GestureDragUpdateCallback? onPanUpdate; - /// Called when the handle dragging ends. + /// Called when the handle resize dragging ends. final GestureDragEndCallback? onPanEnd; - /// Called when the handle dragging is canceled. + /// Called when the handle resize dragging is canceled. final GestureDragCancelCallback? onPanCancel; + /// Called when a rotation gesture starts on the outer ring. + final GestureDragStartCallback? onRotationStart; + + /// Called when a rotation gesture is updated. + final GestureDragUpdateCallback? onRotationUpdate; + + /// Called when a rotation gesture ends. + final GestureDragEndCallback? onRotationEnd; + + /// Called when a rotation gesture is canceled. + final GestureDragCancelCallback? onRotationCancel; + /// Whether the handle is resizable. final bool enabled; /// Whether the handle is visible. final bool visible; + /// Optional override for the [MouseCursor] shown over this handle's gesture + /// zones. Receives the [HandlePosition] and a [HandleCursorKind] indicating + /// whether the resize zone or the rotation ring is being queried. Return + /// `null` from the resolver to fall back to the package defaults. + final HandleCursorResolver? cursorResolver; + /// Whether to paint the handle's bounds for debugging purposes. final bool debugPaintHandleBounds; @@ -50,55 +109,117 @@ class CornerHandleWidget extends StatelessWidget { required this.handleTapSize, required this.supportedDevices, required this.builder, + this.rotationHandleGestureSize = 64.0, + this.rotatable = false, + this.rotation = 0.0, this.onPanStart, this.onPanUpdate, this.onPanEnd, this.onPanCancel, + this.onRotationStart, + this.onRotationUpdate, + this.onRotationEnd, + this.onRotationCancel, this.enabled = true, this.visible = true, + this.cursorResolver, this.debugPaintHandleBounds = false, - }) : assert(handlePosition.isDiagonal, 'A corner handle must be diagonal.'); + }) : assert(handlePosition.isDiagonal, 'A corner handle must be diagonal.'), + assert(rotationHandleGestureSize >= handleTapSize, + 'rotationHandleGestureSize must be >= handleTapSize.'); @override Widget build(BuildContext context) { - Widget child = + Widget visual = visible ? builder(context, handlePosition) : const SizedBox.shrink(); - if (enabled) { - child = GestureDetector( + final double outerSize = + rotatable ? rotationHandleGestureSize : handleTapSize; + final double gestureGap = (outerSize - handleTapSize) / 2; + + // Resize zone: concentric inner square of size [handleTapSize], centered + // on the box corner (which coincides with the centre of the outer zone + // when handleAlignment == center). + Widget resizeInner = enabled + ? GestureDetector( + behavior: HitTestBehavior.opaque, + supportedDevices: supportedDevices, + onPanStart: onPanStart, + onPanUpdate: onPanUpdate, + onPanEnd: onPanEnd, + onPanCancel: onPanCancel, + child: MouseRegion( + cursor: getResizeCursorForHandle(handlePosition), + child: visual, + ), + ) + : visual; + + if (kDebugMode && debugPaintHandleBounds) { + resizeInner = ColoredBox( + color: Colors.orange.withValues(alpha: 0.5), + child: resizeInner, + ); + } + + // Centered resize zone (EdgeInsets.all so the visible handle icon lands + // exactly on the box corner). + Widget resizeZone = Padding( + padding: EdgeInsets.all(gestureGap), + child: resizeInner, + ); + + // Outer rotation ring fills the entire outer zone as the bottom layer; + // resize sits on top. Hit testing: clicks on the inner resize area go to + // resize (top of stack); clicks outside it fall through to rotation. + Widget outer = resizeZone; + if (rotatable && enabled) { + Widget rotationGesture = GestureDetector( behavior: HitTestBehavior.opaque, supportedDevices: supportedDevices, - onPanStart: onPanStart, - onPanUpdate: onPanUpdate, - onPanEnd: onPanEnd, - onPanCancel: onPanCancel, + onPanStart: onRotationStart, + onPanUpdate: onRotationUpdate, + onPanEnd: onRotationEnd, + onPanCancel: onRotationCancel, child: MouseRegion( - cursor: getCursorForHandle(handlePosition), - child: child, + cursor: getRotationCursorForHandle(handlePosition), + child: CustomPaint( + painter: _RotationIndicatorPainter( + handle: handlePosition, + rotation: rotation, + ), + child: const SizedBox.expand(), + ), ), ); - } - - if (kDebugMode && debugPaintHandleBounds) { - child = ColoredBox( - color: Colors.orange.withValues(alpha: 0.5), - child: child, + if (kDebugMode && debugPaintHandleBounds) { + rotationGesture = ColoredBox( + color: Colors.blue.withValues(alpha: 0.35), + child: rotationGesture, + ); + } + outer = Stack( + fit: StackFit.expand, + children: [ + rotationGesture, // bottom: catches everything + resizeZone, // top: wins only in the center + ], ); } - return Positioned( - left: handlePosition.influencesLeft ? 0 : null, - right: handlePosition.influencesRight ? 0 : null, - top: handlePosition.influencesTop ? 0 : null, - bottom: handlePosition.influencesBottom ? 0 : null, - width: handleTapSize, - height: handleTapSize, - child: child, - ); + // Return a fixed-size box; positioning is the caller's responsibility. + return SizedBox(width: outerSize, height: outerSize, child: outer); } - /// Returns the cursor for the given handle position. - MouseCursor getCursorForHandle(HandlePosition handle) { + /// Returns the resize cursor for the given handle position. + /// + /// If a [cursorResolver] was supplied and returns non-null for this handle, + /// that value is used; otherwise the appropriate diagonal + /// `SystemMouseCursors.resize*` constant is returned. + MouseCursor getResizeCursorForHandle(HandlePosition handle) { + final MouseCursor? overridden = + cursorResolver?.call(handle, HandleCursorKind.resize); + if (overridden != null) return overridden; switch (handle) { case HandlePosition.topLeft: case HandlePosition.bottomRight: @@ -110,6 +231,31 @@ class CornerHandleWidget extends StatelessWidget { throw Exception('Invalid handle position.'); } } + + /// Returns the cursor shown over the rotation ring for the given handle. + /// + /// Flutter's [SystemMouseCursors] has no rotation cursor and there is no + /// open Flutter issue tracking one — confirmed against the Flutter API + /// docs. As a default we reuse the diagonal resize cursor so the gesture + /// zone still feels actionable. + /// + /// If you want a real rotate glyph, supply a [cursorResolver] that returns + /// a custom [MouseCursor] for [HandleCursorKind.rotation]. The + /// [`custom_mouse_cursor`](https://pub.dev/packages/custom_mouse_cursor) + /// package can build a native, DPR-aware [MouseCursor] from an asset, icon, + /// or `ui.Image` (works on Windows, macOS, Linux, and web). Returning + /// `null` from the resolver falls back to this default. + MouseCursor getRotationCursorForHandle(HandlePosition handle) { + final MouseCursor? overridden = + cursorResolver?.call(handle, HandleCursorKind.rotation); + if (overridden != null) return overridden; + return getResizeCursorForHandle(handle); + } + + /// Deprecated; use [getResizeCursorForHandle]. Kept for backward-compat + /// with any callers that use the legacy name. + MouseCursor getCursorForHandle(HandlePosition handle) => + getResizeCursorForHandle(handle); } /// Creates a new cardinal handle widget, with its appropriate gesture splash @@ -146,6 +292,12 @@ class SideHandleWidget extends StatelessWidget { /// Whether the handle is visible. final bool visible; + /// Optional override for the [MouseCursor] shown over this handle. Side + /// handles only have a resize zone, so the resolver is always called with + /// [HandleCursorKind.resize]. Return `null` to fall back to the package + /// default. + final HandleCursorResolver? cursorResolver; + /// Whether to paint the handle's bounds for debugging purposes. final bool debugPaintHandleBounds; @@ -162,6 +314,7 @@ class SideHandleWidget extends StatelessWidget { this.onPanCancel, this.enabled = true, this.visible = true, + this.cursorResolver, this.debugPaintHandleBounds = false, }) : assert(handlePosition.isSide, 'A cardinal handle must be cardinal.'); @@ -192,35 +345,20 @@ class SideHandleWidget extends StatelessWidget { ); } - return Positioned( - left: handlePosition.isVertical - ? handleTapSize - : handlePosition.influencesLeft - ? 0 - : null, - right: handlePosition.isVertical - ? handleTapSize - : handlePosition.influencesRight - ? 0 - : null, - top: handlePosition.isHorizontal - ? handleTapSize - : handlePosition.influencesTop - ? 0 - : null, - bottom: handlePosition.isHorizontal - ? handleTapSize - : handlePosition.influencesBottom - ? 0 - : null, - width: handlePosition.isHorizontal ? handleTapSize : null, - height: handlePosition.isVertical ? handleTapSize : null, - child: child, - ); + // SideHandleWidget now returns just its interactive child; positioning + // is the caller's responsibility (see TransformableBox build). + return child; } /// Returns the cursor for the given handle position. + /// + /// If a [cursorResolver] was supplied and returns non-null for this handle, + /// that value is used; otherwise the appropriate cardinal + /// `SystemMouseCursors.resize*` constant is returned. MouseCursor getCursorForHandle(HandlePosition handle) { + final MouseCursor? overridden = + cursorResolver?.call(handle, HandleCursorKind.resize); + if (overridden != null) return overridden; switch (handle) { case HandlePosition.left: case HandlePosition.right: @@ -233,3 +371,60 @@ class SideHandleWidget extends StatelessWidget { } } } + +/// Paints a subtle arc in the outer rotation ring of a corner handle so +/// users can see where to grab for rotation. Arc spans the quadrant of the +/// ring that sits outside the box corner (accounting for rotation so the +/// indicator always points away from the box's visual centre). +class _RotationIndicatorPainter extends CustomPainter { + final HandlePosition handle; + final double rotation; + + const _RotationIndicatorPainter({ + required this.handle, + this.rotation = 0.0, + }); + + @override + void paint(Canvas canvas, Size size) { + // Centre the arc on the handle widget itself; the handle is axis-aligned + // so the arc's geometric centre is just (size.width/2, size.height/2). + final Offset centre = Offset(size.width / 2, size.height / 2); + final double radius = math.min(size.width, size.height) * 0.4; + // Base start angle selects the quadrant that would be outward when + // rotation == 0 (the "outside-the-box" direction from the corner). + final double baseStartAngle; + switch (handle) { + case HandlePosition.topLeft: + baseStartAngle = math.pi; + case HandlePosition.topRight: + baseStartAngle = math.pi * 1.5; + case HandlePosition.bottomRight: + baseStartAngle = 0; + case HandlePosition.bottomLeft: + baseStartAngle = math.pi * 0.5; + default: + return; + } + // Since this handle widget is now axis-aligned in world frame but placed + // at the visually-rotated corner of the box, add [rotation] so the arc + // still points outward relative to the box's current orientation. + final double startAngle = baseStartAngle + rotation; + final paint = Paint() + ..color = const Color(0x554B9EF4) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.2 + ..strokeCap = StrokeCap.round; + canvas.drawArc( + Rect.fromCircle(center: centre, radius: radius), + startAngle, + math.pi / 2, + false, + paint, + ); + } + + @override + bool shouldRepaint(covariant _RotationIndicatorPainter oldDelegate) => + oldDelegate.handle != handle || oldDelegate.rotation != rotation; +} diff --git a/packages/flutter_box_transform/lib/src/rotated_hit_gate.dart b/packages/flutter_box_transform/lib/src/rotated_hit_gate.dart new file mode 100644 index 0000000..a48944c --- /dev/null +++ b/packages/flutter_box_transform/lib/src/rotated_hit_gate.dart @@ -0,0 +1,115 @@ +import 'dart:math' as math; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +/// A render object that filters pointer hits to the rotated rectangle +/// defined by [unrotatedRectInWorld] rotated by [rotation] radians about +/// its center. Hits in this render box's layout bounds but outside the +/// rotated polygon are rejected; hits inside the polygon fall through to +/// [RenderProxyBox.hitTest] so the child's gesture tree handles them. +/// +/// The render box itself is axis-aligned, so its child's gesture +/// coordinates are unaffected. [hitBoxTopLeftInWorld] converts the hit-test +/// local position into the world/parent frame for the containment check. +class RotatedHitGate extends SingleChildRenderObjectWidget { + /// Creates a hit-gate that lets pointer hits through only when they fall + /// inside the rotated rectangle defined by [unrotatedRectInWorld] and + /// [rotation]. + const RotatedHitGate({ + super.key, + required this.unrotatedRectInWorld, + required this.rotation, + required this.hitBoxTopLeftInWorld, + required Widget super.child, + }); + + /// The unrotated rect in world/parent frame that defines the polygon + /// when rotated by [rotation] about its center. + final Rect unrotatedRectInWorld; + + /// Rotation angle in radians, clockwise about [unrotatedRectInWorld]'s + /// center (matches the library's clockwise-positive convention). + final double rotation; + + /// This render box's top-left in the world/parent frame. Used to convert + /// the hit-test local position into world coords before containment. + final Offset hitBoxTopLeftInWorld; + + @override + RotatedHitGateRender createRenderObject(BuildContext context) { + return RotatedHitGateRender( + unrotatedRectInWorld: unrotatedRectInWorld, + rotation: rotation, + hitBoxTopLeftInWorld: hitBoxTopLeftInWorld, + ); + } + + @override + void updateRenderObject( + BuildContext context, + RotatedHitGateRender renderObject, + ) { + renderObject + ..unrotatedRectInWorld = unrotatedRectInWorld + ..rotation = rotation + ..hitBoxTopLeftInWorld = hitBoxTopLeftInWorld; + } +} + +/// Render object backing [RotatedHitGate]. Library-visible so the widget's +/// `createRenderObject` / `updateRenderObject` overrides don't expose a +/// private type in their signatures; not re-exported from the package +/// umbrella, so external callers cannot observe it. +class RotatedHitGateRender extends RenderProxyBox { + /// Creates a [RotatedHitGateRender]. + RotatedHitGateRender({ + required Rect unrotatedRectInWorld, + required double rotation, + required Offset hitBoxTopLeftInWorld, + }) : _unrotatedRectInWorld = unrotatedRectInWorld, + _rotation = rotation, + _hitBoxTopLeftInWorld = hitBoxTopLeftInWorld; + + Rect _unrotatedRectInWorld; + double _rotation; + Offset _hitBoxTopLeftInWorld; + + set unrotatedRectInWorld(Rect value) { + if (value == _unrotatedRectInWorld) return; + _unrotatedRectInWorld = value; + } + + set rotation(double value) { + if (value == _rotation) return; + _rotation = value; + } + + set hitBoxTopLeftInWorld(Offset value) { + if (value == _hitBoxTopLeftInWorld) return; + _hitBoxTopLeftInWorld = value; + } + + @override + bool hitTest(BoxHitTestResult result, {required Offset position}) { + final Offset worldPos = position + _hitBoxTopLeftInWorld; + if (!_insideRotatedRect(worldPos)) return false; + return super.hitTest(result, position: position); + } + + bool _insideRotatedRect(Offset worldPos) { + // Fast-path at zero rotation: cos(0)=1, sin(0)=0 makes the inverse-rotate + // an identity, so the general formula reduces exactly to a rect contains. + if (_rotation.abs() < 1e-9) { + return _unrotatedRectInWorld.contains(worldPos); + } + final Offset center = _unrotatedRectInWorld.center; + final double dx = worldPos.dx - center.dx; + final double dy = worldPos.dy - center.dy; + final double c = math.cos(-_rotation); + final double s = math.sin(-_rotation); + final double localX = c * dx - s * dy + center.dx; + final double localY = s * dx + c * dy + center.dy; + return _unrotatedRectInWorld.contains(Offset(localX, localY)); + } +} diff --git a/packages/flutter_box_transform/lib/src/rotated_layout.dart b/packages/flutter_box_transform/lib/src/rotated_layout.dart new file mode 100644 index 0000000..313b675 --- /dev/null +++ b/packages/flutter_box_transform/lib/src/rotated_layout.dart @@ -0,0 +1,179 @@ +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:box_transform/box_transform.dart'; + +import 'extensions.dart'; +import 'handles.dart'; + +/// Pure helpers for the Flutter-side rotated-box layout: rotating points, +/// finding handle anchors in parent / world frames, and computing the rect +/// that the clamp solver enforces under a given [BindingStrategy]. +/// +/// All members are `static`; the class is not instantiable. +abstract final class RotatedLayout { + const RotatedLayout._(); + + /// Rotates [point] around [pivot] by [angle] radians (CW in y-down screen + /// coords). Pure function; no state. + static Offset rotateOffsetAround(Offset point, Offset pivot, double angle) { + if (angle == 0.0) return point; + final dx = point.dx - pivot.dx; + final dy = point.dy - pivot.dy; + final c = math.cos(angle); + final s = math.sin(angle); + return Offset( + pivot.dx + dx * c - dy * s, + pivot.dy + dx * s + dy * c, + ); + } + + /// Returns the un-rotated corner (or side midpoint) of [rect] for [handle]. + static Offset handleCornerInParent(Rect rect, HandlePosition handle) { + switch (handle) { + case HandlePosition.topLeft: + return rect.topLeft; + case HandlePosition.topRight: + return rect.topRight; + case HandlePosition.bottomLeft: + return rect.bottomLeft; + case HandlePosition.bottomRight: + return rect.bottomRight; + case HandlePosition.top: + return rect.topCenter; + case HandlePosition.bottom: + return rect.bottomCenter; + case HandlePosition.left: + return rect.centerLeft; + case HandlePosition.right: + return rect.centerRight; + case HandlePosition.none: + return rect.center; + } + } + + /// Returns the visual (rotated) world-space position of [handle]'s corner + /// given an un-rotated [rect] rotated by [rotation] radians around its centre. + static Offset rotatedCornerInWorld( + Rect rect, + HandlePosition handle, + double rotation, + ) { + final corner = handleCornerInParent(rect, handle); + if (rotation == 0.0) return corner; + return rotateOffsetAround(corner, rect.center, rotation); + } + + /// Returns the offset within a [handleSize]×[handleSize] handle widget where + /// the box's corner lands, for the given [handle] position and + /// [alignment]. This depends on how the handle is positioned relative to the + /// box edge (inside/outside/center). + static Offset anchorInHandle( + HandlePosition handle, + double handleSize, + HandleAlignment alignment, + ) { + final p = alignment.offset(handleSize); + return Offset( + handle.influencesLeft ? p : handleSize - p, + handle.influencesTop ? p : handleSize - p, + ); + } + + /// Returns the top-left position (world/parent-local coord frame) where a + /// handle widget of size [handleSize]×[handleSize] should be placed so that + /// its internal anchor coincides with the visually-rotated corner of [rect]. + static Offset handleTopLeftInWorld({ + required Rect rect, + required HandlePosition handle, + required double rotation, + required double handleSize, + required HandleAlignment alignment, + }) { + final cornerWorld = rotatedCornerInWorld(rect, handle, rotation); + final anchor = anchorInHandle(handle, handleSize, alignment); + return cornerWorld - anchor; + } + + /// Returns the parent-frame rect occupied by a side handle at the un-rotated + /// [rect]. Only valid for side handles (top/bottom/left/right); rotation is + /// not supported for side handles in v1. + /// + /// The handle is a thin strip of thickness [handleTapSize], inset from the + /// corners by [handleTapSize] so it doesn't overlap corner handles. The + /// [alignment] controls whether the strip sits inside, centred on, or + /// outside the edge. + static Rect sideHandleRectInWorld( + Rect rect, + HandlePosition handle, { + required double handleTapSize, + required HandleAlignment alignment, + }) { + final inset = alignment.offset(handleTapSize); + switch (handle) { + case HandlePosition.top: + return Rect.fromLTWH( + rect.left - inset + handleTapSize, + rect.top - inset, + rect.width + 2 * inset - 2 * handleTapSize, + handleTapSize, + ); + case HandlePosition.bottom: + return Rect.fromLTWH( + rect.left - inset + handleTapSize, + rect.bottom + inset - handleTapSize, + rect.width + 2 * inset - 2 * handleTapSize, + handleTapSize, + ); + case HandlePosition.left: + return Rect.fromLTWH( + rect.left - inset, + rect.top - inset + handleTapSize, + handleTapSize, + rect.height + 2 * inset - 2 * handleTapSize, + ); + case HandlePosition.right: + return Rect.fromLTWH( + rect.right + inset - handleTapSize, + rect.top - inset + handleTapSize, + handleTapSize, + rect.height + 2 * inset - 2 * handleTapSize, + ); + default: + throw ArgumentError( + 'sideHandleRectInWorld requires a side handle, got $handle'); + } + } + + /// Returns the rect that must be contained in the clamp under + /// [bindingStrategy] for a rotated rect with the given [rect] and + /// [rotation]. + /// + /// * [BindingStrategy.boundingBox]: returns the axis-aligned bounding rect + /// of [rect] rotated by [rotation] about its center. This is what the + /// clamp solver enforces under `boundingBox` semantics (rotated corners + /// stay in clamp, rendered footprint fully contained). + /// + /// * [BindingStrategy.originalBox]: returns [rect] unchanged. Rotation does + /// not affect which rect must be contained - the unrotated logical rect + /// stays in the clamp, and rotated corners may extend outside. + /// + /// At [rotation] == 0 both strategies return [rect]. + /// + /// See also: `TransformableBoxController.effectiveContainmentRect` which + /// uses the controller's own state. + static Rect computeEffectiveContainmentRect({ + required Rect rect, + required double rotation, + required BindingStrategy bindingStrategy, + }) { + switch (bindingStrategy) { + case BindingStrategy.boundingBox: + return ClampHelpers.calculateBoundingRect( + rect.toBox(rotation: rotation)) + .toRect(); + case BindingStrategy.originalBox: + return rect; + } + } +} diff --git a/packages/flutter_box_transform/lib/src/transformable_box.dart b/packages/flutter_box_transform/lib/src/transformable_box.dart index 2c9a296..1da8a9a 100644 --- a/packages/flutter_box_transform/lib/src/transformable_box.dart +++ b/packages/flutter_box_transform/lib/src/transformable_box.dart @@ -1,8 +1,11 @@ +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import '../flutter_box_transform.dart'; -import 'handle_builders.dart'; +import 'rotated_hit_gate.dart'; /// A widget that allows you to resize and drag a box around a widget. class TransformableBox extends StatefulWidget { @@ -34,6 +37,23 @@ class TransformableBox extends StatefulWidget { /// Note that this will build for all four sides of the rectangle. final HandleBuilder sideHandleBuilder; + /// Optional override for the [MouseCursor] shown over each handle's gesture + /// zone. Receives the [HandlePosition] and a [HandleCursorKind] indicating + /// the resize zone vs. the rotation ring (the latter only on corners when + /// [rotatable] is true). + /// + /// This applies to the handle's gesture zones, not to pixels painted by + /// [cornerHandleBuilder] or [sideHandleBuilder] — a custom builder that + /// wraps its output in its own [MouseRegion] will win on its own footprint. + /// + /// Return `null` for any case to fall back to the package defaults — the + /// appropriate diagonal/cardinal `SystemMouseCursors.resize*` for resize + /// zones, and the diagonal resize cursor for rotation rings (Flutter has + /// no native rotation cursor; pair with the + /// [`custom_mouse_cursor`](https://pub.dev/packages/custom_mouse_cursor) + /// package if you want to ship a real rotate glyph). + final HandleCursorResolver? cursorResolver; + /// The size of the gesture response area of the handles. If you don't /// specify it, the default value will be used. /// @@ -47,6 +67,23 @@ class TransformableBox extends StatefulWidget { /// The default value is 24 pixels in diameter. final double handleTapSize; + /// Size of the rotation gesture capture ring surrounding a corner handle. + /// Must be >= [handleTapSize]. Only used when [rotatable] is true. Default + /// 64 pixels. + final double rotationHandleGestureSize; + + /// Whether corner handles should capture rotation gestures in an outer + /// ring around the resize zone. Default false. + final bool rotatable; + + /// The current rotation angle (radians) of the box around its center. + /// Ignored if a [controller] is provided. + final double rotation; + + /// Controls whether size constraints and clamping apply to the unrotated + /// box or its rendered bounding rect. Ignored if a [controller] is provided. + final BindingStrategy bindingStrategy; + /// A set containing handles that are enabled. This is different from /// [visibleHandles]. /// @@ -187,6 +224,18 @@ class TransformableBox extends StatefulWidget { /// A callback function that triggers when the box cancels resizing. final RectResizeCancel? onResizeCancel; + /// Fires when a rotation gesture begins on a corner handle. + final RectRotateStart? onRotationStart; + + /// Fires during a rotation gesture. + final RectRotateUpdateEvent? onRotationUpdate; + + /// Fires when a rotation gesture ends. + final RectRotateEnd? onRotationEnd; + + /// Fires when a rotation gesture is cancelled. + final RectRotateCancel? onRotationCancel; + /// A callback function that triggers when the box reaches its minimum width /// when resizing. final TerminalEdgeEvent? onMinWidthReached; @@ -230,14 +279,30 @@ class TransformableBox extends StatefulWidget { /// Whether to paint the handle's bounds for debugging purposes. final bool debugPaintHandleBounds; + /// When true, paints a red border around the rotated axis-aligned bounding + /// box of the box. Useful for visualizing the rendered footprint at non-zero + /// [rotation]. Only active in debug mode. + final bool debugShowBoundingRect; + + /// When true, paints a green border around the unrotated rect. Useful for + /// visualizing the logical (pre-rotation) box. Only active in debug mode. + final bool debugShowUnrotatedRect; + + /// When true, renders red+blue arrows during a rotation gesture: from box + /// center to the initial pointer position, and to the current pointer + /// position. Only active in debug mode. + final bool debugShowRotationArrows; + /// Creates a [TransformableBox] widget. const TransformableBox({ super.key, required this.contentBuilder, this.controller, - this.cornerHandleBuilder = _defaultCornerHandleBuilder, - this.sideHandleBuilder = _defaultSideHandleBuilder, + this.cornerHandleBuilder = HandleBuilders.defaultCorner, + this.sideHandleBuilder = HandleBuilders.defaultSide, + this.cursorResolver, this.handleTapSize = 24, + this.rotationHandleGestureSize = 64, this.allowContentFlipping = true, this.handleAlignment = HandleAlignment.center, this.enabledHandles = const {...HandlePosition.values}, @@ -251,10 +316,13 @@ class TransformableBox extends StatefulWidget { Rect? clampingRect, BoxConstraints? constraints, ValueGetter? resizeModeResolver, + double? rotation, + this.bindingStrategy = BindingStrategy.boundingBox, // Additional controls. this.resizable = true, this.draggable = true, + this.rotatable = false, this.allowFlippingWhileResizing = true, // Tap events @@ -275,6 +343,12 @@ class TransformableBox extends StatefulWidget { this.onDragEnd, this.onDragCancel, + // Rotation Events. + this.onRotationStart, + this.onRotationUpdate, + this.onRotationEnd, + this.onRotationCancel, + // Terminal update events. this.onMinWidthReached, this.onMaxWidthReached, @@ -284,20 +358,26 @@ class TransformableBox extends StatefulWidget { this.onTerminalHeightReached, this.onTerminalSizeReached, this.debugPaintHandleBounds = false, + this.debugShowBoundingRect = false, + this.debugShowUnrotatedRect = false, + this.debugShowRotationArrows = false, }) : assert( (controller == null) || ((rect == null) && (flip == null) && (clampingRect == null) && (constraints == null) && - (resizeModeResolver == null)), + (resizeModeResolver == null) && + (rotation == null)), 'If a controller is provided, the raw values should not be provided.', ), rect = rect ?? Rect.zero, flip = flip ?? Flip.none, clampingRect = clampingRect ?? Rect.largest, constraints = constraints ?? const BoxConstraints.expand(), - resizeModeResolver = resizeModeResolver ?? defaultResizeModeResolver; + rotation = rotation ?? 0.0, + resizeModeResolver = resizeModeResolver ?? + TransformableBoxController.defaultResizeModeResolver; /// Returns the [TransformableBox] of the closest ancestor. static TransformableBox? widgetOf(BuildContext context) { @@ -317,11 +397,14 @@ class TransformableBox extends StatefulWidget { enum _PrimaryGestureOperation { resize, - drag; + drag, + rotate; bool get isDragging => this == _PrimaryGestureOperation.drag; bool get isResizing => this == _PrimaryGestureOperation.resize; + + bool get isRotating => this == _PrimaryGestureOperation.rotate; } class _TransformableBoxState extends State { @@ -331,11 +414,27 @@ class _TransformableBoxState extends State { HandlePosition? lastHandle; + /// Current pointer position during a rotation gesture, in this widget's + /// local coordinate space. Used by the rotation-arrows debug overlay. + Offset? _rotationArrowPointer; + + /// Offset to add to `event.globalPosition` to yield a world/parent-frame + /// coordinate. Captured at gesture start so subsequent updates are immune + /// to the widget moving during the gesture. + /// + /// Flutter's `event.localPosition` is transformed using the hit-test-time + /// transform; once the widget moves mid-gesture, `localPosition` goes + /// stale. `globalPosition` stays accurate in screen coordinates, and a + /// captured offset gives a stable global→world conversion. + Offset? _gestureGlobalToWorldOffset; + bool get isDragging => primaryGestureOperation?.isDragging == true; bool get isResizing => primaryGestureOperation?.isResizing == true; - bool get isGestureActive => isDragging || isResizing; + bool get isRotating => primaryGestureOperation?.isRotating == true; + + bool get isGestureActive => isDragging || isResizing || isRotating; bool mismatchedHandle(HandlePosition handle) => lastHandle != null && lastHandle != handle; @@ -357,6 +456,8 @@ class _TransformableBoxState extends State { constraints: widget.constraints, resizeModeResolver: widget.resizeModeResolver, allowFlippingWhileResizing: widget.allowFlippingWhileResizing, + rotation: widget.rotation, + bindingStrategy: widget.bindingStrategy, ); } } @@ -381,6 +482,8 @@ class _TransformableBoxState extends State { constraints: widget.constraints, resizeModeResolver: widget.resizeModeResolver, allowFlippingWhileResizing: widget.allowFlippingWhileResizing, + rotation: widget.rotation, + bindingStrategy: widget.bindingStrategy, ); } @@ -391,10 +494,33 @@ class _TransformableBoxState extends State { bool shouldRecalculatePosition = false; bool shouldRecalculateSize = false; - if (oldWidget.rect != widget.rect || widget.rect != controller.rect) { - controller.setRect(widget.rect, notify: false); - shouldRecalculatePosition = true; - shouldRecalculateSize = true; + // Two distinct cases: + // + // * Real rect change (caller supplied a new widget.rect): needs a full + // recalc to honour constraints + clamping. + // * Drift mismatch (widget.rect unchanged across rebuilds, but + // controller.rect has drifted): happens when an external rebuild + // trigger (parent setState, new clampingRect / constraints, etc.) + // fires while the controller carries a translation that the parent + // hasn't mirrored back via onChanged. Snapping controller.rect back + // to widget.rect without re-applying position recalc produces a + // visible snap-back — e.g. at clamp saturation, when the clamp + // branch doesn't fire so nothing re-translates the box. We push + // widget.rect in (restoring caller source-of-truth) and then let + // the end-of-method recalculatePosition re-apply any clamp-driven + // translation. Size is NOT recalculated on drift-only — that would + // reintroduce V7's clamp-induced scale shrink. + final bool rectChangedByCaller = oldWidget.rect != widget.rect; + final bool rectMismatch = widget.rect != controller.rect; + if (rectChangedByCaller || rectMismatch) { + controller.setRect(widget.rect, notify: false, recalculate: false); + if (rectChangedByCaller) { + shouldRecalculatePosition = true; + shouldRecalculateSize = true; + } else { + // Drift-only: re-apply clamp translation, but do not resize. + shouldRecalculatePosition = true; + } } if (oldWidget.flip != widget.flip || widget.flip != controller.flip) { @@ -411,8 +537,18 @@ class _TransformableBoxState extends State { if (oldWidget.clampingRect != widget.clampingRect || widget.clampingRect != controller.clampingRect) { - controller.setClampingRect(widget.clampingRect, notify: false); + // `recalculate: false` + position-only reconciliation. A clamp change + // should translate the box to stay inside the clamp, not resize it. + // setClampingRect's internal recalculate() runs recalculateSize via a + // zero-delta scale resize; for a rotated box against a tight clamp + // that picks the largest (w, h) that fits the new clamp, which is + // strictly smaller than the current box by a sliver. Repeated clamp + // repumps (e.g. parent drags the clamp in tiny ticks) accumulate the + // slivers into a visible shrink + off-edge flick. + controller.setClampingRect(widget.clampingRect, + notify: false, recalculate: false); shouldRecalculatePosition = true; + // Intentionally do NOT set shouldRecalculateSize. } if (oldWidget.constraints != widget.constraints || @@ -431,6 +567,16 @@ class _TransformableBoxState extends State { ); } + if (oldWidget.rotation != widget.rotation || + widget.rotation != controller.rotation) { + controller.setRotation(widget.rotation, notify: false); + } + + if (oldWidget.bindingStrategy != widget.bindingStrategy || + widget.bindingStrategy != controller.bindingStrategy) { + controller.setBindingStrategy(widget.bindingStrategy, notify: false); + } + if (shouldRecalculatePosition) { controller.recalculatePosition(notify: false); } @@ -448,29 +594,94 @@ class _TransformableBoxState extends State { } /// Called when the controller is updated. + /// + /// Trigger a rebuild for any observable controller state that the widget + /// renders: rect, flip, rotation, or bindingStrategy. void onControllerUpdate() { - if (widget.rect != controller.rect || widget.flip != controller.flip) { + if (widget.rect != controller.rect || + widget.flip != controller.flip || + widget.rotation != controller.rotation || + widget.bindingStrategy != controller.bindingStrategy) { if (mounted) setState(() {}); } } /// Called when the handle drag starts. - void onHandlePanStart(DragStartDetails event, HandlePosition handle) { - if (isGestureActive || mismatchedHandle(handle)) return; - + /// + /// Any new gesture start forcibly clears prior state (defensive guard + /// against stale `primaryGestureOperation` from a previous gesture whose + /// end/cancel event was dropped). + /// + /// [handleTopLeftWorld] is the parent-frame offset of the handle widget's + /// top-left. It's combined with [event.localPosition] to produce the + /// starting world-frame pointer position, and with [event.globalPosition] + /// to capture a stable global→world offset used by subsequent updates. + void onHandlePanStart( + DragStartDetails event, + HandlePosition handle, + Offset handleTopLeftWorld, { + double rotation = 0.0, + Offset? rotationPivotWorld, + Size? widgetSize, + }) { primaryGestureOperation = _PrimaryGestureOperation.resize; lastHandle = handle; - controller.onResizeStart(event.localPosition); + final Offset worldPos = _eventToWorld( + event.localPosition, + handleTopLeftWorld: handleTopLeftWorld, + rotation: rotation, + rotationPivotWorld: rotationPivotWorld, + widgetSize: widgetSize, + ); + _gestureGlobalToWorldOffset = worldPos - event.globalPosition; + controller.onResizeStart(worldPos); widget.onResizeStart?.call(handle, event); } + /// Converts a gesture event's local-frame position to world coords. + /// + /// For axis-aligned handles (corners and side handles when rotation == 0) + /// this is a translation: `worldPos = handleTopLeftWorld + localPos`. + /// + /// For side handles wrapped in `Transform.rotate(rotation, alignment: + /// center)`, the gesture's local frame is the strip's pre-rotation + /// rectangle. We undo the Transform by rotating the offset-from-center + /// around the strip's world center. + Offset _eventToWorld( + Offset localPos, { + required Offset handleTopLeftWorld, + required double rotation, + required Offset? rotationPivotWorld, + required Size? widgetSize, + }) { + if (rotation == 0.0 || widgetSize == null || rotationPivotWorld == null) { + return handleTopLeftWorld + localPos; + } + final Offset localCenter = Offset( + widgetSize.width / 2, + widgetSize.height / 2, + ); + final Offset off = localPos - localCenter; + final double c = math.cos(rotation); + final double s = math.sin(rotation); + final Offset rotated = + Offset(off.dx * c - off.dy * s, off.dx * s + off.dy * c); + return rotationPivotWorld + rotated; + } + /// Called when the handle drag updates. - void onHandlePanUpdate(DragUpdateDetails event, HandlePosition handle) { + void onHandlePanUpdate( + DragUpdateDetails event, + HandlePosition handle, + Offset handleTopLeftWorld, + ) { if (!isResizing || mismatchedHandle(handle)) return; + final Offset worldPos = + event.globalPosition + (_gestureGlobalToWorldOffset ?? Offset.zero); final UIResizeResult result = controller.onResizeUpdate( - event.localPosition, + worldPos, handle, ); @@ -531,6 +742,61 @@ class _TransformableBoxState extends State { widget.onTerminalSizeReached?.call(false, false, false, false); } + /// Called when a rotation gesture starts on a handle's outer ring. + /// Force-clears any stale gesture state. + void onHandleRotateStart( + DragStartDetails event, + HandlePosition handle, + Offset handleTopLeftWorld, + ) { + primaryGestureOperation = _PrimaryGestureOperation.rotate; + lastHandle = handle; + final Offset worldPos = handleTopLeftWorld + event.localPosition; + _gestureGlobalToWorldOffset = worldPos - event.globalPosition; + controller.onRotateStart(worldPos); + widget.onRotationStart?.call(handle, event); + } + + /// Called during a rotation gesture. + void onHandleRotateUpdate( + DragUpdateDetails event, + HandlePosition handle, + Offset handleTopLeftWorld, + ) { + if (!isRotating || mismatchedHandle(handle)) return; + final Offset worldPos = + event.globalPosition + (_gestureGlobalToWorldOffset ?? Offset.zero); + final result = controller.onRotateUpdate(worldPos, handle); + if (widget.debugShowRotationArrows) { + setState(() => _rotationArrowPointer = worldPos); + } + widget.onRotationUpdate?.call(result, event); + } + + /// Called when a rotation gesture ends. + void onHandleRotateEnd(DragEndDetails event, HandlePosition handle) { + if (!isRotating || mismatchedHandle(handle)) return; + primaryGestureOperation = null; + lastHandle = null; + controller.onRotateEnd(); + if (_rotationArrowPointer != null) { + setState(() => _rotationArrowPointer = null); + } + widget.onRotationEnd?.call(handle, event); + } + + /// Called when a rotation gesture is cancelled. + void onHandleRotateCancel(HandlePosition handle) { + if (!isRotating || mismatchedHandle(handle)) return; + primaryGestureOperation = null; + lastHandle = null; + controller.onRotateCancel(); + if (_rotationArrowPointer != null) { + setState(() => _rotationArrowPointer = null); + } + widget.onRotationCancel?.call(handle); + } + /// Called when the box is tapped. void onTap() { if (isGestureActive) return; @@ -539,23 +805,27 @@ class _TransformableBoxState extends State { } /// Called when the box drag event starts. - void onDragPanStart(DragStartDetails event) { - if (isGestureActive) return; - + /// + /// [dragTopLeftWorld] is the parent-frame top-left of the drag hit region. + /// Combined with [event.localPosition] to seed the world pointer, and with + /// [event.globalPosition] to capture the stable global→world offset. + void onDragPanStart(DragStartDetails event, Offset dragTopLeftWorld) { primaryGestureOperation = _PrimaryGestureOperation.drag; lastHandle = HandlePosition.none; - controller.onDragStart(event.localPosition); + final Offset worldPos = dragTopLeftWorld + event.localPosition; + _gestureGlobalToWorldOffset = worldPos - event.globalPosition; + controller.onDragStart(worldPos); widget.onDragStart?.call(event); } /// Called when the box drag event updates. - void onDragPanUpdate(DragUpdateDetails event) { + void onDragPanUpdate(DragUpdateDetails event, Offset dragTopLeftWorld) { if (!isDragging) return; - final UIMoveResult result = controller.onDragUpdate( - event.localPosition, - ); + final Offset worldPos = + event.globalPosition + (_gestureGlobalToWorldOffset ?? Offset.zero); + final UIMoveResult result = controller.onDragUpdate(worldPos); widget.onChanged?.call(result, event); widget.onDragUpdate?.call(result, event); @@ -586,89 +856,330 @@ class _TransformableBoxState extends State { Widget build(BuildContext context) { final Flip flip = controller.flip; final Rect rect = controller.rect; - - Widget content = Transform.scale( - scaleX: widget.allowContentFlipping && flip.isHorizontal ? -1 : 1, - scaleY: widget.allowContentFlipping && flip.isVertical ? -1 : 1, - child: widget.contentBuilder(context, rect, flip), + final double rotation = controller.rotation; + final Rect boundingRect = controller.boundingRect; + + final double handleTap = widget.handleTapSize; + final double outerHandleSize = + widget.rotatable ? widget.rotationHandleGestureSize : handleTap; + + // --- Outer Positioned bounds --------------------------------------------- + // Must contain the visually rotated box AND all handle hit regions. + // We take the union of boundingRect (rotated AABB) and rect (unrotated, + // since side handles at theta=0 hang off rect's edges), then inflate to + // accommodate handle gesture zones. + final Rect paintRect = Rect.fromLTRB( + (boundingRect.left < rect.left ? boundingRect.left : rect.left) - + outerHandleSize, + (boundingRect.top < rect.top ? boundingRect.top : rect.top) - + outerHandleSize, + (boundingRect.right > rect.right ? boundingRect.right : rect.right) + + outerHandleSize, + (boundingRect.bottom > rect.bottom ? boundingRect.bottom : rect.bottom) + + outerHandleSize, + ); + final Offset origin = paintRect.topLeft; + + // --- Visual content (rotated + flipped, never receives gestures) -------- + Widget visualContent = Transform.rotate( + angle: rotation, + alignment: Alignment.center, + child: Transform.scale( + scaleX: widget.allowContentFlipping && flip.isHorizontal ? -1 : 1, + scaleY: widget.allowContentFlipping && flip.isVertical ? -1 : 1, + child: widget.contentBuilder(context, rect, flip), + ), + ); + // Visual content is NOT wrapped in IgnorePointer: consumers may put a + // tap/hover detector inside [contentBuilder] and those need to see + // pointer events. The drag GestureDetector is a sibling (in Stack) with + // HitTestBehavior.translucent so events reach content underneath too. + final Widget contentLayer = Positioned( + left: rect.left - origin.dx, + top: rect.top - origin.dy, + width: rect.width, + height: rect.height, + child: visualContent, ); + // --- Drag detector (axis-aligned, covers the visually-rotated AABB) ---- + Widget? dragLayer; if (widget.draggable) { - content = GestureDetector( - behavior: HitTestBehavior.translucent, - supportedDevices: widget.supportedDragDevices, - onTap: widget.onTap == null ? null : onTap, - onPanStart: onDragPanStart, - onPanUpdate: onDragPanUpdate, - onPanEnd: onDragPanEnd, - onPanCancel: onDragPanCancel, - child: content, + final Rect dragRect = boundingRect; + final Offset dragTopLeftWorld = dragRect.topLeft; + dragLayer = Positioned( + left: dragRect.left - origin.dx, + top: dragRect.top - origin.dy, + width: dragRect.width, + height: dragRect.height, + child: RotatedHitGate( + unrotatedRectInWorld: rect, + rotation: rotation, + hitBoxTopLeftInWorld: dragRect.topLeft, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + supportedDevices: widget.supportedDragDevices, + onTap: widget.onTap == null ? null : onTap, + onPanStart: (event) => onDragPanStart(event, dragTopLeftWorld), + onPanUpdate: (event) => onDragPanUpdate(event, dragTopLeftWorld), + onPanEnd: onDragPanEnd, + onPanCancel: onDragPanCancel, + ), + ), ); } + // --- Corner handles (at visually-rotated corners, axis-aligned) -------- + final List handleWidgets = []; + // When rotatable, CornerHandleWidget wraps its resize GestureDetector in + // a Padding(EdgeInsets.all(gestureGap)), so its event.localPosition is + // reported relative to the inner resize zone, not the outer widget. + // The rotation GestureDetector is NOT inside that Padding — it fills the + // whole outer zone — so its event.localPosition is relative to the outer. + // NOTE: the current CornerHandleWidget Padding is symmetric (centered), + // which only matches the anchor math when `handleAlignment == center`. + // `rotatable: true` currently requires center alignment; non-center + // alignments would put the inner resize zone off the box corner. + assert( + !widget.rotatable || widget.handleAlignment == HandleAlignment.center, + 'rotatable: true currently requires handleAlignment == center. ' + 'Non-center alignments under rotation are not yet implemented ' + '(CornerHandleWidget pads the inner resize zone symmetrically).', + ); + final double gestureGap = + widget.rotatable ? (outerHandleSize - handleTap) / 2 : 0.0; + if (widget.resizable) { + for (final handle in HandlePosition.corners) { + final bool visible = widget.visibleHandles.contains(handle); + final bool enabled = widget.enabledHandles.contains(handle); + if (!visible && !enabled) continue; + final Offset outerTopLeftWorld = RotatedLayout.handleTopLeftInWorld( + rect: rect, + handle: handle, + rotation: rotation, + handleSize: outerHandleSize, + alignment: widget.handleAlignment, + ); + // Inner resize zone's top-left in world. For rotatable=false, + // gestureGap == 0 and innerTopLeftWorld == outerTopLeftWorld. + final Offset innerTopLeftWorld = + outerTopLeftWorld + Offset(gestureGap, gestureGap); + handleWidgets.add(Positioned( + left: outerTopLeftWorld.dx - origin.dx, + top: outerTopLeftWorld.dy - origin.dy, + width: outerHandleSize, + height: outerHandleSize, + child: CornerHandleWidget( + key: ValueKey(handle), + handlePosition: handle, + handleTapSize: handleTap, + rotationHandleGestureSize: outerHandleSize, + rotatable: widget.rotatable, + rotation: rotation, + supportedDevices: widget.supportedResizeDevices, + enabled: enabled, + visible: visible, + // Resize callbacks: use inner top-left (event.localPosition is + // local to the inner padded resize zone). + onPanStart: (event) => + onHandlePanStart(event, handle, innerTopLeftWorld), + onPanUpdate: (event) => + onHandlePanUpdate(event, handle, innerTopLeftWorld), + onPanEnd: (event) => onHandlePanEnd(event, handle), + onPanCancel: () => onHandlePanCancel(handle), + // Rotation callbacks: use outer top-left (event.localPosition is + // local to the full 64x64 outer zone). + onRotationStart: widget.rotatable + ? (event) => + onHandleRotateStart(event, handle, outerTopLeftWorld) + : null, + onRotationUpdate: widget.rotatable + ? (event) => + onHandleRotateUpdate(event, handle, outerTopLeftWorld) + : null, + onRotationEnd: widget.rotatable + ? (event) => onHandleRotateEnd(event, handle) + : null, + onRotationCancel: + widget.rotatable ? () => onHandleRotateCancel(handle) : null, + builder: widget.cornerHandleBuilder, + cursorResolver: widget.cursorResolver, + debugPaintHandleBounds: widget.debugPaintHandleBounds, + ), + )); + } + // Side handles: rendered under any rotation. The rotated resize + // methods route side handles through `_buildSideRotatedRect` with + // the appropriate locked dimension per mode. + for (final handle in HandlePosition.sides) { + final bool visible = widget.visibleHandles.contains(handle); + final bool enabled = widget.enabledHandles.contains(handle); + if (!visible && !enabled) continue; + // Strip's natural (unrotated) AABB along the rect's edge. + final Rect sideRect = RotatedLayout.sideHandleRectInWorld( + rect, + handle, + handleTapSize: handleTap, + alignment: widget.handleAlignment, + ); + // Place the strip's rotation pivot at its rotated edge midpoint in + // world space. The strip widget itself is laid out axis-aligned at + // (pivot − size/2, pivot + size/2), then a Transform.rotate around + // its local center realigns it with the rotated edge. + final Offset stripCenterWorld = rotation == 0.0 + ? sideRect.center + : RotatedLayout.rotateOffsetAround( + sideRect.center, rect.center, rotation); + final Size stripSize = sideRect.size; + final Offset sideTopLeftWorld = + stripCenterWorld - stripSize.center(Offset.zero); + + Widget stripWidget = SideHandleWidget( + key: ValueKey(handle), + handlePosition: handle, + handleTapSize: handleTap, + supportedDevices: widget.supportedResizeDevices, + enabled: enabled, + visible: visible, + onPanStart: (event) => onHandlePanStart( + event, + handle, + sideTopLeftWorld, + rotation: rotation, + rotationPivotWorld: stripCenterWorld, + widgetSize: stripSize, + ), + onPanUpdate: (event) => + onHandlePanUpdate(event, handle, sideTopLeftWorld), + onPanEnd: (event) => onHandlePanEnd(event, handle), + onPanCancel: () => onHandlePanCancel(handle), + builder: widget.sideHandleBuilder, + cursorResolver: widget.cursorResolver, + debugPaintHandleBounds: widget.debugPaintHandleBounds, + ); + if (rotation != 0.0) { + stripWidget = Transform.rotate( + angle: rotation, + alignment: Alignment.center, + child: stripWidget, + ); + } + handleWidgets.add(Positioned( + left: sideTopLeftWorld.dx - origin.dx, + top: sideTopLeftWorld.dy - origin.dy, + width: stripSize.width, + height: stripSize.height, + child: stripWidget, + )); + } + } + + // --- Debug overlays (kDebugMode only; in paintRect-local coords) ------- + final List overlays = []; + if (kDebugMode && widget.debugShowUnrotatedRect) { + overlays.add(Positioned( + left: rect.left - origin.dx, + top: rect.top - origin.dy, + width: rect.width, + height: rect.height, + child: IgnorePointer( + child: Container( + decoration: BoxDecoration( + border: Border.all(color: Colors.green, width: 2), + ), + ), + ), + )); + } + if (kDebugMode && widget.debugShowBoundingRect) { + overlays.add(Positioned( + left: boundingRect.left - origin.dx, + top: boundingRect.top - origin.dy, + width: boundingRect.width, + height: boundingRect.height, + child: IgnorePointer( + child: Container( + decoration: BoxDecoration( + border: Border.all(color: Colors.red, width: 3), + ), + ), + ), + )); + } + if (kDebugMode && + widget.debugShowRotationArrows && + _rotationArrowPointer != null) { + overlays.add(Positioned.fill( + child: IgnorePointer( + child: CustomPaint( + painter: RenderRotationArrows( + // controller.initialLocalPosition is now in world (parent) frame. + initialPosition: controller.initialLocalPosition - origin, + currentPosition: _rotationArrowPointer! - origin, + rectCenter: rect.center - origin, + ), + ), + ), + )); + } + return Positioned.fromRect( - rect: rect.inflate(widget.handleAlignment.offset(widget.handleTapSize)), + rect: paintRect, child: Stack( clipBehavior: Clip.none, - fit: StackFit.expand, children: [ - Positioned( - left: widget.handleAlignment.offset(widget.handleTapSize), - top: widget.handleAlignment.offset(widget.handleTapSize), - width: rect.width, - height: rect.height, - child: content, - ), - if (widget.resizable) - for (final handle in HandlePosition.corners.where((handle) => - widget.visibleHandles.contains(handle) || - widget.enabledHandles.contains(handle))) - CornerHandleWidget( - key: ValueKey(handle), - handlePosition: handle, - handleTapSize: widget.handleTapSize, - supportedDevices: widget.supportedResizeDevices, - enabled: widget.enabledHandles.contains(handle), - visible: widget.visibleHandles.contains(handle), - onPanStart: (event) => onHandlePanStart(event, handle), - onPanUpdate: (event) => onHandlePanUpdate(event, handle), - onPanEnd: (event) => onHandlePanEnd(event, handle), - onPanCancel: () => onHandlePanCancel(handle), - builder: widget.cornerHandleBuilder, - ), - if (widget.resizable) - for (final handle in HandlePosition.sides.where((handle) => - widget.visibleHandles.contains(handle) || - widget.enabledHandles.contains(handle))) - SideHandleWidget( - key: ValueKey(handle), - handlePosition: handle, - handleTapSize: widget.handleTapSize, - supportedDevices: widget.supportedResizeDevices, - enabled: widget.enabledHandles.contains(handle), - visible: widget.visibleHandles.contains(handle), - onPanStart: (event) => onHandlePanStart(event, handle), - onPanUpdate: (event) => onHandlePanUpdate(event, handle), - onPanEnd: (event) => onHandlePanEnd(event, handle), - onPanCancel: () => onHandlePanCancel(handle), - builder: widget.sideHandleBuilder, - ), + contentLayer, + if (dragLayer != null) dragLayer, + ...handleWidgets, + ...overlays, ], ), ); } } -/// A default implementation of the corner [HandleBuilder] callback. -Widget _defaultCornerHandleBuilder( - BuildContext context, - HandlePosition handle, -) => - DefaultCornerHandle(handle: handle); - -/// A default implementation of the side [HandleBuilder] callback. -Widget _defaultSideHandleBuilder( - BuildContext context, - HandlePosition handle, -) => - DefaultSideHandle(handle: handle); +/// Debug painter that renders vectors from the box center to the initial +/// rotation pointer position and the current rotation pointer position. +/// Red = initial, blue = current. +class RenderRotationArrows extends CustomPainter { + /// Pointer position when the rotation gesture started. + final Offset initialPosition; + + /// Current pointer position during the rotation gesture. + final Offset currentPosition; + + /// Center of the rotated box (in the same coordinate space as the pointer + /// positions). + final Offset rectCenter; + + /// Creates a [RenderRotationArrows] painter. + const RenderRotationArrows({ + required this.initialPosition, + required this.currentPosition, + required this.rectCenter, + }); + + @override + void paint(Canvas canvas, Size size) { + final Paint paint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 3 + ..strokeCap = StrokeCap.round; + + // Initial (red). + paint.color = Colors.red; + canvas.drawLine(rectCenter, initialPosition, paint); + canvas.drawCircle(initialPosition, 6, paint); + + // Current (blue). + paint.color = Colors.blue; + canvas.drawLine(rectCenter, currentPosition, paint); + canvas.drawCircle(currentPosition, 6, paint); + } + + @override + bool shouldRepaint(covariant RenderRotationArrows oldDelegate) => + oldDelegate.initialPosition != initialPosition || + oldDelegate.currentPosition != currentPosition || + oldDelegate.rectCenter != rectCenter; +} diff --git a/packages/flutter_box_transform/lib/src/transformable_box_controller.dart b/packages/flutter_box_transform/lib/src/transformable_box_controller.dart index 3fbf427..819b767 100644 --- a/packages/flutter_box_transform/lib/src/transformable_box_controller.dart +++ b/packages/flutter_box_transform/lib/src/transformable_box_controller.dart @@ -2,38 +2,11 @@ import 'package:box_transform/box_transform.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'extensions.dart'; +import 'rotated_layout.dart'; import 'ui_box_transform.dart'; import 'ui_result.dart'; -/// Default [ResizeModeResolver] implementation. This implementation -/// doesn't rely on the focus system .It resolves the [ResizeMode] based on -/// the pressed keys on the keyboard from the -/// [WidgetsBinding.keyboard.logicalKeysPressed] hence it only works on -/// hardware keyboards. -/// -/// If you want to use it on soft keyboards, you can -/// implement your own [ResizeModeResolver] and pass it to the -/// [TransformableBoxController] constructor. -ResizeMode defaultResizeModeResolver() { - final pressedKeys = WidgetsBinding.instance.keyboard.logicalKeysPressed; - - final isAltPressed = pressedKeys.contains(LogicalKeyboardKey.altLeft) || - pressedKeys.contains(LogicalKeyboardKey.altRight); - - final isShiftPressed = pressedKeys.contains(LogicalKeyboardKey.shiftLeft) || - pressedKeys.contains(LogicalKeyboardKey.shiftRight); - - if (isAltPressed && isShiftPressed) { - return ResizeMode.symmetricScale; - } else if (isAltPressed) { - return ResizeMode.symmetric; - } else if (isShiftPressed) { - return ResizeMode.scale; - } else { - return ResizeMode.freeform; - } -} - /// A controller class that is used to control the [TransformableBox] widget. class TransformableBoxController extends ChangeNotifier { /// Creates a [TransformableBoxController] instance. @@ -44,12 +17,49 @@ class TransformableBoxController extends ChangeNotifier { BoxConstraints? constraints, ValueGetter? resizeModeResolver, bool allowFlippingWhileResizing = true, + double rotation = 0.0, + BindingStrategy bindingStrategy = BindingStrategy.boundingBox, }) : _rect = rect ?? Rect.zero, _flip = flip ?? Flip.none, _clampingRect = clampingRect ?? Rect.largest, _constraints = constraints ?? const BoxConstraints(), _resizeModeResolver = resizeModeResolver ?? defaultResizeModeResolver, - _allowFlippingWhileResizing = allowFlippingWhileResizing; + _allowFlippingWhileResizing = allowFlippingWhileResizing, + _rotation = rotation, + _bindingStrategy = bindingStrategy { + _boundingRect = ClampHelpers.calculateBoundingRect( + (rect ?? Rect.zero).toBox(rotation: rotation), + ).toRect(); + } + + /// Default [ResizeModeResolver] implementation. This implementation + /// doesn't rely on the focus system. It resolves the [ResizeMode] based on + /// the pressed keys on the keyboard from the + /// [WidgetsBinding.keyboard.logicalKeysPressed] hence it only works on + /// hardware keyboards. + /// + /// If you want to use it on soft keyboards, you can + /// implement your own [ResizeModeResolver] and pass it to the + /// [TransformableBoxController] constructor. + static ResizeMode defaultResizeModeResolver() { + final pressedKeys = WidgetsBinding.instance.keyboard.logicalKeysPressed; + + final isAltPressed = pressedKeys.contains(LogicalKeyboardKey.altLeft) || + pressedKeys.contains(LogicalKeyboardKey.altRight); + + final isShiftPressed = pressedKeys.contains(LogicalKeyboardKey.shiftLeft) || + pressedKeys.contains(LogicalKeyboardKey.shiftRight); + + if (isAltPressed && isShiftPressed) { + return ResizeMode.symmetricScale; + } else if (isAltPressed) { + return ResizeMode.symmetric; + } else if (isShiftPressed) { + return ResizeMode.scale; + } else { + return ResizeMode.freeform; + } + } /// The callback function that is used to resolve the [ResizeMode] based on /// the pressed keys on the keyboard. @@ -89,6 +99,45 @@ class TransformableBoxController extends ChangeNotifier { /// The initial [Flip] of the [TransformableBox] when the resizing starts. Flip get initialFlip => _initialFlip; + /// The current rotation (radians) of the [TransformableBox], clockwise around + /// the box center. + double _rotation = 0.0; + + /// The current rotation (radians) of the [TransformableBox]. + double get rotation => _rotation; + + /// The initial rotation (radians) of the [TransformableBox] when a gesture + /// starts. Used as the base for additive rotation updates. + double _initialRotation = 0.0; + + /// The initial rotation captured at the start of a rotation gesture. + double get initialRotation => _initialRotation; + + /// The current axis-aligned bounding rect of the rotated box. Equals [rect] + /// when [rotation] is 0. + Rect _boundingRect = Rect.zero; + + /// The axis-aligned bounding rect of the rotated [rect]. Read-only. + Rect get boundingRect => _boundingRect; + + /// The [BindingStrategy] controlling whether size constraints and clamping + /// apply to the unrotated [rect] or the rendered [boundingRect]. + BindingStrategy _bindingStrategy = BindingStrategy.boundingBox; + + /// The current [BindingStrategy]. + BindingStrategy get bindingStrategy => _bindingStrategy; + + /// The rect that the solver enforces against [clampingRect] under the + /// current [bindingStrategy]. For [BindingStrategy.boundingBox] this + /// equals [boundingRect]; for [BindingStrategy.originalBox] it equals + /// [rect] (rotated corners may extend outside the clamp). + Rect get effectiveContainmentRect => + RotatedLayout.computeEffectiveContainmentRect( + rect: rect, + rotation: rotation, + bindingStrategy: bindingStrategy, + ); + /// The box that limits the dragging and resizing of the [TransformableBox] inside /// its bounds. Rect _clampingRect = Rect.largest; @@ -147,6 +196,42 @@ class TransformableBoxController extends ChangeNotifier { if (notify) notifyListeners(); } + /// Sets the current [rotation] (radians). Also refreshes [boundingRect]. + void setRotation(double rotation, {bool notify = true}) { + _rotation = rotation; + _boundingRect = + ClampHelpers.calculateBoundingRect(_rect.toBox(rotation: _rotation)) + .toRect(); + if (notify) notifyListeners(); + } + + /// Sets the initial [rotation] captured at the start of a gesture. + void setInitialRotation(double initialRotation, {bool notify = true}) { + _initialRotation = initialRotation; + if (notify) notifyListeners(); + } + + /// Sets the [BindingStrategy] used for rotated clamping/constraints. + /// + /// After the assignment, runs [recalculatePosition] (translate-only, + /// `notify: false`) so the box is reconciled against the new strategy's + /// clamp semantics. This handles the case where the box was valid under + /// the previous strategy but violates the new one (e.g. switching from + /// `originalBox` to `boundingBox` with the rotated bounding extending + /// outside the clamp). We intentionally do NOT run `recalculateSize` - + /// translation-only keeps size invariant; users can resize manually if + /// they want the box to shrink to fit. + void setBindingStrategy(BindingStrategy bindingStrategy, + {bool notify = true}) { + if (_bindingStrategy == bindingStrategy) { + if (notify) notifyListeners(); + return; + } + _bindingStrategy = bindingStrategy; + recalculatePosition(notify: false); + if (notify) notifyListeners(); + } + /// Sets the initial local position of the [TransformableBox]. void setInitialLocalPosition(Offset initialLocalPosition, {bool notify = true}) { @@ -170,6 +255,23 @@ class TransformableBoxController extends ChangeNotifier { } /// Sets the current [clampingRect] of the [TransformableBox]. + /// + /// [recalculate] controls whether the controller immediately reconciles + /// the box against the new clamp by running both [recalculatePosition] + /// and [recalculateSize]. The default is `true` for backwards-compat; + /// it is rarely what you want when updating the clamp in a tight loop. + /// + /// WARNING for external-controller users: when a rotated box is near + /// saturation against the clamp, the internal [recalculateSize] (zero- + /// delta, [ResizeMode.scale]) picks the largest (w, h) that fits the + /// new clamp — strictly smaller than the current box by a sliver. Call + /// it per tick while the clamp shrinks and the slivers accumulate into + /// a visible shrink + off-edge flick. Pass `recalculate: false` and + /// then explicitly call [recalculatePosition] (translation-only) when + /// driving clamp updates from a parent/model re-injection loop. + /// + /// `TransformableBox.didUpdateWidget` does exactly that for the + /// internal-controller path. void setClampingRect( Rect clampingRect, { bool notify = true, @@ -228,9 +330,12 @@ class TransformableBoxController extends ChangeNotifier { initialLocalPosition: initialLocalPosition, localPosition: localPosition, clampingRect: clampingRect, + rotation: _rotation, + bindingStrategy: _bindingStrategy, ); _rect = result.rect; + _boundingRect = result.boundingRect; if (notify) notifyListeners(); @@ -290,13 +395,48 @@ class TransformableBoxController extends ChangeNotifier { clampingRect: clampingRect, constraints: constraints, allowFlipping: allowFlippingWhileResizing, + rotation: _rotation, + bindingStrategy: _bindingStrategy, ); - _rect = result.rect; - _flip = result.flip; + // Hold last-feasible state on infeasible ticks. The engine is + // stateless and returns initialRect (gesture-start) as a sentinel + // when it can't satisfy clamp + constraints — but consumers that + // bind their UI to result.rect (e.g. flutter widgets in onChanged + // callbacks) would visibly snap the rect back to gesture-start on + // every infeasible tick. To make the freeze visible all the way to + // the renderer, we override the result with the controller's + // current (last feasible) state before returning. Future ticks + // still recompute from initialRect (gesture-start) so reversing the + // drag direction unfreezes naturally. + if (result.feasible) { + _rect = result.rect; + _flip = result.flip; + _boundingRect = result.boundingRect; + if (notify) notifyListeners(); + return result; + } + final frozen = UIResizeResult( + rect: _rect, + oldRect: result.oldRect, + flip: _flip, + resizeMode: result.resizeMode, + delta: result.delta, + rawSize: Size(_rect.width, _rect.height), + minWidthReached: result.minWidthReached, + minHeightReached: result.minHeightReached, + maxWidthReached: result.maxWidthReached, + maxHeightReached: result.maxHeightReached, + largestRect: result.largestRect, + handle: result.handle, + rotation: result.rotation, + boundingRect: _boundingRect, + oldBoundingRect: result.oldBoundingRect, + feasible: false, + ); if (notify) notifyListeners(); - return result; + return frozen; } /// Called when the resizing ends on [TransformableBox]. @@ -308,6 +448,68 @@ class TransformableBoxController extends ChangeNotifier { if (notify) notifyListeners(); } + /// Called when a rotation gesture starts. + /// + /// [localPosition] is the position of the pointer relative to the + /// [TransformableBox]. Captures the current [rotation] as [initialRotation]. + void onRotateStart(Offset localPosition) { + _initialLocalPosition = localPosition; + _initialRect = rect; + _initialRotation = rotation; + } + + /// Called during a rotation gesture. + /// + /// [localPosition] is the current pointer position (same coord space as + /// [onRotateStart]). [handle] identifies which corner is being dragged. + UIRotateResult onRotateUpdate( + Offset localPosition, + HandlePosition handle, { + bool notify = true, + }) { + final UIRotateResult result = UIBoxTransform.rotate( + initialRect: initialRect, + initialLocalPosition: initialLocalPosition, + localPosition: localPosition, + initialRotation: initialRotation, + clampingRect: clampingRect, + bindingStrategy: bindingStrategy, + ); + // Freeze on infeasible: leave _rotation/_rect at their last feasible + // values AND override result.rect/result.rotation so consumers binding + // their UI to the callback (e.g. `box.rotation = result.rotation`) see + // last-feasible too instead of the engine's gesture-start sentinel. + if (!result.feasible) { + return UIRotateResult( + rect: _rect, + oldRect: result.oldRect, + delta: result.delta, + rawSize: Size(_rect.width, _rect.height), + largestRect: result.largestRect, + rotation: _rotation, + boundingRect: _boundingRect, + oldBoundingRect: result.oldBoundingRect, + feasible: false, + ); + } + _rotation = result.rotation; + _rect = result.rect; + _boundingRect = result.boundingRect; + if (notify) notifyListeners(); + return result; + } + + /// Called when a rotation gesture ends. + void onRotateEnd({bool notify = true}) { + _initialLocalPosition = Offset.zero; + _initialRect = Rect.zero; + _initialRotation = 0.0; + if (notify) notifyListeners(); + } + + /// Called when a rotation gesture is cancelled. + void onRotateCancel({bool notify = true}) => onRotateEnd(notify: notify); + /// Called when the resizing of the [TransformableBox] is cancelled. void onResizeCancel({bool notify = true}) => onResizeEnd(notify: notify); @@ -319,9 +521,12 @@ class TransformableBoxController extends ChangeNotifier { initialLocalPosition: initialLocalPosition, localPosition: initialLocalPosition, clampingRect: clampingRect, + rotation: _rotation, + bindingStrategy: _bindingStrategy, ); _rect = result.rect; + _boundingRect = result.boundingRect; if (notify) notifyListeners(); } @@ -339,9 +544,12 @@ class TransformableBoxController extends ChangeNotifier { initialFlip: initialFlip, constraints: constraints, allowFlipping: allowFlippingWhileResizing, + rotation: _rotation, + bindingStrategy: _bindingStrategy, ); _rect = result.rect; + _boundingRect = result.boundingRect; if (notify) notifyListeners(); } diff --git a/packages/flutter_box_transform/lib/src/typedefs.dart b/packages/flutter_box_transform/lib/src/typedefs.dart index dce46d0..19b06a0 100644 --- a/packages/flutter_box_transform/lib/src/typedefs.dart +++ b/packages/flutter_box_transform/lib/src/typedefs.dart @@ -3,6 +3,32 @@ import 'package:flutter/widgets.dart'; import 'ui_result.dart'; +/// Distinguishes the gesture zone a [MouseCursor] is being requested for. +/// +/// Corner handles expose two zones when `rotatable` is true: an inner resize +/// zone ([HandleCursorKind.resize]) and an outer rotation ring +/// ([HandleCursorKind.rotation]). Side handles only expose a resize zone. +enum HandleCursorKind { + /// Cursor for the resize gesture zone (inner zone on corner handles, the + /// full strip on side handles). + resize, + + /// Cursor for the rotation gesture ring around a corner handle. + rotation, +} + +/// Resolves the [MouseCursor] to display over a handle's gesture zone. +/// +/// Return `null` to fall back to the package defaults: +/// - resize handles use the appropriate `SystemMouseCursors.resize*` +/// - rotation rings reuse the diagonal resize cursor (Flutter has no native +/// rotation cursor; see [`custom_mouse_cursor`](https://pub.dev/packages/custom_mouse_cursor) +/// if you want to ship a real rotate glyph) +typedef HandleCursorResolver = MouseCursor? Function( + HandlePosition handle, + HandleCursorKind kind, +); + /// A callback that expects a [Widget] that represents any of the handles. /// The [handle] is the current position and size of the handle. typedef HandleBuilder = Widget Function( @@ -88,3 +114,26 @@ typedef TerminalEvent = void Function( bool reachedMinHeight, bool reachedMaxHeight, ); + +/// A callback that is called when a rotation gesture begins on a handle. +typedef RectRotateStart = void Function( + HandlePosition handle, + DragStartDetails event, +); + +/// A callback that is called during a rotation gesture. +typedef RectRotateUpdateEvent = void Function( + UIRotateResult result, + DragUpdateDetails event, +); + +/// A callback that is called when a rotation gesture ends. +typedef RectRotateEnd = void Function( + HandlePosition handle, + DragEndDetails event, +); + +/// A callback that is called when a rotation gesture is cancelled. +typedef RectRotateCancel = void Function( + HandlePosition handle, +); diff --git a/packages/flutter_box_transform/lib/src/ui_box_transform.dart b/packages/flutter_box_transform/lib/src/ui_box_transform.dart index 55df739..c33affb 100644 --- a/packages/flutter_box_transform/lib/src/ui_box_transform.dart +++ b/packages/flutter_box_transform/lib/src/ui_box_transform.dart @@ -22,9 +22,12 @@ class UIBoxTransform { ui.Rect clampingRect = ui.Rect.largest, widgets.BoxConstraints constraints = const widgets.BoxConstraints(), bool allowFlipping = true, + double rotation = 0.0, + transform.BindingStrategy bindingStrategy = + transform.BindingStrategy.boundingBox, }) => transform.BoxTransformer.resize( - initialRect: initialRect.toBox(), + initialRect: initialRect.toBox(rotation: rotation), initialLocalPosition: initialLocalPosition.toVector2(), localPosition: localPosition.toVector2(), handle: handle, @@ -33,6 +36,8 @@ class UIBoxTransform { clampingRect: clampingRect.toBox(), constraints: constraints.toConstraints(), allowFlipping: allowFlipping, + rotation: rotation, + bindingStrategy: bindingStrategy, ).toUI(); /// The Flutter wrapper for [transform.BoxTransformer.move]. @@ -41,11 +46,35 @@ class UIBoxTransform { required ui.Offset initialLocalPosition, required ui.Offset localPosition, ui.Rect clampingRect = ui.Rect.largest, + double rotation = 0.0, + transform.BindingStrategy bindingStrategy = + transform.BindingStrategy.boundingBox, }) => transform.BoxTransformer.move( - initialRect: initialRect.toBox(), + initialRect: initialRect.toBox(rotation: rotation), initialLocalPosition: initialLocalPosition.toVector2(), localPosition: localPosition.toVector2(), clampingRect: clampingRect.toBox(), + rotation: rotation, + bindingStrategy: bindingStrategy, + ).toUI(); + + /// The Flutter wrapper for [transform.BoxTransformer.rotate]. + static UIRotateResult rotate({ + required ui.Rect initialRect, + required ui.Offset initialLocalPosition, + required ui.Offset localPosition, + required double initialRotation, + ui.Rect clampingRect = ui.Rect.largest, + transform.BindingStrategy bindingStrategy = + transform.BindingStrategy.boundingBox, + }) => + transform.BoxTransformer.rotate( + initialRect: initialRect.toBox(rotation: initialRotation), + initialLocalPosition: initialLocalPosition.toVector2(), + localPosition: localPosition.toVector2(), + initialRotation: initialRotation, + clampingRect: clampingRect.toBox(), + bindingStrategy: bindingStrategy, ).toUI(); } diff --git a/packages/flutter_box_transform/lib/src/ui_result.dart b/packages/flutter_box_transform/lib/src/ui_result.dart index da80f39..17bf91d 100644 --- a/packages/flutter_box_transform/lib/src/ui_result.dart +++ b/packages/flutter_box_transform/lib/src/ui_result.dart @@ -13,3 +13,7 @@ typedef UIResizeResult = ResizeResult; /// A convenient type alias for a [TransformResult] with Flutter's [Rect], /// [Offset] and [Size] types. typedef UITransformResult = TransformResult; + +/// A convenient type alias for a [RotateResult] with Flutter's [Rect], +/// [Offset] and [Size] types. +typedef UIRotateResult = RotateResult; diff --git a/packages/flutter_box_transform/playground/assets/images/image1.jpg b/packages/flutter_box_transform/playground/assets/images/image1.jpg index 2010bc6..5e23faa 100644 Binary files a/packages/flutter_box_transform/playground/assets/images/image1.jpg and b/packages/flutter_box_transform/playground/assets/images/image1.jpg differ diff --git a/packages/flutter_box_transform/playground/assets/images/image10.jpg b/packages/flutter_box_transform/playground/assets/images/image10.jpg deleted file mode 100644 index 0d18c66..0000000 Binary files a/packages/flutter_box_transform/playground/assets/images/image10.jpg and /dev/null differ diff --git a/packages/flutter_box_transform/playground/assets/images/image4.jpg b/packages/flutter_box_transform/playground/assets/images/image4.jpg index 5e23faa..e7115d6 100644 Binary files a/packages/flutter_box_transform/playground/assets/images/image4.jpg and b/packages/flutter_box_transform/playground/assets/images/image4.jpg differ diff --git a/packages/flutter_box_transform/playground/assets/images/image5.jpg b/packages/flutter_box_transform/playground/assets/images/image5.jpg index e7115d6..0e5bd05 100644 Binary files a/packages/flutter_box_transform/playground/assets/images/image5.jpg and b/packages/flutter_box_transform/playground/assets/images/image5.jpg differ diff --git a/packages/flutter_box_transform/playground/assets/images/image6.jpg b/packages/flutter_box_transform/playground/assets/images/image6.jpg index 0e5bd05..dd66a38 100644 Binary files a/packages/flutter_box_transform/playground/assets/images/image6.jpg and b/packages/flutter_box_transform/playground/assets/images/image6.jpg differ diff --git a/packages/flutter_box_transform/playground/assets/images/image7.jpg b/packages/flutter_box_transform/playground/assets/images/image7.jpg index dd66a38..f25a8ca 100644 Binary files a/packages/flutter_box_transform/playground/assets/images/image7.jpg and b/packages/flutter_box_transform/playground/assets/images/image7.jpg differ diff --git a/packages/flutter_box_transform/playground/assets/images/image8.jpg b/packages/flutter_box_transform/playground/assets/images/image8.jpg index f25a8ca..e38c51c 100644 Binary files a/packages/flutter_box_transform/playground/assets/images/image8.jpg and b/packages/flutter_box_transform/playground/assets/images/image8.jpg differ diff --git a/packages/flutter_box_transform/playground/assets/images/image9.jpg b/packages/flutter_box_transform/playground/assets/images/image9.jpg index e38c51c..0d18c66 100644 Binary files a/packages/flutter_box_transform/playground/assets/images/image9.jpg and b/packages/flutter_box_transform/playground/assets/images/image9.jpg differ diff --git a/packages/flutter_box_transform/playground/lib/main.dart b/packages/flutter_box_transform/playground/lib/main.dart index b9271d4..3ff2a73 100644 --- a/packages/flutter_box_transform/playground/lib/main.dart +++ b/packages/flutter_box_transform/playground/lib/main.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:developer'; import 'dart:math' hide log; @@ -8,14 +9,16 @@ import 'package:flutter/services.dart'; import 'package:flutter_box_transform/flutter_box_transform.dart'; import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher_string.dart'; -import 'package:vector_math/vector_math.dart' hide Colors; +import 'package:vector_math/vector_math_64.dart' hide Colors; import 'resources/asset_icons.dart'; import 'resources/images.dart'; import 'test_recorder.dart'; -bool showTestRecorder = - const bool.fromEnvironment('test_recorder', defaultValue: false); +bool showTestRecorder = const bool.fromEnvironment( + 'test_recorder', + defaultValue: true, +); void main() { runApp(const MyApp()); @@ -38,11 +41,11 @@ class MyApp extends StatelessWidget { colorSchemeSeed: Colors.blue, inputDecorationTheme: InputDecorationTheme( isDense: true, - contentPadding: - const EdgeInsets.symmetric(horizontal: 10, vertical: 12), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(6), + contentPadding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 12, ), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(6)), ), ), dark: ThemeData( @@ -51,11 +54,11 @@ class MyApp extends StatelessWidget { colorSchemeSeed: Colors.blue, inputDecorationTheme: InputDecorationTheme( isDense: true, - contentPadding: - const EdgeInsets.symmetric(horizontal: 10, vertical: 12), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(6), + contentPadding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 12, ), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(6)), ), ), initial: AdaptiveThemeMode.system, @@ -84,20 +87,27 @@ class PlaygroundModel with ChangeNotifier { int selectedBoxIndex = -1; + // Debug visualization toggles — all default off for clean UX. + bool debugShowBoundingRect = false; + bool debugShowUnrotatedRect = false; + bool debugShowRotationArrows = false; + bool debugPaintHandleBounds = false; + BoxData? get selectedBox => selectedBoxIndex != -1 ? boxes[selectedBoxIndex] : null; - void reset(BuildContext context) { + Future reset(BuildContext context) async { final Size size = MediaQuery.of(context).size; final double width = size.width - kSidePanelWidth - kBoxesPanelWidth; final double height = size.height; - clampingRect = Rect.fromLTWH( - 0, - 0, - width, - size.height, - ); + clampingRect = Rect.fromLTWH(0, 0, width, size.height); + + final Size imageSize = await _resolveImageSize(Images.image1); + final double scale = + kInitialMaxSide / max(imageSize.width, imageSize.height); + final double boxWidth = imageSize.width * scale; + final double boxHeight = imageSize.height * scale; boxes.clear(); boxes.add( @@ -105,10 +115,10 @@ class PlaygroundModel with ChangeNotifier { name: 'Box 1', imageAsset: Images.image1, rect: Rect.fromLTWH( - (width - kInitialWidth) / 2, - (height - kInitialHeight) / 2, - kInitialWidth, - kInitialHeight, + (width - boxWidth) / 2, + (height - boxHeight) / 2, + boxWidth, + boxHeight, ), flip: Flip.none, constraintsEnabled: true, @@ -171,16 +181,23 @@ class PlaygroundModel with ChangeNotifier { if (notify) notifyListeners(); } - void addNewBox() { + Future addNewBox() async { + final String asset = Images.values[boxes.length % Images.values.length]; + final Size imageSize = await _resolveImageSize(asset); + final double scale = + kInitialMaxSide / max(imageSize.width, imageSize.height); + final double width = imageSize.width * scale; + final double height = imageSize.height * scale; + boxes.add( BoxData( name: 'Box ${boxes.length + 1}', - imageAsset: Images.values[boxes.length % Images.values.length], + imageAsset: asset, rect: Rect.fromLTWH( - playgroundArea!.center.dx - kInitialWidth / 2, - playgroundArea!.center.dy - kInitialHeight / 2, - kInitialWidth, - kInitialHeight, + playgroundArea!.center.dx - width / 2, + playgroundArea!.center.dy - height / 2, + width, + height, ), flip: Flip.none, ), @@ -235,6 +252,48 @@ class PlaygroundModel with ChangeNotifier { notifyListeners(); } + // --- Rotation controls --------------------------------------------------- + + void setRotation(double radians) { + if (selectedBoxIndex == -1) return; + selectedBox!.rotation = radians; + notifyListeners(); + } + + void toggleRotatable(bool enabled) { + if (selectedBoxIndex == -1) return; + selectedBox!.rotatable = enabled; + notifyListeners(); + } + + void setBindingStrategy(BindingStrategy strategy) { + if (selectedBoxIndex == -1) return; + selectedBox!.bindingStrategy = strategy; + notifyListeners(); + } + + // --- Debug toggles ------------------------------------------------------- + + void toggleDebugShowBoundingRect(bool v) { + debugShowBoundingRect = v; + notifyListeners(); + } + + void toggleDebugShowUnrotatedRect(bool v) { + debugShowUnrotatedRect = v; + notifyListeners(); + } + + void toggleDebugShowRotationArrows(bool v) { + debugShowRotationArrows = v; + notifyListeners(); + } + + void toggleDebugPaintHandleBounds(bool v) { + debugPaintHandleBounds = v; + notifyListeners(); + } + void toggleResizing(bool enabled) { if (selectedBoxIndex == -1) return; selectedBox!.resizable = enabled; @@ -359,7 +418,7 @@ class PlaygroundModel with ChangeNotifier { notifyListeners(); } - void onReorder(int oldIndex, int newIndex) { + void onReorderItem(int oldIndex, int newIndex) { if (oldIndex < newIndex) { newIndex -= 1; } @@ -367,8 +426,9 @@ class PlaygroundModel with ChangeNotifier { final BoxData box = boxes.removeAt(oldIndex); boxes.insert(newIndex, box); if (selectedBox != null) { - selectedBoxIndex = - boxes.indexWhere((box) => box.name == selectedBox.name); + selectedBoxIndex = boxes.indexWhere( + (box) => box.name == selectedBox.name, + ); } notifyListeners(); @@ -386,9 +446,36 @@ const double kSidePanelWidth = 280; const double kBoxesPanelWidth = 250; const double kInitialWidth = 400; const double kInitialHeight = 300; +const double kInitialMaxSide = 400; const double kStrokeWidth = 1.5; const Color kGridColor = Color.fromARGB(126, 27, 181, 228); +Future _resolveImageSize(String asset) { + final completer = Completer(); + final ImageStream stream = AssetImage( + asset, + ).resolve(ImageConfiguration.empty); + late final ImageStreamListener listener; + listener = ImageStreamListener( + (ImageInfo info, bool _) { + if (!completer.isCompleted) { + completer.complete( + Size(info.image.width.toDouble(), info.image.height.toDouble()), + ); + } + stream.removeListener(listener); + }, + onError: (Object error, StackTrace? _) { + if (!completer.isCompleted) { + completer.complete(const Size(kInitialWidth, kInitialHeight)); + } + stream.removeListener(listener); + }, + ); + stream.addListener(listener); + return completer.future; +} + class _PlaygroundState extends State with WidgetsBindingObserver { Set pressedKeys = {}; @@ -438,8 +525,10 @@ class _PlaygroundState extends State with WidgetsBindingObserver { 0, 0, max(size.width - kSidePanelWidth - kBoxesPanelWidth, 100), - max(size.height, - 100), // safe size for when window is resized extremely small. + max( + size.height, + 100, + ), // safe size for when window is resized extremely small. ); final Rect playgroundArea = model.playgroundArea!; @@ -562,16 +651,20 @@ class _ImageBoxState extends State { UITransformResult? lastResult; bool get isShiftPressed => - WidgetsBinding.instance.keyboard.logicalKeysPressed - .contains(LogicalKeyboardKey.shiftLeft) || - WidgetsBinding.instance.keyboard.logicalKeysPressed - .contains(LogicalKeyboardKey.shiftRight); + WidgetsBinding.instance.keyboard.logicalKeysPressed.contains( + LogicalKeyboardKey.shiftLeft, + ) || + WidgetsBinding.instance.keyboard.logicalKeysPressed.contains( + LogicalKeyboardKey.shiftRight, + ); bool get isAltPressed => - WidgetsBinding.instance.keyboard.logicalKeysPressed - .contains(LogicalKeyboardKey.altLeft) || - WidgetsBinding.instance.keyboard.logicalKeysPressed - .contains(LogicalKeyboardKey.altRight); + WidgetsBinding.instance.keyboard.logicalKeysPressed.contains( + LogicalKeyboardKey.altLeft, + ) || + WidgetsBinding.instance.keyboard.logicalKeysPressed.contains( + LogicalKeyboardKey.altRight, + ); ResizeMode get currentResizeMode { if (isShiftPressed && isAltPressed) { @@ -587,14 +680,21 @@ class _ImageBoxState extends State { @override Widget build(BuildContext context) { - final PlaygroundModel model = context.read(); + final PlaygroundModel model = context.watch(); final Color handleColor = Theme.of(context).colorScheme.primary; return TransformableBox( key: ValueKey('image-box-${box.name}'), rect: box.rect, flip: box.flip, + rotation: box.rotation, + rotatable: widget.selected && box.rotatable, + bindingStrategy: box.bindingStrategy, clampingRect: model.clampingEnabled ? model.clampingRect : null, constraints: box.constraintsEnabled ? box.constraints : null, + debugShowBoundingRect: model.debugShowBoundingRect, + debugShowUnrotatedRect: model.debugShowUnrotatedRect, + debugShowRotationArrows: model.debugShowRotationArrows, + debugPaintHandleBounds: model.debugPaintHandleBounds, onChanged: (result, event) { widget.onChanged?.call(result); largestClampingBox = result.largestRect; @@ -602,6 +702,22 @@ class _ImageBoxState extends State { lastResult = result; model.onRectChanged(result); }, + onResizeUpdate: (result, event) { + if (!showTestRecorder || + currentAction == null || + !recorder.isRecording) { + return; + } + recorder.onUpdate( + action: currentAction!, + localPosition: event.localPosition, + result: result, + ); + }, + onRotationUpdate: (result, event) { + box.rotation = result.rotation; + setState(() {}); + }, resizable: widget.selected && box.resizable, visibleHandles: !widget.selected || !box.resizable ? {} : box.visibleHandles, @@ -623,21 +739,18 @@ class _ImageBoxState extends State { clampingRect: model.clampingEnabled ? model.clampingRect : null, constraints: box.constraintsEnabled ? box.constraints : null, flipRect: box.flipRectWhileResizing, + rotation: box.rotation, + bindingStrategy: box.bindingStrategy, ); }, onResizeEnd: (handle, event) { if (!showTestRecorder || currentAction == null || - !recorder.isRecording || - lastResult == null) { + !recorder.isRecording) { return; } - log('Recording resize action result'); - // recorder.onResult( - // action: currentAction!, - // result: lastResult!, - // localPosition: event.localPosition, - // ); + log('Resize gesture ended (${currentAction!.id})'); + currentAction = null; }, onTerminalSizeReached: ( bool reachedMinWidth, @@ -763,10 +876,27 @@ class _ClampingRectState extends State { label = 'Clamping Box'; } - final minWidth = model.boxes.fold(0.0, - (previousValue, element) => max(previousValue, element.rect.width)); - final minHeight = model.boxes.fold(0.0, - (previousValue, element) => max(previousValue, element.rect.height)); + // Size the clamping minimum per each box's strategy: + // boundingBox: at least the rotated bounding rect (rendered footprint). + // originalBox: at least the unrotated rect (rotated corners may poke + // out of the clamp and that's OK under originalBox semantics). + // Fold over boxes taking the max so the clamp fits every box at once. + final minWidth = model.boxes.fold(0.0, (previousValue, element) { + final ecr = RotatedLayout.computeEffectiveContainmentRect( + rect: element.rect, + rotation: element.rotation, + bindingStrategy: element.bindingStrategy, + ); + return max(previousValue, ecr.width); + }); + final minHeight = model.boxes.fold(0.0, (previousValue, element) { + final ecr = RotatedLayout.computeEffectiveContainmentRect( + rect: element.rect, + rotation: element.rotation, + bindingStrategy: element.bindingStrategy, + ); + return max(previousValue, ecr.height); + }); return TransformableBox( key: const ValueKey('clamping-box'), @@ -797,36 +927,25 @@ class _ClampingRectState extends State { }); }, handleAlignment: HandleAlignment.inside, - cornerHandleBuilder: (context, handle) => AngularHandle( - handle: handle, - color: mainColor, - hasShadow: false, - ), - sideHandleBuilder: (context, handle) => AngularHandle( - handle: handle, - color: mainColor, - hasShadow: false, - ), + cornerHandleBuilder: (context, handle) => + AngularHandle(handle: handle, color: mainColor, hasShadow: false), + sideHandleBuilder: (context, handle) => + AngularHandle(handle: handle, color: mainColor, hasShadow: false), contentBuilder: (context, _, flip) => Container( width: model.clampingRect.width, height: model.clampingRect.height, alignment: Alignment.bottomRight, decoration: BoxDecoration( border: Border.symmetric( - horizontal: BorderSide( - color: horizontalEdgeColor, - width: 1.5, - ), - vertical: BorderSide( - color: verticalEdgeColor, - width: 1.5, - ), + horizontal: BorderSide(color: horizontalEdgeColor, width: 1.5), + vertical: BorderSide(color: verticalEdgeColor, width: 1.5), ), ), child: DecoratedBox( decoration: BoxDecoration( - color: - (anyTerminalSize ? Colors.orange : mainColor).withValues(alpha: 0.1), + color: (anyTerminalSize ? Colors.orange : mainColor).withValues( + alpha: 0.1, + ), borderRadius: const BorderRadius.only( topLeft: Radius.circular(6), ), @@ -878,7 +997,8 @@ class ControlPanel extends StatelessWidget { IconButton( tooltip: 'Open on GitHub', onPressed: () => launchUrlString( - 'https://github.com/birjuvachhani/rect_resizer'), + 'https://github.com/birjuvachhani/rect_resizer', + ), icon: const Icon(Icons.open_in_new_rounded), ), const SizedBox(width: 8), @@ -941,6 +1061,10 @@ class ControlPanel extends StatelessWidget { const Divider(height: 1), const ConstraintsControls(), const Divider(height: 1), + const RotationControls(), + const Divider(height: 1), + const DebugControls(), + const Divider(height: 1), ] else ...[ const SizedBox(height: 44), Text( @@ -996,10 +1120,7 @@ class BoxesPanel extends StatelessWidget { padding: const EdgeInsets.fromLTRB(16, 0, 4, 0), child: Row( children: [ - const SectionHeader( - 'Boxes', - padding: EdgeInsets.zero, - ), + const SectionHeader('Boxes', padding: EdgeInsets.zero), Text( ' • ${model.boxes.length}', style: TextStyle( @@ -1043,7 +1164,7 @@ class BoxesPanel extends StatelessWidget { if (model.boxes.isNotEmpty) ReorderableListView.builder( itemCount: model.boxes.length, - onReorder: model.onReorder, + onReorderItem: model.onReorderItem, reverse: true, shrinkWrap: true, buildDefaultDragHandles: false, @@ -1055,7 +1176,9 @@ class BoxesPanel extends StatelessWidget { key: ValueKey(box.name), child: Container( color: box.name == model.selectedBox?.name - ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.2) + ? Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.2) : null, child: ListTile( title: Text(box.name), @@ -1109,13 +1232,9 @@ class PositionControls extends StatelessWidget { const SizedBox(height: 8), Row( children: [ - Expanded( - child: ValueText(box.rect.left.floor().toString()), - ), + Expanded(child: ValueText(box.rect.left.floor().toString())), const SizedBox(width: 16), - Expanded( - child: ValueText(box.rect.top.floor().toString()), - ), + Expanded(child: ValueText(box.rect.top.floor().toString())), ], ), const SizedBox(height: 16), @@ -1129,9 +1248,7 @@ class PositionControls extends StatelessWidget { const SizedBox(height: 8), Row( children: [ - Expanded( - child: ValueText(box.rect.right.floor().toString()), - ), + Expanded(child: ValueText(box.rect.right.floor().toString())), const SizedBox(width: 16), Expanded( child: ValueText(box.rect.bottom.floor().toString()), @@ -1149,9 +1266,7 @@ class PositionControls extends StatelessWidget { const SizedBox(height: 8), Row( children: [ - Expanded( - child: ValueText(box.rect.width.toStringAsFixed(0)), - ), + Expanded(child: ValueText(box.rect.width.toStringAsFixed(0))), const SizedBox(width: 16), Expanded( child: ValueText(box.rect.height.toStringAsFixed(0)), @@ -1259,10 +1374,9 @@ class _HandleControlsState extends State { Expanded( child: Text( 'Enabled Handles', - style: Theme.of(context) - .textTheme - .titleSmall! - .copyWith( + style: Theme.of( + context, + ).textTheme.titleSmall!.copyWith( color: Theme.of(context).colorScheme.secondary, ), @@ -1328,19 +1442,21 @@ class _HandleControlsState extends State { }, child: Padding( padding: const EdgeInsets.only( - left: 36, top: 4, bottom: 4), + left: 36, + top: 4, + bottom: 4, + ), child: Row( children: [ Expanded( child: Text( handle.prettify, - style: Theme.of(context) - .textTheme - .labelMedium - ?.copyWith( - color: Theme.of(context) - .colorScheme - .secondary, + style: Theme.of( + context, + ).textTheme.labelMedium?.copyWith( + color: Theme.of( + context, + ).colorScheme.secondary, ), ), ), @@ -1349,8 +1465,9 @@ class _HandleControlsState extends State { child: Transform.scale( scale: 0.5, child: Switch( - value: box.enabledHandles - .contains(handle), + value: box.enabledHandles.contains( + handle, + ), onChanged: (bool? value) { if (value == null) return; if (value) { @@ -1380,10 +1497,9 @@ class _HandleControlsState extends State { Expanded( child: Text( 'Visible Handles', - style: Theme.of(context) - .textTheme - .titleSmall! - .copyWith( + style: Theme.of( + context, + ).textTheme.titleSmall!.copyWith( color: Theme.of(context).colorScheme.secondary, ), @@ -1449,19 +1565,21 @@ class _HandleControlsState extends State { }, child: Padding( padding: const EdgeInsets.only( - left: 36, top: 4, bottom: 4), + left: 36, + top: 4, + bottom: 4, + ), child: Row( children: [ Expanded( child: Text( handle.prettify, - style: Theme.of(context) - .textTheme - .labelMedium - ?.copyWith( - color: Theme.of(context) - .colorScheme - .secondary, + style: Theme.of( + context, + ).textTheme.labelMedium?.copyWith( + color: Theme.of( + context, + ).colorScheme.secondary, ), ), ), @@ -1470,8 +1588,9 @@ class _HandleControlsState extends State { child: Transform.scale( scale: 0.5, child: Switch( - value: box.visibleHandles - .contains(handle), + value: box.visibleHandles.contains( + handle, + ), onChanged: (bool? value) { if (value == null) return; if (value) { @@ -1544,10 +1663,9 @@ class FlipControls extends StatelessWidget { widthFactor: 0.9, child: Text( 'Allows to flip the rect while resizing. The actual contents of the rect won\'t be flipped.', - style: Theme.of(context) - .textTheme - .bodySmall! - .copyWith( + style: Theme.of( + context, + ).textTheme.bodySmall!.copyWith( color: Theme.of(context).colorScheme.secondary, ), ), @@ -1594,10 +1712,9 @@ class FlipControls extends StatelessWidget { widthFactor: 0.9, child: Text( 'Flip the contents of the rect when it is flipped.', - style: Theme.of(context) - .textTheme - .bodySmall! - .copyWith( + style: Theme.of( + context, + ).textTheme.bodySmall!.copyWith( color: Theme.of(context).colorScheme.secondary, ), ), @@ -1631,10 +1748,7 @@ class FlipControls extends StatelessWidget { model.flipVertically(); } }, - isSelected: [ - box.flip.isHorizontal, - box.flip.isVertical, - ], + isSelected: [box.flip.isHorizontal, box.flip.isVertical], selectedColor: Theme.of(context).colorScheme.primary, constraints: const BoxConstraints.tightFor(height: 32), children: const [ @@ -1714,14 +1828,18 @@ class _ClampingControlsState extends State { @override void initState() { super.initState(); - leftController = - TextEditingController(text: model.clampingRect.left.toStringAsFixed(0)); - topController = - TextEditingController(text: model.clampingRect.top.toStringAsFixed(0)); + leftController = TextEditingController( + text: model.clampingRect.left.toStringAsFixed(0), + ); + topController = TextEditingController( + text: model.clampingRect.top.toStringAsFixed(0), + ); bottomController = TextEditingController( - text: model.clampingRect.bottom.toStringAsFixed(0)); + text: model.clampingRect.bottom.toStringAsFixed(0), + ); rightController = TextEditingController( - text: model.clampingRect.right.toStringAsFixed(0)); + text: model.clampingRect.right.toStringAsFixed(0), + ); model.addListener(onModelChanged); } @@ -1775,10 +1893,9 @@ class _ClampingControlsState extends State { Expanded( child: Text( 'CLAMPING', - style: Theme.of(context) - .textTheme - .titleSmall! - .copyWith( + style: Theme.of( + context, + ).textTheme.titleSmall!.copyWith( color: Theme.of(context).colorScheme.secondary, ), @@ -1954,14 +2071,18 @@ class _ConstraintsControlsState extends State { @override void initState() { super.initState(); - minWidthController = - TextEditingController(text: formatted(box.constraints.minWidth)); - minHeightController = - TextEditingController(text: formatted(box.constraints.minHeight)); - maxHeightController = - TextEditingController(text: formatted(box.constraints.maxHeight)); - maxWidthController = - TextEditingController(text: formatted(box.constraints.maxWidth)); + minWidthController = TextEditingController( + text: formatted(box.constraints.minWidth), + ); + minHeightController = TextEditingController( + text: formatted(box.constraints.minHeight), + ); + maxHeightController = TextEditingController( + text: formatted(box.constraints.maxHeight), + ); + maxWidthController = TextEditingController( + text: formatted(box.constraints.maxWidth), + ); model.addListener(onModelChanged); } @@ -2025,10 +2146,9 @@ class _ConstraintsControlsState extends State { Expanded( child: Text( 'CONSTRAINTS', - style: Theme.of(context) - .textTheme - .titleSmall! - .copyWith( + style: Theme.of( + context, + ).textTheme.titleSmall!.copyWith( color: Theme.of(context).colorScheme.secondary, ), @@ -2169,10 +2289,12 @@ class _ConstraintsControlsState extends State { const SizedBox(height: 16), FilledButton.tonalIcon( onPressed: () { - model.setConstraints(const BoxConstraints( - minWidth: double.infinity, - minHeight: double.infinity, - )); + model.setConstraints( + const BoxConstraints( + minWidth: double.infinity, + minHeight: double.infinity, + ), + ); }, icon: const Icon(Icons.refresh), label: const Text('Reset'), @@ -2224,8 +2346,9 @@ class KeyboardListenerIndicator extends StatelessWidget { ), boxShadow: [ BoxShadow( - color: - Theme.of(context).colorScheme.onPrimary.withValues(alpha: 0.2), + color: Theme.of( + context, + ).colorScheme.onPrimary.withValues(alpha: 0.2), blurRadius: 1, offset: const Offset(1, 3), ), @@ -2244,10 +2367,7 @@ class KeyboardListenerIndicator extends StatelessWidget { onPressed: onClear, splashRadius: 16, iconSize: 18, - icon: Icon( - Icons.clear, - color: Colors.grey.shade400, - ), + icon: Icon(Icons.clear, color: Colors.grey.shade400), ), ], ); @@ -2291,10 +2411,7 @@ class ValueText extends StatelessWidget { @override Widget build(BuildContext context) { - return Text( - value, - style: Theme.of(context).textTheme.titleMedium, - ); + return Text(value, style: Theme.of(context).textTheme.titleMedium); } } @@ -2303,12 +2420,7 @@ class SectionHeader extends StatelessWidget { final EdgeInsets? padding; final double? height; - const SectionHeader( - this.title, { - super.key, - this.padding, - this.height, - }); + const SectionHeader(this.title, {super.key, this.padding, this.height}); @override Widget build(BuildContext context) { @@ -2326,6 +2438,163 @@ class SectionHeader extends StatelessWidget { } } +class RotationControls extends StatelessWidget { + const RotationControls({super.key}); + + @override + Widget build(BuildContext context) { + final model = context.watch(); + final box = model.selectedBox; + if (box == null) return const SizedBox.shrink(); + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Rotation', + style: Theme.of(context).textTheme.titleSmall!.copyWith( + color: Theme.of(context).colorScheme.secondary, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + const Text('Rotatable'), + const Spacer(), + SizedBox( + height: 20, + child: Transform.scale( + scale: 0.7, + child: Switch( + value: box.rotatable, + onChanged: (v) => model.toggleRotatable(v), + ), + ), + ), + ], + ), + const SizedBox(height: 4), + Row( + children: [ + const Text('Angle'), + const SizedBox(width: 8), + Expanded( + child: Slider( + min: -pi, + max: pi, + value: box.rotation.clamp(-pi, pi).toDouble(), + onChanged: (v) => model.setRotation(v), + ), + ), + SizedBox( + width: 48, + child: Text( + '${(box.rotation * 180 / pi).toStringAsFixed(0)}\u00B0', + textAlign: TextAlign.right, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Row( + children: [ + const Text('Binding'), + const SizedBox(width: 8), + Expanded( + child: DropdownButton( + value: box.bindingStrategy, + isDense: true, + isExpanded: true, + items: const [ + DropdownMenuItem( + value: BindingStrategy.originalBox, + child: Text('Original Box'), + ), + DropdownMenuItem( + value: BindingStrategy.boundingBox, + child: Text('Bounding Box'), + ), + ], + onChanged: (v) { + if (v != null) model.setBindingStrategy(v); + }, + ), + ), + ], + ), + ], + ), + ); + } +} + +class DebugControls extends StatelessWidget { + const DebugControls({super.key}); + + @override + Widget build(BuildContext context) { + final model = context.watch(); + + Widget toggleRow(String label, bool value, ValueChanged onChanged) { + return Row( + children: [ + Expanded( + child: Text(label, style: Theme.of(context).textTheme.bodySmall), + ), + SizedBox( + height: 20, + child: Transform.scale( + scale: 0.7, + child: Switch(value: value, onChanged: onChanged), + ), + ), + ], + ); + } + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Debug Visualizations', + style: Theme.of(context).textTheme.titleSmall!.copyWith( + color: Theme.of(context).colorScheme.secondary, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + toggleRow( + 'Bounding rect (red)', + model.debugShowBoundingRect, + model.toggleDebugShowBoundingRect, + ), + toggleRow( + 'Unrotated rect (green)', + model.debugShowUnrotatedRect, + model.toggleDebugShowUnrotatedRect, + ), + toggleRow( + 'Rotation arrows', + model.debugShowRotationArrows, + model.toggleDebugShowRotationArrows, + ), + toggleRow( + 'Handle gesture regions', + model.debugPaintHandleBounds, + model.toggleDebugPaintHandleBounds, + ), + ], + ), + ); + } +} + class BoxData { final String name; Rect rect = Rect.zero; @@ -2339,6 +2608,9 @@ class BoxData { bool constraintsEnabled = false; bool resizable = true; bool draggable = true; + bool rotatable = false; + double rotation = 0.0; + BindingStrategy bindingStrategy = BindingStrategy.boundingBox; Set enabledHandles; Set visibleHandles; @@ -2357,6 +2629,9 @@ class BoxData { this.constraintsEnabled = false, this.draggable = true, this.resizable = true, + this.rotatable = false, + this.rotation = 0.0, + this.bindingStrategy = BindingStrategy.boundingBox, Set? enabledHandles, Set? visibleHandles, }) : enabledHandles = enabledHandles ?? {...HandlePosition.values} @@ -2367,7 +2642,15 @@ class BoxData { List? extendLineToRect(Rect rect, Vector2 p1, Vector2 p2) { final result = extendLinePointsToRectPoints( - rect.left, rect.top, rect.right, rect.bottom, p1.x, p1.y, p2.x, p2.y); + rect.left, + rect.top, + rect.right, + rect.bottom, + p1.x, + p1.y, + p2.x, + p2.y, + ); if (result == null) return null; diff --git a/packages/flutter_box_transform/playground/lib/resources/images.dart b/packages/flutter_box_transform/playground/lib/resources/images.dart index 6780cb7..937e67d 100644 --- a/packages/flutter_box_transform/playground/lib/resources/images.dart +++ b/packages/flutter_box_transform/playground/lib/resources/images.dart @@ -2,7 +2,6 @@ class Images { Images._(); static const String image1 = 'assets/images/image1.jpg'; - static const String image10 = 'assets/images/image10.jpg'; static const String image2 = 'assets/images/image2.jpg'; static const String image3 = 'assets/images/image3.jpg'; static const String image4 = 'assets/images/image4.jpg'; @@ -14,7 +13,6 @@ class Images { static const List values = [ image1, - image10, image2, image3, image4, @@ -22,6 +20,6 @@ class Images { image6, image7, image8, - image9 + image9, ]; } diff --git a/packages/flutter_box_transform/playground/lib/test_recorder.dart b/packages/flutter_box_transform/playground/lib/test_recorder.dart index e770744..1aaca0e 100644 --- a/packages/flutter_box_transform/playground/lib/test_recorder.dart +++ b/packages/flutter_box_transform/playground/lib/test_recorder.dart @@ -38,6 +38,8 @@ class TestRecorder with ChangeNotifier { required HandlePosition handle, required Offset cursorPosition, required bool flipRect, + required double rotation, + required BindingStrategy bindingStrategy, Rect? clampingRect, BoxConstraints? constraints, }) { @@ -50,22 +52,36 @@ class TestRecorder with ChangeNotifier { clampingBox: clampingRect, constraints: constraints, flipRect: flipRect, + rotation: rotation, + bindingStrategy: bindingStrategy, ); current.add(TestRecord(action: action)); return action; } - void onResult({ + // Append a single onResizeUpdate tick to the matching record. The recorder + // captures every tick of a gesture so the exported test can replay the + // same micro-delta sequence — the bug we chase often only manifests + // tick-by-tick, not on a single direct jump from start to end. + void onUpdate({ required TestAction action, required Offset localPosition, required UITransformResult result, }) { final record = current.firstWhere((item) => item.action.id == action.id); - record.result = result; - record.localPosition = localPosition; - notifyListeners(); + record.ticks.add(TestTick(localPosition: localPosition, result: result)); } + // Legacy: kept for callers that only push a final result. Implemented as + // a tick append so old call-sites still produce a valid (single-tick) + // recording. + void onResult({ + required TestAction action, + required Offset localPosition, + required UITransformResult result, + }) => + onUpdate(action: action, localPosition: localPosition, result: result); + void stopRecording({String? saveAs}) { if (current.isNotEmpty) { tests[saveAs ?? testBeingRecorded ?? 'Test ${tests.length + 1}'] = [ @@ -81,14 +97,16 @@ class TestRecorder with ChangeNotifier { class TestRecord { final TestAction action; - UITransformResult? result; - Offset? localPosition; + final List ticks = []; - TestRecord({ - required this.action, - this.result, - this.localPosition, - }); + TestRecord({required this.action}); +} + +class TestTick { + final Offset localPosition; + final UITransformResult result; + + TestTick({required this.localPosition, required this.result}); } class TestAction with EquatableMixin { @@ -101,6 +119,8 @@ class TestAction with EquatableMixin { final Rect? clampingBox; final BoxConstraints? constraints; final bool flipRect; + final double rotation; + final BindingStrategy bindingStrategy; TestAction({ required this.resizeMode, @@ -111,6 +131,8 @@ class TestAction with EquatableMixin { this.clampingBox, this.constraints, this.flipRect = false, + this.rotation = 0.0, + this.bindingStrategy = BindingStrategy.boundingBox, }) : id = DateTime.now().millisecondsSinceEpoch; @override @@ -124,6 +146,8 @@ class TestAction with EquatableMixin { clampingBox, constraints, flipRect, + rotation, + bindingStrategy, ]; } @@ -289,10 +313,11 @@ class _TestRecorderUIState extends State { if (settings == null) return; - final path = await FilePicker.platform.saveFile( + final path = await FilePicker.saveFile( dialogTitle: 'Export tests', fileName: 'resizing_test.dart', allowedExtensions: ['.dart'], + type: FileType.custom, ); if (path == null) return; @@ -301,6 +326,7 @@ class _TestRecorderUIState extends State { roundValues: settings['roundValues'] == true, withTolerance: settings['withTolerance'] == true, ); + log('Exporting tests to $path with settings: $settings'); } catch (error, stacktrace) { log(error.toString()); log(stacktrace.toString()); @@ -317,7 +343,10 @@ class _TestRecorderUIState extends State { final List tests = []; for (final record in recorder.tests.entries) { final name = record.key; - final records = record.value; + // Skip records that captured no ticks (recorder paused mid-gesture + // before any onResizeUpdate fired). + final records = record.value.where((r) => r.ticks.isNotEmpty).toList(); + if (records.isEmpty) continue; final contents = []; for (var index = 0; index < records.length; index++) { final record = records[index]; @@ -340,7 +369,7 @@ class _TestRecorderUIState extends State { final testFile = ''' import 'package:test/test.dart'; - import 'package:vector_math/vector_math.dart'; + import 'package:vector_math/vector_math_64.dart'; import 'package:box_transform/box_transform.dart'; ${withTolerance ? "import 'utils.dart';" : ''} @@ -349,7 +378,9 @@ class _TestRecorderUIState extends State { } '''; - final formatted = DartFormatter().format(testFile); + final formatted = DartFormatter( + languageVersion: DartFormatter.latestLanguageVersion, + ).format(testFile); final dir = Directory('tests'); if (!await dir.exists()) await dir.create(); @@ -397,13 +428,6 @@ class _TestRecorderUIState extends State { final String width = formattedValue(record.action.box.width); final String height = formattedValue(record.action.box.height); - String matcher = - 'Box.fromLTWH(${formattedValue(record.result!.rect.left)}, ${formattedValue(record.result!.rect.top)}, ${formattedValue(record.result!.rect.width)}, ${formattedValue(record.result!.rect.height)})'; - - if (withTolerance) { - matcher = 'withTolerance($matcher)'; - } - String? clampingRect; String? constraints; @@ -417,24 +441,54 @@ class _TestRecorderUIState extends State { 'Constraints(minWidth: ${formattedValue(record.action.constraints!.minWidth)}, minHeight: ${formattedValue(record.action.constraints!.minHeight)}, maxWidth: ${formattedValue(record.action.constraints!.maxWidth)}, maxHeight: ${formattedValue(record.action.constraints!.maxHeight)})'; } + // Emit the tick cursor sequence. The replay loop calls + // `BoxTransformer.resize` once per tick — the engine is stateless so + // every tick uses the SAME initialRect/initialLocalPosition (gesture + // start), with only `localPosition` varying. This is exactly how + // TransformableBoxController calls the engine during a real gesture. + final tickList = record.ticks + .map((t) => + 'Vector2(${formattedValue(t.localPosition.dx)}, ${formattedValue(t.localPosition.dy)})') + .join(',\n '); + + final hasClamp = clampingRect != null; + buffer.writeln(''' - - ${index == 0 ? 'var ' : ''}result = BoxTransformer.resize( - resizeMode: ${record.action.resizeMode}, - initialFlip: ${record.action.flip}, - initialBox: Box.fromLTWH($left, $top, $width, $height), - handle: ${record.action.handle}, - initialLocalPosition: Vector2(${formattedValue(record.action.position.dx)}, ${formattedValue(record.action.position.dy)}), - flipRect: ${record.action.flipRect}, - localPosition: Vector2(${formattedValue(record.localPosition!.dx)}, ${formattedValue(record.localPosition!.dy)}), - ${clampingRect != null ? 'clampingRect: $clampingRect,' : ''} - ${constraints != null ? 'constraints: $constraints,' : ''} - ); - - expect(result.flip, ${record.result!.flip}); - expect(result.rect${roundValues ? '.floor()' : ''}, $matcher); - expect(result.resizeMode, ${record.result!.resizeMode}); - + + ${index == 0 ? 'final ' : ''}initialRect = Box.fromLTWH($left, $top, $width, $height); + ${index == 0 ? 'final ' : ''}initialCursor = Vector2(${formattedValue(record.action.position.dx)}, ${formattedValue(record.action.position.dy)}); + ${index == 0 ? 'final ' : ''}cursors = [ + $tickList, + ]; + ${index == 0 ? 'late RawResizeResult ' : ''}result; + for (var i = 0; i < cursors.length; i++) { + result = BoxTransformer.resize( + resizeMode: ${record.action.resizeMode}, + initialFlip: ${record.action.flip}, + initialRect: initialRect, + handle: ${record.action.handle}, + initialLocalPosition: initialCursor, + allowFlipping: ${record.action.flipRect}, + rotation: ${record.action.rotation}, + bindingStrategy: ${record.action.bindingStrategy}, + localPosition: cursors[i], + ${hasClamp ? 'clampingRect: $clampingRect,' : ''} + ${constraints != null ? 'constraints: $constraints,' : ''} + ); + ${hasClamp ? ''' + // Invariant: rect must stay inside clamp at every tick. + final clamp = $clampingRect; + expect(result.rect.left, greaterThanOrEqualTo(clamp.left - 1e-3), + reason: 'tick \$i: rect.left=\${result.rect.left} leaked clamp.left=\${clamp.left}'); + expect(result.rect.top, greaterThanOrEqualTo(clamp.top - 1e-3), + reason: 'tick \$i: rect.top=\${result.rect.top} leaked clamp.top=\${clamp.top}'); + expect(result.rect.right, lessThanOrEqualTo(clamp.right + 1e-3), + reason: 'tick \$i: rect.right=\${result.rect.right} leaked clamp.right=\${clamp.right}'); + expect(result.rect.bottom, lessThanOrEqualTo(clamp.bottom + 1e-3), + reason: 'tick \$i: rect.bottom=\${result.rect.bottom} leaked clamp.bottom=\${clamp.bottom}'); + ''' : ''} + } + '''); return buffer.toString(); diff --git a/packages/flutter_box_transform/playground/macos/Podfile b/packages/flutter_box_transform/playground/macos/Podfile index 049abe2..9ec46f8 100644 --- a/packages/flutter_box_transform/playground/macos/Podfile +++ b/packages/flutter_box_transform/playground/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '10.14' +platform :osx, '10.15' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/packages/flutter_box_transform/playground/macos/Runner.xcodeproj/project.pbxproj b/packages/flutter_box_transform/playground/macos/Runner.xcodeproj/project.pbxproj index 2bf17b0..cbc4062 100644 --- a/packages/flutter_box_transform/playground/macos/Runner.xcodeproj/project.pbxproj +++ b/packages/flutter_box_transform/playground/macos/Runner.xcodeproj/project.pbxproj @@ -406,7 +406,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -485,7 +485,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -532,7 +532,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; diff --git a/packages/flutter_box_transform/playground/macos/Runner/DebugProfile.entitlements b/packages/flutter_box_transform/playground/macos/Runner/DebugProfile.entitlements index d35e43a..f8697e3 100644 --- a/packages/flutter_box_transform/playground/macos/Runner/DebugProfile.entitlements +++ b/packages/flutter_box_transform/playground/macos/Runner/DebugProfile.entitlements @@ -4,5 +4,7 @@ com.apple.security.cs.allow-jit + com.apple.security.files.user-selected.read-write + diff --git a/packages/flutter_box_transform/playground/macos/Runner/Release.entitlements b/packages/flutter_box_transform/playground/macos/Runner/Release.entitlements index 0c67376..080e1e0 100644 --- a/packages/flutter_box_transform/playground/macos/Runner/Release.entitlements +++ b/packages/flutter_box_transform/playground/macos/Runner/Release.entitlements @@ -1,5 +1,8 @@ - + + com.apple.security.files.user-selected.read-write + + diff --git a/packages/flutter_box_transform/playground/macos/Runner/RunnerDebug.entitlements b/packages/flutter_box_transform/playground/macos/Runner/RunnerDebug.entitlements index d35e43a..f8697e3 100644 --- a/packages/flutter_box_transform/playground/macos/Runner/RunnerDebug.entitlements +++ b/packages/flutter_box_transform/playground/macos/Runner/RunnerDebug.entitlements @@ -4,5 +4,7 @@ com.apple.security.cs.allow-jit + com.apple.security.files.user-selected.read-write + diff --git a/packages/flutter_box_transform/playground/macos/RunnerTests/RunnerTests.swift b/packages/flutter_box_transform/playground/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/packages/flutter_box_transform/playground/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/packages/flutter_box_transform/playground/pubspec.yaml b/packages/flutter_box_transform/playground/pubspec.yaml index 39ecf8c..e326283 100644 --- a/packages/flutter_box_transform/playground/pubspec.yaml +++ b/packages/flutter_box_transform/playground/pubspec.yaml @@ -5,26 +5,28 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev version: 0.1.0+1 environment: - sdk: '>=3.0.0 <4.0.0' - flutter: ">=3.3.0" + sdk: ^3.6.0 + flutter: ">=3.27.0" + +resolution: workspace dependencies: flutter: sdk: flutter - adaptive_theme: ^3.6.0 + adaptive_theme: ^3.7.0 url_launcher: ^6.3.0 - provider: ^6.1.2 - equatable: ^2.0.5 - dart_style: ^2.3.6 + provider: ^6.1.5 + equatable: ^2.0.7 + dart_style: ^3.1.0 vector_math: ^2.1.4 - file_picker: ^8.0.7 + file_picker: ^11.0.0 flutter_box_transform: path: ../ dev_dependencies: flutter_test: sdk: flutter - flutter_lints: ^4.0.0 + flutter_lints: ^6.0.0 flutter: uses-material-design: true @@ -34,10 +36,10 @@ flutter: - assets/images/image1.jpg - assets/images/image2.jpg - assets/images/image3.jpg + - assets/images/image1.jpg - assets/images/image4.jpg - assets/images/image5.jpg - assets/images/image6.jpg - assets/images/image7.jpg - assets/images/image8.jpg - assets/images/image9.jpg - - assets/images/image10.jpg diff --git a/packages/flutter_box_transform/pubspec.yaml b/packages/flutter_box_transform/pubspec.yaml index f589a0d..14f44c7 100644 --- a/packages/flutter_box_transform/pubspec.yaml +++ b/packages/flutter_box_transform/pubspec.yaml @@ -9,8 +9,10 @@ issue_tracker: https://github.com/hyper-designed/box_transform/issues documentation: https://docs.page/hyper-designed/box_transform environment: - sdk: '>=3.0.0 <4.0.0' - flutter: ">=3.10.0" + sdk: ^3.6.0 + flutter: ">=3.27.0" + +resolution: workspace dependencies: flutter: @@ -21,7 +23,7 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter - flutter_lints: ^5.0.0 + flutter_lints: ^6.0.0 topics: - rectangle diff --git a/packages/flutter_box_transform/test/binding_strategy_helpers_test.dart b/packages/flutter_box_transform/test/binding_strategy_helpers_test.dart new file mode 100644 index 0000000..5127b10 --- /dev/null +++ b/packages/flutter_box_transform/test/binding_strategy_helpers_test.dart @@ -0,0 +1,128 @@ +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_box_transform/flutter_box_transform.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('RotatedLayout.computeEffectiveContainmentRect (pure function)', () { + test('boundingBox returns the rotated AABB for rotated rects', () { + const rect = Rect.fromLTWH(0, 0, 100, 100); + final ecr = RotatedLayout.computeEffectiveContainmentRect( + rect: rect, + rotation: math.pi / 4, + bindingStrategy: BindingStrategy.boundingBox, + ); + expect(ecr.width, closeTo(100 * math.sqrt2, 1e-4)); + expect(ecr.height, closeTo(100 * math.sqrt2, 1e-4)); + // Centered at the same point as the unrotated rect. + expect(ecr.center.dx, closeTo(50, 1e-9)); + expect(ecr.center.dy, closeTo(50, 1e-9)); + }); + + test('originalBox returns the unrotated rect unchanged', () { + const rect = Rect.fromLTWH(10, 20, 100, 80); + final ecr = RotatedLayout.computeEffectiveContainmentRect( + rect: rect, + rotation: math.pi / 3, + bindingStrategy: BindingStrategy.originalBox, + ); + expect(ecr, rect); + }); + + test('theta=0 both strategies equal the rect', () { + const rect = Rect.fromLTWH(5, 5, 80, 60); + for (final s in BindingStrategy.values) { + final ecr = RotatedLayout.computeEffectiveContainmentRect( + rect: rect, + rotation: 0.0, + bindingStrategy: s, + ); + expect(ecr, rect, reason: 'strategy $s at theta=0 must equal rect'); + } + }); + }); + + group('controller.effectiveContainmentRect', () { + test('boundingBox controller returns rotated AABB', () { + final c = TransformableBoxController( + rect: const Rect.fromLTWH(0, 0, 100, 100), + rotation: math.pi / 4, + bindingStrategy: BindingStrategy.boundingBox, + ); + expect(c.effectiveContainmentRect.width, closeTo(100 * math.sqrt2, 1e-4)); + // Same as boundingRect for this strategy. + expect(c.effectiveContainmentRect, c.boundingRect); + }); + + test('originalBox controller returns unrotated rect', () { + final c = TransformableBoxController( + rect: const Rect.fromLTWH(10, 20, 100, 80), + rotation: math.pi / 3, + bindingStrategy: BindingStrategy.originalBox, + ); + expect(c.effectiveContainmentRect, c.rect); + }); + }); + + group('setBindingStrategy reconciliation', () { + test('reconciles position when switching to a tighter strategy', () { + // Box at (0, 0, 100, 100), rotation pi/4. Clamp (-100, -100, 100, 100). + // Under originalBox: rect corners at (0,0), (100,0), (100,100), (0,100) + // — last corner is outside clamp.right=100? (100, 100) is at the + // clamp boundary. For this geometry, originalBox is basically + // at the edge. + // Under boundingBox: rotated bounding half-width = 70.71, center at + // (50, 50). Rotated right-extreme at 50 + 70.71 = 120.71, OUTSIDE + // clamp.right=100. So under boundingBox this config is infeasible + // — switching to boundingBox must translate the box left so the + // rotated bounding fits. + // Target center.x = 100 - 70.71 = 29.29, so rect.left = -20.71. + // By symmetry, center.y = 29.29, rect.top = -20.71. + final c = TransformableBoxController( + rect: const Rect.fromLTWH(0, 0, 100, 100), + rotation: math.pi / 4, + clampingRect: const Rect.fromLTRB(-100, -100, 100, 100), + bindingStrategy: BindingStrategy.originalBox, + ); + // Sanity: initial rect untouched. + expect(c.rect.left, 0); + expect(c.rect.top, 0); + + c.setBindingStrategy(BindingStrategy.boundingBox); + + // Translated to fit rotated bounding in clamp. + expect(c.rect.left, lessThan(-1), + reason: 'box must translate when switching to tighter strategy'); + // Size preserved (translate-only reconciliation, no shrink). + expect(c.rect.width, closeTo(100, 1e-6)); + expect(c.rect.height, closeTo(100, 1e-6)); + }); + + test('no-op when new strategy is already satisfied', () { + // Generous clamp — rect is valid under either strategy; no change. + final c = TransformableBoxController( + rect: const Rect.fromLTWH(0, 0, 100, 100), + rotation: math.pi / 4, + clampingRect: const Rect.fromLTRB(-500, -500, 500, 500), + bindingStrategy: BindingStrategy.originalBox, + ); + final initialRect = c.rect; + c.setBindingStrategy(BindingStrategy.boundingBox); + expect(c.rect.left, initialRect.left); + expect(c.rect.top, initialRect.top); + expect(c.rect.width, initialRect.width); + expect(c.rect.height, initialRect.height); + }); + + test('bindingStrategy field reflects the new value', () { + final c = TransformableBoxController( + rect: const Rect.fromLTWH(0, 0, 100, 100), + bindingStrategy: BindingStrategy.originalBox, + ); + expect(c.bindingStrategy, BindingStrategy.originalBox); + c.setBindingStrategy(BindingStrategy.boundingBox); + expect(c.bindingStrategy, BindingStrategy.boundingBox); + }); + }); +} diff --git a/packages/flutter_box_transform/test/clamp_shrink_continuity_test.dart b/packages/flutter_box_transform/test/clamp_shrink_continuity_test.dart new file mode 100644 index 0000000..6a9766c --- /dev/null +++ b/packages/flutter_box_transform/test/clamp_shrink_continuity_test.dart @@ -0,0 +1,419 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_box_transform/flutter_box_transform.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Reproduces the user report: slowly dragging the playground's clamping +/// rect's right edge toward the box via "aggressive tiny deltas" causes +/// the box to visibly "flick" to an odd offset at the moment the clamp +/// reaches its minimum width (= rotated bounding rect of the box). +/// +/// Root cause: each parent repump with a tighter clamp used to trigger +/// `setClampingRect(..., recalculate: true)` in `didUpdateWidget`, which +/// runs a zero-delta `recalculateSize` under `ResizeMode.scale`. For a +/// rotated box against a tight clamp the scale solver picks the largest +/// (w, h) that fits — strictly smaller than the current box by a sliver. +/// Accumulated over many ticks, the slivers manifest as a visible shrink +/// + off-edge flick. +/// +/// This first test bypasses the widget layer and directly calls +/// `controller.setClampingRect(...)` per tick with `recalculate: true`. +/// It does NOT reproduce the bug on its own (see test 2 for the widget- +/// level reproduction that does). It's retained as a lower-level sanity +/// guard — any regression that leaks into the controller path would +/// flick here too. +void main() { + test( + 'shrinking clamp from the right in 1 px ticks must produce continuous ' + 'box motion — no flicks at the last tick before saturation', + () { + const theta = math.pi / 6; + final c = TransformableBoxController( + rect: const Rect.fromLTWH(200, 200, 100, 100), + rotation: theta, + clampingRect: const Rect.fromLTRB(50, 50, 500, 500), + bindingStrategy: BindingStrategy.boundingBox, + ); + final double initialBoundingW = c.boundingRect.width; + final double initialBoundingH = c.boundingRect.height; + final double minClampRight = 50 + initialBoundingW; + + double prevLeft = c.rect.left; + double prevTop = c.rect.top; + const double tick = 1.0; + + // Shrink until (and including) clamp.width == boundingW (saturation). + // We do NOT over-shrink beyond saturation — the clamp's own controller + // in the playground refuses to shrink below minWidth, so we honour + // that boundary here. + double right = 500.0; + int stepIdx = 0; + while (right > minClampRight + tick * 0.5) { + right -= tick; + c.setClampingRect(Rect.fromLTRB(50, 50, right, 500)); + final dLeft = (c.rect.left - prevLeft).abs(); + final dTop = (c.rect.top - prevTop).abs(); + expect( + dLeft, + lessThanOrEqualTo(tick + 0.01), + reason: 'step $stepIdx (right=$right): box.left jumped by $dLeft ' + '(>tick=$tick) — flick bug', + ); + expect( + dTop, + lessThanOrEqualTo(tick + 0.01), + reason: 'step $stepIdx (right=$right): box.top jumped by $dTop ' + '(>tick=$tick) — flick bug', + ); + prevLeft = c.rect.left; + prevTop = c.rect.top; + stepIdx++; + } + + // Final saturation tick exactly at boundingW. + c.setClampingRect(Rect.fromLTRB(50, 50, minClampRight, 500)); + final dSatLeft = (c.rect.left - prevLeft).abs(); + final dSatTop = (c.rect.top - prevTop).abs(); + expect( + dSatLeft, + lessThanOrEqualTo(tick + 0.01), + reason: 'saturation tick: box.left flicked by $dSatLeft', + ); + expect( + dSatTop, + lessThanOrEqualTo(tick + 0.01), + reason: 'saturation tick: box.top flicked by $dSatTop', + ); + + // Sanity: size preserved throughout (pure translation). + expect(c.rect.width, closeTo(100, 1e-6)); + expect(c.rect.height, closeTo(100, 1e-6)); + expect(c.boundingRect.width, closeTo(initialBoundingW, 1e-6)); + expect(c.boundingRect.height, closeTo(initialBoundingH, 1e-6)); + }, + ); + + testWidgets( + 'WIDGET (user scenario): 396x428 box, rotation ~15°, aggressive shrink', + (tester) async { + // Mirror the playground screenshot: box at (269, 165, 396, 428), + // rotation ~15° (0.26 rad), clampingRect starts wide and shrinks from + // the right. The playground's ClampingRect widget computes minWidth + // from the box's bounding rect, but at each tick the clamp feeds back + // a new rect, so we simulate the exact widget pump. + const theta = 0.26; + const double boxW = 396; + const double boxH = 428; + final boundingW = + boxW * math.cos(theta).abs() + boxH * math.sin(theta).abs(); + + final key = GlobalKey>(); + Future pumpWithClamp(Rect clamp) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 1400, + height: 1200, + child: Stack( + children: [ + TransformableBox( + key: key, + rect: const Rect.fromLTWH(269, 165, boxW, boxH), + rotation: theta, + clampingRect: clamp, + bindingStrategy: BindingStrategy.boundingBox, + contentBuilder: (_, __, ___) => + const ColoredBox(color: Color(0xFF4B9EF4)), + ), + ], + ), + ), + ), + ), + ); + } + + TransformableBoxController readC() => + // ignore: avoid_dynamic_calls + (key.currentState! as dynamic).controller + as TransformableBoxController; + + // Initial clamp spans the whole stage. + await pumpWithClamp(const Rect.fromLTRB(0, 0, 1400, 1200)); + final initW = readC().rect.width; + final initH = readC().rect.height; + double prevLeft = readC().rect.left; + double prevTop = readC().rect.top; + const double tick = 1.0; + + int idx = 0; + // Shrink clamp.right aggressively, going through saturation and beyond. + // Clamp minWidth stop is ~boundingW; test drives it all the way. + for (double right = 1400 - tick; right >= boundingW - 50; right -= tick) { + await pumpWithClamp(Rect.fromLTRB(0, 0, right, 1200)); + final c = readC(); + final dLeft = (c.rect.left - prevLeft).abs(); + final dTop = (c.rect.top - prevTop).abs(); + final dW = (c.rect.width - initW).abs(); + final dH = (c.rect.height - initH).abs(); + expect(dLeft, lessThanOrEqualTo(tick + 0.01), + reason: 'step $idx (right=$right): box.left flicked $dLeft'); + expect(dTop, lessThanOrEqualTo(tick + 0.01), + reason: 'step $idx (right=$right): box.top flicked $dTop'); + expect(dW, lessThanOrEqualTo(0.01), + reason: 'step $idx (right=$right): box.width shrank by $dW ' + '(should be pure translation, no size change)'); + expect(dH, lessThanOrEqualTo(0.01), + reason: 'step $idx (right=$right): box.height shrank by $dH'); + prevLeft = c.rect.left; + prevTop = c.rect.top; + idx++; + } + }, + ); + + testWidgets( + 'WIDGET: progressive clampingRect shrink via pumpWidget — no flick', + (tester) async { + // True widget-level reproduction using the playground's pattern: + // NO external controller — `clampingRect` is passed as a widget arg, + // so each re-pump triggers didUpdateWidget → setClampingRect(...) + // (internal recalc) + recalculatePosition + recalculateSize. + const theta = math.pi / 6; + // Keep a reference to the internal controller via a Global key so we + // can read its rect between pumps. + final key = GlobalKey>(); + + Future pumpWithClamp(Rect clamp) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 800, + height: 600, + child: Stack( + children: [ + TransformableBox( + key: key, + rect: const Rect.fromLTWH(200, 200, 100, 100), + rotation: theta, + clampingRect: clamp, + bindingStrategy: BindingStrategy.boundingBox, + contentBuilder: (_, __, ___) => + const ColoredBox(color: Color(0xFF4B9EF4)), + ), + ], + ), + ), + ), + ), + ); + } + + // Read the internal controller via reflection on the State's field. + TransformableBoxController readController() { + // ignore: avoid_dynamic_calls + return (key.currentState! as dynamic).controller + as TransformableBoxController; + } + + await pumpWithClamp(const Rect.fromLTRB(50, 50, 500, 500)); + double prevLeft = readController().rect.left; + double prevTop = readController().rect.top; + const double tick = 1.0; + + int idx = 0; + for (double right = 500 - tick; right >= 100; right -= tick) { + await pumpWithClamp(Rect.fromLTRB(50, 50, right, 500)); + final c = readController(); + final dLeft = (c.rect.left - prevLeft).abs(); + final dTop = (c.rect.top - prevTop).abs(); + expect( + dLeft, + lessThanOrEqualTo(tick + 0.01), + reason: 'widget step $idx (right=$right): box.left ' + 'jumped by $dLeft (>tick=$tick) — FLICK', + ); + expect( + dTop, + lessThanOrEqualTo(tick + 0.01), + reason: 'widget step $idx (right=$right): box.top ' + 'jumped by $dTop (>tick=$tick) — FLICK', + ); + prevLeft = c.rect.left; + prevTop = c.rect.top; + idx++; + } + }, + ); + + test( + 'widget-style double recalc path: shrink clamp in 1 px ticks, no flick', + () { + // Mirror the exact sequence `TransformableBox.didUpdateWidget` runs + // when the parent passes a new clampingRect: + // 1. controller.setClampingRect(new, notify: false) + // → internally calls recalculate() = recalculatePosition + // + recalculateSize (both zero-delta). + // 2. controller.recalculatePosition(notify: false) (explicit) + // 3. controller.recalculateSize(notify: false) (explicit) + // My earlier direct-controller test only ran step 1's recalc; this + // test runs all three per tick, matching what the playground actually + // invokes. + const theta = math.pi / 6; + final c = TransformableBoxController( + rect: const Rect.fromLTWH(200, 200, 100, 100), + rotation: theta, + clampingRect: const Rect.fromLTRB(50, 50, 500, 500), + bindingStrategy: BindingStrategy.boundingBox, + ); + + double right = 500.0; + const double tick = 1.0; + double prevLeft = c.rect.left; + double prevTop = c.rect.top; + + int stepIdx = 0; + // Drive clamp.right down to and through saturation in 1 px steps. + while (right > 50 + 100) { + right -= tick; + c.setClampingRect(Rect.fromLTRB(50, 50, right, 500)); + c.recalculatePosition(notify: false); + c.recalculateSize(notify: false); + final dLeft = (c.rect.left - prevLeft).abs(); + final dTop = (c.rect.top - prevTop).abs(); + expect( + dLeft, + lessThanOrEqualTo(tick + 0.01), + reason: 'step $stepIdx (right=$right): box.left jumped by $dLeft', + ); + expect( + dTop, + lessThanOrEqualTo(tick + 0.01), + reason: 'step $stepIdx (right=$right): box.top jumped by $dTop', + ); + prevLeft = c.rect.left; + prevTop = c.rect.top; + stepIdx++; + } + }, + ); + + testWidgets( + 'E2E (both widgets in tree): dragging ClampingRect\'s right handle ' + 'translates the rotated inner box without shrinking it', + (tester) async { + // Faithful reproduction of the playground's controlled-component loop: + // outer ClampingRect emits onChanged → setState → outer rebuilds with + // new rect AND inner rebuilds with new clampingRect. The inner's + // widget.rect is intentionally held constant across pumps: in the + // playground, ImageBox's widget.rect comes from PlaygroundModel and + // is ONLY updated by direct image gestures via onChanged. Clamp- + // driven internal translations via recalculatePosition(notify:false) + // never propagate back to the model, so the playground's + // widget.rect is stale across the entire clamp drag — exactly what + // a constant widget.rect here simulates. Saturation (clamp stops + // changing while parent keeps rebuilding) is the frame where the + // didUpdateWidget rect-sync branch must not snap the controller + // back to widget.rect without re-applying clamp translation. + const theta = math.pi / 6; + const Rect initialImg = Rect.fromLTWH(200, 200, 100, 100); + Rect clamp = const Rect.fromLTRB(50, 50, 700, 550); + + final imgKey = GlobalKey>(); + TransformableBoxController readInner() => + // ignore: avoid_dynamic_calls + (imgKey.currentState! as dynamic).controller + as TransformableBoxController; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: SizedBox( + width: 800, + height: 600, + child: StatefulBuilder( + builder: (context, setState) { + // Mirror playground/main.dart:828 — minWidth/minHeight for + // the clamp widget are derived from the inner box's rotated + // bounding rect. Inner shouldn't shrink, so this is stable. + final innerBounding = ClampHelpers.calculateBoundingRect( + initialImg.toBox(rotation: theta), + ); + return Stack( + children: [ + TransformableBox( + rect: clamp, + flip: Flip.none, + clampingRect: const Rect.fromLTRB(0, 0, 800, 600), + allowFlippingWhileResizing: false, + constraints: BoxConstraints( + minWidth: innerBounding.width, + minHeight: innerBounding.height, + ), + onChanged: (result, _) => setState(() { + clamp = result.rect; + }), + contentBuilder: (_, __, ___) => + const ColoredBox(color: Color(0x22FF0000)), + ), + TransformableBox( + key: imgKey, + rect: initialImg, + rotation: theta, + clampingRect: clamp, + bindingStrategy: BindingStrategy.boundingBox, + contentBuilder: (_, __, ___) => + const ColoredBox(color: Color(0xFF4B9EF4)), + ), + ], + ); + }, + ), + ), + ), + ); + + // Outer clamp is unrotated; right-middle handle sits at (700, 300). + final gesture = await tester.startGesture(const Offset(700, 300)); + await tester.pump(); + + final double initW = readInner().rect.width; + final double initH = readInner().rect.height; + double prevLeft = readInner().rect.left; + double prevTop = readInner().rect.top; + const double stepPx = 1.0; + + // Drag inward ~600 px. clamp.right goes 700 → ~100. Saturation for the + // inner 100x100 @ pi/6 happens near 50 + bounding.width ≈ 50 + 137. + // We over-drive past saturation to prove the clamp bottoms out cleanly. + for (int i = 0; i < 600; i++) { + await gesture.moveBy(const Offset(-stepPx, 0)); + await tester.pump(); + + final c = readInner(); + final dLeft = (c.rect.left - prevLeft).abs(); + final dTop = (c.rect.top - prevTop).abs(); + final dW = (c.rect.width - initW).abs(); + final dH = (c.rect.height - initH).abs(); + + expect(dLeft, lessThanOrEqualTo(stepPx + 0.05), + reason: 'step $i: inner.left flicked $dLeft'); + expect(dTop, lessThanOrEqualTo(stepPx + 0.05), + reason: 'step $i: inner.top flicked $dTop'); + expect(dW, lessThanOrEqualTo(0.05), + reason: 'step $i: inner.width drifted $dW (not pure translation)'); + expect(dH, lessThanOrEqualTo(0.05), + reason: 'step $i: inner.height drifted $dH'); + + prevLeft = c.rect.left; + prevTop = c.rect.top; + } + + await gesture.up(); + await tester.pump(); + }, + ); +} diff --git a/packages/flutter_box_transform/test/resize_force_flip_fallback_test.dart b/packages/flutter_box_transform/test/resize_force_flip_fallback_test.dart new file mode 100644 index 0000000..a5ded15 --- /dev/null +++ b/packages/flutter_box_transform/test/resize_force_flip_fallback_test.dart @@ -0,0 +1,131 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_box_transform/flutter_box_transform.dart'; +import 'package:flutter_test/flutter_test.dart'; + +// When a rotated freeform resize gesture pushes the cursor past the anchor +// in a force-flip direction but the flipped state can't satisfy clamp + +// constraints, the engine falls back to the natural (un-flipped) direction. +// The rect then tracks the cursor by clamp-pinning at the natural wall — +// "expectantly clamped" — instead of freezing. +// +// Freeze (controller holds last-feasible, result.feasible=false) is now +// reserved for the rare case where neither force-flip NOR natural direction +// can produce a valid rect — a scenario that effectively requires the +// gesture-start state to also be infeasible, which the controller setup +// itself guards against. + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const theta = math.pi / 6; + const rectSize = 100.0; + const rectLeft = 50.0; + const rectTop = 50.0; + final center = Offset(rectLeft + rectSize / 2, rectTop + rectSize / 2); + final cosT = math.cos(theta); + final sinT = math.sin(theta); + + Offset rotateAroundCenter(Offset unrotated) { + final dx = unrotated.dx - center.dx; + final dy = unrotated.dy - center.dy; + return Offset( + center.dx + dx * cosT - dy * sinT, + center.dy + dx * sinT + dy * cosT, + ); + } + + group('Resize gesture: natural-direction fallback when flip is infeasible', + () { + test( + 'cursor past anchor in flip territory tracks natural direction ' + 'instead of freezing (rect stays inside clamp, no flip)', + () { + final c = TransformableBoxController( + rect: const Rect.fromLTWH(rectLeft, rectTop, rectSize, rectSize), + clampingRect: const Rect.fromLTRB(0, 0, 200, 200), + rotation: theta, + bindingStrategy: BindingStrategy.boundingBox, + constraints: const BoxConstraints( + minWidth: 80, + minHeight: 80, + maxWidth: 500, + maxHeight: 500, + ), + ); + c.onResizeStart(rotateAroundCenter(const Offset(rectLeft, rectTop))); + + // Cursor far past the bottomRight anchor → would request a + // diagonal flip. Min 80×80 flipped at this rotation can't fit + // the clamp. Engine falls back to natural direction; result is + // feasible with no flip. + final tick = c.onResizeUpdate( + const Offset(1000, 1000), + HandlePosition.topLeft, + ); + expect(tick.feasible, isTrue, + reason: 'natural-direction fallback keeps the gesture feasible'); + expect(tick.flip, equals(Flip.none), + reason: 'fallback rect is not flipped'); + // Rect must stay inside clamp. + const clamp = Rect.fromLTRB(0, 0, 200, 200); + expect(tick.rect.left, greaterThanOrEqualTo(clamp.left - 1e-3)); + expect(tick.rect.top, greaterThanOrEqualTo(clamp.top - 1e-3)); + expect(tick.rect.right, lessThanOrEqualTo(clamp.right + 1e-3)); + expect(tick.rect.bottom, lessThanOrEqualTo(clamp.bottom + 1e-3)); + }, + ); + + test( + 'rect tracks cursor through flip territory (multiple ticks): ' + 'each tick produces a fresh natural-direction result, not frozen', + () { + // Move the cursor through several positions deep in flip territory. + // Without the fallback, every tick would freeze at last-feasible + // (the same rect every tick). With the fallback, each tick gets + // a fresh feasible rect that depends on the cursor position. + final c = TransformableBoxController( + rect: const Rect.fromLTWH(rectLeft, rectTop, rectSize, rectSize), + clampingRect: const Rect.fromLTRB(0, 0, 200, 200), + rotation: theta, + bindingStrategy: BindingStrategy.boundingBox, + constraints: const BoxConstraints( + minWidth: 80, + minHeight: 80, + maxWidth: 500, + maxHeight: 500, + ), + ); + c.onResizeStart(rotateAroundCenter(const Offset(rectLeft, rectTop))); + // All three cursor positions are far past the bottomRight anchor + // (force-flip territory), but at different distances. + final t1 = + c.onResizeUpdate(const Offset(800, 800), HandlePosition.topLeft); + final r1 = c.rect; + final t2 = + c.onResizeUpdate(const Offset(1200, 1200), HandlePosition.topLeft); + final r2 = c.rect; + final t3 = + c.onResizeUpdate(const Offset(2000, 2000), HandlePosition.topLeft); + final r3 = c.rect; + for (final t in [t1, t2, t3]) { + expect(t.feasible, isTrue, + reason: 'fallback feasible for every tick in flip territory'); + expect(t.flip, equals(Flip.none)); + } + // Rect must be clamp-pinned (large size); verifying it actually + // moves with the cursor would require a non-degenerate clamp wall + // intersection — what we can assert universally is that none of + // the three states is the gesture-start sentinel. + for (final r in [r1, r2, r3]) { + expect( + r, + isNot(equals( + const Rect.fromLTWH(rectLeft, rectTop, rectSize, rectSize))), + reason: 'rect must respond to cursor, not stay at start'); + } + }, + ); + }); +} diff --git a/packages/flutter_box_transform/test/rotated_drag_hit_gate_test.dart b/packages/flutter_box_transform/test/rotated_drag_hit_gate_test.dart new file mode 100644 index 0000000..f961f85 --- /dev/null +++ b/packages/flutter_box_transform/test/rotated_drag_hit_gate_test.dart @@ -0,0 +1,215 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_box_transform/flutter_box_transform.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Regression: the drag GestureDetector is axis-aligned over the rotated +/// box's AABB. Before the RotatedHitGate, the four triangular wedges of +/// the AABB outside the rotated polygon registered drag/tap events — +/// visible as "draggable outside the rendered rotated pixels." These +/// tests assert the hit region matches the rotated polygon exactly. +void main() { + Future pumpBox( + WidgetTester tester, { + required Rect rect, + required double rotation, + VoidCallback? onTap, + double stageW = 800, + double stageH = 600, + }) async { + final controller = TransformableBoxController( + rect: rect, + rotation: rotation, + ); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: SizedBox( + width: stageW, + height: stageH, + child: Stack( + children: [ + TransformableBox( + controller: controller, + onTap: onTap, + contentBuilder: (context, r, flip) => + const ColoredBox(color: Color(0xFF4B9EF4)), + ), + ], + ), + ), + ), + ); + await tester.pump(); + return controller; + } + + group('square @ pi/4 (center at 250,250)', () { + testWidgets( + 'tap in AABB wedge (outside rotated polygon) does NOT fire onTap', + (tester) async { + int tapCount = 0; + await pumpBox( + tester, + rect: const Rect.fromLTWH(200, 200, 100, 100), + rotation: math.pi / 4, + onTap: () => tapCount++, + ); + // AABB ~ (179.3, 179.3, 320.7, 320.7). (180, 180) is in the + // top-left wedge: rotated back by -pi/4 about (250,250), local + // ~ (151, 250) — outside rect (200,200,300,300). + await tester.tapAt(const Offset(180, 180)); + await tester.pump(); + expect(tapCount, 0, reason: 'tap in AABB wedge must not fire onTap'); + }, + ); + + testWidgets( + 'tap at polygon center fires onTap', + (tester) async { + int tapCount = 0; + await pumpBox( + tester, + rect: const Rect.fromLTWH(200, 200, 100, 100), + rotation: math.pi / 4, + onTap: () => tapCount++, + ); + await tester.tapAt(const Offset(250, 250)); + await tester.pump(); + expect(tapCount, 1); + }, + ); + + testWidgets( + 'drag started in AABB wedge does NOT move the box', + (tester) async { + final c = await pumpBox( + tester, + rect: const Rect.fromLTWH(200, 200, 100, 100), + rotation: math.pi / 4, + ); + final Rect initial = c.rect; + final gesture = await tester.startGesture(const Offset(180, 180)); + await tester.pump(); + await gesture.moveBy(const Offset(50, 50)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + expect(c.rect, initial, + reason: 'drag from AABB wedge must not move box'); + }, + ); + + testWidgets( + 'drag started in polygon translates the box normally', + (tester) async { + final c = await pumpBox( + tester, + rect: const Rect.fromLTWH(200, 200, 100, 100), + rotation: math.pi / 4, + ); + final gesture = await tester.startGesture(const Offset(250, 250)); + await tester.pump(); + await gesture.moveBy(const Offset(-50, 0)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + expect(c.rect.left, closeTo(150, 0.5)); + expect(c.rect.top, closeTo(200, 0.5)); + }, + ); + }); + + testWidgets( + 'theta=0: interior tap fires (no regression)', + (tester) async { + int tapCount = 0; + await pumpBox( + tester, + rect: const Rect.fromLTWH(200, 200, 100, 100), + rotation: 0.0, + onTap: () => tapCount++, + ); + // Single interior tap. With no rotation the gate's unrotated check is + // the identity, so any interior AABB point must pass through. + await tester.tapAt(const Offset(250, 250)); + await tester.pump(); + expect(tapCount, 1); + }, + ); + + testWidgets( + 'non-square 160x80 @ pi/6: wedge tap rejected, center tap accepted', + (tester) async { + int tapCount = 0; + await pumpBox( + tester, + rect: const Rect.fromLTWH(200, 200, 160, 80), + rotation: math.pi / 6, + onTap: () => tapCount++, + ); + // Center of this rect is (280, 240). AABB ~ (190.7, 165.4, 369.3, 314.6). + // (191, 166) is in the top-left wedge: rotated back, local ~ (165.9, + // 220.4) — outside rect (200, 200, 360, 280). + await tester.tapAt(const Offset(191, 166)); + await tester.pump(); + expect(tapCount, 0, reason: 'non-square @ pi/6 wedge tap must not fire'); + // Polygon center IS inside. + await tester.tapAt(const Offset(280, 240)); + await tester.pump(); + expect(tapCount, 1); + }, + ); + + testWidgets( + 'far-translated box (center ~1000,1000): wedge vs polygon', + (tester) async { + // Default test surface is 800x600; enlarge so (1000,1000) is on-screen. + tester.view.physicalSize = const Size(1200, 1200); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + int tapCount = 0; + await pumpBox( + tester, + rect: const Rect.fromLTWH(950, 950, 100, 100), + rotation: math.pi / 4, + onTap: () => tapCount++, + stageW: 1200, + stageH: 1200, + ); + // AABB ~ (929.3, 929.3, 1070.7, 1070.7). (930, 930) is a wedge. + await tester.tapAt(const Offset(930, 930)); + await tester.pump(); + expect(tapCount, 0); + // Polygon center. + await tester.tapAt(const Offset(1000, 1000)); + await tester.pump(); + expect(tapCount, 1); + }, + ); + + testWidgets( + 'drag started in polygon CONTINUES when pointer exits polygon ' + '(gesture arena caches the hit path at pointer-down)', + (tester) async { + final c = await pumpBox( + tester, + rect: const Rect.fromLTWH(200, 200, 100, 100), + rotation: math.pi / 4, + ); + final gesture = await tester.startGesture(const Offset(250, 250)); + await tester.pump(); + // Translate far enough that intermediate pointer positions are + // well outside the ORIGINAL polygon centred at (250,250). + await gesture.moveBy(const Offset(300, 0)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + expect(c.rect.left, closeTo(500, 1.0), + reason: 'gesture must continue once arena has recognized it'); + }, + ); +} diff --git a/packages/flutter_box_transform/test/rotated_gesture_integration_test.dart b/packages/flutter_box_transform/test/rotated_gesture_integration_test.dart new file mode 100644 index 0000000..643ba92 --- /dev/null +++ b/packages/flutter_box_transform/test/rotated_gesture_integration_test.dart @@ -0,0 +1,564 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_box_transform/flutter_box_transform.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// End-to-end widget tests that drive real pointer events through a +/// `TransformableBox` rotated at non-zero angle. +/// +/// These are the tests that _should_ fail before the Option B refactor and +/// pass after. They prove the coord-system fix end-to-end. +void main() { + /// Builds a minimally-sized harness hosting one `TransformableBox` at a + /// known position, so test coordinates map directly to parent-frame coords. + Future pumpBox( + WidgetTester tester, { + required double rotation, + bool rotatable = false, + Rect rect = const Rect.fromLTWH(200, 200, 100, 100), + Rect? clampingRect, + BoxConstraints? constraints, + BindingStrategy bindingStrategy = BindingStrategy.originalBox, + }) async { + final controller = TransformableBoxController( + rect: rect, + rotation: rotation, + clampingRect: clampingRect, + constraints: constraints, + bindingStrategy: bindingStrategy, + ); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: SizedBox( + width: 800, + height: 600, + child: Stack( + children: [ + TransformableBox( + controller: controller, + rotatable: rotatable, + contentBuilder: (context, rect, flip) => const ColoredBox( + color: Color(0xFF4B9EF4), + ), + ), + ], + ), + ), + ), + ); + // Allow one frame for any post-frame callbacks. + await tester.pump(); + return controller; + } + + group('Tap behaviour (contentBuilder onTap must fire)', () { + testWidgets('tap inside box fires contentBuilder onTap', (tester) async { + int tapCount = 0; + final controller = TransformableBoxController( + rect: const Rect.fromLTWH(200, 200, 100, 100), + ); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: SizedBox( + width: 800, + height: 600, + child: Stack( + children: [ + TransformableBox( + controller: controller, + contentBuilder: (context, rect, flip) => GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => tapCount++, + child: Container(color: const Color(0xFF4B9EF4)), + ), + ), + ], + ), + ), + ), + ); + await tester.pump(); + await tester.tapAt(const Offset(250, 250)); + await tester.pump(); + expect(tapCount, 1, + reason: 'contentBuilder onTap must fire on tap inside box'); + }); + + testWidgets('tap on rotated box still fires contentBuilder onTap', + (tester) async { + int tapCount = 0; + final controller = TransformableBoxController( + rect: const Rect.fromLTWH(200, 200, 100, 100), + rotation: math.pi / 4, + ); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: SizedBox( + width: 800, + height: 600, + child: Stack( + children: [ + TransformableBox( + controller: controller, + contentBuilder: (context, rect, flip) => GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => tapCount++, + child: Container(color: const Color(0xFF4B9EF4)), + ), + ), + ], + ), + ), + ), + ); + await tester.pump(); + // Tap the centre of the box (250, 250). Under rotation the centre is + // unchanged, so this must still hit the rotated content. + await tester.tapAt(const Offset(250, 250)); + await tester.pump(); + expect(tapCount, 1, reason: 'rotated content onTap must still fire'); + }); + }); + + group('Rotated drag tracks cursor', () { + testWidgets('theta=0: drag by (50, 0) moves rect by (50, 0)', + (tester) async { + final c = await pumpBox(tester, rotation: 0); + // Start drag at box centre (250, 250) in screen = (250, 250) here. + final gesture = await tester.startGesture(const Offset(250, 250)); + await tester.pump(); + await gesture.moveBy(const Offset(50, 0)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + expect(c.rect.left, closeTo(250, 1.0)); + expect(c.rect.top, closeTo(200, 1.0)); + }); + + testWidgets('theta=pi/4: drag by (50, 0) moves rect by (50, 0) in parent', + (tester) async { + final c = await pumpBox(tester, rotation: math.pi / 4); + // The box is visually rotated 45°; its centre is still (250, 250). + // Dragging by (50, 0) on screen should translate the unrotated rect + // by (50, 0) in the parent frame. + final gesture = await tester.startGesture(const Offset(250, 250)); + await tester.pump(); + await gesture.moveBy(const Offset(50, 0)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + expect(c.rect.left, closeTo(250, 1.0)); + expect(c.rect.top, closeTo(200, 1.0)); + expect(c.rotation, closeTo(math.pi / 4, 1e-9)); + }); + }); + + group('Continuous drag (multi-step, catches accumulation bugs)', () { + testWidgets('theta=0: 5 moves of 10px right total 50px rect shift', + (tester) async { + final c = await pumpBox(tester, rotation: 0); + final gesture = await tester.startGesture(const Offset(250, 250)); + await tester.pump(); + for (int i = 0; i < 5; i++) { + await gesture.moveBy(const Offset(10, 0)); + await tester.pump(); + // Per-step pointer tracking: box.left should increase by exactly + // 10px each step (1:1 cursor tracking, not accumulating). + expect(c.rect.left, closeTo(200 + (i + 1) * 10, 0.01), + reason: 'step $i: drift from 1:1 tracking'); + } + await gesture.up(); + await tester.pump(); + expect(c.rect.left, closeTo(250, 0.01)); + expect(c.rect.top, closeTo(200, 0.01)); + }); + + testWidgets('theta=pi/4: 5 moves of 10px right total 50px rect shift', + (tester) async { + final c = await pumpBox(tester, rotation: math.pi / 4); + final gesture = await tester.startGesture(const Offset(250, 250)); + await tester.pump(); + for (int i = 0; i < 5; i++) { + await gesture.moveBy(const Offset(10, 0)); + await tester.pump(); + expect(c.rect.left, closeTo(200 + (i + 1) * 10, 0.01), + reason: 'step $i under rotation: drift from 1:1 tracking'); + } + await gesture.up(); + await tester.pump(); + expect(c.rect.left, closeTo(250, 0.01)); + expect(c.rect.top, closeTo(200, 0.01)); + }); + }); + + group('Multi-step resize tracks cursor 1:1', () { + // Box 200x200, rotation=0. Grab bottomRight at (300,300), drag it in + // 5 steps of (10, 0) each. After each step, width should grow by 10 + // exactly. Height should remain 100. + testWidgets('theta=0: multi-step resize on bottomRight grows width 1:1', + (tester) async { + final c = await pumpBox(tester, rotation: 0); + final gesture = await tester.startGesture(const Offset(300, 300)); + await tester.pump(); + for (int i = 0; i < 5; i++) { + await gesture.moveBy(const Offset(10, 0)); + await tester.pump(); + expect(c.rect.width, closeTo(100 + (i + 1) * 10, 0.01), + reason: 'step $i: width drift'); + expect(c.rect.height, closeTo(100, 0.01), + reason: 'step $i: height drift (should be unchanged)'); + expect(c.rect.left, closeTo(200, 0.01), + reason: 'step $i: left drift (anchor must stay)'); + } + await gesture.up(); + await tester.pump(); + }); + + // Same box, rotation=pi/2 (CCW). The visual bottomRight is at + // ClampHelpers.rotatePointAround((300,300), (250,250), pi/2) = (200, 300). Grab there; + // drag (10, 0) in world each step. New dimensions follow + // delta (in local frame) = (10 * cos(-pi/2), 10 * sin(-pi/2)) = (0, -10), + // i.e. the box visually grows left, which in local frame reduces height + // by 10 per step (since the local "bottom" edge moves up). Let's just + // assert bounding rect tracks cursor 1:1 for the dragged corner. + testWidgets('theta=pi/2: multi-step resize keeps visual anchor fixed', + (tester) async { + // Anchor (visual topLeft) for bottomRight-handle resize is + // ClampHelpers.rotatePointAround(rect.topLeft, rect.center, rotation). + final c = await pumpBox(tester, rotation: math.pi / 2); + // rect = (200,200,300,300). Visual bottomRight @ pi/2 = (200, 300). + final gesture = await tester.startGesture(const Offset(200, 300)); + await tester.pump(); + for (int i = 0; i < 5; i++) { + await gesture.moveBy(const Offset(-10, 0)); // further left + await tester.pump(); + // Visual topLeft (anchor) in world should be stable. + // topLeft unrotated = (200, 200). Rotated pi/2 around center (250,250). + // dx=-50, dy=-50, cos=0, sin=1: x' = 250 + 50 = 300, y' = 250 - 50 = 200. + // So visual anchor = (300, 200). + final cx = (c.rect.left + c.rect.right) / 2; + final cy = (c.rect.top + c.rect.bottom) / 2; + final anchorUnrot = Offset(c.rect.left, c.rect.top); + final anchorVisual = RotatedLayout.rotateOffsetAround( + anchorUnrot, Offset(cx, cy), c.rotation); + expect(anchorVisual.dx, closeTo(300, 0.1), + reason: 'step $i: visual anchor drifted'); + expect(anchorVisual.dy, closeTo(200, 0.1), + reason: 'step $i: visual anchor drifted'); + } + await gesture.up(); + await tester.pump(); + }); + }); + + group('Rotated box clamp: no shrinking under bounded movement/resize', () { + // Scenario: rotated box entirely inside a generous clamp. Drag the box + // toward a clamp edge. Expect translation to stop at the wall, with no + // change in box dimensions at any step. + testWidgets('theta=pi/6: drag into clamp wall preserves size', + (tester) async { + final c = await pumpBox( + tester, + rotation: math.pi / 6, + rect: const Rect.fromLTWH(200, 200, 100, 100), + clampingRect: const Rect.fromLTRB(100, 100, 500, 500), + ); + final initialSize = c.rect.size; + // Drag far to the left, into the clamp. + final gesture = await tester.startGesture(const Offset(250, 250)); + await tester.pump(); + for (int i = 0; i < 10; i++) { + await gesture.moveBy(const Offset(-20, 0)); + await tester.pump(); + expect(c.rect.width, closeTo(initialSize.width, 0.01), + reason: 'width must not change during drag (step $i)'); + expect(c.rect.height, closeTo(initialSize.height, 0.01), + reason: 'height must not change during drag (step $i)'); + } + await gesture.up(); + await tester.pump(); + }); + + // Resize a rotated corner handle outward past the clamp. Box should + // grow, then stop. Neither dimension should ever decrease during the + // gesture. + testWidgets('theta=pi/6: resize into clamp wall does not shrink', + (tester) async { + // Box 100x100 at rotation pi/6. Clamp barely bigger. + // Rotated-box AABB ≈ 100*(cos(pi/6)+sin(pi/6)) = 100 * (0.866+0.5) = 136.6. + // Place clamp so resizing outward eventually hits a corner. + final c = await pumpBox( + tester, + rotation: math.pi / 6, + rect: const Rect.fromLTWH(200, 200, 100, 100), + clampingRect: const Rect.fromLTRB(100, 100, 400, 400), + ); + final initialW = c.rect.width; + final initialH = c.rect.height; + // Visual bottomRight under rotation pi/6 around centre (250, 250). + final handleWorld = RotatedLayout.rotateOffsetAround( + const Offset(300, 300), const Offset(250, 250), math.pi / 6); + final gesture = await tester.startGesture(handleWorld); + await tester.pump(); + for (int i = 0; i < 10; i++) { + await gesture.moveBy(const Offset(20, 20)); + await tester.pump(); + expect(c.rect.width, greaterThanOrEqualTo(initialW - 0.01), + reason: 'width must not shrink during resize-outward (step $i)'); + expect(c.rect.height, greaterThanOrEqualTo(initialH - 0.01), + reason: 'height must not shrink during resize-outward (step $i)'); + } + await gesture.up(); + await tester.pump(); + }); + }); + + group('Non-bottomRight handles under rotation', () { + // Default playground-like setup: 100x100 box rotated ~30°, clamp big + // (plenty of room), constraints 100/500 on both axes. + Future setup(WidgetTester tester) => pumpBox( + tester, + rotation: math.pi / 6, + rect: const Rect.fromLTWH(200, 200, 100, 100), + clampingRect: const Rect.fromLTRB(0, 0, 950, 600), + constraints: const BoxConstraints( + minWidth: 100, + minHeight: 100, + maxWidth: 500, + maxHeight: 500, + ), + ); + + // User drags visual topRight corner horizontally right. Both w and h + // should grow monotonically, never shrink. 15 steps of 20px → S=300, + // which crosses the threshold (~S=225) where the pre-fix math starts + // shrinking h via bogus mirror-corner inequalities. + testWidgets('topRight handle: drag right 15x20px grows w+h monotonically', + (tester) async { + final c = await setup(tester); + final handleWorld = RotatedLayout.rotateOffsetAround( + const Offset(300, 200), const Offset(250, 250), math.pi / 6); + final gesture = await tester.startGesture(handleWorld); + await tester.pump(); + double lastW = c.rect.width; + double lastH = c.rect.height; + for (int i = 0; i < 15; i++) { + await gesture.moveBy(const Offset(20, 0)); + await tester.pump(); + expect(c.rect.width, greaterThanOrEqualTo(lastW - 0.01), + reason: 'step $i: width must not shrink'); + expect(c.rect.height, greaterThanOrEqualTo(lastH - 0.01), + reason: 'step $i: height must not shrink'); + lastW = c.rect.width; + lastH = c.rect.height; + } + await gesture.up(); + await tester.pump(); + }); + + // Playground-like geometry: 100x100 box centred in 950x600 clamp, + // rotation pi/6. Grab topLeft and drag outward by (-300, -300). Pre-fix + // math collapses width to ~100 (minWidth). Correct math keeps width + // substantially above min. + testWidgets('topLeft handle: centred box, big outward drag keeps shape', + (tester) async { + final c = await pumpBox( + tester, + rotation: math.pi / 6, + rect: const Rect.fromLTWH(425, 250, 100, 100), + clampingRect: const Rect.fromLTRB(0, 0, 950, 600), + constraints: const BoxConstraints( + minWidth: 100, + minHeight: 100, + maxWidth: 500, + maxHeight: 500, + ), + ); + final handleWorld = RotatedLayout.rotateOffsetAround( + const Offset(425, 250), const Offset(475, 300), math.pi / 6); + final gesture = await tester.startGesture(handleWorld); + await tester.pump(); + await gesture.moveBy(const Offset(-300, -300)); + await tester.pump(); + expect(c.rect.width, greaterThan(200.0), + reason: 'topLeft big outward drag: width collapsed to ~min'); + expect(c.rect.height, greaterThan(200.0), + reason: 'topLeft big outward drag: height collapsed to ~min'); + await gesture.up(); + await tester.pump(); + }); + + // Bottom-left handle: 7 steps of (-20, 0) → S=140. Stays inside the + // clamp so the rotated corner doesn't legitimately exit; any shrink + // here would be a bogus mirror-polygon artefact. + testWidgets('bottomLeft handle: drag left 7x20px grows w+h monotonically', + (tester) async { + final c = await setup(tester); + final handleWorld = RotatedLayout.rotateOffsetAround( + const Offset(200, 300), const Offset(250, 250), math.pi / 6); + final gesture = await tester.startGesture(handleWorld); + await tester.pump(); + double lastW = c.rect.width; + double lastH = c.rect.height; + for (int i = 0; i < 7; i++) { + await gesture.moveBy(const Offset(-20, 0)); + await tester.pump(); + expect(c.rect.width, greaterThanOrEqualTo(lastW - 0.01), + reason: 'step $i: width must not shrink'); + expect(c.rect.height, greaterThanOrEqualTo(lastH - 0.01), + reason: 'step $i: height must not shrink'); + lastW = c.rect.width; + lastH = c.rect.height; + } + await gesture.up(); + await tester.pump(); + }); + }); + + group('Rotation tracks gesture angle exactly', () { + // Box centered at (250, 250), rotatable=true, initial rotation=0. + // Place the pointer just beyond the bottomRight corner at (312, 312) + // (vector from centre = (62, 62), screen angle = atan2(62, 62) = pi/4). + // Drag to (188, 312), vector (-62, 62), angle atan2(62, -62) = 3*pi/4. + // Expected net rotation delta = 3*pi/4 - pi/4 = pi/2 exactly. + testWidgets('quarter-turn pointer sweep produces exactly pi/2 rotation', + (tester) async { + final c = await pumpBox(tester, rotation: 0, rotatable: true); + final gesture = await tester.startGesture(const Offset(312, 312)); + await tester.pump(); + await gesture.moveBy(const Offset(-124, 0)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + expect(c.rotation, closeTo(math.pi / 2, 1e-3), + reason: 'rotation should track pointer angle delta 1:1'); + }); + + // Same setup; smaller, intermediate rotation. + testWidgets('one-eighth turn produces exactly pi/4 rotation', + (tester) async { + final c = await pumpBox(tester, rotation: 0, rotatable: true); + final gesture = await tester.startGesture(const Offset(312, 312)); + await tester.pump(); + // Target the angle pi/2 (vector (0, 87.68) from centre). + // That's a pi/4 delta from start (pi/4 → pi/2). + await gesture.moveBy(const Offset(-62, 25.68)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + expect(c.rotation, closeTo(math.pi / 4, 0.02), + reason: 'rotation should track pointer angle delta 1:1'); + }); + }); + + group('Rotated resize tracks cursor', () { + testWidgets( + 'theta=pi/2: dragging the visual bottomRight handle grows the box', + (tester) async { + final c = await pumpBox( + tester, + rotation: math.pi / 2, + rect: const Rect.fromLTWH(200, 200, 100, 100), + ); + // At rotation pi/2 (90° CCW), the unrotated bottomRight (300, 300) + // is visually at (200, 300). Grab the visual handle there. + final initialArea = c.rect.width * c.rect.height; + final gesture = await tester.startGesture(const Offset(200, 300)); + await tester.pump(); + // Drag outward (further from centre). + await gesture.moveBy(const Offset(-30, 30)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + // Resize must make the box _larger_, not smaller or jump elsewhere. + expect(c.rect.width * c.rect.height, greaterThan(initialArea), + reason: 'dragging outward should grow the box'); + // Box centre should remain close to the original (250, 250). + final newCentre = c.rect.center; + expect(newCentre.dx, closeTo(250, 25)); + expect(newCentre.dy, closeTo(250, 25)); + }); + }); + + group('rotatable=true resize uses correct absolute pointer', () { + testWidgets( + 'resize click at box corner stores world-space initialLocalPosition, not inner-padding-local', + (tester) async { + final c = await pumpBox( + tester, + rotation: 0, + rotatable: true, + ); + // Box's unrotated bottomRight is at (300, 300). rotatable=true means + // the handle is a 64x64 zone centered on that corner, with the 24x24 + // resize gesture in the middle. Tap at the exact corner (inside the + // resize zone). + final gesture = await tester.startGesture(const Offset(300, 300)); + await tester.pump(); + // Commit the pan recognizer with a tiny move past touch slop. + await gesture.moveBy(const Offset(10, 10)); + await tester.pump(); + // controller.initialLocalPosition captured at onResizeStart should be + // the world click point (300, 300), not offset by the Padding gap. + expect(c.initialLocalPosition.dx, closeTo(300, 1.0)); + expect(c.initialLocalPosition.dy, closeTo(300, 1.0)); + await gesture.up(); + await tester.pump(); + }); + }); + + group('rotatable=true combined with rotation!=0', () { + testWidgets( + 'rotation=pi/4 + rotatable: resize via inner zone tracks world cursor', + (tester) async { + final c = await pumpBox( + tester, + rotation: math.pi / 4, + rotatable: true, + ); + // Unrotated bottomRight (300, 300), rotated around centre (250, 250) + // by pi/4: + // dx=50, dy=50, cos=sin=sqrt(2)/2 -> (250 + 0, 250 + 50*sqrt2). + // ~= (250.0, 250 + 70.71) = (250.0, 320.71). + const visualBR = Offset(250.0, 320.71); + final gesture = await tester.startGesture(visualBR); + await tester.pump(); + await gesture.moveBy(const Offset(10, 10)); + await tester.pump(); + // initialLocalPosition should be approximately the visual BR. + expect(c.initialLocalPosition.dx, closeTo(visualBR.dx, 2.0)); + expect(c.initialLocalPosition.dy, closeTo(visualBR.dy, 2.0)); + await gesture.up(); + await tester.pump(); + }); + }); + + group('Rotate via handle', () { + testWidgets('dragging outside a corner rotates the box', (tester) async { + final c = await pumpBox( + tester, + rotation: 0, + rotatable: true, + ); + // Rotation outer ring is 64px; visible visual handle is 24px at the + // corner. Grab the outer ring just past the bottomRight corner. + final start = Offset(300 + 12, 300 + 12); + final gesture = await tester.startGesture(start); + await tester.pump(); + // Arc the pointer a quarter-turn around the centre (250, 250). + // Start vector from centre: (62, 62). Quarter-turn CCW target vector: + // (-62, 62) → absolute point (188, 312). + await gesture.moveTo(const Offset(188, 312)); + await tester.pump(); + await gesture.up(); + await tester.pump(); + // Expect roughly pi/2 rotation (tolerance: trackpad quantisation). + expect(c.rotation.abs(), greaterThan(math.pi / 4), + reason: 'rotation should have significantly changed'); + }); + }); +} diff --git a/packages/flutter_box_transform/test/rotated_layout_test.dart b/packages/flutter_box_transform/test/rotated_layout_test.dart new file mode 100644 index 0000000..349c5ff --- /dev/null +++ b/packages/flutter_box_transform/test/rotated_layout_test.dart @@ -0,0 +1,195 @@ +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_box_transform/flutter_box_transform.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('RotatedLayout.rotateOffsetAround', () { + test('identity at theta=0', () { + const p = Offset(10, 5); + final r = RotatedLayout.rotateOffsetAround(p, const Offset(2, 2), 0.0); + expect(r.dx, closeTo(10, 1e-9)); + expect(r.dy, closeTo(5, 1e-9)); + }); + + test('rotates pi/2 around origin maps (1,0) -> (0,1)', () { + final r = RotatedLayout.rotateOffsetAround( + const Offset(1, 0), Offset.zero, math.pi / 2); + expect(r.dx, closeTo(0, 1e-9)); + expect(r.dy, closeTo(1, 1e-9)); + }); + + test('round-trip with negated angle', () { + const p = Offset(42, -3); + const pivot = Offset(5, 7); + final there = RotatedLayout.rotateOffsetAround(p, pivot, 0.6435); + final back = RotatedLayout.rotateOffsetAround(there, pivot, -0.6435); + expect(back.dx, closeTo(42, 1e-9)); + expect(back.dy, closeTo(-3, 1e-9)); + }); + }); + + group('RotatedLayout.handleCornerInParent', () { + final r = const Rect.fromLTWH(100, 200, 50, 40); + + test('topLeft', () { + expect(RotatedLayout.handleCornerInParent(r, HandlePosition.topLeft), + const Offset(100, 200)); + }); + test('bottomRight', () { + expect(RotatedLayout.handleCornerInParent(r, HandlePosition.bottomRight), + const Offset(150, 240)); + }); + test('top centre', () { + expect(RotatedLayout.handleCornerInParent(r, HandlePosition.top), + const Offset(125, 200)); + }); + test('right centre', () { + expect(RotatedLayout.handleCornerInParent(r, HandlePosition.right), + const Offset(150, 220)); + }); + }); + + group('RotatedLayout.rotatedCornerInWorld', () { + final r = const Rect.fromLTWH(0, 0, 100, 100); + + test('theta=0 equals unrotated corner', () { + expect(RotatedLayout.rotatedCornerInWorld(r, HandlePosition.topLeft, 0.0), + const Offset(0, 0)); + }); + test('pi/2 around center (50,50) maps topLeft -> topRight visual', () { + // Rotate (0,0) by pi/2 around (50,50): + // dx=-50, dy=-50 -> cos=0, sin=1 -> (50 + 0 - (-50), 50 + (-50) + 0) = (100, 0) + final c = RotatedLayout.rotatedCornerInWorld( + r, HandlePosition.topLeft, math.pi / 2); + expect(c.dx, closeTo(100, 1e-9)); + expect(c.dy, closeTo(0, 1e-9)); + }); + }); + + group('RotatedLayout.anchorInHandle', () { + const size = 64.0; + + test('center alignment always returns centre', () { + for (final h in const [ + HandlePosition.topLeft, + HandlePosition.topRight, + HandlePosition.bottomLeft, + HandlePosition.bottomRight, + ]) { + expect(RotatedLayout.anchorInHandle(h, size, HandleAlignment.center), + const Offset(32, 32), + reason: 'center alignment, handle=$h'); + } + }); + + test('inside alignment puts anchor at handle corner nearest box corner', + () { + expect( + RotatedLayout.anchorInHandle( + HandlePosition.topLeft, size, HandleAlignment.inside), + const Offset(0, 0)); + expect( + RotatedLayout.anchorInHandle( + HandlePosition.topRight, size, HandleAlignment.inside), + const Offset(64, 0)); + expect( + RotatedLayout.anchorInHandle( + HandlePosition.bottomLeft, size, HandleAlignment.inside), + const Offset(0, 64)); + expect( + RotatedLayout.anchorInHandle( + HandlePosition.bottomRight, size, HandleAlignment.inside), + const Offset(64, 64)); + }); + + test('outside alignment is the mirror of inside', () { + expect( + RotatedLayout.anchorInHandle( + HandlePosition.topLeft, size, HandleAlignment.outside), + const Offset(64, 64)); + expect( + RotatedLayout.anchorInHandle( + HandlePosition.bottomRight, size, HandleAlignment.outside), + const Offset(0, 0)); + }); + }); + + group('RotatedLayout.handleTopLeftInWorld', () { + const size = 64.0; + final r = const Rect.fromLTWH(100, 100, 200, 200); + + test('rotation=0, center alignment, topLeft', () { + // Corner in world: (100, 100). Anchor inside handle = (32, 32). + // handleTopLeftWorld = (100, 100) - (32, 32) = (68, 68). + final p = RotatedLayout.handleTopLeftInWorld( + rect: r, + handle: HandlePosition.topLeft, + rotation: 0, + handleSize: size, + alignment: HandleAlignment.center, + ); + expect(p.dx, closeTo(68, 1e-9)); + expect(p.dy, closeTo(68, 1e-9)); + }); + + test('rotation=pi/2, topLeft corner visually moves to top-right', () { + // Unrotated topLeft (100, 100). Center (200, 200). + // Rotated by pi/2: (100,100) -> (300, 100). + // Anchor inside handle still (32, 32). handleTopLeftWorld = (268, 68). + final p = RotatedLayout.handleTopLeftInWorld( + rect: r, + handle: HandlePosition.topLeft, + rotation: math.pi / 2, + handleSize: size, + alignment: HandleAlignment.center, + ); + expect(p.dx, closeTo(268, 1e-9)); + expect(p.dy, closeTo(68, 1e-9)); + }); + }); + + group('RotatedLayout.sideHandleRectInWorld', () { + final r = const Rect.fromLTWH(100, 200, 80, 60); + const tap = 24.0; + + test('top handle (center): horizontal strip inset from corners', () { + final rect = RotatedLayout.sideHandleRectInWorld( + r, + HandlePosition.top, + handleTapSize: tap, + alignment: HandleAlignment.center, + ); + // Top handle centred on rect.top, inset 12 from each corner-handle edge. + expect(rect.left, closeTo(100 + tap / 2, 1e-9)); + expect(rect.right, closeTo(180 - tap / 2, 1e-9)); + expect(rect.top, closeTo(200 - tap / 2, 1e-9)); + expect(rect.bottom, closeTo(200 + tap / 2, 1e-9)); + }); + + test('left handle (inside): vertical strip inside box', () { + final rect = RotatedLayout.sideHandleRectInWorld( + r, + HandlePosition.left, + handleTapSize: tap, + alignment: HandleAlignment.inside, + ); + expect(rect.left, closeTo(100, 1e-9)); + expect(rect.width, closeTo(tap, 1e-9)); + expect(rect.top, closeTo(200 + tap, 1e-9)); + expect(rect.bottom, closeTo(260 - tap, 1e-9)); + }); + + test('right handle (outside): vertical strip outside right edge', () { + final rect = RotatedLayout.sideHandleRectInWorld( + r, + HandlePosition.right, + handleTapSize: tap, + alignment: HandleAlignment.outside, + ); + expect(rect.left, closeTo(180, 1e-9)); + expect(rect.width, closeTo(tap, 1e-9)); + }); + }); +} diff --git a/packages/flutter_box_transform/test/rotation_controller_test.dart b/packages/flutter_box_transform/test/rotation_controller_test.dart new file mode 100644 index 0000000..27e9dc8 --- /dev/null +++ b/packages/flutter_box_transform/test/rotation_controller_test.dart @@ -0,0 +1,271 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_box_transform/flutter_box_transform.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + group('TransformableBoxController rotation gestures', () { + test('onRotateStart snapshots current rotation into initialRotation', () { + final c = TransformableBoxController( + rect: const Rect.fromLTWH(0, 0, 100, 100), + rotation: 0.3, + ); + c.onRotateStart(const Offset(100, 50)); + expect(c.initialRotation, closeTo(0.3, 1e-9)); + expect(c.initialLocalPosition, const Offset(100, 50)); + }); + + test('onRotateUpdate advances rotation additively from initialRotation', + () { + final c = TransformableBoxController( + rect: const Rect.fromLTWH(-50, -50, 100, 100), + rotation: 0.0, + ); + // Center is (0,0). Pointer goes from (+X) to (+Y) = +pi/2. + c.onRotateStart(const Offset(100, 0)); + c.onRotateUpdate(const Offset(0, 100), HandlePosition.bottomRight); + expect(c.rotation, closeTo(math.pi / 2, 1e-6)); + }); + + test('onRotateEnd clears gesture state', () { + final c = TransformableBoxController( + rect: const Rect.fromLTWH(0, 0, 100, 100), + rotation: 0.0, + ); + c.onRotateStart(const Offset(100, 50)); + c.onRotateEnd(); + expect(c.initialRotation, 0.0); + expect(c.initialRect, Rect.zero); + expect(c.initialLocalPosition, Offset.zero); + }); + + test('onRotateUpdate freezes rotation when clamp cannot fit the angle', () { + // 100×100 box exactly filling a 100×100 clamp. Any non-cardinal + // rotation (here π/4) inflates the AABB beyond the clamp, and no + // translation can rescue it. + final c = TransformableBoxController( + rect: const Rect.fromLTWH(0, 0, 100, 100), + clampingRect: const Rect.fromLTRB(0, 0, 100, 100), + rotation: 0.0, + bindingStrategy: BindingStrategy.boundingBox, + ); + // Center is (50, 50). Pointer goes from (+X) to angle π/4 of center. + c.onRotateStart(const Offset(100, 50)); + final result = c.onRotateUpdate( + Offset( + 50 + 50 * math.cos(math.pi / 4), 50 + 50 * math.sin(math.pi / 4)), + HandlePosition.bottomRight, + ); + expect(result.feasible, isFalse); + expect(c.rotation, equals(0.0), + reason: 'Controller must not update rotation on infeasible result.'); + expect(c.rect, equals(const Rect.fromLTWH(0, 0, 100, 100)), + reason: 'Controller must not update rect on infeasible result.'); + }); + + test( + 'multi-tick gesture: feasible → infeasible (frozen) → reverse ' + '(feasible again)', + () { + // R1: drag past the cap, gesture freezes at last feasible state; + // reversing pointer direction unfreezes and resumes tracking. + // Geometry: 100×100 centered in 130×130 clamp → AABB at π/12 fits + // (122.5 < 130) but AABB at π/6 doesn't (136.6 > 130). + final c = TransformableBoxController( + rect: const Rect.fromLTWH(15, 15, 100, 100), + clampingRect: const Rect.fromLTRB(0, 0, 130, 130), + rotation: 0.0, + bindingStrategy: BindingStrategy.boundingBox, + ); + // Pointer rotates around the box center (65, 65). + Offset pointerAt(double theta) => + Offset(65 + 50 * math.cos(theta), 65 + 50 * math.sin(theta)); + c.onRotateStart(pointerAt(0)); + + // Tick 1: small rotation (feasible). + const theta1 = math.pi / 12; + final r1 = c.onRotateUpdate( + pointerAt(theta1), + HandlePosition.bottomRight, + ); + expect(r1.feasible, isTrue); + expect(c.rotation, closeTo(theta1, 1e-9)); + final frozenRotation = c.rotation; + final frozenRect = c.rect; + + // Tick 2: rotation past the cap → infeasible. + const theta2 = math.pi / 6; + final r2 = c.onRotateUpdate( + pointerAt(theta2), + HandlePosition.bottomRight, + ); + expect(r2.feasible, isFalse); + expect(c.rotation, equals(frozenRotation), + reason: 'Frozen at last feasible rotation.'); + expect(c.rect, equals(frozenRect), + reason: 'Rect frozen at last feasible position.'); + + // Tick 3: reverse direction back to a feasible rotation. + const theta3 = math.pi / 24; + final r3 = c.onRotateUpdate( + pointerAt(theta3), + HandlePosition.bottomRight, + ); + expect(r3.feasible, isTrue); + expect(c.rotation, closeTo(theta3, 1e-9), + reason: 'Reversal resumes tracking from gesture-start, not ' + 'from the frozen rotation.'); + }, + ); + + test( + 'side-handle resize tick-by-tick across clamp boundary stays clean', + () { + // Reproduces the user-reported symptom: dragging the right side + // handle in tiny ticks, the box's bottom-right corner intersects + // clamp.bottom, and the box appears to grow in the reverse + // direction before clamping. + const theta = math.pi / 12; + final c = TransformableBoxController( + rect: const Rect.fromLTWH(150, 250, 100, 100), + clampingRect: const Rect.fromLTRB(0, 0, 400, 400), + rotation: theta, + bindingStrategy: BindingStrategy.boundingBox, + ); + // World position of the rotated right edge midpoint (where the + // strip sits visually under rotation). + final rectCenter = const Offset(200, 300); + final unrotatedRightMid = const Offset(250, 300); + final dx = unrotatedRightMid.dx - rectCenter.dx; + final dy = unrotatedRightMid.dy - rectCenter.dy; + final cosT = math.cos(theta); + final sinT = math.sin(theta); + final rotatedRightMidWorld = Offset( + rectCenter.dx + dx * cosT - dy * sinT, + rectCenter.dy + dx * sinT + dy * cosT, + ); + + c.onResizeStart(rotatedRightMidWorld); + // Drag tick-by-tick along the rotated +x direction (visually + // outward from the right edge). + const ticks = 30; + const stepPx = 5.0; + final widths = []; + for (int i = 1; i <= ticks; i++) { + final stepWorld = Offset( + rotatedRightMidWorld.dx + i * stepPx * cosT, + rotatedRightMidWorld.dy + i * stepPx * sinT, + ); + final result = c.onResizeUpdate(stepWorld, HandlePosition.right); + widths.add(result.rect.width); + } + // Must be monotonic non-decreasing. + for (int i = 1; i < widths.length; i++) { + expect( + widths[i], + greaterThanOrEqualTo(widths[i - 1] - 1e-6), + reason: 'tick $i: width regressed ' + '(${widths[i - 1]} → ${widths[i]})', + ); + } + }, + ); + + test( + 'force-flip during rotated resize keeps rect in clamp', + () { + // User reports: dragging a corner past anchor (force-flip) under + // rotation can leave the rect bleeding past clamp boundaries. + const theta = math.pi / 12; + final c = TransformableBoxController( + rect: const Rect.fromLTWH(150, 150, 100, 100), + clampingRect: const Rect.fromLTRB(0, 0, 400, 400), + rotation: theta, + bindingStrategy: BindingStrategy.boundingBox, + ); + // World position of the rotated bottomRight corner. + final centerWorld = const Offset(200, 200); + final unrotatedBR = const Offset(250, 250); + final cosT = math.cos(theta); + final sinT = math.sin(theta); + final dx = unrotatedBR.dx - centerWorld.dx; + final dy = unrotatedBR.dy - centerWorld.dy; + final initialBR = Offset( + centerWorld.dx + dx * cosT - dy * sinT, + centerWorld.dy + dx * sinT + dy * cosT, + ); + c.onResizeStart(initialBR); + // Drag past topLeft anchor and continue to clamp's far corner. + for (int i = 0; i <= 60; i++) { + // Cursor sweeps from BR toward upper-left in screen, eventually + // past clamp.left and clamp.top. + final targetWorld = Offset( + initialBR.dx - i * 8.0, + initialBR.dy - i * 8.0, + ); + final result = + c.onResizeUpdate(targetWorld, HandlePosition.bottomRight); + // Verify the controller's rect's rotated corners stay in clamp. + final r = result.rect; + final rcx = r.center.dx; + final rcy = r.center.dy; + final rrot = c.rotation; + final rcosT = math.cos(rrot); + final rsinT = math.sin(rrot); + final corners = [ + r.topLeft, + r.topRight, + r.bottomRight, + r.bottomLeft, + ].map((p) { + final ddx = p.dx - rcx; + final ddy = p.dy - rcy; + return Offset( + rcx + ddx * rcosT - ddy * rsinT, + rcy + ddx * rsinT + ddy * rcosT, + ); + }).toList(); + for (int k = 0; k < 4; k++) { + expect( + corners[k].dx, greaterThanOrEqualTo(c.clampingRect.left - 1e-3), + reason: 'tick $i corner $k leaked left'); + expect( + corners[k].dy, greaterThanOrEqualTo(c.clampingRect.top - 1e-3), + reason: 'tick $i corner $k leaked top'); + expect( + corners[k].dx, lessThanOrEqualTo(c.clampingRect.right + 1e-3), + reason: 'tick $i corner $k leaked right'); + expect( + corners[k].dy, lessThanOrEqualTo(c.clampingRect.bottom + 1e-3), + reason: 'tick $i corner $k leaked bottom'); + } + } + }, + ); + + test('onRotateUpdate slides rect into clamp when slack room exists', () { + // 100×100 box flush against top-left of a 200×200 clamp. Rotating + // by π/12 inflates the AABB by ~11 px on each axis; the box must + // slide into the clamp so the AABB fits. + final c = TransformableBoxController( + rect: const Rect.fromLTWH(0, 0, 100, 100), + clampingRect: const Rect.fromLTRB(0, 0, 200, 200), + rotation: 0.0, + bindingStrategy: BindingStrategy.boundingBox, + ); + const theta = math.pi / 12; + c.onRotateStart(const Offset(100, 50)); + final result = c.onRotateUpdate( + Offset(50 + 50 * math.cos(theta), 50 + 50 * math.sin(theta)), + HandlePosition.bottomRight, + ); + expect(result.feasible, isTrue); + expect(c.rotation, closeTo(theta, 1e-9)); + // Rect should have moved (not still at 0,0). + expect(c.rect.left, greaterThan(0.0)); + expect(c.rect.top, greaterThan(0.0)); + }); + }); +} diff --git a/packages/flutter_box_transform/test/rotation_widget_smoke_test.dart b/packages/flutter_box_transform/test/rotation_widget_smoke_test.dart new file mode 100644 index 0000000..f816b80 --- /dev/null +++ b/packages/flutter_box_transform/test/rotation_widget_smoke_test.dart @@ -0,0 +1,43 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_box_transform/flutter_box_transform.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('controller initialized with rotation reports boundingRect', + (tester) async { + final controller = TransformableBoxController( + rect: const Rect.fromLTWH(0, 0, 100, 100), + rotation: math.pi / 4, + ); + final expectedBoundingSide = 100 * math.sqrt(2); + expect( + controller.boundingRect.width, + closeTo(expectedBoundingSide, 1e-12), + ); + expect( + controller.boundingRect.height, + closeTo(expectedBoundingSide, 1e-12), + ); + expect(controller.rotation, closeTo(math.pi / 4, 1e-12)); + }); + + test( + 'controller.onDragUpdate under rotation clamps corners into clampingRect', + () { + final c = TransformableBoxController( + rect: const Rect.fromLTWH(0, 0, 100, 100), + rotation: math.pi / 4, + clampingRect: const Rect.fromLTRB(-100, -100, 200, 200), + bindingStrategy: BindingStrategy.boundingBox, + ); + c.onDragStart(const Offset(50, 50)); + final result = c.onDragUpdate(const Offset(300, 300)); + // BoundingRect tracks the rendered AABB. Must stay within clampingRect. + expect(result.boundingRect.left, greaterThanOrEqualTo(-100 - 1e-4)); + expect(result.boundingRect.right, lessThanOrEqualTo(200 + 1e-4)); + expect(result.boundingRect.top, greaterThanOrEqualTo(-100 - 1e-4)); + expect(result.boundingRect.bottom, lessThanOrEqualTo(200 + 1e-4)); + }); +} diff --git a/pubspec.yaml b/pubspec.yaml index 8640c24..4ed1a1d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,11 @@ -name: melos_box_transform +name: box_transform_workspace +publish_to: none environment: - sdk: '>=3.0.0 <4.0.0' + sdk: ^3.6.0 -dev_dependencies: - melos: ^6.3.2 +workspace: + - packages/box_transform + - packages/flutter_box_transform + - packages/flutter_box_transform/example + - packages/flutter_box_transform/playground diff --git a/scripts/find_globals.sh b/scripts/find_globals.sh new file mode 100755 index 0000000..2c830c8 --- /dev/null +++ b/scripts/find_globals.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# Enumerate top-level (global) function declarations in lib/ files of both +# packages. Anything matching one of these patterns at column 0 is a +# top-level function: +# +# (...) // returns a value +# void (...) // void +# <...>(...) // generic +# Future<...> (...) // async +# (...) // bare (no return type, rare) +# +# Excludes class members (those are indented under a class) and getters. +# Imperfect but good enough as a worklist seed: the user can verify each +# hit before refactoring. + +set -euo pipefail +cd "$(dirname "$0")/../packages" + +# Match a line that: +# * starts at column 0 +# * begins with a Dart identifier or one of: void / Future / Iterable / List / Map / Set / Stream / FutureOr / Box / Vector2 / Offset / Rect / Size / Constraints / Resizer / Flip / HandlePosition / RotateResult / ResizeResult / MoveResult / RawResizeResult / RawMoveResult / RawRotateResult / Box? / int / double / bool / String / num +# * (followed by name, optional generics, '(' ) +# * and is NOT a class declaration / abstract / sealed / typedef / extension / library / part / export / import / final / const / var / late +# +# We grep first, then filter out obviously-not-function lines. + +LIB_GLOBS=( + "box_transform/lib/**/*.dart" + "flutter_box_transform/lib/**/*.dart" +) + +FILES=$(find box_transform/lib flutter_box_transform/lib -name '*.dart' | sort) + +echo "# Top-level (global) declarations in lib/" +echo +echo "Generated $(date -u +%Y-%m-%dT%H:%M:%SZ) by scripts/find_globals.sh." +echo +echo "Each row is a candidate global function/getter for refactoring into a" +echo "class-scoped static. Verify by hand before moving." +echo + +for f in $FILES; do + # Lines that LOOK like top-level function decls. Excludes: + # class / abstract class / sealed class / final class / mixin / + # extension / typedef / enum / part / part of / library / import / + # export / @ + awk ' + BEGIN { in_block_comment = 0 } + /^\/\*/ { in_block_comment = 1 } + /\*\// { in_block_comment = 0; next } + in_block_comment { next } + /^\/\// { next } + /^[[:space:]]*$/ { next } + /^[[:space:]]/ { next } # any indented line is class member + /^class / { next } + /^abstract / { next } + /^sealed / { next } + /^final class / { next } + /^mixin / { next } + /^extension / { next } + /^typedef / { next } + /^enum / { next } + /^part / { next } + /^library/ { next } + /^import / { next } + /^export / { next } + /^@/ { next } + /^const / { next } + /^final / { next } + /^var / { next } + /^late / { next } + /^void main\(/ { next } # entry point of bench/cli + /^void mainMethod\(/ { next } + # A line that has a `(` and a likely identifier prefix. + /\(/ { + # Skip pure-statement lines (no identifier-followed-by-paren). + if ($0 ~ /^[A-Za-z_<>?,[:space:]]+[A-Za-z_][A-Za-z0-9_]*[[:space:]]*\(/) { + printf "%s:%d %s\n", FILENAME, NR, $0 + } + } + ' "$f" +done