From 316755e2ca156b749c6bfe6407c106cde13add38 Mon Sep 17 00:00:00 2001 From: rami3l Date: Sun, 14 Dec 2025 20:23:52 +0100 Subject: [PATCH 1/2] feat: impl first port --- Cargo.lock | 288 ++++++++++++++++ Cargo.toml | 8 + README.md | 10 + rustfmt.toml | 12 + src/bez.rs | 15 + src/bez/strand.rs | 45 +++ src/error.rs | 7 + src/lib.rs | 41 ++- src/spiro.rs | 492 ++++++++++++++++++++++++++++ src/spiro/arc.rs | 153 +++++++++ src/spiro/k.rs | 158 +++++++++ tests/snapshots/to_bez__bezier.snap | 42 +++ tests/to_bez.rs | 72 ++++ 13 files changed, 1332 insertions(+), 11 deletions(-) create mode 100644 README.md create mode 100644 rustfmt.toml create mode 100644 src/bez.rs create mode 100644 src/bez/strand.rs create mode 100644 src/error.rs create mode 100644 src/spiro.rs create mode 100644 src/spiro/arc.rs create mode 100644 src/spiro/k.rs create mode 100644 tests/snapshots/to_bez__bezier.snap create mode 100644 tests/to_bez.rs diff --git a/Cargo.lock b/Cargo.lock index bbdc522..095377a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,294 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "windows-sys 0.59.0", +] + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "insta" +version = "1.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b76866be74d68b1595eb8060cb9191dca9c021db2316558e52ddc5d55d41b66c" +dependencies = [ + "console", + "once_cell", + "similar", + "tempfile", +] + +[[package]] +name = "libc" +version = "0.2.178" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "sirop" version = "0.1.0" +dependencies = [ + "insta", + "thiserror", +] + +[[package]] +name = "syn" +version = "2.0.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" diff --git a/Cargo.toml b/Cargo.toml index 54478dd..0d3c838 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,17 @@ [package] name = "sirop" version = "0.1.0" +license = "EPL-2.0" edition = "2024" +homepage = "https://github.com/rami3l/sirop" +repository = "https://github.com/rami3l/sirop" +description = "A libspiro port in pure Rust." [dependencies] +thiserror = "2.0.17" + +[dev-dependencies] +insta = "1.45.0" [lints.rust] missing_copy_implementations = "warn" diff --git a/README.md b/README.md new file mode 100644 index 0000000..6836b30 --- /dev/null +++ b/README.md @@ -0,0 +1,10 @@ +## Sirop + +This is a manual port of [`spiro-js`] from TypeScript to Rust, trying to become +a truly readable and maintainable Rust-native alternative to [`spiro-sys-rs`]. + +Please refer to [`spiro-js`] for the licensing and authorship of the original +code. + +[`spiro-js`]: https://github.com/be5invis/spiro-js +[`spiro-sys-rs`]: https://github.com/Pangpang-Studio/spiro-sys-rs diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..f70eefe --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,12 @@ +edition = "2024" +unstable_features = true + +format_macro_matchers = true +format_macro_bodies = true +group_imports = "StdExternalCrate" +imports_granularity = "Crate" +reorder_impl_items = true +wrap_comments = true + +use_field_init_shorthand = true +trailing_semicolon = true diff --git a/src/bez.rs b/src/bez.rs new file mode 100644 index 0000000..3aa55f8 --- /dev/null +++ b/src/bez.rs @@ -0,0 +1,15 @@ +mod strand; + +pub use self::strand::{Ctx as StrandCtx, Strand}; +use crate::Point; + +pub trait Ctx { + fn move_to(&mut self, to: Point); + fn line_to(&mut self, to: Point); + fn cubic_to(&mut self, c1: Point, c2: Point, to: Point); + + fn mark_knot(&mut self, _idx: usize) {} + + fn start(&mut self) {} + fn end(&mut self) {} +} diff --git a/src/bez/strand.rs b/src/bez/strand.rs new file mode 100644 index 0000000..f80f260 --- /dev/null +++ b/src/bez/strand.rs @@ -0,0 +1,45 @@ +use crate::Point; + +#[derive(Default, Debug, Clone)] +pub struct Ctx { + pub strands: Vec, + pub last_pos: Point, +} + +#[derive(Debug, Clone, Copy)] +pub enum Strand { + Line { + start: Point, + end: Point, + }, + Cubic { + start: Point, + c1: Point, + c2: Point, + end: Point, + }, +} + +impl super::Ctx for Ctx { + fn move_to(&mut self, to: Point) { + self.last_pos = to; + } + + fn line_to(&mut self, to: Point) { + self.strands.push(Strand::Line { + start: self.last_pos, + end: to, + }); + self.last_pos = to; + } + + fn cubic_to(&mut self, c1: Point, c2: Point, to: Point) { + self.strands.push(Strand::Cubic { + start: self.last_pos, + c1, + c2, + end: to, + }); + self.last_pos = to; + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..1729405 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,7 @@ +pub type Result = std::result::Result; + +#[derive(thiserror::Error, Debug, Clone, Copy)] +pub enum Error { + #[error("not enough spiro control points to create bezier path")] + NotEnoughCps, +} diff --git a/src/lib.rs b/src/lib.rs index b93cf3f..5a0288a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,14 +1,33 @@ -pub fn add(left: u64, right: u64) -> u64 { - left + right -} +pub mod bez; +mod error; +pub mod spiro; + +use crate::{bez::Ctx, spiro::Segs}; +pub use crate::{ + error::{Error, Result}, + spiro::{Cp, CpTy}, +}; + +pub type Point = (f64, f64); + +/// Renders a spiro curve to a bezier context. +/// +/// # Errors +/// +/// Returns [`Error::NotEnoughCps`] if `cps` has fewer than 2 elements. +pub fn bezier( + cps: impl IntoIterator, + ctx: &mut C, + is_closed: bool, + delta: impl Into>, +) -> Result<()> { + let delta = delta.into().unwrap_or(1.); + let mut cps: Vec<_> = cps.into_iter().collect(); -#[cfg(test)] -mod tests { - use super::*; + ctx.start(); + let segs = Segs::from_cps(&mut cps, is_closed)?; + segs.to_bez_path(ctx, segs.0.len(), delta); + ctx.end(); - #[test] - fn it_works() { - let result = add(2, 2); - assert_eq!(result, 4); - } + Ok(()) } diff --git a/src/spiro.rs b/src/spiro.rs new file mode 100644 index 0000000..cc865a9 --- /dev/null +++ b/src/spiro.rs @@ -0,0 +1,492 @@ +mod arc; +mod k; + +pub use self::arc::Arc; +use self::k::K; +use crate::{Error, Point, Result, bez::Ctx}; + +/// A spiro control point. +#[derive(Default, Debug, Clone, Copy)] +pub struct Cp { + pub pt: Point, + pub ty: CpTy, +} + +impl Cp { + const fn x(&self) -> f64 { + self.pt.0 + } + + const fn y(&self) -> f64 { + self.pt.1 + } +} + +/// The type of a [`Cp`]. +#[derive(Copy, Clone, Default, PartialEq, Eq, Debug)] +#[repr(u8)] +pub enum CpTy { + #[default] + Corner = b'v', + G4 = b'o', + G2 = b'c', + /// Also known as "left". + Flat = b'[', + /// Also known as "right". + Curl = b']', + Open = b'{', + EndOpen = b'}', +} + +impl CpTy { + const fn jinc(self, other: Self) -> usize { + #[allow(clippy::enum_glob_use)] + use CpTy::*; + + match (self, other) { + (G4 | Curl, _) | (_, G4 | Flat) => 4, + (G2, G2) => 2, + (G2, EndOpen | Corner | Curl) | (Open | Corner | Flat, G2) => 1, + _ => 0, + } + } +} + +impl TryFrom for CpTy { + type Error = u8; + + fn try_from(value: u8) -> Result { + match value { + b'v' => Ok(Self::Corner), + b'o' => Ok(Self::G4), + b'c' => Ok(Self::G2), + b'[' => Ok(Self::Flat), + b']' => Ok(Self::Curl), + b'{' => Ok(Self::Open), + b'}' => Ok(Self::EndOpen), + i => Err(i), + } + } +} + +#[derive(Default, Debug, Clone, Copy)] +pub struct Seg { + cp: Cp, + + bend_th: f64, + ks: K, + ch: f64, + th: f64, +} + +impl Seg { + const fn pt(&self) -> Point { + self.cp.pt + } + + const fn x(&self) -> f64 { + self.cp.x() + } + + const fn y(&self) -> f64 { + self.cp.y() + } + + const fn ty(&self) -> CpTy { + self.cp.ty + } + + fn ends_and_pderivs(&self, jinc: usize, ends: &mut Ends, derivs: &mut Derivs) { + let recip_delta = 2e6_f64; + let delta = recip_delta.recip(); + let mut try_ks = K::default(); + let mut try_ends = Ends::default(); + + try_ks.ends(self.ch, ends); + + #[allow(clippy::needless_range_loop)] + for i in 0..jinc { + try_ks = self.ks; + + try_ks.0[i] += delta; + try_ks.ends(self.ch, &mut try_ends); + for k in 0..2 { + for j in 0..4 { + derivs[j][k][i] = recip_delta * (try_ends[k][j] - ends[k][j]); + } + } + } + } +} + +#[derive(Debug)] +pub struct Segs(pub Vec); + +impl Segs { + fn new(src: &[Cp]) -> Self { + let n = src.len(); + let is_open = src[0].ty == CpTy::Open; + let n_seg = n - usize::from(is_open); + + let mut r = vec![Seg::default(); n_seg + 1]; + for (cp, s) in src[..n_seg].iter().zip(r.iter_mut()) { + s.cp = *cp; + } + r[n_seg].cp = src[n_seg % n]; + + for i in 0..n_seg { + let dx = r[i + 1].x() - r[i].x(); + let dy = r[i + 1].y() - r[i].y(); + r[i].ch = dx.hypot(dy); + r[i].th = dy.atan2(dx); + } + + let mut i_last = n_seg - 1; + for i in 0..n_seg { + r[i].bend_th = match r[i].ty() { + CpTy::Open | CpTy::EndOpen | CpTy::Corner => 0., + _ => mod_tau(r[i].th - r[i_last].th), + }; + i_last = i; + } + + Self(r) + } + + fn count(&self, n_seg: usize) -> usize { + self.0[..=n_seg] + .windows(2) + .map(|w| w[0].ty().jinc(w[1].ty())) + .sum() + } + + #[allow( + clippy::many_single_char_names, + clippy::too_many_lines, + clippy::similar_names + )] + fn spiro(&mut self, n: usize, m: &mut [BandMat], perm: &mut [usize], v: &mut [f64]) -> f64 { + let nmat = self.count(n); + let s = &mut self.0; + let is_cyclic = !matches!(s[0].ty(), CpTy::Open | CpTy::Corner); + + m.fill(BandMat::default()); + v.fill(0.); + + let mut j = 0; + let mut jj = match s[0].ty() { + CpTy::G4 => nmat - 2, + CpTy::G2 => nmat - 1, + _ => 0, + }; + + for i in 0..n { + let ty0 = s[i].ty(); + let ty1 = s[i + 1].ty(); + let jinc = ty0.jinc(ty1); + let th = s[i].bend_th; + let mut ends = Ends::default(); + let mut derivs = Derivs::default(); + + let mut jthl = usize::MAX; + let mut jk0l = usize::MAX; + let mut jk1l = usize::MAX; + let mut jk2l = usize::MAX; + let mut jthr = usize::MAX; + let mut jk0r = usize::MAX; + let mut jk1r = usize::MAX; + let mut jk2r = usize::MAX; + + s[i].ends_and_pderivs(jinc, &mut ends, &mut derivs); + + /* constraints crossing LEFT */ + if matches!(ty0, CpTy::G4 | CpTy::G2 | CpTy::Flat | CpTy::Curl) { + jthl = jj; + jj += 1; + jj %= nmat; + jk0l = jj; + jj += 1; + } + if ty0 == CpTy::G4 { + jj %= nmat; + jk1l = jj; + jj += 1; + jk2l = jj; + jj += 1; + } + + /* constraints on LEFT */ + if matches!(ty0, CpTy::Flat | CpTy::Corner | CpTy::Open | CpTy::G2) && jinc == 4 { + if ty0 != CpTy::G2 { + jk1l = jj; + jj += 1; + } + jk2l = jj; + jj += 1; + } + + /* constraints on RIGHT */ + if matches!(ty1, CpTy::Curl | CpTy::Corner | CpTy::EndOpen | CpTy::G2) && jinc == 4 { + if ty1 != CpTy::G2 { + jk1r = jj; + jj += 1; + } + jk2r = jj; + jj += 1; + } + + /* constraints crossing RIGHT */ + if matches!(ty1, CpTy::G4 | CpTy::G2 | CpTy::Flat | CpTy::Curl) { + jthr = jj; + jk0r = (jj + 1) % nmat; + } + if ty1 == CpTy::G4 { + jk1r = (jj + 2) % nmat; + jk2r = (jj + 3) % nmat; + } + + let mut add_mat_line = |derivs: &[f64], x: f64, y: f64, jj: usize| { + if jj.cast_signed() < 0 { + return; + } + let mut joff = (j + 5 + nmat - jj) % nmat; + if nmat < 6 { + joff = j + 5 - jj; + } else if nmat == 6 { + joff = 2 + ((j + 3 + nmat - jj) % nmat); + } + v[jj] += x; + for (k, &deriv) in derivs[..jinc].iter().enumerate() { + m[jj].a[joff + k] += y * deriv; + } + }; + + add_mat_line(&derivs[0][0], th - ends[0][0], 1., jthl); + add_mat_line(&derivs[1][0], ends[0][1], -1., jk0l); + add_mat_line(&derivs[2][0], ends[0][2], -1., jk1l); + add_mat_line(&derivs[3][0], ends[0][3], -1., jk2l); + add_mat_line(&derivs[0][1], -ends[1][0], 1., jthr); + add_mat_line(&derivs[1][1], -ends[1][1], 1., jk0r); + add_mat_line(&derivs[2][1], -ends[1][2], 1., jk1r); + add_mat_line(&derivs[3][1], -ends[1][3], 1., jk2r); + + j += jinc; + } + + let n_invert; + (n_invert, j) = if is_cyclic { + m.copy_within(0..nmat, nmat); + m.copy_within(0..nmat, 2 * nmat); + v.copy_within(0..nmat, nmat); + v.copy_within(0..nmat, 2 * nmat); + (3 * nmat, nmat) + } else { + (nmat, 0) + }; + + BandMat::bandec11(m, n_invert, perm); + BandMat::banbks11(m, n_invert, perm, v); + + let mut norm = 0.; + for i in 0..n { + let ty0 = s[i].ty(); + let ty1 = s[i + 1].ty(); + for k in 0..ty0.jinc(ty1) { + let dk = v[j]; + j += 1; + s[i].ks.0[k] += dk; + norm += dk * dk; + } + } + norm + } + + fn solve(&mut self, n_seg: usize) { + let nmat = self.count(n_seg); + if nmat == 0 { + return; + } + + let s = &mut self.0; + let mut n_alloc = nmat; + if !matches!(s[0].ty(), CpTy::Open | CpTy::Corner) { + n_alloc *= 3; + } + n_alloc = n_alloc.max(5); + + let mut m = vec![BandMat::default(); n_alloc]; + let mut v = vec![0.; n_alloc]; + let mut perm = vec![0; n_alloc]; + + for _ in 0..10 { + let norm = self.spiro(n_seg, &mut m, &mut perm, &mut v); + if norm < 1e-12 { + break; + } + } + } + + pub(crate) fn to_bez_path(&self, ctx: &mut C, n_seg: usize, delta: f64) { + let s = &self.0; + let n_seg = n_seg - usize::from(s[0].ty() == CpTy::Open); + + for (i, w) in s.windows(2).enumerate().take(n_seg) { + let [p0, p1, ..] = w else { + unreachable!(); + }; + let (p0, p1) = (p0.pt(), p1.pt()); + + if i == 0 { + ctx.move_to(p0); + ctx.mark_knot(0); + } + + Arc::new(s[i].ks, p0, p1).to_bez_path(ctx, delta); + ctx.mark_knot(i + 1); + } + } + + /// Creates a [`Segs`] from control points. + /// + /// # Errors + /// + /// Returns [`Error::NotEnoughCps`] if `cps` has fewer than 2 elements. + pub fn from_cps(cps: &mut [Cp], is_closed: bool) -> Result { + let n = cps.len(); + let [head, .., last] = cps else { + return Err(Error::NotEnoughCps); + }; + if !is_closed { + head.ty = CpTy::Open; + last.ty = CpTy::EndOpen; + } + + let n_seg = n - usize::from(head.ty == CpTy::Open); + let mut segs = Self::new(cps); + segs.solve(n_seg); + Ok(segs) + } +} + +#[derive(Default, Debug, Clone, Copy)] +struct BandMat { + /// The band-diagonal matrix. + a: [f64; 11], + /// The lower part of band-diagonal decomposition. + al: [f64; 5], +} + +impl BandMat { + fn bandec11(m: &mut [Self], n: usize, perm: &mut [usize]) { + // Pack top triangle to the LEFT. + for (i, mi) in m[..5].iter_mut().enumerate() { + for j in 0..(i + 6) { + mi.a[j] = mi.a[j + 5 - i]; + } + for it in &mut mi.a[i + 6..] { + *it = 0.; + } + } + + let mut l = 5; + for k in 0..n { + let mut pivot = k; + let mut pivot_val = m[k].a[0]; + + l = if l < n { 1 + l } else { n }; + + for (j, mj) in m[..l].iter().enumerate().skip(k + 1) { + if mj.a[0].abs() > pivot_val.abs() { + pivot_val = mj.a[0]; + pivot = j; + } + } + + perm[k] = pivot; + m.swap(k, pivot); + + if pivot_val.abs() < 1e-12 { + pivot_val = 1e-12; + } + let pivot_scale = pivot_val.recip(); + for i in (k + 1)..l { + let x = m[i].a[0] * pivot_scale; + m[k].al[i - k - 1] = x; + for j in 1..11 { + m[i].a[j - 1] = x.mul_add(-m[k].a[j], m[i].a[j]); + } + m[i].a[10] = 0.; + } + } + } + + #[allow(clippy::many_single_char_names)] + fn banbks11(m: &[Self], n: usize, perm: &[usize], v: &mut [f64]) { + /* forward substitution */ + let mut l = 5; + for k in 0..n { + let i = perm[k]; + v.swap(i, k); + l += usize::from(l < n); + for i in (k + 1)..l { + v[i] -= m[k].al[i - k - 1] * v[k]; + } + } + + /* back substitution */ + l = 1; + for i in (0..n).rev() { + let mut x = v[i]; + for k in 1..l { + x -= m[i].a[k] * v[k + i]; + } + v[i] = x / m[i].a[0]; + l += usize::from(l < 11); + } + } +} + +type Ends = [[f64; 4]; 2]; +type Derivs = [[[f64; 4]; 2]; 4]; + +impl K { + fn ends(&self, seg_ch: f64, ends: &mut Ends) -> f64 { + let [k0, k1, k2, k3] = self.0; + + let (sx, sy) = self.integrate_spiro(); + let ch = sx.hypot(sy); + let th = sy.atan2(sx); + let l = ch / seg_ch; + + let th_even = k0 / 2. + k2 / 48.; + let th_odd = k1 / 8. + k3 / 384. - th; + ends[0][0] = th_even - th_odd; + ends[1][0] = th_even + th_odd; + + let k0_even = l * (k0 + k2 / 8.); + let k0_odd = l * (k1 / 2. + k3 / 48.); + ends[0][1] = k0_even - k0_odd; + ends[1][1] = k0_even + k0_odd; + + let l2 = l * l; + let k1_even = l2 * (k1 + k3 / 8.); + let k1_odd = l2 * k2 / 2.; + ends[0][2] = k1_even - k1_odd; + ends[1][2] = k1_even + k1_odd; + + let l3 = l2 * l; + let k2_even = l3 * k2; + let k2_odd = l3 * k3 / 2.; + ends[0][3] = k2_even - k2_odd; + ends[1][3] = k2_even + k2_odd; + + l + } +} + +fn mod_tau(th: f64) -> f64 { + use std::f64::consts::TAU; + + let u = th / TAU; + TAU * (u - (u + 0.5).floor()) +} diff --git a/src/spiro/arc.rs b/src/spiro/arc.rs new file mode 100644 index 0000000..efccd98 --- /dev/null +++ b/src/spiro/arc.rs @@ -0,0 +1,153 @@ +use super::K as SpiroK; +use crate::{ + Point, + bez::{self, Ctx}, +}; + +/// An [`Arc`] is defined as: +/// ∫[-1/2 .. 1/2] exp(i * (k0 s + (1/2) * k1 * s^2 + (1/6) * k2 * s^3 + (1/24) +/// * k3 * s^4)) d s +#[derive(Debug, Clone, Copy)] +pub struct Arc { + pub p0: Point, + pub p1: Point, + + rot: f64, + ks: SpiroK, + + pub derive_x0: f64, + pub derive_y0: f64, + pub derive_x1: f64, + pub derive_y1: f64, + + pub len: f64, + pub bend: f64, +} + +impl Arc { + #[allow(clippy::similar_names)] + #[must_use] + pub fn new(ks: SpiroK, p0: Point, p1: Point) -> Self { + let (x0, y0) = p0; + let (x1, y1) = p1; + let seg_ch = (x1 - x0).hypot(y1 - y0); + let seg_th = (y1 - y0).atan2(x1 - x0); + + let (sx, sy) = ks.integrate_spiro(); + let ch = sx.hypot(sy); + let th = sy.atan2(sx); + let arc_len = seg_ch / ch; + + let rot = seg_th - th; + let theta_left = rot + ks.theta(-0.5, -0.5); + let theta_right = rot + ks.theta(0.5, 0.5); + + Self { + p0, + p1, + ks, + rot, + derive_x0: arc_len * theta_left.cos(), + derive_y0: arc_len * theta_left.sin(), + derive_x1: arc_len * theta_right.cos(), + derive_y1: arc_len * theta_right.sin(), + len: seg_ch / ch, + bend: ks.bend(), + } + } + + #[must_use] + pub fn to_cubic(&self) -> bez::Strand { + bez::Strand::Cubic { + start: self.p0, + c1: ( + self.p0.0 + self.derive_x0 / 3., + self.p0.1 + self.derive_y0 / 3., + ), + c2: ( + self.p1.0 - self.derive_x1 / 3., + self.p1.1 - self.derive_y1 / 3., + ), + end: self.p1, + } + } + + #[must_use] + pub fn eval(&self, at: f64) -> Point { + let t = at - 0.5; + self.eval_k_sub(t, &self.ks.divide(-0.5, t)) + } + + fn eval_k_sub(&self, t: f64, k_sub: &SpiroK) -> Point { + let th_sub = self.rot + self.ks.theta(-0.5, t); + let cth = (t + 0.5) * self.len * th_sub.cos(); + let sth = (t + 0.5) * self.len * th_sub.sin(); + let (sx, sy) = k_sub.integrate_spiro(); + let x_mid = self.p0.0 + cth * sx - sth * sy; + let y_mid = self.p0.1 + cth * sy + sth * sx; + (x_mid, y_mid) + } + + #[must_use] + pub fn derivative(&self, at: f64) -> Point { + let t = at - 0.5; + let theta = self.rot + self.ks.theta(t, t); + (self.len * theta.cos(), self.len * theta.sin()) + } + + #[must_use] + pub fn subdivide(&self, at: f64) -> (Self, Self) { + let t = at - 0.5; + let k_sub = self.ks.divide(-0.5, t); + let k_sub_rear = self.ks.divide(t, 0.5); + + let mid = self.eval_k_sub(t, &k_sub); + ( + Self::new(k_sub, self.p0, mid), + Self::new(k_sub_rear, mid, self.p1), + ) + } + + fn uniform_subdivide(&self, stops: usize, sink: &mut Vec) { + let mut arc = *self; + for s in (2..=stops).rev() { + #[allow(clippy::cast_precision_loss)] + let (head, tail) = arc.subdivide((s as f64).recip()); + sink.push(head); + arc = tail; + } + sink.push(arc); + } + + #[must_use] + pub fn subdivisions(&self, delta: f64) -> Vec { + const MAX_STOPS: usize = 32; + for stops in 1..MAX_STOPS { + let mut sink = Vec::with_capacity(stops); + self.uniform_subdivide(stops, &mut sink); + if sink.iter().all(|part| part.bend <= delta) { + return sink; + } + } + + let mut sink = Vec::with_capacity(MAX_STOPS); + self.uniform_subdivide(MAX_STOPS, &mut sink); + sink + } + + pub fn to_bez_path(&self, ctx: &mut C, delta: f64) { + const ARC_STRAIGHT_EPSILON: f64 = 1e-8; + if self.bend <= ARC_STRAIGHT_EPSILON { + ctx.line_to(self.p1); + return; + } + let subdivision = self.subdivisions(delta); + for part in subdivision { + let strand = part.to_cubic(); + let crate::bez::Strand::Cubic { c1, c2, end, .. } = strand else { + unreachable!(); + }; + ctx.cubic_to(c1, c2, end); + } + } +} diff --git a/src/spiro/k.rs b/src/spiro/k.rs new file mode 100644 index 0000000..7ec3e5c --- /dev/null +++ b/src/spiro/k.rs @@ -0,0 +1,158 @@ +use crate::Point; + +#[derive(Default, Debug, Clone, Copy)] +pub struct K(pub [f64; 4]); + +impl K { + /// Integrate polynomial spiral curve from -0.5 to 0.5. + #[allow(clippy::many_single_char_names)] + #[must_use] + pub fn integrate_spiro(&self) -> Point { + let [k0, k1, k2, k3] = self.0; + let iterations = 4; + + let th1 = k0; + let th2 = 0.5 * k1; + let th3 = (1. / 6.) * k2; + let th4 = (1. / 24.) * k3; + let ds = 1. / f64::from(iterations); + let ds2 = ds * ds; + let ds3 = ds2 * ds; + let k0 = k0 * ds; + let k1 = k1 * ds; + let k2 = k2 * ds; + let k3 = k3 * ds; + + let mut s = ds.mul_add(0.5, -0.5); + + let mut x = 0.; + let mut y = 0.; + for _ in 0..iterations { + let km0 = (1. / 6. * k3) + .mul_add(s, 0.5 * k2) + .mul_add(s, k1) + .mul_add(s, k0); + let km1 = (0.5 * k3).mul_add(s, k2).mul_add(s, k1) * ds; + let km2 = k3.mul_add(s, k2) * ds2; + let km3 = k3 * ds3; + + // Order 12 implementation + let t1_1 = km0; + let t1_2 = 0.5 * km1; + let t1_3 = 1. / 6. * km2; + let t1_4 = 1. / 24. * km3; + let t2_2 = t1_1 * t1_1; + let t2_3 = 2. * (t1_1 * t1_2); + let t2_4 = 2_f64.mul_add(t1_1 * t1_3, t1_2 * t1_2); + let t2_5 = 2. * (t1_1 * t1_4 + t1_2 * t1_3); + let t2_6 = 2_f64.mul_add(t1_2 * t1_4, t1_3 * t1_3); + let t2_7 = 2. * (t1_3 * t1_4); + let t2_8 = t1_4 * t1_4; + let t3_4 = t2_2 * t1_2 + t2_3 * t1_1; + let t3_6 = t2_2 * t1_4 + t2_3 * t1_3 + t2_4 * t1_2 + t2_5 * t1_1; + let t3_8 = t2_4 * t1_4 + t2_5 * t1_3 + t2_6 * t1_2 + t2_7 * t1_1; + let t3_10 = t2_6 * t1_4 + t2_7 * t1_3 + t2_8 * t1_2; + let t4_4 = t2_2 * t2_2; + let t4_5 = 2. * (t2_2 * t2_3); + let t4_6 = 2_f64.mul_add(t2_2 * t2_4, t2_3 * t2_3); + let t4_7 = 2. * (t2_2 * t2_5 + t2_3 * t2_4); + let t4_8 = 2_f64.mul_add(t2_2 * t2_6 + t2_3 * t2_5, t2_4 * t2_4); + let t4_9 = 2. * (t2_2 * t2_7 + t2_3 * t2_6 + t2_4 * t2_5); + let t4_10 = 2_f64.mul_add(t2_2 * t2_8 + t2_3 * t2_7 + t2_4 * t2_6, t2_5 * t2_5); + let t5_6 = t4_4 * t1_2 + t4_5 * t1_1; + let t5_8 = t4_4 * t1_4 + t4_5 * t1_3 + t4_6 * t1_2 + t4_7 * t1_1; + let t5_10 = t4_6 * t1_4 + t4_7 * t1_3 + t4_8 * t1_2 + t4_9 * 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 t6_9 = t4_4 * t2_5 + t4_5 * t2_4 + t4_6 * t2_3 + t4_7 * t2_2; + let t6_10 = t4_4 * t2_6 + t4_5 * t2_5 + t4_6 * t2_4 + t4_7 * t2_3 + t4_8 * t2_2; + let t7_8 = t6_6 * t1_2 + t6_7 * t1_1; + let t7_10 = t6_6 * t1_4 + t6_7 * t1_3 + t6_8 * t1_2 + t6_9 * t1_1; + let t8_8 = t6_6 * t2_2; + let t8_9 = t6_6 * t2_3 + t6_7 * t2_2; + let t8_10 = t6_6 * t2_4 + t6_7 * t2_3 + t6_8 * t2_2; + let t9_10 = t8_8 * t1_2 + t8_9 * t1_1; + let t10_10 = t8_8 * t2_2; + + let mut u = 1.; + let mut v = 0.; + + v += (1_f64 / 12.).mul_add(t1_2, 1. / 80. * t1_4); + u -= (1_f64 / 24.).mul_add( + t2_2, + (1_f64 / 160.).mul_add(t2_4, (1_f64 / 896.).mul_add(t2_6, 1_f64 / 4608. * t2_8)), + ); + + v -= (1_f64 / 480.).mul_add( + t3_4, + (1_f64 / 2688.).mul_add(t3_6, (1_f64 / 13824.).mul_add(t3_8, 1. / 67584. * t3_10)), + ); + u += (1_f64 / 1920.).mul_add( + t4_4, + (1_f64 / 10752.) + .mul_add(t4_6, (1_f64 / 55296.).mul_add(t4_8, 1. / 270_336. * t4_10)), + ); + + v += (1_f64 / 53760.).mul_add( + t5_6, + (1_f64 / 276_480.).mul_add(t5_8, 1. / 1.35168e+06 * t5_10), + ); + u -= (1_f64 / 322_560.).mul_add( + t6_6, + (1_f64 / 1.65888e+06).mul_add(t6_8, 1. / 8.11008e+06 * t6_10), + ); + + v -= (1_f64 / 1.16122e+07).mul_add(t7_8, 1. / 5.67706e+07 * t7_10); + u += (1_f64 / 9.28973e+07).mul_add(t8_8, 1. / 4.54164e+08 * t8_10); + + v += 1. / 4.08748e+09 * t9_10; + u -= 1. / 4.08748e+10 * t10_10; + + let th = th4.mul_add(s, th3).mul_add(s, th2).mul_add(s, th1) * s; + let cth = th.cos(); + let sth = th.sin(); + + x += cth * u - sth * v; + y += cth * v + sth * u; + s += ds; + } + + (x * ds, y * ds) + } + + #[must_use] + pub fn bend(&self) -> f64 { + let [k0, k1, k2, k3] = self.0; + k0.abs() + (0.5 * k1).abs() + (0.125 * k2).abs() + (1. / 48. * k3).abs() + } + + #[must_use] + pub fn theta(&self, s0: f64, s1: f64) -> f64 { + let [k0, k1, k2, k3] = self.0; + let s = s0.midpoint(s1); + + (1. / 24. * k3) + .mul_add(s, 1. / 6. * k2) + .mul_add(s, 1. / 2. * k1) + .mul_add(s, k0) + * s + } + + #[must_use] + pub fn divide(&self, s0: f64, s1: f64) -> Self { + let [k0, k1, k2, k3] = self.0; + let s = s0.midpoint(s1); + let t = s1 - s0; + + Self([ + t * (1. / 6. * k3) + .mul_add(s, 1. / 2. * k2) + .mul_add(s, k1) + .mul_add(s, k0), + t * t * (1. / 2. * k3).mul_add(s, k2).mul_add(s, k1), + t * t * t * k3.mul_add(s, k2), + t * t * t * t * k3, + ]) + } +} diff --git a/tests/snapshots/to_bez__bezier.snap b/tests/snapshots/to_bez__bezier.snap new file mode 100644 index 0000000..3243b30 --- /dev/null +++ b/tests/snapshots/to_bez__bezier.snap @@ -0,0 +1,42 @@ +--- +source: tests/to_bez.rs +assertion_line: 69 +expression: "&ctx.buf" +--- +M 0.00000000 0.00000000 + +C 47.17044899 13.85038259, 86.14961741 52.82955101, 100.00000000 100.00000000 + +C 103.97701048 112.65648836, 106.33321379 125.77200651, 110.17201303 138.47109823 +C 114.01081226 151.17018994, 119.50210664 163.56071241, 128.01100900 173.73921380 +C 136.51991135 183.91771520, 147.98402725 191.56116004, 160.62139640 195.59851199 +C 173.25876556 199.63586394, 186.73337615 200.00126024, 200.00000000 200.00000000 + +L 300.00000000 200.00000000 + +C 312.91821760 198.16143569, 325.86767767 196.37633127, 338.56673679 193.37723771 +C 351.26579590 190.37814415, 363.80208311 186.09095608, 374.81974363 179.10011770 +C 385.83740416 172.10927933, 395.31703795 162.17910223, 400.00000000 150.00000000 + +C 401.39357678 141.55320919, 399.13380275 132.71120909, 394.55245491 125.47922573 +C 389.97110707 118.24724238, 383.18836541 112.55734121, 375.62361502 108.54923907 +C 368.05886462 104.54113693, 359.71533523 102.14585872, 351.26371291 100.78188971 +C 342.81209059 99.41792070, 334.22595321 99.05952603, 325.66508155 99.10204333 +C 317.10420989 99.14456064, 308.55076503 99.58197045, 300.00000000 100.00000000 + +L 200.00000000 100.00000000 + +C 193.43416843 100.01083362, 186.75014648 99.81170291, 180.54305335 97.67108233 +C 174.33596022 95.53046175, 168.81360777 91.50176023, 164.81756220 86.29197316 +C 160.82151662 81.08218609, 158.36146328 74.86578447, 156.41961987 68.59366316 +C 154.47777645 62.32154185, 152.94471698 55.86846687, 150.00000000 50.00000000 + +C 144.94289038 39.28079880, 135.47535392 31.40811664, 126.35377494 23.84026167 +C 117.23219596 16.27240669, 108.38080044 8.38080044, 100.00000000 0.00000000 + +L 0.00000000 -100.00000000 + +C -13.41661149 -113.41225126, -25.82801283 -128.20596847, -33.31705947 -145.63606107 +C -40.80610611 -163.06615367, -43.79177548 -182.07370936, -50.00000000 -200.00000000 + +C -56.38090977 -218.50892525, -66.67137644 -235.65970303, -80.00000000 -250.00000000 diff --git a/tests/to_bez.rs b/tests/to_bez.rs new file mode 100644 index 0000000..492e1ac --- /dev/null +++ b/tests/to_bez.rs @@ -0,0 +1,72 @@ +use std::fmt::Write as _; + +use sirop::{Point, bez}; + +macro_rules! spiro_cp { + ({ $x:literal, $y:literal, $ty:literal }) => { + #[allow(clippy::cast_lossless, clippy::char_lit_as_u8)] + ::sirop::Cp { + pt: ($x as f64, $y as f64), + ty: ($ty as u8).try_into().unwrap(), + } + }; +} + +#[derive(Debug, Default, Clone)] +struct TestBezCtx { + buf: String, +} + +impl TestBezCtx { + const PRECISION: usize = 8; +} + +impl bez::Ctx for TestBezCtx { + fn move_to(&mut self, (x, y): Point) { + let p = Self::PRECISION; + _ = writeln!(self.buf, "M {x:.p$} {y:.p$}"); + } + + fn line_to(&mut self, (x, y): Point) { + let p = Self::PRECISION; + _ = writeln!(self.buf, "L {x:.p$} {y:.p$}"); + } + + fn cubic_to(&mut self, (x1, y1): Point, (x2, y2): Point, (x3, y3): Point) { + let p = Self::PRECISION; + _ = writeln!( + self.buf, + "C {x1:.p$} {y1:.p$}, {x2:.p$} {y2:.p$}, {x3:.p$} {y3:.p$}" + ); + } + + fn mark_knot(&mut self, _idx: usize) { + _ = writeln!(self.buf); + } +} + +#[test] +fn bezier() -> sirop::Result<()> { + let path5 = [ + spiro_cp!({ 0, 0, '{'}), + spiro_cp!({100, 100, 'c'}), + spiro_cp!({200, 200, '['}), + spiro_cp!({300, 200, ']'}), + spiro_cp!({400, 150, 'c'}), + spiro_cp!({300, 100, '['}), + spiro_cp!({200, 100, ']'}), + spiro_cp!({150, 50, 'c'}), + spiro_cp!({100, 0, '['}), + spiro_cp!({ 0,-100, ']'}), + spiro_cp!({-50,-200, 'c'}), + spiro_cp!({-80,-250, '}'}), + ]; + + let ctx = &mut TestBezCtx::default(); + sirop::bezier(path5, ctx, false, None)?; + + // You may verify the output at . + insta::assert_snapshot!(&ctx.buf); + + Ok(()) +} From cd1d824327a6260d29d5f9d2117c46f33085dab3 Mon Sep 17 00:00:00 2001 From: rami3l Date: Sun, 21 Dec 2025 21:07:40 +0100 Subject: [PATCH 2/2] ci: add `test` workflow --- .github/workflows/test.yml | 39 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..8eec80e --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,39 @@ +name: Test + +on: + merge_group: + pull_request: + push: + branches: + - master + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + - os: macos-latest + - os: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + + - uses: dtolnay/rust-toolchain@stable + + - name: Test native build + run: cargo build --verbose --locked + + - name: Run simple tests + run: cargo test --verbose + + - name: Run heavy tests + run: cargo test --verbose -- --ignored