2026-06-23 09:57:04 +08:00
|
|
|
|
// ignore_for_file: avoid_print
|
2026-06-21 20:15:21 +08:00
|
|
|
|
// 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<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');
|
2026-06-23 09:57:04 +08:00
|
|
|
|
print('Size: $sizeKb KB');
|
2026-06-21 20:15:21 +08:00
|
|
|
|
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); // ~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<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)');
|
|
|
|
|
|
}
|