Something went wrong. Try again.
inkfinite (an infinite canvas/whiteboard) monorepo ink.stormlightlabs.org
svelte rust tauri svg canvas perfect-freehand
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644use perfect_freehand::{InputPoint, StrokeOptions, get_stroke};use serde_json::Value;
use super::{Bounds, Document, EngineError, ShapeId, ShapeParent, ShapeRecord};use crate::{PathGeometry, PathSegment, ShapeProperties, StrokeProperties, StrokeWidthPoint, Transform, Vec2};
/// A two-dimensional affine transform shared by document geometry consumers.#[derive(Clone, Copy, Debug, PartialEq)]pub struct Affine { /// Horizontal scale and rotation component. pub a: f64, /// Vertical shear and rotation component. pub b: f64, /// Horizontal shear and rotation component. pub c: f64, /// Vertical scale and rotation component. pub d: f64, /// Horizontal translation. pub e: f64, /// Vertical translation. pub f: f64,}
impl Affine { /// Identity transform. pub const IDENTITY: Self = Self { a: 1.0, b: 0.0, c: 0.0, d: 1.0, e: 0.0, f: 0.0 };
/// Converts an Inkfinite relative transform to an affine matrix. #[must_use] pub fn from_transform(transform: Transform) -> Self { let cos = transform.rotation.cos(); let sin = transform.rotation.sin(); Self { a: cos * transform.scale_x, b: sin * transform.scale_x, c: -sin * transform.scale_y, d: cos * transform.scale_y, e: transform.translation.x, f: transform.translation.y, } }
/// Composes `child` after this transform. #[must_use] pub fn then(self, child: Self) -> Self { Self { a: self.a * child.a + self.c * child.b, b: self.b * child.a + self.d * child.b, c: self.a * child.c + self.c * child.d, d: self.b * child.c + self.d * child.d, e: self.a * child.e + self.c * child.f + self.e, f: self.b * child.e + self.d * child.f + self.f, } }
/// Applies the transform to a point. #[must_use] pub fn point(self, point: Vec2) -> Vec2 { Vec2 { x: self.a * point.x + self.c * point.y + self.e, y: self.b * point.x + self.d * point.y + self.f } }
/// Returns the axis-aligned bounds of a transformed rectangle. #[must_use] pub fn transform_bounds(self, bounds: Bounds) -> Bounds { bounds_from_points(&[ self.point(Vec2 { x: bounds.x, y: bounds.y }), self.point(Vec2 { x: bounds.x + bounds.width, y: bounds.y }), self.point(Vec2 { x: bounds.x + bounds.width, y: bounds.y + bounds.height }), self.point(Vec2 { x: bounds.x, y: bounds.y + bounds.height }), ]) }
/// Returns the inverse matrix, or `None` for a singular transform. #[must_use] pub fn inverse(self) -> Option<Self> { let determinant = self.a * self.d - self.b * self.c; if determinant.abs() < f64::EPSILON { return None; } Some(Self { a: self.d / determinant, b: -self.b / determinant, c: -self.c / determinant, d: self.a / determinant, e: (self.c * self.f - self.d * self.e) / determinant, f: (self.b * self.e - self.a * self.f) / determinant, }) }}
/// Returns a shape's axis-aligned bounds in its parent's coordinate space.#[must_use]pub fn local_shape_bounds(shape: &ShapeRecord) -> Bounds { let local = if shape.kind.as_str() == crate::PATH_KIND { crate::path_geometry_from_properties(&shape.properties) .map_or(Bounds { x: 0.0, y: 0.0, width: 0.0, height: 0.0 }, |geometry| { path_bounds(&geometry) }) } else if shape.kind.as_str() == crate::STROKE_KIND { stroke_bounds(&shape.properties).unwrap_or(Bounds { x: 0.0, y: 0.0, width: 0.0, height: 0.0 }) } else { let width = numeric_property(shape, "width").unwrap_or(0.0).abs(); let height = numeric_property(shape, "height").unwrap_or(0.0).abs(); Bounds { x: 0.0, y: 0.0, width, height } }; Affine::from_transform(shape.transform).transform_bounds(local)}
/// Computes the committed freehand outline using the Rust canonical renderer.////// The editor may calculate the same outline for a responsive preview, but/// transaction validation and affected-region calculation use this function.////// # Errors////// Returns the decoded stroke-property error when the properties are malformed.pub fn stroke_outline(properties: &ShapeProperties) -> Result<Vec<Vec2>, String> { crate::validate_shape_properties(crate::STROKE_KIND, properties).map_err(|error| error.to_string())?; let stroke: StrokeProperties = serde_json::from_value(Value::Object(properties.clone().into_iter().collect())) .map_err(|error| format!("stroke properties could not be decoded: {error}"))?; if stroke.points.len() < 2 { return Ok(Vec::new()); } let variable_profile = stroke.width_profile.as_deref().filter(|profile| !profile.is_empty()); let points = if let Some(profile) = variable_profile { let offsets = stroke_point_offsets(&stroke.points); let max_width = profile.iter().map(|point| point.width).fold(0.01, f64::max); stroke .points .iter() .zip(offsets) .map(|(point, offset)| { InputPoint::Array( [point[0], point[1]], Some(interpolate_stroke_width(profile, offset) / max_width), ) }) .collect::<Vec<_>>() } else { stroke .points .iter() .map(|point| InputPoint::Array([point[0], point[1]], point.get(2).copied())) .collect::<Vec<_>>() }; let options = if let Some(profile) = variable_profile { StrokeOptions { size: Some(profile.iter().map(|point| point.width).fold(0.01, f64::max) / 2.0), thinning: Some(1.0), smoothing: Some(stroke.brush.smoothing), streamline: Some(stroke.brush.streamline), simulate_pressure: Some(false), ..StrokeOptions::default() } } else { StrokeOptions { size: Some(stroke.brush.size), thinning: Some(stroke.brush.thinning), smoothing: Some(stroke.brush.smoothing), streamline: Some(stroke.brush.streamline), simulate_pressure: Some(stroke.brush.simulate_pressure), ..StrokeOptions::default() } }; Ok(get_stroke(&points, &options) .into_iter() .map(|point| Vec2 { x: point[0], y: point[1] }) .collect())}
/// Returns the axis-aligned bounds of a committed freehand outline.////// # Errors////// Returns the decoded stroke-property error when the properties are malformed.pub fn stroke_bounds(properties: &ShapeProperties) -> Result<Bounds, String> { Ok(bounds_from_points(&stroke_outline(properties)?))}
fn stroke_point_offsets(points: &[Vec<f64>]) -> Vec<f64> { if points.len() < 2 { return points.iter().map(|_| 0.0).collect(); } let mut distances = Vec::with_capacity(points.len()); distances.push(0.0); for index in 1..points.len() { let previous = &points[index - 1]; let current = &points[index]; let distance = (current[0] - previous[0]).hypot(current[1] - previous[1]); distances.push(distances[index - 1] + distance); } let total = *distances.last().unwrap_or(&0.0); if total <= f64::EPSILON { distances .iter() .enumerate() .map(|(index, _)| index as f64 / (points.len() - 1) as f64) .collect() } else { distances.into_iter().map(|distance| distance / total).collect() }}
fn interpolate_stroke_width(profile: &[StrokeWidthPoint], offset: f64) -> f64 { let Some(first) = profile.first() else { return 0.01; }; if offset <= first.offset { return first.width.max(0.01); } for pair in profile.windows(2) { let [previous, next] = pair else { continue }; if offset <= next.offset { let span = next.offset - previous.offset; let amount = if span <= f64::EPSILON { 0.0 } else { (offset - previous.offset) / span }; return (previous.width + (next.width - previous.width) * amount).max(0.01); } } profile.last().map_or(0.01, |point| point.width.max(0.01))}
/// Returns a shape's axis-aligned bounds in document coordinates.#[must_use]pub fn world_shape_bounds(document: &Document, shape_id: &ShapeId) -> Bounds { let Some(shape) = document.shapes.get(shape_id) else { return Bounds { x: 0.0, y: 0.0, width: 0.0, height: 0.0 }; }; let local = if shape.kind.as_str() == crate::ARROW_KIND { crate::resolve_arrow_geometry_for_shape(document, shape) .map(|geometry| path_bounds(&geometry.path)) .unwrap_or_else(|_| local_shape_bounds(shape)) } else { local_shape_bounds(shape) }; parent_world_transform(document, &shape.parent) .unwrap_or(Affine::IDENTITY) .transform_bounds(local)}
/// Returns the world transform of a shape parent.////// `None` means that the parent record is missing. Callers that are validating/// a document should report that missing record instead of falling back to the/// identity transform.#[must_use]pub fn parent_world_transform(document: &Document, parent: &ShapeParent) -> Option<Affine> { match parent { ShapeParent::Layer(layer_id) => document.layers.contains_key(layer_id).then_some(Affine::IDENTITY), ShapeParent::Shape(shape_id) => document .shapes .get(shape_id) .map(|shape| world_transform(document, shape)), }}
/// Decomposes an affine transform into the native translation, rotation, and/// scale representation.////// Native transforms cannot represent shear. `None` is returned for singular,/// non-finite, or sheared matrices.#[must_use]pub fn decompose_transform(matrix: Affine) -> Option<Transform> { let scale_x = matrix.a.hypot(matrix.b); if ![matrix.a, matrix.b, matrix.c, matrix.d, matrix.e, matrix.f, scale_x] .into_iter() .all(f64::is_finite) || scale_x <= f64::EPSILON { return None; } let rotation = matrix.b.atan2(matrix.a); let scale_y = (matrix.a * matrix.d - matrix.b * matrix.c) / scale_x; if !scale_y.is_finite() || scale_y.abs() <= f64::EPSILON { return None; } let transform = Transform { translation: Vec2 { x: matrix.e, y: matrix.f }, rotation, scale_x, scale_y }; affine_approximately_equal(Affine::from_transform(transform), matrix).then_some(transform)}
/// Converts a world transform into a native transform relative to `parent`.#[must_use]pub fn local_transform_from_world(document: &Document, parent: &ShapeParent, world: Affine) -> Option<Transform> { parent_world_transform(document, parent) .and_then(Affine::inverse) .and_then(|inverse| decompose_transform(inverse.then(world)))}
fn affine_approximately_equal(left: Affine, right: Affine) -> bool { [ (left.a, right.a), (left.b, right.b), (left.c, right.c), (left.d, right.d), (left.e, right.e), (left.f, right.f), ] .into_iter() .all(|(left, right)| (left - right).abs() <= 1e-9 * (1.0 + left.abs().max(right.abs())))}
/// Returns the exact axis-aligned bounds of normalized path geometry.////// Quadratic and cubic Bézier derivative roots are included, so the bounds do/// not depend on a sampling step. A closed subpath contributes its implicit/// closing line; an open subpath contributes only its stored segments.#[must_use]pub fn path_bounds(geometry: &PathGeometry) -> Bounds { let mut points = Vec::new(); for subpath in &geometry.subpaths { let Some(PathSegment::Move { to: start }) = subpath.segments.first() else { continue; }; let mut current = *start; points.push(current); for segment in subpath.segments.iter().skip(1) { match segment { PathSegment::Move { to } => { current = *to; points.push(current); } PathSegment::Line { to } => { points.extend([current, *to]); current = *to; } PathSegment::Quadratic { control, to } => { points.extend([current, *to]); add_quadratic_extremum(&mut points, current, *control, *to, |t| { quadratic_point(current, *control, *to, t) }); current = *to; } PathSegment::Cubic { control_1, control_2, to } => { points.extend([current, *to]); add_cubic_extrema(&mut points, current, *control_1, *control_2, *to, |t| { cubic_point(current, *control_1, *control_2, *to, t) }); current = *to; } } } if subpath.closed { points.extend([current, *start]); } } bounds_from_points(&points)}
/// Returns the complete local-to-world transform for a shape hierarchy.#[must_use]pub fn world_transform(document: &Document, shape: &ShapeRecord) -> Affine { let mut chain = vec![shape]; let mut parent = &shape.parent; while let ShapeParent::Shape(parent_id) = parent { let Some(parent_shape) = document.shapes.get(parent_id) else { break }; chain.push(parent_shape); parent = &parent_shape.parent; } chain.into_iter().rev().fold(Affine::IDENTITY, |matrix, item| { matrix.then(Affine::from_transform(item.transform)) })}
/// Returns the smallest axis-aligned bounds containing every supplied point.#[must_use]pub fn bounds_from_points(points: &[Vec2]) -> Bounds { if points.is_empty() { return Bounds { x: 0.0, y: 0.0, width: 0.0, height: 0.0 }; } let (mut min_x, mut min_y, mut max_x, mut max_y) = (points[0].x, points[0].y, points[0].x, points[0].y); for point in &points[1..] { min_x = min_x.min(point.x); min_y = min_y.min(point.y); max_x = max_x.max(point.x); max_y = max_y.max(point.y); } Bounds { x: min_x, y: min_y, width: max_x - min_x, height: max_y - min_y }}
/// Reads a finite numeric property from a shape record.#[must_use]pub fn numeric_property(shape: &ShapeRecord, name: &str) -> Option<f64> { let alias = match name { "width" => Some("w"), "height" => Some("h"), _ => None, }; shape .properties .get(name) .or_else(|| alias.and_then(|name| shape.properties.get(name))) .and_then(serde_json::Value::as_f64) .filter(|value| value.is_finite())}
/// Converts a collection length to the engine's geometry scalar.////// # Errors////// Returns [`EngineError::Invariant`] when the length exceeds `u32`.pub fn count_as_f64(count: usize) -> Result<f64, EngineError> { let count = u32::try_from(count).map_err(|_| EngineError::Invariant("layout selection is too large".into()))?; Ok(f64::from(count))}
/// Returns the horizontal center of `bounds`.#[must_use]pub fn center_x(bounds: &Bounds) -> f64 { bounds.x + bounds.width / 2.0}
/// Returns the vertical center of `bounds`.#[must_use]pub fn center_y(bounds: &Bounds) -> f64 { bounds.y + bounds.height / 2.0}
/// Returns the right edge of `bounds`.#[must_use]pub fn right(bounds: &Bounds) -> f64 { bounds.x + bounds.width}
/// Returns the bottom edge of `bounds`.#[must_use]pub fn bottom(bounds: &Bounds) -> f64 { bounds.y + bounds.height}
/// Returns whether two axis-aligned bounds overlap or touch.#[must_use]pub fn intersects(left: &Bounds, right_bounds: &Bounds) -> bool { left.x <= right(right_bounds) && right(left) >= right_bounds.x && left.y <= bottom(right_bounds) && bottom(left) >= right_bounds.y}
/// Returns the smallest bounds containing both inputs.#[must_use]pub fn union(left: Bounds, right_bounds: Bounds) -> Bounds { let x = left.x.min(right_bounds.x); let y = left.y.min(right_bounds.y); Bounds { x, y, width: right(&left).max(right(&right_bounds)) - x, height: bottom(&left).max(bottom(&right_bounds)) - y, }}
fn add_quadratic_extremum<F>(points: &mut Vec<Vec2>, start: Vec2, control: Vec2, end: Vec2, evaluate: F)where F: Fn(f64) -> Vec2,{ for t in [ quadratic_extremum(start.x, control.x, end.x), quadratic_extremum(start.y, control.y, end.y), ] .into_iter() .flatten() .filter(|t| *t > 0.0 && *t < 1.0) { points.push(evaluate(t)); }}
fn quadratic_extremum(start: f64, control: f64, end: f64) -> Option<f64> { let denominator = start - 2.0 * control + end; if denominator.abs() <= f64::EPSILON { None } else { Some((start - control) / denominator) }}
fn add_cubic_extrema<F>(points: &mut Vec<Vec2>, start: Vec2, control_1: Vec2, control_2: Vec2, end: Vec2, evaluate: F)where F: Fn(f64) -> Vec2,{ for (a, b, c) in [ cubic_derivative_coefficients(start.x, control_1.x, control_2.x, end.x), cubic_derivative_coefficients(start.y, control_1.y, control_2.y, end.y), ] { for t in quadratic_roots(a, b, c) .into_iter() .flatten() .filter(|t| *t > 0.0 && *t < 1.0) { points.push(evaluate(t)); } }}
fn cubic_derivative_coefficients(start: f64, control_1: f64, control_2: f64, end: f64) -> (f64, f64, f64) { ( -start + 3.0 * control_1 - 3.0 * control_2 + end, 2.0 * (start - 2.0 * control_1 + control_2), control_1 - start, )}
fn quadratic_roots(a: f64, b: f64, c: f64) -> [Option<f64>; 2] { if a.abs() <= f64::EPSILON { return [if b.abs() > f64::EPSILON { Some(-c / b) } else { None }, None]; } let discriminant = b * b - 4.0 * a * c; if discriminant < 0.0 { return [None, None]; } let root = discriminant.sqrt(); [Some((-b - root) / (2.0 * a)), Some((-b + root) / (2.0 * a))]}
fn quadratic_point(start: Vec2, control: Vec2, end: Vec2, t: f64) -> Vec2 { let inverse = 1.0 - t; Vec2 { x: inverse * inverse * start.x + 2.0 * inverse * t * control.x + t * t * end.x, y: inverse * inverse * start.y + 2.0 * inverse * t * control.y + t * t * end.y, }}
fn cubic_point(start: Vec2, control_1: Vec2, control_2: Vec2, end: Vec2, t: f64) -> Vec2 { let inverse = 1.0 - t; Vec2 { x: inverse.powi(3) * start.x + 3.0 * inverse.powi(2) * t * control_1.x + 3.0 * inverse * t.powi(2) * control_2.x + t.powi(3) * end.x, y: inverse.powi(3) * start.y + 3.0 * inverse.powi(2) * t * control_1.y + 3.0 * inverse * t.powi(2) * control_2.y + t.powi(3) * end.y, }}
#[cfg(test)]mod tests { use super::*;
#[test] fn union_contains_both_input_regions() { let left = Bounds { x: 0.0, y: 2.0, width: 4.0, height: 3.0 }; let right = Bounds { x: 3.0, y: 0.0, width: 5.0, height: 4.0 }; assert_eq!(union(left, right), Bounds { x: 0.0, y: 0.0, width: 8.0, height: 5.0 }); }
#[test] fn path_bounds_include_quadratic_and_cubic_extrema() { let geometry = PathGeometry { subpaths: vec![crate::PathSubpath { segments: vec![ PathSegment::Move { to: Vec2 { x: 0.0, y: 0.0 } }, PathSegment::Quadratic { control: Vec2 { x: 10.0, y: 20.0 }, to: Vec2 { x: 20.0, y: 0.0 } }, PathSegment::Cubic { control_1: Vec2 { x: 30.0, y: -20.0 }, control_2: Vec2 { x: 40.0, y: 20.0 }, to: Vec2 { x: 50.0, y: 0.0 }, }, ], closed: false, handle_modes: None, }], fill_rule: crate::PathFillRule::NonZero, }; let bounds = path_bounds(&geometry);
assert!((bounds.x - 0.0).abs() < 1e-12); assert!((bounds.y + 5.773502691896258).abs() < 1e-12); assert!((bounds.width - 50.0).abs() < 1e-12); assert!((bounds.height - 15.773502691896258).abs() < 1e-12); }
#[test] fn closed_path_bounds_include_the_implicit_closing_line() { let geometry = PathGeometry { subpaths: vec![crate::PathSubpath { segments: vec![ PathSegment::Move { to: Vec2 { x: 10.0, y: 20.0 } }, PathSegment::Line { to: Vec2 { x: 30.0, y: 20.0 } }, ], closed: true, handle_modes: None, }], fill_rule: crate::PathFillRule::EvenOdd, };
assert_eq!( path_bounds(&geometry), Bounds { x: 10.0, y: 20.0, width: 20.0, height: 0.0 } ); }
#[test] fn variable_width_profile_changes_committed_outline_width() { let properties = |profile: Option<serde_json::Value>| { let mut properties = ShapeProperties::from([ ("points".into(), serde_json::json!([[0.0, 0.0], [100.0, 0.0]])), ("style".into(), serde_json::json!({ "color": "#000", "opacity": 1.0 })), ( "brush".into(), serde_json::json!({ "size": 10.0, "thinning": 0.0, "smoothing": 0.5, "streamline": 0.5, "simulatePressure": true }), ), ]); if let Some(profile) = profile { properties.insert("widthProfile".into(), profile); } properties }; let flat = stroke_bounds(&properties(None)).expect("flat stroke should render"); let variable = stroke_bounds(&properties(Some(serde_json::json!([ { "offset": 0.0, "width": 4.0 }, { "offset": 1.0, "width": 24.0 } ])))) .expect("variable stroke should render");
assert!(variable.height > flat.height); }
#[test] fn affine_composition_and_inverse_round_trip_a_child_point() { let parent = Affine::from_transform(crate::Transform { translation: crate::Vec2 { x: 10.0, y: 20.0 }, rotation: std::f64::consts::FRAC_PI_2, scale_x: 2.0, scale_y: 1.0, }); let child = Affine::from_transform(crate::Transform { translation: crate::Vec2 { x: 5.0, y: 0.0 }, rotation: 0.0, scale_x: 1.0, scale_y: 1.0, }); let matrix = parent.then(child); let local = crate::Vec2 { x: 3.0, y: 4.0 }; let world = matrix.point(local); let restored = matrix.inverse().expect("non-singular transform").point(world);
assert!((restored.x - local.x).abs() < f64::EPSILON); assert!((restored.y - local.y).abs() < f64::EPSILON); }}