Files
BadNote/tool/gen_dense_strokes.dart
Akiba So d1b265dc70
Some checks failed
CI / Windows build (push) Has been cancelled
chore: clean analyzer to zero issues
Whole-project `flutter analyze` exited 1 on 17 pre-existing info/warning
lints (no errors) in dev tools and test files. Clean them so analyze is
green:
- editor_repository_test: drop the redundant sqflite_common import; keep
  the used utils import with a transitive-dep ignore.
- pen_* widget tests: `(_, __)` wildcard params -> `(_, _)`.
- search_snippet_test / gen_bench_pdf / gen_dense_strokes: drop needless
  interpolation braces; remove an unused `Size` show and an unused local;
  mark the gen tool as print-allowed.

No runtime behavior changed. flutter analyze: No issues found. Affected
tests: 22/22 pass.
2026-06-23 09:57:04 +08:00

213 lines
7.9 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ignore_for_file: avoid_print
// tool/gen_dense_strokes.dart
//
// Generates synthetic ink-stroke datasets as JSON matching InkStroke.toJson()
// (from lib/models/ink_stroke.dart + lib/models/ink_point.dart) exactly.
//
// Output: test/assets/dense_strokes.json
// Format:
// {
// "2000": [ ...2000 InkStroke objects... ],
// "5000": [ ...5000 InkStroke objects... ]
// }
//
// Each stroke:
// - 820 InkPoint objects
// - x/y ∈ [0,1] (normalized page space, matching InkStroke coordinate model)
// - pressure ∈ [0.2, 1.0]
// - tilt ∈ [0.0, 30.0] degrees
// - pointerDeviceKind: "stylus" (surface pen benchmark)
// - tool: "pen"
// - color: varied from a palette of realistic ink colors
// - strokeWidth: 1.04.0
//
// Usage:
// dart run tool/gen_dense_strokes.dart # 2000 + 5000 (defaults)
// dart run tool/gen_dense_strokes.dart 500 1000 # custom counts
//
// The counts are also the JSON keys (converted to strings).
import 'dart:convert';
import 'dart:io';
import 'dart:math';
void main(List<String> args) {
final counts = args.isNotEmpty
? args.map(int.parse).toList()
: [2000, 5000];
final outputPath = _resolveOutputPath();
File(outputPath).parent.createSync(recursive: true);
final rng = Random(12345); // deterministic seed for reproducibility
final Map<String, dynamic> result = {};
for (final count in counts) {
final strokes = List.generate(count, (i) => _generateStroke(rng, i));
result['$count'] = strokes;
print('Generated $count strokes');
}
final jsonStr = const JsonEncoder.withIndent(null).convert(result);
File(outputPath).writeAsStringSync(jsonStr);
final sizeKb = (File(outputPath).lengthSync() / 1024).toStringAsFixed(1);
print('Output: $outputPath');
print('Size: $sizeKb KB');
for (final count in counts) {
print(' "$count": ${(result[count.toString()] as List).length} strokes');
}
// ── Inline round-trip sanity check ─────────────────────────────────────
// Verify that the first stroke in the first dataset round-trips through the
// InkStroke JSON shape without data loss (field names, enum values, types).
_verifyRoundTrip(result[counts.first.toString()]);
}
/// Generates one InkStroke as a plain Map matching InkStroke.toJson().
///
/// Field names and enum string values are taken directly from the generated
/// code in:
/// lib/models/ink_stroke.g.dart (_$$InkStrokeImplToJson)
/// lib/models/ink_point.g.dart (_$$InkPointImplToJson)
///
/// InkStroke fields:
/// id, points, tool, color, strokeWidth, createdAt, filled,
/// textContent, fontSize
///
/// InkPoint fields:
/// x, y, pressure, tilt, timestamp, pointerDeviceKind
Map<String, dynamic> _generateStroke(Random rng, int index) {
// Pick a random color from a set of realistic ink tones.
// Stored as ARGB int (0xFF......) matching @Default(0xFF000000).
final color = _pickColor(rng);
final strokeWidth = 1.0 + rng.nextDouble() * 3.0; // [1.0, 4.0]
final pointCount = 8 + rng.nextInt(13); // [8, 20]
// Start position — random page location
double x = 0.05 + rng.nextDouble() * 0.90; // [0.05, 0.95]
double y = 0.05 + rng.nextDouble() * 0.90;
// Simulate a realistic hand-drawn stroke: incremental movement with
// small steps (realistic velocity on a ~A4 page at ~1000 DPI effective).
final points = <Map<String, dynamic>>[];
int timestamp = DateTime.now().millisecondsSinceEpoch - (5000 - index * 2);
for (int p = 0; p < pointCount; p++) {
// Step in a semi-consistent direction with jitter
final angle = rng.nextDouble() * 2 * pi;
final step = 0.005 + rng.nextDouble() * 0.015; // [0.005, 0.02] page-units
x = (x + cos(angle) * step).clamp(0.0, 1.0);
y = (y + sin(angle) * step).clamp(0.0, 1.0);
// Pressure ramps up then down (pen-press profile)
final t = p / (pointCount - 1);
final basePressure = sin(t * pi); // 0→1→0 over the stroke
final pressure = (0.2 + basePressure * 0.8 + (rng.nextDouble() - 0.5) * 0.1)
.clamp(0.2, 1.0);
final tilt = rng.nextDouble() * 30.0; // [0, 30] degrees
timestamp += 8 + rng.nextInt(8); // ~816 ms between points (120 Hz stylus)
points.add({
'x': _round6(x),
'y': _round6(y),
'pressure': _round6(pressure),
'tilt': _round6(tilt),
'timestamp': timestamp,
// enum string from _$InputDeviceKindEnumMap in ink_point.g.dart
'pointerDeviceKind': 'stylus',
});
}
// createdAt as ISO-8601 string (DateTime.toIso8601String() format)
final createdAt = DateTime.fromMillisecondsSinceEpoch(timestamp - pointCount * 12)
.toIso8601String();
return {
'id': 'bench_${index.toString().padLeft(6, '0')}',
'points': points,
// enum string from _$PenToolEnumMap in ink_stroke.g.dart
'tool': 'pen',
'color': color,
'strokeWidth': _round6(strokeWidth),
'createdAt': createdAt,
'filled': false,
'textContent': null,
'fontSize': 14.0,
};
}
/// Returns one of several realistic ink colors as an ARGB int.
/// These match the range of values that @Default(0xFF000000) int color stores.
int _pickColor(Random rng) {
// Palette: black, dark-blue, dark-red, dark-green, dark-purple, charcoal
const palette = [
0xFF000000, // black
0xFF0A1E50, // dark navy
0xFF800020, // dark red
0xFF1A4D1A, // dark green
0xFF3D0066, // dark purple
0xFF1C1C1C, // charcoal
0xFF002B5C, // midnight blue
0xFF4B0000, // deep crimson
];
return palette[rng.nextInt(palette.length)];
}
/// Rounds a double to 6 decimal places to keep JSON compact and exact.
double _round6(double v) => double.parse(v.toStringAsFixed(6));
/// Resolves test/assets/dense_strokes.json relative to this script.
String _resolveOutputPath() {
final scriptUri = Platform.script;
final toolDir = File.fromUri(scriptUri).parent;
final projectRoot = toolDir.parent;
return '${projectRoot.path}/test/assets/dense_strokes.json';
}
/// Minimal round-trip verification that confirms the JSON shape produced
/// here matches InkStroke.fromJson() expectations.
///
/// We cannot call actual Dart model classes (they import Flutter packages),
/// so we do a structural check: re-parse the JSON and assert that every
/// required field survives the round-trip with the correct type.
void _verifyRoundTrip(dynamic dataset) {
final strokes = dataset as List<dynamic>;
assert(strokes.isNotEmpty, 'Dataset must not be empty');
final raw = strokes.first as Map<String, dynamic>;
// Re-encode → decode to simulate fromJson parsing.
final encoded = jsonEncode(raw);
final decoded = jsonDecode(encoded) as Map<String, dynamic>;
assert(decoded['id'] is String, 'id must be String');
assert(decoded['points'] is List, 'points must be List');
assert(decoded['tool'] == 'pen', 'tool enum must be "pen"');
assert(decoded['color'] is int || decoded['color'] is num, 'color must be int/num');
assert(decoded['strokeWidth'] is double || decoded['strokeWidth'] is num,
'strokeWidth must be num');
assert(decoded['createdAt'] is String, 'createdAt must be String (ISO-8601)');
assert(decoded['filled'] is bool, 'filled must be bool');
assert(decoded['fontSize'] is double || decoded['fontSize'] is num,
'fontSize must be num');
final points = decoded['points'] as List<dynamic>;
assert(points.isNotEmpty, 'stroke must have at least one point');
final p0 = points.first as Map<String, dynamic>;
assert(p0['x'] is num, 'InkPoint.x must be num');
assert(p0['y'] is num, 'InkPoint.y must be num');
assert(p0['pressure'] is num, 'InkPoint.pressure must be num');
assert(p0['tilt'] is num, 'InkPoint.tilt must be num');
assert(p0['timestamp'] is int || p0['timestamp'] is num,
'InkPoint.timestamp must be int/num');
assert(p0['pointerDeviceKind'] == 'stylus',
'pointerDeviceKind must be "stylus"');
print('Round-trip check: PASS (all required fields present with correct types)');
}