diff --git a/_config.yml b/_config.yml
index d6b2c72..73f5e6d 100644
--- a/_config.yml
+++ b/_config.yml
@@ -41,3 +41,7 @@ plugins:
# - vendor/cache/
# - vendor/gems/
# - vendor/ruby/
+
+exclude:
+ - Cargo.toml
+ - Cargo.lock
diff --git a/_figures/.gitignore b/_figures/.gitignore
new file mode 100644
index 0000000..4f96631
--- /dev/null
+++ b/_figures/.gitignore
@@ -0,0 +1,2 @@
+/target
+/dist
diff --git a/_figures/beztoy/.gitignore b/_figures/beztoy/.gitignore
new file mode 100644
index 0000000..4f96631
--- /dev/null
+++ b/_figures/beztoy/.gitignore
@@ -0,0 +1,2 @@
+/target
+/dist
diff --git a/_figures/beztoy/Cargo.toml b/_figures/beztoy/Cargo.toml
new file mode 100644
index 0000000..68a84eb
--- /dev/null
+++ b/_figures/beztoy/Cargo.toml
@@ -0,0 +1,11 @@
+[package]
+name = "beztoy"
+license = "Apache-2.0"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+console_error_panic_hook = "0.1"
+wasm-bindgen = "0.2.87"
+web-sys = "0.3.64"
+xilem_web = { git = "https://github.com/linebender/xilem", rev = "1ef39fc562d969cfc8548d16b2b72202a5d2196d" }
diff --git a/_figures/beztoy/README.md b/_figures/beztoy/README.md
new file mode 100644
index 0000000..f54a358
--- /dev/null
+++ b/_figures/beztoy/README.md
@@ -0,0 +1,5 @@
+# beztoy
+
+A toy for experimenting with Bézier curves, intended to become a testbed for Euler spiral based stroke expansion.
+
+Run with `trunk serve`, then navigate to the webpage.
diff --git a/_figures/beztoy/Trunk.toml b/_figures/beztoy/Trunk.toml
new file mode 100644
index 0000000..af4efa9
--- /dev/null
+++ b/_figures/beztoy/Trunk.toml
@@ -0,0 +1,4 @@
+[build]
+#public_url = "https://levien.com/tmp/beztoy/"
+
+
diff --git a/_figures/beztoy/beztoy.tar.gz b/_figures/beztoy/beztoy.tar.gz
new file mode 100644
index 0000000..7c1fa8d
Binary files /dev/null and b/_figures/beztoy/beztoy.tar.gz differ
diff --git a/_figures/beztoy/index.html b/_figures/beztoy/index.html
new file mode 100644
index 0000000..a73a2ff
--- /dev/null
+++ b/_figures/beztoy/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+ See raphlinus#100 for more context.
+
diff --git a/_figures/beztoy/src/arc.rs b/_figures/beztoy/src/arc.rs
new file mode 100644
index 0000000..845373d
--- /dev/null
+++ b/_figures/beztoy/src/arc.rs
@@ -0,0 +1,71 @@
+// Copyright 2023 the raphlinus.github.io Authors
+// SPDX-License-Identifier: Apache-2.0
+
+//! Convert an Euler spiral to a series of arcs.
+
+use xilem_web::svg::kurbo::{SvgArc, Vec2};
+
+use crate::euler::EulerSeg;
+
+#[allow(unused)]
+pub fn euler_to_arcs(es: &EulerSeg, tol: f64) -> Vec {
+ let arclen = es.p0.distance(es.p1) / es.params.ch;
+ let n_subdiv = ((1. / 120.) * arclen / tol * es.params.k1.abs()).cbrt();
+ web_sys::console::log_1(&format!("n_subdiv = {n_subdiv}").into());
+ let n = (n_subdiv.ceil() as usize).max(1);
+ let dt = 1.0 / n as f64;
+ let mut p0 = es.p0;
+ (0..n)
+ .map(|i| {
+ let t0 = i as f64 * dt;
+ let t1 = t0 + dt;
+ let p1 = if i + 1 == n { es.p1 } else { es.eval(t1) };
+ let t = t0 + 0.5 * dt - 0.5;
+ let k = es.params.k0 + t * es.params.k1;
+ web_sys::console::log_1(&format!("{i}: k = {k} t = {t}").into());
+ let r = arclen / k;
+ let arc = SvgArc {
+ from: p0,
+ to: p1,
+ radii: Vec2::new(r, r),
+ x_rotation: 0.0,
+ large_arc: false,
+ sweep: k < 0.0,
+ };
+ p0 = p1;
+ arc
+ })
+ .collect()
+}
+
+pub fn espc_to_arcs(es: &EulerSeg, d: f64, tol: f64) -> Vec {
+ let arclen = es.p0.distance(es.p1) / es.params.ch;
+ let est_err =
+ (1. / 120.) / tol * es.params.k1.abs() * (arclen + 0.4 * (es.params.k1 * d).abs());
+ let n_subdiv = est_err.cbrt();
+ web_sys::console::log_1(&format!("n_subdiv = {n_subdiv}").into());
+ let n = (n_subdiv.ceil() as usize).max(1);
+ let dt = 1.0 / n as f64;
+ let mut p0 = es.eval_with_offset(0.0, d);
+ (0..n)
+ .map(|i| {
+ let t0 = i as f64 * dt;
+ let t1 = t0 + dt;
+ let p1 = es.eval_with_offset(t1, d);
+ let t = t0 + 0.5 * dt - 0.5;
+ let k = es.params.k0 + t * es.params.k1;
+ let arclen_offset = arclen + d * k;
+ let r = arclen_offset / k;
+ let arc = SvgArc {
+ from: p0,
+ to: p1,
+ radii: Vec2::new(r, r),
+ x_rotation: 0.0,
+ large_arc: false,
+ sweep: k < 0.0,
+ };
+ p0 = p1;
+ arc
+ })
+ .collect()
+}
diff --git a/_figures/beztoy/src/euler.rs b/_figures/beztoy/src/euler.rs
new file mode 100644
index 0000000..723bf9c
--- /dev/null
+++ b/_figures/beztoy/src/euler.rs
@@ -0,0 +1,339 @@
+// Copyright 2023 the raphlinus.github.io Authors
+// SPDX-License-Identifier: Apache-2.0
+
+//! Calculations and utilities for Euler spirals
+
+use xilem_web::svg::kurbo::{CubicBez, Point, Vec2};
+
+#[derive(Debug)]
+pub struct CubicParams {
+ pub th0: f64,
+ pub th1: f64,
+ pub d0: f64,
+ pub d1: f64,
+}
+
+#[derive(Debug)]
+pub struct EulerParams {
+ pub th0: f64,
+ pub th1: f64,
+ pub k0: f64,
+ pub k1: f64,
+ pub ch: f64,
+}
+
+#[derive(Debug)]
+pub struct EulerSeg {
+ pub p0: Point,
+ pub p1: Point,
+ pub params: EulerParams,
+}
+
+pub struct CubicToEulerIter {
+ c: CubicBez,
+ tolerance: f64,
+ // [t0 * dt .. (t0 + 1) * dt] is the range we're currently considering
+ t0: u64,
+ dt: f64,
+ last_p: Vec2,
+ last_q: Vec2,
+ last_t: f64,
+}
+
+impl CubicParams {
+ /// Compute parameters from endpoints and derivatives.
+ pub fn from_points_derivs(p0: Vec2, p1: Vec2, q0: Vec2, q1: Vec2, dt: f64) -> Self {
+ let chord = p1 - p0;
+ // Robustness note: we must protect this function from being called when the
+ // chord length is (near-)zero.
+ let scale = dt / chord.length_squared();
+ let h0 = Vec2::new(
+ q0.x * chord.x + q0.y * chord.y,
+ q0.y * chord.x - q0.x * chord.y,
+ );
+ let th0 = h0.atan2();
+ let d0 = h0.length() * scale;
+ let h1 = Vec2::new(
+ q1.x * chord.x + q1.y * chord.y,
+ q1.x * chord.y - q1.y * chord.x,
+ );
+ let th1 = h1.atan2();
+ let d1 = h1.length() * scale;
+ // Robustness note: we may want to clamp the magnitude of the angles to
+ // a bit less than pi. Perhaps here, perhaps downstream.
+ CubicParams { th0, th1, d0, d1 }
+ }
+
+ pub fn from_cubic(c: CubicBez) -> Self {
+ let chord = c.p3 - c.p0;
+ // TODO: if chord is 0, we have a problem
+ let d01 = c.p1 - c.p0;
+ let h0 = Vec2::new(
+ d01.x * chord.x + d01.y * chord.y,
+ d01.y * chord.x - d01.x * chord.y,
+ );
+ let th0 = h0.atan2();
+ let d0 = h0.hypot() / chord.hypot2();
+ let d23 = c.p3 - c.p2;
+ let h1 = Vec2::new(
+ d23.x * chord.x + d23.y * chord.y,
+ d23.x * chord.y - d23.y * chord.x,
+ );
+ let th1 = h1.atan2();
+ let d1 = h1.hypot() / chord.hypot2();
+ CubicParams { th0, th1, d0, d1 }
+ }
+
+ // Estimated error of GH to Euler spiral
+ //
+ // Return value is normalized to chord - to get actual error, multiply
+ // by chord.
+ pub fn est_euler_err(&self) -> f64 {
+ // Potential optimization: work with unit vector rather than angle
+ let cth0 = self.th0.cos();
+ let cth1 = self.th1.cos();
+ if cth0 * cth1 < 0.0 {
+ // Rationale: this happens when fitting a cusp or near-cusp with
+ // a near 180 degree u-turn. The actual ES is bounded in that case.
+ // Further subdivision won't reduce the angles if actually a cusp.
+ return 2.0;
+ }
+ let e0 = (2. / 3.) / (1.0 + cth0);
+ let e1 = (2. / 3.) / (1.0 + cth1);
+ let s0 = self.th0.sin();
+ let s1 = self.th1.sin();
+ // Note: some other versions take sin of s0 + s1 instead. Those are incorrect.
+ // Strangely, calibration is the same, but more work could be done.
+ let s01 = cth0 * s1 + cth1 * s0;
+ let amin = 0.15 * (2. * e0 * s0 + 2. * e1 * s1 - e0 * e1 * s01);
+ let a = 0.15 * (2. * self.d0 * s0 + 2. * self.d1 * s1 - self.d0 * self.d1 * s01);
+ let aerr = (a - amin).abs();
+ let symm = (self.th0 + self.th1).abs();
+ let asymm = (self.th0 - self.th1).abs();
+ let dist = (self.d0 - e0).hypot(self.d1 - e1);
+ let ctr = 3.7e-6 * symm.powi(5) + 6e-3 * asymm * symm.powi(2);
+ let halo_symm = 5e-3 * symm * dist;
+ let halo_asymm = 7e-2 * asymm * dist;
+ 1.25 * ctr + 1.55 * aerr + halo_symm + halo_asymm
+ }
+}
+
+impl EulerParams {
+ pub fn from_angles(th0: f64, th1: f64) -> EulerParams {
+ let k0 = th0 + th1;
+ let dth = th1 - th0;
+ let d2 = dth * dth;
+ let k2 = k0 * k0;
+ let mut a = 6.0;
+ a -= d2 * (1. / 70.);
+ a -= (d2 * d2) * (1. / 10780.);
+ a += (d2 * d2 * d2) * 2.769178184818219e-07;
+ let b = -0.1 + d2 * (1. / 4200.) + d2 * d2 * 1.6959677820260655e-05;
+ let c = -1. / 1400. + d2 * 6.84915970574303e-05 - k2 * 7.936475029053326e-06;
+ a += (b + c * k2) * k2;
+ let k1 = dth * a;
+
+ // calculation of chord
+ let mut ch = 1.0;
+ ch -= d2 * (1. / 40.);
+ ch += (d2 * d2) * 0.00034226190482569864;
+ ch -= (d2 * d2 * d2) * 1.9349474568904524e-06;
+ let b = -1. / 24. + d2 * 0.0024702380951963226 - d2 * d2 * 3.7297408997537985e-05;
+ let c = 1. / 1920. - d2 * 4.87350869747975e-05 - k2 * 3.1001936068463107e-06;
+ ch += (b + c * k2) * k2;
+ EulerParams {
+ th0,
+ th1,
+ k0,
+ k1,
+ ch,
+ }
+ }
+
+ pub fn eval_th(&self, t: f64) -> f64 {
+ (self.k0 + 0.5 * self.k1 * (t - 1.0)) * t - self.th0
+ }
+
+ /// Evaluate the curve at the given parameter.
+ ///
+ /// The parameter is in the range 0..1, and the result goes from (0, 0) to (1, 0).
+ fn eval(&self, t: f64) -> Point {
+ let thm = self.eval_th(t * 0.5);
+ let k0 = self.k0;
+ let k1 = self.k1;
+ let (u, v) = integ_euler_10((k0 + k1 * (0.5 * t - 0.5)) * t, k1 * t * t);
+ let s = t / self.ch * thm.sin();
+ let c = t / self.ch * thm.cos();
+ let x = u * c - v * s;
+ let y = -v * c - u * s;
+ Point::new(x, y)
+ }
+
+ fn eval_with_offset(&self, t: f64, offset: f64) -> Point {
+ let th = self.eval_th(t);
+ let v = Vec2::new(offset * th.sin(), offset * th.cos());
+ self.eval(t) + v
+ }
+
+ // Determine whether a render as a single cubic will be adequate
+ pub fn cubic_ok(&self) -> bool {
+ self.th0.abs() < 1.0 && self.th1.abs() < 1.0
+ }
+}
+
+impl EulerSeg {
+ pub fn from_params(p0: Point, p1: Point, params: EulerParams) -> Self {
+ EulerSeg { p0, p1, params }
+ }
+
+ /// Use two-parabola approximation.
+ pub fn to_cubic(&self) -> CubicBez {
+ let (s0, c0) = self.params.th0.sin_cos();
+ let (s1, c1) = self.params.th1.sin_cos();
+ let d0 = (2. / 3.) / (1.0 + c0);
+ let d1 = (2. / 3.) / (1.0 + c1);
+ let chord = self.p1 - self.p0;
+ let p1 = self.p0 + d0 * Vec2::new(chord.x * c0 - chord.y * s0, chord.y * c0 + chord.x * s0);
+ let p2 = self.p1 - d1 * Vec2::new(chord.x * c1 + chord.y * s1, chord.y * c1 - chord.x * s1);
+ CubicBez::new(self.p0, p1, p2, self.p1)
+ }
+
+ #[allow(unused)]
+ pub fn eval(&self, t: f64) -> Point {
+ let Point { x, y } = self.params.eval(t);
+ let chord = self.p1 - self.p0;
+ Point::new(
+ self.p0.x + chord.x * x - chord.y * y,
+ self.p0.y + chord.x * y + chord.y * x,
+ )
+ }
+
+ pub fn eval_with_offset(&self, t: f64, offset: f64) -> Point {
+ let chord = self.p1 - self.p0;
+ let scaled = offset / chord.hypot();
+ let Point { x, y } = self.params.eval_with_offset(t, scaled);
+ Point::new(
+ self.p0.x + chord.x * x - chord.y * y,
+ self.p0.y + chord.x * y + chord.y * x,
+ )
+ }
+}
+
+/// Evaluate both the point and derivative of a cubic bezier.
+fn eval_cubic_and_deriv(c: &CubicBez, t: f64) -> (Vec2, Vec2) {
+ let p0 = c.p0.to_vec2();
+ let p1 = c.p1.to_vec2();
+ let p2 = c.p2.to_vec2();
+ let p3 = c.p3.to_vec2();
+ let m = 1.0 - t;
+ let mm = m * m;
+ let mt = m * t;
+ let tt = t * t;
+ let p = p0 * (mm * m) + (p1 * (3.0 * mm) + p2 * (3.0 * mt) + p3 * tt) * t;
+ let q = (p1 - p0) * mm + (p2 - p1) * (2.0 * mt) + (p3 - p2) * tt;
+ (p, q)
+}
+
+impl Iterator for CubicToEulerIter {
+ type Item = EulerSeg;
+
+ fn next(&mut self) -> Option {
+ let t0 = (self.t0 as f64) * self.dt;
+ if t0 == 1.0 {
+ return None;
+ }
+ loop {
+ let mut t1 = t0 + self.dt;
+ let p0 = self.last_p;
+ let q0 = self.last_q;
+ let (mut p1, mut q1) = eval_cubic_and_deriv(&self.c, t1);
+ if q1.length_squared() < DERIV_THRESH.powi(2) {
+ let (new_p1, new_q1) = eval_cubic_and_deriv(&self.c, t1 - DERIV_EPS);
+ q1 = new_q1;
+ if t1 < 1. {
+ p1 = new_p1;
+ t1 -= DERIV_EPS;
+ }
+ }
+ // TODO: robustness
+ let actual_dt = t1 - self.last_t;
+ let cubic_params = CubicParams::from_points_derivs(p0, p1, q0, q1, actual_dt);
+ let est_err: f64 = cubic_params.est_euler_err();
+ let err = est_err * (p0 - p1).hypot();
+ if err <= self.tolerance {
+ self.t0 += 1;
+ let shift = self.t0.trailing_zeros();
+ self.t0 >>= shift;
+ self.dt *= (1 << shift) as f64;
+ let euler_params = EulerParams::from_angles(cubic_params.th0, cubic_params.th1);
+ let es = EulerSeg::from_params(p0.to_point(), p1.to_point(), euler_params);
+ self.last_p = p1;
+ self.last_q = q1;
+ self.last_t = t1;
+ return Some(es);
+ }
+ self.t0 *= 2;
+ self.dt *= 0.5;
+ }
+ }
+}
+
+/// Threshold below which a derivative is considered too small.
+const DERIV_THRESH: f64 = 1e-6;
+/// Amount to nudge t when derivative is near-zero.
+const DERIV_EPS: f64 = 1e-6;
+
+impl CubicToEulerIter {
+ pub fn new(c: CubicBez, tolerance: f64) -> Self {
+ let mut last_q = c.p1 - c.p0;
+ // TODO: tweak
+ if last_q.length_squared() < DERIV_THRESH.powi(2) {
+ last_q = eval_cubic_and_deriv(&c, DERIV_EPS).1;
+ }
+ CubicToEulerIter {
+ c,
+ tolerance,
+ t0: 0,
+ dt: 1.0,
+ last_p: c.p0.to_vec2(),
+ last_q,
+ last_t: 0.0,
+ }
+ }
+}
+
+/// Integrate Euler spiral.
+///
+/// TODO: investigate needed accuracy. We might be able to get away
+/// with 8th order.
+fn integ_euler_10(k0: f64, k1: f64) -> (f64, f64) {
+ let t1_1 = k0;
+ let t1_2 = 0.5 * k1;
+ let t2_2 = t1_1 * t1_1;
+ let t2_3 = 2. * (t1_1 * t1_2);
+ let t2_4 = t1_2 * t1_2;
+ let t3_4 = t2_2 * t1_2 + t2_3 * t1_1;
+ let t3_6 = t2_4 * t1_2;
+ let t4_4 = t2_2 * t2_2;
+ let t4_5 = 2. * (t2_2 * t2_3);
+ let t4_6 = 2. * (t2_2 * t2_4) + t2_3 * t2_3;
+ let t4_7 = 2. * (t2_3 * t2_4);
+ let t4_8 = t2_4 * t2_4;
+ let t5_6 = t4_4 * t1_2 + t4_5 * t1_1;
+ let t5_8 = t4_6 * t1_2 + t4_7 * t1_1;
+ let t6_6 = t4_4 * t2_2;
+ let t6_7 = t4_4 * t2_3 + t4_5 * t2_2;
+ let t6_8 = t4_4 * t2_4 + t4_5 * t2_3 + t4_6 * t2_2;
+ let t7_8 = t6_6 * t1_2 + t6_7 * t1_1;
+ let t8_8 = t6_6 * t2_2;
+ let mut u = 1.;
+ u -= (1. / 24.) * t2_2 + (1. / 160.) * t2_4;
+ u += (1. / 1920.) * t4_4 + (1. / 10752.) * t4_6 + (1. / 55296.) * t4_8;
+ u -= (1. / 322560.) * t6_6 + (1. / 1658880.) * t6_8;
+ u += (1. / 92897280.) * t8_8;
+ let mut v = (1. / 12.) * t1_2;
+ v -= (1. / 480.) * t3_4 + (1. / 2688.) * t3_6;
+ v += (1. / 53760.) * t5_6 + (1. / 276480.) * t5_8;
+ v -= (1. / 11612160.) * t7_8;
+ (u, v)
+}
diff --git a/_figures/beztoy/src/flatten.rs b/_figures/beztoy/src/flatten.rs
new file mode 100644
index 0000000..dc8d0b9
--- /dev/null
+++ b/_figures/beztoy/src/flatten.rs
@@ -0,0 +1,90 @@
+// Copyright 2023 the raphlinus.github.io Authors
+// SPDX-License-Identifier: Apache-2.0
+
+//! Math for flattening of Euler spiral parallel curve
+
+use std::f64::consts::FRAC_PI_4;
+
+use xilem_web::svg::kurbo::BezPath;
+
+use crate::euler::EulerSeg;
+
+pub fn flatten_offset(iter: impl Iterator- , offset: f64, tol: f64) -> BezPath {
+ let mut result = BezPath::new();
+ let mut first = true;
+ for es in iter {
+ if core::mem::take(&mut first) {
+ result.move_to(es.eval_with_offset(0.0, offset));
+ }
+ let scale = es.p0.distance(es.p1);
+ let (k0, k1) = (es.params.k0 - 0.5 * es.params.k1, es.params.k1);
+ // compute forward integral to determine number of subdivisions
+ let dist_scaled = offset / scale;
+ let a = -2.0 * dist_scaled * k1;
+ let b = -1.0 - 2.0 * dist_scaled * k0;
+ let int0 = espc_int_approx(b);
+ let int1 = espc_int_approx(a + b);
+ let integral = int1 - int0;
+ let k_peak = k0 - k1 * b / a;
+ let integrand_peak = (k_peak * (k_peak * dist_scaled + 1.0)).abs().sqrt();
+ let scaled_int = integral * integrand_peak / a;
+ let n_frac = 0.5 * (scale / tol).sqrt() * scaled_int;
+ let n = n_frac.ceil();
+ for i in 0..n as usize {
+ let t = (i + 1) as f64 / n;
+ let inv = espc_int_inv_approx(integral * t + int0);
+ let s = (inv - b) / a;
+ result.line_to(es.eval_with_offset(s, offset));
+ }
+ }
+ result
+}
+
+const BREAK1: f64 = 0.8;
+const BREAK2: f64 = 1.25;
+const BREAK3: f64 = 2.1;
+const SIN_SCALE: f64 = 1.0976991822760038;
+const QUAD_A1: f64 = 0.6406;
+const QUAD_B1: f64 = -0.81;
+const QUAD_C1: f64 = 0.9148117935952064;
+const QUAD_A2: f64 = 0.5;
+const QUAD_B2: f64 = -0.156;
+const QUAD_C2: f64 = 0.16145779359520596;
+
+fn espc_int_approx(x: f64) -> f64 {
+ let y = x.abs();
+ let a = if y < BREAK1 {
+ (SIN_SCALE * y).sin() * (1.0 / SIN_SCALE)
+ } else if y < BREAK2 {
+ (8.0f64.sqrt() / 3.0) * (y - 1.0) * (y - 1.0).abs().sqrt() + FRAC_PI_4
+ } else {
+ let (a, b, c) = if y < BREAK3 {
+ (QUAD_A1, QUAD_B1, QUAD_C1)
+ } else {
+ (QUAD_A2, QUAD_B2, QUAD_C2)
+ };
+ a * y * y + b * y + c
+ };
+ a.copysign(x)
+}
+
+fn espc_int_inv_approx(x: f64) -> f64 {
+ let y = x.abs();
+ let a = if y < 0.7010707591262915 {
+ (x * SIN_SCALE).asin() * (1.0 / SIN_SCALE)
+ } else if y < 0.903249293595206 {
+ let b = y - FRAC_PI_4;
+ let u = b.abs().powf(2. / 3.).copysign(b);
+ u * (9.0f64 / 8.).cbrt() + 1.0
+ } else {
+ let (u, v, w) = if y < 2.038857793595206 {
+ const B: f64 = 0.5 * QUAD_B1 / QUAD_A1;
+ (B * B - QUAD_C1 / QUAD_A1, 1.0 / QUAD_A1, B)
+ } else {
+ const B: f64 = 0.5 * QUAD_B2 / QUAD_A2;
+ (B * B - QUAD_C2 / QUAD_A2, 1.0 / QUAD_A2, B)
+ };
+ (u + v * y).sqrt() - w
+ };
+ a.copysign(x)
+}
diff --git a/_figures/beztoy/src/main.rs b/_figures/beztoy/src/main.rs
new file mode 100644
index 0000000..a250f40
--- /dev/null
+++ b/_figures/beztoy/src/main.rs
@@ -0,0 +1,231 @@
+// Copyright 2023 the raphlinus.github.io Authors
+// SPDX-License-Identifier: Apache-2.0
+
+//! An interactive toy for experimenting with rendering of Bézier paths,
+//! including Euler spiral based stroke expansion.
+
+mod arc;
+mod euler;
+mod flatten;
+
+use wasm_bindgen::JsCast;
+use xilem_web::{
+ document_body,
+ elements::{
+ html::{div, input},
+ svg::{g, svg},
+ },
+ interfaces::*,
+ svg::{
+ kurbo::{Arc, BezPath, Circle, CubicBez, Line, Point, Shape},
+ peniko::Color,
+ },
+ App, PointerMsg, View,
+};
+
+use crate::{
+ arc::espc_to_arcs,
+ euler::{CubicParams, CubicToEulerIter},
+ flatten::flatten_offset,
+};
+
+#[derive(Default)]
+struct AppState {
+ p0: Point,
+ p1: Point,
+ p2: Point,
+ p3: Point,
+ grab: GrabState,
+ offset: f64,
+ tolerance: f64,
+}
+
+#[derive(Default)]
+struct GrabState {
+ is_down: bool,
+ id: i32,
+ dx: f64,
+ dy: f64,
+}
+
+impl GrabState {
+ fn handle(&mut self, pt: &mut Point, p: &PointerMsg) {
+ match p {
+ PointerMsg::Down(e) => {
+ if e.button == 0 {
+ self.dx = pt.x - e.x;
+ self.dy = pt.y - e.y;
+ self.id = e.id;
+ self.is_down = true;
+ }
+ }
+ PointerMsg::Move(e) => {
+ if self.is_down && self.id == e.id {
+ pt.x = self.dx + e.x;
+ pt.y = self.dy + e.y;
+ }
+ }
+ PointerMsg::Up(e) => {
+ if self.id == e.id {
+ self.is_down = false;
+ }
+ }
+ }
+ }
+}
+
+// https://iamkate.com/data/12-bit-rainbow/
+const RAINBOW_PALETTE: [Color; 12] = [
+ Color::rgb8(0x88, 0x11, 0x66),
+ Color::rgb8(0xaa, 0x33, 0x55),
+ Color::rgb8(0xcc, 0x66, 0x66),
+ Color::rgb8(0xee, 0x99, 0x44),
+ Color::rgb8(0xee, 0xdd, 0x00),
+ Color::rgb8(0x99, 0xdd, 0x55),
+ Color::rgb8(0x44, 0xdd, 0x88),
+ Color::rgb8(0x22, 0xcc, 0xbb),
+ Color::rgb8(0x00, 0xbb, 0xcc),
+ Color::rgb8(0x00, 0x99, 0xcc),
+ Color::rgb8(0x33, 0x66, 0xbb),
+ Color::rgb8(0x66, 0x33, 0x99),
+];
+
+fn lerp_color(a: Color, b: Color, t: f64) -> Color {
+ let r = (a.r as f64 + (b.r as f64 - a.r as f64) * t) * (1. / 255.);
+ let g = (a.g as f64 + (b.g as f64 - a.g as f64) * t) * (1. / 255.);
+ let b = (a.b as f64 + (b.b as f64 - a.b as f64) * t) * (1. / 255.);
+ Color::rgb(r, g, b)
+}
+
+fn app_logic(state: &mut AppState) -> impl View {
+ let mut path = BezPath::new();
+ path.move_to(state.p0);
+ path.curve_to(state.p1, state.p2, state.p3);
+ let stroke = xilem_web::svg::kurbo::Stroke::new(2.0);
+ let stroke_thick = xilem_web::svg::kurbo::Stroke::new(15.0);
+ let stroke_thin = xilem_web::svg::kurbo::Stroke::new(1.0);
+ const NONE: Color = Color::TRANSPARENT;
+ const HANDLE_RADIUS: f64 = 4.0;
+ let c = CubicBez::new(state.p0, state.p1, state.p2, state.p3);
+ let params = CubicParams::from_cubic(c);
+ let err = params.est_euler_err();
+ let mut spirals = vec![];
+ let tol = state.tolerance;
+ for (i, es) in CubicToEulerIter::new(c, tol).enumerate() {
+ let path = if es.params.cubic_ok() {
+ es.to_cubic().into_path(1.0)
+ } else {
+ // Janky rendering, we should be more sophisticated
+ // and subdivide into cubics with appropriate bounds
+ let mut path = BezPath::new();
+ const N: usize = 20;
+ path.move_to(es.p0);
+ for i in 1..N {
+ let t = i as f64 / N as f64;
+ path.line_to(es.eval(t));
+ }
+ path.line_to(es.p1);
+ path
+ };
+ let color = RAINBOW_PALETTE[(i * 7) % 12];
+ let color = lerp_color(color, Color::WHITE, 0.5);
+ spirals.push(path.stroke(color, stroke_thick.clone()).fill(NONE));
+ }
+ let offset = state.offset;
+ let flat_ref = flatten_offset(CubicToEulerIter::new(c, 0.01), offset, 0.01);
+ let mut flat_pts = vec![];
+ let mut flat = BezPath::new();
+ web_sys::console::log_1(&"---".into());
+ for es in CubicToEulerIter::new(c, tol) {
+ if flat.is_empty() {
+ flat.move_to(es.eval_with_offset(0.0, offset));
+ }
+ for arc in espc_to_arcs(&es, offset, tol) {
+ let circle = Circle::new(arc.to, 2.0).fill(Color::BLACK);
+ flat_pts.push(circle);
+ if let Some(arc) = Arc::from_svg_arc(&arc) {
+ flat.extend(arc.append_iter(0.1));
+ } else {
+ web_sys::console::log_1(&format!("conversion failed {arc:?}").into());
+ }
+ }
+ }
+ let svg_el = svg(g((
+ g(spirals),
+ path.stroke(Color::BLACK, stroke_thin.clone()).fill(NONE),
+ flat_ref
+ .stroke(Color::BLACK, stroke_thin.clone())
+ .fill(NONE),
+ flat.stroke(Color::GREEN, stroke_thin.clone()).fill(NONE),
+ g(flat_pts),
+ Line::new(state.p0, state.p1).stroke(Color::BLUE, stroke.clone()),
+ Line::new(state.p2, state.p3).stroke(Color::BLUE, stroke.clone()),
+ Line::new((790., 300.), (790., 300. - 1000. * err)).stroke(Color::RED, stroke.clone()),
+ g((
+ Circle::new(state.p0, HANDLE_RADIUS)
+ .pointer(|s: &mut AppState, msg| s.grab.handle(&mut s.p0, &msg)),
+ Circle::new(state.p1, HANDLE_RADIUS)
+ .pointer(|s: &mut AppState, msg| s.grab.handle(&mut s.p1, &msg)),
+ Circle::new(state.p2, HANDLE_RADIUS)
+ .pointer(|s: &mut AppState, msg| s.grab.handle(&mut s.p2, &msg)),
+ Circle::new(state.p3, HANDLE_RADIUS)
+ .pointer(|s: &mut AppState, msg| s.grab.handle(&mut s.p3, &msg)),
+ )),
+ )))
+ .attr("width", 800)
+ .attr("height", 600);
+ let slider_el = input(())
+ .attr("type", "range")
+ .attr("min", "1")
+ .attr("max", "100")
+ .attr("value", "100")
+ .on_input(|state: &mut AppState, evt| {
+ if let Some(element) = evt
+ .target()
+ .and_then(|t| t.dyn_into::().ok())
+ {
+ let value = element.value();
+ if let Ok(val_f64) = value.parse::() {
+ state.offset = val_f64;
+ //web_sys::console::log_1(&format!("got input event {val_f64}").into());
+ }
+ }
+ });
+ let tolerance_el = input(())
+ .attr("type", "range")
+ .attr("min", "1")
+ .attr("max", "100")
+ .attr("value", "10")
+ .on_input(|state: &mut AppState, evt| {
+ if let Some(element) = evt
+ .target()
+ .and_then(|t| t.dyn_into::().ok())
+ {
+ let value = element.value();
+ if let Ok(val_f64) = value.parse::() {
+ state.tolerance = val_f64 * 0.1;
+ //web_sys::console::log_1(&format!("got input event {val_f64}").into());
+ }
+ }
+ });
+ div((
+ div("Offset"),
+ slider_el,
+ div("Tolerance"),
+ tolerance_el,
+ div(svg_el),
+ ))
+}
+
+pub fn main() {
+ console_error_panic_hook::set_once();
+ let mut state = AppState::default();
+ state.p0 = Point::new(100.0, 100.0);
+ state.p1 = Point::new(300.0, 150.0);
+ state.p2 = Point::new(500.0, 150.0);
+ state.p3 = Point::new(700.0, 150.0);
+ state.offset = 100.0;
+ state.tolerance = 1.0;
+ let app = App::new(state, app_logic);
+ app.run(&document_body());
+}
diff --git a/_figures/grid_robustness.drawio b/_figures/grid_robustness.drawio
new file mode 100644
index 0000000..9add686
--- /dev/null
+++ b/_figures/grid_robustness.drawio
@@ -0,0 +1 @@
+5VZdb5swFP01PK4CjEn62NJsU6d2raKozxa+AasGR45Tkv76mWADDkmzRam6dXmI7HM/bJ9zdIWHkmL9TZJFficocC/06dpDN14YXmJf/9fApgHiUdwAmWS0gYIOmLJXMKCpy1aMwtJJVEJwxRYumIqyhFQ5GJFSVG7aXHD31AXJYABMU8KH6BOjKm/QsX1WjX8HluX25MA3kYLYZAMsc0JF1YPQxEOJFEI1q2KdAK+5s7w0dV8PRNuLSSjV7xTMHuR88nr/YxYr/Pzz7v7xdnb7xXR5IXxlHmwuqzaWASlWJYW6ie+h6ypnCqYLktbRSkuusVwVXO8CvZwzzhPBhdzWIophTCONL5UUz9CL+NufjpgLgFSwPviyoOVL+wxEAUpudIotiA3FxmPh2OyrTrE2J++rZUFiXJK1vTsi9cJw+Qe8hsd5hZJe1QbVu1KU4PLokt7UAh1Y9ShDPQbwHgIsJoETxV7c9vtIMSc8CKYP7gQIXAEQ3uF1KVYyBVPV9+hOI+0LV8lwp5EiMgM1aLTVqH326bLFZ5VtYPokcUz/pqCHbfVhKuMdcS7xBT5N52B0tNU7K40+w+CLxn/d4Iv+q8EXnWvw4Q8efPh9B5/v16PvHxA0GEV2ELWj6URJ97QanUtUve0+HJv07usbTX4B
\ No newline at end of file
diff --git a/_figures/simplify_figs/.DS_Store b/_figures/simplify_figs/.DS_Store
new file mode 100644
index 0000000..81229e8
Binary files /dev/null and b/_figures/simplify_figs/.DS_Store differ
diff --git a/_figures/xilem_view.drawio b/_figures/xilem_view.drawio
new file mode 100644
index 0000000..e4dc1f3
--- /dev/null
+++ b/_figures/xilem_view.drawio
@@ -0,0 +1 @@
+zVZNc5swEP01HONByHz4mDiue/FMZtxpm6MKG9AEEBXC4P76Ckvi027STDu2L9a+Xa2W994ILLzOmi0nRbJjEaSWY0eNhR8tx0EIL+VfixwV4nm+AmJOI13UA3v6CzRoa7SiEZSjQsFYKmgxBkOW5xCKEUY4Z/W47IWl41MLEsMM2IcknaPfaCQShQau3eOfgcaJORnZOpMRU6yBMiERqwcQ3lh4zRkTapU1a0hb8gwvat+nC9luMA65eM+G7et2x35w3uyeANYlL3Jvc6e7HEha6Qe2HC+V/R5emGwrpxZHTYX3s2ImcVeehLqXBSgomj4pV3H7/3UvSPhqesmhVDuV1Hx0nR3OqjyCdk5bpuuECtgXJGyztbSVxBKRpTJC3e4DcAHNRSpQR7B0JrAMBD/KErNhpTUxptRh3SuMjGzJQF1TR7Sp4q5zz7tcaOr/QgbnogxlQfKPy/AFGjEQQTW7EREmGqDg2iLg/yTCQyUEy29VBie4NR2WZ3SYkAR5dN9e7jIKU1KWNBzzMiaxVUm/WVAgY8kMP35vkwvXhM+69hQ8NqPoqCM1BkSzN8aEazkqq3gIb9+7gvAYxFsXw1y7gTbuGWkMxiElgh7G457TS5/wxOjp0m8mVphaw7RQj6l3DV89k0YOnjTCk0aKh1mjk326x/64o/xbdRQ0VKhtvqvDZ31gu+53tcEVbYivaUMHowVeYWTj5TJwfHfl/tlM73Ul8v2FjVf9z5u43f9HJpVh/5mnyvuPZbz5DQ==
\ No newline at end of file