diff --git a/Cargo.toml b/Cargo.toml index d2e23ff..a782470 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "freedraw" -version = "0.0.0-development" -edition = "2021" +version = "2.0.0" +edition = "2024" authors = ["Jorge Soares"] description = "A Rust port of the perfect-freehand library for creating smooth, beautiful freehand lines" license = "MIT" diff --git a/README.md b/README.md index 1061fad..08f41d2 100644 --- a/README.md +++ b/README.md @@ -121,11 +121,11 @@ let options = StrokeOptions { streamline: Some(0.5), // How much to streamline the stroke simulate_pressure: Some(true), // Whether to simulate pressure based on velocity start: Some(TaperOptions { // Tapering options for the start of the line - taper: Some(TaperType::Bool(true)), + taper: Some(4.0), ..Default::default() }), end: Some(TaperOptions { // Tapering options for the end of the line - taper: Some(TaperType::Bool(true)), + taper: Some(4.0), ..Default::default() }), ..Default::default() @@ -148,7 +148,7 @@ The `start` and `end` options accept a `TaperOptions` struct: | Property | Type | Default | Description | | -------- | ----------------- | ------- | ---------------------------------------------------------------------------------------- | | `cap` | boolean | true | Whether to draw a cap. | -| `taper` | TaperType | None | The distance to taper. Can be a numerical value or boolean. | +| `taper` | number | None | The distance to taper. | | `easing` | EasingType | linear | An easing function for the tapering effect. | ## Input Points @@ -185,4 +185,4 @@ Check out the examples directory for more usage examples: [MIT License](LICENSE) -Original JavaScript library by [Steve Ruiz](https://twitter.com/steveruizok) \ No newline at end of file +Original JavaScript library by [Steve Ruiz](https://twitter.com/steveruizok) diff --git a/demo/Cargo.toml b/demo/Cargo.toml index 5f27a59..a9b5ea9 100644 --- a/demo/Cargo.toml +++ b/demo/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "freedraw-demo" version = "0.1.0" -edition = "2021" +edition = "2024" # Main binary (Yew app) [[bin]] diff --git a/demo/src/app.rs b/demo/src/app.rs index 4a8a292..5394100 100644 --- a/demo/src/app.rs +++ b/demo/src/app.rs @@ -1,9 +1,8 @@ use yew::prelude::*; -use web_sys::{wasm_bindgen::JsCast, Element, HtmlElement, DomRect}; +use web_sys::wasm_bindgen::JsCast; use wasm_bindgen::closure::Closure; use wasm_bindgen::JsValue; -use js_sys::Promise; -use freedraw::{get_stroke, get_svg_path_from_stroke, StrokeOptions, InputPoint, TaperOptions, TaperType}; +use freedraw::{StrokeOptions, TaperOptions}; #[derive(Properties, PartialEq)] pub struct SvgExampleProps { @@ -48,7 +47,6 @@ impl StrokeStyle { smoothing: Some(0.5), streamline: Some(0.5), simulate_pressure: Some(false), - closed: Some(false), ..Default::default() }, false, @@ -61,7 +59,6 @@ impl StrokeStyle { smoothing: Some(0.5), streamline: Some(0.5), simulate_pressure: Some(false), - closed: Some(false), ..Default::default() }, true, @@ -75,14 +72,13 @@ impl StrokeStyle { streamline: Some(0.5), simulate_pressure: Some(false), start: Some(TaperOptions { - taper: Some(TaperType::Bool(true)), + taper: Some(4.0), ..Default::default() }), end: Some(TaperOptions { - taper: Some(TaperType::Bool(true)), + taper: Some(4.0), ..Default::default() }), - closed: Some(false), ..Default::default() }, false, @@ -96,14 +92,13 @@ impl StrokeStyle { streamline: Some(0.5), simulate_pressure: Some(false), start: Some(TaperOptions { - taper: Some(TaperType::Bool(true)), + taper: Some(4.0), ..Default::default() }), end: Some(TaperOptions { - taper: Some(TaperType::Bool(true)), + taper: Some(4.0), ..Default::default() }), - closed: Some(false), ..Default::default() }, true, @@ -116,7 +111,6 @@ impl StrokeStyle { smoothing: Some(0.5), streamline: Some(0.5), simulate_pressure: Some(false), - closed: Some(false), ..Default::default() }, false, @@ -129,7 +123,6 @@ impl StrokeStyle { smoothing: Some(0.5), streamline: Some(0.5), simulate_pressure: Some(false), - closed: Some(false), ..Default::default() }, true, @@ -142,7 +135,6 @@ impl StrokeStyle { smoothing: Some(0.5), streamline: Some(0.5), simulate_pressure: Some(false), - closed: Some(false), ..Default::default() }, false, @@ -155,7 +147,6 @@ impl StrokeStyle { smoothing: Some(0.5), streamline: Some(0.5), simulate_pressure: Some(false), - closed: Some(false), ..Default::default() }, true, @@ -163,34 +154,22 @@ impl StrokeStyle { ) } } - - fn to_str(&self) -> &'static str { - match self { - StrokeStyle::Default => "Default", - StrokeStyle::DefaultWithStroke => "Default with Stroke", - StrokeStyle::Tapered => "Tapered", - StrokeStyle::TaperedWithStroke => "Tapered with Stroke", - StrokeStyle::Thin => "Thin", - StrokeStyle::ThinWithStroke => "Thin with Stroke", - StrokeStyle::Thick => "Thick", - StrokeStyle::ThickWithStroke => "Thick with Stroke" - } - } } // Added new DrawingBoard component for interactive drawing #[function_component(DrawingBoard)] +#[expect(deprecated)] fn drawing_board() -> Html { use web_sys::{HtmlCanvasElement, CanvasRenderingContext2d, window}; - use freedraw::{get_stroke, get_svg_path_from_stroke, StrokeOptions, InputPoint}; + use freedraw::{get_stroke, get_svg_path_from_stroke, InputPoint}; let canvas_ref = use_node_ref(); // Instead of a simple array of points, we'll track multiple strokes - let strokes = use_state(|| Vec::>::new()); - let current_stroke = use_state(|| Vec::<(f64, f64)>::new()); + let strokes = use_state(Vec::>::new); + let current_stroke = use_state(Vec::<(f64, f64)>::new); let drawing = use_state(|| false); - let svg_paths = use_state(|| Vec::<(String, bool)>::new()); - let copy_message = use_state(|| String::new()); + let svg_paths = use_state(Vec::<(String, bool)>::new); + let copy_message = use_state(String::new); let show_trace = use_state(|| false); let stroke_style = use_state(|| StrokeStyle::Default); @@ -217,7 +196,7 @@ fn drawing_board() -> Html { // Process all completed strokes for stroke_points in strokes.iter() { - if stroke_points.len() > 0 { + if !stroke_points.is_empty() { let input_points: Vec = stroke_points .iter() .map(|(x, y)| InputPoint::Array([*x, *y], None)) @@ -236,7 +215,7 @@ fn drawing_board() -> Html { } // Process the current stroke if it exists - if current_stroke.len() > 0 { + if !current_stroke.is_empty() { let input_points: Vec = current_stroke .iter() .map(|(x, y)| InputPoint::Array([*x, *y], None)) @@ -269,23 +248,18 @@ fn drawing_board() -> Html { let (x, y) = get_canvas_coordinates(&canvas, e.client_x(), e.client_y()); // Start a new current stroke - let mut new_stroke = Vec::new(); - new_stroke.push((x, y)); - current_stroke.set(new_stroke); + current_stroke.set(vec![(x, y)]); - if *show_trace { - if let Ok(ctx_option) = canvas.get_context("2d") { - if let Some(ctx_js) = ctx_option { - if let Ok(ctx) = ctx_js.dyn_into::() { + if *show_trace + && let Ok(ctx_option) = canvas.get_context("2d") + && let Some(ctx_js) = ctx_option + && let Ok(ctx) = ctx_js.dyn_into::() { ctx.begin_path(); ctx.set_stroke_style(&JsValue::from_str("red")); ctx.set_line_width(2.0); ctx.move_to(x, y); ctx.stroke(); } - } - } - } } }) }; @@ -297,8 +271,8 @@ fn drawing_board() -> Html { let update_svg_paths = update_svg_paths.clone(); let show_trace = show_trace.clone(); Callback::from(move |e: web_sys::MouseEvent| { - if *drawing { - if let Some(canvas) = canvas_ref.cast::() { + if *drawing + && let Some(canvas) = canvas_ref.cast::() { let (x, y) = get_canvas_coordinates(&canvas, e.client_x(), e.client_y()); // Add to the current stroke @@ -309,20 +283,16 @@ fn drawing_board() -> Html { // Update SVG in real-time update_svg_paths(); - if *show_trace { - if let Ok(ctx_option) = canvas.get_context("2d") { - if let Some(ctx_js) = ctx_option { - if let Ok(ctx) = ctx_js.dyn_into::() { + if *show_trace + && let Ok(ctx_option) = canvas.get_context("2d") + && let Some(ctx_js) = ctx_option + && let Ok(ctx) = ctx_js.dyn_into::() { ctx.set_stroke_style(&JsValue::from_str("red")); ctx.set_line_width(2.0); ctx.line_to(x, y); ctx.stroke(); } - } - } - } } - } }) }; @@ -358,15 +328,12 @@ fn drawing_board() -> Html { svg_paths.set(Vec::new()); // Clear the canvas - if let Some(canvas) = canvas_ref.cast::() { - if let Ok(ctx_option) = canvas.get_context("2d") { - if let Some(ctx_js) = ctx_option { - if let Ok(ctx) = ctx_js.dyn_into::() { + if let Some(canvas) = canvas_ref.cast::() + && let Ok(ctx_option) = canvas.get_context("2d") + && let Some(ctx_js) = ctx_option + && let Ok(ctx) = ctx_js.dyn_into::() { ctx.clear_rect(0.0, 0.0, canvas.width() as f64, canvas.height() as f64); } - } - } - } }) }; @@ -379,17 +346,13 @@ fn drawing_board() -> Html { show_trace.set(new_value); // Clear the canvas when toggling off to hide existing traces - if !new_value { - if let Some(canvas) = canvas_ref.cast::() { - if let Ok(ctx_option) = canvas.get_context("2d") { - if let Some(ctx_js) = ctx_option { - if let Ok(ctx) = ctx_js.dyn_into::() { + if !new_value + && let Some(canvas) = canvas_ref.cast::() + && let Ok(ctx_option) = canvas.get_context("2d") + && let Some(ctx_js) = ctx_option + && let Ok(ctx) = ctx_js.dyn_into::() { ctx.clear_rect(0.0, 0.0, canvas.width() as f64, canvas.height() as f64); } - } - } - } - } }) }; @@ -847,4 +810,4 @@ pub fn app() -> Html { } -} \ No newline at end of file +} diff --git a/demo/src/svg_conversion.rs b/demo/src/svg_conversion.rs index 5adb77f..dc791c1 100644 --- a/demo/src/svg_conversion.rs +++ b/demo/src/svg_conversion.rs @@ -1,11 +1,11 @@ -use freedraw::{get_stroke, get_svg_path_from_stroke, InputPoint, StrokeOptions, TaperOptions, TaperType}; +use freedraw::{get_stroke, get_svg_path_from_stroke, InputPoint, StrokeOptions, TaperOptions}; use std::fs::{self, File}; use std::io::{Read, Write}; use std::path::Path; use serde_json::Value; fn load_test_data(filename: &str) -> serde_json::Value { - let mut file = File::open(format!("../tests/{}", filename)).expect(&format!("Could not open {}", filename)); + let mut file = File::open(format!("../tests/{}", filename)).unwrap_or_else(|_| panic!("Could not open {}", filename)); let mut contents = String::new(); file.read_to_string(&mut contents).expect("Could not read file"); serde_json::from_str(&contents).expect("Could not parse JSON") @@ -223,11 +223,11 @@ fn process_raw_points(name: &str, points: &[Value], color: &str) { streamline: Some(streamline), simulate_pressure: Some(simulate_pressure), start: Some(TaperOptions { - taper: Some(TaperType::Bool(true)), + taper: Some(4.0), ..Default::default() }), end: Some(TaperOptions { - taper: Some(TaperType::Bool(true)), + taper: Some(4.0), ..Default::default() }), ..Default::default() @@ -311,11 +311,11 @@ pub fn main() { streamline: Some(streamline), simulate_pressure: Some(simulate_pressure), start: Some(TaperOptions { - taper: Some(TaperType::Bool(true)), + taper: Some(4.0), ..Default::default() }), end: Some(TaperOptions { - taper: Some(TaperType::Bool(true)), + taper: Some(4.0), ..Default::default() }), ..Default::default() @@ -375,4 +375,4 @@ pub fn main() { } println!("\nAll SVG files generated in the examples/svg directory."); -} \ No newline at end of file +} diff --git a/src/get_stroke.rs b/src/get_stroke.rs index d7dca5f..b3a04a9 100644 --- a/src/get_stroke.rs +++ b/src/get_stroke.rs @@ -2,18 +2,21 @@ use crate::get_stroke_outline_points::get_stroke_outline_points; use crate::get_stroke_points::get_stroke_points; use crate::types::{InputPoint, StrokeOptions}; -/// Get an array of points describing a polygon that surrounds the input points. +/// Get an outline polygon that surrounds the input points. +/// +/// This is a convenience wrapper around `get_stroke_points` and +/// `get_stroke_outline_points`. /// /// # Arguments -/// * `points` - An array of points (with optional pressure data) -/// * `options` - Options for the stroke generation +/// * `points` - Input points with optional pressure data (0.0..=1.0). +/// * `options` - Stroke options controlling smoothing, pressure, and caps. /// /// # Returns -/// An array of points (as `[x, y]`) that define the outline of the stroke +/// A list of `[x, y]` points in winding order forming the stroke outline. pub fn get_stroke( points: &[InputPoint], options: &StrokeOptions, ) -> Vec<[f64; 2]> { let stroke_points = get_stroke_points(points, options); get_stroke_outline_points(&stroke_points, options) -} \ No newline at end of file +} diff --git a/src/get_stroke_outline_points.rs b/src/get_stroke_outline_points.rs index 67b01e4..92b8c89 100644 --- a/src/get_stroke_outline_points.rs +++ b/src/get_stroke_outline_points.rs @@ -1,6 +1,6 @@ use crate::get_stroke_radius::get_stroke_radius; use crate::types::{StrokeOptions, StrokePoint}; -use crate::vec::{add, dist2, dpr, mul, neg, per, rot_around}; +use crate::vec::{add, dist2, dpr, lrp, mul, neg, per, prj, rot_around, sub, uni}; use std::f64::consts::PI; // This is the rate of change for simulated pressure. It could be an option. @@ -9,42 +9,53 @@ const RATE_OF_PRESSURE_CHANGE: f64 = 0.275; // Browser strokes seem to be off if PI is regular, a tiny offset seems to fix it const FIXED_PI: f64 = PI + 0.0001; -/// Get an array of points (as `[x, y]`) representing the outline of a stroke. +/// Build the outline polygon for a stroke from processed points. /// /// # Arguments -/// * `points` - An array of StrokePoints as returned from `get_stroke_points` -/// * `options` - Options for the stroke generation +/// * `points` - Stroke points as returned from `get_stroke_points`. +/// * `options` - Stroke options controlling size, thinning, taper, and caps. /// /// # Returns -/// An array of points (as `[x, y]`) that define the outline of the stroke +/// A list of `[x, y]` points forming the outline polygon in winding order. pub fn get_stroke_outline_points( points: &[StrokePoint], options: &StrokeOptions, ) -> Vec<[f64; 2]> { + fn default_easing(t: f64) -> f64 { + t + } + + fn default_taper_start_easing(t: f64) -> f64 { + t * (2.0 - t) + } + + fn default_taper_end_easing(t: f64) -> f64 { + let t = t - 1.0; + t * t * t + 1.0 + } + let size = options.size.unwrap_or(16.0); let smoothing = options.smoothing.unwrap_or(0.5); let thinning = options.thinning.unwrap_or(0.5); let simulate_pressure = options.simulate_pressure.unwrap_or(true); - let _is_complete = options.last.unwrap_or(false); - - // Define the easing function or use the default (identity function) - let easing_fn = options.easing.unwrap_or(|t| t); - - // Get start and end options with defaults + let easing_fn = options.easing.as_deref().unwrap_or(&default_easing); let start_options = options.start.clone().unwrap_or_default(); let end_options = options.end.clone().unwrap_or_default(); + let is_complete = options.last.unwrap_or(false); - // Cap and taper settings let cap_start = start_options.cap.unwrap_or(true); let cap_end = end_options.cap.unwrap_or(true); - // Taper start easing - let taper_start_ease = start_options.easing.unwrap_or(|t| t * (2.0 - t)); - - // Taper end easing - let taper_end_ease = end_options.easing.unwrap_or(|t| 1.0 - (1.0 - t).powi(3)); + let taper_start_ease = start_options + .easing + .as_deref() + .unwrap_or(&default_taper_start_easing); + let taper_end_ease = end_options + .easing + .as_deref() + .unwrap_or(&default_taper_end_easing); - // We can't do anything with an empty array or a stroke with negative size + // We can't do anything with an empty array or a stroke with negative size. if points.is_empty() || size <= 0.0 { return vec![]; } @@ -52,6 +63,10 @@ pub fn get_stroke_outline_points( // The total length of the line let total_length = points.last().map(|p| p.running_length).unwrap_or(0.0); + // Taper start and end distances + let taper_start = start_options.taper.unwrap_or(0.0); + let taper_end = end_options.taper.unwrap_or(0.0); + // The minimum allowed distance between points (squared) let min_distance = (size * smoothing).powi(2); @@ -59,30 +74,36 @@ pub fn get_stroke_outline_points( let mut left_pts: Vec<[f64; 2]> = Vec::new(); let mut right_pts: Vec<[f64; 2]> = Vec::new(); - // Previous pressure (start with average of first five pressures, - // in order to prevent fat starts for every line. Drawn lines - // almost always start slow! - let mut prev_pressure = points.iter().take(10).fold(points[0].pressure, |acc, curr| { - let mut pressure = curr.pressure; - - if simulate_pressure { - // Speed of change - how fast should the the pressure changing? - let sp = f64::min(1.0, curr.distance / size); - // Rate of change - how much of a change is there? - let rp = f64::min(1.0, 1.0 - sp); - // Accelerate the pressure - pressure = f64::min(1.0, acc + (rp - acc) * (sp * RATE_OF_PRESSURE_CHANGE)); - } + // Previous pressure (start with average of first five pressures) + let mut prev_pressure = + points + .iter() + .take(10) + .fold(points[0].pressure, |acc, curr| { + let mut pressure = curr.pressure; + + if simulate_pressure { + let sp = f64::min(1.0, curr.distance / size); + let rp = f64::min(1.0, 1.0 - sp); + pressure = f64::min( + 1.0, + acc + (rp - acc) * (sp * RATE_OF_PRESSURE_CHANGE), + ); + } - (acc + pressure) / 2.0 - }); + (acc + pressure) / 2.0 + }); - // Calculate the first point's radius for the start cap - let first_point_radius = if thinning > 0.0 { - get_stroke_radius(size, thinning, points[0].pressure, Some(easing_fn)) - } else { - size / 2.0 - }; + // The current radius + let mut radius = get_stroke_radius( + size, + thinning, + points[points.len() - 1].pressure, + Some(easing_fn), + ); + + // The radius of the first saved point + let mut first_radius: Option = None; // Previous vector let mut prev_vector = points[0].vector; @@ -91,65 +112,49 @@ pub fn get_stroke_outline_points( let mut pl = points[0].point; let mut pr = pl; - // Keep track of whether the previous point is a sharp corner - // ... so that we don't detect the same corner twice - let mut is_prev_sharp_corner = false; - - // Determine taper settings - let taper_start = match &start_options.taper { - Some(taper) => match taper { - crate::types::TaperType::Bool(false) => 0.0, - crate::types::TaperType::Bool(true) => f64::max(size, total_length), - crate::types::TaperType::Number(value) => *value, - }, - None => 0.0, - }; - - let taper_end = match &end_options.taper { - Some(taper) => match taper { - crate::types::TaperType::Bool(false) => 0.0, - crate::types::TaperType::Bool(true) => f64::max(size, total_length), - crate::types::TaperType::Number(value) => *value, - }, - None => 0.0, - }; + // Temporary left and right points + let mut tl = pl; + let mut tr = pr; - // Iterate through the points and generate the outline - for (i, curr) in points.iter().enumerate() { - // Skip the first point - if i == 0 { + // Keep track of whether the previous point is a sharp corner + let mut is_prev_point_sharp_corner = false; + + /* + Find the outline's left and right points + */ + for i in 0..points.len() { + let mut pressure = points[i].pressure; + let point = points[i].point; + let vector = points[i].vector; + let distance = points[i].distance; + let running_length = points[i].running_length; + + // Removes noise from the end of the line + if i < points.len() - 1 && total_length - running_length < 3.0 { continue; } - // Get the current point and vector - let point = curr.point; - let vector = curr.vector; - let distance = curr.distance; - let running_length = curr.running_length; - - // Calculate the current pressure - let mut pressure = curr.pressure; - - // Simulate pressure if needed - if thinning > 0.0 && simulate_pressure { - let sp = f64::min(1.0, distance / size); - let rp = f64::min(1.0, 1.0 - sp); - pressure = f64::min( - 1.0, - prev_pressure + (rp - prev_pressure) * (sp * RATE_OF_PRESSURE_CHANGE), - ); - } - - prev_pressure = pressure; + // Calculate the radius + if thinning != 0.0 { + if simulate_pressure { + let sp = f64::min(1.0, distance / size); + let rp = f64::min(1.0, 1.0 - sp); + pressure = f64::min( + 1.0, + prev_pressure + (rp - prev_pressure) * (sp * RATE_OF_PRESSURE_CHANGE), + ); + } - // Calculate the current radius - let radius = if thinning > 0.0 { - get_stroke_radius(size, thinning, pressure, Some(easing_fn)) + radius = get_stroke_radius(size, thinning, pressure, Some(easing_fn)); } else { - size / 2.0 - }; + radius = size / 2.0; + } + + if first_radius.is_none() { + first_radius = Some(radius); + } - // Apply tapering if needed + // Apply tapering let ts = if running_length < taper_start { taper_start_ease(running_length / taper_start) } else { @@ -162,159 +167,167 @@ pub fn get_stroke_outline_points( 1.0 }; - let radius = f64::max(0.01, radius * f64::min(ts, te)); + radius = f64::max(0.01, radius * f64::min(ts, te)); - // Calculate the normal vector for this point - let normal_vector = per(vector); + // Handle sharp corners + let next_vector = if i < points.len() - 1 { + points[i + 1].vector + } else { + points[i].vector + }; + let next_dpr = if i < points.len() - 1 { + dpr(vector, next_vector) + } else { + 1.0 + }; + let prev_dpr = dpr(vector, prev_vector); - // Calculate the offset points for this point - let offset_vector = mul(normal_vector, radius); - let left_point = add(point, offset_vector); - let right_point = add(point, neg(offset_vector)); + let is_point_sharp_corner = prev_dpr < 0.0 && !is_prev_point_sharp_corner; + let is_next_point_sharp_corner = next_dpr < 0.0; - // Check if we need to handle sharp corners - let is_sharp_corner = dpr(prev_vector, vector) < 0.0; + if is_point_sharp_corner || is_next_point_sharp_corner { + let offset = mul(per(prev_vector), radius); - if is_sharp_corner && !is_prev_sharp_corner { - // Add the last point - skip if too close to the previous point - if dist2(left_point, pl) > min_distance { - left_pts.push(left_point); - pl = left_point; - } + let step = 1.0 / 13.0; + let mut t = 0.0; + while t <= 1.0 { + tl = rot_around(sub(point, offset), point, FIXED_PI * t); + left_pts.push(tl); - if dist2(right_point, pr) > min_distance { - right_pts.push(right_point); - pr = right_point; - } - } else { - // We're in a curve (or straight line) - - if !is_prev_sharp_corner { - // Create the next offset point - let prev_normal = per(prev_vector); - let offset_a = mul(prev_normal, radius); - - // Calculate temporary left and right points - let tl = add(point, offset_a); - let tr = add(point, neg(offset_a)); - - // Add the previous offset points - if dist2(pl, tl) > min_distance { - left_pts.push(tl); - pl = tl; - } + tr = rot_around(add(point, offset), point, FIXED_PI * -t); + right_pts.push(tr); - if dist2(pr, tr) > min_distance { - right_pts.push(tr); - pr = tr; - } + t += step; } - // Add the current offset points - if dist2(pl, left_point) > min_distance { - left_pts.push(left_point); - pl = left_point; - } + pl = tl; + pr = tr; - if dist2(pr, right_point) > min_distance { - right_pts.push(right_point); - pr = right_point; + if is_next_point_sharp_corner { + is_prev_point_sharp_corner = true; } + continue; + } + + is_prev_point_sharp_corner = false; + + // Handle the last point + if i == points.len() - 1 { + let offset = mul(per(vector), radius); + left_pts.push(sub(point, offset)); + right_pts.push(add(point, offset)); + continue; + } + + // Add regular points + let offset = mul(per(lrp(next_vector, vector, next_dpr)), radius); + + tl = sub(point, offset); + + if i <= 1 || dist2(pl, tl) > min_distance { + left_pts.push(tl); + pl = tl; + } + + tr = add(point, offset); + + if i <= 1 || dist2(pr, tr) > min_distance { + right_pts.push(tr); + pr = tr; } - // Set variables for the next iteration + prev_pressure = pressure; prev_vector = vector; - is_prev_sharp_corner = is_sharp_corner; } - // Add caps if needed - let mut result = Vec::new(); - - // Get the closed flag - let close_path = options.closed.unwrap_or(false); - - // Start cap - if cap_start { - let first_point = points[0].point; - let first_normal = per(points[0].vector); - let offset_vector = mul(first_normal, first_point_radius); - - let start_left = add(first_point, offset_vector); - let start_right = add(first_point, neg(offset_vector)); - - // Add the start cap (from left to right) - result.push(start_left); - - // Add semicircular cap - if points.len() > 1 { - let steps = 4; - for i in 0..=steps { - let t = i as f64 / steps as f64; - let angle = FIXED_PI - t * FIXED_PI; - result.push(rot_around(start_right, first_point, angle)); + // Draw caps + let first_point = points[0].point; + + let last_point = if points.len() > 1 { + points[points.len() - 1].point + } else { + add(points[0].point, [1.0, 1.0]) + }; + + let mut start_cap: Vec<[f64; 2]> = Vec::new(); + let mut end_cap: Vec<[f64; 2]> = Vec::new(); + + // Draw a dot for very short or completed strokes + if points.len() == 1 { + if !(taper_start != 0.0 || taper_end != 0.0) || is_complete { + let start = prj( + first_point, + uni(per(sub(first_point, last_point))), + -(first_radius.unwrap_or(radius)), + ); + let mut dot_pts: Vec<[f64; 2]> = Vec::new(); + let step = 1.0 / 13.0; + let mut t = step; + while t <= 1.0 { + dot_pts.push(rot_around(start, first_point, FIXED_PI * 2.0 * t)); + t += step; } - } else { - result.push(start_right); + return dot_pts; } - } - - // Add right side points (from start to end) - for p in right_pts.iter() { - result.push(*p); - } - - // End cap - if cap_end && !right_pts.is_empty() { - let last_point = points.last().map(|p| p.point).unwrap_or_default(); - let last_vector = points.last().map(|p| p.vector).unwrap_or_default(); - let last_normal = per(last_vector); - - let last_radius = if points.len() > 1 { - let last_pressure = points.last().map(|p| p.pressure).unwrap_or(0.5); - if thinning > 0.0 { - get_stroke_radius(size, thinning, last_pressure, Some(easing_fn)) - } else { - size / 2.0 + } else { + // Draw a start cap + if taper_start != 0.0 || (taper_end != 0.0 && points.len() == 1) { + // Tapered start, noop + } else if cap_start { + let step = 1.0 / 13.0; + let mut t = step; + while t <= 1.0 { + let pt = rot_around(right_pts[0], first_point, FIXED_PI * t); + start_cap.push(pt); + t += step; } } else { - first_point_radius - }; + let corners_vector = sub(left_pts[0], right_pts[0]); + let offset_a = mul(corners_vector, 0.5); + let offset_b = mul(corners_vector, 0.51); - let tapered_radius = if taper_end > 0.0 { - 0.01 - } else { - last_radius - }; - - let offset_vector = mul(last_normal, tapered_radius); - let end_right = add(last_point, neg(offset_vector)); - let end_left = add(last_point, offset_vector); + start_cap.push( + sub(first_point, offset_a), + ); + start_cap.push( + sub(first_point, offset_b), + ); + start_cap.push( + add(first_point, offset_b), + ); + start_cap.push( + add(first_point, offset_a), + ); + } - // Add semicircular cap (from right to left) - if points.len() > 1 { - let steps = 4; - for i in 0..=steps { - let t = i as f64 / steps as f64; - result.push(rot_around(end_right, last_point, t * FIXED_PI)); + // Draw an end cap + let direction = per(neg(points[points.len() - 1].vector)); + + if taper_end != 0.0 || (taper_start != 0.0 && points.len() == 1) { + end_cap.push(last_point); + } else if cap_end { + let start = prj(last_point, direction, radius); + let step = 1.0 / 29.0; + let mut t = step; + while t < 1.0 { + end_cap.push(rot_around(start, last_point, FIXED_PI * 3.0 * t)); + t += step; } } else { - result.push(end_left); + end_cap.push(add(last_point, mul(direction, radius))); + end_cap.push(add(last_point, mul(direction, radius * 0.99))); + end_cap.push(sub(last_point, mul(direction, radius * 0.99))); + end_cap.push(sub(last_point, mul(direction, radius))); } } - - // Add left side points (from end to start) - for p in left_pts.iter().rev() { - result.push(*p); - } - - // Close the path if needed - if close_path && !result.is_empty() && result.len() > 1 { - result.push(result[0]); - } - // If not explicitly closed, ensure we close it for testing purposes - else if !result.is_empty() && result.len() > 1 && result[0] != result[result.len() - 1] { - result.push(result[0]); - } + + // Return points in the correct winding order + let mut result = Vec::new(); + result.extend(left_pts); + result.extend(end_cap); + right_pts.reverse(); + result.extend(right_pts); + result.extend(start_cap); result -} \ No newline at end of file +} diff --git a/src/get_stroke_points.rs b/src/get_stroke_points.rs index 7d5a174..c7b718c 100644 --- a/src/get_stroke_points.rs +++ b/src/get_stroke_points.rs @@ -1,14 +1,17 @@ use crate::types::{InputPoint, StrokeOptions, StrokePoint}; use crate::vec::{add, dist, is_equal, lrp, sub, uni}; -/// Get an array of points as objects with an adjusted point, pressure, vector, distance, and running_length. +/// Convert raw input points into processed `StrokePoint` values. +/// +/// This step applies streamline smoothing and normalizes pressure, and computes +/// per-point distance, direction, and running length used by outline generation. /// /// # Arguments -/// * `points` - An array of points with optional pressure data -/// * `options` - Options for the stroke generation +/// * `points` - Input points with optional pressure data (0.0..=1.0). +/// * `options` - Stroke options; `streamline`, `size`, and `last` affect output. /// /// # Returns -/// An array of StrokePoint objects +/// A vector of `StrokePoint` values suitable for `get_stroke_outline_points`. pub fn get_stroke_points( points: &[InputPoint], options: &StrokeOptions, @@ -26,11 +29,11 @@ pub fn get_stroke_points( let t = 0.15 + (1.0 - streamline) * 0.85; // Convert all input points to a consistent format [x, y, pressure] - let mut pts: Vec<([f64; 2], f64)> = points + let mut pts: Vec<([f64; 2], Option)> = points .iter() .map(|p| match p { - InputPoint::Array(point, pressure) => (*point, pressure.unwrap_or(0.5)), - InputPoint::Struct { x, y, pressure } => ([*x, *y], pressure.unwrap_or(0.5)), + InputPoint::Array(point, pressure) => (*point, *pressure), + InputPoint::Struct { x, y, pressure } => ([*x, *y], Some(pressure.unwrap_or(0.5))), }) .collect(); @@ -42,7 +45,7 @@ pub fn get_stroke_points( for i in 1..5 { let t = i as f64 / 4.0; let lerp_point = lrp(pts[0].0, last.0, t); - pts.push((lerp_point, last.1)); + pts.push((lerp_point, None)); } } @@ -57,7 +60,10 @@ pub fn get_stroke_points( // Start it out with the first point, which needs no adjustment let mut stroke_points = vec![StrokePoint { point: [pts[0].0[0], pts[0].0[1]], - pressure: if pts[0].1 >= 0.0 { pts[0].1 } else { 0.25 }, + pressure: match pts[0].1 { + Some(p) if p >= 0.0 => p, + _ => 0.25, + }, vector: [1.0, 1.0], distance: 0.0, running_length: 0.0, @@ -76,16 +82,20 @@ pub fn get_stroke_points( let max = pts.len() - 1; // Iterate through all of the points, creating StrokePoints - for i in 1..pts.len() { + for (i, point) in pts.iter().enumerate() { + let pressure = match point.1 { + Some(p) if p >= 0.0 => p, + _ => 0.5, + }; let point = if is_complete && i == max { // If we're at the last point, and options.last is true, // then add the actual input point - [pts[i].0[0], pts[i].0[1]] + [point.0[0], point.0[1]] } else { // Otherwise, using the t calculated from the streamline // option, interpolate a new point between the previous // point the current point - lrp(prev.point, pts[i].0, t) + lrp(prev.point, point.0, t) }; // If the new point is the same as the previous point, skip ahead @@ -114,7 +124,7 @@ pub fn get_stroke_points( // The adjusted point point, // The input pressure (or .5 if not specified) - pressure: if pts[i].1 >= 0.0 { pts[i].1 } else { 0.5 }, + pressure, // The vector from the current point to the previous point vector: uni(sub(prev.point, point)), // The distance between the current point and the previous point @@ -133,4 +143,4 @@ pub fn get_stroke_points( } stroke_points -} \ No newline at end of file +} diff --git a/src/get_stroke_radius.rs b/src/get_stroke_radius.rs index da5da06..033ebfc 100644 --- a/src/get_stroke_radius.rs +++ b/src/get_stroke_radius.rs @@ -1,15 +1,15 @@ -/// Compute a radius based on the pressure. +/// Compute a stroke radius from size and pressure. /// /// # Arguments -/// * `size` - The base size of the stroke -/// * `thinning` - The effect of pressure on the stroke's size -/// * `pressure` - The pressure (between 0 and 1) -/// * `easing` - An optional easing function to apply to the pressure +/// * `size` - Base diameter of the stroke. +/// * `thinning` - How much pressure affects size (0.0 = constant). +/// * `pressure` - Pressure value in the 0.0..=1.0 range. +/// * `easing` - Optional easing function applied to the pressure. pub fn get_stroke_radius( size: f64, thinning: f64, pressure: f64, - easing: Option f64>, + easing: Option<&dyn Fn(f64) -> f64>, ) -> f64 { let t = match easing { Some(ease_fn) => ease_fn(0.5 - thinning * (0.5 - pressure)), @@ -17,4 +17,4 @@ pub fn get_stroke_radius( }; size * t -} \ No newline at end of file +} diff --git a/src/lib.rs b/src/lib.rs index aacbb5b..7d84a6c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,10 @@ +//! Freehand stroke generation utilities. +//! +//! Primary entry points: +//! - `get_stroke` for a ready-to-render outline polygon. +//! - `get_stroke_points` + `get_stroke_outline_points` for lower-level control. +//! - `get_svg_path_from_stroke` for SVG path data from outlines. + mod get_stroke; mod get_stroke_outline_points; mod get_stroke_points; diff --git a/src/types.rs b/src/types.rs index df1a1db..4affae7 100644 --- a/src/types.rs +++ b/src/types.rs @@ -1,69 +1,56 @@ -/// The options object for `get_stroke` or `get_stroke_points`. +use std::sync::Arc; + + +/// Options for generating stroke points and outlines. +/// +/// Defaults are chosen to match the reference implementation: +/// size = 16.0, thinning = 0.5, smoothing = 0.5, streamline = 0.5, +/// simulate_pressure = true, last = false. /// -/// * `size` - The base size (diameter) of the stroke. -/// * `thinning` - The effect of pressure on the stroke's size. -/// * `smoothing` - How much to soften the stroke's edges. -/// * `easing` - An easing function to apply to each point's pressure. -/// * `simulate_pressure` - Whether to simulate pressure based on velocity. -/// * `start` - Cap, taper and easing for the start of the line. -/// * `end` - Cap, taper and easing for the end of the line. -/// * `last` - Whether to handle the points as a completed stroke. -/// * `closed` - Whether to close the path by connecting the last point back to the first. +/// Notes: +/// - Pressures are expected in the 0.0..=1.0 range. Missing pressures default to 0.5. +/// - `streamline` trades accuracy for smoothing: higher values lag more but reduce jitter. +/// - `last` controls whether the final input point is used as-is (finished stroke) +/// or still smoothed toward (in-progress stroke). #[derive(Clone)] +#[derive(Default)] pub struct StrokeOptions { + /// Base diameter of the stroke in the same units as input points. pub size: Option, + /// How much pressure affects the stroke size (0.0 = constant size). pub thinning: Option, + /// How aggressively to filter small outline segments (higher = smoother). pub smoothing: Option, + /// How much to smooth raw input points (0.0 = follow input, 1.0 = max smoothing). pub streamline: Option, - pub easing: Option f64>, + /// Easing function applied to pressure before converting it to radius. + pub easing: Option f64 + Send + Sync>>, + /// Whether to derive pressure from velocity when input pressure is missing. pub simulate_pressure: Option, + /// Start cap and taper settings. pub start: Option, + /// End cap and taper settings. pub end: Option, + /// Treat the points as a completed stroke. pub last: Option, - pub closed: Option, } -impl Default for StrokeOptions { - fn default() -> Self { - Self { - size: None, - thinning: None, - smoothing: None, - streamline: None, - easing: None, - simulate_pressure: None, - start: None, - end: None, - last: None, - closed: Some(false), - } - } -} -/// Options for tapering at the start or end of a stroke +/// Options for tapering at the start or end of a stroke. +/// +/// Defaults: +/// cap = true, taper = 0.0, easing = a built-in ease-in/out. #[derive(Clone)] +#[derive(Default)] pub struct TaperOptions { + /// Whether to draw a round cap when taper is zero. pub cap: Option, - pub taper: Option, - pub easing: Option f64>, + /// Length of the taper in the same units as input points. + pub taper: Option, + /// Easing function for the taper along its length. + pub easing: Option f64 + Send + Sync>>, } -impl Default for TaperOptions { - fn default() -> Self { - Self { - cap: None, - taper: None, - easing: None, - } - } -} - -/// Represents either a boolean or a numeric taper value -#[derive(Debug, Clone)] -pub enum TaperType { - Bool(bool), - Number(f64), -} impl std::fmt::Debug for StrokeOptions { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { @@ -77,7 +64,6 @@ impl std::fmt::Debug for StrokeOptions { .field("start", &self.start) .field("end", &self.end) .field("last", &self.last) - .field("closed", &self.closed) .finish() } } @@ -92,19 +78,28 @@ impl std::fmt::Debug for TaperOptions { } } -/// The points returned by `get_stroke_points`, and the input for `get_stroke_outline_points`. +/// A processed point with geometry and pressure data. +/// +/// Returned by `get_stroke_points` and accepted by `get_stroke_outline_points`. #[derive(Debug, Clone)] pub struct StrokePoint { + /// The adjusted point after streamline smoothing. pub point: [f64; 2], + /// Pressure value in the 0.0..=1.0 range. pub pressure: f64, + /// Distance from the previous point. pub distance: f64, + /// Unit vector from this point to the previous point. pub vector: [f64; 2], + /// Total distance accumulated along the stroke. pub running_length: f64, } -/// Represents an input point with optional pressure +/// Represents an input point with optional pressure. #[derive(Debug, Clone)] pub enum InputPoint { + /// Tuple-style point: ([x, y], pressure). Array([f64; 2], Option), + /// Struct-style point: { x, y, pressure }. Struct { x: f64, y: f64, pressure: Option }, -} \ No newline at end of file +} diff --git a/src/utils.rs b/src/utils.rs index dc05bdd..e49bbb2 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -3,14 +3,17 @@ fn average(a: f64, b: f64) -> f64 { (a + b) / 2.0 } -/// Converts a stroke's outline points to an SVG path data string +/// Convert outline points into an SVG path data string. +/// +/// Uses quadratic smoothing through midpoints, matching the reference output. +/// Returns an empty string when fewer than 4 points are provided. /// /// # Arguments -/// * `points` - The outline points returned by `get_stroke` -/// * `closed` - Whether to close the path with a 'Z' command +/// * `points` - The outline points returned by `get_stroke`. +/// * `closed` - Whether to close the path with a 'Z' command. /// /// # Returns -/// A string containing SVG path commands +/// A string containing SVG path commands. pub fn get_svg_path_from_stroke(points: &[[f64; 2]], closed: bool) -> String { let len = points.len(); @@ -48,4 +51,4 @@ pub fn get_svg_path_from_stroke(points: &[[f64; 2]], closed: bool) -> String { } result -} \ No newline at end of file +} diff --git a/tests/get_stroke_points_test.rs b/tests/get_stroke_points_test.rs index 1e05b3b..85187d0 100644 --- a/tests/get_stroke_points_test.rs +++ b/tests/get_stroke_points_test.rs @@ -15,7 +15,7 @@ fn test_get_stroke_points_with_one_point() { let options = StrokeOptions::default(); let result = get_stroke_points(&points, &options); - assert!(result.len() >= 1); + assert!(!result.is_empty()); // First point should match the input assert_eq!(result[0].point, [100.0, 100.0]); diff --git a/tests/get_stroke_radius_test.rs b/tests/get_stroke_radius_test.rs index 3e8b5fe..72eea5f 100644 --- a/tests/get_stroke_radius_test.rs +++ b/tests/get_stroke_radius_test.rs @@ -50,16 +50,43 @@ fn test_stroke_radius_with_easing() { let square_easing = |t: f64| t * t; // With 1.0 thinning and exponential easing - assert_eq!(get_stroke_radius(100.0, 1.0, 0.0, Some(square_easing)), 0.0); - assert_eq!(get_stroke_radius(100.0, 1.0, 0.25, Some(square_easing)), 6.25); - assert_eq!(get_stroke_radius(100.0, 1.0, 0.5, Some(square_easing)), 25.0); - assert_eq!(get_stroke_radius(100.0, 1.0, 0.75, Some(square_easing)), 56.25); - assert_eq!(get_stroke_radius(100.0, 1.0, 1.0, Some(square_easing)), 100.0); + assert_eq!(get_stroke_radius(100.0, 1.0, 0.0, Some(&square_easing)), 0.0); + assert_eq!( + get_stroke_radius(100.0, 1.0, 0.25, Some(&square_easing)), + 6.25 + ); + assert_eq!( + get_stroke_radius(100.0, 1.0, 0.5, Some(&square_easing)), + 25.0 + ); + assert_eq!( + get_stroke_radius(100.0, 1.0, 0.75, Some(&square_easing)), + 56.25 + ); + assert_eq!( + get_stroke_radius(100.0, 1.0, 1.0, Some(&square_easing)), + 100.0 + ); // With -1.0 thinning and exponential easing - assert_eq!(get_stroke_radius(100.0, -1.0, 0.0, Some(square_easing)), 100.0); - assert_eq!(get_stroke_radius(100.0, -1.0, 0.25, Some(square_easing)), 56.25); - assert_eq!(get_stroke_radius(100.0, -1.0, 0.5, Some(square_easing)), 25.0); - assert_eq!(get_stroke_radius(100.0, -1.0, 0.75, Some(square_easing)), 6.25); - assert_eq!(get_stroke_radius(100.0, -1.0, 1.0, Some(square_easing)), 0.0); -} \ No newline at end of file + assert_eq!( + get_stroke_radius(100.0, -1.0, 0.0, Some(&square_easing)), + 100.0 + ); + assert_eq!( + get_stroke_radius(100.0, -1.0, 0.25, Some(&square_easing)), + 56.25 + ); + assert_eq!( + get_stroke_radius(100.0, -1.0, 0.5, Some(&square_easing)), + 25.0 + ); + assert_eq!( + get_stroke_radius(100.0, -1.0, 0.75, Some(&square_easing)), + 6.25 + ); + assert_eq!( + get_stroke_radius(100.0, -1.0, 1.0, Some(&square_easing)), + 0.0 + ); +} diff --git a/tests/get_stroke_test.rs b/tests/get_stroke_test.rs index f409ace..4e98d00 100644 --- a/tests/get_stroke_test.rs +++ b/tests/get_stroke_test.rs @@ -59,10 +59,8 @@ fn test_get_stroke_with_default_options() { // Ensure we got some results assert!(!result.is_empty()); - // Ensure the first and last points form a closed path - if result.len() > 1 { - assert_eq!(result[0], result[result.len() - 1]); - } + // Outline points are returned as an open path (closure is optional at render time) + assert!(result.len() > 1); } #[test] @@ -87,10 +85,8 @@ fn test_get_stroke_with_custom_options() { // Ensure we got some results assert!(!result.is_empty()); - // Ensure the first and last points form a closed path - if result.len() > 1 { - assert_eq!(result[0], result[result.len() - 1]); - } + // Outline points are returned as an open path (closure is optional at render time) + assert!(result.len() > 1); } #[test] @@ -158,4 +154,4 @@ fn test_svg_path_conversion() { // Print the SVG path for demonstration println!("SVG Path for Number Pairs: {}", path_data); -} \ No newline at end of file +}