diff --git a/CHANGELOG.md b/CHANGELOG.md index 1108a96..149a6ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## Unreleased + +- Precompute contour metrics and pair costs during assignment; skip assignment + work when each role has exactly one contour. +- Add `precacheMorph`, including optional reverse-pair preparation using the + widgets' existing bounded caches. +- Add `onFallback` and `MorphFallbackDetails` to both widgets for per-request + diagnostics in debug and release, with stale-request and disposal guards. +- Add geometry microbenchmarks and a standalone example performance harness. + ## 0.6.1 - Update the web showcase to Lucide 3.1.17. diff --git a/README.md b/README.md index b3102ba..4f5a915 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,49 @@ cache, `MorphCache.disable()` to empty and disable it, and `MorphCache.reset()` to restore `defaultMaxMorphs` and `defaultMaxBytes`. `configure` throws `ArgumentError` unless both limits are positive. +Prepare a known pair before the first interaction, for example from +`didChangeDependencies` or an event handler: + +```dart +await precacheMorph( + context, + from: Icons.menu, + to: Icons.close, + bidirectional: true, +); +``` + +The helper shares the widgets' bounded caches and inherits the asset bundle, +direction, and font axes from the context. Pass the same `textDirection`, +`fill`, `weight`, `grade`, `opticalSize`, and `fontWeight` overrides as the +widget when applicable. Reverse plans are separate; `bidirectional` prepares +both. It respects `MorphCache` limits, clearing and disabling; with caching +disabled no completed plan is retained. Eviction can make later preparation +necessary again. The returned future completes with an error if preparation +fails; catch it if unavailable +fonts are expected. Equal icons require no loading. + +Both widgets accept `onFallback` for diagnostics in debug and release: + +```dart +AnimatedMorphIcon( + icon: currentIcon, + onFallback: (details) { + debugPrint('Morph to ${details.to} unavailable: ${details.error}'); + }, +) +``` + +The callback receives `MorphFallbackDetails` with the source, target, original +error, and stack trace. It runs once per failed current preparation request, +replaces default debug error reporting, and ignores superseded requests or +completion after disposal. Native-icon fallback still works and an implicit +transition still calls `onEnd` when it settles. No callback means the existing +fallback behavior is unchanged. + +See [the performance guide](https://github.com/KickNext/morphnext/blob/main/tool/PERFORMANCE.md) for geometry benchmarks and +a standalone Flutter frame-timing harness. + ## Limitations morphnext preserves filled contour topology and keeps holes open, but arbitrary diff --git a/example/lib/performance.dart b/example/lib/performance.dart new file mode 100644 index 0000000..91988bc --- /dev/null +++ b/example/lib/performance.dart @@ -0,0 +1,179 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:morphnext/morphnext.dart'; + +/// Standalone benchmark: flutter run --profile -t lib/performance.dart +void main() => runApp(const MaterialApp(home: MorphPerformancePage())); + +/// Measures preparation separately from animated UI/raster frame times. +class MorphPerformancePage extends StatefulWidget { + const MorphPerformancePage({super.key}); + + @override + State createState() => _MorphPerformancePageState(); +} + +class _MorphPerformancePageState extends State { + late BuildContext _sceneContext; + int _count = 20; + int _sampleId = 0; + bool _running = false; + IconData _icon = Icons.menu; + String _result = 'Select a count and run a five-second sample.'; + Timer? _timer; + bool _recording = false; + final List _frames = []; + + void _record(List frames) => _frames.addAll(frames); + + void _stopRecording() { + if (!_recording) return; + SchedulerBinding.instance.removeTimingsCallback(_record); + _recording = false; + } + + Future _run({required bool interrupt}) async { + MorphCache.clear(); + setState(() { + _running = true; + // Dispose the previous animation before measuring a cold preparation. + _sampleId++; + _icon = Icons.menu; + _result = 'Preparing…'; + }); + await WidgetsBinding.instance.endOfFrame; + if (!mounted) return; + try { + final watch = Stopwatch()..start(); + await precacheMorph( + _sceneContext, + from: Icons.menu, + to: Icons.close, + bidirectional: true, + ); + final cold = watch.elapsedMicroseconds; + if (!mounted) return; + watch.reset(); + await precacheMorph( + _sceneContext, + from: Icons.menu, + to: Icons.close, + bidirectional: true, + ); + final warm = watch.elapsedMicroseconds; + watch.stop(); + if (!mounted) return; + // Do not mix the preparation phase into the frame sample. + _frames.clear(); + SchedulerBinding.instance.addTimingsCallback(_record); + _recording = true; + setState(() { + _icon = Icons.close; + _result = + 'Measuring ${interrupt ? 'interruptions' : 'cached transitions'}…'; + }); + _timer = Timer.periodic(Duration(milliseconds: interrupt ? 80 : 1000), ( + _, + ) { + if (!mounted) return; + setState(() => _icon = _icon == Icons.menu ? Icons.close : Icons.menu); + }); + await Future.delayed(const Duration(seconds: 5)); + if (!mounted) return; + _timer?.cancel(); + _stopRecording(); + setState(() { + _result = + '$_count icons · ${interrupt ? '80 ms retarget' : '1 s toggles'}\n' + 'Preparation, both directions: cold $cold µs / cached $warm µs\n' + '${_frames.length} frames · UI p95 ${_p95(false)} · raster p95 ${_p95(true)}\n' + 'Profile/release only. Use DevTools for memory and Chrome DevTools on web.'; + }); + } catch (error) { + if (mounted) setState(() => _result = 'Benchmark failed: $error'); + } finally { + _timer?.cancel(); + _stopRecording(); + if (mounted) setState(() => _running = false); + } + } + + String _p95(bool raster) { + if (_frames.isEmpty) return 'unavailable'; + final times = [ + for (final frame in _frames) + (raster ? frame.rasterDuration : frame.buildDuration).inMicroseconds, + ]..sort(); + return '${(times[(times.length * 0.95).ceil() - 1] / 1000).toStringAsFixed(2)} ms'; + } + + @override + void dispose() { + _timer?.cancel(); + _stopRecording(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text('Morph performance')), + body: Builder( + builder: (context) { + _sceneContext = context; + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SegmentedButton( + segments: [ + for (final count in [1, 20, 100]) + ButtonSegment(value: count, label: Text('$count icons')), + ], + selected: {_count}, + onSelectionChanged: _running + ? null + : (value) => setState(() => _count = value.single), + ), + const SizedBox(height: 12), + Wrap( + spacing: 12, + children: [ + FilledButton( + onPressed: _running ? null : () => _run(interrupt: false), + child: const Text('Cached transitions'), + ), + OutlinedButton( + onPressed: _running ? null : () => _run(interrupt: true), + child: const Text('Rapid interruptions'), + ), + ], + ), + const SizedBox(height: 12), + Text(_result), + const SizedBox(height: 24), + Expanded( + child: SingleChildScrollView( + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (var i = 0; i < _count; i++) + AnimatedMorphIcon( + key: ValueKey((_sampleId, i)), + icon: _icon, + size: 32, + ), + ], + ), + ), + ), + ], + ), + ); + }, + ), + ); +} diff --git a/example/test/performance_test.dart b/example/test/performance_test.dart new file mode 100644 index 0000000..b44abb4 --- /dev/null +++ b/example/test/performance_test.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:morphnext_example/performance.dart'; + +void main() { + testWidgets('idle performance page can be disposed', (tester) async { + await tester.pumpWidget(const MaterialApp(home: MorphPerformancePage())); + await tester.pumpWidget(const SizedBox()); + expect(tester.takeException(), isNull); + }); + + for (final mode in ['Cached transitions', 'Rapid interruptions']) { + testWidgets('$mode completes and releases timing callbacks', ( + tester, + ) async { + await tester.pumpWidget(const MaterialApp(home: MorphPerformancePage())); + await tester.tap(find.text(mode)); + await tester.pumpAndSettle(); + await tester.pump(const Duration(seconds: 6)); + await tester.pumpAndSettle(); + expect( + find.textContaining('Preparation, both directions:'), + findsOneWidget, + ); + await tester.pumpWidget(const SizedBox()); + expect(tester.takeException(), isNull); + }); + } +} diff --git a/lib/morphnext.dart b/lib/morphnext.dart index ffa5d89..d8a96b9 100644 --- a/lib/morphnext.dart +++ b/lib/morphnext.dart @@ -3,6 +3,8 @@ library; export 'package:flutter/physics.dart' show SpringDescription; export 'src/cache/morph_cache.dart' show MorphCache; +export 'src/morph_fallback_details.dart' show MorphFallbackDetails; export 'src/morph_spring.dart' show MorphSprings; +export 'src/precache_morph.dart' show precacheMorph; export 'src/widgets/animated_morph_icon.dart' show AnimatedMorphIcon; export 'src/widgets/morph_icon.dart' show MorphIcon; diff --git a/lib/src/geometry/morph_plan.dart b/lib/src/geometry/morph_plan.dart index e9dec1b..440911f 100644 --- a/lib/src/geometry/morph_plan.dart +++ b/lib/src/geometry/morph_plan.dart @@ -277,17 +277,31 @@ List<(int?, int?)> _matchRole( for (final sourceIndex in sourceIndices) (sourceIndex, null), ]; } + if (sourceIndices.length == 1 && targetIndices.length == 1) { + return <(int?, int?)>[(sourceIndices.single, targetIndices.single)]; + } final sourceAreaScale = _maximumArea(source, sourceIndices); final targetAreaScale = _maximumArea(target, targetIndices); - double cost(int sourcePosition, int targetPosition) => _contourCost( - source, - sourceIndices[sourcePosition], - sourceAreaScale, - target, - targetIndices[targetPosition], - targetAreaScale, - ); + final sourceMetrics = <_ContourMetrics>[ + for (final index in sourceIndices) + _contourMetrics(source, index, sourceAreaScale), + ]; + final targetMetrics = <_ContourMetrics>[ + for (final index in targetIndices) + _contourMetrics(target, index, targetAreaScale), + ]; + // Assignment revisits the same pair in different branches. Compute each + // cost once, without changing traversal order or tie-breaking. + final costs = [ + for (final sourceMetric in sourceMetrics) + Float64List.fromList([ + for (final targetMetric in targetMetrics) + _contourCost(sourceMetric, targetMetric), + ]), + ]; + double cost(int sourcePosition, int targetPosition) => + costs[sourcePosition][targetPosition]; if (sourceIndices.length <= targetIndices.length) { final assignment = _minimumInjection( @@ -348,37 +362,37 @@ double _maximumArea(MorphShape shape, List indices) => indices .map((index) => shape.contours[index].signedArea.abs()) .fold(_energyEpsilon, math.max); -double _contourCost( - MorphShape source, - int sourceIndex, - double sourceAreaScale, - MorphShape target, - int targetIndex, - double targetAreaScale, -) { - final sourceContour = source.contours[sourceIndex]; - final targetContour = target.contours[targetIndex]; - final sourceCentroid = _centroid(sourceContour.points); - final targetCentroid = _centroid(targetContour.points); - final centroidCost = _distance(sourceCentroid, targetCentroid); - final sourceLength = - _polygonLength(sourceContour.points) / math.sqrt(sourceAreaScale); - final targetLength = - _polygonLength(targetContour.points) / math.sqrt(targetAreaScale); - final lengthCost = (sourceLength - targetLength).abs(); - final areaCost = - (sourceContour.signedArea.abs() / sourceAreaScale - - targetContour.signedArea.abs() / targetAreaScale) - .abs(); - final depthCost = (sourceContour.depth - targetContour.depth).abs(); +typedef _ContourMetrics = ({ + (double, double) centroid, + double length, + double area, + int depth, + (double, double)? parentCentroid, +}); + +_ContourMetrics _contourMetrics(MorphShape shape, int index, double areaScale) { + final contour = shape.contours[index]; + return ( + centroid: _centroid(contour.points), + length: _polygonLength(contour.points) / math.sqrt(areaScale), + area: contour.signedArea.abs() / areaScale, + depth: contour.depth, + parentCentroid: contour.parent == null + ? null + : _centroid(shape.contours[contour.parent!].points), + ); +} + +double _contourCost(_ContourMetrics source, _ContourMetrics target) { + final centroidCost = _distance(source.centroid, target.centroid); + final lengthCost = (source.length - target.length).abs(); + final areaCost = (source.area - target.area).abs(); + final depthCost = (source.depth - target.depth).abs(); var parentCost = 0.0; - if ((sourceContour.parent == null) != (targetContour.parent == null)) { + if ((source.parentCentroid == null) != (target.parentCentroid == null)) { parentCost = 2; - } else if (sourceContour.parent != null) { - parentCost = _distance( - _centroid(source.contours[sourceContour.parent!].points), - _centroid(target.contours[targetContour.parent!].points), - ); + } else if (source.parentCentroid != null) { + parentCost = _distance(source.parentCentroid!, target.parentCentroid!); } return centroidCost + 0.35 * lengthCost + diff --git a/lib/src/morph_fallback_details.dart b/lib/src/morph_fallback_details.dart new file mode 100644 index 0000000..a60158a --- /dev/null +++ b/lib/src/morph_fallback_details.dart @@ -0,0 +1,25 @@ +import 'package:flutter/widgets.dart'; + +/// Why a requested vector transition fell back to ordinary Flutter icons. +@immutable +final class MorphFallbackDetails { + /// Creates diagnostic information for one failed preparation request. + const MorphFallbackDetails({ + required this.from, + required this.to, + required this.error, + required this.stackTrace, + }); + + /// Source icon, or the preceding target when a transition was interrupted. + final IconData from; + + /// Requested target icon. + final IconData to; + + /// Original font-resolution, decoding or geometry error. + final Object error; + + /// Stack trace captured when preparation failed. + final StackTrace stackTrace; +} diff --git a/lib/src/precache_morph.dart b/lib/src/precache_morph.dart new file mode 100644 index 0000000..a44b2d1 --- /dev/null +++ b/lib/src/precache_morph.dart @@ -0,0 +1,56 @@ +import 'package:flutter/widgets.dart'; + +import 'cache/morph_cache.dart'; +import 'morph_repository.dart'; +import 'widgets/morph_icon_frame.dart'; + +/// Prepares a bundled-font icon pair in the widgets' existing bounded cache. +/// +/// Call from an event handler or [State.didChangeDependencies]. The asset +/// bundle, direction and font axes are captured synchronously from [context]. +/// Use the same overrides as the widget that will display the transition. +/// Set [bidirectional] to also prepare the reverse transition. +/// +/// Completes with an error if the pair cannot be prepared; callers can catch +/// that error while widgets continue to use their native-icon fallback. +/// Equal icons require no preparation. Cached entries can later be evicted; +/// this does not reserve memory or guarantee that a future lookup is a hit. +/// Respects [MorphCache] limits and disabling; when disabled, preparation +/// completes but no completed plan is retained. +Future precacheMorph( + BuildContext context, { + required IconData from, + required IconData to, + bool bidirectional = false, + TextDirection? textDirection, + double? fill, + double? weight, + double? grade, + double? opticalSize, + FontWeight? fontWeight, +}) { + assert(fill == null || 0 <= fill && fill <= 1); + assert(weight == null || weight > 0); + assert(opticalSize == null || opticalSize > 0); + if (from == to) return Future.value(); + final style = resolveMorphIconStyle( + context, + size: null, + color: null, + textDirection: textDirection, + applyTextScaling: false, + fill: fill, + weight: weight, + grade: grade, + opticalSize: opticalSize, + shadows: null, + blendMode: null, + fontWeight: fontWeight, + ); + final repository = MorphRepository.forBundle(DefaultAssetBundle.of(context)); + return Future.wait(>[ + repository.planFor(from, to, style.textDirection, style.fontSelection), + if (bidirectional) + repository.planFor(to, from, style.textDirection, style.fontSelection), + ]).then((_) {}); +} diff --git a/lib/src/widgets/animated_morph_icon.dart b/lib/src/widgets/animated_morph_icon.dart index 0990392..1f446f8 100644 --- a/lib/src/widgets/animated_morph_icon.dart +++ b/lib/src/widgets/animated_morph_icon.dart @@ -6,6 +6,7 @@ import 'package:flutter/widgets.dart'; import '../geometry/morph_plan.dart'; import '../font/font_selection.dart'; import '../morph_repository.dart'; +import '../morph_fallback_details.dart'; import '../morph_spring.dart'; import '../rendering/morph_painter.dart'; import 'morph_diagnostics.dart'; @@ -29,6 +30,7 @@ final class AnimatedMorphIcon extends StatefulWidget { this.shadows, this.spring = MorphSprings.snappy, this.onEnd, + this.onFallback, this.semanticLabel, this.textDirection, this.applyTextScaling, @@ -75,6 +77,13 @@ final class AnimatedMorphIcon extends StatefulWidget { /// transition. final VoidCallback? onEnd; + /// Called once per failed current preparation request, including in release. + /// + /// Replaces default debug error reporting. Superseded requests and requests + /// completed after disposal do not call this callback. The fallback spring + /// still settles normally and calls [onEnd]. + final ValueChanged? onFallback; + /// The semantic label announced for this icon. /// /// When null, the icon is excluded from the semantics tree. @@ -291,6 +300,7 @@ final class _AnimatedMorphIconState extends State direction: direction, error: error, stack: stack, + onFallback: widget.onFallback, ); } if (!mounted || requestId != _requestId) return; diff --git a/lib/src/widgets/morph_diagnostics.dart b/lib/src/widgets/morph_diagnostics.dart index fdf439f..362466d 100644 --- a/lib/src/widgets/morph_diagnostics.dart +++ b/lib/src/widgets/morph_diagnostics.dart @@ -1,6 +1,7 @@ import 'package:flutter/widgets.dart'; import '../font/binary_reader.dart'; +import '../morph_fallback_details.dart'; final Set _reportedMorphFailures = {}; @@ -10,7 +11,30 @@ void reportMorphFailureOnce({ required TextDirection direction, required Object error, StackTrace? stack, + ValueChanged? onFallback, }) { + if (onFallback != null) { + try { + onFallback( + MorphFallbackDetails( + from: from, + to: to, + error: error, + stackTrace: stack ?? StackTrace.empty, + ), + ); + } catch (callbackError, callbackStack) { + FlutterError.reportError( + FlutterErrorDetails( + exception: callbackError, + stack: callbackStack, + library: 'morphnext', + context: ErrorDescription('while calling onFallback'), + ), + ); + } + return; + } assert(() { if (error is FontDataException && error.cause == null) return true; diff --git a/lib/src/widgets/morph_icon.dart b/lib/src/widgets/morph_icon.dart index f79a9b7..97a3c86 100644 --- a/lib/src/widgets/morph_icon.dart +++ b/lib/src/widgets/morph_icon.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/widgets.dart'; import '../font/font_selection.dart'; +import '../morph_fallback_details.dart'; import '../geometry/morph_plan.dart'; import '../morph_repository.dart'; import '../rendering/morph_painter.dart'; @@ -32,6 +33,7 @@ final class MorphIcon extends StatefulWidget { this.applyTextScaling, this.blendMode, this.fontWeight, + this.onFallback, super.key, }) : assert(fill == null || 0 <= fill && fill <= 1), assert(weight == null || weight > 0), @@ -92,6 +94,13 @@ final class MorphIcon extends StatefulWidget { /// Selects a static font face when the family declares multiple weights. final FontWeight? fontWeight; + /// Called once per failed current preparation request, including in release. + /// + /// Replaces default debug error reporting. Superseded requests and requests + /// completed after disposal do not call this callback. Native icons remain + /// usable regardless of whether a callback is supplied. + final ValueChanged? onFallback; + @override State createState() => _MorphIconState(); } @@ -182,6 +191,7 @@ final class _MorphIconState extends State { direction: direction, error: error, stack: stack, + onFallback: widget.onFallback, ); } } diff --git a/test/morph_fallback_test.dart b/test/morph_fallback_test.dart new file mode 100644 index 0000000..e9f4729 --- /dev/null +++ b/test/morph_fallback_test.dart @@ -0,0 +1,136 @@ +import 'dart:typed_data'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:morphnext/morphnext.dart'; + +import 'morph_icon_test.dart' show testHost; +import 'support/test_asset_bundle.dart'; +import 'support/test_icons.dart'; + +const _missing = IconData(0xe900, fontFamily: 'MissingFont'); + +void main() { + for (final implicit in [false, true]) { + testWidgets( + 'fallback reports once and remains usable (implicit=$implicit)', + (tester) async { + final bundle = fixtureBundle(); + final failures = []; + var ended = 0; + Widget icon(IconData target) => implicit + ? AnimatedMorphIcon( + icon: target, + onFallback: failures.add, + onEnd: () => ended++, + ) + : MorphIcon( + from: testQuadraticIcon, + to: target, + progress: const AlwaysStoppedAnimation(1), + onFallback: failures.add, + ); + await tester.pumpWidget(testHost(bundle, icon(testQuadraticIcon))); + await tester.pumpWidget(testHost(bundle, icon(_missing))); + await tester.pumpAndSettle(); + expect(failures, hasLength(1)); + expect(failures.single.from, testQuadraticIcon); + expect(failures.single.to, _missing); + expect(failures.single.error, isA()); + expect(failures.single.stackTrace.toString(), isNotEmpty); + expect(find.byIcon(_missing), findsOneWidget); + expect(ended, implicit ? 1 : 0); + await tester.pumpWidget(testHost(bundle, icon(_missing))); + await tester.pumpAndSettle(); + expect(failures, hasLength(1)); + expect(tester.takeException(), isNull); + }, + ); + + for (final dispose in [false, true]) { + testWidgets( + 'late failure is ignored (implicit=$implicit, dispose=$dispose)', + (tester) async { + final bundle = DelayedTestAssetBundle.fonts( + manifest: { + testFontFamily: ['broken.ttf'], + }, + assets: {'broken.ttf': Uint8List(4)}, + )..delay('broken.ttf'); + final failures = []; + Widget icon(IconData target) => implicit + ? AnimatedMorphIcon(icon: target, onFallback: failures.add) + : MorphIcon( + from: testQuadraticIcon, + to: target, + progress: const AlwaysStoppedAnimation(1), + onFallback: failures.add, + ); + await tester.pumpWidget(testHost(bundle, icon(testQuadraticIcon))); + await tester.pumpWidget(testHost(bundle, icon(testCompositeIcon))); + await tester.pump(); + await tester.pumpWidget( + dispose + ? const SizedBox() + : testHost(bundle, icon(testQuadraticIcon)), + ); + bundle.release('broken.ttf'); + await tester.pumpAndSettle(); + expect(failures, isEmpty); + expect(tester.takeException(), isNull); + }, + ); + } + } + + testWidgets('throwing callback is reported without stopping fallback', ( + tester, + ) async { + final bundle = fixtureBundle(); + var ended = 0; + await tester.pumpWidget( + testHost(bundle, const AnimatedMorphIcon(icon: testQuadraticIcon)), + ); + await tester.pumpWidget( + testHost( + bundle, + AnimatedMorphIcon( + icon: _missing, + onFallback: (_) => throw StateError('observer failed'), + onEnd: () => ended++, + ), + ), + ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isStateError); + expect(find.byIcon(_missing), findsOneWidget); + expect(ended, 1); + }); + + testWidgets('malformed fonts reach callback instead of debug reporting', ( + tester, + ) async { + final bundle = TestAssetBundle.fonts( + manifest: { + testFontFamily: ['broken.ttf'], + }, + assets: {'broken.ttf': Uint8List(4)}, + ); + final failures = []; + await tester.pumpWidget( + testHost( + bundle, + MorphIcon( + from: testQuadraticIcon, + to: testCompositeIcon, + progress: const AlwaysStoppedAnimation(1), + onFallback: failures.add, + ), + ), + ); + await tester.pumpAndSettle(); + expect(failures, hasLength(1)); + expect(failures.single.error.toString(), contains('bundled font')); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/precache_morph_test.dart b/test/precache_morph_test.dart new file mode 100644 index 0000000..16d439a --- /dev/null +++ b/test/precache_morph_test.dart @@ -0,0 +1,251 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:morphnext/morphnext.dart'; +import 'package:morphnext/src/morph_repository.dart'; +import 'package:morphnext/src/rendering/morph_painter.dart'; + +import 'morph_icon_test.dart' show testHost; +import 'support/test_asset_bundle.dart'; +import 'support/test_font_builder.dart'; +import 'support/test_icons.dart'; + +void main() { + setUp(MorphCache.reset); + tearDown(MorphCache.reset); + testWidgets('precache shares themed forward and reverse plans with widgets', ( + tester, + ) async { + final bundle = fixtureBundle(); + late BuildContext context; + await tester.pumpWidget( + testHost( + bundle, + IconTheme( + data: const IconThemeData(color: Color(0xff000000), weight: 650), + child: Directionality( + textDirection: TextDirection.rtl, + child: Builder( + builder: (value) { + context = value; + return const SizedBox(); + }, + ), + ), + ), + ), + ); + final first = precacheMorph( + context, + from: testQuadraticIcon, + to: testCompositeIcon, + bidirectional: true, + ); + final second = precacheMorph( + context, + from: testQuadraticIcon, + to: testCompositeIcon, + bidirectional: true, + ); + await tester.pumpAndSettle(); + await Future.wait(>[first, second]); + expect(bundle.loadCount('assets/icons.ttf'), 1); + expect(MorphCache.currentMorphs, 2); + expect(MorphCache.currentBytes, greaterThan(0)); + final theme = IconTheme.of(context); + final selection = ( + fill: theme.fill, + weight: theme.weight, + grade: theme.grade, + opticalSize: theme.opticalSize, + fontWeight: null, + ); + final repository = MorphRepository.forBundle(bundle); + final forward = await repository.planFor( + testQuadraticIcon, + testCompositeIcon, + TextDirection.rtl, + selection, + ); + final reverse = await repository.planFor( + testCompositeIcon, + testQuadraticIcon, + TextDirection.rtl, + selection, + ); + for (final entry in [ + (testQuadraticIcon, testCompositeIcon, forward), + (testCompositeIcon, testQuadraticIcon, reverse), + ]) { + await tester.pumpWidget( + testHost( + bundle, + MorphIcon( + from: entry.$1, + to: entry.$2, + weight: 650, + textDirection: TextDirection.rtl, + progress: const AlwaysStoppedAnimation(0.5), + ), + ), + ); + await tester.pumpAndSettle(); + final painter = + tester.widget(find.byType(CustomPaint)).painter + as MorphPainter; + expect(identical(painter.plan, entry.$3), isTrue); + } + }); + + testWidgets('precache obeys global limits, clear and disable', ( + tester, + ) async { + final bundle = fixtureBundle(); + late BuildContext context; + await tester.pumpWidget( + testHost( + bundle, + Builder( + builder: (value) { + context = value; + return const SizedBox(); + }, + ), + ), + ); + Future prepare() async { + final pending = precacheMorph( + context, + from: testQuadraticIcon, + to: testCompositeIcon, + bidirectional: true, + ); + await tester.pumpAndSettle(); + await pending; + } + + MorphCache.configure(maxMorphs: 1, maxBytes: 1 << 20); + await prepare(); + expect(MorphCache.currentMorphs, 1); + MorphCache.clear(); + expect(MorphCache.currentMorphs, 0); + await prepare(); + expect(MorphCache.currentMorphs, 1); + MorphCache.disable(); + await prepare(); + expect(MorphCache.currentMorphs, 0); + expect(MorphCache.currentBytes, 0); + MorphCache.configure(maxMorphs: 2, maxBytes: 1); + await prepare(); + expect(MorphCache.currentMorphs, 0); + }); + + testWidgets('clearing during precache prevents late cache repopulation', ( + tester, + ) async { + final bundle = DelayedTestAssetBundle.fonts( + manifest: { + testFontFamily: ['icons.ttf'], + }, + assets: {'icons.ttf': TestFontBuilder.trueType()}, + )..delay('icons.ttf'); + late BuildContext context; + await tester.pumpWidget( + testHost( + bundle, + Builder( + builder: (value) { + context = value; + return const SizedBox(); + }, + ), + ), + ); + final pending = precacheMorph( + context, + from: testQuadraticIcon, + to: testCompositeIcon, + bidirectional: true, + ); + await tester.pump(); + MorphCache.clear(); + bundle.release('icons.ttf'); + await tester.pumpAndSettle(); + await pending; + expect(MorphCache.currentMorphs, 0); + final retry = precacheMorph( + context, + from: testQuadraticIcon, + to: testCompositeIcon, + bidirectional: true, + ); + await tester.pumpAndSettle(); + await retry; + expect(MorphCache.currentMorphs, 2); + }); + + testWidgets( + 'precache errors are awaitable and transient failures can retry', + (tester) async { + final bundle = fixtureBundle()..failNext('assets/icons.ttf'); + late BuildContext context; + await tester.pumpWidget( + testHost( + bundle, + Builder( + builder: (value) { + context = value; + return const SizedBox(); + }, + ), + ), + ); + final failed = expectLater( + precacheMorph(context, from: testQuadraticIcon, to: testCompositeIcon), + throwsFormatException, + ); + await tester.pumpAndSettle(); + await failed; + final retried = precacheMorph( + context, + from: testQuadraticIcon, + to: testCompositeIcon, + ); + await tester.pumpAndSettle(); + await retried; + expect(bundle.loadCount('assets/icons.ttf'), 2); + }, + ); + + testWidgets('equal icons do not load fonts and context may unmount', ( + tester, + ) async { + final bundle = fixtureBundle(); + late BuildContext context; + await tester.pumpWidget( + testHost( + bundle, + Builder( + builder: (value) { + context = value; + return const SizedBox(); + }, + ), + ), + ); + await precacheMorph( + context, + from: testQuadraticIcon, + to: testQuadraticIcon, + ); + expect(bundle.loadCount('FontManifest.json'), 0); + final pending = precacheMorph( + context, + from: testQuadraticIcon, + to: testCompositeIcon, + ); + await tester.pumpWidget(const SizedBox()); + await tester.pumpAndSettle(); + await pending; + expect(tester.takeException(), isNull); + }); +} diff --git a/test/src/geometry/morph_plan_test.dart b/test/src/geometry/morph_plan_test.dart index e8eab6b..7687fc2 100644 --- a/test/src/geometry/morph_plan_test.dart +++ b/test/src/geometry/morph_plan_test.dart @@ -6,6 +6,24 @@ import 'package:morphnext/src/geometry/morph_plan.dart'; import 'package:morphnext/src/geometry/shape.dart'; void main() { + for (final count in [6, 9]) { + test('matches reordered contours with $count-contour assignment', () { + final contours = [ + for (var i = 0; i < count; i++) circleContour(i * 4.0, 0, 1, 0, null), + ]; + final source = MorphShape(contours); + final target = MorphShape(contours.reversed.toList()); + final plan = buildMorphPlan(source, target); + final output = plan.allocateOutput(); + for (final t in [0.0, 0.37, 1.0]) { + plan.interpolate(t, output); + for (var i = 0; i < count; i++) { + expectPointsClose(output[i], contours[i].points); + } + } + }); + } + test('polar plan is exact at both endpoints', () { final source = irregularLoop(); final target = transformContour( diff --git a/tool/PERFORMANCE.md b/tool/PERFORMANCE.md new file mode 100644 index 0000000..ccca2d5 --- /dev/null +++ b/tool/PERFORMANCE.md @@ -0,0 +1,72 @@ +# Performance checks + +## Geometry CPU benchmark + +From the package root: + +```console +dart run tool/benchmark_geometry.dart +``` + +For before/after comparisons prefer an AOT executable, compiled separately at +each revision with the same Dart SDK and benchmark source: + +```console +dart compile exe tool/benchmark_geometry.dart -o geometry-benchmark.exe +./geometry-benchmark.exe +``` + +The JSON output reports p50/p95 in microseconds after 20 warm-up iterations +and 100 samples. It covers one, six (exact assignment), and nine (greedy +assignment) contours, alternating interrupted transitions, and interpolation +for 1/20/100 icons. The interpolation scenarios reuse output buffers. + +These are synthetic CPU measurements, not Flutter frame times or font I/O. +They do not establish a device frame budget. Record the SDK, build mode and +machine alongside results; compare several runs on an idle machine. Avoid +hard timing assertions in unit tests. Use the checksum as a quick comparison +signal, not a substitute for geometry and golden tests. + +### Local comparison, 2026-09-22 + +Baseline `361f54d` versus the contour-cost optimization, using the same benchmark +source, Dart 3.13.4 AOT, Windows x64, AMD Ryzen 9 7945HX. Values below are the +median of p50 measurements from three alternating before/after runs, in µs. + +| Workload | Before | After | +| --- | ---: | ---: | +| Build, 1 contour | 12.8 | 10.5 | +| Build, 6 contours | 441.6 | 65.1 | +| Build, 9 contours | 214.8 | 101.8 | +| Retarget, 1 contour | 14.4 | 12.4 | +| Retarget, 6 contours | 378.6 | 94.1 | +| Retarget, 9 contours | 264.1 | 147.2 | + +Checksums matched across all six runs. These numbers describe this synthetic +workload and machine; they are not an FPS claim or a mobile/web guarantee. + +## Flutter preparation and frames + +From `example/`: + +```console +flutter run --profile -t lib/performance.dart +``` + +Select 1, 20 or 100 icons, then run cached transitions or rapid interruptions. +Each run measures preparation of `menu ↔ close` with fresh morph caches, +then measures a repeated cached lookup. The standalone harness uses +`MorphCache.clear()` before each run and recreates the icon widgets so previous +animations cannot warm the cache during setup. Platform/OS font-byte caches +may remain warm; this is not a cold disk benchmark. + +The next five seconds measure animation frames separately, with either +one-second toggles or 80 ms retargeting. The UI reports preparation time, +frame count, and UI/raster p95 where frame timings are available. This is a +small-icon workload; use the geometry benchmark for multi-contour stress. + +Use a physical mobile device in profile mode for frame conclusions. On web, +use Chrome DevTools; the in-app frame sample may be unavailable. Inspect +allocation/heap profiles separately in DevTools, including a stream of unique +pairs and repeated interruptions. The harness does not claim total memory +usage from cache byte budgets. diff --git a/tool/benchmark_geometry.dart b/tool/benchmark_geometry.dart new file mode 100644 index 0000000..2e652af --- /dev/null +++ b/tool/benchmark_geometry.dart @@ -0,0 +1,81 @@ +// Run with `dart run tool/benchmark_geometry.dart`. +// This isolates geometry CPU work; it does not measure Flutter frame times. +import 'dart:convert'; +import 'dart:io'; +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:morphnext/src/geometry/morph_plan.dart'; +import 'package:morphnext/src/geometry/resample.dart'; +import 'package:morphnext/src/geometry/shape.dart'; + +void main() { + final results = {}; + var checksum = 0.0; + for (final count in [1, 6, 9]) { + final source = _shape(count, 0); + final target = _shape(count, 0.7); + results['plan_${count}_contours'] = _measure(() { + final plan = buildMorphPlan(source, target); + checksum += plan.items.first.theta; + }); + var plan = buildMorphPlan(source, target); + var reverse = false; + results['retarget_${count}_contours'] = _measure(() { + reverse = !reverse; + plan = buildMorphPlan(plan.snapshot(0.37), reverse ? source : target); + checksum += plan.items.first.source[0]; + }); + for (final icons in [1, 20, 100]) { + final output = plan.allocateOutput(); + results['interpolate_${count}_contours_${icons}_icons'] = _measure(() { + for (var icon = 0; icon < icons; icon++) { + plan.interpolate((icon + 1) / (icons + 1), output); + checksum += output.first[0]; + } + }); + } + } + stdout.writeln( + const JsonEncoder.withIndent(' ').convert({ + 'runtime': Platform.version, + 'mode': 'Dart VM microbenchmark; not Flutter frame timings', + 'warmup': 20, + 'samples': 100, + 'unit': 'microseconds', + 'results': results, + 'checksum': checksum, + }), + ); +} + +Map _measure(void Function() action) { + for (var i = 0; i < 20; i++) { + action(); + } + final times = []; + final watch = Stopwatch(); + for (var i = 0; i < 100; i++) { + watch.reset(); + watch.start(); + action(); + watch.stop(); + times.add(watch.elapsedTicks * 1000000 / watch.frequency); + } + times.sort(); + return {'p50': times[49], 'p95': times[94]}; +} + +MorphShape _shape(int count, double phase) => + sampledShapeFromPointBuffers([ + for (var contour = 0; contour < count; contour++) + Float64List.fromList([ + for (var point = 0; point < 128; point++) ...[ + contour * 0.4 + + (0.1 + 0.02 * math.sin(point * 0.2 + phase)) * + math.cos(point * 2 * math.pi / 128 + phase), + (0.1 + 0.02 * math.sin(point * 0.2 + phase)) * + math.sin(point * 2 * math.pi / 128 + phase), + ], + ]), + ], fillRule: MorphFillRule.nonZero); diff --git a/tool/visual_ab_test.dart b/tool/visual_ab_test.dart new file mode 100644 index 0000000..b3ec04a --- /dev/null +++ b/tool/visual_ab_test.dart @@ -0,0 +1,278 @@ +// Manual A/B capture. Run the identical file against each revision: +// flutter test tool/visual_ab_test.dart --dart-define=VISUAL_OUTPUT= +import 'dart:convert'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:morphnext/morphnext.dart'; +import 'package:morphnext/src/morph_repository.dart'; +import 'package:morphnext/src/rendering/morph_painter.dart'; + +import '../test/support/test_asset_bundle.dart'; +import '../test/support/test_font_builder.dart'; +import '../test/support/test_icons.dart'; + +const _output = String.fromEnvironment('VISUAL_OUTPUT'); +const _boundary = ValueKey('capture'); +const _cell = 128.0; +const _progress = [0.0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0]; + +void main() { + final fixtures = fixtureBundle(); + final variable = TestAssetBundle.fonts( + manifest: { + testFontFamily: ['variable.ttf'], + }, + assets: {'variable.ttf': TestFontBuilder.variableTrueType()}, + ); + final cases = <_Case>[ + _Case('menu-close', Icons.menu, Icons.close, rootBundle), + _Case('heart-hole', Icons.favorite_border, Icons.favorite, rootBundle), + _Case('play-pause', Icons.play_arrow, Icons.pause, rootBundle), + _Case('settings-tune', Icons.settings, Icons.tune, rootBundle), + _Case('apps-dialpad', Icons.apps, Icons.dialpad, rootBundle), + _Case('drag-more', Icons.drag_indicator, Icons.more_horiz, rootBundle), + _Case('directional', Icons.arrow_back, Icons.arrow_forward, rootBundle), + _Case('visibility', Icons.visibility, Icons.visibility_off, rootBundle), + _Case( + 'fixture-holes', + testSquareWithHoleIcon, + testDiamondWithHoleIcon, + fixtures, + ), + _Case('cff-truetype', testCffIcon, testQuadraticIcon, fixtures), + _Case('composite', testCompositeIcon, testCompressedIcon, fixtures), + _Case( + 'variable-weight', + testQuadraticIcon, + testCompositeIcon, + variable, + weight: 650, + ), + ]; + + setUpAll(() async { + if (_output.isEmpty) throw ArgumentError('Set VISUAL_OUTPUT'); + Directory(_output).createSync(recursive: true); + await loadTestIconFonts(); + await (FontLoader( + 'MaterialIcons', + )..addFont(rootBundle.load('fonts/MaterialIcons-Regular.otf'))).load(); + }); + + testWidgets('controlled frames and interrupted springs', (tester) async { + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final files = []; + final geometry = {}; + for (final direction in TextDirection.values) { + for (final entry in cases) { + final plan = await tester.runAsync( + () => MorphRepository.forBundle(entry.bundle) + .planFor(entry.from, entry.to, direction, ( + fill: 0.0, + weight: entry.weight ?? 400.0, + grade: 0.0, + opticalSize: 48.0, + fontWeight: null, + )), + ); + geometry['${entry.name}-${direction.name}'] = plan!.items.length; + } + } + + for (final size in [24.0, 48.0, 96.0]) { + for (final dpr in [1.0, 2.0]) { + for (final direction in TextDirection.values) { + for (final dark in [false, true]) { + final name = + '${size.toInt()}-${dpr.toInt()}-${direction.name}-${dark ? 'dark' : 'light'}'; + final extent = Size(_cell * _progress.length, _cell * cases.length); + tester.view.devicePixelRatio = dpr; + tester.view.physicalSize = extent * dpr; + await tester.pumpWidget( + _host( + direction, + dark, + dpr, + Column( + children: [ + for (final entry in cases) + Row( + children: [ + for (final progress in _progress) + _tile( + entry, + MorphIcon( + from: entry.from, + to: entry.to, + size: size, + weight: entry.weight, + progress: AlwaysStoppedAnimation(progress), + ), + ), + ], + ), + ], + ), + ), + ); + await tester.pumpAndSettle(); + expect( + find.byType(CustomPaint), + findsNWidgets(cases.length * _progress.length), + ); + await _capture(tester, 'controlled-$name.png'); + files.add('controlled-$name.png'); + } + } + } + } + + for (final dpr in [1.0, 2.0]) { + for (final direction in TextDirection.values) { + final name = '${dpr.toInt()}-${direction.name}'; + tester.view.devicePixelRatio = dpr; + tester.view.physicalSize = Size(_cell * cases.length, _cell) * dpr; + await tester.pumpWidget(const SizedBox()); + Future target(bool to) async { + await tester.pumpWidget( + _host( + direction, + false, + dpr, + Row( + children: [ + for (final entry in cases) + _tile( + entry, + AnimatedMorphIcon( + key: ValueKey(entry.name), + icon: to ? entry.to : entry.from, + size: 80, + weight: entry.weight, + ), + ), + ], + ), + ), + ); + // Flush preparation futures without advancing the animation clock. + for (var i = 0; i < 20; i++) { + await tester.pump(); + } + } + + Future capture(String phase) async { + final file = 'spring-$name-$phase.png'; + await _capture(tester, file); + files.add(file); + } + + await target(false); + await capture('00-initial'); + await target(true); + expect(find.byType(CustomPaint), findsNWidgets(cases.length)); + await capture('01-start'); + await tester.pump(const Duration(milliseconds: 16)); + await capture('02-16ms'); + await tester.pump(const Duration(milliseconds: 54)); + await capture('03-before-retarget'); + await target(false); + await capture('04-after-retarget'); + await tester.pump(const Duration(milliseconds: 16)); + await capture('05-retarget-16ms'); + await tester.pump(const Duration(milliseconds: 24)); + await capture('06-before-second-retarget'); + await target(true); + await capture('07-after-second-retarget'); + for (var frame = 0; frame < 12; frame++) { + await tester.pump(const Duration(milliseconds: 32)); + await capture('08-tail-${frame.toString().padLeft(2, '0')}'); + } + await tester.pumpAndSettle(); + await capture('09-settled'); + for (final widget in tester.widgetList( + find.byType(CustomPaint), + )) { + expect((widget.painter! as MorphPainter).progress.value, 1); + } + } + } + expect(tester.takeException(), isNull); + File('$_output/manifest.json').writeAsStringSync( + jsonEncode({ + 'cases': [for (final entry in cases) entry.name], + 'progress': _progress, + 'contourPairs': geometry, + 'files': files, + }), + ); + }, timeout: const Timeout(Duration(minutes: 5))); +} + +Widget _host(TextDirection direction, bool dark, double dpr, Widget child) => + MediaQuery( + data: MediaQueryData(devicePixelRatio: dpr), + child: Directionality( + textDirection: direction, + child: IconTheme( + data: IconThemeData( + color: dark ? const Color(0xffe2eaff) : const Color(0xff234785), + shadows: dark + ? const [ + Shadow( + color: Color(0x66000000), + blurRadius: 2, + offset: Offset(1, 2), + ), + ] + : null, + ), + child: RepaintBoundary( + key: _boundary, + child: ColoredBox( + color: dark ? const Color(0xff162032) : const Color(0xfff4f6fa), + child: child, + ), + ), + ), + ), + ); + +Widget _tile(_Case entry, Widget child) => SizedBox.square( + dimension: _cell, + child: DefaultAssetBundle( + bundle: entry.bundle, + child: Center(child: child), + ), +); + +Future _capture(WidgetTester tester, String name) async { + final boundary = tester.renderObject( + find.byKey(_boundary), + ); + await tester.runAsync(() async { + final image = await boundary.toImage( + pixelRatio: tester.view.devicePixelRatio, + ); + final data = await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); + File('$_output/$name').writeAsBytesSync( + data!.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes), + ); + }); +} + +class _Case { + const _Case(this.name, this.from, this.to, this.bundle, {this.weight}); + final String name; + final IconData from; + final IconData to; + final AssetBundle bundle; + final double? weight; +}