// 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: // - 8–20 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.0–4.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 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 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 _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 = >[]; 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); // ~8–16 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; assert(strokes.isNotEmpty, 'Dataset must not be empty'); final raw = strokes.first as Map; // Re-encode → decode to simulate fromJson parsing. final encoded = jsonEncode(raw); final decoded = jsonDecode(encoded) as Map; 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; assert(points.isNotEmpty, 'stroke must have at least one point'); final p0 = points.first as Map; 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)'); }