diff --git a/star/src/lower/visit.rs b/star/src/lower/visit.rs index e1fdd40..4b74776 100644 --- a/star/src/lower/visit.rs +++ b/star/src/lower/visit.rs @@ -2,6 +2,7 @@ use std::{collections::HashMap, str::FromStr}; use euclid::default::Transform2D; use log::{debug, warn}; +use lyon_geom::{Box2D, Point}; use roxmltree::{Document, Node}; use svgtypes::{ AspectRatio, LengthListParser, PathParser, PathSegment, PointsParser, TransformListParser, @@ -172,6 +173,10 @@ impl<'a, 'input, T: Turtle> XmlVisitor for ConversionVisitor<'a, 'input, T> { fn visit_enter(&mut self, node: Node) { use PathSegment::*; + let mut view_box_opt = None; + let mut viewport_pos_opt = [None, None]; + let mut viewport_size_opt = [1., 1.]; + if node.tag_name().name() == CLIP_PATH_TAG_NAME { warn!("Clip paths are not supported: {:?}", node); } @@ -266,6 +271,10 @@ impl<'a, 'input, T: Turtle> XmlVisitor for ConversionVisitor<'a, 'input, T> { let viewport_pos = ["x", "y"].map(|attr| self.length_attr_to_user_units(&node, attr)); + view_box_opt = view_box; + viewport_pos_opt = viewport_pos; + viewport_size_opt = viewport_size; + self.viewport_dim_stack .push(match (view_box.as_ref(), &viewport_size) { (Some(ViewBox { w, h, .. }), _) => [*w, *h], @@ -333,6 +342,9 @@ impl<'a, 'input, T: Turtle> XmlVisitor for ConversionVisitor<'a, 'input, T> { flattened_transform = flattened_transform.then(&viewport_transform); // Does not need Y-axis translation unlike , already in g-code coords space. } + view_box_opt = view_box; + viewport_pos_opt = [None, None]; + viewport_size_opt = viewport_size; } else if node.has_attribute("viewBox") { warn!("View box is not supported on a {}", node.tag_name().name()); } @@ -341,6 +353,21 @@ impl<'a, 'input, T: Turtle> XmlVisitor for ConversionVisitor<'a, 'input, T> { self.name_stack .push(node_name(&node, &self.config.extra_attribute_name)); + if node.has_tag_name(SVG_TAG_NAME) || node.has_tag_name(SYMBOL_TAG_NAME) { + let viewport_box = if let Some(vb) = view_box_opt { + Box2D::new(Point::new(vb.x, vb.y), Point::new(vb.x + vb.w, vb.y + vb.h)) + } else { + let px = viewport_pos_opt[0].unwrap_or(0.); + let py = viewport_pos_opt[1].unwrap_or(0.); + Box2D::new( + Point::new(px, py), + Point::new(px + viewport_size_opt[0], py + viewport_size_opt[1]), + ) + }; + + self.terrarium.push_viewport_bounds(viewport_box); + } + if !self.should_draw_node(node) { return; } @@ -635,8 +662,33 @@ impl<'a, 'input, T: Turtle> XmlVisitor for ConversionVisitor<'a, 'input, T> { .length_attr_to_user_units(&node, "height") .unwrap_or(0.); + let preserve_aspect_ratio = node + .attribute("preserveAspectRatio") + .map(|attr| { + AspectRatio::from_str(attr).expect("could not parse preserveAspectRatio") + }) + .unwrap_or(svgtypes::AspectRatio { + defer: false, + align: svgtypes::Align::XMidYMid, + slice: false, + }); + + let view_box = svgtypes::ViewBox { + x: 0., + y: 0., + w: image.width() as f64, + h: image.height() as f64, + }; + let image_to_user = get_viewport_transform( + view_box, + Some(preserve_aspect_ratio), + [width, height], + [Some(x), Some(y)], + ); + self.comment(); - self.terrarium.image(image, x, y, width, height); + self.terrarium + .image(image, image_to_user, preserve_aspect_ratio); } // No-op tags SVG_TAG_NAME | GROUP_TAG_NAME | USE_TAG_NAME | SYMBOL_TAG_NAME => {} @@ -651,6 +703,7 @@ impl<'a, 'input, T: Turtle> XmlVisitor for ConversionVisitor<'a, 'input, T> { self.name_stack.pop(); if matches!(node.tag_name().name(), SVG_TAG_NAME | SYMBOL_TAG_NAME) { self.viewport_dim_stack.pop(); + self.terrarium.pop_bounds(); } } } diff --git a/star/src/turtle/elements/image_ops.rs b/star/src/turtle/elements/image_ops.rs new file mode 100644 index 0000000..6f498e6 --- /dev/null +++ b/star/src/turtle/elements/image_ops.rs @@ -0,0 +1,267 @@ +//! Image transformation and cropping utilities to normalize images before passing them to turtles. + +use euclid::default::Transform2D; +use image::{DynamicImage, GenericImage, GenericImageView}; +use lyon_geom::{Box2D, point, vector}; +use svgtypes::AspectRatio; + +use super::RasterImage; + +/// Transforms (rotates, scales, resamples) the input image using the given transform and aspect ratio. +/// Returns the transformed image and its bounding box in final coordinate space. +/// +/// # Note on Scaling +/// For memory reasons, the pixel dimensions of the returned `DynamicImage` likely won't map directly to the +/// physical dimensions of the returned `Box2D`. +/// +/// - axis-aligned transforms defer scaling to `Turtle`-time, where a more efficient operation could be applied +/// - non-aligned transforms do require resizing, but this is clamped to avoid more than 4x memory usage +/// +/// The `Turtle` must handle any stretching/scaling as appropriate. +pub fn transform_image( + mut image: DynamicImage, + image_to_user: Transform2D, + user_to_final: &Transform2D, + preserve_aspect_ratio: AspectRatio, +) -> (DynamicImage, Box2D) { + // TODO: should this be more coarse? + const EPSILON: f64 = f64::EPSILON; + const MAX_NON_AFFINE_SCALE: f64 = 2.; + + let orig_w = image.width(); + let orig_h = image.height(); + + let pixel_bounds = Box2D::new(point(0., 0.), point(orig_w as f64, orig_h as f64)); + let user_bounds = image_to_user.outer_transformed_box(&pixel_bounds); + let image_to_final = image_to_user.then(user_to_final); + let transformed_box = image_to_final.outer_transformed_box(&pixel_bounds); + + let (is_transform_axis_aligned, [transformed_x_axis, transformed_y_axis]) = { + let tx = user_to_final.transform_vector(vector(1.0, 0.0)); + let ty = user_to_final.transform_vector(vector(0.0, 1.0)); + + let aligned = (tx.y.abs() < EPSILON && ty.x.abs() < EPSILON) + || (tx.x.abs() < EPSILON && ty.y.abs() < EPSILON); + + (aligned, [tx, ty]) + }; + + let aspect_ratios_match = { + let orig_aspect_ratio = orig_w as f64 / orig_h as f64; + let final_aspect_ratio = user_bounds.width() / user_bounds.height(); + (orig_aspect_ratio - final_aspect_ratio).abs() < EPSILON + }; + let is_simple_orthogonal_rotation = is_transform_axis_aligned + && (preserve_aspect_ratio.align == svgtypes::Align::None || aspect_ratios_match); + + if is_simple_orthogonal_rotation { + if transformed_x_axis.x.abs() < EPSILON && transformed_y_axis.y.abs() < EPSILON { + if transformed_x_axis.y > 0.0 && transformed_y_axis.x < 0.0 { + image = image.rotate90(); + } else if transformed_x_axis.y < 0.0 && transformed_y_axis.x > 0.0 { + image = image.rotate270(); + } + } else if transformed_x_axis.y.abs() < EPSILON + && transformed_y_axis.x.abs() < EPSILON + && transformed_x_axis.x < 0.0 + && transformed_y_axis.y < 0.0 + { + image = image.rotate180(); + } + } else { + // During non-aligned rotation, the corners need to be transparent + image = add_alpha_channel(image); + + + + let scale_x = image_to_final.transform_vector(vector(1.0, 0.0)).length(); + let scale_y = image_to_final.transform_vector(vector(0.0, 1.0)).length(); + + // Clamp the scale factors proportionally (preserve aspect ratio) to avoid an unnecessarily + // large buffer. + let max_scale = scale_x.max(scale_y); + let (resample_scale_x, resample_scale_y) = if max_scale > MAX_NON_AFFINE_SCALE { + ( + scale_x * (MAX_NON_AFFINE_SCALE / max_scale), + scale_y * (MAX_NON_AFFINE_SCALE / max_scale), + ) + } else { + (scale_x, scale_y) + }; + + let new_w = (orig_w as f64 * resample_scale_x).ceil() as u32; + let new_h = (orig_h as f64 * resample_scale_y).ceil() as u32; + + let mut new_img = DynamicImage::new(new_w, new_h, image.color()); + + if let Some(inv) = image_to_final.inverse() { + let min = transformed_box.min; + let tb_w = transformed_box.width(); + let tb_h = transformed_box.height(); + for new_y in 0..new_h { + for new_x in 0..new_w { + // Map new pixel to target space + let target_pt = min + + vector( + (new_x as f64 / new_w as f64) * tb_w, + (new_y as f64 / new_h as f64) * tb_h, + ); + + let orig_pt = inv.transform_point(target_pt); + if (0.0..orig_w as f64).contains(&orig_pt.x) + && (0.0..orig_h as f64).contains(&orig_pt.y) + && let Some(pixel) = + sample_lanczos3(&image, orig_pt.x, orig_pt.y, orig_w, orig_h) + { + new_img.put_pixel(new_x, new_y, pixel); + } + } + } + image = new_img; + } + } + + (image, transformed_box) +} + +/// Crops the image bounds to the specified viewport bounds, returning None if the cropped area is empty. +pub fn crop_image_to_bounds( + mut image: DynamicImage, + img_bbox: Box2D, + bounds: Box2D, +) -> Option { + let cropped_bbox = img_bbox.intersection(&bounds)?; + if cropped_bbox.is_empty() || img_bbox.is_empty() { + return None; + } + + let src_w = image.width(); + let src_h = image.height(); + if src_w == 0 || src_h == 0 { + return None; + } + + let orig_w = img_bbox.width(); + let orig_h = img_bbox.height(); + let w_crop = cropped_bbox.width(); + let h_crop = cropped_bbox.height(); + + let crop_offset = cropped_bbox.min - img_bbox.min; + + let mut pixel_x = (crop_offset.x / orig_w * src_w as f64).round() as u32; + let mut pixel_y = (crop_offset.y / orig_h * src_h as f64).round() as u32; + + pixel_x = pixel_x.min(src_w - 1); + pixel_y = pixel_y.min(src_h - 1); + + let pixel_w = ((w_crop / orig_w) * src_w as f64).round() as u32; + let pixel_h = ((h_crop / orig_h) * src_h as f64).round() as u32; + + let pixel_w = pixel_w.clamp(1, src_w - pixel_x); + let pixel_h = pixel_h.clamp(1, src_h - pixel_y); + + image = image.crop_imm(pixel_x, pixel_y, pixel_w, pixel_h); + + Some(RasterImage { + dimensions: cropped_bbox, + image, + }) +} + +fn add_alpha_channel(image: DynamicImage) -> DynamicImage { + if image.color().has_alpha() { + image + } else { + match image { + DynamicImage::ImageLuma8(_) => DynamicImage::ImageLumaA8(image.to_luma_alpha8()), + DynamicImage::ImageLuma16(_) => DynamicImage::ImageLumaA16(image.to_luma_alpha16()), + DynamicImage::ImageRgb8(_) => DynamicImage::ImageRgba8(image.to_rgba8()), + DynamicImage::ImageRgb16(_) => DynamicImage::ImageRgba16(image.to_rgba16()), + DynamicImage::ImageRgb32F(_) => DynamicImage::ImageRgba32F(image.to_rgba32f()), + other => DynamicImage::ImageRgba8(other.to_rgba8()), + } + } +} + +/// +fn lanczos3_weight(x: f64) -> f64 { + const A: f64 = 3.; + + let x = x.abs(); + if x < f64::EPSILON { + 1.0 + } else if x < A { + // sinc(x)*sinc(x/a) + let pi_x = std::f64::consts::PI * x; + (pi_x.sin() * (pi_x / A).sin()) / (pi_x * pi_x / A) + } else { + // Does not contribute + 0.0 + } +} + +/// TODO: Bad attempt at lanczos3 resample for non-aligned transforms that should probably be revisited... +fn sample_lanczos3( + image: &image::DynamicImage, + x0: f64, + y0: f64, + orig_w: u32, + orig_h: u32, +) -> Option> { + let x_floor = x0.floor() as i64; + let y_floor = y0.floor() as i64; + + // Pre-compute horizontal (x) and vertical (y) weights in 1D arrays + let mut wx_arr = [0.0; 6]; + for (dx, wx) in wx_arr.iter_mut().enumerate() { + let px = x_floor - 2 + dx as i64; + *wx = lanczos3_weight(x0 - px as f64); + } + + let mut wy_arr = [0.0; 6]; + for (dy, wy) in wy_arr.iter_mut().enumerate() { + let py = y_floor - 2 + dy as i64; + *wy = lanczos3_weight(y0 - py as f64); + } + + let mut r_sum = 0.0; + let mut g_sum = 0.0; + let mut b_sum = 0.0; + let mut a_sum = 0.0; + let mut w_sum = 0.0; + + for (dy_idx, &wy) in wy_arr.iter().enumerate() { + if wy.abs() < f64::EPSILON { + continue; + } + let py = y_floor - 2 + dy_idx as i64; + let clamped_py = py.clamp(0, orig_h as i64 - 1) as u32; + + for (dx_idx, &wx) in wx_arr.iter().enumerate() { + let w = wx * wy; + if w.abs() < f64::EPSILON { + continue; + } + let px = x_floor - 2 + dx_idx as i64; + let clamped_px = px.clamp(0, orig_w as i64 - 1) as u32; + + let pixel = image.get_pixel(clamped_px, clamped_py); + r_sum += pixel.0[0] as f64 * w; + g_sum += pixel.0[1] as f64 * w; + b_sum += pixel.0[2] as f64 * w; + a_sum += pixel.0[3] as f64 * w; + w_sum += w; + } + } + + if w_sum > 0.0 { + let r = (r_sum / w_sum).clamp(0.0, 255.0).round() as u8; + let g = (g_sum / w_sum).clamp(0.0, 255.0).round() as u8; + let b = (b_sum / w_sum).clamp(0.0, 255.0).round() as u8; + let a = (a_sum / w_sum).clamp(0.0, 255.0).round() as u8; + + Some(image::Rgba([r, g, b, a])) + } else { + None + } +} diff --git a/star/src/turtle/elements/mod.rs b/star/src/turtle/elements/mod.rs index ecab444..9e4f3be 100644 --- a/star/src/turtle/elements/mod.rs +++ b/star/src/turtle/elements/mod.rs @@ -18,6 +18,9 @@ mod tsp; pub(crate) mod fill; +#[cfg(feature = "image")] +pub(crate) mod image_ops; + /// Defines the algorithm used to calculate how a polygon is filled. /// /// diff --git a/star/src/turtle/mod.rs b/star/src/turtle/mod.rs index a21507e..d7636a7 100644 --- a/star/src/turtle/mod.rs +++ b/star/src/turtle/mod.rs @@ -1,7 +1,7 @@ use std::fmt::Debug; use lyon_geom::{ - ArcFlags, CubicBezierSegment, Point, QuadraticBezierSegment, SvgArc, Vector, + ArcFlags, Box2D, CubicBezierSegment, Point, QuadraticBezierSegment, SvgArc, Vector, euclid::{Angle, default::Transform2D}, point, vector, }; @@ -62,6 +62,8 @@ pub(crate) struct Terrarium { previous_quadratic_control: Option>, previous_cubic_control: Option>, comment: Option, + current_bounds: Option>, + bounds_stack: Vec>>, } impl Terrarium { @@ -77,9 +79,29 @@ impl Terrarium { previous_quadratic_control: None, previous_cubic_control: None, comment: None, + current_bounds: None, + bounds_stack: vec![], } } + pub fn push_viewport_bounds(&mut self, viewport_box: Box2D) { + let new_bounds = self.current_transform.outer_transformed_box(&viewport_box); + let combined_bounds = match self.current_bounds { + Some(parent) => parent.intersection(&new_bounds), + None => Some(new_bounds), + }; + self.push_bounds(combined_bounds); + } + + pub fn push_bounds(&mut self, bounds: Option>) { + self.bounds_stack.push(self.current_bounds); + self.current_bounds = bounds; + } + + pub fn pop_bounds(&mut self) { + self.current_bounds = self.bounds_stack.pop().expect("bounds stack underflow"); + } + /// Move the turtle to the given absolute/relative coordinates in the current transform /// fn move_to(&mut self, abs: bool, x: f64, y: f64) -> Point { @@ -371,20 +393,31 @@ impl Terrarium { /// #[cfg(feature = "image")] - pub fn image(&mut self, image: image::DynamicImage, x: f64, y: f64, width: f64, height: f64) { - // Transform the corners to get the final x, y, width, height. - let t0 = self.current_transform.transform_point(point(x, y)); - let t1 = self - .current_transform - .transform_point(point(x, y) + vector(width, height)); - self.turtle.image(crate::turtle::elements::RasterImage { - // After transformation, the corners may be swapped resulting in a new x y. - dimensions: lyon_geom::Box2D::new( - point(t0.x.min(t1.x), t0.y.min(t1.y)), - point(t0.x.max(t1.x), t0.y.max(t1.y)), - ), + pub fn image( + &mut self, + image: image::DynamicImage, + image_to_user: Transform2D, + preserve_aspect_ratio: svgtypes::AspectRatio, + ) { + let (image, transformed_box) = self::elements::image_ops::transform_image( image, - }); + image_to_user, + &self.current_transform, + preserve_aspect_ratio, + ); + + let final_raster_image = if let Some(bounds) = self.current_bounds { + self::elements::image_ops::crop_image_to_bounds(image, transformed_box, bounds) + } else { + Some(crate::turtle::elements::RasterImage { + dimensions: transformed_box, + image, + }) + }; + + if let Some(raster_image) = final_raster_image { + self.turtle.image(raster_image); + } } /// Push a generic transform onto the stack @@ -536,6 +569,8 @@ impl Terrarium { previous_quadratic_control: self.previous_quadratic_control, previous_cubic_control: self.previous_cubic_control, comment: self.comment.clone(), + current_bounds: self.current_bounds, + bounds_stack: vec![], }; sub.apply_path(segments); let segments = sub.finish().into_strokes();