diff --git a/Cargo.toml b/Cargo.toml index b50fd90..088d505 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,22 +14,31 @@ repository = "https://github.com/shnewto/edges" license = "MIT OR Apache-2.0" +[lints.clippy] +cast_precision_loss = { level = "allow", priority = 1 } +pedantic = { level = "warn", priority = 0 } + [features] -default=[] -bevy=["dep:bevy"] +default = ["bevy"] +glam-latest = ["dep:glam"] +bevy = ["dep:bevy_math", "dep:bevy_render"] [dependencies] -glam = "0.27.0" -hashbrown = "0.14" image = "0.25" -mashmap = "0.1" -ordered-float = "4.2" -thiserror = "1.0" +rayon = "1.10.0" + +[dependencies.glam] +version = "0.29" +optional = true + +[dependencies.bevy_math] +version = "0.14" +default-features = false +optional = true -[dependencies.bevy] +[dependencies.bevy_render] version = "0.14" default-features = false -features = ["bevy_render"] optional = true [dev-dependencies] diff --git a/README.md b/README.md index 4c3f00f..90a373a 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ -[![Crates.io]()]() -[![Crates.io]()]() -[![MIT/Apache 2.0]()]() - # edges +[![Crates.io](https://img.shields.io/crates/v/edges.svg)](https://crates.io/crates/edges) +[![Crates.io](https://img.shields.io/crates/d/edges.svg)](https://crates.io/crates/edges) +[![MIT/Apache 2.0](https://img.shields.io/badge/license-MIT%2FApache-blue.svg)](https://github.com/shnewto/edges#license) + get the edges of objects in images with transparency. ## supported image types @@ -24,13 +24,20 @@ println!("{:#?}", edges.single_image_edge_translated()); ## how it works -i was inspired by [a coding train (or, coding in the cabana rather) on an implementation of "marching squares"](). -so this crate takes a "march through all the values" approach to find edges, i.e. pixels with at least 1 empty neighboring pixel, but -instead of drawing a contour in place, it just keeps track of all the actual pixel coordinates. to determine "empty" I bitwise -or all the bytes for each pixel and, in images with transparency, "empty" is a zero value for the pixel. - -after that, we need to put the coordinates in some kind of "drawing order" so whatever we pass all the points to, knows how we want the object constructed. for this, the -crate collects all pixels, in order, that are a distance of 1 from eachother. if there are pixels that have a distance greater than 1 +i was inspired by [a coding train (or, coding in the cabana rather) +on an implementation of "marching squares"](https://youtu.be/0ZONMNUKTfU). +so this crate takes a "march through all the values" approach to find edges, i.e. +pixels with at least 1 empty neighboring pixel, but +instead of drawing a contour in place, +it just keeps track of all the actual pixel coordinates. to determine "empty" I bitwise +or all the bytes for each pixel and, +in images with transparency, "empty" is a zero value for the pixel. + +after that, we need to put the coordinates in some kind of +"drawing order" so whatever we pass all the points to, +knows how we want the object constructed. for this, the +crate collects all pixels, in order, that are a distance of 1 from eachother. +if there are pixels that have a distance greater than 1 from any pixel in an existing group, that pixel begins a new group. ## license diff --git a/assets/diagonals.png b/assets/diagonals.png new file mode 100644 index 0000000..d630714 Binary files /dev/null and b/assets/diagonals.png differ diff --git a/examples/bevy-image.rs b/examples/bevy-image.rs index 7dd6852..1af434b 100644 --- a/examples/bevy-image.rs +++ b/examples/bevy-image.rs @@ -1,6 +1,10 @@ -use bevy::{prelude::Image, render::texture::ImageType}; +use bevy_render::{ + prelude::Image, + render_asset::RenderAssetUsages, + texture::{CompressedImageFormats, ImageSampler, ImageType}, +}; use edges::Edges; -use raqote::*; +use raqote::{DrawOptions, DrawTarget, PathBuilder, SolidSource, Source, StrokeStyle}; // in an actual bevy app, you wouldn't need all this building an Image from scratch logic, // it'd be something closer to this: // `let image = image_assets.get(handle).unwrap();` @@ -11,39 +15,54 @@ fn main() { let boulders = Image::from_buffer( include_bytes!("../assets/boulders.png"), ImageType::Extension("png"), - Default::default(), + CompressedImageFormats::default(), true, - Default::default(), - Default::default(), + ImageSampler::default(), + RenderAssetUsages::default(), ) .unwrap(); let more_lines = Image::from_buffer( include_bytes!("../assets/more-lines.png"), ImageType::Extension("png"), - Default::default(), + CompressedImageFormats::default(), true, - Default::default(), - Default::default(), + ImageSampler::default(), + RenderAssetUsages::default(), ) .unwrap(); - draw_png(boulders, "boulders.png"); - draw_png(more_lines, "more-lines.png"); + let diagonals = Image::from_buffer( + include_bytes!("../assets/diagonals.png"), + ImageType::Extension("png"), + CompressedImageFormats::default(), + true, + ImageSampler::default(), + RenderAssetUsages::default(), + ) + .unwrap(); + + draw_png(&boulders, "boulders"); + draw_png(&more_lines, "more-lines"); + draw_png(&diagonals, "diagonals"); } -fn draw_png(image: Image, img_path: &str) { +fn draw_png(image: &Image, img_path: &str) { // get the image's edges - let edges = Edges::from(image.clone()); + let edges = Edges::from(image); + let scale = 8; - let (width, height) = (image.width() as i32 * scale, image.height() as i32 * scale); + let (width, height) = ( + i32::try_from(image.width()).expect("Image to wide.") * scale, + i32::try_from(image.height()).expect("Image to tall.") * scale, + ); // draw the edges to a png let mut dt = DrawTarget::new(width, height); - let objects_iter = edges.multi_image_edges_raw().into_iter(); + let objects = edges.multi_image_edges_raw(); - for object in objects_iter { + for object in objects { let mut pb = PathBuilder::new(); let mut edges_iter = object.into_iter(); @@ -71,6 +90,6 @@ fn draw_png(image: Image, img_path: &str) { ); } - dt.write_png(format!("edges-{}", img_path)).unwrap(); - _ = open::that(format!("edges-{}", img_path)); + dt.write_png(format!("edges-{img_path}.png")).unwrap(); + _ = open::that(format!("edges-{img_path}.png")); } diff --git a/examples/dynamic-image.rs b/examples/dynamic-image.rs index 5c1cb1d..3e1e544 100644 --- a/examples/dynamic-image.rs +++ b/examples/dynamic-image.rs @@ -1,5 +1,5 @@ use edges::Edges; -use raqote::*; +use raqote::{DrawOptions, DrawTarget, PathBuilder, SolidSource, Source, StrokeStyle}; use std::path::Path; fn main() { @@ -9,10 +9,15 @@ fn main() { } fn draw_png(img_path: &str) { - let image = &image::open(Path::new(&format!("assets/{}", img_path))).unwrap(); - let edges = Edges::from(image); + let image = image::open(Path::new(&format!("assets/{img_path}"))).unwrap(); + // get the image's edges + let edges = Edges::from(&image); + let scale = 8; - let (width, height) = (image.width() as i32 * scale, image.height() as i32 * scale); + let (width, height) = ( + i32::try_from(image.width()).expect("Image to wide.") * scale, + i32::try_from(image.height()).expect("Image to tall.") * scale, + ); // draw the edges to a png let mut dt = DrawTarget::new(width, height); @@ -41,6 +46,6 @@ fn draw_png(img_path: &str) { &DrawOptions::new(), ); - dt.write_png(format!("edges-{}", img_path)).unwrap(); - _ = open::that(format!("edges-{}", img_path)); + dt.write_png(format!("edges-{img_path}")).unwrap(); + _ = open::that(format!("edges-{img_path}")); } diff --git a/src/bin_image.rs b/src/bin_image.rs new file mode 100644 index 0000000..f2db458 --- /dev/null +++ b/src/bin_image.rs @@ -0,0 +1,161 @@ +use crate::{utils::is_corner, UVec2, Vec2}; +use rayon::prelude::*; +pub mod neighbors { + pub const NORTH: u8 = 0b1000_0000; + pub const SOUTH: u8 = 0b0100_0000; + pub const EAST: u8 = 0b0010_0000; + pub const WEST: u8 = 0b0001_0000; + pub const NORTHEAST: u8 = 0b0000_1000; + pub const NORTHWEST: u8 = 0b0000_0100; + pub const SOUTHEAST: u8 = 0b0000_0010; + pub const SOUTHWEST: u8 = 0b0000_0001; +} + +pub struct BinImage { + data: Vec, + height: u32, + width: u32, +} + +impl BinImage { + /// Creates a new `BinImage` from the given height, width, and raw pixel data. + /// + /// # Arguments + /// + /// * `height` - The height of the image in pixels. + /// * `width` - The width of the image in pixels. + /// * `data` - A slice of bytes representing the raw pixel data. The length of this slice + /// must be at least `height * width`. + /// + /// # Panics + /// + /// This function will panic if the length of `data` is less than `height * width`. + pub fn new(height: u32, width: u32, data: &[u8]) -> Self { + assert!( + data.len() >= (height * width) as usize, + "data must not be smaller than image dimensions" + ); + let compress_step = data.len() / (height * width) as usize; + Self { + data: data + .par_chunks(8 * compress_step) + .map(|chunk| { + chunk + .par_chunks(compress_step) + .map(|chunk| chunk.iter().any(|i| *i != 0)) + .enumerate() + .map(|(index, bit)| u8::from(bit) << index) + .sum() + }) + .collect(), + height, + width, + } + } + + /// Gets the pixel value at the given coordinate. + /// + /// # Arguments + /// + /// * `p` - A `UVec2` representing the coordinates of the pixel. + /// + /// # Returns + /// + /// Returns `true` if the pixel is "on" (1), and `false` if it is "off" (0) or out of bounds. + pub fn get(&self, p: UVec2) -> bool { + if p.x >= self.width { + return false; + } + let index = p.y * self.width + p.x; + if let Some(mut byte) = self + .data + .get((index / 8) as usize) // index of byte + .copied() + { + byte >>= index % 8; // index of bit + byte & 1 > 0 + } else { + false + } + } + + /// Gets the values of the neighboring pixels (8-connectivity) around the given coordinate. + /// + /// # Arguments + /// + /// * `p` - A `UVec2` representing the coordinates of the center pixel. + /// + /// # Returns + /// + /// An byte representing the state of the neighboring pixels. + pub fn get_neighbors(&self, p: UVec2) -> u8 { + let (x, y) = (p.x, p.y); + let mut neighbors = 0; + if y < u32::MAX && self.get(UVec2::new(x, y + 1)) { + neighbors |= neighbors::NORTH; + } + if y > u32::MIN && self.get(UVec2::new(x, y - 1)) { + neighbors |= neighbors::SOUTH; + } + if x < u32::MAX && self.get(UVec2::new(x + 1, y)) { + neighbors |= neighbors::EAST; + } + if x > u32::MIN && self.get(UVec2::new(x - 1, y)) { + neighbors |= neighbors::WEST; + } + if x < u32::MAX && y < u32::MAX && self.get(UVec2::new(x + 1, y + 1)) { + neighbors |= neighbors::NORTHEAST; + } + if x > u32::MIN && y < u32::MAX && self.get(UVec2::new(x - 1, y + 1)) { + neighbors |= neighbors::NORTHWEST; + } + if x < u32::MAX && y > u32::MIN && self.get(UVec2::new(x + 1, y - 1)) { + neighbors |= neighbors::SOUTHEAST; + } + if x > u32::MIN && y > u32::MIN && self.get(UVec2::new(x - 1, y - 1)) { + neighbors |= neighbors::SOUTHWEST; + } + neighbors + } + + pub fn is_corner(&self, p: UVec2) -> bool { + is_corner(self.get_neighbors(p)) + } + + /// Translates a point in positive (x, y) coordinates to a coordinate system centered at (0, 0). + /// + /// # Arguments + /// + /// * `p` - A `Vec2` representing the point to translate. + /// + /// # Returns + /// + /// A new `Vec2` representing the translated coordinates + fn translate_point(&self, p: Vec2) -> Vec2 { + Vec2::new( + p.x - ((self.width / 2) as f32 - 1.0), + ((self.height / 2) as f32 - 1.0) - p.y, + ) + } + + /// Translates an `Vec` of points in positive (x, y) coordinates to a coordinate system centered at (0, 0). + /// + /// # Arguments + /// + /// * `v` - An `Vec` of `Vec2` points to translate. + /// + /// # Returns + /// + /// A vector of `Vec2` representing the translated coordinates. + pub fn translate(&self, v: Vec) -> Vec { + v.into_par_iter().map(|p| self.translate_point(p)).collect() + } + + pub const fn height(&self) -> u32 { + self.height + } + + pub const fn width(&self) -> u32 { + self.width + } +} diff --git a/src/edges.rs b/src/edges.rs deleted file mode 100644 index b64c674..0000000 --- a/src/edges.rs +++ /dev/null @@ -1,349 +0,0 @@ -use std::fmt; - -use glam::Vec2; -use hashbrown::HashSet; -use mashmap::MashMap; -use ordered_float::OrderedFloat; - -pub enum Edges { - DynamicImage(image::DynamicImage), - #[cfg(feature = "bevy")] - BevyImage(bevy::prelude::Image), -} - -impl Edges { - /// If there's only one sprite / object in the image, this returns just one, with - /// coordinates translated to either side of (0, 0) - pub fn single_image_edge_translated(&self) -> Vec { - self.image_edges(true).into_iter().flatten().collect() - } - - /// If there's only one sprite / object in the image, this returns just one, with - /// coordinates left alone and all in positive x and y - pub fn single_image_edge_raw(&self) -> Vec { - self.image_edges(false).into_iter().flatten().collect() - } - - /// If there's more than one sprite / object in the image, this returns all it finds, with - /// coordinates translated to either side of (0, 0) - pub fn multi_image_edge_translated(&self) -> Vec> { - self.image_edges(true) - } - - /// If there's more than one sprite / object in the image, this returns all it finds, with - /// coordinates left alone and all in positive x and y - pub fn multi_image_edges_raw(&self) -> Vec> { - self.image_edges(false) - } - - /// Takes a Bevy DynamicImage type and an boolean to indicate whether to translate - /// the points you get back to either side of (0, 0) instead of everything in positive x and y - pub fn image_edges(&self, translate: bool) -> Vec> { - let rows = self.height(); - let cols = self.width(); - let data: &[u8] = self.bytes(); - let mut byte_combine_step: usize = 1; - if (rows * cols) < data.len() { - byte_combine_step = data.len() / (rows * cols); - } - - let mut processed: Vec = vec![]; - for i in (0..data.len()).step_by(byte_combine_step) { - let mut b: usize = 0; - for j in 0..byte_combine_step { - b |= data[i + j] as usize; // just need to retain any non-zero values - } - processed.push(b); - } - - Edges::march_edges(&processed, rows, cols, translate) - } - - /// Marching squares adjacent, walks all the pixels in the provided data and keeps track of - /// any that have at least one transparent / zero value neighbor then, while sorting into drawing - /// order, groups them into sets of connected pixels - /// - /// Accepts a flag indicating whether or not to translate coordinates to either side of (0,0) - /// or leave it all in positive x,y - pub fn march_edges( - data: &[usize], - rows: usize, - cols: usize, - translate: bool, - ) -> Vec> { - let mut edge_points: Vec = vec![]; - - for d in 0..data.len() { - let (x, y) = Edges::get_xy(d, rows); - let (c, r) = (x as isize, y as isize); - - if Edges::get_at(r, c, rows, cols, data) == 0 { - continue; - } - - let neighbors = [ - Edges::get_at(r + 1, c, rows, cols, data), - Edges::get_at(r - 1, c, rows, cols, data), - Edges::get_at(r, c + 1, rows, cols, data), - Edges::get_at(r, c - 1, rows, cols, data), - Edges::get_at(r + 1, c + 1, rows, cols, data), - Edges::get_at(r - 1, c - 1, rows, cols, data), - Edges::get_at(r + 1, c - 1, rows, cols, data), - Edges::get_at(r - 1, c + 1, rows, cols, data), - ]; - - let n: usize = neighbors.iter().sum(); - let surrounded = neighbors.len(); - if n < surrounded { - edge_points.push(Vec2::new(x, y)); - } - } - - Edges::points_to_drawing_order(&edge_points, translate, rows, cols) - } - - /// Takes a collection of coordinates and attempts to sort them according to drawing order - /// - /// Pixel sorted so that the distance to previous and next is 1. When there is no pixel left - /// with distance 1, another group is created and sorted the same way. - fn points_to_drawing_order( - points: &[Vec2], - translate: bool, - rows: usize, - cols: usize, - ) -> Vec> { - let mut groups: Vec> = vec![]; - let mut in_drawing_order: Vec = vec![]; - let mut drawn_points_with_counts: MashMap<(OrderedFloat, OrderedFloat), ()> = - MashMap::new(); - let mut drawn_points: HashSet<(OrderedFloat, OrderedFloat)> = HashSet::new(); - let hashable = |v: Vec2| (OrderedFloat(v.x), OrderedFloat(v.y)); - if points.is_empty() { - return groups; - } - - let mut current = points[0]; - let mut start = current; - in_drawing_order.push(current); - drawn_points_with_counts.insert(hashable(current), ()); - drawn_points.insert(hashable(current)); - - while drawn_points.len() < points.len() { - let neighbors = &points - .iter() - .filter(|p| Edges::distance(current, **p) == 1.0) - .collect::>(); - - if let Some(p) = neighbors - .iter() - .min_by_key(|n| drawn_points_with_counts.get_iter(&hashable(***n)).count()) - { - current = **p; - in_drawing_order.push(**p); - drawn_points_with_counts.insert(hashable(**p), ()); - drawn_points.insert(hashable(**p)); - } - - // we've traversed and backtracked and we're back at the start without reaching the end of the points - // so we need to start a collecting the points of a new unconnected object - if current == start { - // remove the connecting coordinate - _ = in_drawing_order.pop(); - groups.push(in_drawing_order.clone()); - in_drawing_order.clear(); - drawn_points_with_counts.clear(); - - if let Some(c) = points - .iter() - .find(|p| !drawn_points.contains(&hashable(**p))) - { - in_drawing_order.push(*c); - drawn_points_with_counts.insert(hashable(*c), ()); - drawn_points.insert(hashable(*c)); - current = *c; - start = current; - } else { - break; - } - } - } - - groups.push(in_drawing_order.clone()); - - if translate { - groups = groups - .into_iter() - .map(|p| Edges::translate_vec(p, rows, cols)) - .collect(); - } - - groups - } - - /// conceptual helper, access a 1D vector like it's a 2D vector - fn get_xy(idx: usize, offset: usize) -> (f32, f32) { - let quot = idx / offset; - let rem = idx % offset; - (quot as f32, rem as f32) - } - - /// pythagoras, distance between two points - fn distance(a: Vec2, b: Vec2) -> f32 { - // d=√((x2-x1)²+(y2-y1)²) - ((a.x - b.x).abs().powi(2) + (a.y - b.y).abs().powi(2)).sqrt() - } - - /// get zero or non-zero pixel the value at given coordinate - fn get_at(row: isize, col: isize, rows: usize, cols: usize, data: &[usize]) -> usize { - if row < 0 || col < 0 || row >= rows as isize || col >= cols as isize { - 0 - } else { - let idx = row as usize * cols + col as usize; - data.get(idx) - .map(|i| if *i == 0 { 0 } else { 1 }) - .unwrap_or_else(|| 0) - } - } - - /// translate point in positive x,y to either side of (0,0) - fn xy_translate(p: Vec2, rows: usize, cols: usize) -> Vec2 { - Vec2::new( - p.x - (cols as f32 / 2. - 1.0), - -p.y + (rows as f32 / 2. - 1.0), - ) - } - - /// Translate vector of points in positive x,y to either side of (0,0) - pub fn translate_vec(v: Vec, rows: usize, cols: usize) -> Vec { - v.into_iter() - .map(|p| Edges::xy_translate(p, rows, cols)) - .collect() - } - - fn width(&self) -> usize { - match self { - Edges::DynamicImage(i) => i.width() as usize, - #[cfg(feature = "bevy")] - Edges::BevyImage(i) => i.size().x as usize, - } - } - - fn height(&self) -> usize { - match self { - Edges::DynamicImage(i) => i.height() as usize, - #[cfg(feature = "bevy")] - Edges::BevyImage(i) => i.size().y as usize, - } - } - - fn bytes(&self) -> &[u8] { - match self { - Edges::DynamicImage(i) => i.as_bytes(), - #[cfg(feature = "bevy")] - Edges::BevyImage(i) => &i.data, - } - } -} - -#[cfg(feature = "bevy")] -impl From for Edges { - fn from(i: bevy::prelude::Image) -> Edges { - Edges::BevyImage(i) - } -} - -#[cfg(feature = "bevy")] -impl From<&bevy::prelude::Image> for Edges { - fn from(i: &bevy::prelude::Image) -> Edges { - Edges::BevyImage(i.clone()) - } -} - -impl From for Edges { - fn from(i: image::DynamicImage) -> Edges { - Edges::DynamicImage(i) - } -} - -impl From<&image::DynamicImage> for Edges { - fn from(i: &image::DynamicImage) -> Edges { - Edges::DynamicImage(i.clone()) - } -} - -impl fmt::Debug for Edges { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - #[derive(Debug)] - #[allow(dead_code)] - struct EdgesDisplay { - raw: Vec>, - translated: Vec>, - } - - let edges_display = EdgesDisplay { - raw: self.image_edges(false), - translated: self.image_edges(true), - }; - write!(f, "{:#?}", edges_display) - } -} - -#[cfg(feature = "bevy")] -#[cfg(test)] -mod tests { - use crate::Edges; - use bevy::render::texture::{Image, ImageType}; - use std::path::Path; - - #[test] - fn same_image_same_edges() { - let dynamic_image = image::open(Path::new("assets/car.png")).unwrap(); - let dynamic_edges = Edges::from(dynamic_image); - - let bevy_image = Image::from_buffer( - include_bytes!("../assets/car.png"), // buffer - ImageType::Extension("png"), - Default::default(), - true, // - Default::default(), - Default::default(), - ) - .unwrap(); - let bevy_edges = Edges::from(bevy_image); - - assert_eq!( - dynamic_edges.single_image_edge_raw(), - bevy_edges.single_image_edge_raw() - ); - assert_eq!( - dynamic_edges.single_image_edge_translated(), - bevy_edges.single_image_edge_translated() - ); - } - - #[test] - fn same_images_same_edges() { - let dynamic_image = image::open(Path::new("assets/boulders.png")).unwrap(); - let dynamic_edges = Edges::from(dynamic_image); - - let bevy_image = Image::from_buffer( - include_bytes!("../assets/boulders.png"), // buffer - ImageType::Extension("png"), - Default::default(), - true, // - Default::default(), - Default::default(), - ) - .unwrap(); - let bevy_edges = Edges::from(bevy_image); - - assert_eq!( - dynamic_edges.multi_image_edges_raw(), - bevy_edges.multi_image_edges_raw() - ); - assert_eq!( - dynamic_edges.multi_image_edge_translated(), - bevy_edges.multi_image_edge_translated() - ); - } -} diff --git a/src/error.rs b/src/error.rs deleted file mode 100644 index 43779ae..0000000 --- a/src/error.rs +++ /dev/null @@ -1,2 +0,0 @@ -#[derive(thiserror::Error, Debug)] -pub enum Error {} diff --git a/src/lib.rs b/src/lib.rs index 0771ee0..6abfdca 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,193 @@ #![doc = include_str!("../README.md")] -mod edges; -mod error; +use crate::bin_image::BinImage; +#[cfg(feature = "bevy")] +pub use bevy_math::prelude::{UVec2, Vec2}; +#[cfg(all(not(feature = "bevy"), feature = "glam-latest"))] +pub use glam::{UVec2, Vec2}; +use rayon::prelude::*; +use std::fmt; +use utils::{handle_neighbors, in_polygon, Direction}; -pub use edges::*; -pub use glam::Vec2; +mod bin_image; +#[cfg(feature = "bevy")] +#[cfg(test)] +mod tests; +mod utils; + +pub struct Edges { + image: BinImage, +} + +impl Edges { + #[must_use] + pub fn new(height: u32, width: u32, data: &[u8]) -> Self { + Self { + image: BinImage::new(height, width, data), + } + } + + /// If there's only one sprite / object in the image, this returns just one, with + /// coordinates translated to either side of (0, 0) + #[must_use] + pub fn single_image_edge_translated(&self) -> Vec { + self.image_edges(true).into_par_iter().flatten().collect() + } + + /// If there's only one sprite / object in the image, this returns just one, with + /// coordinates left alone and all in positive x and y + #[must_use] + pub fn single_image_edge_raw(&self) -> Vec { + self.image_edges(false).into_par_iter().flatten().collect() + } + + /// If there's more than one sprite / object in the image, this returns all it finds, with + /// coordinates translated to either side of (0, 0) + #[must_use] + pub fn multi_image_edge_translated(&self) -> Vec> { + self.image_edges(true) + } + + /// If there's more than one sprite / object in the image, this returns all it finds, with + /// coordinates left alone and all in positive x and y + #[must_use] + pub fn multi_image_edges_raw(&self) -> Vec> { + self.image_edges(false) + } + + /// Takes `Edges` and a boolean to indicate whether to translate + /// the points you get back to either side of (0, 0) instead of everything in positive x and y. + #[must_use] + pub fn image_edges(&self, translate: bool) -> Vec> { + let image = &self.image; + // Marching squares adjacent, walks all the pixels in the provided data and keeps track of + // any that have at least one transparent / zero value neighbor then, while sorting into drawing + // order, groups them into sets of connected pixels + let corners: Vec<_> = (0..image.height() * image.width()) + .into_par_iter() + .map(|i| UVec2::new(i / image.height(), i % image.height())) + .filter(|p| image.get(*p) && image.is_corner(*p)) + .collect(); + + let objects: Vec<_> = self + .collect_objects(&corners) + .into_par_iter() + .map(|object| object.into_par_iter().map(|p| p.as_vec2()).collect()) + .collect(); + if translate { + objects + .into_par_iter() + .map(|object| self.translate(object)) + .collect() + } else { + objects + } + } + + fn collect_objects(&self, corners: &[UVec2]) -> Vec> { + if corners.is_empty() { + return Vec::new(); + } + + let mut objects: Vec> = Vec::new(); + + while let Some(start) = corners.iter().find(|point| { + objects + .par_iter() + .all(|object| !(object.contains(point) || in_polygon(**point, object))) + }) { + let mut current = *start; + let mut group: Vec = Vec::new(); + group.push(current); + let object = loop { + let (last, neighbors) = (*group.last().unwrap(), self.image.get_neighbors(current)); + if last != current { + group.push(current); + } + match handle_neighbors(current, last, neighbors) { + Direction::North => current.y += 1, + Direction::South => current.y -= 1, + Direction::East => current.x += 1, + Direction::West => current.x -= 1, + Direction::Northeast => { + current.x += 1; + current.y += 1; + } + Direction::Northwest => { + current.x -= 1; + current.y += 1; + } + Direction::Southeast => { + current.x += 1; + current.y -= 1; + } + Direction::Southwest => { + current.x -= 1; + current.y -= 1; + } + } + if current == *start { + break group; + } + }; + objects.push(object); + } + + objects + } + + /// Translates an `Vec` of points in positive (x, y) coordinates to a coordinate system centered at (0, 0). + /// + /// # Arguments + /// + /// * `v` - An `Vec` of `Vec2` points to translate. + /// + /// # Returns + /// + /// A vector of `Vec2` representing the translated coordinates. + #[must_use] + pub fn translate(&self, v: Vec) -> Vec { + self.image.translate(v) + } +} + +#[cfg(feature = "bevy")] +impl From for Edges { + fn from(i: bevy_render::prelude::Image) -> Edges { + Self::new(i.height(), i.width(), &i.data) + } +} + +impl From for Edges { + fn from(i: image::DynamicImage) -> Edges { + Self::new(i.height(), i.width(), i.as_bytes()) + } +} + +#[cfg(feature = "bevy")] +impl From<&bevy_render::prelude::Image> for Edges { + fn from(i: &bevy_render::prelude::Image) -> Edges { + Self::new(i.height(), i.width(), &i.data) + } +} + +impl From<&image::DynamicImage> for Edges { + fn from(i: &image::DynamicImage) -> Edges { + Self::new(i.height(), i.width(), i.as_bytes()) + } +} + +impl fmt::Debug for Edges { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Edges {{{}\n}}", + format!( + "\nraw: {:#?},\ntranslated: {:#?}", + self.image_edges(false), + self.image_edges(true), + ) + .replace('\n', "\n "), + ) + } +} diff --git a/src/tests.rs b/src/tests.rs new file mode 100644 index 0000000..4611555 --- /dev/null +++ b/src/tests.rs @@ -0,0 +1,58 @@ +use crate::Edges; +use bevy_render::{ + render_asset::RenderAssetUsages, + texture::{CompressedImageFormats, Image, ImageSampler, ImageType}, +}; +use std::path::Path; + +#[test] +fn same_image_same_edges() { + let dynamic_image = image::open(Path::new("assets/car.png")).unwrap(); + let dynamic_edges = Edges::from(dynamic_image); + + let bevy_image = Image::from_buffer( + include_bytes!("../assets/car.png"), // buffer + ImageType::Extension("png"), + CompressedImageFormats::default(), + true, // + ImageSampler::default(), + RenderAssetUsages::default(), + ) + .unwrap(); + let bevy_edges = Edges::from(bevy_image); + + assert_eq!( + dynamic_edges.single_image_edge_raw(), + bevy_edges.single_image_edge_raw() + ); + assert_eq!( + dynamic_edges.single_image_edge_translated(), + bevy_edges.single_image_edge_translated() + ); +} + +#[test] +fn same_images_same_edges() { + let dynamic_image = image::open(Path::new("assets/boulders.png")).unwrap(); + let dynamic_edges = Edges::from(dynamic_image); + + let bevy_image = Image::from_buffer( + include_bytes!("../assets/boulders.png"), // buffer + ImageType::Extension("png"), + CompressedImageFormats::default(), + true, // + ImageSampler::default(), + RenderAssetUsages::default(), + ) + .unwrap(); + let bevy_edges = Edges::from(bevy_image); + + assert_eq!( + dynamic_edges.multi_image_edges_raw(), + bevy_edges.multi_image_edges_raw() + ); + assert_eq!( + dynamic_edges.multi_image_edge_translated(), + bevy_edges.multi_image_edge_translated() + ); +} diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..c67a4f2 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,315 @@ +use crate::UVec2; +use std::cmp::Ordering::{Equal, Greater, Less}; + +// Get the bounding box of the polygon +fn bounding_box(polygon: &[UVec2]) -> Option<(UVec2, UVec2)> { + polygon + .iter() + .copied() + .zip(polygon.iter().copied()) + .reduce(|(min, max), (a, b)| (min.min(a), max.max(b))) +} + +pub fn in_polygon(point: UVec2, polygon: &[UVec2]) -> bool { + if let Some((min, max)) = bounding_box(polygon) { + // Check if the point is within the bounding box + if point.x < min.x || point.x > max.x || point.y < min.y || point.y > max.y { + return false; // Early exit if outside the bounding box + } + } + + let mut is_inside = false; + + for i in 0..polygon.len() { + let (p1, p2) = (polygon[i], polygon[(i + 1) % polygon.len()]); + let (min, max) = (p1.min(p2), p1.max(p2)); + let (dy, dx) = (max.y - min.y, max.x - min.x); + + if min.y <= point.y && point.y < max.y && point.x <= min.x + dx * (point.y - min.y) / dy { + if min.x <= point.x && point.x < max.x { + return true; + } + is_inside = !is_inside; + } + } + is_inside +} + +pub fn is_corner(neighbors: u8) -> bool { + !matches!( + neighbors, + 255 + | 239 + | 238 + | 235 + | 234 + | 223 + | 221 + | 215 + | 213 + | 188..=207 + | 127 + | 123 + | 119 + | 115 + | 48..=63 + | 9 + | 6 + | 0 + ) +} + +pub enum Direction { + North, + South, + East, + West, + + Northeast, + Northwest, + Southeast, + Southwest, +} + +#[allow(clippy::too_many_lines)] +pub fn handle_neighbors(current: UVec2, last: UVec2, neighbors: u8) -> Direction { + use Direction::{East, North, Northeast, Northwest, South, Southeast, Southwest, West}; + match neighbors { + 0 | 255 => unreachable!(), + 188..=191 | 127 | 123 | 119 | 115 | 48..=63 => match last.x.cmp(¤t.x) { + Greater => West, + Equal => unreachable!(), + Less => East, + }, + 239 | 238 | 235 | 234 | 223 | 221 | 215 | 213 | 192..=207 => match last.y.cmp(¤t.y) { + Greater => South, + Equal => unreachable!(), + Less => North, + }, + 6 => match last.x.cmp(¤t.x) { + Greater => Northwest, + Equal => unreachable!(), + Less => Southeast, + }, + 9 => match last.x.cmp(¤t.x) { + Greater => Southwest, + Equal => unreachable!(), + Less => Northeast, + }, + + 140 | 136 | 132 | 128 => North, + 99 | 98 | 64..=67 => South, + 42 | 40 | 34 | 32 => East, + 21 | 20 | 17 | 16 => West, + 8 => Northeast, + 4 => Northwest, + 2 => Southeast, + 1 => Southwest, + 247 | 245 | 174 | 172 | 170 | 168 | 166 | 164 | 162 | 160 => match last.x.cmp(¤t.x) { + Greater => North, + Equal => East, + Less => unreachable!(), + }, + 253 | 104..=107 | 97 | 96 => match last.x.cmp(¤t.x) { + Greater => South, + Equal => East, + Less => unreachable!(), + }, + 251 | 157 | 156 | 153 | 152 | 149 | 148 | 145 | 144 => match last.x.cmp(¤t.x) { + Greater => unreachable!(), + Equal => West, + Less => North, + }, + 254 | 250 | 80..=87 => match last.x.cmp(¤t.x) { + Greater => unreachable!(), + Equal => West, + Less => South, + }, + 180..=182 => match last.x.cmp(¤t.x) { + Greater => North, + Equal => unreachable!(), + Less => East, + }, + 186 | 184 => match last.x.cmp(¤t.x) { + Greater => unreachable!(), + Equal => West, + Less => East, + }, + 231 | 226 => match last.x.cmp(¤t.x) { + Greater => North, + Equal => South, + Less => unreachable!(), + }, + 236 | 232 => match last.y.cmp(¤t.y) { + Greater => South, + Equal => unreachable!(), + Less => East, + }, + 249 | 248 | 246 | 244 | 240..=242 => { + match (last.x.cmp(¤t.x), last.y.cmp(¤t.y)) { + (Less, Equal) => South, + (Equal, Less) => East, + (Greater, Equal) => North, + (Equal, Greater) => West, + _ => unreachable!(), + } + } + + 110 | 103 | 102 => match last.x.cmp(¤t.x) { + Greater => Northwest, + Equal => unreachable!(), + Less => South, + }, + 111 | 109 | 108 | 101 | 100 => match last.x.cmp(¤t.x) { + Greater => Northwest, + Equal => East, + Less => South, + }, + 46 | 44 | 38 | 36 => match last.x.cmp(¤t.x) { + Greater => Northwest, + Equal => unreachable!(), + Less => East, + }, + 43 | 41 | 35 | 33 => match last.x.cmp(¤t.x) { + Greater => Southwest, + Equal => unreachable!(), + Less => East, + }, + 175 | 173 | 171 | 169 | 167 | 165 | 163 | 161 => match last.x.cmp(¤t.x) { + Greater => North, + Equal => Southwest, + Less => East, + }, + 142 | 138 | 134 | 130 => match last.x.cmp(¤t.x) { + Greater => North, + Equal => Southeast, + Less => unreachable!(), + }, + 95 | 93 | 91 | 89 => match last.x.cmp(¤t.x) { + Greater => West, + Equal => Northeast, + Less => unreachable!(), + }, + 141 | 137 | 133 | 129 => match last.x.cmp(¤t.x) { + Greater => unreachable!(), + Equal => Southwest, + Less => North, + }, + 94 | 92 | 90 | 88 => match last.x.cmp(¤t.x) { + Greater => West, + Equal => Northeast, + Less => South, + }, + 23 | 22 | 19 | 18 => match last.x.cmp(¤t.x) { + Greater => West, + Equal => unreachable!(), + Less => Southeast, + }, + 159 | 158 | 155 | 154 | 151 | 150 | 147 | 146 => match last.x.cmp(¤t.x) { + Greater => North, + Equal => West, + Less => Southeast, + }, + 29 | 28 | 25 | 24 => match last.x.cmp(¤t.x) { + Greater => West, + Equal => unreachable!(), + Less => Northeast, + }, + 72..=75 => match last.x.cmp(¤t.x) { + Greater => South, + Equal => Northeast, + Less => unreachable!(), + }, + 68..=71 => match last.x.cmp(¤t.x) { + Greater => unreachable!(), + Equal => Northwest, + Less => South, + }, + + 31 | 30 | 27 | 26 => match last.y.cmp(¤t.y) { + Greater => West, + Equal => Southeast, + Less => Northeast, + }, + 76..=79 => match last.x.cmp(¤t.x) { + Greater => Northwest, + Equal => Northeast, + Less => South, + }, + 47 | 45 | 39 | 37 => match last.y.cmp(¤t.y) { + Greater => Southwest, + Equal => Northwest, + Less => East, + }, + 143 | 139 | 135 | 131 => match last.x.cmp(¤t.x) { + Greater => North, + Equal => Southwest, + Less => Southeast, + }, + 10 => match last.y.cmp(¤t.y) { + Greater | Equal => Southeast, + Less => Northeast, + }, + 12 => match last.x.cmp(¤t.x) { + Greater => Northwest, + Equal => unreachable!(), + Less => Northeast, + }, + 3 => match last.x.cmp(¤t.x) { + Greater => Southwest, + Equal => unreachable!(), + Less => Southeast, + }, + 5 => match last.x.cmp(¤t.x) { + Greater => Southwest, + Equal => unreachable!(), + Less => Northwest, + }, + 15 => match (last.x.cmp(¤t.x), last.y.cmp(¤t.y)) { + (Greater, Less) => Northeast, + (Greater, Greater) => Northwest, + (Less, Less) => Southeast, + (Less, Greater) => Southwest, + _ => unreachable!(), + }, + + 252 | 124..=126 | 120..=122 | 116..=118 | 112..=114 => match last.x.cmp(¤t.x) { + Greater => West, + Equal => East, + Less => South, + }, + 243 | 187 | 185 | 183 | 176..=179 => match last.x.cmp(¤t.x) { + Greater => North, + Equal => West, + Less => East, + }, + 222 | 216..=220 | 214 | 208..=212 => match last.y.cmp(¤t.y) { + Greater => West, + Equal => South, + Less => North, + }, + 237 | 233 | 227..=230 | 225 | 224 => match last.y.cmp(¤t.y) { + Greater => South, + Equal => North, + Less => East, + }, + 7 => match (last.x.cmp(¤t.x), last.y.cmp(¤t.y)) { + (Greater, Less) => Northwest, + (Less, Less) => Southeast, + (Less, Greater) => Southwest, + _ => unreachable!(), + }, + 14 | 11 => match (last.x.cmp(¤t.x), last.y.cmp(¤t.y)) { + (Greater, Less) => Northeast, + (Less, Less) => Southeast, + (Greater, Greater) => Southwest, + _ => unreachable!(), + }, + 13 => match (last.x.cmp(¤t.x), last.y.cmp(¤t.y)) { + (Less, Greater) => Northeast, + (Less, Less) => Southeast, + (Greater, Greater) => Southwest, + _ => unreachable!(), + }, + } +}