Initial commit
This commit is contained in:
commit
1ab1f067b5
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
@ -0,0 +1,29 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
|
||||
/pubspec.lock
|
||||
**/doc/api/
|
||||
.dart_tool/
|
||||
.packages
|
||||
build/
|
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
[submodule "assets/kanjivg"]
|
||||
path = assets/kanjivg
|
||||
url = git@github.com:KanjiVG/kanjivg.git
|
10
.metadata
Normal file
10
.metadata
Normal file
@ -0,0 +1,10 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: 77d935af4db863f6abd0b9c31c7e6df2a13de57b
|
||||
channel: stable
|
||||
|
||||
project_type: package
|
3
CHANGELOG.md
Normal file
3
CHANGELOG.md
Normal file
@ -0,0 +1,3 @@
|
||||
## 0.0.1
|
||||
|
||||
* TODO: Describe initial release.
|
1
LICENSE
Normal file
1
LICENSE
Normal file
@ -0,0 +1 @@
|
||||
TODO: Add your license here.
|
40
README.md
Normal file
40
README.md
Normal file
@ -0,0 +1,40 @@
|
||||
<!--
|
||||
This README describes the package. If you publish this package to pub.dev,
|
||||
this README's contents appear on the landing page for your package.
|
||||
|
||||
For information about how to write a good package README, see the guide for
|
||||
[writing package pages](https://dart.dev/guides/libraries/writing-package-pages).
|
||||
|
||||
For general information about developing packages, see the Dart guide for
|
||||
[creating packages](https://dart.dev/guides/libraries/create-library-packages)
|
||||
and the Flutter guide for
|
||||
[developing packages and plugins](https://flutter.dev/developing-packages).
|
||||
-->
|
||||
|
||||
TODO: Put a short description of the package here that helps potential users
|
||||
know whether this package might be useful for them.
|
||||
|
||||
## Features
|
||||
|
||||
TODO: List what your package can do. Maybe include images, gifs, or videos.
|
||||
|
||||
## Getting started
|
||||
|
||||
TODO: List prerequisites and provide or point to information on how to
|
||||
start using the package.
|
||||
|
||||
## Usage
|
||||
|
||||
TODO: Include short and useful examples for package users. Add longer examples
|
||||
to `/example` folder.
|
||||
|
||||
```dart
|
||||
const like = 'sample';
|
||||
```
|
||||
|
||||
## Additional information
|
||||
|
||||
The [svg library used](lib/svg) is mostly a rewrite of pythons [svg.path][svg.path].
|
||||
This is what kanimaji originally used for animation, and even thought there's a lot of svg path parsers in dart, I found none that was able to calculate the length of the path. If you do find one, please let me know!
|
||||
|
||||
[svg.path]: https://pypi.org/project/svg.path/
|
4
analysis_options.yaml
Normal file
4
analysis_options.yaml
Normal file
@ -0,0 +1,4 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
1
assets/kanjivg
Submodule
1
assets/kanjivg
Submodule
@ -0,0 +1 @@
|
||||
Subproject commit e1d99250c5477796c1d08bc3e032566c5be3538a
|
7
lib/kanimaji.dart
Normal file
7
lib/kanimaji.dart
Normal file
@ -0,0 +1,7 @@
|
||||
library kanimaji;
|
||||
|
||||
/// A Calculator.
|
||||
class Calculator {
|
||||
/// Returns [value] plus 1.
|
||||
int addOne(int value) => value + 1;
|
||||
}
|
581
lib/kanimaji/animate_kanji.dart
Normal file
581
lib/kanimaji/animate_kanji.dart
Normal file
@ -0,0 +1,581 @@
|
||||
/// ignore_for_file: non_constant_identifier_names, avoid_print, unused_local_variable, dead_code, constant_identifier_names
|
||||
|
||||
import 'dart:io';
|
||||
import 'dart:math' show min;
|
||||
|
||||
import '../svg/parser.dart';
|
||||
|
||||
import 'bezier_cubic.dart' as bezier_cubic;
|
||||
import 'settings.dart';
|
||||
import 'package:xml/xml.dart';
|
||||
import 'package:path/path.dart';
|
||||
|
||||
double computePathLength(String path) =>
|
||||
parse_path(path).size(error: 1e-8).toDouble();
|
||||
|
||||
String shescape(path) => "'${path.replace(RegExp(r"(?=['\\\\])"), "\\\\")}'";
|
||||
|
||||
String dedent(String s) {
|
||||
final withoutEmptyLines =
|
||||
s.split('\n').where((l) => l.isNotEmpty).toList();
|
||||
final whitespaceAmounts = [
|
||||
for (final line in withoutEmptyLines)
|
||||
line.split('').takeWhile((c) => c == ' ').length
|
||||
];
|
||||
final whitespaceToRemove = whitespaceAmounts.reduce(min);
|
||||
return withoutEmptyLines.map((l) => l.replaceRange(0, whitespaceToRemove, '')).join('\n');
|
||||
}
|
||||
|
||||
// ease, ease-in, etc:
|
||||
// https://developer.mozilla.org/en-US/docs/Web/CSS/timing-function#ease
|
||||
const pt1 = bezier_cubic.Point(0, 0);
|
||||
const easeCt1 = bezier_cubic.Point(0.25, 0.1);
|
||||
const easeCt2 = bezier_cubic.Point(0.25, 1.0);
|
||||
const easeInCt1 = bezier_cubic.Point(0.42, 0.0);
|
||||
const easeInCt2 = bezier_cubic.Point(1.0, 1.0);
|
||||
const easeInOutCt1 = bezier_cubic.Point(0.42, 0.0);
|
||||
const easeInOutCt2 = bezier_cubic.Point(0.58, 1.0);
|
||||
const easeOutCt1 = bezier_cubic.Point(0.0, 0.0);
|
||||
const easeOutCt2 = bezier_cubic.Point(0.58, 1.0);
|
||||
const pt2 = bezier_cubic.Point(1, 1);
|
||||
|
||||
double linear(x) => x;
|
||||
double ease(x) => bezier_cubic.value(pt1, easeCt1, easeCt2, pt2, x);
|
||||
double easeIn(x) => bezier_cubic.value(pt1, easeInCt1, easeInCt2, pt2, x);
|
||||
double easeInOut(x) =>
|
||||
bezier_cubic.value(pt1, easeInOutCt1, easeInOutCt2, pt2, x);
|
||||
double easeOut(x) => bezier_cubic.value(pt1, easeOutCt1, easeOutCt2, pt2, x);
|
||||
|
||||
const Map<String, double Function(double)> timingFunctions = {
|
||||
'linear': linear,
|
||||
'ease': ease,
|
||||
'ease-in': easeIn,
|
||||
'ease-in-out': easeInOut,
|
||||
'ease-out': easeOut
|
||||
};
|
||||
|
||||
final myTimingFunction = timingFunctions[TIMING_FUNCTION]!;
|
||||
|
||||
// we will need this to deal with svg
|
||||
const namespaces = {
|
||||
'n': 'http://www.w3.org/2000/svg',
|
||||
'xlink': 'http://www.w3.org/1999/xlink'
|
||||
};
|
||||
// etree.register_namespace("xlink","http://www.w3.org/1999/xlink")
|
||||
// final parser = etree.XMLParser(remove_blank_text=true);
|
||||
|
||||
void createAnimation(String filename) {
|
||||
print('processing $filename');
|
||||
final String filenameNoext = filename.replaceAll(RegExp(r'\.[^\.]+$'), '');
|
||||
final String baseid = basename(filenameNoext);
|
||||
|
||||
// load xml
|
||||
final XmlDocument doc = XmlDocument.parse(File(filename).readAsStringSync());
|
||||
|
||||
// for xlink namespace introduction
|
||||
doc.rootElement.setAttribute('xmlns:xlink', namespaces['xlink']);
|
||||
doc.rootElement.setAttribute('xlink:used', '');
|
||||
|
||||
// clear all extra elements this program may have previously added
|
||||
for (final XmlNode el in doc
|
||||
.getElement('svg', namespace: namespaces['n'])
|
||||
?.getElement('style', namespace: namespaces['n'])
|
||||
?.children ??
|
||||
[]) {
|
||||
if (RegExp(r'-Kanimaji$').hasMatch(el.getAttribute('id') ?? '')) {
|
||||
el.parent!.children.remove(el);
|
||||
}
|
||||
}
|
||||
for (final XmlNode g in doc
|
||||
.getElement('svg', namespace: namespaces['n'])
|
||||
?.getElement('g', namespace: namespaces['n'])
|
||||
?.children ??
|
||||
[]) {
|
||||
if (RegExp(r'-Kanimaji$').hasMatch(g.getAttribute('id') ?? '')) {
|
||||
g.parent!.children.remove(g);
|
||||
}
|
||||
}
|
||||
|
||||
// create groups with a copies (references actually) of the paths
|
||||
XmlDocumentFragment pathCopyGroup(
|
||||
{required String id, required String color, required double width}) {
|
||||
final builder = XmlBuilder();
|
||||
builder.element(
|
||||
'g',
|
||||
attributes: {
|
||||
'id': 'kvg:$baseid-$id-Kanimaji',
|
||||
'style': 'fill:none;'
|
||||
'stroke:$color;'
|
||||
'stroke-width:$width;'
|
||||
'stroke-linecap:round;'
|
||||
'stroke-linejoin:round;',
|
||||
},
|
||||
isSelfClosing: false,
|
||||
);
|
||||
return builder.buildFragment();
|
||||
}
|
||||
|
||||
final bgGroup = pathCopyGroup(
|
||||
id: 'bg',
|
||||
color: STOKE_UNFILLED_COLOR,
|
||||
width: STOKE_UNFILLED_WIDTH,
|
||||
);
|
||||
final animGroup = pathCopyGroup(
|
||||
id: 'anim',
|
||||
color: STOKE_FILLED_COLOR,
|
||||
width: STOKE_FILLED_WIDTH,
|
||||
);
|
||||
|
||||
late final XmlDocumentFragment brushGroup;
|
||||
late final XmlDocumentFragment brushBrdGroup;
|
||||
if (SHOW_BRUSH) {
|
||||
brushGroup = pathCopyGroup(
|
||||
id: 'brush',
|
||||
color: BRUSH_COLOR,
|
||||
width: BRUSH_WIDTH,
|
||||
);
|
||||
brushBrdGroup = pathCopyGroup(
|
||||
id: 'brush-brd',
|
||||
color: BRUSH_BORDER_COLOR,
|
||||
width: BRUSH_BORDER_WIDTH,
|
||||
);
|
||||
}
|
||||
|
||||
// compute total length and time, at first
|
||||
double totlen = 0;
|
||||
double tottime = 0;
|
||||
|
||||
// for (final g in doc.xpath("/n:svg/n:g", namespaces=namespaces) {
|
||||
for (final XmlNode g in doc
|
||||
.getElement('svg', namespace: namespaces['n'])
|
||||
?.getElement('g', namespace: namespaces['n'])
|
||||
?.children ??
|
||||
[]) {
|
||||
if (RegExp(r'^kvg:StrokeNumbers_').hasMatch(g.getAttribute('id') ?? '')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (final p in g.findAllElements('path', namespace: namespaces['n'])) {
|
||||
final pathlen = computePathLength(p.getAttribute('d')!);
|
||||
final duration = stroke_length_to_duration(pathlen);
|
||||
totlen += pathlen;
|
||||
tottime += duration;
|
||||
}
|
||||
}
|
||||
|
||||
double animationTime = time_rescale(tottime); // math.pow(3 * tottime, 2.0/3)
|
||||
tottime += WAIT_AFTER * tottime / animationTime;
|
||||
final double actualAnimationTime = animationTime;
|
||||
animationTime += WAIT_AFTER;
|
||||
|
||||
final Map<int, String> staticCss = {};
|
||||
late String animatedCss;
|
||||
final jsAnimationElements = []; // collect the ids of animating elements
|
||||
final jsAnimationTimes = []; // the time set (as default) for each animation
|
||||
|
||||
String jsAnimatedCss = '';
|
||||
|
||||
const String cssHeader =
|
||||
'\n/* CSS automatically generated by kanimaji.py, do not edit! */\n';
|
||||
if (GENERATE_SVG) animatedCss = cssHeader;
|
||||
if (GENERATE_JS_SVG) {
|
||||
jsAnimatedCss += cssHeader +
|
||||
'''
|
||||
.backward {\n
|
||||
animation-direction: reverse !important;\n
|
||||
}
|
||||
''';
|
||||
}
|
||||
if (GENERATE_GIF) {
|
||||
// final static_css = {};
|
||||
final last_frame_index = actualAnimationTime ~/ GIF_FRAME_DURATION + 1;
|
||||
for (int i = 0; i < last_frame_index + 1; i++) {
|
||||
staticCss[i] = cssHeader;
|
||||
}
|
||||
final last_frame_delay =
|
||||
animationTime - last_frame_index * GIF_FRAME_DURATION;
|
||||
}
|
||||
double elapsedlen = 0;
|
||||
double elapsedtime = 0;
|
||||
|
||||
|
||||
// add css elements for all strokes
|
||||
for (final XmlNode g in doc
|
||||
.getElement('svg', namespace: namespaces['n'])!
|
||||
.findElements('g', namespace: namespaces['n'])) {
|
||||
// for (final g in doc.xpath("/n:svg/n:g", namespaces=namespaces)){
|
||||
final groupid = g.getAttribute('id') ?? '';
|
||||
if (RegExp(r'^kvg:StrokeNumbers_').hasMatch(groupid)) {
|
||||
final String rule = dedent('''
|
||||
#${groupid.replaceAll(':', '\\3a ')} {
|
||||
display: none;
|
||||
}
|
||||
''');
|
||||
if (GENERATE_SVG) animatedCss += rule;
|
||||
if (GENERATE_JS_SVG) jsAnimatedCss += rule;
|
||||
if (GENERATE_GIF) {
|
||||
for (final k in staticCss.keys) {
|
||||
staticCss[k] = staticCss[k]! + rule;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
final gidcss = groupid.replaceAll(':', '\\3a ');
|
||||
final rule = dedent('''
|
||||
#$gidcss {
|
||||
stroke-width: ${STOKE_BORDER_WIDTH.toStringAsFixed(1)}px !important;
|
||||
stroke: $STOKE_BORDER_COLOR !important;
|
||||
}
|
||||
''');
|
||||
|
||||
if (GENERATE_SVG) animatedCss += rule;
|
||||
if (GENERATE_JS_SVG) jsAnimatedCss += rule;
|
||||
if (GENERATE_GIF) {
|
||||
for (final k in staticCss.keys) {
|
||||
staticCss[k] = staticCss[k]! + rule;
|
||||
}
|
||||
}
|
||||
|
||||
for (final p in g.findAllElements("path", namespace: namespaces['n'])) {
|
||||
final pathid = p.getAttribute('id') as String;
|
||||
final pathidcss = pathid.replaceAll(':', '\\3a ');
|
||||
|
||||
if (GENERATE_JS_SVG) jsAnimationElements.add({});
|
||||
|
||||
void addHref(String suffix, XmlDocumentFragment element) {
|
||||
final builder = XmlBuilder();
|
||||
builder.element(
|
||||
'use',
|
||||
attributes: {'id': '$pathid-$suffix', 'xlink:href': '#$pathid'},
|
||||
);
|
||||
final ref = builder.buildFragment();
|
||||
element.firstElementChild!.children.add(ref);
|
||||
if (GENERATE_JS_SVG) jsAnimationElements.last[suffix] = ref;
|
||||
}
|
||||
|
||||
final String bgPathidcss = '$pathidcss-bg';
|
||||
final String animPathidcss = '$pathidcss-anim';
|
||||
final String brushPathidcss = '$pathidcss-brush';
|
||||
final String brushBrdPathidcss = '$pathidcss-brush-brd';
|
||||
|
||||
addHref('bg', bgGroup);
|
||||
addHref('anim', animGroup);
|
||||
|
||||
if (SHOW_BRUSH) {
|
||||
addHref('brush', brushGroup);
|
||||
addHref('brush-brd', brushBrdGroup);
|
||||
}
|
||||
|
||||
final pathname = pathid.replaceAll(RegExp(r'^kvg:'), '');
|
||||
final pathlen = computePathLength(p.getAttribute('d') as String);
|
||||
final duration = stroke_length_to_duration(pathlen);
|
||||
final relduration = duration * tottime / animationTime; // unscaled time
|
||||
|
||||
if (GENERATE_JS_SVG) {
|
||||
jsAnimationTimes.add(relduration);
|
||||
}
|
||||
|
||||
final newelapsedlen = elapsedlen + pathlen;
|
||||
final newelapsedtime = elapsedtime + duration;
|
||||
final animStart = elapsedtime / tottime * 100;
|
||||
final animEnd = newelapsedtime / tottime * 100;
|
||||
|
||||
if (GENERATE_SVG) {
|
||||
// animation stroke progression
|
||||
animatedCss += dedent('''
|
||||
@keyframes strike-$pathname {
|
||||
0% { stroke-dashoffset: ${pathlen.toStringAsFixed(3)}; }
|
||||
${animStart.toStringAsFixed(3)}% { stroke-dashoffset: ${pathlen.toStringAsFixed(3)}; }
|
||||
${animEnd.toStringAsFixed(3)}% { stroke-dashoffset: 0; }
|
||||
100% { stroke-dashoffset: 0; }
|
||||
}
|
||||
@keyframes showhide-$pathname {
|
||||
${animStart.toStringAsFixed(3)}% { visibility: hidden; }
|
||||
${animEnd.toStringAsFixed(3)}% { stroke: $STOKE_FILLING_COLOR; }
|
||||
}
|
||||
#$animPathidcss {
|
||||
stroke-dasharray: ${pathlen.toStringAsFixed(3)} ${pathlen.toStringAsFixed(3)};
|
||||
stroke-dashoffset: 0;
|
||||
animation: strike-$pathname ${animationTime.toStringAsFixed(3)}s $TIMING_FUNCTION infinite,
|
||||
showhide-$pathname ${animationTime.toStringAsFixed(3)}s step-start infinite;
|
||||
}
|
||||
''');
|
||||
|
||||
if (SHOW_BRUSH) {
|
||||
// brush element visibility
|
||||
animatedCss += dedent('''
|
||||
@keyframes showhide-brush-$pathname {
|
||||
${animStart.toStringAsFixed(3)}% { visibility: hidden; }
|
||||
${animEnd.toStringAsFixed(3)}% { visibility: visible; }
|
||||
100% { visibility: hidden; }
|
||||
}
|
||||
#$brushPathidcss, #$brushBrdPathidcss {
|
||||
stroke-dasharray: 0 ${pathlen.toStringAsFixed(3)};
|
||||
animation: strike-$pathname ${animationTime.toStringAsFixed(3)}s $TIMING_FUNCTION infinite,
|
||||
showhide-brush-$pathname ${animationTime.toStringAsFixed(3)}s step-start infinite;
|
||||
}
|
||||
''');
|
||||
}
|
||||
}
|
||||
|
||||
if (GENERATE_JS_SVG) {
|
||||
jsAnimatedCss += '\n/* stroke $pathid */';
|
||||
|
||||
// brush and background hidden by default
|
||||
if (SHOW_BRUSH) {
|
||||
jsAnimatedCss += dedent('''
|
||||
#$brushPathidcss, #$brushBrdPathidcss, #$bgPathidcss {
|
||||
visibility: hidden;
|
||||
}
|
||||
''');
|
||||
}
|
||||
|
||||
// hide stroke after current element
|
||||
const after_curr = '[class *= "current"]';
|
||||
jsAnimatedCss += dedent('''
|
||||
$after_curr ~ #$animPathidcss {
|
||||
visibility: hidden;
|
||||
}
|
||||
$after_curr ~ #$bgPathidcss, #$bgPathidcss.animate {
|
||||
visibility: visible;
|
||||
}
|
||||
@keyframes strike-$pathname {
|
||||
0% { stroke-dashoffset: ${pathlen.toStringAsFixed(3)}; }
|
||||
100% { stroke-dashoffset: 0; }
|
||||
}
|
||||
#$animPathidcss.animate {
|
||||
stroke: $STOKE_FILLING_COLOR;
|
||||
stroke-dasharray: ${pathlen.toStringAsFixed(3)} ${pathlen.toStringAsFixed(3)};
|
||||
visibility: visible;
|
||||
animation: strike-$pathname ${relduration.toStringAsFixed(3)}s $TIMING_FUNCTION forwards 1;
|
||||
}
|
||||
''');
|
||||
if (SHOW_BRUSH) {
|
||||
jsAnimatedCss += dedent('''
|
||||
@keyframes strike-brush-$pathname {
|
||||
0% { stroke-dashoffset: ${pathlen.toStringAsFixed(3)}; }
|
||||
100% { stroke-dashoffset: 0.4; }
|
||||
}
|
||||
#$brushPathidcss.animate.brush, #$brushBrdPathidcss.animate.brush {
|
||||
stroke-dasharray: 0 ${pathlen.toStringAsFixed(3)};
|
||||
visibility: visible;
|
||||
animation: strike-brush-$pathname ${relduration.toStringAsFixed(3)}s $TIMING_FUNCTION forwards 1;
|
||||
}
|
||||
''');
|
||||
}
|
||||
}
|
||||
|
||||
if (GENERATE_GIF) {
|
||||
for (final k in staticCss.keys) {
|
||||
final time = k * GIF_FRAME_DURATION;
|
||||
final reltime = time * tottime / animationTime; // unscaled time
|
||||
|
||||
staticCss[k] = staticCss[k]! + '\n/* stroke $pathid */\n';
|
||||
|
||||
String rule = '';
|
||||
|
||||
// animation
|
||||
if (reltime < elapsedtime) {
|
||||
// just hide everything
|
||||
rule += "#$animPathidcss";
|
||||
|
||||
if (SHOW_BRUSH) {
|
||||
rule += ", #$brushPathidcss, #$brushBrdPathidcss";
|
||||
}
|
||||
|
||||
staticCss[k] = staticCss[k]! +
|
||||
dedent('''
|
||||
%$rule {
|
||||
visibility: hidden;
|
||||
}
|
||||
''');
|
||||
} else if (reltime > newelapsedtime) {
|
||||
// just hide the brush, and bg
|
||||
rule += "#$bgPathidcss";
|
||||
|
||||
if (SHOW_BRUSH) {
|
||||
rule += ", #$brushPathidcss, #$brushBrdPathidcss";
|
||||
}
|
||||
|
||||
staticCss[k] = staticCss[k]! +
|
||||
dedent('''
|
||||
$rule {
|
||||
visibility: hidden;
|
||||
}
|
||||
''');
|
||||
} else {
|
||||
final intervalprop =
|
||||
((reltime - elapsedtime) / (newelapsedtime - elapsedtime));
|
||||
final progression = myTimingFunction(intervalprop);
|
||||
|
||||
staticCss[k] = staticCss[k]! +
|
||||
dedent('''
|
||||
#$animPathidcss {
|
||||
stroke-dasharray: ${pathlen.toStringAsFixed(3)} ${(pathlen + 0.002).toStringAsFixed(3)};
|
||||
stroke-dashoffset: ${(pathlen * (1 - progression) + 0.0015).toStringAsFixed(4)};
|
||||
stroke: $STOKE_FILLING_COLOR;
|
||||
}
|
||||
''');
|
||||
if (SHOW_BRUSH) {
|
||||
staticCss[k] = staticCss[k]! +
|
||||
dedent('''
|
||||
#$brushPathidcss, #$brushBrdPathidcss {
|
||||
stroke-dasharray: 0.001 ${(pathlen + 0.002).toStringAsFixed(3)};
|
||||
stroke-dashoffset: ${(pathlen * (1 - progression) + 0.0015).toStringAsFixed(4)};
|
||||
}
|
||||
''');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
elapsedlen = newelapsedlen;
|
||||
elapsedtime = newelapsedtime;
|
||||
}
|
||||
}
|
||||
|
||||
void addGroup(XmlDocumentFragment g) =>
|
||||
doc.root.firstElementChild?.children.add(g);
|
||||
|
||||
// insert groups
|
||||
if (SHOW_BRUSH && !SHOW_BRUSH_FRONT_BORDER) addGroup(brushBrdGroup);
|
||||
addGroup(bgGroup);
|
||||
if (SHOW_BRUSH && SHOW_BRUSH_FRONT_BORDER) addGroup(brushBrdGroup);
|
||||
addGroup(animGroup);
|
||||
if (SHOW_BRUSH) addGroup(brushGroup);
|
||||
|
||||
if (GENERATE_SVG) {
|
||||
print(animatedCss);
|
||||
final builder = XmlBuilder();
|
||||
final style = (builder
|
||||
..element(
|
||||
'style',
|
||||
attributes: {'id': "style-Kanimaji", 'type': 'text/css'},
|
||||
nest: animatedCss,
|
||||
))
|
||||
.buildFragment();
|
||||
doc.root.firstElementChild!.children.insert(0, style);
|
||||
final svgfile = '${filenameNoext}_anim.svg';
|
||||
File(svgfile).writeAsStringSync(doc.toXmlString(pretty: true));
|
||||
doc.root.children.removeAt(0);
|
||||
print('written $svgfile');
|
||||
}
|
||||
|
||||
if (GENERATE_GIF) {
|
||||
// svgframefiles = []
|
||||
// pngframefiles = []
|
||||
// svgexport_data = []
|
||||
// for k in static_css:
|
||||
// svgframefile = filename_noext_ascii + ("_frame%04d.svg"%k)
|
||||
// pngframefile = filename_noext_ascii + ("_frame%04d.png"%k)
|
||||
// svgframefiles.append(svgframefile)
|
||||
// pngframefiles.append(pngframefile)
|
||||
// svgexport_data.append({"input": [abspath(svgframefile)],
|
||||
// "output": [[abspath(pngframefile),
|
||||
// "%d:%d"% (GIF_SIZE, GIF_SIZE)]]})
|
||||
|
||||
// style = E.style(static_css[k], id="style-Kanimaji")
|
||||
// doc.getroot().insert(0, style)
|
||||
// doc.write(svgframefile, pretty_print=True)
|
||||
// doc.getroot().remove(style)
|
||||
// print 'written %s' % svgframefile
|
||||
|
||||
// // create json file
|
||||
// svgexport_datafile = filename_noext_ascii+"_export_data.json"
|
||||
// with open(svgexport_datafile,'w') as f:
|
||||
// f.write(json.dumps(svgexport_data))
|
||||
// print 'created instructions %s' % svgexport_datafile
|
||||
|
||||
// // run svgexport
|
||||
// cmdline = 'svgexport %s' % shescape(svgexport_datafile)
|
||||
// print cmdline
|
||||
// if os.system(cmdline) != 0:
|
||||
// exit('Error running external command')
|
||||
|
||||
// if DELETE_TEMPORARY_FILES:
|
||||
// os.remove(svgexport_datafile)
|
||||
// for f in svgframefiles:
|
||||
// os.remove(f)
|
||||
|
||||
// // generate GIF
|
||||
// giffile_tmp1 = filename_noext + '_anim_tmp1.gif'
|
||||
// giffile_tmp2 = filename_noext + '_anim_tmp2.gif'
|
||||
// giffile = filename_noext + '_anim.gif'
|
||||
// escpngframefiles = ' '.join(shescape(f) for f in pngframefiles[0:-1])
|
||||
|
||||
// if GIF_BACKGROUND_COLOR == 'transparent':
|
||||
// bgopts = '-dispose previous'
|
||||
// else:
|
||||
// bgopts = "-background '%s' -alpha remove" % GIF_BACKGROUND_COLOR
|
||||
// cmdline = ("convert -delay %d %s -delay %d %s "+
|
||||
// "%s -layers OptimizePlus %s") % (
|
||||
// int(GIF_FRAME_DURATION*100),
|
||||
// escpngframefiles,
|
||||
// int(last_frame_delay*100),
|
||||
// shescape(pngframefiles[-1]),
|
||||
// bgopts,
|
||||
// shescape(giffile_tmp1))
|
||||
// print(cmdline);
|
||||
// if os.system(cmdline) != 0:
|
||||
// exit('Error running external command')
|
||||
|
||||
// if DELETE_TEMPORARY_FILES:
|
||||
// for f in pngframefiles:
|
||||
// os.remove(f)
|
||||
// print 'cleaned up.'
|
||||
|
||||
// cmdline = ("convert %s \\( -clone 0--1 -background none "+
|
||||
// "+append -quantize transparent -colors 63 "+
|
||||
// "-unique-colors -write mpr:cmap +delete \\) "+
|
||||
// "-map mpr:cmap %s") % (
|
||||
// shescape(giffile_tmp1),
|
||||
// shescape(giffile_tmp2))
|
||||
// print cmdline
|
||||
// if os.system(cmdline) != 0:
|
||||
// exit('Error running external command')
|
||||
// if DELETE_TEMPORARY_FILES:
|
||||
// os.remove(giffile_tmp1)
|
||||
|
||||
// cmdline = ("gifsicle -O3 %s -o %s") % (
|
||||
// shescape(giffile_tmp2),
|
||||
// shescape(giffile))
|
||||
// print cmdline
|
||||
// if os.system(cmdline) != 0:
|
||||
// exit('Error running external command')
|
||||
// if DELETE_TEMPORARY_FILES:
|
||||
// os.remove(giffile_tmp2)
|
||||
}
|
||||
|
||||
// if (GENERATE_JS_SVG) {
|
||||
// final f0insert = [bg_g, anim_g];
|
||||
// if (SHOW_BRUSH) f0insert += [brush_g, brush_brd_g];
|
||||
// for g in f0insert:
|
||||
// el = E.a()
|
||||
// el.set("data-stroke","0")
|
||||
// g.insert(0, el)
|
||||
|
||||
// for i in range(0, len(js_anim_els)):
|
||||
// els = js_anim_els[i]
|
||||
// for k in els:
|
||||
// els[k].set("data-stroke",str(i+1))
|
||||
// els["anim"].set("data-duration", str(js_anim_time[i]))
|
||||
|
||||
// doc.getroot().set('data-num-strokes', str(len(js_anim_els)))
|
||||
|
||||
// style = E.style(js_animated_css, id="style-Kanimaji")
|
||||
// doc.getroot().insert(0, style)
|
||||
// svgfile = filename_noext + '_js_anim.svg'
|
||||
// doc.write(svgfile, pretty_print=True)
|
||||
// doc.getroot().remove(style)
|
||||
// print('written $svgfile');
|
||||
// }
|
||||
}
|
||||
|
||||
void main(List<String> args) {
|
||||
if (!timingFunctions.keys.contains(TIMING_FUNCTION)) {
|
||||
throw 'Sorry, invalid timing function "$TIMING_FUNCTION"';
|
||||
}
|
||||
// createAnimation('assets/kanjivg/kanji/0f9b1.svg');
|
||||
createAnimation('assets/kanjivg/kanji/04f5c.svg');
|
||||
}
|
84
lib/kanimaji/bezier_cubic.dart
Normal file
84
lib/kanimaji/bezier_cubic.dart
Normal file
@ -0,0 +1,84 @@
|
||||
|
||||
import 'dart:math' as math;
|
||||
|
||||
class Point {
|
||||
final double x;
|
||||
final double y;
|
||||
|
||||
const Point(this.x, this.y);
|
||||
|
||||
@override
|
||||
String toString() => '($x,$y)';
|
||||
}
|
||||
|
||||
double thrt(double x) =>
|
||||
x > 0 ? math.pow(x, 1.0 / 3).toDouble() : -math.pow(-x, 1.0 / 3).toDouble();
|
||||
|
||||
double sqrt(double x) => x > 0 ? math.sqrt(x) : 0;
|
||||
|
||||
double sq(x) => x * x;
|
||||
|
||||
double cb(x) => x * x * x;
|
||||
|
||||
/// x(t) = t^3 T + 3t^2(1-t) U + 3t(1-t)^2 V + (1-t)^3 W
|
||||
double time(Point pt1, Point ct1, Point ct2, Point pt2, double x) {
|
||||
// var C = Cubic, a,b,c,d,p,q,lambda,sqlambda,tmp,addcoef,t,qb,qc,norm,angle,fact;
|
||||
final double a = pt1.x - 3 * ct1.x + 3 * ct2.x - pt2.x;
|
||||
final double b = 3 * ct1.x - 6 * ct2.x + 3 * pt2.x;
|
||||
final double c = 3 * ct2.x - 3 * pt2.x;
|
||||
final double d = pt2.x - x;
|
||||
|
||||
if (a.abs() < 0.000000001) { // quadratic
|
||||
if (b.abs() < 0.000000001) return -d / c; // linear
|
||||
|
||||
final qb = c / b;
|
||||
final qc = d / b;
|
||||
final tmp = sqrt(sq(qb) - 4 * qc);
|
||||
return (-qb + ((qb > 0 || qc < 0) ? tmp : -tmp)) / 2;
|
||||
}
|
||||
|
||||
final p = -sq(b) / (3 * sq(a)) + c / a;
|
||||
final q = 2 * cb(b / (3 * a)) - b * c / (3 * sq(a)) + d / a;
|
||||
final addcoef = -b / (3 * a);
|
||||
|
||||
final lmbd = sq(q) / 4 + cb(p) / 27;
|
||||
if (lmbd >= 0) { // real
|
||||
final sqlambda = sqrt(lmbd);
|
||||
final tmp = thrt(-q / 2 + (q < 0 ? sqlambda : -sqlambda));
|
||||
return tmp - p / (3 * tmp) + addcoef;
|
||||
}
|
||||
|
||||
final norm = sqrt(sq(q) / 4 - lmbd);
|
||||
if (norm < 0.0000000001) return addcoef;
|
||||
|
||||
final angle = math.acos(-q / (2 * norm)) / 3;
|
||||
final fact = 2 * thrt(norm);
|
||||
double t = double.infinity;
|
||||
for (final i in [-1, 0, 1]) {
|
||||
final tmp = fact * math.cos(angle + i * math.pi * 2 / 3) + addcoef;
|
||||
if (tmp >= -0.000000001 && tmp < t) t = tmp;
|
||||
}
|
||||
|
||||
return t;
|
||||
}
|
||||
|
||||
double value(Point pt1, Point ct1, Point ct2, Point pt2, double x) {
|
||||
final t = time(pt1, ct1, ct2, pt2, x);
|
||||
return cb(t) * pt1.y +
|
||||
3 * sq(t) * (1 - t) * ct1.y +
|
||||
3 * t * sq(1 - t) * ct2.y +
|
||||
cb(1 - t) * pt2.y;
|
||||
}
|
||||
|
||||
// if __name__ == "__main__":
|
||||
// pt1 = pt(0,0)
|
||||
// ct1 = pt(0.25, 0.1)
|
||||
// ct2 = pt(0.25, 1.0)
|
||||
// pt2 = pt(1,1)
|
||||
|
||||
// part = 100
|
||||
// with open('ease.txt', 'w') as f:
|
||||
// for i in range(0,part+1,1):
|
||||
// x = float(i) / part
|
||||
// y = value(pt1, ct1, ct2, pt2, x)
|
||||
// f.write("%f %f\n" % (x,y))
|
56
lib/kanimaji/settings.dart
Normal file
56
lib/kanimaji/settings.dart
Normal file
@ -0,0 +1,56 @@
|
||||
import 'dart:math';
|
||||
|
||||
// *_BORDER_WIDTH is the width INCLUDING the border.
|
||||
const STOKE_BORDER_WIDTH = 4.5;
|
||||
const STOKE_BORDER_COLOR = "#666";
|
||||
const STOKE_UNFILLED_COLOR = "#eee";
|
||||
const double STOKE_UNFILLED_WIDTH = 3;
|
||||
const STOKE_FILLING_COLOR = "#f00";
|
||||
const STOKE_FILLED_COLOR = "#000";
|
||||
const double STOKE_FILLED_WIDTH = 3.1;
|
||||
|
||||
// brush settings
|
||||
const SHOW_BRUSH = true;
|
||||
const SHOW_BRUSH_FRONT_BORDER = true;
|
||||
const BRUSH_COLOR = "#f00";
|
||||
const double BRUSH_WIDTH = 5.5;
|
||||
const BRUSH_BORDER_COLOR = "#666";
|
||||
const double BRUSH_BORDER_WIDTH = 7;
|
||||
|
||||
const WAIT_AFTER = 1.5;
|
||||
|
||||
// gif settings
|
||||
const DELETE_TEMPORARY_FILES = false;
|
||||
const GIF_SIZE = 150;
|
||||
const GIF_FRAME_DURATION = 0.04;
|
||||
const GIF_BACKGROUND_COLOR = '#ddf';
|
||||
// set to true to allow transparent background, much bigger file!
|
||||
const GIF_ALLOW_TRANSPARENT = false;
|
||||
|
||||
// edit here to decide what will be generated
|
||||
const GENERATE_SVG = true;
|
||||
const GENERATE_JS_SVG = true;
|
||||
const GENERATE_GIF = true;
|
||||
|
||||
// sqrt, ie a stroke 4 times the length is drawn
|
||||
// at twice the speed, in twice the time.
|
||||
double stroke_length_to_duration(double length) => sqrt(length) / 8;
|
||||
|
||||
// global time rescale, let's make animation a bit
|
||||
// faster when there are many strokes.
|
||||
double time_rescale(interval) => pow(2 * interval, 2.0 / 3).toDouble();
|
||||
|
||||
// Possibilities are linear, ease, ease-in, ease-in-out, ease-out, see
|
||||
// https://developer.mozilla.org/en-US/docs/Web/CSS/timing-function
|
||||
// for more info.
|
||||
const TIMING_FUNCTION = "ease-in-out";
|
||||
|
||||
//
|
||||
// colorful debug settings
|
||||
//
|
||||
// STOKE_BORDER_COLOR = "#00f"
|
||||
// STOKE_UNFILLED_COLOR = "#ff0"
|
||||
// STOKE_FILLING_COLOR = "#f00"
|
||||
// STOKE_FILLED_COLOR = "#000"
|
||||
// BRUSH_COLOR = "#0ff"
|
||||
// BRUSH_BORDER_COLOR = "#0f0"
|
410
lib/svg/parser.dart
Normal file
410
lib/svg/parser.dart
Normal file
@ -0,0 +1,410 @@
|
||||
/// SVG Path specification parser
|
||||
///
|
||||
import 'path.dart'
|
||||
show Arc, Close, CubicBezier, Line, Move, Path, Point, QuadraticBezier;
|
||||
|
||||
const COMMANDS = {
|
||||
'M',
|
||||
'm',
|
||||
'Z',
|
||||
'z',
|
||||
'L',
|
||||
'l',
|
||||
'H',
|
||||
'h',
|
||||
'V',
|
||||
'v',
|
||||
'C',
|
||||
'c',
|
||||
'S',
|
||||
's',
|
||||
'Q',
|
||||
'q',
|
||||
'T',
|
||||
't',
|
||||
'A',
|
||||
'a'
|
||||
};
|
||||
const UPPERCASE = {'M', 'Z', 'L', 'H', 'V', 'C', 'S', 'Q', 'T', 'A'};
|
||||
|
||||
final COMMAND_RE = RegExp("(?=[${COMMANDS.join('')}])");
|
||||
final FLOAT_RE = RegExp(r"^[-+]?[0-9]*\.?[0-9]+(?:[eE][-+]?[0-9]+)?");
|
||||
|
||||
class ParserResult<T> {
|
||||
final T value;
|
||||
final String remaining;
|
||||
|
||||
const ParserResult({required this.value, required this.remaining});
|
||||
}
|
||||
|
||||
class InvalidPathError implements Exception {
|
||||
final String msg;
|
||||
const InvalidPathError(this.msg);
|
||||
|
||||
@override
|
||||
String toString() => 'InvalidPathError: $msg';
|
||||
}
|
||||
|
||||
// The argument sequences from the grammar, made sane.
|
||||
// u: Non-negative number
|
||||
// s: Signed number or coordinate
|
||||
// c: coordinate-pair, which is two coordinates/numbers, separated by whitespace
|
||||
// f: A one character flag, doesn't need whitespace, 1 or 0
|
||||
const ARGUMENT_SEQUENCE = {
|
||||
"M": "c",
|
||||
"Z": "",
|
||||
"L": "c",
|
||||
"H": "s",
|
||||
"V": "s",
|
||||
"C": "ccc",
|
||||
"S": "cc",
|
||||
"Q": "cc",
|
||||
"T": "c",
|
||||
"A": "uusffc",
|
||||
};
|
||||
|
||||
/// Strips whitespace and commas
|
||||
String strip_array(String arg_array) {
|
||||
// EBNF wsp:(#x20 | #x9 | #xD | #xA) + comma: 0x2C
|
||||
while (arg_array.isNotEmpty && ' \t\n\r,'.contains(arg_array[0])) {
|
||||
arg_array = arg_array.substring(1);
|
||||
}
|
||||
return arg_array;
|
||||
}
|
||||
|
||||
ParserResult<double> pop_number(String arg_array) {
|
||||
final res = FLOAT_RE.firstMatch(arg_array);
|
||||
if (res == null) {
|
||||
throw InvalidPathError("Expected a number, got '$arg_array'.");
|
||||
}
|
||||
|
||||
final number = double.parse(res.group(0)!);
|
||||
final start = res.start;
|
||||
final end = res.end;
|
||||
arg_array = arg_array.substring(0, start) + arg_array.substring(end);
|
||||
arg_array = strip_array(arg_array);
|
||||
|
||||
return ParserResult(value: number, remaining: arg_array);
|
||||
}
|
||||
|
||||
ParserResult<double> pop_unsigned_number(arg_array) {
|
||||
final number = pop_number(arg_array);
|
||||
if (number.value < 0) {
|
||||
throw InvalidPathError("Expected a non-negative number, got '$number'.");
|
||||
}
|
||||
return number;
|
||||
}
|
||||
|
||||
ParserResult<Point> pop_coordinate_pair(arg_array) {
|
||||
final x = pop_number(arg_array);
|
||||
final y = pop_number(x.remaining);
|
||||
return ParserResult(value: Point(x.value, y.value), remaining: y.remaining);
|
||||
}
|
||||
|
||||
ParserResult<bool> pop_flag(String arg_array) {
|
||||
final flag = arg_array[0];
|
||||
arg_array = arg_array.substring(1);
|
||||
arg_array = strip_array(arg_array);
|
||||
if (flag == '0') return ParserResult(value: false, remaining: arg_array);
|
||||
if (flag == '1') return ParserResult(value: true, remaining: arg_array);
|
||||
|
||||
throw InvalidPathError("Expected either 1 or 0, got '$flag'");
|
||||
}
|
||||
|
||||
const FIELD_POPPERS = {
|
||||
"u": pop_unsigned_number,
|
||||
"s": pop_number,
|
||||
"c": pop_coordinate_pair,
|
||||
"f": pop_flag,
|
||||
};
|
||||
|
||||
class Command {
|
||||
final String command;
|
||||
final String args;
|
||||
|
||||
const Command({required this.command, required this.args});
|
||||
|
||||
@override
|
||||
String toString() => 'Command: $command $args';
|
||||
}
|
||||
|
||||
// Splits path into commands and arguments
|
||||
List<Command> _commandify_path(String pathdef) {
|
||||
List<Command> tokens = [];
|
||||
List<String> token = [];
|
||||
for (String c in pathdef.split(COMMAND_RE)) {
|
||||
String x = c[0];
|
||||
String? y = (c.length > 1) ? c.substring(1).trim() : null;
|
||||
if (!COMMANDS.contains(x)) {
|
||||
throw InvalidPathError("Path does not start with a command: $pathdef");
|
||||
}
|
||||
if (token.isNotEmpty) {
|
||||
tokens.add(Command(command: token[0], args: token[1]));
|
||||
// yield token;
|
||||
}
|
||||
if (x == "z" || x == "Z") {
|
||||
// The end command takes no arguments, so add a blank one
|
||||
token.addAll([x, ""]);
|
||||
} else {
|
||||
// token = [x, x.substring(1).trim()];
|
||||
token = [x];
|
||||
}
|
||||
|
||||
if (y != null) {
|
||||
token.add(y);
|
||||
}
|
||||
}
|
||||
tokens.add(Command(command: token[0], args: token[1]));
|
||||
// yield token;
|
||||
return tokens;
|
||||
}
|
||||
|
||||
class Token {
|
||||
final String command;
|
||||
final List<Object> args;
|
||||
|
||||
const Token({required this.command, required this.args});
|
||||
|
||||
@override
|
||||
String toString() => 'Token: $command ($args)';
|
||||
}
|
||||
|
||||
List<Token> _tokenize_path(String pathdef) {
|
||||
List<Token> tokens = [];
|
||||
for (final token in _commandify_path(pathdef)) {
|
||||
// _commandify_path(pathdef).forEach((List<String> token) {
|
||||
String command = token.command;
|
||||
String args = token.args;
|
||||
|
||||
// Shortcut this for the close command, that doesn't have arguments:
|
||||
if (command == "z" || command == "Z") {
|
||||
tokens.add(Token(command: command, args: []));
|
||||
continue;
|
||||
}
|
||||
|
||||
// For the rest of the commands, we parse the arguments and
|
||||
// yield one command per full set of arguments
|
||||
final String arg_sequence = ARGUMENT_SEQUENCE[command.toUpperCase()]!;
|
||||
String arguments = args;
|
||||
while (arguments.isNotEmpty) {
|
||||
final List<Object> command_arguments = [];
|
||||
for (final arg in arg_sequence.split('')) {
|
||||
try {
|
||||
final result = FIELD_POPPERS[arg]!.call(arguments);
|
||||
arguments = result.remaining;
|
||||
command_arguments.add(result.value);
|
||||
} on InvalidPathError {
|
||||
throw InvalidPathError("Invalid path element $command $args");
|
||||
}
|
||||
}
|
||||
|
||||
tokens.add(Token(command: command, args: command_arguments));
|
||||
// yield (command,) + tuple(command_arguments)
|
||||
|
||||
// Implicit Moveto commands should be treated as Lineto commands.
|
||||
if (command == "m") {
|
||||
command = "l";
|
||||
} else if (command == "M") {
|
||||
command = "L";
|
||||
}
|
||||
}
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
Path parse_path(String pathdef) {
|
||||
final segments = Path();
|
||||
Point? start_pos;
|
||||
String? last_command;
|
||||
Point current_pos = Point.zero;
|
||||
|
||||
for (final token in _tokenize_path(pathdef)) {
|
||||
final command = token.command.toUpperCase();
|
||||
final absolute = token.command.toUpperCase() == token.command;
|
||||
if (command == "M") {
|
||||
final pos = token.args[0] as Point;
|
||||
if (absolute) {
|
||||
current_pos = pos;
|
||||
} else {
|
||||
current_pos += pos;
|
||||
}
|
||||
segments.add(Move(to: current_pos));
|
||||
start_pos = current_pos;
|
||||
} else if (command == "Z") {
|
||||
// TODO Throw error if not available:
|
||||
segments.add(Close(start: current_pos, end: start_pos!));
|
||||
current_pos = start_pos;
|
||||
} else if (command == "L") {
|
||||
Point pos = token.args[0] as Point;
|
||||
if (!absolute) {
|
||||
pos += current_pos;
|
||||
}
|
||||
segments.add(Line(start: current_pos, end: pos));
|
||||
current_pos = pos;
|
||||
} else if (command == "H") {
|
||||
double hpos = token.args[0] as double;
|
||||
if (!absolute) {
|
||||
hpos += current_pos.x;
|
||||
}
|
||||
final pos = Point(hpos, current_pos.y);
|
||||
segments.add(Line(start: current_pos, end: pos));
|
||||
current_pos = pos;
|
||||
} else if (command == "V") {
|
||||
double vpos = token.args[0] as double;
|
||||
if (!absolute) {
|
||||
vpos += current_pos.y;
|
||||
}
|
||||
final pos = Point(current_pos.x, vpos);
|
||||
segments.add(Line(start: current_pos, end: pos));
|
||||
current_pos = pos;
|
||||
} else if (command == "C") {
|
||||
Point control1 = token.args[0] as Point;
|
||||
Point control2 = token.args[1] as Point;
|
||||
Point end = token.args[2] as Point;
|
||||
|
||||
if (!absolute) {
|
||||
control1 += current_pos;
|
||||
control2 += current_pos;
|
||||
end += current_pos;
|
||||
}
|
||||
|
||||
segments.add(
|
||||
CubicBezier(
|
||||
start: current_pos,
|
||||
control1: control1,
|
||||
control2: control2,
|
||||
end: end,
|
||||
),
|
||||
);
|
||||
current_pos = end;
|
||||
} else if (command == "S") {
|
||||
// Smooth curve. First control point is the "reflection" of
|
||||
// the second control point in the previous path.
|
||||
Point control2 = token.args[0] as Point;
|
||||
Point end = token.args[1] as Point;
|
||||
|
||||
if (!absolute) {
|
||||
control2 += current_pos;
|
||||
end += current_pos;
|
||||
}
|
||||
|
||||
late final Point control1;
|
||||
|
||||
if (last_command == 'C' || last_command == 'S') {
|
||||
// The first control point is assumed to be the reflection of
|
||||
// the second control point on the previous command relative
|
||||
// to the current point.
|
||||
control1 =
|
||||
current_pos + current_pos - (segments.last as CubicBezier).control2;
|
||||
} else {
|
||||
// If there is no previous command or if the previous command
|
||||
// was not an C, c, S or s, assume the first control point is
|
||||
// coincident with the current point.
|
||||
control1 = current_pos;
|
||||
}
|
||||
segments.add(
|
||||
CubicBezier(
|
||||
start: current_pos,
|
||||
control1: control1,
|
||||
control2: control2,
|
||||
end: end),
|
||||
);
|
||||
current_pos = end;
|
||||
} else if (command == "Q") {
|
||||
Point control = token.args[0] as Point;
|
||||
Point end = token.args[1] as Point;
|
||||
|
||||
if (!absolute) {
|
||||
control += current_pos;
|
||||
end += current_pos;
|
||||
}
|
||||
|
||||
segments.add(
|
||||
QuadraticBezier(start: current_pos, control: control, end: end),
|
||||
);
|
||||
current_pos = end;
|
||||
} else if (command == "T") {
|
||||
// Smooth curve. Control point is the "reflection" of
|
||||
// the second control point in the previous path.
|
||||
Point end = token.args[0] as Point;
|
||||
|
||||
if (!absolute) {
|
||||
end += current_pos;
|
||||
}
|
||||
|
||||
late final Point control;
|
||||
if (last_command == "Q" || last_command == 'T') {
|
||||
// The control point is assumed to be the reflection of
|
||||
// the control point on the previous command relative
|
||||
// to the current point.
|
||||
control = current_pos +
|
||||
current_pos -
|
||||
(segments.last as QuadraticBezier).control;
|
||||
} else {
|
||||
// If there is no previous command or if the previous command
|
||||
// was not an Q, q, T or t, assume the first control point is
|
||||
// coincident with the current point.
|
||||
control = current_pos;
|
||||
}
|
||||
|
||||
segments.add(
|
||||
QuadraticBezier(start: current_pos, control: control, end: end),
|
||||
);
|
||||
current_pos = end;
|
||||
} else if (command == "A") {
|
||||
// For some reason I implemented the Arc with a complex radius.
|
||||
// That doesn't really make much sense, but... *shrugs*
|
||||
final radius = Point(token.args[0] as double, token.args[1] as double);
|
||||
final rotation = token.args[2] as double;
|
||||
final arc = token.args[3] as bool;
|
||||
final sweep = token.args[4] as bool;
|
||||
Point end = token.args[5] as Point;
|
||||
|
||||
if (!absolute) {
|
||||
end += current_pos;
|
||||
}
|
||||
|
||||
segments.add(
|
||||
Arc(
|
||||
start: current_pos,
|
||||
radius: radius,
|
||||
rotation: rotation,
|
||||
arc: arc,
|
||||
sweep: sweep,
|
||||
end: end,
|
||||
),
|
||||
);
|
||||
current_pos = end;
|
||||
}
|
||||
|
||||
// Finish up the loop in preparation for next command
|
||||
last_command = command;
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
void main(List<String> args) {
|
||||
// print(_commandify_path('M 10 10 C 20 20, 40 20, 50 10'));
|
||||
// print(_tokenize_path('M 10 10 C 20 20, 40 20, 50 10'));
|
||||
// print(_tokenize_path('M 10 80 Q 52.5 10, 95 80 T 180 80'));
|
||||
// print(_tokenize_path("""
|
||||
// M 10 315
|
||||
// L 110 215
|
||||
// A 30 50 0 0 1 162.55 162.45
|
||||
// L 172.55 152.45
|
||||
// A 30 50 -45 0 1 215.1 109.9
|
||||
// L 315 10
|
||||
// """));
|
||||
|
||||
print(parse_path('M 10 10 C 20 20, 40 20, 50 10'));
|
||||
print(parse_path('M 10 80 Q 52.5 10, 95 80 T 180 80'));
|
||||
print(parse_path("""
|
||||
M 10 315
|
||||
L 110 215
|
||||
A 30 50 0 0 1 162.55 162.45
|
||||
L 172.55 152.45
|
||||
A 30 50 -45 0 1 215.1 109.9
|
||||
L 315 10
|
||||
"""));
|
||||
}
|
682
lib/svg/path.dart
Normal file
682
lib/svg/path.dart
Normal file
@ -0,0 +1,682 @@
|
||||
import 'dart:collection';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:math' show sqrt, sin, cos, acos, log, pi;
|
||||
import 'package:bisect/bisect.dart';
|
||||
|
||||
// try:
|
||||
// from collections.abc import MutableSequence
|
||||
// except ImportError:
|
||||
// from collections import MutableSequence
|
||||
|
||||
// This file contains classes for the different types of SVG path segments as
|
||||
// well as a Path object that contains a sequence of path segments.
|
||||
|
||||
double radians(num n) => n * pi / 180;
|
||||
double degrees(num n) => n * 180 / pi;
|
||||
|
||||
class Point {
|
||||
final num x;
|
||||
final num y;
|
||||
|
||||
const Point(this.x, this.y);
|
||||
const Point.from({this.x = 0, this.y = 0});
|
||||
static const zero = Point(0, 0);
|
||||
|
||||
operator +(covariant Point p) => Point(x + p.x, y + p.y);
|
||||
operator -(covariant Point p) => Point(x - p.x, y - p.y);
|
||||
operator *(covariant Point p) => Point(x * p.x, y * p.y);
|
||||
operator /(covariant Point p) => Point(x / p.x, y / p.y);
|
||||
|
||||
Point addX(num n) => Point(x + n, y);
|
||||
Point addY(num n) => Point(x, y + n);
|
||||
Point add(num n) => Point(x + n, y + n);
|
||||
|
||||
Point subtractX(num n) => Point(x - n, y);
|
||||
Point subtractY(num n) => Point(x, y - n);
|
||||
Point subtractXY(num n) => Point(x - n, y - n);
|
||||
|
||||
Point xSubtract(num n) => Point(n - x, y);
|
||||
Point ySubtract(num n) => Point(x, n - y);
|
||||
Point xySubtract(num n) => Point(n - x, n - y);
|
||||
|
||||
Point timesX(num n) => Point(x * n, y);
|
||||
Point timesY(num n) => Point(x, y * n);
|
||||
Point times(num n) => Point(x * n, y * n);
|
||||
|
||||
Point dividesX(num n) => Point(x / n, y);
|
||||
Point dividesY(num n) => Point(x, y / n);
|
||||
Point divides(num n) => Point(x / n, y / n);
|
||||
|
||||
Point pow(int n) => Point(math.pow(x, n), math.pow(y, n));
|
||||
double abs() => math.sqrt(x * x + y * y);
|
||||
|
||||
@override
|
||||
String toString() => '($x,$y)';
|
||||
}
|
||||
|
||||
const defaultMinDepth = 5;
|
||||
const defaultError = 1e-12;
|
||||
|
||||
/// Recursively approximates the length by straight lines
|
||||
double segmentLength({
|
||||
required SvgPath curve,
|
||||
required num start,
|
||||
required num end,
|
||||
required Point startPoint,
|
||||
required Point endPoint,
|
||||
required double error,
|
||||
required int minDepth,
|
||||
required double depth,
|
||||
}) {
|
||||
num mid = (start + end) / 2;
|
||||
Point midPoint = curve.point(mid);
|
||||
double length = (endPoint - startPoint).abs();
|
||||
double firstHalf = (midPoint - startPoint).abs();
|
||||
double secondHalf = (endPoint - midPoint).abs();
|
||||
|
||||
double length2 = firstHalf + secondHalf;
|
||||
if ((length2 - length > error) || (depth < minDepth)) {
|
||||
// Calculate the length of each segment:
|
||||
depth += 1;
|
||||
return segmentLength(
|
||||
curve: curve,
|
||||
start: start,
|
||||
end: mid,
|
||||
startPoint: startPoint,
|
||||
endPoint: midPoint,
|
||||
error: error,
|
||||
minDepth: minDepth,
|
||||
depth: depth,
|
||||
) +
|
||||
segmentLength(
|
||||
curve: curve,
|
||||
start: mid,
|
||||
end: end,
|
||||
startPoint: midPoint,
|
||||
endPoint: endPoint,
|
||||
error: error,
|
||||
minDepth: minDepth,
|
||||
depth: depth,
|
||||
);
|
||||
}
|
||||
// This is accurate enough.
|
||||
return length2;
|
||||
}
|
||||
|
||||
abstract class SvgPath {
|
||||
final Point start;
|
||||
final Point end;
|
||||
|
||||
const SvgPath({
|
||||
required this.start,
|
||||
required this.end,
|
||||
});
|
||||
|
||||
/// Calculate the x,y position at a certain position of the path
|
||||
Point point(num pos);
|
||||
|
||||
/// Calculate the length of the path up to a certain position
|
||||
double size({double error = defaultError, int minDepth = defaultMinDepth});
|
||||
}
|
||||
|
||||
abstract class Bezier extends SvgPath {
|
||||
const Bezier({
|
||||
required Point start,
|
||||
required Point end,
|
||||
}) : super(start: start, end: end);
|
||||
|
||||
/// Checks if this segment would be a smooth segment following the previous
|
||||
bool isSmoothFrom(Object? previous);
|
||||
}
|
||||
|
||||
/// A straight line
|
||||
/// The base for Line() and Close().
|
||||
class Linear extends SvgPath {
|
||||
const Linear({
|
||||
required Point start,
|
||||
required Point end,
|
||||
}) : super(start: start, end: end);
|
||||
|
||||
// def __ne__(self, other):
|
||||
// if not isinstance(other, Line):
|
||||
// return NotImplemented
|
||||
// return not self == other
|
||||
|
||||
@override
|
||||
Point point(num pos) => start + (end - start).times(pos);
|
||||
|
||||
@override
|
||||
double size({double error = defaultError, int minDepth = defaultMinDepth}) {
|
||||
final distance = end - start;
|
||||
return sqrt(distance.x * distance.x + distance.y * distance.y);
|
||||
}
|
||||
}
|
||||
|
||||
class Line extends Linear {
|
||||
const Line({
|
||||
required Point start,
|
||||
required Point end,
|
||||
}) : super(start: start, end: end);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return "Line(start=$start, end=$end)";
|
||||
}
|
||||
// @override
|
||||
// operator ==(covariant Line other) => start == other.start && end == other.end;
|
||||
}
|
||||
|
||||
class CubicBezier extends Bezier {
|
||||
final Point control1;
|
||||
final Point control2;
|
||||
|
||||
const CubicBezier({
|
||||
required Point start,
|
||||
required this.control1,
|
||||
required this.control2,
|
||||
required Point end,
|
||||
}) : super(start: start, end: end);
|
||||
|
||||
@override
|
||||
String toString() => "CubicBezier(start=$start, control1=$control1, "
|
||||
"control2=$control2, end=$end)";
|
||||
|
||||
// @override
|
||||
// operator ==(covariant CubicBezier other) =>
|
||||
// start == other.start &&
|
||||
// and end == other.end &&
|
||||
// and control1 == other.control1 &&
|
||||
// and control2 == other.control2;
|
||||
|
||||
// def __ne__(self, other):
|
||||
// if not isinstance(other, CubicBezier):
|
||||
// return NotImplemented
|
||||
// return not self == other
|
||||
|
||||
@override
|
||||
bool isSmoothFrom(Object? previous) => previous is CubicBezier
|
||||
? start == previous.end &&
|
||||
control1 - start == previous.end - previous.control2
|
||||
: control1 == start;
|
||||
|
||||
@override
|
||||
Point point(num pos) =>
|
||||
start.times(math.pow(1 - pos, 3)) +
|
||||
control1.times(math.pow(1 - pos, 2) * 3 * pos) +
|
||||
control2.times(math.pow(pos, 2) * 3 * (1 - pos)) +
|
||||
end.times(math.pow(pos, 3));
|
||||
|
||||
@override
|
||||
double size({double error = defaultError, int minDepth = defaultMinDepth}) {
|
||||
final startPoint = point(0);
|
||||
final endPoint = point(1);
|
||||
return segmentLength(
|
||||
curve: this,
|
||||
start: 0,
|
||||
end: 1,
|
||||
startPoint: startPoint,
|
||||
endPoint: endPoint,
|
||||
error: error,
|
||||
minDepth: minDepth,
|
||||
depth: 0);
|
||||
}
|
||||
}
|
||||
|
||||
class QuadraticBezier extends Bezier {
|
||||
final Point control;
|
||||
|
||||
const QuadraticBezier({
|
||||
required Point start,
|
||||
required Point end,
|
||||
required this.control,
|
||||
}) : super(
|
||||
start: start,
|
||||
end: end,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
"QuadraticBezier(start=$start, control=$control, end=$end)";
|
||||
|
||||
// def __eq__(self, other):
|
||||
// if not isinstance(other, QuadraticBezier):
|
||||
// return NotImplemented
|
||||
// return (
|
||||
// self.start == other.start
|
||||
// and self.end == other.end
|
||||
// and self.control == other.control
|
||||
// )
|
||||
|
||||
// def __ne__(self, other):
|
||||
// if not isinstance(other, QuadraticBezier):
|
||||
// return NotImplemented
|
||||
// return not self == other
|
||||
|
||||
@override
|
||||
bool isSmoothFrom(Object? previous) => previous is QuadraticBezier
|
||||
? start == previous.end &&
|
||||
(control - start) == (previous.end - previous.control)
|
||||
: control == start;
|
||||
|
||||
@override
|
||||
Point point(num pos) =>
|
||||
start.times(math.pow(1 - pos, 2)) +
|
||||
control.times(pos * (1 - pos) * 2) +
|
||||
end.times(math.pow(pos, 2));
|
||||
|
||||
@override
|
||||
double size({double error = defaultError, int minDepth = defaultMinDepth}) {
|
||||
final Point a = start - control.times(2) + end;
|
||||
final Point b = (control - start).times(2);
|
||||
final num aDotB = a.x * b.x + a.y * b.y;
|
||||
|
||||
late final double s;
|
||||
if (a.abs() < 1e-12) {
|
||||
s = b.abs();
|
||||
} else if ((aDotB + a.abs() * b.abs()).abs() < 1e-12) {
|
||||
final k = b.abs() / a.abs();
|
||||
s = (k >= 2) ? b.abs() - a.abs() : a.abs() * ((k * k) / 2 - k + 1);
|
||||
} else {
|
||||
// For an explanation of this case, see
|
||||
// http://www.malczak.info/blog/quadratic-bezier-curve-length/
|
||||
final num A = 4 * (a.x * a.x + a.y * a.y);
|
||||
final num B = 4 * (a.x * b.x + a.y * b.y);
|
||||
final num C = b.x * b.x + b.y * b.y;
|
||||
|
||||
final double sabc = 2 * sqrt(A + B + C);
|
||||
final double a2 = sqrt(A);
|
||||
final double a32 = 2 * A * a2;
|
||||
final double c2 = 2 * sqrt(C);
|
||||
final double bA = B / a2;
|
||||
|
||||
s = (a32 * sabc +
|
||||
a2 * B * (sabc - c2) +
|
||||
(4 * C * A - (B * B)) * log((2 * a2 + bA + sabc) / (bA + c2))) /
|
||||
(4 * a32);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
/// radius is complex, rotation is in degrees,
|
||||
/// large and sweep are 1 or 0 (True/False also work)
|
||||
class Arc extends SvgPath {
|
||||
final Point radius;
|
||||
final double rotation;
|
||||
final bool arc;
|
||||
final bool sweep;
|
||||
late final num radiusScale;
|
||||
late final Point center;
|
||||
late final num theta;
|
||||
late final num delta;
|
||||
|
||||
Arc({
|
||||
required Point start,
|
||||
required Point end,
|
||||
required this.radius,
|
||||
required this.rotation,
|
||||
required this.arc,
|
||||
required this.sweep,
|
||||
}) : super(start: start, end: end) {
|
||||
_parameterize();
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => "Arc(start=$start, radius=$radius, rotation=$rotation, "
|
||||
"arc=$arc, sweep=$sweep, end=$end)";
|
||||
|
||||
// def __eq__(self, other):
|
||||
// if not isinstance(other, Arc):
|
||||
// return NotImplemented
|
||||
// return (
|
||||
// self.start == other.start
|
||||
// and self.end == other.end
|
||||
// and self.radius == other.radius
|
||||
// and self.rotation == other.rotation
|
||||
// and self.arc == other.arc
|
||||
// and self.sweep == other.sweep
|
||||
// )
|
||||
|
||||
// def __ne__(self, other):
|
||||
// if not isinstance(other, Arc):
|
||||
// return NotImplemented
|
||||
// return not self == other
|
||||
|
||||
void _parameterize() {
|
||||
// Conversion from endpoint to center parameterization
|
||||
// http://www.w3.org/TR/SVG/implnote.html#ArcImplementationNotes
|
||||
|
||||
// This is equivalent of omitting the segment, so do nothing
|
||||
if (start == end) return;
|
||||
|
||||
// This should be treated as a straight line
|
||||
if (radius.x == 0 || radius.y == 0) return;
|
||||
|
||||
final cosr = cos(radians(rotation));
|
||||
final sinr = sin(radians(rotation));
|
||||
final dx = (start.x - end.x) / 2;
|
||||
final dy = (start.y - end.y) / 2;
|
||||
final x1prim = cosr * dx + sinr * dy;
|
||||
final x1primSq = x1prim * x1prim;
|
||||
final y1prim = -sinr * dx + cosr * dy;
|
||||
final y1primSq = y1prim * y1prim;
|
||||
|
||||
num rx = radius.x;
|
||||
num rxSq = rx * rx;
|
||||
num ry = radius.y;
|
||||
num rySq = ry * ry;
|
||||
|
||||
// Correct out of range radii
|
||||
num radiusScale = (x1primSq / rxSq) + (y1primSq / rySq);
|
||||
if (radiusScale > 1) {
|
||||
radiusScale = sqrt(radiusScale);
|
||||
rx *= radiusScale;
|
||||
ry *= radiusScale;
|
||||
rxSq = rx * rx;
|
||||
rySq = ry * ry;
|
||||
this.radiusScale = radiusScale;
|
||||
} else {
|
||||
// SVG spec only scales UP
|
||||
this.radiusScale = 1;
|
||||
}
|
||||
|
||||
final t1 = rxSq * y1primSq;
|
||||
final t2 = rySq * x1primSq;
|
||||
double c = sqrt(((rxSq * rySq - t1 - t2) / (t1 + t2)).abs());
|
||||
|
||||
if (arc == sweep) {
|
||||
c = -c;
|
||||
}
|
||||
final cxprim = c * rx * y1prim / ry;
|
||||
final cyprim = -c * ry * x1prim / rx;
|
||||
|
||||
center = Point(
|
||||
(cosr * cxprim - sinr * cyprim) + ((start.x + end.x) / 2),
|
||||
(sinr * cxprim + cosr * cyprim) + ((start.y + end.y) / 2),
|
||||
);
|
||||
|
||||
final ux = (x1prim - cxprim) / rx;
|
||||
final uy = (y1prim - cyprim) / ry;
|
||||
final vx = (-x1prim - cxprim) / rx;
|
||||
final vy = (-y1prim - cyprim) / ry;
|
||||
num n = sqrt(ux * ux + uy * uy);
|
||||
num p = ux;
|
||||
theta = (((uy < 0) ? -1 : 1) * degrees(acos(p / n))) % 360;
|
||||
|
||||
n = sqrt((ux * ux + uy * uy) * (vx * vx + vy * vy));
|
||||
p = ux * vx + uy * vy;
|
||||
num d = p / n;
|
||||
// In certain cases the above calculation can through inaccuracies
|
||||
// become just slightly out of range, f ex -1.0000000000000002.
|
||||
if (d > 1.0) {
|
||||
d = 1.0;
|
||||
} else if (d < -1.0) {
|
||||
d = -1.0;
|
||||
}
|
||||
delta = ((((ux * vy - uy * vx) < 0) ? -1 : 1) * degrees(acos(d))) % 360;
|
||||
if (!sweep) delta -= 360;
|
||||
}
|
||||
|
||||
@override
|
||||
Point point(num pos) {
|
||||
// This is equivalent of omitting the segment
|
||||
if (start == end) return start;
|
||||
|
||||
// This should be treated as a straight line
|
||||
if (this.radius.x == 0 || this.radius.y == 0) {
|
||||
return start + (end - start) * pos;
|
||||
}
|
||||
|
||||
final angle = radians(theta + pos * delta);
|
||||
final cosr = cos(radians(rotation));
|
||||
final sinr = sin(radians(rotation));
|
||||
final radius = this.radius.times(radiusScale);
|
||||
|
||||
final x =
|
||||
cosr * cos(angle) * radius.x - sinr * sin(angle) * radius.y + center.x;
|
||||
|
||||
final y =
|
||||
sinr * cos(angle) * radius.x + cosr * sin(angle) * radius.y + center.y;
|
||||
|
||||
return Point(x, y);
|
||||
}
|
||||
|
||||
/// The length of an elliptical arc segment requires numerical
|
||||
/// integration, and in that case it's simpler to just do a geometric
|
||||
/// approximation, as for cubic bezier curves.
|
||||
@override
|
||||
double size({double error = defaultError, minDepth = defaultMinDepth}) {
|
||||
// This is equivalent of omitting the segment
|
||||
if (start == end) return 0;
|
||||
|
||||
// This should be treated as a straight line
|
||||
if (radius.x == 0 || radius.y == 0) {
|
||||
final distance = end - start;
|
||||
return sqrt(distance.x * distance.x + distance.y * distance.y);
|
||||
}
|
||||
|
||||
if (radius.x == radius.y) {
|
||||
// It's a circle, which simplifies this a LOT.
|
||||
final radius = this.radius.x * radiusScale;
|
||||
return radians(radius * delta).abs();
|
||||
}
|
||||
|
||||
final startPoint = point(0);
|
||||
final endPoint = point(1);
|
||||
return segmentLength(
|
||||
curve: this,
|
||||
start: 0,
|
||||
end: 1,
|
||||
startPoint: startPoint,
|
||||
endPoint: endPoint,
|
||||
error: error,
|
||||
minDepth: minDepth,
|
||||
depth: 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Represents move commands. Does nothing, but is there to handle
|
||||
// paths that consist of only move commands, which is valid, but pointless.
|
||||
class Move extends SvgPath {
|
||||
const Move({required Point to}) : super(start: to, end: to);
|
||||
|
||||
@override
|
||||
String toString() => "Move(to=$start)";
|
||||
// def __eq__(self, other):
|
||||
// if not isinstance(other, Move):
|
||||
// return NotImplemented
|
||||
// return self.start == other.start
|
||||
|
||||
// def __ne__(self, other):
|
||||
// if not isinstance(other, Move):
|
||||
// return NotImplemented
|
||||
// return not self == other
|
||||
|
||||
@override
|
||||
Point point(num pos) => start;
|
||||
|
||||
@override
|
||||
double size({double error = defaultError, int minDepth = defaultMinDepth}) => 0;
|
||||
}
|
||||
|
||||
// Represents the closepath command
|
||||
class Close extends Linear {
|
||||
const Close({
|
||||
required Point start,
|
||||
required Point end,
|
||||
}) : super(start: start, end: end);
|
||||
|
||||
// def __eq__(self, other):
|
||||
// if not isinstance(other, Close):
|
||||
// return NotImplemented
|
||||
// return self.start == other.start and self.end == other.end
|
||||
|
||||
@override
|
||||
String toString() => "Close(start=$start, end=$end)";
|
||||
}
|
||||
|
||||
/// A Path is a sequence of path segments
|
||||
class Path extends ListBase<SvgPath> {
|
||||
late final List<SvgPath> segments;
|
||||
List<num>? _memoizedLengths;
|
||||
num? _memoizedLength;
|
||||
final List<num> _fractions = [];
|
||||
|
||||
Path() {
|
||||
segments = [];
|
||||
}
|
||||
|
||||
@override
|
||||
SvgPath operator [](int index) => segments[index];
|
||||
|
||||
@override
|
||||
void operator []=(int index, SvgPath value) {
|
||||
segments[index] = value;
|
||||
_memoizedLength = null;
|
||||
}
|
||||
|
||||
@override
|
||||
int get length => segments.length;
|
||||
|
||||
@override
|
||||
set length(int newLength) => segments.length = newLength;
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'Path(${[for (final s in segments) s.toString()].join(", ")})';
|
||||
|
||||
void _calcLengths({double error = defaultError, int minDepth = defaultMinDepth}) {
|
||||
if (_memoizedLength != null) return;
|
||||
|
||||
final lengths = [
|
||||
for (final s in segments) s.size(error: error, minDepth: minDepth)
|
||||
];
|
||||
_memoizedLength = lengths.reduce((a, b) => a + b);
|
||||
if (_memoizedLength == 0) {
|
||||
_memoizedLengths = lengths;
|
||||
} else {
|
||||
_memoizedLengths = [for (final l in lengths) l / _memoizedLength!];
|
||||
}
|
||||
|
||||
// Calculate the fractional distance for each segment to use in point()
|
||||
num fraction = 0;
|
||||
for (final l in _memoizedLengths!) {
|
||||
fraction += l;
|
||||
_fractions.add(fraction);
|
||||
}
|
||||
}
|
||||
|
||||
Point point({required num pos, double error = defaultError}) {
|
||||
// Shortcuts
|
||||
if (pos == 0.0) {
|
||||
return segments[0].point(pos);
|
||||
}
|
||||
if (pos == 1.0) {
|
||||
return segments.last.point(pos);
|
||||
}
|
||||
|
||||
_calcLengths(error: error);
|
||||
|
||||
// Fix for paths of length 0 (i.e. points)
|
||||
if (length == 0) {
|
||||
return segments[0].point(0.0);
|
||||
}
|
||||
|
||||
// Find which segment the point we search for is located on:
|
||||
late final num segmentPos;
|
||||
int i = _fractions.bisect(pos);
|
||||
if (i == 0) {
|
||||
segmentPos = pos / _fractions[0];
|
||||
} else {
|
||||
segmentPos =
|
||||
(pos - _fractions[i - 1]) / (_fractions[i] - _fractions[i - 1]);
|
||||
}
|
||||
return segments[i].point(segmentPos);
|
||||
}
|
||||
|
||||
num size({error = defaultError, minDepth = defaultMinDepth}) {
|
||||
_calcLengths(error: error, minDepth: minDepth);
|
||||
return _memoizedLength!;
|
||||
}
|
||||
|
||||
String d() {
|
||||
Point? currentPos;
|
||||
final parts = [];
|
||||
SvgPath? previousSegment;
|
||||
final end = last.end;
|
||||
|
||||
String formatNumber(num n) => n.toString();
|
||||
String coord(Point p) => '${formatNumber(p.x)},${formatNumber(p.y)}';
|
||||
|
||||
for (final segment in this) {
|
||||
final start = segment.start;
|
||||
// If the start of this segment does not coincide with the end of
|
||||
// the last segment or if this segment is actually the close point
|
||||
// of a closed path, then we should start a new subpath here.
|
||||
if (segment is Close) {
|
||||
parts.add("Z");
|
||||
} else if (segment is Move ||
|
||||
(currentPos != start) ||
|
||||
(start == end && previousSegment is! Move)) {
|
||||
parts.add("M ${coord(start)}");
|
||||
}
|
||||
|
||||
if (segment is Line) {
|
||||
parts.add("L ${coord(segment.end)}");
|
||||
} else if (segment is CubicBezier) {
|
||||
if (segment.isSmoothFrom(previousSegment)) {
|
||||
parts.add("S ${coord(segment.control2)} ${coord(segment.end)}");
|
||||
} else {
|
||||
parts.add(
|
||||
"C ${coord(segment.control1)} ${coord(segment.control2)} ${coord(segment.end)}",
|
||||
);
|
||||
}
|
||||
} else if (segment is QuadraticBezier) {
|
||||
if (segment.isSmoothFrom(previousSegment)) {
|
||||
parts.add("T ${coord(segment.end)}");
|
||||
} else {
|
||||
parts.add("Q ${coord(segment.control)} ${coord(segment.end)}");
|
||||
}
|
||||
} else if (segment is Arc) {
|
||||
parts.add(
|
||||
"A ${coord(segment.radius)} ${formatNumber(segment.rotation)} "
|
||||
"${(segment.arc ? 1 : 0).toDouble},${(segment.sweep ? 1 : 0).toDouble} ${coord(end)}",
|
||||
);
|
||||
}
|
||||
|
||||
currentPos = segment.end;
|
||||
previousSegment = segment;
|
||||
}
|
||||
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
// def __delitem__(self, index):
|
||||
// del self._segments[index]
|
||||
// self._length = None
|
||||
|
||||
// def reverse(self):
|
||||
// # Reversing the order of a path would require reversing each element
|
||||
// # as well. That's not implemented.
|
||||
// raise NotImplementedError
|
||||
|
||||
// def __len__(self):
|
||||
// return len(self._segments)
|
||||
|
||||
// def __eq__(self, other):
|
||||
|
||||
// if not isinstance(other, Path):
|
||||
// return NotImplemented
|
||||
// if len(self) != len(other):
|
||||
// return False
|
||||
// for s, o in zip(self._segments, other._segments):
|
||||
// if not s == o:
|
||||
// return False
|
||||
// return True
|
||||
|
||||
// def __ne__(self, other):
|
||||
// if not isinstance(other, Path):
|
||||
// return NotImplemented
|
||||
// return not self == other
|
||||
|
||||
}
|
56
pubspec.yaml
Normal file
56
pubspec.yaml
Normal file
@ -0,0 +1,56 @@
|
||||
name: kanimaji
|
||||
description: A new Flutter package project.
|
||||
version: 0.0.1
|
||||
homepage:
|
||||
|
||||
environment:
|
||||
sdk: ">=2.15.1 <3.0.0"
|
||||
flutter: ">=1.17.0"
|
||||
|
||||
dependencies:
|
||||
bisect: ^1.0.2
|
||||
flutter:
|
||||
sdk: flutter
|
||||
xml: ^5.3.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^1.0.0
|
||||
|
||||
# For information on the generic Dart part of this file, see the
|
||||
# following page: https://dart.dev/tools/pub/pubspec
|
||||
|
||||
# The following section is specific to Flutter.
|
||||
flutter:
|
||||
|
||||
# To add assets to your package, add an assets section, like this:
|
||||
# assets:
|
||||
# - images/a_dot_burr.jpeg
|
||||
# - images/a_dot_ham.jpeg
|
||||
#
|
||||
# For details regarding assets in packages, see
|
||||
# https://flutter.dev/assets-and-images/#from-packages
|
||||
#
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
# https://flutter.dev/assets-and-images/#resolution-aware.
|
||||
|
||||
# To add custom fonts to your package, add a fonts section here,
|
||||
# in this "flutter" section. Each entry in this list should have a
|
||||
# "family" key with the font family name, and a "fonts" key with a
|
||||
# list giving the asset and other descriptors for the font. For
|
||||
# example:
|
||||
# fonts:
|
||||
# - family: Schyler
|
||||
# fonts:
|
||||
# - asset: fonts/Schyler-Regular.ttf
|
||||
# - asset: fonts/Schyler-Italic.ttf
|
||||
# style: italic
|
||||
# - family: Trajan Pro
|
||||
# fonts:
|
||||
# - asset: fonts/TrajanPro.ttf
|
||||
# - asset: fonts/TrajanPro_Bold.ttf
|
||||
# weight: 700
|
||||
#
|
||||
# For details regarding fonts in packages, see
|
||||
# https://flutter.dev/custom-fonts/#from-packages
|
12
test/kanimaji_test.dart
Normal file
12
test/kanimaji_test.dart
Normal file
@ -0,0 +1,12 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:kanimaji/kanimaji.dart';
|
||||
|
||||
void main() {
|
||||
test('adds one to input values', () {
|
||||
final calculator = Calculator();
|
||||
expect(calculator.addOne(2), 3);
|
||||
expect(calculator.addOne(-7), -6);
|
||||
expect(calculator.addOne(0), 1);
|
||||
});
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user