diff --git a/examples/overlay_editor/Cargo.toml b/examples/overlay_editor/Cargo.toml index ef6cd6e3..fcb98aef 100644 --- a/examples/overlay_editor/Cargo.toml +++ b/examples/overlay_editor/Cargo.toml @@ -26,8 +26,8 @@ log = "0.4.22" console_log = "^1.0.0" console_error_panic_hook = "^0" -#i_mesh = "^0.4.0" -#i_triangle = { version = "^0.35.0", features = ["serde"] } +#i_mesh = "^0.5.0" +#i_triangle = { version = "^0.44.0", features = ["serde"] } i_triangle = { path = "../../../../iShape/iTriangle/iTriangle", default-features = true, features = ["serde"] } i_mesh = { path = "../../../../iShape/iMesh/iMesh" } diff --git a/examples/overlay_editor/src/app/main.rs b/examples/overlay_editor/src/app/main.rs index f48296db..8d57c76e 100644 --- a/examples/overlay_editor/src/app/main.rs +++ b/examples/overlay_editor/src/app/main.rs @@ -6,6 +6,7 @@ use crate::app::string::content::StringMessage; use crate::app::string::content::StringState; use crate::app::stroke::content::StrokeMessage; use crate::app::stroke::content::StrokeState; +use crate::app::variable_stroke::content::{VariableStrokeMessage, VariableStrokeState}; use iced::keyboard::key::Named; use iced::keyboard::Key; use iced::widget::{rule, Space}; @@ -29,6 +30,7 @@ pub(super) struct MainState { pub(super) boolean: BooleanState, pub(super) string: StringState, pub(super) stroke: StrokeState, + pub(super) variable_stroke: VariableStrokeState, pub(super) outline: OutlineState, } @@ -37,6 +39,7 @@ pub(crate) enum MainAction { Boolean, String, Stroke, + VariableStroke, Outline, } @@ -46,6 +49,7 @@ impl MainAction { MainAction::Boolean => "Boolean", MainAction::String => "String", MainAction::Stroke => "Stroke", + MainAction::VariableStroke => "Variable Stroke", MainAction::Outline => "Outline", } } @@ -62,6 +66,7 @@ pub(crate) enum AppMessage { Bool(BooleanMessage), String(StringMessage), Stroke(StrokeMessage), + VariableStroke(VariableStrokeMessage), Outline(OutlineMessage), NextTest, PrevTest, @@ -74,6 +79,7 @@ impl EditorApp { MainAction::Boolean, MainAction::String, MainAction::Stroke, + MainAction::VariableStroke, MainAction::Outline, ], state: MainState { @@ -81,6 +87,7 @@ impl EditorApp { boolean: BooleanState::new(&mut app_resource.boolean), string: StringState::new(&mut app_resource.string), stroke: StrokeState::new(&mut app_resource.stroke), + variable_stroke: VariableStrokeState::new(&mut app_resource.variable_stroke), outline: OutlineState::new(&mut app_resource.outline), }, app_resource, @@ -96,17 +103,20 @@ impl EditorApp { AppMessage::Bool(msg) => self.boolean_update(msg), AppMessage::String(msg) => self.string_update(msg), AppMessage::Stroke(msg) => self.stroke_update(msg), + AppMessage::VariableStroke(msg) => self.variable_stroke_update(msg), AppMessage::Outline(msg) => self.outline_update(msg), AppMessage::NextTest => match self.state.selected_action { MainAction::Boolean => self.boolean_next_test(), MainAction::String => self.string_next_test(), MainAction::Stroke => self.stroke_next_test(), + MainAction::VariableStroke => self.variable_stroke_next_test(), MainAction::Outline => self.outline_next_test(), }, AppMessage::PrevTest => match self.state.selected_action { MainAction::Boolean => self.boolean_prev_test(), MainAction::String => self.string_prev_test(), MainAction::Stroke => self.stroke_prev_test(), + MainAction::VariableStroke => self.variable_stroke_prev_test(), MainAction::Outline => self.outline_prev_test(), }, } @@ -133,6 +143,7 @@ impl EditorApp { MainAction::Boolean => self.boolean_init(), MainAction::String => self.string_init(), MainAction::Stroke => self.stroke_init(), + MainAction::VariableStroke => self.variable_stroke_init(), MainAction::Outline => self.outline_init(), } } @@ -157,6 +168,9 @@ impl EditorApp { MainAction::Stroke => content .push(rule::vertical(1).style(style_separator)) .push(self.stroke_content()), + MainAction::VariableStroke => content + .push(rule::vertical(1).style(style_separator)) + .push(self.variable_stroke_content()), MainAction::Outline => content .push(rule::vertical(1).style(style_separator)) .push(self.outline_content()), diff --git a/examples/overlay_editor/src/app/mod.rs b/examples/overlay_editor/src/app/mod.rs index 18af7d16..6f50052c 100644 --- a/examples/overlay_editor/src/app/mod.rs +++ b/examples/overlay_editor/src/app/mod.rs @@ -6,3 +6,4 @@ mod outline; mod solver_option; mod string; mod stroke; +mod variable_stroke; diff --git a/examples/overlay_editor/src/app/variable_stroke/content.rs b/examples/overlay_editor/src/app/variable_stroke/content.rs new file mode 100644 index 00000000..aead8718 --- /dev/null +++ b/examples/overlay_editor/src/app/variable_stroke/content.rs @@ -0,0 +1,307 @@ +use crate::app::design; +use crate::app::main::{AppMessage, EditorApp}; +use crate::app::variable_stroke::workspace::WorkspaceState; +use crate::data::variable_stroke::VariableStrokeResource; +use crate::geom::camera::Camera; +use crate::point_editor::point::PathsToEditorPoints; +use crate::point_editor::widget::PointEditUpdate; +use i_triangle::i_overlay::i_float::int::point::IntPoint; +use i_triangle::i_overlay::i_float::int::rect::IntRect; +use i_triangle::i_overlay::mesh::variable_stroke::offset::VariableStrokeOffset; +use i_triangle::i_overlay::mesh::variable_stroke::{StrokeVertex, VariableStrokeStyle}; +use iced::widget::{scrollable, Button, Column, Container, Row, Space, Text}; +use iced::{Alignment, Length, Padding, Size, Vector}; +use std::collections::HashMap; +use std::fmt::Write; + +#[derive(Debug, Clone)] +pub(crate) struct VariableStrokePoint { + pub(crate) pos: IntPoint, + pub(crate) width: f32, +} + +pub(crate) struct VariableStrokeState { + pub(crate) test: usize, + pub(crate) width_scale: f32, + pub(crate) round_angle: u8, + pub(crate) workspace: WorkspaceState, + pub(crate) size: Size, + pub(crate) cameras: HashMap, +} + +#[derive(Debug, Clone)] +pub(crate) enum VariableStrokeMessage { + TestSelected(usize), + WidthScaleUpdated(f32), + RoundAngleUpdated(u8), + PointEdited(PointEditUpdate), + WorkspaceSized(Size), + WorkspaceZoomed(Camera), + WorkspaceDragged(Vector), +} + +impl EditorApp { + fn variable_stroke_sidebar(&self) -> Column<'_, AppMessage> { + let count = self.app_resource.variable_stroke.count; + let mut column = + Column::new().push(Space::new().width(Length::Fill).height(Length::Fixed(2.0))); + for index in 0..count { + let is_selected = self.state.variable_stroke.test == index; + column = column.push( + Container::new( + Button::new( + Text::new(format!("test_{}", index)) + .style(if is_selected { + design::style_sidebar_text_selected + } else { + design::style_sidebar_text + }) + .size(14), + ) + .width(Length::Fill) + .on_press(AppMessage::VariableStroke( + VariableStrokeMessage::TestSelected(index), + )) + .style(if is_selected { + design::style_sidebar_button_selected + } else { + design::style_sidebar_button + }), + ) + .padding(self.design.action_padding()), + ); + } + + column + } + + pub(crate) fn variable_stroke_content(&self) -> Row<'_, AppMessage> { + Row::new() + .push( + scrollable( + Container::new(self.variable_stroke_sidebar()) + .width(Length::Fixed(180.0)) + .height(Length::Shrink) + .align_x(Alignment::Start) + .padding(Padding::new(0.0).right(8)) + .style(design::style_sidebar_background), + ) + .direction(scrollable::Direction::Vertical( + scrollable::Scrollbar::new() + .width(4) + .margin(0) + .scroller_width(4) + .anchor(scrollable::Anchor::Start), + )), + ) + .push(self.variable_stroke_workspace()) + } + + pub(crate) fn variable_stroke_update(&mut self, message: VariableStrokeMessage) { + match message { + VariableStrokeMessage::TestSelected(index) => self.variable_stroke_set_test(index), + VariableStrokeMessage::WidthScaleUpdated(value) => { + self.variable_stroke_update_width_scale(value) + } + VariableStrokeMessage::RoundAngleUpdated(value) => { + self.variable_stroke_update_round_angle(value) + } + VariableStrokeMessage::PointEdited(update) => self.variable_stroke_update_point(update), + VariableStrokeMessage::WorkspaceSized(size) => self.variable_stroke_update_size(size), + VariableStrokeMessage::WorkspaceZoomed(zoom) => self.variable_stroke_update_zoom(zoom), + VariableStrokeMessage::WorkspaceDragged(drag) => self.variable_stroke_update_drag(drag), + } + } + + fn variable_stroke_set_test(&mut self, index: usize) { + self.state + .variable_stroke + .set_test(index, &mut self.app_resource.variable_stroke); + self.state.variable_stroke.update_solution(); + } + + pub(crate) fn variable_stroke_init(&mut self) { + self.variable_stroke_set_test(self.state.variable_stroke.test); + } + + pub(crate) fn variable_stroke_next_test(&mut self) { + let next_test = self.state.variable_stroke.test + 1; + if next_test < self.app_resource.variable_stroke.count { + self.variable_stroke_set_test(next_test); + } + } + + pub(crate) fn variable_stroke_prev_test(&mut self) { + let test = self.state.variable_stroke.test; + if test >= 1 { + self.variable_stroke_set_test(test - 1); + } + } + + fn variable_stroke_update_size(&mut self, size: Size) { + self.state.variable_stroke.size = size; + let points = &self.state.variable_stroke.workspace.points; + if self.state.variable_stroke.workspace.camera.is_empty() && !points.is_empty() { + let rect = IntRect::with_iter(points.iter().map(|p| &p.pos)) + .unwrap_or(IntRect::new(-10_000, 10_000, -10_000, 10_000)); + let camera = Camera::new(rect, size); + self.state.variable_stroke.workspace.camera = camera; + } else { + self.state.variable_stroke.workspace.camera.size = size; + } + } + + fn variable_stroke_update_width_scale(&mut self, width_scale: f32) { + self.state.variable_stroke.width_scale = width_scale; + self.state.variable_stroke.update_solution(); + } + + fn variable_stroke_update_round_angle(&mut self, value: u8) { + self.state.variable_stroke.round_angle = value; + self.state.variable_stroke.update_solution(); + } +} + +impl VariableStrokeState { + pub(crate) fn new(resource: &mut VariableStrokeResource) -> Self { + let mut state = VariableStrokeState { + test: usize::MAX, + width_scale: 1.0, + round_angle: 12, + workspace: Default::default(), + cameras: HashMap::with_capacity(resource.count), + size: Size::ZERO, + }; + + state.set_test(0, resource); + state.update_solution(); + state + } + + fn set_test(&mut self, index: usize, resource: &mut VariableStrokeResource) { + let Some(test) = resource.load(index) else { + return; + }; + + self.workspace.scale = test.scale; + self.cameras.insert(self.test, self.workspace.camera); + + let editor_points = &mut self.workspace.points; + editor_points.clear(); + + let mut variable_input = Vec::with_capacity(test.stroke.len()); + let mut centerline_input = Vec::with_capacity(test.stroke.len()); + for path in test.stroke.iter() { + let mut variable_path = Vec::with_capacity(path.len()); + let mut centerline_path = Vec::with_capacity(path.len()); + for vertex in path.iter() { + let x = (test.scale * vertex.point[0]) as i32; + let y = (test.scale * vertex.point[1]) as i32; + let pos = IntPoint::new(x, y); + variable_path.push(VariableStrokePoint { + pos, + width: vertex.width, + }); + centerline_path.push(pos); + } + variable_input.push(variable_path); + centerline_input.push(centerline_path); + } + + self.workspace.variable_input = variable_input; + self.workspace.centerline_input = centerline_input; + self.workspace + .centerline_input + .feed_edit_points(0, editor_points); + + let mut camera = *self.cameras.get(&index).unwrap_or(&Camera::empty()); + if camera.is_empty() && self.size.width > 0.001 { + let rect = IntRect::with_iter(editor_points.iter().map(|p| &p.pos)) + .unwrap_or(IntRect::new(-10_000, 10_000, -10_000, 10_000)); + camera = Camera::new(rect, self.size); + } + + self.workspace.camera = camera; + self.test = index; + } + + fn update_solution(&mut self) { + let scale = 1.0 / self.workspace.scale; + let mut float_paths = Vec::with_capacity(self.workspace.variable_input.len()); + for path in self.workspace.variable_input.iter() { + let mut float_path = Vec::with_capacity(path.len()); + for p in path.iter() { + let x = scale * p.pos.x as f32; + let y = scale * p.pos.y as f32; + let width = self.width_scale * p.width; + float_path.push(StrokeVertex::new([x, y], width)); + } + float_paths.push(float_path); + } + + let round_angle = 0.015 * self.round_angle as f32; + self.print_repro(&float_paths, round_angle); + + let style = VariableStrokeStyle::new().round_angle(round_angle); + + let float_shapes = float_paths.variable_stroke(style); + + let scale = self.workspace.scale; + let mut int_paths = Vec::with_capacity(float_shapes.len()); + for float_shape in float_shapes.iter() { + for float_path in float_shape.iter() { + let mut path = Vec::with_capacity(float_path.len()); + for p in float_path.iter() { + let x = (scale * p[0]) as i32; + let y = (scale * p[1]) as i32; + path.push(IntPoint::new(x, y)); + } + int_paths.push(path); + } + } + + self.workspace.stroke_output = int_paths + } + + fn print_repro(&self, paths: &[Vec>], round_angle: f32) { + let mut dump = String::new(); + let _ = writeln!( + dump, + "\n// Dynamic Width repro: test={} width_scale={:?}", + self.test, self.width_scale + ); + let _ = writeln!(dump, "let paths = vec!["); + for path in paths { + let _ = writeln!(dump, " vec!["); + for vertex in path { + let _ = writeln!( + dump, + " StrokeVertex::new([{:?}_f32, {:?}_f32], {:?}_f32),", + vertex.point[0], vertex.point[1], vertex.width + ); + } + let _ = writeln!(dump, " ],"); + } + let _ = writeln!(dump, "];"); + let _ = writeln!( + dump, + "let style = VariableStrokeStyle::new().round_angle({round_angle:?}_f32);" + ); + let _ = writeln!(dump, "let result = paths.variable_stroke(style);"); + + #[cfg(not(target_arch = "wasm32"))] + println!("{dump}"); + + #[cfg(target_arch = "wasm32")] + log::info!("{dump}"); + } + + pub(super) fn variable_stroke_update_point(&mut self, update: PointEditUpdate) { + self.workspace.points[update.index] = update.point.clone(); + let m_index = update.point.index; + self.workspace.centerline_input[m_index.path_index][m_index.point_index] = update.point.pos; + self.workspace.variable_input[m_index.path_index][m_index.point_index].pos = + update.point.pos; + self.update_solution(); + } +} diff --git a/examples/overlay_editor/src/app/variable_stroke/control.rs b/examples/overlay_editor/src/app/variable_stroke/control.rs new file mode 100644 index 00000000..43481197 --- /dev/null +++ b/examples/overlay_editor/src/app/variable_stroke/control.rs @@ -0,0 +1,60 @@ +use crate::app::main::{AppMessage, EditorApp}; +use crate::app::variable_stroke::content::VariableStrokeMessage; +use iced::widget::{slider, Column, Container, Row, Text}; +use iced::{Alignment, Length}; + +impl EditorApp { + pub(crate) fn variable_stroke_control(&self) -> Column<'_, AppMessage> { + let width_scale = Row::new() + .push(label("Width Scale:")) + .push( + Container::new( + slider( + 0.1f32..=3.0f32, + self.state.variable_stroke.width_scale, + on_update_width_scale, + ) + .step(0.01_f32), + ) + .width(160) + .height(Length::Fill) + .align_y(Alignment::Center), + ) + .height(Length::Fixed(40.0)); + + let round_angle = Row::new() + .push(label("Round Detail:")) + .push( + Container::new( + slider( + 1..=50, + self.state.variable_stroke.round_angle, + on_update_round_angle, + ) + .default(12) + .shift_step(5), + ) + .width(160) + .height(Length::Fill) + .align_y(Alignment::Center), + ) + .height(Length::Fixed(40.0)); + + Column::new().push(width_scale).push(round_angle) + } +} + +fn label(value: &str) -> Text<'_> { + Text::new(value) + .width(Length::Fixed(120.0)) + .height(Length::Fill) + .align_y(Alignment::Center) +} + +fn on_update_width_scale(value: f32) -> AppMessage { + AppMessage::VariableStroke(VariableStrokeMessage::WidthScaleUpdated(value)) +} + +fn on_update_round_angle(value: u8) -> AppMessage { + AppMessage::VariableStroke(VariableStrokeMessage::RoundAngleUpdated(value)) +} diff --git a/examples/overlay_editor/src/app/variable_stroke/mod.rs b/examples/overlay_editor/src/app/variable_stroke/mod.rs new file mode 100644 index 00000000..2ac0d54b --- /dev/null +++ b/examples/overlay_editor/src/app/variable_stroke/mod.rs @@ -0,0 +1,3 @@ +pub(crate) mod content; +mod control; +mod workspace; diff --git a/examples/overlay_editor/src/app/variable_stroke/workspace.rs b/examples/overlay_editor/src/app/variable_stroke/workspace.rs new file mode 100644 index 00000000..6335f44c --- /dev/null +++ b/examples/overlay_editor/src/app/variable_stroke/workspace.rs @@ -0,0 +1,135 @@ +use crate::app::design::{style_sheet_background, Design}; +use crate::app::main::{AppMessage, EditorApp}; +use crate::app::variable_stroke::content::{VariableStrokeMessage, VariableStrokePoint}; +use crate::draw::path::PathWidget; +use crate::draw::shape::ShapeWidget; +use crate::geom::camera::Camera; +use crate::point_editor::point::EditorPoint; +use crate::point_editor::widget::{PointEditUpdate, PointsEditorWidget}; +use crate::sheet::widget::SheetWidget; +use i_triangle::i_overlay::core::fill_rule::FillRule; +use i_triangle::i_overlay::i_shape::int::path::IntPaths; +use iced::widget::Container; +use iced::widget::Stack; +use iced::{Length, Padding, Size, Vector}; + +pub(crate) struct WorkspaceState { + pub(crate) camera: Camera, + pub(crate) scale: f32, + pub(crate) variable_input: Vec>, + pub(crate) centerline_input: IntPaths, + pub(crate) stroke_output: IntPaths, + pub(crate) points: Vec, +} + +impl EditorApp { + pub(crate) fn variable_stroke_workspace(&self) -> Container<'_, AppMessage> { + Container::new({ + let mut stack = Stack::new(); + stack = stack.push( + Container::new(SheetWidget::new( + self.state.variable_stroke.workspace.camera, + Design::negative_color().scale_alpha(0.5), + on_update_size, + on_update_zoom, + on_update_drag, + )) + .width(Length::Fill) + .height(Length::Fill), + ); + + if self.state.variable_stroke.workspace.camera.is_not_empty() { + let shapes = &self.state.variable_stroke.workspace.stroke_output; + if !shapes.is_empty() { + stack = stack.push( + Container::new(ShapeWidget::with_paths( + &self.state.variable_stroke.workspace.stroke_output, + self.state.variable_stroke.workspace.camera, + Some(FillRule::NonZero), + Some(Design::solution_color().scale_alpha(0.1)), + Some(Design::solution_color()), + 2.0, + )) + .width(Length::Fill) + .height(Length::Fill), + ); + } + stack = stack.push( + Container::new(PathWidget::with_paths( + &self.state.variable_stroke.workspace.centerline_input, + self.state.variable_stroke.workspace.camera, + Design::subject_color(), + 1.0, + false, + )) + .width(Length::Fill) + .height(Length::Fill), + ); + stack = stack.push( + Container::new( + PointsEditorWidget::new( + &self.state.variable_stroke.workspace.points, + self.state.variable_stroke.workspace.camera, + on_update_point, + ) + .set_drag_color(Design::accent_color()) + .set_hover_color(Design::negative_color()), + ) + .width(Length::Fill) + .height(Length::Fill), + ); + } + + stack.push( + Container::new(self.variable_stroke_control()) + .width(Length::Shrink) + .height(Length::Shrink) + .padding(Padding::new(8.0)), + ) + }) + .style(style_sheet_background) + } + + pub(super) fn variable_stroke_update_point(&mut self, update: PointEditUpdate) { + self.state + .variable_stroke + .variable_stroke_update_point(update); + } + + pub(super) fn variable_stroke_update_zoom(&mut self, camera: Camera) { + self.state.variable_stroke.workspace.camera = camera; + } + + pub(super) fn variable_stroke_update_drag(&mut self, new_pos: Vector) { + self.state.variable_stroke.workspace.camera.pos = new_pos; + } +} + +fn on_update_point(event: PointEditUpdate) -> AppMessage { + AppMessage::VariableStroke(VariableStrokeMessage::PointEdited(event)) +} + +fn on_update_size(size: Size) -> AppMessage { + AppMessage::VariableStroke(VariableStrokeMessage::WorkspaceSized(size)) +} + +fn on_update_zoom(zoom: Camera) -> AppMessage { + AppMessage::VariableStroke(VariableStrokeMessage::WorkspaceZoomed(zoom)) +} + +fn on_update_drag(drag: Vector) -> AppMessage { + AppMessage::VariableStroke(VariableStrokeMessage::WorkspaceDragged(drag)) +} + +impl Default for WorkspaceState { + fn default() -> Self { + WorkspaceState { + scale: 1.0, + camera: Camera::empty(), + variable_input: vec![], + centerline_input: vec![], + stroke_output: vec![], + points: vec![], + } + } +} diff --git a/examples/overlay_editor/src/data/mod.rs b/examples/overlay_editor/src/data/mod.rs index becad71f..efd31f58 100644 --- a/examples/overlay_editor/src/data/mod.rs +++ b/examples/overlay_editor/src/data/mod.rs @@ -3,3 +3,4 @@ pub(crate) mod outline; pub mod resource; pub(crate) mod string; pub(crate) mod stroke; +pub(crate) mod variable_stroke; diff --git a/examples/overlay_editor/src/data/resource.rs b/examples/overlay_editor/src/data/resource.rs index 7ecae58b..72258b5b 100644 --- a/examples/overlay_editor/src/data/resource.rs +++ b/examples/overlay_editor/src/data/resource.rs @@ -2,21 +2,30 @@ use crate::data::boolean::BooleanResource; use crate::data::outline::OutlineResource; use crate::data::string::StringResource; use crate::data::stroke::StrokeResource; +use crate::data::variable_stroke::VariableStrokeResource; pub struct AppResource { pub(crate) boolean: BooleanResource, pub(crate) string: StringResource, pub(crate) stroke: StrokeResource, + pub(crate) variable_stroke: VariableStrokeResource, pub(crate) outline: OutlineResource, } impl AppResource { #[cfg(not(target_arch = "wasm32"))] - pub(crate) fn with_paths(boolean: &str, string: &str, stroke: &str, outline: &str) -> Self { + pub(crate) fn with_paths( + boolean: &str, + string: &str, + stroke: &str, + variable_stroke: &str, + outline: &str, + ) -> Self { Self { boolean: BooleanResource::with_path(boolean), string: StringResource::with_path(string), stroke: StrokeResource::with_path(stroke), + variable_stroke: VariableStrokeResource::with_path(variable_stroke), outline: OutlineResource::with_path(outline), } } @@ -26,12 +35,14 @@ impl AppResource { boolean: &String, string: &String, stroke: &String, + variable_stroke: &String, outline: &String, ) -> Self { Self { boolean: BooleanResource::with_content(boolean), string: StringResource::with_content(string), stroke: StrokeResource::with_content(stroke), + variable_stroke: VariableStrokeResource::with_content(variable_stroke), outline: OutlineResource::with_content(outline), } } diff --git a/examples/overlay_editor/src/data/variable_stroke.rs b/examples/overlay_editor/src/data/variable_stroke.rs new file mode 100644 index 00000000..291d0189 --- /dev/null +++ b/examples/overlay_editor/src/data/variable_stroke.rs @@ -0,0 +1,110 @@ +use serde::Deserialize; +use std::collections::HashMap; +use std::path::PathBuf; + +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct VariableStrokeVertex { + pub(crate) point: [f32; 2], + pub(crate) width: f32, +} + +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct VariableStrokeTest { + pub(crate) scale: f32, + pub(crate) stroke: Vec>, +} + +impl VariableStrokeTest { + fn load(index: usize, folder: &str) -> Option { + let file_name = format!("test_{}.json", index); + let mut path_buf = PathBuf::from(folder); + path_buf.push(file_name); + + let data = match std::fs::read_to_string(path_buf.as_path()) { + Ok(data) => data, + Err(e) => { + eprintln!("{:?}", e); + return None; + } + }; + + match serde_json::from_str(&data) { + Ok(test) => Some(test), + Err(e) => { + eprintln!("Failed to parse JSON: {}", e); + None + } + } + } + + fn tests_count(folder: &str) -> usize { + let folder_path = PathBuf::from(folder); + match std::fs::read_dir(folder_path) { + Ok(entries) => entries + .filter_map(|entry| { + entry.ok().and_then(|e| { + let path = e.path(); + if path.extension()?.to_str()? == "json" { + Some(()) + } else { + None + } + }) + }) + .count(), + Err(e) => { + eprintln!("Failed to read directory: {}", e); + 0 + } + } + } +} + +pub(crate) struct VariableStrokeResource { + folder: Option, + pub(crate) count: usize, + pub(crate) tests: HashMap, +} + +impl VariableStrokeResource { + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn with_path(folder: &str) -> Self { + let count = VariableStrokeTest::tests_count(folder); + Self { + count, + folder: Some(folder.to_string()), + tests: Default::default(), + } + } + + #[cfg(target_arch = "wasm32")] + pub(crate) fn with_content(content: &String) -> Self { + let tests_vec: Vec = + serde_json::from_str(content).unwrap_or_else(|e| { + eprintln!("Failed to parse JSON content: {}", e); + vec![] + }); + + let tests: HashMap = tests_vec.into_iter().enumerate().collect(); + let count = tests.len(); + Self { + count, + folder: None, + tests, + } + } + + pub(crate) fn load(&mut self, index: usize) -> Option { + if self.count <= index { + return None; + } + if let Some(test) = self.tests.get(&index) { + return Some(test.clone()); + } + + let folder = self.folder.as_ref()?; + let test = VariableStrokeTest::load(index, folder)?; + self.tests.insert(index, test.clone()); + Some(test) + } +} diff --git a/examples/overlay_editor/src/geom/camera.rs b/examples/overlay_editor/src/geom/camera.rs index 384a3a57..d84a826e 100644 --- a/examples/overlay_editor/src/geom/camera.rs +++ b/examples/overlay_editor/src/geom/camera.rs @@ -34,8 +34,8 @@ impl Camera { } pub(crate) fn new(rect: IntRect, size: Size) -> Self { - let w_pow = rect.width().ilog2() as usize; - let h_pow = rect.height().ilog2() as usize; + let w_pow = rect.width().max(1).ilog2() as usize; + let h_pow = rect.height().max(1).ilog2() as usize; let width = (1 << w_pow) as f32; let height = (1 << h_pow) as f32; @@ -95,3 +95,26 @@ impl Camera { Vector { x, y } } } + +#[cfg(test)] +mod tests { + use super::Camera; + use i_triangle::i_overlay::i_float::int::rect::IntRect; + use iced::Size; + + #[test] + fn camera_supports_degenerate_bounds() { + let rects = [ + IntRect::new(0, 10_000, 0, 0), + IntRect::new(0, 0, -10_000, 10_000), + IntRect::new(42, 42, 24, 24), + ]; + + for rect in rects { + let camera = Camera::new(rect, Size::new(800.0, 600.0)); + assert!(camera.scale.is_finite()); + assert!(camera.scale > 0.0); + assert!(camera.i_scale.is_finite()); + } + } +} diff --git a/examples/overlay_editor/src/main.rs b/examples/overlay_editor/src/main.rs index 4cd68f8f..3ad77010 100644 --- a/examples/overlay_editor/src/main.rs +++ b/examples/overlay_editor/src/main.rs @@ -21,6 +21,7 @@ fn run_desktop() -> iced::Result { "../tests/boolean", "../tests/string", "../tests/stroke", + "../tests/variable_stroke", "../tests/outline", ); let app = EditorApp::with_resource(app_resource); diff --git a/examples/overlay_editor/src/web.rs b/examples/overlay_editor/src/web.rs index a819f6f4..662ae197 100644 --- a/examples/overlay_editor/src/web.rs +++ b/examples/overlay_editor/src/web.rs @@ -21,6 +21,7 @@ impl WebApp { boolean_data: String, string_data: String, stroke_data: String, + variable_stroke_data: String, outline_data: String, ) { use iced::application; @@ -38,8 +39,13 @@ impl WebApp { let app_initializer = move || { info!("wasm init"); - let app_resource = - AppResource::with_content(&boolean_data, &string_data, &stroke_data, &outline_data); + let app_resource = AppResource::with_content( + &boolean_data, + &string_data, + &stroke_data, + &variable_stroke_data, + &outline_data, + ); let app = EditorApp::with_resource(app_resource); (app, iced::Task::none()) diff --git a/examples/tests/sc_variable_stroke_to_web.py b/examples/tests/sc_variable_stroke_to_web.py new file mode 100644 index 00000000..0781498f --- /dev/null +++ b/examples/tests/sc_variable_stroke_to_web.py @@ -0,0 +1,31 @@ +import json +import os + +DIRECTORY = "./variable_stroke" +OUTPUT_FILE = "../../web_tests/variable_stroke_tests.json" + +all_data = [] + +for filename in sorted(os.listdir(DIRECTORY)): + if filename.endswith(".json"): + file_path = os.path.join(DIRECTORY, filename) + + with open(file_path, "r", encoding="utf-8") as file: + try: + data = json.load(file) + if "stroke" in data: + all_data.append( + { + "stroke": data.get("stroke"), + "scale": data.get("scale"), + } + ) + else: + print(f"Skipping incomplete file: {filename}") + except json.JSONDecodeError as error: + print(f"Skipping invalid JSON: {filename} ({error})") + +with open(os.path.join(DIRECTORY, OUTPUT_FILE), "w", encoding="utf-8") as file: + json.dump(all_data, file, indent=4) + +print(f"Aggregated {len(all_data)} JSON files into {OUTPUT_FILE}") diff --git a/examples/tests/variable_stroke/test_0.json b/examples/tests/variable_stroke/test_0.json new file mode 100644 index 00000000..e7b300e2 --- /dev/null +++ b/examples/tests/variable_stroke/test_0.json @@ -0,0 +1,11 @@ +{ + "scale": 100.0, + "stroke": [ + [ + { "point": [0.0, 0.0], "width": 12.0 }, + { "point": [45.0, -15.0], "width": 12.0 }, + { "point": [85.0, 20.0], "width": 12.0 }, + { "point": [130.0, 0.0], "width": 12.0 } + ] + ] +} diff --git a/examples/tests/variable_stroke/test_1.json b/examples/tests/variable_stroke/test_1.json new file mode 100644 index 00000000..651f391f --- /dev/null +++ b/examples/tests/variable_stroke/test_1.json @@ -0,0 +1,12 @@ +{ + "scale": 100.0, + "stroke": [ + [ + { "point": [0.0, 0.0], "width": 2.0 }, + { "point": [35.0, -10.0], "width": 6.0 }, + { "point": [70.0, 0.0], "width": 14.0 }, + { "point": [105.0, 25.0], "width": 5.0 }, + { "point": [140.0, 15.0], "width": 10.0 } + ] + ] +} diff --git a/examples/tests/variable_stroke/test_10.json b/examples/tests/variable_stroke/test_10.json new file mode 100644 index 00000000..8626ed80 --- /dev/null +++ b/examples/tests/variable_stroke/test_10.json @@ -0,0 +1,10 @@ +{ + "scale": 100.0, + "stroke": [ + [ + { "point": [0.0, 0.0], "width": 100.0 }, + { "point": [100.0, 0.0], "width": 10.0 }, + { "point": [100.0, -100.0], "width": 100.0 } + ] + ] +} diff --git a/examples/tests/variable_stroke/test_11.json b/examples/tests/variable_stroke/test_11.json new file mode 100644 index 00000000..6e4efc09 --- /dev/null +++ b/examples/tests/variable_stroke/test_11.json @@ -0,0 +1,10 @@ +{ + "scale": 100.0, + "stroke": [ + [ + { "point": [0.0, 0.0], "width": 10.0 }, + { "point": [100.0, 0.0], "width": 100.0 }, + { "point": [100.0, -100.0], "width": 10.0 } + ] + ] +} diff --git a/examples/tests/variable_stroke/test_12.json b/examples/tests/variable_stroke/test_12.json new file mode 100644 index 00000000..e0163655 --- /dev/null +++ b/examples/tests/variable_stroke/test_12.json @@ -0,0 +1,10 @@ +{ + "scale": 100.0, + "stroke": [ + [ + { "point": [-86, 2.0], "width": 10.0 }, + { "point": [100.0, 0.0], "width": 100.0 }, + { "point": [99.0, -45.0], "width": 10.0 } + ] + ] +} diff --git a/examples/tests/variable_stroke/test_2.json b/examples/tests/variable_stroke/test_2.json new file mode 100644 index 00000000..11a48eef --- /dev/null +++ b/examples/tests/variable_stroke/test_2.json @@ -0,0 +1,13 @@ +{ + "scale": 100.0, + "stroke": [ + [ + { "point": [0.0, 0.0], "width": 8.0 }, + { "point": [40.0, -25.0], "width": 18.0 }, + { "point": [85.0, 5.0], "width": 6.0 }, + { "point": [55.0, 50.0], "width": 22.0 }, + { "point": [5.0, 40.0], "width": 10.0 }, + { "point": [0.0, 0.0], "width": 8.0 } + ] + ] +} diff --git a/examples/tests/variable_stroke/test_3.json b/examples/tests/variable_stroke/test_3.json new file mode 100644 index 00000000..18d936e4 --- /dev/null +++ b/examples/tests/variable_stroke/test_3.json @@ -0,0 +1,12 @@ +{ + "scale": 100.0, + "stroke": [ + [ + { "point": [0.0, 0.0], "width": 6.0 }, + { "point": [35.0, 35.0], "width": 18.0 }, + { "point": [70.0, -20.0], "width": 4.0 }, + { "point": [105.0, 35.0], "width": 20.0 }, + { "point": [140.0, 0.0], "width": 8.0 } + ] + ] +} diff --git a/examples/tests/variable_stroke/test_4.json b/examples/tests/variable_stroke/test_4.json new file mode 100644 index 00000000..4c0ac20f --- /dev/null +++ b/examples/tests/variable_stroke/test_4.json @@ -0,0 +1,17 @@ +{ + "scale": 100.0, + "stroke": [ + [ + { "point": [0.0, 0.0], "width": 5.0 }, + { "point": [35.0, -25.0], "width": 15.0 }, + { "point": [75.0, -5.0], "width": 7.0 }, + { "point": [110.0, -30.0], "width": 18.0 } + ], + [ + { "point": [0.0, 35.0], "width": 18.0 }, + { "point": [35.0, 15.0], "width": 6.0 }, + { "point": [75.0, 45.0], "width": 14.0 }, + { "point": [110.0, 20.0], "width": 4.0 } + ] + ] +} diff --git a/examples/tests/variable_stroke/test_5.json b/examples/tests/variable_stroke/test_5.json new file mode 100644 index 00000000..b755e4d0 --- /dev/null +++ b/examples/tests/variable_stroke/test_5.json @@ -0,0 +1,9 @@ +{ + "scale": 100.0, + "stroke": [ + [ + { "point": [0.0, 0.0], "width": 4.0 }, + { "point": [20.0, 0.0], "width": 44.0 } + ] + ] +} diff --git a/examples/tests/variable_stroke/test_6.json b/examples/tests/variable_stroke/test_6.json new file mode 100644 index 00000000..46be31c2 --- /dev/null +++ b/examples/tests/variable_stroke/test_6.json @@ -0,0 +1,11 @@ +{ + "scale": 100.0, + "stroke": [ + [ + { "point": [0.0, 0.0], "width": 6.0 }, + { "point": [30.0, 0.0], "width": 80.0 }, + { "point": [65.0, 20.0], "width": 12.0 }, + { "point": [105.0, 0.0], "width": 24.0 } + ] + ] +} diff --git a/examples/tests/variable_stroke/test_7.json b/examples/tests/variable_stroke/test_7.json new file mode 100644 index 00000000..d01d52a9 --- /dev/null +++ b/examples/tests/variable_stroke/test_7.json @@ -0,0 +1,11 @@ +{ + "scale": 100.0, + "stroke": [ + [ + { "point": [0.0, 0.0], "width": 80.0 }, + { "point": [30.0, 0.0], "width": 6.0 }, + { "point": [65.0, -20.0], "width": 18.0 }, + { "point": [105.0, 0.0], "width": 8.0 } + ] + ] +} diff --git a/examples/tests/variable_stroke/test_8.json b/examples/tests/variable_stroke/test_8.json new file mode 100644 index 00000000..a52dcebd --- /dev/null +++ b/examples/tests/variable_stroke/test_8.json @@ -0,0 +1,11 @@ +{ + "scale": 100.0, + "stroke": [ + [ + { "point": [0.0, 0.0], "width": 0.0 }, + { "point": [45.0, -10.0], "width": 18.0 }, + { "point": [90.0, 15.0], "width": 4.0 }, + { "point": [135.0, 0.0], "width": 0.0 } + ] + ] +} diff --git a/examples/tests/variable_stroke/test_9.json b/examples/tests/variable_stroke/test_9.json new file mode 100644 index 00000000..858179bb --- /dev/null +++ b/examples/tests/variable_stroke/test_9.json @@ -0,0 +1,11 @@ +{ + "scale": 100.0, + "stroke": [ + [ + { "point": [0.0, 0.0], "width": 8.0 }, + { "point": [60.0, 0.0], "width": 20.0 }, + { "point": [5.0, 0.0], "width": 10.0 }, + { "point": [65.0, 20.0], "width": 16.0 } + ] + ] +} diff --git a/examples/variable_stroke_debug/.gitignore b/examples/variable_stroke_debug/.gitignore new file mode 100644 index 00000000..79034fbf --- /dev/null +++ b/examples/variable_stroke_debug/.gitignore @@ -0,0 +1,3 @@ +/target +/Cargo.lock + diff --git a/examples/variable_stroke_debug/Cargo.toml b/examples/variable_stroke_debug/Cargo.toml new file mode 100644 index 00000000..5dc732ef --- /dev/null +++ b/examples/variable_stroke_debug/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "variable_stroke_debug" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +iced = { version = "0.14.0", features = ["wgpu", "advanced", "fira-sans"] } +i_overlay = { path = "../../iOverlay", features = ["variable_stroke_debug"] } +i_triangle = { version = "^0.46.0", features = ["serde"] } +serde = { version = "^1.0", features = ["derive"] } +serde_json = "^1.0" + +[patch.crates-io] +i_overlay = { path = "../../iOverlay" } + +[profile.release] +opt-level = 3 +codegen-units = 1 diff --git a/examples/variable_stroke_debug/README.md b/examples/variable_stroke_debug/README.md new file mode 100644 index 00000000..897ec30f --- /dev/null +++ b/examples/variable_stroke_debug/README.md @@ -0,0 +1,24 @@ +# Variable Stroke Debug Editor + +Focused desktop debugger for `i_overlay::mesh::variable_stroke`. It reuses the `iced` camera, +sheet, path/shape rendering, point dragging, and fixture navigation approach from +`examples/overlay_editor`. + +Run from the repository root: + +```bash +cargo run --release --manifest-path examples/variable_stroke_debug/Cargo.toml +``` + +The editor loads every JSON fixture in `examples/tests/variable_stroke` (including +`test_10`, `test_11`, and `test_12`). Drag a diamond to move a `StrokeVertex`, select a vertex in +the left panel to change its width, and adjust `round_angle` with the slider. Mouse-drag empty +canvas space to pan and use the wheel/trackpad to zoom. Up/down arrow keys switch fixtures. + +The layer controls show or hide the centerline and radius guides, tangent section boundaries, +join chords, cap chords, straight closing edges, and the final post-overlay contour. Raw edges +are colored by construction role and can display arrowheads in exact `SegmentBuilder` insertion +direction. The header reports raw edge counts by category. + +The instrumentation is compiled only with the `variable_stroke_debug` feature. Normal +`i_overlay` users do not see the debug trait or result types. diff --git a/examples/variable_stroke_debug/src/data.rs b/examples/variable_stroke_debug/src/data.rs new file mode 100644 index 00000000..44227e85 --- /dev/null +++ b/examples/variable_stroke_debug/src/data.rs @@ -0,0 +1,82 @@ +use serde::Deserialize; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct FixtureVertex { + pub(crate) point: [f32; 2], + pub(crate) width: f32, +} + +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct Fixture { + pub(crate) scale: f32, + pub(crate) stroke: Vec>, +} + +pub(crate) struct FixtureResource { + files: Vec, +} + +impl FixtureResource { + pub(crate) fn new(folder: impl AsRef) -> Self { + let mut files: Vec<_> = std::fs::read_dir(folder) + .into_iter() + .flatten() + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.extension().is_some_and(|ext| ext == "json")) + .collect(); + files.sort_by_key(|path| fixture_index(path).unwrap_or(usize::MAX)); + Self { files } + } + + pub(crate) fn len(&self) -> usize { + self.files.len() + } + + pub(crate) fn name(&self, index: usize) -> String { + self.files + .get(index) + .and_then(|path| path.file_stem()) + .and_then(|name| name.to_str()) + .unwrap_or("unknown") + .to_owned() + } + + pub(crate) fn load(&self, index: usize) -> Result { + let path = self + .files + .get(index) + .ok_or_else(|| format!("fixture index {index} is out of range"))?; + let content = std::fs::read_to_string(path) + .map_err(|error| format!("failed to read {}: {error}", path.display()))?; + serde_json::from_str(&content) + .map_err(|error| format!("failed to parse {}: {error}", path.display())) + } +} + +fn fixture_index(path: &Path) -> Option { + path.file_stem()? + .to_str()? + .strip_prefix("test_")? + .parse() + .ok() +} + +#[cfg(test)] +mod tests { + use super::FixtureResource; + use std::path::PathBuf; + + #[test] + fn discovers_repro_fixtures_in_numeric_order() { + let folder = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../tests/variable_stroke"); + let resource = FixtureResource::new(folder); + + assert!(resource.len() >= 13); + for index in [10, 11, 12] { + assert_eq!(resource.name(index), format!("test_{index}")); + assert!(!resource.load(index).unwrap().stroke.is_empty()); + } + } +} diff --git a/examples/variable_stroke_debug/src/draw.rs b/examples/variable_stroke_debug/src/draw.rs new file mode 100644 index 00000000..f381790c --- /dev/null +++ b/examples/variable_stroke_debug/src/draw.rs @@ -0,0 +1,162 @@ +use crate::geom::camera::Camera; +use i_triangle::i_overlay::i_shape::int::path::IntPaths; +use iced::advanced::graphics::Mesh; +use iced::advanced::graphics::color::pack; +use iced::advanced::graphics::mesh::{Indexed, SolidVertex2D}; +use iced::advanced::layout::{self, Layout}; +use iced::advanced::renderer; +use iced::advanced::widget::{Tree, Widget}; +use iced::{Color, Element, Length, Rectangle, Renderer, Size, Theme, Transformation, mouse}; + +pub(crate) struct LinesWidget { + mesh: Option, +} + +impl LinesWidget { + pub(crate) fn new( + paths: &IntPaths, + camera: Camera, + color: Color, + width: f32, + arrows: bool, + closed: bool, + ) -> Self { + let color = pack(color); + let mut vertices = Vec::new(); + let mut indices = Vec::new(); + + for path in paths { + for pair in path.windows(2) { + append_segment( + camera, + pair[0], + pair[1], + color, + width, + arrows, + &mut vertices, + &mut indices, + ); + } + if closed && path.len() > 2 { + append_segment( + camera, + *path.last().unwrap(), + path[0], + color, + width, + false, + &mut vertices, + &mut indices, + ); + } + } + + let mesh = (!indices.is_empty()).then_some(Mesh::Solid { + buffers: Indexed { vertices, indices }, + transformation: Transformation::IDENTITY, + clip_bounds: Rectangle::INFINITE, + }); + Self { mesh } + } +} + +fn append_segment( + camera: Camera, + a: i_triangle::i_overlay::i_float::int::point::IntPoint, + b: i_triangle::i_overlay::i_float::int::point::IntPoint, + color: iced::advanced::graphics::color::Packed, + width: f32, + arrows: bool, + vertices: &mut Vec, + indices: &mut Vec, +) { + let a = camera.int_world_to_view(a); + let b = camera.int_world_to_view(b); + let dx = b.x - a.x; + let dy = b.y - a.y; + let length = (dx * dx + dy * dy).sqrt(); + if length < 0.001 { + return; + } + let half = 0.5 * width; + let nx = -dy * half / length; + let ny = dx * half / length; + let base = vertices.len() as u32; + vertices.extend([ + vertex(a.x + nx, a.y + ny, color), + vertex(a.x - nx, a.y - ny, color), + vertex(b.x - nx, b.y - ny, color), + vertex(b.x + nx, b.y + ny, color), + ]); + indices.extend([base, base + 1, base + 2, base, base + 2, base + 3]); + + if arrows { + let ux = dx / length; + let uy = dy / length; + let size = (4.0 * width).max(5.0); + let tip_x = a.x + 0.68 * dx; + let tip_y = a.y + 0.68 * dy; + let back_x = tip_x - ux * size; + let back_y = tip_y - uy * size; + let wing = 0.55 * size; + let arrow_base = vertices.len() as u32; + vertices.extend([ + vertex(tip_x, tip_y, color), + vertex(back_x - uy * wing, back_y + ux * wing, color), + vertex(back_x + uy * wing, back_y - ux * wing, color), + ]); + indices.extend([arrow_base, arrow_base + 1, arrow_base + 2]); + } +} + +fn vertex(x: f32, y: f32, color: iced::advanced::graphics::color::Packed) -> SolidVertex2D { + SolidVertex2D { + position: [x, y], + color, + } +} + +impl Widget for LinesWidget { + fn size(&self) -> Size { + Size::new(Length::Fill, Length::Fill) + } + + fn layout( + &mut self, + _tree: &mut Tree, + _renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + layout::Node::new(limits.max()) + } + + fn draw( + &self, + _tree: &Tree, + renderer: &mut Renderer, + _theme: &Theme, + _style: &renderer::Style, + layout: Layout<'_>, + _cursor: mouse::Cursor, + _viewport: &Rectangle, + ) { + use iced::advanced::Renderer as _; + use iced::advanced::graphics::mesh::Renderer as _; + + let bounds = layout.bounds(); + renderer.with_layer(bounds, |renderer| { + if let Some(mesh) = &self.mesh { + renderer.with_translation(layout.position() - iced::Point::ORIGIN, |renderer| { + renderer.draw_mesh(mesh.clone()); + }); + } + }); + } +} + +impl<'a, Message: 'a> From for Element<'a, Message> { + fn from(widget: LinesWidget) -> Self { + Self::new(widget) + } +} diff --git a/examples/variable_stroke_debug/src/main.rs b/examples/variable_stroke_debug/src/main.rs new file mode 100644 index 00000000..87d0fb6e --- /dev/null +++ b/examples/variable_stroke_debug/src/main.rs @@ -0,0 +1,612 @@ +mod data; +mod draw; + +#[path = "../../overlay_editor/src/geom/mod.rs"] +#[allow(dead_code)] +mod geom; +#[path = "../../overlay_editor/src/point_editor/mod.rs"] +#[allow(dead_code)] +mod point_editor; +#[path = "../../overlay_editor/src/sheet/mod.rs"] +mod sheet; + +use crate::data::FixtureResource; +use crate::draw::LinesWidget; +use crate::geom::camera::Camera; +use crate::point_editor::point::{EditorPoint, MultiIndex}; +use crate::point_editor::widget::{PointEditUpdate, PointsEditorWidget}; +use crate::sheet::widget::SheetWidget; +use i_overlay::mesh::variable_stroke::{ + StrokeVertex, VariableStrokeDebug, VariableStrokeDebugEdgeKind, VariableStrokeStyle, +}; +use i_triangle::i_overlay::i_float::int::point::IntPoint; +use i_triangle::i_overlay::i_float::int::rect::IntRect; +use i_triangle::i_overlay::i_shape::int::path::IntPaths; +use iced::keyboard::Key; +use iced::keyboard::key::Named; +use iced::widget::{ + Button, Column, Container, Row, Stack, Text, button, checkbox, container, scrollable, slider, +}; +use iced::{Color, Element, Length, Size, Subscription, Task, Vector, application, keyboard}; +use std::collections::HashMap; +use std::f32::consts::PI; +use std::path::PathBuf; + +fn main() -> iced::Result { + application(EditorApp::new, EditorApp::update, EditorApp::view) + .title("iOverlay ยท Variable Stroke Debug") + .resizable(true) + .centered() + .subscription(EditorApp::subscription) + .run() +} + +#[derive(Debug, Clone)] +struct VariablePoint { + pos: IntPoint, + width: f32, +} + +#[derive(Debug, Clone, Copy)] +enum Layer { + Input, + RadiusGuides, + RawEdges, + Sections, + Joins, + Caps, + Closures, + Direction, + FinalContour, +} + +#[derive(Debug, Clone)] +enum Message { + FixtureSelected(usize), + VertexSelected(usize), + WidthChanged(f32), + RoundAngleChanged(f32), + LayerToggled(Layer, bool), + PointEdited(PointEditUpdate), + WorkspaceSized(Size), + WorkspaceZoomed(Camera), + WorkspaceDragged(Vector), + NextFixture, + PreviousFixture, +} + +struct LayerVisibility { + input: bool, + radius_guides: bool, + raw_edges: bool, + sections: bool, + joins: bool, + caps: bool, + closures: bool, + direction: bool, + final_contour: bool, +} + +impl Default for LayerVisibility { + fn default() -> Self { + Self { + input: true, + radius_guides: true, + raw_edges: true, + sections: true, + joins: true, + caps: true, + closures: true, + direction: true, + final_contour: true, + } + } +} + +#[derive(Default)] +struct EdgeCounts { + sections: usize, + joins: usize, + caps: usize, + closures: usize, +} + +struct EditorApp { + resource: FixtureResource, + fixture_index: usize, + fixture_name: String, + scale: f32, + variable_paths: Vec>, + centerlines: IntPaths, + editor_points: Vec, + selected_vertex: usize, + round_angle: f32, + final_contours: IntPaths, + radius_guides: IntPaths, + section_edges: IntPaths, + join_edges: IntPaths, + cap_edges: IntPaths, + closing_edges: IntPaths, + counts: EdgeCounts, + layers: LayerVisibility, + camera: Camera, + viewport_size: Size, + cameras: HashMap, + error: Option, +} + +impl EditorApp { + fn new() -> (Self, Task) { + let fixture_folder = + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../tests/variable_stroke"); + let resource = FixtureResource::new(fixture_folder); + let mut app = Self { + resource, + fixture_index: usize::MAX, + fixture_name: String::new(), + scale: 1.0, + variable_paths: vec![], + centerlines: vec![], + editor_points: vec![], + selected_vertex: 0, + round_angle: 0.18, + final_contours: vec![], + radius_guides: vec![], + section_edges: vec![], + join_edges: vec![], + cap_edges: vec![], + closing_edges: vec![], + counts: EdgeCounts::default(), + layers: LayerVisibility::default(), + camera: Camera::empty(), + viewport_size: Size::ZERO, + cameras: HashMap::new(), + error: None, + }; + if app.resource.len() > 0 { + app.load_fixture(0); + } else { + app.error = Some("No variable_stroke JSON fixtures found".to_owned()); + } + (app, Task::none()) + } + + fn subscription(&self) -> Subscription { + keyboard::listen().filter_map(|event| match event { + keyboard::Event::KeyPressed { key, .. } => match key { + Key::Named(Named::ArrowDown) => Some(Message::NextFixture), + Key::Named(Named::ArrowUp) => Some(Message::PreviousFixture), + _ => None, + }, + _ => None, + }) + } + + fn update(&mut self, message: Message) -> Task { + match message { + Message::FixtureSelected(index) => self.load_fixture(index), + Message::VertexSelected(index) => self.selected_vertex = index, + Message::WidthChanged(width) => { + if let Some(index) = self + .editor_points + .get(self.selected_vertex) + .map(|p| p.index.clone()) + { + self.variable_paths[index.path_index][index.point_index].width = width; + self.rebuild(); + } + } + Message::RoundAngleChanged(angle) => { + self.round_angle = angle; + self.rebuild(); + } + Message::LayerToggled(layer, visible) => match layer { + Layer::Input => self.layers.input = visible, + Layer::RadiusGuides => self.layers.radius_guides = visible, + Layer::RawEdges => self.layers.raw_edges = visible, + Layer::Sections => self.layers.sections = visible, + Layer::Joins => self.layers.joins = visible, + Layer::Caps => self.layers.caps = visible, + Layer::Closures => self.layers.closures = visible, + Layer::Direction => self.layers.direction = visible, + Layer::FinalContour => self.layers.final_contour = visible, + }, + Message::PointEdited(update) => { + self.selected_vertex = update.index; + let index = update.point.index.clone(); + self.editor_points[update.index] = update.point.clone(); + self.variable_paths[index.path_index][index.point_index].pos = update.point.pos; + self.refresh_centerlines(); + self.rebuild(); + } + Message::WorkspaceSized(size) => { + self.viewport_size = size; + if self.camera.is_empty() { + self.frame_input(); + } else { + self.camera.size = size; + } + } + Message::WorkspaceZoomed(camera) => self.camera = camera, + Message::WorkspaceDragged(position) => self.camera.pos = position, + Message::NextFixture => { + if self.fixture_index + 1 < self.resource.len() { + self.load_fixture(self.fixture_index + 1); + } + } + Message::PreviousFixture => { + if self.fixture_index > 0 && self.fixture_index != usize::MAX { + self.load_fixture(self.fixture_index - 1); + } + } + } + Task::none() + } + + fn load_fixture(&mut self, index: usize) { + match self.resource.load(index) { + Ok(fixture) => { + if self.fixture_index != usize::MAX { + self.cameras.insert(self.fixture_index, self.camera); + } + self.scale = fixture.scale; + self.variable_paths = fixture + .stroke + .into_iter() + .map(|path| { + path.into_iter() + .map(|vertex| VariablePoint { + pos: self.to_int(vertex.point), + width: vertex.width, + }) + .collect() + }) + .collect(); + self.fixture_index = index; + self.fixture_name = self.resource.name(index); + self.selected_vertex = 0; + self.refresh_centerlines(); + self.refresh_editor_points(); + self.camera = self + .cameras + .get(&index) + .copied() + .unwrap_or_else(Camera::empty); + if self.camera.is_empty() && self.viewport_size.width > 0.0 { + self.frame_input(); + } + self.error = None; + self.rebuild(); + } + Err(error) => self.error = Some(error), + } + } + + fn refresh_centerlines(&mut self) { + self.centerlines = self + .variable_paths + .iter() + .map(|path| path.iter().map(|vertex| vertex.pos).collect()) + .collect(); + } + + fn refresh_editor_points(&mut self) { + self.editor_points.clear(); + for (path_index, path) in self.variable_paths.iter().enumerate() { + for (point_index, vertex) in path.iter().enumerate() { + self.editor_points.push(EditorPoint { + pos: vertex.pos, + index: MultiIndex { + point_index, + path_index, + group_index: 0, + }, + }); + } + } + } + + fn frame_input(&mut self) { + if let Some(rect) = IntRect::with_iter(self.editor_points.iter().map(|point| &point.pos)) { + self.camera = Camera::new(rect, self.viewport_size); + } + } + + fn rebuild(&mut self) { + let inverse_scale = 1.0 / self.scale; + let paths: Vec> = self + .variable_paths + .iter() + .map(|path| { + path.iter() + .map(|vertex| { + StrokeVertex::new( + [ + inverse_scale * vertex.pos.x as f32, + inverse_scale * vertex.pos.y as f32, + ], + vertex.width, + ) + }) + .collect() + }) + .collect(); + let style = VariableStrokeStyle::new().round_angle(self.round_angle); + let debug = paths.variable_stroke_debug(style); + + self.final_contours = debug + .shapes + .into_iter() + .flatten() + .map(|contour| { + contour + .into_iter() + .map(|point| self.to_int(point)) + .collect() + }) + .collect(); + self.section_edges.clear(); + self.join_edges.clear(); + self.cap_edges.clear(); + self.closing_edges.clear(); + self.counts = EdgeCounts::default(); + + for edge in debug.edges { + let path = vec![self.to_int(edge.a), self.to_int(edge.b)]; + match edge.kind { + VariableStrokeDebugEdgeKind::SectionBoundary => { + self.counts.sections += 1; + self.section_edges.push(path); + } + VariableStrokeDebugEdgeKind::JoinArc => { + self.counts.joins += 1; + self.join_edges.push(path); + } + VariableStrokeDebugEdgeKind::CapArc | VariableStrokeDebugEdgeKind::CircleArc => { + self.counts.caps += 1; + self.cap_edges.push(path); + } + VariableStrokeDebugEdgeKind::JoinClosure + | VariableStrokeDebugEdgeKind::CapClosure => { + self.counts.closures += 1; + self.closing_edges.push(path); + } + } + } + self.radius_guides = radius_guides(&paths, self.scale); + } + + fn to_int(&self, point: [f32; 2]) -> IntPoint { + IntPoint::new( + (self.scale * point[0]).round() as i32, + (self.scale * point[1]).round() as i32, + ) + } + + fn selected_width(&self) -> f32 { + self.editor_points + .get(self.selected_vertex) + .map(|point| self.variable_paths[point.index.path_index][point.index.point_index].width) + .unwrap_or(0.0) + } + + fn view(&self) -> Element<'_, Message> { + Row::new() + .push( + Container::new(scrollable(self.controls()).height(Length::Fill)) + .width(Length::Fixed(285.0)) + .height(Length::Fill) + .padding(12), + ) + .push(self.workspace()) + .height(Length::Fill) + .into() + } + + fn controls(&self) -> Column<'_, Message> { + let mut fixtures = Column::new().spacing(2); + for index in 0..self.resource.len() { + let selected = index == self.fixture_index; + let button = Button::new(Text::new(self.resource.name(index)).size(14)) + .width(Length::Fill) + .on_press(Message::FixtureSelected(index)); + fixtures = fixtures.push(if selected { + button.style(button::primary) + } else { + button.style(button::text) + }); + } + + let mut vertices = Column::new().spacing(2); + for (index, point) in self.editor_points.iter().enumerate() { + let vertex = &self.variable_paths[point.index.path_index][point.index.point_index]; + let label = format!( + "path {} ยท v{} width {:.2}", + point.index.path_index, point.index.point_index, vertex.width + ); + let button = Button::new(Text::new(label).size(13)) + .width(Length::Fill) + .on_press(Message::VertexSelected(index)); + vertices = vertices.push(if index == self.selected_vertex { + button.style(button::primary) + } else { + button.style(button::text) + }); + } + + let total_edges = + self.counts.sections + self.counts.joins + self.counts.caps + self.counts.closures; + let stats = format!( + "raw edges: {total_edges}\nsections {} ยท joins {}\ncaps {} ยท closing {}", + self.counts.sections, self.counts.joins, self.counts.caps, self.counts.closures + ); + + let mut column = Column::new() + .spacing(8) + .push(Text::new("Variable Stroke Debug").size(22)) + .push(Text::new(format!("{} ยท fixture {}/{}", self.fixture_name, self.fixture_index.saturating_add(1), self.resource.len())).size(13)) + .push(Text::new(stats).size(13)) + .push(Text::new("Fixtures").size(16)) + .push(fixtures) + .push(Text::new("StrokeVertex width").size(16)) + .push(vertices) + .push(Text::new(format!("selected width: {:.2}", self.selected_width())).size(13)) + .push(slider(0.0..=300.0, self.selected_width(), Message::WidthChanged).step(0.1_f32)) + .push(Text::new(format!("round_angle: {:.4} rad", self.round_angle)).size(13)) + .push(slider(0.01 * PI..=0.25 * PI, self.round_angle, Message::RoundAngleChanged).step(0.005_f32)) + .push(Text::new("Layers").size(16)) + .push(layer_checkbox("Input centerline / vertices", self.layers.input, Layer::Input)) + .push(layer_checkbox("Vertex radius guides", self.layers.radius_guides, Layer::RadiusGuides)) + .push(layer_checkbox("Raw added edges (master)", self.layers.raw_edges, Layer::RawEdges)) + .push(layer_checkbox(" Cyan ยท section boundaries", self.layers.sections, Layer::Sections)) + .push(layer_checkbox(" Orange ยท join arcs", self.layers.joins, Layer::Joins)) + .push(layer_checkbox(" Magenta ยท cap arcs", self.layers.caps, Layer::Caps)) + .push(layer_checkbox(" Yellow ยท closing edges", self.layers.closures, Layer::Closures)) + .push(layer_checkbox("Edge direction arrows", self.layers.direction, Layer::Direction)) + .push(layer_checkbox("Green ยท final contour", self.layers.final_contour, Layer::FinalContour)) + .push(Text::new("Drag diamonds to edit positions. Drag empty canvas to pan; wheel/trackpad zooms. โ†‘/โ†“ changes fixture.").size(12)); + + if let Some(error) = &self.error { + column = column.push( + Text::new(error) + .color(Color::from_rgb8(255, 80, 80)) + .size(13), + ); + } + column + } + + fn workspace(&self) -> Container<'_, Message> { + let mut stack = Stack::new().push( + Container::new(SheetWidget::new( + self.camera, + Color::from_rgb8(130, 130, 130).scale_alpha(0.35), + Message::WorkspaceSized, + Message::WorkspaceZoomed, + Message::WorkspaceDragged, + )) + .width(Length::Fill) + .height(Length::Fill), + ); + + if self.camera.is_not_empty() { + if self.layers.final_contour && !self.final_contours.is_empty() { + stack = stack.push(full_layer(LinesWidget::new( + &self.final_contours, + self.camera, + Color::from_rgb8(45, 210, 90), + 2.5, + false, + true, + ))); + } + if self.layers.input && self.layers.radius_guides { + stack = stack.push(full_layer(LinesWidget::new( + &self.radius_guides, + self.camera, + Color::from_rgb8(170, 170, 180).scale_alpha(0.65), + 1.0, + false, + false, + ))); + } + if self.layers.raw_edges { + if self.layers.sections { + stack = stack.push(full_layer(LinesWidget::new( + &self.section_edges, + self.camera, + Color::from_rgb8(45, 200, 255), + 1.7, + self.layers.direction, + false, + ))); + } + if self.layers.joins { + stack = stack.push(full_layer(LinesWidget::new( + &self.join_edges, + self.camera, + Color::from_rgb8(255, 145, 40), + 2.0, + self.layers.direction, + false, + ))); + } + if self.layers.caps { + stack = stack.push(full_layer(LinesWidget::new( + &self.cap_edges, + self.camera, + Color::from_rgb8(220, 80, 255), + 2.0, + self.layers.direction, + false, + ))); + } + if self.layers.closures { + stack = stack.push(full_layer(LinesWidget::new( + &self.closing_edges, + self.camera, + Color::from_rgb8(255, 220, 40), + 2.2, + self.layers.direction, + false, + ))); + } + } + if self.layers.input { + stack = stack.push(full_layer(LinesWidget::new( + &self.centerlines, + self.camera, + Color::from_rgb8(255, 70, 70), + 1.5, + false, + false, + ))); + stack = stack.push(full_layer( + PointsEditorWidget::new(&self.editor_points, self.camera, Message::PointEdited) + .set_drag_color(Color::from_rgb8(255, 145, 40)) + .set_hover_color(Color::WHITE), + )); + } + } + + Container::new(stack) + .width(Length::Fill) + .height(Length::Fill) + .style(container::dark) + } +} + +fn layer_checkbox(label: &str, value: bool, layer: Layer) -> iced::widget::Checkbox<'_, Message> { + checkbox(value) + .label(label) + .on_toggle(move |visible| Message::LayerToggled(layer, visible)) +} + +fn full_layer<'a>(widget: impl Into>) -> Container<'a, Message> { + Container::new(widget) + .width(Length::Fill) + .height(Length::Fill) +} + +fn radius_guides(paths: &[Vec>], scale: f32) -> IntPaths { + const STEPS: usize = 48; + let mut guides = Vec::new(); + for path in paths { + for vertex in path { + let radius = 0.5 * vertex.width.max(0.0); + if radius <= 0.0 { + continue; + } + let mut circle = Vec::with_capacity(STEPS + 1); + for step in 0..=STEPS { + let angle = 2.0 * PI * step as f32 / STEPS as f32; + circle.push(IntPoint::new( + (scale * (vertex.point[0] + radius * angle.cos())).round() as i32, + (scale * (vertex.point[1] + radius * angle.sin())).round() as i32, + )); + } + guides.push(circle); + } + } + guides +} diff --git a/examples/web_tests/variable_stroke_tests.json b/examples/web_tests/variable_stroke_tests.json new file mode 100644 index 00000000..8f490acb --- /dev/null +++ b/examples/web_tests/variable_stroke_tests.json @@ -0,0 +1,102 @@ +[ + { + "scale": 100.0, + "stroke": [[ + { "point": [0.0, 0.0], "width": 12.0 }, + { "point": [45.0, -15.0], "width": 12.0 }, + { "point": [85.0, 20.0], "width": 12.0 }, + { "point": [130.0, 0.0], "width": 12.0 } + ]] + }, + { + "scale": 100.0, + "stroke": [[ + { "point": [0.0, 0.0], "width": 2.0 }, + { "point": [35.0, -10.0], "width": 6.0 }, + { "point": [70.0, 0.0], "width": 14.0 }, + { "point": [105.0, 25.0], "width": 5.0 }, + { "point": [140.0, 15.0], "width": 10.0 } + ]] + }, + { + "scale": 100.0, + "stroke": [[ + { "point": [0.0, 0.0], "width": 8.0 }, + { "point": [40.0, -25.0], "width": 18.0 }, + { "point": [85.0, 5.0], "width": 6.0 }, + { "point": [55.0, 50.0], "width": 22.0 }, + { "point": [5.0, 40.0], "width": 10.0 }, + { "point": [0.0, 0.0], "width": 8.0 } + ]] + }, + { + "scale": 100.0, + "stroke": [[ + { "point": [0.0, 0.0], "width": 6.0 }, + { "point": [35.0, 35.0], "width": 18.0 }, + { "point": [70.0, -20.0], "width": 4.0 }, + { "point": [105.0, 35.0], "width": 20.0 }, + { "point": [140.0, 0.0], "width": 8.0 } + ]] + }, + { + "scale": 100.0, + "stroke": [ + [ + { "point": [0.0, 0.0], "width": 5.0 }, + { "point": [35.0, -25.0], "width": 15.0 }, + { "point": [75.0, -5.0], "width": 7.0 }, + { "point": [110.0, -30.0], "width": 18.0 } + ], + [ + { "point": [0.0, 35.0], "width": 18.0 }, + { "point": [35.0, 15.0], "width": 6.0 }, + { "point": [75.0, 45.0], "width": 14.0 }, + { "point": [110.0, 20.0], "width": 4.0 } + ] + ] + }, + { + "scale": 100.0, + "stroke": [[ + { "point": [0.0, 0.0], "width": 4.0 }, + { "point": [20.0, 0.0], "width": 44.0 } + ]] + }, + { + "scale": 100.0, + "stroke": [[ + { "point": [0.0, 0.0], "width": 6.0 }, + { "point": [30.0, 0.0], "width": 80.0 }, + { "point": [65.0, 20.0], "width": 12.0 }, + { "point": [105.0, 0.0], "width": 24.0 } + ]] + }, + { + "scale": 100.0, + "stroke": [[ + { "point": [0.0, 0.0], "width": 80.0 }, + { "point": [30.0, 0.0], "width": 6.0 }, + { "point": [65.0, -20.0], "width": 18.0 }, + { "point": [105.0, 0.0], "width": 8.0 } + ]] + }, + { + "scale": 100.0, + "stroke": [[ + { "point": [0.0, 0.0], "width": 0.0 }, + { "point": [45.0, -10.0], "width": 18.0 }, + { "point": [90.0, 15.0], "width": 4.0 }, + { "point": [135.0, 0.0], "width": 0.0 } + ]] + }, + { + "scale": 100.0, + "stroke": [[ + { "point": [0.0, 0.0], "width": 8.0 }, + { "point": [60.0, 0.0], "width": 20.0 }, + { "point": [5.0, 0.0], "width": 10.0 }, + { "point": [65.0, 20.0], "width": 16.0 } + ]] + } +] diff --git a/iOverlay/CHANGELOG.md b/iOverlay/CHANGELOG.md index 9c5cfd48..fd6c525a 100644 --- a/iOverlay/CHANGELOG.md +++ b/iOverlay/CHANGELOG.md @@ -1,3 +1,51 @@ +## [8.1.0] - 2026-08-16 +### Added +- Variable-width strokes. +- Flat shape hierarchy. +- Batch point-location API. + +### Fixed +- Stroke and inner-butt edge cases. + +## [8.0.0] - 2026-08-02 +### Changed +- Introduced the unified `OverlayInt` trait. +- Upgraded to `i_float` and `i_shape` 4.x. +- Improved handling of empty and degenerate geometry. + +## [7.0.0] - 2026-06-01 +### Added +- Generic integer API supporting `i16`, `i32`, and `i64`. +- Edge attributes and provenance. +- Selectable integer engines for floating-point operations. + +## [6.0.0] - 2026-05-02 +### Changed +- Simplified the floating-point API using the associated `Scalar` type in `FloatPointCompatible`. +- Upgraded to `i_float` and `i_shape` 2.x. + +## [5.0.0] - 2026-04-22 +### Changed +- Established Rust 1.88 as the minimum supported Rust version. +- Adopted a SemVer-based release policy. +- Improved performance and moved multithreaded sorting behind a feature. + +## [4.0.0] - 2025-05-26 +### Changed +- Added `no_std` support. +- Disabled multithreading by default. +- Significantly refactored the public API and internal buffers. + +## [3.0.0] - 2025-04-17 +### Changed +- Changed the default contour orientation to counterclockwise for outer contours and clockwise for holes. +- Reworked splitting, hole binding, and simplification. + +## [2.0.0] - 2025-02-20 +### Added +- Stroke, outline, and buffering APIs. +- Multiple `LineCap` and `LineJoin` styles. + ## [1.10.0] - 2025-02-02 ### Changed - snap by radius can now grow without limit. diff --git a/iOverlay/Cargo.toml b/iOverlay/Cargo.toml index 443525e1..f3a76a67 100644 --- a/iOverlay/Cargo.toml +++ b/iOverlay/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "i_overlay" -version = "8.0.0" +version = "8.1.0" authors = ["Nail Sharipov "] edition = "2024" rust-version = "1.88" @@ -32,12 +32,13 @@ default = [] glam = ["i_float/glam"] serde = ["i_float/serde", "i_shape/serde"] allow_multithreading = ["dep:rayon", "i_key_sort/allow_multithreading"] +variable_stroke_debug = [] [dev-dependencies] serde = { version = "^1.0", features = ["derive"] } serde_json = "^1.0" rand = { version = "~0.10", features = ["alloc"] } -i_float = { version = "^4.0.0", features = ["serde"] } +i_float = { version = "^4.1.0", features = ["serde"] } i_shape = { version = "^4.0.0", features = ["serde"] } #i_float = { path = "../../iFloat", features = ["serde"] } -#i_shape = { path = "../../iShape", features = ["serde"] } \ No newline at end of file +#i_shape = { path = "../../iShape", features = ["serde"] } diff --git a/iOverlay/README.md b/iOverlay/README.md index 0038d33d..831d5b55 100644 --- a/iOverlay/README.md +++ b/iOverlay/README.md @@ -22,6 +22,7 @@ iOverlay powers polygon boolean operations in [geo](https://github.com/georust/g - [Quick Start](#quick-start) - [Boolean Operations](#boolean-operations) - [Simple Example](#simple-example) + - [Flat Shape Hierarchy](#flat-shape-hierarchy) - [Overlay Rules](#overlay-rules) - [Edge Attributes and Provenance](#edge-attributes-and-provenance) - [Spatial Predicates](#spatial-predicates) @@ -53,6 +54,7 @@ iOverlay powers polygon boolean operations in [geo](https://github.com/georust/g - **Spatial Predicates**: `intersects`, `disjoint`, `interiors_intersect`, `touches`, `within`, `covers` with early-exit optimization. - **Polyline Operations**: clip and slice. - **Polygons**: with holes, self-intersections, and multiple contours. +- **Flat Shape Hierarchy**: FFI-friendly shape, hole, and nested-island relationships. - **Simplification**: removes degenerate vertices and merges collinear edges. - **Buffering**: offsets paths and polygons. - **Fill Rules**: even-odd, non-zero, positive and negative. @@ -77,7 +79,7 @@ iOverlay supports: ```toml [dependencies] -i_overlay = { version = "^8.0", features = ["allow_multithreading"] } +i_overlay = { version = "^8.1", features = ["allow_multithreading"] } ``` Average relative time for iOverlay Rust solvers @@ -92,7 +94,7 @@ See the detailed reports: [Performance Comparison](https://ishape-rust.github.io Add the following to your Cargo.toml: ```toml [dependencies] -i_overlay = "^8.0" +i_overlay = "^8.1" ``` Read full [documentation](https://ishape-rust.github.io/iShape-js/overlay/doc.html) @@ -190,6 +192,36 @@ The `overlay` function returns `Shapes

`, which is an alias for `Vec> **Note**: By default, outer boundaries are counterclockwise and holes are clockwiseโ€”unless `main_direction` is set. [More information](https://ishape-rust.github.io/iShape-js/overlay/contours/contours.html) about contours. +  +### Flat Shape Hierarchy + +The regular `overlay` result groups each outer contour with its holes, but shapes nested inside those holes are separate entries. Use `overlay_hierarchy` when you also need the immediate parent-hole relationship. + +Flat Shape Hierarchy + +```rust +use i_overlay::core::fill_rule::FillRule; +use i_overlay::core::overlay::Overlay; +use i_overlay::core::overlay_rule::OverlayRule; +use i_overlay::i_shape::int_shape; + +let subject = int_shape![ + [[0, 0], [100, 0], [100, 100], [0, 100]], + [[10, 10], [10, 90], [90, 90], [90, 10]], + [[20, 20], [80, 20], [80, 80], [20, 80]], +]; + +let mut overlay = Overlay::with_contours(&subject, &[]); +let result = overlay.overlay_hierarchy(OverlayRule::Subject, FillRule::EvenOdd); + +assert_eq!(result.shapes.shape_ranges.len(), 2); +assert_eq!(result.links.len(), 1); + +let link = result.links[0]; +assert_eq!(link.parent_shape_index, 0); +assert_eq!(link.parent_contour_index, 1); +assert_eq!(link.child_shape_index, 1); +```   ### Overlay Rules diff --git a/iOverlay/readme/shape_hierarchy.svg b/iOverlay/readme/shape_hierarchy.svg new file mode 100644 index 00000000..0dc946b4 --- /dev/null +++ b/iOverlay/readme/shape_hierarchy.svg @@ -0,0 +1,173 @@ + + + + diff --git a/iOverlay/src/bind/segment.rs b/iOverlay/src/bind/segment.rs index 1ee7ed88..afbcc143 100644 --- a/iOverlay/src/bind/segment.rs +++ b/iOverlay/src/bind/segment.rs @@ -13,6 +13,11 @@ pub(crate) struct ContourIndex { impl ContourIndex { pub(crate) const EMPTY: ContourIndex = ContourIndex { data: usize::MAX }; + #[inline] + pub(crate) fn is_empty(&self) -> bool { + self.data == usize::MAX + } + #[inline] pub(crate) fn is_hole(&self) -> bool { self.data & 1 == 1 diff --git a/iOverlay/src/bind/solver.rs b/iOverlay/src/bind/solver.rs index 5ad69e62..8d8f730e 100644 --- a/iOverlay/src/bind/solver.rs +++ b/iOverlay/src/bind/solver.rs @@ -4,6 +4,7 @@ use crate::util::log::Int; use alloc::vec; use alloc::vec::Vec; use core::cmp::Ordering; +use core::ops::ControlFlow; use i_float::int::number::int::IntNumber; use i_float::int::point::IntPoint; use i_key_sort::sort::key::SortKey; @@ -24,57 +25,93 @@ pub(crate) struct ShapeBinder; impl ShapeBinder { #[inline] - pub(crate) fn bind( + pub(crate) fn bind_required( shape_count: usize, hole_segments: Vec>, segments: Vec>, ) -> BindSolution where I: IntNumber + Expiration, + { + let parent_for_child = vec![usize::MAX; hole_segments.len()]; + + Self::bind_with_resolver( + shape_count, + hole_segments, + segments, + parent_for_child, + Self::resolve_required_parent, + ) + } + + #[inline] + pub(crate) fn bind_optional( + shape_count: usize, + child_segments: Vec>, + segments: Vec>, + ) -> BindSolution + where + I: IntNumber + Expiration, + { + let parent_for_child = vec![usize::MAX; child_segments.len()]; + Self::bind_with_resolver( + shape_count, + child_segments, + segments, + parent_for_child, + Self::resolve_optional_parent, + ) + } + + fn bind_with_resolver( + shape_count: usize, + child_segments: Vec>, + segments: Vec>, + parent_for_child: Vec, + resolve_parent: F, + ) -> BindSolution + where + I: IntNumber + Expiration, + F: Fn(ContourIndex, &[usize]) -> ControlFlow<(), usize>, { if shape_count < 32 { let capacity = segments.len().log2_sqrt().max(4) * 2; let list = KeyExpList::new(capacity); - Self::private_solve::, I, ContourIndex>>( + Self::private_solve::, I, ContourIndex>, F>( list, shape_count, - hole_segments, + child_segments, segments, + parent_for_child, + resolve_parent, ) } else { let capacity = segments.len().log2_sqrt().max(8); let list = KeyExpTree::new(capacity); - Self::private_solve::, I, ContourIndex>>( + Self::private_solve::, I, ContourIndex>, F>( list, shape_count, - hole_segments, + child_segments, segments, + parent_for_child, + resolve_parent, ) } } - fn private_solve( + fn private_solve( mut scan_list: S, shape_count: usize, anchors: Vec>, segments: Vec>, + mut parent_for_child: Vec, + resolve_parent: F, ) -> BindSolution where I: IntNumber + Expiration, S: KeyExpCollection, I, ContourIndex>, + F: Fn(ContourIndex, &[usize]) -> ControlFlow<(), usize>, { - let children_count = anchors.len(); - let mut parent_for_child = { - #[cfg(debug_assertions)] - { - // prefer crash in debug mode - vec![usize::MAX; children_count] - } - #[cfg(not(debug_assertions))] - { - vec![0; children_count] - } - }; let mut children_count_for_parent = vec![0; shape_count]; let mut j = 0; @@ -95,15 +132,13 @@ impl ShapeBinder { } let target_id = scan_list.first_less(anchor.v_segment.a.x, ContourIndex::EMPTY, anchor.v_segment); - let parent_index = if target_id.is_hole() { - // index is a hole index - // at this moment this hole parent is known - parent_for_child[target_id.index()] - } else { - target_id.index() + let ControlFlow::Continue(parent_index) = resolve_parent(target_id, &parent_for_child) else { + continue; }; let child_index = anchor.contour_index.index(); + debug_assert!(child_index < parent_for_child.len()); + debug_assert!(parent_index < children_count_for_parent.len()); parent_for_child[child_index] = parent_index; children_count_for_parent[parent_index] += 1; @@ -114,6 +149,45 @@ impl ShapeBinder { children_count_for_parent, } } + + #[inline] + fn resolve_required_parent( + target_id: ContourIndex, + parent_for_child: &[usize], + ) -> ControlFlow<(), usize> { + ControlFlow::Continue(Self::target_parent(target_id, parent_for_child)) + } + + #[inline] + fn resolve_optional_parent( + target_id: ContourIndex, + parent_for_child: &[usize], + ) -> ControlFlow<(), usize> { + if target_id.is_empty() { + return ControlFlow::Break(()); + } + + let parent_index = Self::target_parent(target_id, parent_for_child); + if parent_index == usize::MAX { + // The scan can hit another root child before reaching empty space. + // Propagate its missing parent: this child is outside as well. + ControlFlow::Break(()) + } else { + ControlFlow::Continue(parent_index) + } + } + + #[inline] + fn target_parent(target_id: ContourIndex, parent_for_child: &[usize]) -> usize { + if target_id.is_hole() { + // index is a child index; at this moment its parent is known + let child_index = target_id.index(); + debug_assert!(child_index < parent_for_child.len()); + parent_for_child[child_index] + } else { + target_id.index() + } + } } pub(crate) trait JoinHoles { @@ -184,7 +258,7 @@ impl JoinHoles for Vec> { segments.sort_by_a_then_by_angle(); - let solution = ShapeBinder::bind(self.len(), hole_segments, segments); + let solution = ShapeBinder::bind_required(self.len(), hole_segments, segments); for (shape_index, &capacity) in solution.children_count_for_parent.iter().enumerate() { self[shape_index].reserve(capacity); diff --git a/iOverlay/src/core/extract.rs b/iOverlay/src/core/extract.rs index 1ec8db7d..408874b4 100644 --- a/iOverlay/src/core/extract.rs +++ b/iOverlay/src/core/extract.rs @@ -2,6 +2,7 @@ use super::overlay_rule::OverlayRule; use crate::bind::segment::{ContourIndex, IdSegment}; use crate::bind::solver::{JoinHoles, LeftBottomSegment}; use crate::core::graph::{OverlayGraph, OverlayNode}; +use crate::core::hierarchy::FlatShapeHierarchy; use crate::core::integer::OverlayInt; use crate::core::link::OverlayLink; use crate::core::link::OverlayLinkFilter; @@ -75,6 +76,21 @@ where } } + /// Extracts flat shapes and the immediate nesting relationships between them. + /// + /// Each link connects a hole contour to a shape directly contained by that + /// hole. Shapes absent from all links are standalone one-node trees. + #[inline] + pub fn extract_shape_hierarchy( + &self, + overlay_rule: OverlayRule, + buffer: &mut BooleanExtractionBuffer, + ) -> FlatShapeHierarchy { + let clockwise = self.options.output_direction == ContourDirection::Clockwise; + let shapes = self.extract_shapes(overlay_rule, buffer); + FlatShapeHierarchy::from_shapes(shapes, clockwise) + } + /// Extracts the flat contours from the overlay graph based on the specified overlay rule. /// /// This method performs a Boolean operation (e.g., union or intersection) and stores the result diff --git a/iOverlay/src/core/hierarchy.rs b/iOverlay/src/core/hierarchy.rs new file mode 100644 index 00000000..e3b73fdc --- /dev/null +++ b/iOverlay/src/core/hierarchy.rs @@ -0,0 +1,265 @@ +use crate::bind::segment::{ContourIndex, IdSegment, IdSegments}; +use crate::bind::solver::{LeftBottomSegment, ShapeBinder, SortByAngle}; +use alloc::vec::Vec; +use i_float::int::number::int::IntNumber; +use i_key_sort::sort::key::SortKey; +use i_key_sort::sort::two_keys_cmp::TwoKeysAndCmpSort; +use i_shape::flat::buffer::FlatShapesBuffer; +use i_shape::int::count::PointsCount; +use i_shape::int::shape::IntShapes; +use i_tree::Expiration; + +/// A direct relationship between a hole contour and a shape nested inside it. +/// +/// All indices address the flat buffers in [`FlatShapeHierarchy::shapes`]. +/// `parent_contour_index` is a global index into +/// [`FlatShapesBuffer::contour_ranges`], not an index local to the parent shape. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct ChildLink { + pub parent_shape_index: usize, + pub parent_contour_index: usize, + pub child_shape_index: usize, +} + +/// Flat boolean shapes together with their immediate nesting relationships. +/// +/// Shapes that do not occur in `links` are standalone one-node trees. A root +/// of a non-trivial tree occurs as a parent but never as a child. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FlatShapeHierarchy { + pub shapes: FlatShapesBuffer, + pub links: Vec, +} + +impl Default for FlatShapeHierarchy { + fn default() -> Self { + Self { + shapes: FlatShapesBuffer::default(), + links: Vec::new(), + } + } +} + +impl FlatShapeHierarchy +where + I: IntNumber + Expiration + SortKey, +{ + pub(crate) fn from_shapes(shapes: IntShapes, clockwise: bool) -> Self { + let links = Self::bind_links(&shapes, clockwise); + let shapes = Self::flatten(shapes); + + Self { shapes, links } + } + + fn bind_links(shapes: &IntShapes, clockwise: bool) -> Vec { + let shape_count = shapes.len(); + let hole_count = shapes.iter().map(|shape| shape.len().saturating_sub(1)).sum(); + if shape_count == 0 || hole_count == 0 { + return Vec::new(); + } + + let mut hole_owners = Vec::with_capacity(hole_count); + let mut contour_index = 0; + for (shape_index, shape) in shapes.iter().enumerate() { + for local_contour_index in 1..shape.len() { + hole_owners.push((shape_index, contour_index + local_contour_index)); + } + contour_index += shape.len(); + } + + let mut anchors = Vec::with_capacity(shape_count); + for (shape_index, shape) in shapes.iter().enumerate() { + let contour = &shape[0]; + anchors.push(IdSegment::with_segment( + ContourIndex::new_hole(shape_index), + contour.left_bottom_segment(), + )); + } + anchors.sort_by_a_then_by_angle(); + + let x_min = anchors[0].v_segment.a.x; + let x_max = anchors[anchors.len() - 1].v_segment.a.x; + let mut segments = Vec::with_capacity(shapes.points_count() / 2); + let mut hole_index = 0; + + for (shape_index, shape) in shapes.iter().enumerate() { + shape[0].append_id_segments( + &mut segments, + ContourIndex::new_hole(shape_index), + x_min, + x_max, + !clockwise, + ); + + for hole in shape.iter().skip(1) { + hole.append_id_segments( + &mut segments, + ContourIndex::new_shape(hole_index), + x_min, + x_max, + !clockwise, + ); + hole_index += 1; + } + } + + segments.sort_by_a_then_by_angle(); + let solution = ShapeBinder::bind_optional(hole_count, anchors, segments); + let mut links = Vec::with_capacity(shape_count.saturating_sub(1)); + + for (child_shape_index, parent_hole_index) in solution.parent_for_child.into_iter().enumerate() { + if parent_hole_index == usize::MAX { + continue; + } + + let (parent_shape_index, parent_contour_index) = hole_owners[parent_hole_index]; + links.push(ChildLink { + parent_shape_index, + parent_contour_index, + child_shape_index, + }); + } + + links.sort_by_two_keys_then_by( + false, + |link| link.parent_shape_index, + |link| link.parent_contour_index, + |a, b| a.child_shape_index.cmp(&b.child_shape_index), + ); + links + } + + fn flatten(shapes: IntShapes) -> FlatShapesBuffer { + let points_count = shapes.points_count(); + let contour_count = shapes.iter().map(Vec::len).sum(); + let shape_count = shapes.len(); + let mut flat = FlatShapesBuffer::with_capacity(points_count, contour_count, shape_count); + + for shape in shapes { + let shape_start = flat.contour_ranges.len(); + for contour in shape { + let point_start = flat.points.len(); + flat.points.extend(contour); + flat.contour_ranges.push(point_start..flat.points.len()); + } + flat.shape_ranges.push(shape_start..flat.contour_ranges.len()); + } + + flat + } +} + +#[cfg(test)] +mod tests { + use super::{ChildLink, FlatShapeHierarchy}; + use crate::core::fill_rule::FillRule; + use crate::core::overlay::{ContourDirection, Overlay}; + use crate::core::overlay_rule::OverlayRule; + use alloc::vec; + use i_shape::int_shape; + + #[test] + fn default_hierarchy_is_empty() { + let hierarchy = FlatShapeHierarchy::::default(); + + assert!(hierarchy.shapes.points.is_empty()); + assert!(hierarchy.shapes.contour_ranges.is_empty()); + assert!(hierarchy.shapes.shape_ranges.is_empty()); + assert!(hierarchy.links.is_empty()); + } + + #[test] + fn nested_shapes_form_a_link_chain() { + #[rustfmt::skip] + let subject = int_shape![ + [[0, 0], [100, 0], [100, 100], [0, 100]], + [[10, 10], [10, 90], [90, 90], [90, 10]], + [[20, 20], [80, 20], [80, 80], [20, 80]], + [[30, 30], [30, 70], [70, 70], [70, 30]], + [[40, 40], [60, 40], [60, 60], [40, 60]], + ]; + + let mut overlay = Overlay::with_contours(&subject, &[]); + let hierarchy = overlay.overlay_hierarchy(OverlayRule::Subject, FillRule::EvenOdd); + let mut regular_overlay = Overlay::with_contours(&subject, &[]); + let regular_shapes = regular_overlay.overlay(OverlayRule::Subject, FillRule::EvenOdd); + + assert_eq!(hierarchy.shapes.to_shapes(), regular_shapes); + assert_eq!(hierarchy.shapes.shape_ranges, vec![0..2, 2..4, 4..5]); + assert_eq!( + hierarchy.links, + vec![ + ChildLink { + parent_shape_index: 0, + parent_contour_index: 1, + child_shape_index: 1, + }, + ChildLink { + parent_shape_index: 1, + parent_contour_index: 3, + child_shape_index: 2, + }, + ] + ); + } + + #[test] + fn one_hole_can_have_multiple_children() { + #[rustfmt::skip] + let subject = int_shape![ + [[0, 0], [100, 0], [100, 100], [0, 100]], + [[10, 10], [10, 90], [90, 90], [90, 10]], + [[20, 20], [30, 20], [30, 30], [20, 30]], + [[60, 60], [70, 60], [70, 70], [60, 70]], + ]; + + let mut overlay = Overlay::with_contours(&subject, &[]); + let hierarchy = overlay.overlay_hierarchy(OverlayRule::Subject, FillRule::EvenOdd); + + assert_eq!(hierarchy.shapes.shape_ranges, vec![0..2, 2..3, 3..4]); + assert_eq!( + hierarchy.links, + vec![ + ChildLink { + parent_shape_index: 0, + parent_contour_index: 1, + child_shape_index: 1, + }, + ChildLink { + parent_shape_index: 0, + parent_contour_index: 1, + child_shape_index: 2, + }, + ] + ); + } + + #[test] + fn standalone_shape_is_absent_from_links() { + #[rustfmt::skip] + let subject = int_shape![ + [[0, 0], [100, 0], [100, 100], [0, 100]], + [[10, 10], [10, 90], [90, 90], [90, 10]], + [[20, 20], [30, 20], [30, 30], [20, 30]], + [[200, 0], [210, 0], [210, 10], [200, 10]], + ]; + + let mut overlay = Overlay::with_contours(&subject, &[]); + overlay.options.output_direction = ContourDirection::Clockwise; + let hierarchy = overlay.overlay_hierarchy(OverlayRule::Subject, FillRule::EvenOdd); + + assert_eq!(hierarchy.shapes.shape_ranges.len(), 3); + assert_eq!(hierarchy.links.len(), 1); + + let linked = &hierarchy.links[0]; + assert_eq!(linked.parent_shape_index, 0); + assert_eq!(linked.parent_contour_index, 1); + assert_eq!(linked.child_shape_index, 1); + assert!( + hierarchy + .links + .iter() + .all(|link| link.parent_shape_index != 2 && link.child_shape_index != 2) + ); + } +} diff --git a/iOverlay/src/core/mod.rs b/iOverlay/src/core/mod.rs index 0277e466..5ff21ca7 100644 --- a/iOverlay/src/core/mod.rs +++ b/iOverlay/src/core/mod.rs @@ -5,11 +5,13 @@ pub mod extract; mod extract_ogc; pub mod fill_rule; pub mod graph; +pub mod hierarchy; pub mod integer; pub(crate) mod link; pub(crate) mod nearest_vector; pub mod overlay; pub mod overlay_rule; +pub mod point_location; pub mod predicate; pub mod relate; pub mod simplify; diff --git a/iOverlay/src/core/overlay.rs b/iOverlay/src/core/overlay.rs index 50dfb360..98fa06a4 100644 --- a/iOverlay/src/core/overlay.rs +++ b/iOverlay/src/core/overlay.rs @@ -5,6 +5,7 @@ use crate::build::builder::GraphBuilder; use crate::core::extract::BooleanExtractionBuffer; use crate::core::fill_rule::FillRule; +use crate::core::hierarchy::FlatShapeHierarchy; use crate::core::integer::OverlayInt; use crate::core::overlay_rule::OverlayRule; use crate::core::solver::Solver; @@ -371,6 +372,36 @@ where shapes } + /// Executes a Boolean operation and returns flat shapes together with their + /// immediate nesting relationships. + /// + /// A hierarchy link associates a hole contour with each shape directly + /// contained by that hole. Independent shapes do not occur in the link list. + #[inline] + pub fn overlay_hierarchy( + &mut self, + overlay_rule: OverlayRule, + fill_rule: FillRule, + ) -> FlatShapeHierarchy { + self.split_solver.split_segments(&mut self.segments, &self.solver); + if self.segments.is_empty() { + return FlatShapeHierarchy::default(); + } + let mut buffer = self.boolean_buffer.take().unwrap_or_default(); + let hierarchy = self + .graph_builder + .build_boolean_overlay( + fill_rule, + overlay_rule, + self.options, + &self.solver, + &self.segments, + ) + .extract_shape_hierarchy(overlay_rule, &mut buffer); + self.boolean_buffer = Some(buffer); + hierarchy + } + /// Executes a single Boolean operation and writes the result into a flat contour buffer. /// /// This is a lower-allocation alternative to [`Self::overlay`] when you want flat contour diff --git a/iOverlay/src/core/point_location.rs b/iOverlay/src/core/point_location.rs new file mode 100644 index 00000000..ce1d58a6 --- /dev/null +++ b/iOverlay/src/core/point_location.rs @@ -0,0 +1,422 @@ +//! Batched point-in-polygon queries for integer geometry. + +use crate::core::integer::OverlayInt; +use crate::core::overlay::ShapeType; +use crate::geom::end::End; +use crate::geom::v_segment::VSegment; +use crate::segm::boolean::ShapeCountBoolean; +use crate::segm::build::BuildSegments; +use crate::segm::segment::Segment; +use crate::segm::sort::ShapeSegmentsSort; +use crate::segm::winding::WindingCount; +use crate::util::log::Int; +use alloc::vec; +use alloc::vec::Vec; +use i_float::int::point::IntPoint; +use i_float::triangle::Triangle; +use i_key_sort::sort::two_keys::TwoKeysSort; +use i_shape::int::shape::{IntContour, IntShape}; +use i_tree::key::exp::KeyExpCollection; +use i_tree::key::list::KeyExpList; +use i_tree::key::tree::KeyExpTree; + +const MAX_LIST_EDGE_COUNT: usize = 8_000; + +const EMPTY_COUNT: ShapeCountBoolean = ShapeCountBoolean { subj: 0, clip: 0 }; + +#[derive(Clone, Copy)] +struct QueryPoint { + point: IntPoint, + index: usize, +} + +/// Convenience methods for one-shot batched point-in-polygon queries. +/// +/// Every shape must have resolved topology, for example as the result of a +/// `simplify_shape` operation. A collection of shapes is evaluated as their +/// union. +/// +/// # Example +/// +/// ``` +/// use i_overlay::core::point_location::IntPointContainment; +/// use i_overlay::i_float::int::point::IntPoint; +/// +/// let contour = [ +/// IntPoint::new(0, 0), +/// IntPoint::new(10, 0), +/// IntPoint::new(10, 10), +/// IntPoint::new(0, 10), +/// ]; +/// let points = [IntPoint::new(5, 5), IntPoint::new(20, 5)]; +/// +/// assert_eq!(contour.contains_points(&points), [true, false]); +/// ``` +pub trait IntPointContainment { + /// Tests whether each point is strictly inside this geometry. + /// + /// Points on contour boundaries are outside the method's contract. + fn contains_points(&self, points: &[IntPoint]) -> Vec; +} + +impl IntPointContainment for [IntPoint] { + #[inline] + fn contains_points(&self, points: &[IntPoint]) -> Vec { + contains_points_in_valid_contours(core::iter::once(self), points) + } +} + +impl IntPointContainment for [IntContour] { + #[inline] + fn contains_points(&self, points: &[IntPoint]) -> Vec { + contains_points_in_valid_contours(self.iter().map(Vec::as_slice), points) + } +} + +impl IntPointContainment for [IntShape] { + #[inline] + fn contains_points(&self, points: &[IntPoint]) -> Vec { + let mut result = vec![false; points.len()]; + for shape in self { + let shape_result = contains_points_in_valid_contours(shape.iter().map(Vec::as_slice), points); + for (contains, shape_contains) in result.iter_mut().zip(shape_result) { + *contains |= shape_contains; + } + } + result + } +} + +fn contains_points_in_valid_contours<'a, I, It>(contours: It, points: &[IntPoint]) -> Vec +where + I: OverlayInt + 'a, + It: IntoIterator]>, +{ + if points.is_empty() { + return Vec::new(); + } + + let mut queries: Vec<_> = points + .iter() + .copied() + .enumerate() + .map(|(index, point)| QueryPoint { point, index }) + .collect(); + queries.sort_by_two_keys(false, |query| query.point.x, |query| query.point.y); + + let mut result = vec![false; points.len()]; + let mut contour_result = vec![false; points.len()]; + + for contour in contours { + let mut segments = Vec::with_capacity(contour.len()); + segments.append_path_iter(contour.iter().copied(), ShapeType::Subject, false); + if segments.is_empty() { + continue; + } + segments.sort_by_ab(false); + contour_result.fill(false); + + if segments.len() < MAX_LIST_EDGE_COUNT { + let capacity = segments.len().log2_sqrt().max(4) * 2; + let mut list = KeyExpList::new(capacity); + contains_with_scan(&mut list, &segments, &queries, &mut contour_result); + } else { + let capacity = segments.len().log2_sqrt().max(8); + let mut tree = KeyExpTree::new(capacity); + contains_with_scan(&mut tree, &segments, &queries, &mut contour_result); + } + + for (contains, contour_contains) in result.iter_mut().zip(&contour_result) { + *contains ^= contour_contains; + } + } + + result +} + +fn contains_with_scan( + scan: &mut S, + segments: &[Segment], + queries: &[QueryPoint], + result: &mut [bool], +) where + I: OverlayInt, + S: KeyExpCollection, I, ShapeCountBoolean>, +{ + let mut node = Vec::with_capacity(4); + let mut segment_index = 0; + + for query in queries { + while segment_index < segments.len() && segments[segment_index].x_segment.a.x <= query.point.x { + let p = segments[segment_index].x_segment.a; + node.push(End { + index: segment_index, + point: segments[segment_index].x_segment.b, + }); + segment_index += 1; + + while segment_index < segments.len() && segments[segment_index].x_segment.a == p { + node.push(End { + index: segment_index, + point: segments[segment_index].x_segment.b, + }); + segment_index += 1; + } + + if node.len() > 1 { + node.sort_by(|a, b| Triangle::clock_order(p, b.point, a.point)); + } + + let mut sum = scan.first_less_or_equal_by(p.x, EMPTY_COUNT, |s| s.is_under_point_order(p)); + + for end in &node { + let segment = &segments[end.index]; + sum = sum.add(segment.count); + + if segment.x_segment.is_not_vertical() { + scan.insert(segment.x_segment.into(), sum, p.x); + } + } + + node.clear(); + } + + let count = scan.first_less_or_equal_by(query.point.x, EMPTY_COUNT, |segment| { + Triangle::clock_order(segment.a, query.point, segment.b) + }); + + result[query.index] = is_filled(count.subj); + } +} + +#[inline(always)] +fn is_filled(count: i32) -> bool { + count & 1 != 0 +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::fill_rule::FillRule; + use crate::core::overlay::IntOverlayOptions; + use crate::core::simplify::Simplify; + use alloc::vec; + use i_shape::int::path::ContourExtension; + + fn ccw_square(min: i32, max: i32) -> IntContour { + vec![ + IntPoint::new(min, min), + IntPoint::new(max, min), + IntPoint::new(max, max), + IntPoint::new(min, max), + ] + } + + #[test] + fn contains_inside_points_and_preserves_order() { + let contour = ccw_square(0, 10); + let points = [ + IntPoint::new(5, 5), + IntPoint::new(-1, 5), + IntPoint::new(2, 8), + IntPoint::new(20, 5), + IntPoint::new(5, 5), + ]; + + assert_eq!( + contour.contains_points(&points), + vec![true, false, true, false, true] + ); + } + + #[test] + fn supports_holes_and_preserves_query_order() { + let mut hole = ccw_square(3, 7); + hole.reverse(); + let shape = [ccw_square(0, 10), hole]; + let points = [ + IntPoint::new(5, 5), + IntPoint::new(1, 1), + IntPoint::new(2, 5), + IntPoint::new(5, 5), + IntPoint::new(20, 20), + ]; + + assert_eq!( + shape.contains_points(&points), + vec![false, true, true, false, false] + ); + } + + #[test] + fn accepts_either_outer_contour_direction() { + let ccw = ccw_square(0, 10); + let mut cw = ccw.clone(); + cw.reverse(); + let point = [IntPoint::new(5, 5)]; + + assert_eq!(ccw.contains_points(&point), vec![true]); + assert_eq!(cw.contains_points(&point), vec![true]); + } + + #[test] + fn supports_i64_and_multiple_vertical_ranges() { + let contours = [ + vec![ + IntPoint::::new(0, 0), + IntPoint::new(10, 0), + IntPoint::new(10, 10), + IntPoint::new(0, 10), + ], + vec![ + IntPoint::new(0, 20), + IntPoint::new(10, 20), + IntPoint::new(10, 30), + IntPoint::new(0, 30), + ], + ]; + let points = [IntPoint::new(5, 5), IntPoint::new(5, 15), IntPoint::new(5, 25)]; + + assert_eq!(contours.contains_points(&points), vec![true, false, true]); + } + + #[test] + fn contains_points_in_simplified_shapes() { + let contours = [ccw_square(0, 10), ccw_square(5, 15)]; + let shapes = contours + .as_slice() + .simplify(FillRule::NonZero, IntOverlayOptions::default()); + let points = [ + IntPoint::new(2, 2), + IntPoint::new(7, 7), + IntPoint::new(12, 12), + IntPoint::new(2, 12), + ]; + + assert_eq!(shapes.contains_points(&points), vec![true, true, true, false]); + } + + #[test] + fn vertical_edges_update_winding_but_are_not_stored_in_scan() { + let contour = [ + IntPoint::new(0, 0), + IntPoint::new(10, 0), + IntPoint::new(10, 10), + IntPoint::new(5, 10), + IntPoint::new(5, 5), + IntPoint::new(0, 5), + ]; + let points = [ + IntPoint::new(5, 2), + IntPoint::new(4, 7), + IntPoint::new(6, 7), + IntPoint::new(5, 12), + ]; + + assert_eq!(contour.contains_points(&points), vec![true, false, true, false]); + } + + #[test] + fn random_simplified_contours_match_contains_point_in_50_by_50_space() { + for iteration in 0..256_u64 { + let seed = next_test_seed(iteration); + let mut rng = TestRng::new(seed); + let contour_count = rng.range_usize(1, 4); + let mut contours = Vec::with_capacity(contour_count); + + for _ in 0..contour_count { + let point_count = rng.range_usize(3, 20); + let mut contour = Vec::with_capacity(point_count); + for _ in 0..point_count { + contour.push(IntPoint::new(rng.range_i32(0, 50), rng.range_i32(0, 50))); + } + contours.push(contour); + } + + let shapes = contours + .as_slice() + .simplify(FillRule::NonZero, IntOverlayOptions::ogc()); + let mut points = Vec::with_capacity(51 * 51); + for y in 0..=50 { + for x in 0..=50 { + let point = IntPoint::new(x, y); + if !is_on_boundary(&shapes, point) { + points.push(point); + } + } + } + rng.shuffle(&mut points); + + let actual = shapes.contains_points(&points); + for (index, &point) in points.iter().enumerate() { + let expected = shapes.iter().any(|shape| { + shape.iter().fold(false, |contains, contour| { + contains ^ contour.contains_point(point) + }) + }); + assert_eq!( + actual[index], expected, + "iteration={iteration} seed={seed} point={point} contours={contours:?} shapes={shapes:?}" + ); + } + } + } + + fn is_on_boundary(shapes: &[IntShape], point: IntPoint) -> bool { + shapes.iter().flatten().any(|contour| { + let Some(&last) = contour.last() else { + return false; + }; + let mut a = last; + contour.iter().any(|&b| { + let contains = Triangle::is_line(a, point, b) + && a.x.min(b.x) <= point.x + && point.x <= a.x.max(b.x) + && a.y.min(b.y) <= point.y + && point.y <= a.y.max(b.y); + a = b; + contains + }) + }) + } + + struct TestRng { + state: u64, + } + + impl TestRng { + fn new(seed: u64) -> Self { + Self { + state: seed ^ 0xa076_1d64_78bd_642f, + } + } + + fn range_usize(&mut self, min: usize, max: usize) -> usize { + min + self.next_u32() as usize % (max - min + 1) + } + + fn range_i32(&mut self, min: i32, max: i32) -> i32 { + min + (self.next_u32() % (max - min + 1) as u32) as i32 + } + + fn shuffle(&mut self, values: &mut [T]) { + for index in (1..values.len()).rev() { + let target = self.range_usize(0, index); + values.swap(index, target); + } + } + + fn next_u32(&mut self) -> u32 { + self.state = self + .state + .wrapping_mul(0xe703_7ed1_a0b4_28db) + .wrapping_add(0x8ebc_6af0_9c88_c6e3); + (self.state >> 32) as u32 + } + } + + fn next_test_seed(seed: u64) -> u64 { + seed.wrapping_mul(0xe703_7ed1_a0b4_28db) + .wrapping_add(0x8ebc_6af0_9c88_c6e3) + } +} diff --git a/iOverlay/src/float/graph.rs b/iOverlay/src/float/graph.rs index 36303699..eb123a1e 100644 --- a/iOverlay/src/float/graph.rs +++ b/iOverlay/src/float/graph.rs @@ -6,6 +6,7 @@ use crate::core::extract::BooleanExtractionBuffer; use crate::core::graph::OverlayGraph; use crate::core::integer::OverlayInt; use crate::core::overlay_rule::OverlayRule; +use crate::float::hierarchy::FloatFlatShapeHierarchy; use i_float::adapter::FloatPointAdapter; use i_float::float::compatible::FloatPointCompatible; use i_float::int::number::int::IntNumber; @@ -77,4 +78,21 @@ where float } + + /// Extracts flat float shapes and their immediate nesting relationships. + #[inline] + pub fn extract_shape_hierarchy( + &self, + overlay_rule: OverlayRule, + buffer: &mut BooleanExtractionBuffer, + ) -> FloatFlatShapeHierarchy

{ + let preserve_output_collinear = self.graph.options.preserve_output_collinear; + let hierarchy = self.graph.extract_shape_hierarchy(overlay_rule, buffer); + FloatFlatShapeHierarchy::from_int( + hierarchy, + &self.adapter, + self.clean_result, + preserve_output_collinear, + ) + } } diff --git a/iOverlay/src/float/hierarchy.rs b/iOverlay/src/float/hierarchy.rs new file mode 100644 index 00000000..964a70ea --- /dev/null +++ b/iOverlay/src/float/hierarchy.rs @@ -0,0 +1,356 @@ +use crate::core::hierarchy::{ChildLink, FlatShapeHierarchy}; +use alloc::vec; +use alloc::vec::Vec; +use i_float::adapter::FloatPointAdapter; +use i_float::float::compatible::FloatPointCompatible; +use i_float::int::number::int::IntNumber; +use i_shape::flat::float::FloatFlatShapesBuffer; +use i_shape::float::despike::DeSpikeContour; +use i_shape::float::simple::SimplifyContour; + +/// Flat float shapes together with their immediate nesting relationships. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FloatFlatShapeHierarchy

{ + pub shapes: FloatFlatShapesBuffer

, + pub links: Vec, +} + +impl

Default for FloatFlatShapeHierarchy

{ + fn default() -> Self { + Self { + shapes: FloatFlatShapesBuffer::with_capacity(0, 0, 0), + links: Vec::new(), + } + } +} + +impl FloatFlatShapeHierarchy

{ + pub(crate) fn from_int( + hierarchy: FlatShapeHierarchy, + adapter: &FloatPointAdapter, + clean_result: bool, + preserve_output_collinear: bool, + ) -> Self { + let int_shapes = hierarchy.shapes; + if !clean_result { + let mut shapes = FloatFlatShapesBuffer::with_capacity( + int_shapes.points.len(), + int_shapes.contour_ranges.len(), + int_shapes.shape_ranges.len(), + ); + let points = int_shapes.points.iter().map(|point| adapter.int_to_float(point)); + shapes.set_with_iter(points, &int_shapes.contour_ranges, &int_shapes.shape_ranges); + return Self { + shapes, + links: hierarchy.links, + }; + } + + let mut shapes = FloatFlatShapesBuffer::with_capacity( + int_shapes.points.len(), + int_shapes.contour_ranges.len(), + int_shapes.shape_ranges.len(), + ); + let mut shape_map = vec![usize::MAX; int_shapes.shape_ranges.len()]; + let mut contour_map = vec![usize::MAX; int_shapes.contour_ranges.len()]; + + for (old_shape_index, old_shape_range) in int_shapes.shape_ranges.iter().enumerate() { + let new_shape_start = shapes.contour_ranges.len(); + let mut hull_is_empty = false; + + for old_contour_index in old_shape_range.clone() { + let point_range = int_shapes.contour_ranges[old_contour_index].clone(); + let mut contour: Vec

= int_shapes.points[point_range] + .iter() + .map(|point| adapter.int_to_float(point)) + .collect(); + + if preserve_output_collinear { + contour.despike_contour(adapter); + } else { + contour.simplify_contour(adapter); + } + + if contour.is_empty() { + if old_contour_index == old_shape_range.start { + hull_is_empty = true; + break; + } + continue; + } + + contour_map[old_contour_index] = shapes.contour_ranges.len(); + let point_start = shapes.points.len(); + shapes.points.extend(contour); + shapes.contour_ranges.push(point_start..shapes.points.len()); + } + + if hull_is_empty { + shapes.points.truncate( + shapes + .contour_ranges + .get(new_shape_start) + .map_or(shapes.points.len(), |range| range.start), + ); + shapes.contour_ranges.truncate(new_shape_start); + for old_contour_index in old_shape_range.clone() { + contour_map[old_contour_index] = usize::MAX; + } + continue; + } + + shape_map[old_shape_index] = shapes.shape_ranges.len(); + shapes + .shape_ranges + .push(new_shape_start..shapes.contour_ranges.len()); + } + + let mut links = Vec::with_capacity(hierarchy.links.len()); + for link in hierarchy.links { + let parent_shape_index = shape_map[link.parent_shape_index]; + let parent_contour_index = contour_map[link.parent_contour_index]; + let child_shape_index = shape_map[link.child_shape_index]; + + if parent_shape_index == usize::MAX + || parent_contour_index == usize::MAX + || child_shape_index == usize::MAX + { + continue; + } + + links.push(ChildLink { + parent_shape_index, + parent_contour_index, + child_shape_index, + }); + } + debug_assert!(links.windows(2).all(|pair| pair[0] <= pair[1])); + + Self { shapes, links } + } +} + +#[cfg(test)] +mod tests { + use crate::core::extract::BooleanExtractionBuffer; + use crate::core::fill_rule::FillRule; + use crate::core::hierarchy::{ChildLink, FlatShapeHierarchy}; + use crate::core::overlay_rule::OverlayRule; + use crate::core::solver::Solver; + use crate::float::hierarchy::FloatFlatShapeHierarchy; + use crate::float::overlay::{FloatOverlay, OverlayOptions}; + use alloc::vec; + use alloc::vec::Vec; + use i_float::adapter::FloatPointAdapter; + use i_float::float::rect::FloatRect; + use i_float::int::point::IntPoint; + use i_shape::flat::buffer::FlatShapesBuffer; + + #[test] + fn float_overlay_exports_nested_hierarchy() { + let subject = nested_subject::(); + let clip: Vec> = Vec::new(); + let mut overlay = FloatOverlay::with_subj_and_clip(&subject, &clip); + let hierarchy = overlay.overlay_hierarchy(OverlayRule::Subject, FillRule::EvenOdd); + + assert_eq!(hierarchy.shapes.shape_ranges, vec![0..2, 2..4, 4..5]); + assert_eq!( + hierarchy.links, + vec![ + ChildLink { + parent_shape_index: 0, + parent_contour_index: 1, + child_shape_index: 1, + }, + ChildLink { + parent_shape_index: 1, + parent_contour_index: 3, + child_shape_index: 2, + }, + ] + ); + } + + #[test] + fn float_graph_exports_nested_hierarchy_with_clean_result() { + let subject = nested_subject::(); + let clip: Vec> = Vec::new(); + let mut overlay = FloatOverlay::with_subj_and_clip(&subject, &clip); + let graph = overlay.build_graph_view(FillRule::EvenOdd).unwrap(); + let mut buffer = BooleanExtractionBuffer::default(); + let hierarchy = graph.extract_shape_hierarchy(OverlayRule::Subject, &mut buffer); + + assert_eq!(hierarchy.shapes.shape_ranges, vec![0..2, 2..4, 4..5]); + assert_eq!(hierarchy.links.len(), 2); + } + + #[test] + fn float_graph_hierarchy_covers_other_cleaning_modes() { + let subject = nested_subject::(); + let mut options = OverlayOptions::::default(); + options.preserve_output_collinear = true; + let mut overlay = FloatOverlay::from_subj_custom(&subject, options, Solver::default()); + let graph = overlay.build_graph_view(FillRule::EvenOdd).unwrap(); + let mut buffer = BooleanExtractionBuffer::default(); + let preserved = graph.extract_shape_hierarchy(OverlayRule::Subject, &mut buffer); + + assert_eq!(preserved.shapes.shape_ranges, vec![0..2, 2..4, 4..5]); + assert_eq!(preserved.links.len(), 2); + + let subject = nested_subject::(); + let mut overlay = FloatOverlay::<[f64; 2], i32>::from_subj(&subject); + let graph = overlay.build_graph_view(FillRule::EvenOdd).unwrap(); + let mut buffer = BooleanExtractionBuffer::default(); + let uncleaned = graph.extract_shape_hierarchy(OverlayRule::Subject, &mut buffer); + + assert_eq!(uncleaned.shapes.shape_ranges, vec![0..2, 2..4, 4..5]); + assert_eq!(uncleaned.links.len(), 2); + } + + #[test] + fn default_hierarchy_is_empty() { + let hierarchy = FloatFlatShapeHierarchy::<[f64; 2]>::default(); + + assert!(hierarchy.shapes.points.is_empty()); + assert!(hierarchy.shapes.contour_ranges.is_empty()); + assert!(hierarchy.shapes.shape_ranges.is_empty()); + assert!(hierarchy.links.is_empty()); + } + + #[test] + fn clean_result_preserves_valid_collinear_mode_contour() { + let int_hierarchy = FlatShapeHierarchy { + shapes: FlatShapesBuffer { + points: vec![ + IntPoint::new(0, 0), + IntPoint::new(10, 0), + IntPoint::new(10, 10), + IntPoint::new(0, 10), + ], + contour_ranges: vec![0..4], + shape_ranges: vec![0..1], + }, + links: vec![], + }; + let adapter = + FloatPointAdapter::<[f64; 2], i32>::with_scale(FloatRect::new(-10.0, 20.0, -10.0, 20.0), 1.0); + + let hierarchy = FloatFlatShapeHierarchy::from_int(int_hierarchy, &adapter, true, true); + + assert_eq!(hierarchy.shapes.shape_ranges, vec![0..1]); + assert_eq!(hierarchy.shapes.contour_ranges, vec![0..4]); + assert!(hierarchy.links.is_empty()); + } + + #[test] + fn clean_result_drops_empty_hull_and_remaps_surviving_link() { + let int_hierarchy = FlatShapeHierarchy { + shapes: FlatShapesBuffer { + points: vec![ + IntPoint::new(0, 0), + IntPoint::new(20, 0), + IntPoint::new(20, 20), + IntPoint::new(0, 20), + IntPoint::new(5, 5), + IntPoint::new(5, 15), + IntPoint::new(15, 15), + IntPoint::new(15, 5), + IntPoint::new(6, 6), + IntPoint::new(7, 6), + IntPoint::new(8, 6), + IntPoint::new(7, 7), + IntPoint::new(9, 7), + IntPoint::new(9, 9), + IntPoint::new(7, 9), + ], + contour_ranges: vec![0..4, 4..8, 8..11, 11..15], + shape_ranges: vec![0..2, 2..3, 3..4], + }, + links: vec![ + ChildLink { + parent_shape_index: 0, + parent_contour_index: 1, + child_shape_index: 1, + }, + ChildLink { + parent_shape_index: 0, + parent_contour_index: 1, + child_shape_index: 2, + }, + ChildLink { + parent_shape_index: 1, + parent_contour_index: 2, + child_shape_index: 2, + }, + ], + }; + let adapter = + FloatPointAdapter::<[f64; 2], i32>::with_scale(FloatRect::new(-10.0, 30.0, -10.0, 30.0), 1.0); + + let hierarchy = FloatFlatShapeHierarchy::from_int(int_hierarchy, &adapter, true, false); + + assert_eq!(hierarchy.shapes.shape_ranges, vec![0..2, 2..3]); + assert_eq!( + hierarchy.links, + vec![ChildLink { + parent_shape_index: 0, + parent_contour_index: 1, + child_shape_index: 1, + }] + ); + } + + #[test] + fn clean_result_remaps_removed_hole() { + let int_hierarchy = FlatShapeHierarchy { + shapes: FlatShapesBuffer { + points: vec![ + IntPoint::new(0, 0), + IntPoint::new(10, 0), + IntPoint::new(10, 10), + IntPoint::new(0, 10), + IntPoint::new(2, 2), + IntPoint::new(3, 2), + IntPoint::new(4, 2), + IntPoint::new(3, 3), + IntPoint::new(4, 3), + IntPoint::new(4, 4), + IntPoint::new(3, 4), + ], + contour_ranges: vec![0..4, 4..7, 7..11], + shape_ranges: vec![0..2, 2..3], + }, + links: vec![ChildLink { + parent_shape_index: 0, + parent_contour_index: 1, + child_shape_index: 1, + }], + }; + let adapter = + FloatPointAdapter::<[f64; 2], i32>::with_scale(FloatRect::new(-10.0, 20.0, -10.0, 20.0), 1.0); + + let hierarchy = FloatFlatShapeHierarchy::from_int(int_hierarchy, &adapter, true, false); + + assert_eq!(hierarchy.shapes.shape_ranges, vec![0..1, 1..2]); + assert_eq!(hierarchy.shapes.contour_ranges.len(), 2); + assert!(hierarchy.links.is_empty()); + } + + fn nested_subject + Copy>() -> Vec> { + [ + [[0.0, 0.0], [100.0, 0.0], [100.0, 100.0], [0.0, 100.0]], + [[10.0, 10.0], [10.0, 90.0], [90.0, 90.0], [90.0, 10.0]], + [[20.0, 20.0], [80.0, 20.0], [80.0, 80.0], [20.0, 80.0]], + [[30.0, 30.0], [30.0, 70.0], [70.0, 70.0], [70.0, 30.0]], + [[40.0, 40.0], [60.0, 40.0], [60.0, 60.0], [40.0, 60.0]], + ] + .into_iter() + .map(|contour| { + contour + .into_iter() + .map(|[x, y]| [F::from(x), F::from(y)]) + .collect() + }) + .collect() + } +} diff --git a/iOverlay/src/float/mod.rs b/iOverlay/src/float/mod.rs index 4a3c14a7..5d81ace5 100644 --- a/iOverlay/src/float/mod.rs +++ b/iOverlay/src/float/mod.rs @@ -1,5 +1,6 @@ pub mod clip; pub mod graph; +pub mod hierarchy; pub mod overlay; pub mod relate; pub mod scale; diff --git a/iOverlay/src/float/overlay.rs b/iOverlay/src/float/overlay.rs index e8999178..a02996b6 100644 --- a/iOverlay/src/float/overlay.rs +++ b/iOverlay/src/float/overlay.rs @@ -8,6 +8,7 @@ use crate::core::overlay::{ContourDirection, IntOverlayOptions, Overlay, ShapeTy use crate::core::overlay_rule::OverlayRule; use crate::core::solver::Solver; use crate::float::graph::FloatOverlayGraph; +use crate::float::hierarchy::FloatFlatShapeHierarchy; use crate::i_shape::source::resource::ShapeResource; use core::marker::PhantomData; use i_float::adapter::FloatPointAdapter; @@ -367,6 +368,24 @@ where float } + /// Executes a Boolean operation and returns flat float shapes together with + /// their immediate nesting relationships. + #[inline] + pub fn overlay_hierarchy( + &mut self, + overlay_rule: OverlayRule, + fill_rule: FillRule, + ) -> FloatFlatShapeHierarchy

{ + let preserve_output_collinear = self.overlay.options.preserve_output_collinear; + let hierarchy = self.overlay.overlay_hierarchy(overlay_rule, fill_rule); + FloatFlatShapeHierarchy::from_int( + hierarchy, + &self.adapter, + self.clean_result, + preserve_output_collinear, + ) + } + /// Executes a single Boolean operation and writes the result into a flat contour buffer. /// /// This is a lower-allocation alternative to [`Self::overlay`] when you want flat contour diff --git a/iOverlay/src/mesh/mod.rs b/iOverlay/src/mesh/mod.rs index e56f2294..3350e09b 100644 --- a/iOverlay/src/mesh/mod.rs +++ b/iOverlay/src/mesh/mod.rs @@ -6,3 +6,4 @@ mod rotator; pub mod stroke; pub mod style; mod subject; +pub mod variable_stroke; diff --git a/iOverlay/src/mesh/variable_stroke/builder.rs b/iOverlay/src/mesh/variable_stroke/builder.rs new file mode 100644 index 00000000..a4bbb5d0 --- /dev/null +++ b/iOverlay/src/mesh/variable_stroke/builder.rs @@ -0,0 +1,1059 @@ +use crate::mesh::rotator::Rotator; +use crate::mesh::variable_stroke::section::{RadiusTrend, Section}; +use crate::mesh::variable_stroke::style::{StrokeVertex, VariableStrokeStyle}; +use crate::segm::boolean::ShapeCountBoolean; +use crate::segm::segment::Segment; +use alloc::vec::Vec; +use core::f64::consts::PI; +use i_float::adapter::FloatPointAdapter; +use i_float::float::compatible::FloatPointCompatible; +use i_float::float::number::FloatNumber; +use i_float::float::vector::FloatPointMath; +use i_float::int::number::int::IntNumber; +use i_float::int::number::wide_int::WideIntNumber; + +#[cfg(feature = "variable_stroke_debug")] +use crate::mesh::variable_stroke::{VariableStrokeDebugEdge, VariableStrokeDebugEdgeKind}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Cap { + Butt, + Round, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ArcSweep { + Minor, + Major, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct SubSegment { + start: usize, + end: usize, + start_cap: Cap, + end_cap: Cap, +} + +pub(super) struct VariableStrokeBuilder { + round_angle: T, +} + +impl VariableStrokeBuilder { + pub(super) fn new(style: VariableStrokeStyle) -> Self { + Self { + round_angle: style.normalized().round_angle, + } + } + + pub(super) fn build( + &self, + path: &[StrokeVertex

], + adapter: &FloatPointAdapter, + segments: &mut Vec>, + ) where + P: FloatPointCompatible, + I: IntNumber, + { + if path.is_empty() { + return; + } + + let subsegments = Self::find_subsegments(path, adapter); + let mut output = SegmentBuilder { + adapter, + segments, + #[cfg(feature = "variable_stroke_debug")] + debug_edges: None, + #[cfg(feature = "variable_stroke_debug")] + debug_path_index: 0, + }; + + for subsegment in subsegments.iter() { + self.add_subsegment(subsegment, path, &mut output); + } + } + + #[cfg(feature = "variable_stroke_debug")] + pub(super) fn build_debug( + &self, + path: &[StrokeVertex

], + path_index: usize, + adapter: &FloatPointAdapter, + segments: &mut Vec>, + debug_edges: &mut Vec>, + ) where + P: FloatPointCompatible, + I: IntNumber, + { + if path.is_empty() { + return; + } + + let subsegments = Self::find_subsegments(path, adapter); + let mut output = SegmentBuilder { + adapter, + segments, + debug_edges: Some(debug_edges), + debug_path_index: path_index, + }; + + for subsegment in subsegments.iter() { + self.add_subsegment(subsegment, path, &mut output); + } + } + + fn add_subsegment( + &self, + subsegment: &SubSegment, + path: &[StrokeVertex

], + output: &mut SegmentBuilder, + ) where + P: FloatPointCompatible, + I: IntNumber, + { + if subsegment.start == subsegment.end { + if subsegment.start_cap != Cap::Butt || subsegment.end_cap != Cap::Butt { + let vertex = &path[subsegment.start]; + output.add_circle(&vertex.point, vertex.radius(), self.round_angle); + } + return; + } + + let adapter = output.adapter; + let mut sections = (subsegment.start..subsegment.end) + .filter_map(|index| Section::try_new(&path[index], &path[index + 1], adapter)); + let Some(mut previous) = sections.next() else { + return; + }; + + output.add_section(&previous); + output.add_start_cap(&previous, subsegment.start_cap, self.round_angle); + + for section in sections { + output.add_section(§ion); + output.add_join(&previous, §ion, self.round_angle); + previous = section; + } + + output.add_end_cap(&previous, subsegment.end_cap, self.round_angle); + } + + fn find_subsegments(path: &[StrokeVertex

], adapter: &FloatPointAdapter) -> Vec + where + P: FloatPointCompatible, + I: IntNumber, + { + if path.is_empty() { + return Vec::new(); + } + + let mut result = Vec::new(); + let mut start = 0; + let mut start_cap = Cap::Round; + let mut final_end_cap = Cap::Round; + + for (index, pair) in path.windows(2).enumerate() { + final_end_cap = Cap::Round; + + if let Some((end_cap, next_start_cap)) = Self::break_caps(&pair[0], &pair[1], adapter) { + result.push(SubSegment { + start, + end: index, + start_cap, + end_cap, + }); + + start = index + 1; + start_cap = next_start_cap; + continue; + } + + if index > 0 && Self::circle_is_covered_by_section(&path[index - 1], &pair[0], &pair[1], adapter) + { + result.push(SubSegment { + start, + end: index, + start_cap, + end_cap: Cap::Round, + }); + + start = index; + start_cap = Cap::Butt; + final_end_cap = Cap::Butt; + } + } + + result.push(SubSegment { + start, + end: path.len() - 1, + start_cap, + end_cap: final_end_cap, + }); + result + } + + fn break_caps( + a: &StrokeVertex

, + b: &StrokeVertex

, + adapter: &FloatPointAdapter, + ) -> Option<(Cap, Cap)> + where + P: FloatPointCompatible, + I: IntNumber, + { + let int_a = adapter.float_to_int(&a.point); + let int_b = adapter.float_to_int(&b.point); + let a_radius = adapter.round_len_to_int(a.radius()); + let b_radius = adapter.round_len_to_int(b.radius()); + let radius_delta = a_radius.to_wide() - b_radius.to_wide(); + let distance_sqr = (int_b - int_a).sqr_length(); + + if radius_delta * radius_delta < distance_sqr { + return None; + } + + if a_radius >= b_radius { + Some((Cap::Round, Cap::Butt)) + } else { + Some((Cap::Butt, Cap::Round)) + } + } + + fn circle_is_covered_by_section( + a: &StrokeVertex

, + b: &StrokeVertex

, + c: &StrokeVertex

, + adapter: &FloatPointAdapter, + ) -> bool + where + P: FloatPointCompatible, + I: IntNumber, + { + let a_radius = adapter.round_len_to_int(a.radius()); + let b_radius = adapter.round_len_to_int(b.radius()); + let c_radius = adapter.round_len_to_int(c.radius()); + if a_radius.max(b_radius) <= c_radius { + return false; + } + + let Some(section) = Section::try_new(a, b, adapter) else { + return false; + }; + + let points = [ + adapter.float_to_int(§ion.a_left), + adapter.float_to_int(§ion.b_left), + adapter.float_to_int(§ion.b_right), + adapter.float_to_int(§ion.a_right), + ]; + let center = adapter.float_to_int(&c.point); + let radius = c_radius.to_wide(); + let first_edge = points[1] - points[0]; + let orientation = first_edge.cross_product(points[2] - points[1]); + if orientation == I::Wide::ZERO { + return false; + } + + for index in 0..points.len() { + let a = points[index]; + let b = points[(index + 1) % points.len()]; + let edge = b - a; + let side = edge.cross_product(center - a); + let interior_distance = if orientation > I::Wide::ZERO { side } else { -side }; + if interior_distance < I::Wide::ZERO { + return false; + } + + let length_sqr = edge.sqr_length(); + let mut length = length_sqr.isqrt(); + if length * length < length_sqr { + length = length + I::Wide::ONE; + } + if interior_distance < radius * length { + return false; + } + } + + true + } + + pub(super) fn capacity(&self, paths_count: usize, points_count: usize) -> usize { + let edge_count = points_count.saturating_sub(paths_count); + let round_count = (T::from_float(2.0 * PI) / self.round_angle) + .to_usize() + .saturating_add(1); + 2 * edge_count + 2 * round_count * points_count + } + + pub(super) fn additional_offset(&self, max_radius: T) -> T { + T::from_float(1.1) * max_radius + } +} + +struct SegmentBuilder<'a, P: FloatPointCompatible, I: IntNumber> { + adapter: &'a FloatPointAdapter, + segments: &'a mut Vec>, + #[cfg(feature = "variable_stroke_debug")] + debug_edges: Option<&'a mut Vec>>, + #[cfg(feature = "variable_stroke_debug")] + debug_path_index: usize, +} + +impl SegmentBuilder<'_, P, I> { + fn add_circle(&mut self, center: &P, radius: P::Scalar, angle: P::Scalar) { + let int_radius = self.adapter.round_len_to_int(radius); + if int_radius <= I::ONE { + return; + } + + let center = self.adapter.int_to_float(&self.adapter.float_to_int(center)); + let radius = self.adapter.len_to_float(int_radius); + let count = (P::Scalar::from_float(2.0 * PI) / angle) + .to_usize() + .saturating_add(1) + .clamp(3, 1024); + let rotator = Rotator::with_angle(P::Scalar::from_float(2.0 * PI) / P::Scalar::from_usize(count)); + let mut vector = P::from_xy(radius, P::Scalar::ZERO); + let first = FloatPointMath::add(¢er, &vector); + let mut a = first; + + for i in 1..=count { + let b = if i == count { + first + } else { + vector = rotator.rotate(&vector); + FloatPointMath::add(¢er, &vector) + }; + self.add_edge( + &a, + &b, + #[cfg(feature = "variable_stroke_debug")] + VariableStrokeDebugEdgeKind::CircleArc, + ); + a = b; + } + } + + #[inline] + fn add_section(&mut self, section: &Section

) { + self.add_edge( + §ion.b_left, + §ion.a_left, + #[cfg(feature = "variable_stroke_debug")] + VariableStrokeDebugEdgeKind::SectionBoundary, + ); + self.add_edge( + §ion.a_right, + §ion.b_right, + #[cfg(feature = "variable_stroke_debug")] + VariableStrokeDebugEdgeKind::SectionBoundary, + ); + } + + fn add_join(&mut self, prev: &Section

, next: &Section

, angle: P::Scalar) -> usize { + let prev_center = self.adapter.float_to_int(&prev.b); + let next_center = self.adapter.float_to_int(&next.a); + if prev_center != next_center { + // A non-drawable section between these sections was filtered out. They belong to + // separate chains, so close both chains instead of building an arc between centers. + self.add_end_cap(prev, Cap::Butt, angle); + self.add_start_cap(next, Cap::Butt, angle); + return 0; + } + + let prev_a_left = self.adapter.float_to_int(&prev.a_left); + let prev_b_left = self.adapter.float_to_int(&prev.b_left); + let prev_a_right = self.adapter.float_to_int(&prev.a_right); + let prev_b_right = self.adapter.float_to_int(&prev.b_right); + let next_a_left = self.adapter.float_to_int(&next.a_left); + let next_b_left = self.adapter.float_to_int(&next.b_left); + let next_a_right = self.adapter.float_to_int(&next.a_right); + let next_b_right = self.adapter.float_to_int(&next.b_right); + + let prev_left = prev_b_left - prev_a_left; + let prev_right = prev_b_right - prev_a_right; + let next_left = next_b_left - next_a_left; + let next_right = next_b_right - next_a_right; + + let mut arc_count = 0; + let left_cross = next_left.cross_product(prev_left); + + let right_cross = prev_right.cross_product(next_right); + + let prev_a = self.adapter.float_to_int(&prev.a); + let prev_b = prev_center; + let next_a = self.adapter.float_to_int(&next.a); + let next_b = self.adapter.float_to_int(&next.b); + + let prev_middle = prev_b - prev_a; + let next_middle = next_b - next_a; + + let middle_cross = prev_middle.cross_product(next_middle); + + let left_arc = left_cross > I::Wide::ZERO || middle_cross < I::Wide::ZERO; + let right_arc = right_cross > I::Wide::ZERO || middle_cross >= I::Wide::ZERO; + + if left_arc { + arc_count += self.add_arc_ccw( + &prev.b, + &next.a_left, + &prev.b_left, + angle, + ArcSweep::Minor, + #[cfg(feature = "variable_stroke_debug")] + VariableStrokeDebugEdgeKind::JoinArc, + ) as usize; + } else { + self.add_edge( + &next.a_left, + &prev.b_left, + #[cfg(feature = "variable_stroke_debug")] + VariableStrokeDebugEdgeKind::JoinClosure, + ); + } + + if right_arc { + arc_count += self.add_arc_ccw( + &prev.b, + &prev.b_right, + &next.a_right, + angle, + ArcSweep::Major, + #[cfg(feature = "variable_stroke_debug")] + VariableStrokeDebugEdgeKind::JoinArc, + ) as usize; + } else { + self.add_edge( + &prev.b_right, + &next.a_right, + #[cfg(feature = "variable_stroke_debug")] + VariableStrokeDebugEdgeKind::JoinClosure, + ); + } + + arc_count + } + + fn add_start_cap(&mut self, section: &Section

, cap: Cap, angle: P::Scalar) { + match cap { + Cap::Butt => self.add_edge( + §ion.a_left, + §ion.a_right, + #[cfg(feature = "variable_stroke_debug")] + VariableStrokeDebugEdgeKind::CapClosure, + ), + Cap::Round => { + let sweep = if section.radius_trend == RadiusTrend::Decreasing { + ArcSweep::Major + } else { + ArcSweep::Minor + }; + self.add_arc_ccw( + §ion.a, + §ion.a_left, + §ion.a_right, + angle, + sweep, + #[cfg(feature = "variable_stroke_debug")] + VariableStrokeDebugEdgeKind::CapArc, + ); + } + } + } + + fn add_end_cap(&mut self, section: &Section

, cap: Cap, angle: P::Scalar) { + match cap { + Cap::Butt => self.add_edge( + §ion.b_right, + §ion.b_left, + #[cfg(feature = "variable_stroke_debug")] + VariableStrokeDebugEdgeKind::CapClosure, + ), + Cap::Round => { + let sweep = if section.radius_trend == RadiusTrend::Increasing { + ArcSweep::Major + } else { + ArcSweep::Minor + }; + self.add_arc_ccw( + §ion.b, + §ion.b_right, + §ion.b_left, + angle, + sweep, + #[cfg(feature = "variable_stroke_debug")] + VariableStrokeDebugEdgeKind::CapArc, + ); + } + } + } + + fn arc_sweep_ccw(&self, center: &P, from: &P, to: &P, aligned_sweep: ArcSweep) -> ArcSweep { + let center = self.adapter.float_to_int(center); + let from_vector = self.adapter.float_to_int(from) - center; + let to_vector = self.adapter.float_to_int(to) - center; + let cross = from_vector.cross_product(to_vector); + + if cross > I::Wide::ZERO { + ArcSweep::Minor + } else if cross < I::Wide::ZERO { + ArcSweep::Major + } else if from_vector.dot_product(to_vector) < I::Wide::ZERO { + // Both choices describe the same half-circle. + ArcSweep::Minor + } else { + // Coincident directions can mean either a collapsed minor arc or a full major arc. + aligned_sweep + } + } + + fn add_arc_ccw( + &mut self, + center: &P, + from: &P, + to: &P, + angle: P::Scalar, + aligned_sweep: ArcSweep, + #[cfg(feature = "variable_stroke_debug")] edge_kind: VariableStrokeDebugEdgeKind, + ) -> bool { + let sweep = self.arc_sweep_ccw(center, from, to, aligned_sweep); + if sweep == ArcSweep::Minor && self.adapter.float_to_int(from) == self.adapter.float_to_int(to) { + return false; + } + + let from_point = *from; + let to_point = *to; + let from_vector = FloatPointMath::sub(&from_point, center); + let from_unit = FloatPointMath::normalize(&from_vector); + let to_unit = FloatPointMath::normalize(&FloatPointMath::sub(&to_point, center)); + let dot = FloatPointMath::dot_product(&from_unit, &to_unit) + .max(-P::Scalar::ONE) + .min(P::Scalar::ONE); + let base = dot.acos(); + let sweep = match sweep { + ArcSweep::Minor => base, + ArcSweep::Major => P::Scalar::from_float(2.0 * PI) - base, + }; + let count = (sweep / angle).to_usize().saturating_add(1).clamp(1, 1024); + let rotator = Rotator::with_angle(sweep / P::Scalar::from_usize(count)); + + let mut vector = from_vector; + let mut a = from_point; + for i in 1..=count { + let b = if i == count { + to_point + } else { + vector = rotator.rotate(&vector); + FloatPointMath::add(center, &vector) + }; + #[cfg(not(feature = "variable_stroke_debug"))] + self.add_edge(&a, &b); + #[cfg(feature = "variable_stroke_debug")] + self.add_edge(&a, &b, edge_kind); + a = b; + } + + true + } + + #[inline] + fn add_edge( + &mut self, + a: &P, + b: &P, + #[cfg(feature = "variable_stroke_debug")] kind: VariableStrokeDebugEdgeKind, + ) { + let a = self.adapter.float_to_int(a); + let b = self.adapter.float_to_int(b); + if a != b { + #[cfg(feature = "variable_stroke_debug")] + if let Some(debug_edges) = self.debug_edges.as_mut() { + debug_edges.push(VariableStrokeDebugEdge { + a: self.adapter.int_to_float(&a), + b: self.adapter.int_to_float(&b), + kind, + path_index: self.debug_path_index, + order: debug_edges.len(), + }); + } + self.segments.push(Segment::subject(a, b)); + } + } +} + +#[cfg(test)] +mod tests { + use super::{ArcSweep, Cap, SegmentBuilder, SubSegment, VariableStrokeBuilder}; + #[cfg(feature = "variable_stroke_debug")] + use crate::mesh::variable_stroke::VariableStrokeDebugEdgeKind; + use crate::mesh::variable_stroke::offset::VariableStrokeOffset; + use crate::mesh::variable_stroke::section::Section; + use crate::mesh::variable_stroke::style::{StrokeVertex, VariableStrokeStyle}; + use crate::segm::boolean::ShapeCountBoolean; + use crate::segm::segment::Segment; + use alloc::vec; + use alloc::vec::Vec; + use i_float::adapter::FloatPointAdapter; + use i_float::float::rect::FloatRect; + + fn adapter() -> FloatPointAdapter<[f64; 2], i32> { + FloatPointAdapter::with_scale(FloatRect::new(-100.0, 100.0, -100.0, 100.0), 1.0) + } + + #[test] + fn empty_path_does_not_create_subsegments_or_edges() { + let path: [StrokeVertex<[f64; 2]>; 0] = []; + let adapter = adapter(); + let builder = VariableStrokeBuilder::new(VariableStrokeStyle::new()); + let mut segments = Vec::>::new(); + + assert!(VariableStrokeBuilder::::find_subsegments(&path, &adapter).is_empty()); + builder.build(&path, &adapter, &mut segments); + + assert!(segments.is_empty()); + } + + #[test] + fn single_round_vertex_builds_a_circle() { + let path = [StrokeVertex::new([0.0, 0.0], 4.0)]; + let adapter = adapter(); + let builder = VariableStrokeBuilder::new(VariableStrokeStyle::new()); + let mut segments = Vec::>::new(); + + builder.build(&path, &adapter, &mut segments); + + assert!(!segments.is_empty()); + } + + #[test] + fn covered_break_uses_butt_on_smaller_side() { + let path = [ + StrokeVertex::new([-20.0, 0.0], 4.0), + StrokeVertex::new([0.0, 0.0], 4.0), + StrokeVertex::new([2.0, 0.0], 20.0), + StrokeVertex::new([22.0, 0.0], 20.0), + ]; + let subsegments = VariableStrokeBuilder::::find_subsegments(&path, &adapter()); + assert_eq!(subsegments.len(), 2); + assert_eq!(subsegments[0].start, 0); + assert_eq!(subsegments[0].end, 1); + assert_eq!(subsegments[0].end_cap, Cap::Butt); + assert_eq!(subsegments[1].start, 2); + assert_eq!(subsegments[1].end, 3); + assert_eq!(subsegments[1].start_cap, Cap::Round); + } + + #[test] + fn reverse_covered_break_uses_butt_on_smaller_side() { + let path = [ + StrokeVertex::new([-20.0, 0.0], 20.0), + StrokeVertex::new([0.0, 0.0], 20.0), + StrokeVertex::new([2.0, 0.0], 4.0), + StrokeVertex::new([22.0, 0.0], 4.0), + ]; + let subsegments = VariableStrokeBuilder::::find_subsegments(&path, &adapter()); + assert_eq!(subsegments.len(), 2); + assert_eq!(subsegments[0].start, 0); + assert_eq!(subsegments[0].end, 1); + assert_eq!(subsegments[0].end_cap, Cap::Round); + assert_eq!(subsegments[1].start, 2); + assert_eq!(subsegments[1].end, 3); + assert_eq!(subsegments[1].start_cap, Cap::Butt); + } + + #[test] + fn near_covered_sections_stay_in_one_subsegment() { + let path = [ + StrokeVertex::new([0.0, 0.0], 6.0), + StrokeVertex::new([7.57, 3.86], 18.0), + StrokeVertex::new([19.2, 7.12], 42.0), + ]; + let precise_adapter: FloatPointAdapter<[f64; 2], i32> = + FloatPointAdapter::with_scale(FloatRect::new(-100.0, 100.0, -100.0, 100.0), 100.0); + let subsegments = VariableStrokeBuilder::::find_subsegments(&path, &precise_adapter); + assert_eq!(subsegments.len(), 1); + assert_eq!(subsegments[0].start, 0); + assert_eq!(subsegments[0].end, 2); + assert_eq!(subsegments[0].start_cap, Cap::Round); + assert_eq!(subsegments[0].end_cap, Cap::Round); + } + + #[test] + fn trapezoid_cover_requires_a_larger_source_circle() { + let equal_a = StrokeVertex::new([0.0, 0.0], 20.0); + let equal_b = StrokeVertex::new([100.0, 0.0], 20.0); + let c = StrokeVertex::new([50.0, 0.0], 20.0); + let larger_a = StrokeVertex::new([0.0, 0.0], 40.0); + let larger_b = StrokeVertex::new([100.0, 0.0], 40.0); + + assert!(!VariableStrokeBuilder::::circle_is_covered_by_section( + &equal_a, + &equal_b, + &c, + &adapter(), + )); + assert!(VariableStrokeBuilder::::circle_is_covered_by_section( + &larger_a, + &larger_b, + &c, + &adapter(), + )); + } + + #[test] + fn zero_length_butt_subsegment_is_not_drawn() { + let path = [ + StrokeVertex::new([-2.0, 0.0], 20.0), + StrokeVertex::new([0.0, 0.0], 2.0), + StrokeVertex::new([2.0, 0.0], 20.0), + ]; + let adapter = adapter(); + let subsegments = VariableStrokeBuilder::::find_subsegments(&path, &adapter); + + assert_eq!(subsegments.len(), 3); + assert_eq!( + subsegments[1], + SubSegment { + start: 1, + end: 1, + start_cap: Cap::Butt, + end_cap: Cap::Butt, + } + ); + + let builder = VariableStrokeBuilder::new(VariableStrokeStyle::new()); + let mut segments = Vec::>::new(); + let mut output = SegmentBuilder { + adapter: &adapter, + segments: &mut segments, + #[cfg(feature = "variable_stroke_debug")] + debug_edges: None, + #[cfg(feature = "variable_stroke_debug")] + debug_path_index: 0, + }; + builder.add_subsegment(&subsegments[1], &path, &mut output); + + assert!(segments.is_empty()); + } + + #[test] + fn join_keeps_all_tangent_contacts() { + let path = [ + StrokeVertex::new([-20.0, 0.0], 8.0), + StrokeVertex::new([0.0, 0.0], 20.0), + StrokeVertex::new([15.0, 18.0], 12.0), + ]; + let adapter: FloatPointAdapter<[f64; 2], i32> = + FloatPointAdapter::with_scale(FloatRect::new(-100.0, 100.0, -100.0, 100.0), 1_000.0); + let previous = Section::try_new(&path[0], &path[1], &adapter).unwrap(); + let next = Section::try_new(&path[1], &path[2], &adapter).unwrap(); + let contacts = [previous.b_left, previous.b_right, next.a_left, next.a_right]; + let mut segments = Vec::>::new(); + let mut output = SegmentBuilder { + adapter: &adapter, + segments: &mut segments, + #[cfg(feature = "variable_stroke_debug")] + debug_edges: None, + #[cfg(feature = "variable_stroke_debug")] + debug_path_index: 0, + }; + + output.add_join(&previous, &next, core::f64::consts::FRAC_PI_4); + + for contact in contacts { + let point = adapter.float_to_int(&contact); + assert!( + segments + .iter() + .any(|segment| segment.x_segment.a == point || segment.x_segment.b == point), + "missing tangent contact {point:?}" + ); + } + } + + fn join_arc_count(path: [StrokeVertex<[f64; 2]>; 3]) -> usize { + let adapter: FloatPointAdapter<[f64; 2], i32> = + FloatPointAdapter::with_scale(FloatRect::new(-100.0, 100.0, -100.0, 100.0), 1_000.0); + let previous = Section::try_new(&path[0], &path[1], &adapter).unwrap(); + let next = Section::try_new(&path[1], &path[2], &adapter).unwrap(); + let mut segments = Vec::>::new(); + let mut output = SegmentBuilder { + adapter: &adapter, + segments: &mut segments, + #[cfg(feature = "variable_stroke_debug")] + debug_edges: None, + #[cfg(feature = "variable_stroke_debug")] + debug_path_index: 0, + }; + + output.add_join(&previous, &next, core::f64::consts::FRAC_PI_4) + } + + #[test] + fn width_peak_builds_two_join_arcs() { + let path = [ + StrokeVertex::new([-10.0, 0.0], 4.0), + StrokeVertex::new([0.0, 0.0], 10.0), + StrokeVertex::new([10.0, 0.0], 4.0), + ]; + + assert_eq!(join_arc_count(path), 2); + } + + #[test] + fn ordinary_turn_builds_one_join_arc() { + let path = [ + StrokeVertex::new([-10.0, 0.0], 4.0), + StrokeVertex::new([0.0, 0.0], 4.0), + StrokeVertex::new([0.0, 10.0], 4.0), + ]; + + assert_eq!(join_arc_count(path), 1); + } + #[test] + fn coarse_arc_is_one_exact_contact_segment() { + let adapter: FloatPointAdapter<[f64; 2], i32> = + FloatPointAdapter::with_scale(FloatRect::new(-100.0, 100.0, -100.0, 100.0), 1_000.0); + let center = [0.0, 0.0]; + let from = [10.0, 0.0]; + let sweep = 0.1_f64; + let to = [10.0 * sweep.cos(), 10.0 * sweep.sin()]; + let mut segments = Vec::>::new(); + let mut output = SegmentBuilder { + adapter: &adapter, + segments: &mut segments, + #[cfg(feature = "variable_stroke_debug")] + debug_edges: None, + #[cfg(feature = "variable_stroke_debug")] + debug_path_index: 0, + }; + + assert!(output.add_arc_ccw( + ¢er, + &from, + &to, + core::f64::consts::FRAC_PI_4, + ArcSweep::Minor, + #[cfg(feature = "variable_stroke_debug")] + VariableStrokeDebugEdgeKind::JoinArc, + )); + assert_eq!(segments.len(), 1); + let edge = segments[0].x_segment; + let from = adapter.float_to_int(&from); + let to = adapter.float_to_int(&to); + assert!(edge.a == from || edge.b == from); + assert!(edge.a == to || edge.b == to); + } + + #[test] + fn coincident_contacts_keep_topological_major_arc() { + let adapter: FloatPointAdapter<[f64; 2], i32> = + FloatPointAdapter::with_scale(FloatRect::new(-100.0, 100.0, -100.0, 100.0), 1_000.0); + let center = [0.0, 0.0]; + let contact = [10.0, 0.0]; + let mut segments = Vec::>::new(); + let mut output = SegmentBuilder { + adapter: &adapter, + segments: &mut segments, + #[cfg(feature = "variable_stroke_debug")] + debug_edges: None, + #[cfg(feature = "variable_stroke_debug")] + debug_path_index: 0, + }; + + assert!(output.add_arc_ccw( + ¢er, + &contact, + &contact, + core::f64::consts::FRAC_PI_4, + ArcSweep::Major, + #[cfg(feature = "variable_stroke_debug")] + VariableStrokeDebugEdgeKind::JoinArc, + )); + assert!(segments.len() >= 3); + } + + #[test] + fn coarse_missed_arc_0() { + let paths = vec![vec![ + StrokeVertex::new([0.0_f32, 0.0_f32], 8.0_f32), + StrokeVertex::new([60.0_f32, 0.0_f32], 20.0_f32), + StrokeVertex::new([5.0_f32, 8.0_f32], 10.0_f32), + ]]; + let style = VariableStrokeStyle::new().round_angle(0.17999999_f32); + let result = paths.variable_stroke(style); + + assert!(!result.is_empty()); + } + + #[test] + fn coarse_missed_arc_1() { + let paths = vec![vec![ + StrokeVertex::new([0.0_f32, 0.0_f32], 8.0_f32), + StrokeVertex::new([60.0_f32, 0.0_f32], 20.0_f32), + StrokeVertex::new([60.0_f32, -60.0_f32], 10.0_f32), + ]]; + let style = VariableStrokeStyle::new().round_angle(0.17999999_f32); + let result = paths.variable_stroke(style); + + assert!(!result.is_empty()); + } + + #[test] + fn missed_arc_1() { + let paths = vec![vec![ + StrokeVertex::new([-86.0_f32, 2.0_f32], 10.0_f32), + StrokeVertex::new([100.0_f32, 0.0_f32], 100.0_f32), + StrokeVertex::new([99.0_f32, -45.0_f32], 10.0_f32), + ]]; + let precise_adapter: FloatPointAdapter<[f32; 2], i32> = + FloatPointAdapter::with_scale(FloatRect::new(-200.0, 200.0, -200.0, 200.0), 1_000.0); + let previous = Section::try_new(&paths[0][0], &paths[0][1], &precise_adapter).unwrap(); + let next = Section::try_new(&paths[0][1], &paths[0][2], &precise_adapter).unwrap(); + let mut segments = Vec::>::new(); + let mut output = SegmentBuilder { + adapter: &precise_adapter, + segments: &mut segments, + #[cfg(feature = "variable_stroke_debug")] + debug_edges: None, + #[cfg(feature = "variable_stroke_debug")] + debug_path_index: 0, + }; + + assert_eq!( + output.add_join(&previous, &next, 0.17999999_f32), + 2, + "the wide reversal exposes both join arcs" + ); + + let style = VariableStrokeStyle::new().round_angle(0.17999999_f32); + let result = paths.variable_stroke(style); + + assert!(!result.is_empty()); + } + + #[test] + fn missed_arc_2() { + // Dynamic Width repro: test=11 width_scale=2.2 + let paths = vec![vec![ + StrokeVertex::new([0.0_f32, 0.0_f32], 22.0_f32), + StrokeVertex::new([100.0_f32, 0.0_f32], 220.0_f32), + StrokeVertex::new([100.0_f32, -100.0_f32], 22.0_f32), + ]]; + let precise_adapter: FloatPointAdapter<[f32; 2], i32> = + FloatPointAdapter::with_scale(FloatRect::new(-250.0, 250.0, -250.0, 250.0), 1_000.0); + let first = Section::try_new(&paths[0][0], &paths[0][1], &precise_adapter).unwrap(); + let second = Section::try_new(&paths[0][1], &paths[0][2], &precise_adapter).unwrap(); + let mut join_segments = Vec::>::new(); + let mut output = SegmentBuilder { + adapter: &precise_adapter, + segments: &mut join_segments, + #[cfg(feature = "variable_stroke_debug")] + debug_edges: None, + #[cfg(feature = "variable_stroke_debug")] + debug_path_index: 0, + }; + + assert_eq!( + output.arc_sweep_ccw(&first.b, &second.a_left, &first.b_left, ArcSweep::Minor,), + ArcSweep::Major, + "the left CCW join crosses the major radial sector" + ); + assert_eq!(output.add_join(&first, &second, 0.21_f32), 2); + assert!(join_segments.len() > 20, "the major join arc was not built"); + + let style = VariableStrokeStyle::new().round_angle(0.21_f32); + let result = paths.variable_stroke(style); + + assert!(!result.is_empty()); + } + + #[test] + fn moderate_width_peak_builds_one_arc() { + // Dynamic Width repro: test=11 width_scale=0.88 + let paths = vec![vec![ + StrokeVertex::new([0.0_f32, 0.0_f32], 8.8_f32), + StrokeVertex::new([100.0_f32, 0.0_f32], 88.0_f32), + StrokeVertex::new([100.0_f32, -100.0_f32], 8.8_f32), + ]]; + let precise_adapter: FloatPointAdapter<[f32; 2], i32> = + FloatPointAdapter::with_scale(FloatRect::new(-150.0, 150.0, -150.0, 150.0), 1_000.0); + let first = Section::try_new(&paths[0][0], &paths[0][1], &precise_adapter).unwrap(); + let second = Section::try_new(&paths[0][1], &paths[0][2], &precise_adapter).unwrap(); + let mut join_segments = Vec::>::new(); + let mut output = SegmentBuilder { + adapter: &precise_adapter, + segments: &mut join_segments, + #[cfg(feature = "variable_stroke_debug")] + debug_edges: None, + #[cfg(feature = "variable_stroke_debug")] + debug_path_index: 0, + }; + + assert_eq!(output.add_join(&first, &second, 0.615_f32), 1); + + let style = VariableStrokeStyle::new().round_angle(0.615_f32); + let result = paths.variable_stroke(style); + + assert!(!result.is_empty()); + } + + #[test] + fn middle_left_reversal_closes_both_sections() { + let paths = vec![vec![ + StrokeVertex::new([-86.0_f32, 2.0_f32], 21.800001_f32), + StrokeVertex::new([100.0_f32, 0.0_f32], 218.0_f32), + StrokeVertex::new([-20.699999_f32, -16.029999_f32], 21.800001_f32), + ]]; + let precise_adapter: FloatPointAdapter<[f32; 2], i32> = + FloatPointAdapter::with_scale(FloatRect::new(-250.0, 250.0, -250.0, 250.0), 1_000.0); + let subsegments = VariableStrokeBuilder::::find_subsegments(&paths[0], &precise_adapter); + + assert_eq!(subsegments.len(), 2); + assert_eq!(subsegments[0].start, 0); + assert_eq!(subsegments[0].end, 1); + assert_eq!(subsegments[0].end_cap, Cap::Round); + assert_eq!(subsegments[1].start, 1); + assert_eq!(subsegments[1].end, 2); + assert_eq!(subsegments[1].start_cap, Cap::Butt); + assert_eq!(subsegments[1].end_cap, Cap::Butt); + + let result = paths.variable_stroke(VariableStrokeStyle::new().round_angle(0.75_f32)); + let has_tooth = result.iter().flatten().flatten().any(|point| { + let dx = point[0] - 100.0; + let dy = point[1]; + point[0] > 20.0 && point[1] < -70.0 && dx * dx + dy * dy < 108.5 * 108.5 + }); + + assert_eq!(result.len(), 1); + assert!(!has_tooth); + } + + #[test] + fn middle_right_reversal_closes_both_sections() { + let paths = vec![vec![ + StrokeVertex::new([-86.0_f32, -2.0_f32], 21.800001_f32), + StrokeVertex::new([100.0_f32, 0.0_f32], 218.0_f32), + StrokeVertex::new([-20.699999_f32, 16.029999_f32], 21.800001_f32), + ]]; + let precise_adapter: FloatPointAdapter<[f32; 2], i32> = + FloatPointAdapter::with_scale(FloatRect::new(-250.0, 250.0, -250.0, 250.0), 1_000.0); + let subsegments = VariableStrokeBuilder::::find_subsegments(&paths[0], &precise_adapter); + + assert_eq!(subsegments.len(), 2); + assert_eq!(subsegments[0].end_cap, Cap::Round); + assert_eq!(subsegments[1].start_cap, Cap::Butt); + assert_eq!(subsegments[1].end_cap, Cap::Butt); + + let result = paths.variable_stroke(VariableStrokeStyle::new().round_angle(0.75_f32)); + let has_tooth = result.iter().flatten().flatten().any(|point| { + let dx = point[0] - 100.0; + let dy = point[1]; + point[0] > 20.0 && point[1] > 70.0 && dx * dx + dy * dy < 108.5 * 108.5 + }); + + assert_eq!(result.len(), 1); + assert!(!has_tooth); + } +} diff --git a/iOverlay/src/mesh/variable_stroke/debug.rs b/iOverlay/src/mesh/variable_stroke/debug.rs new file mode 100644 index 00000000..205f46bd --- /dev/null +++ b/iOverlay/src/mesh/variable_stroke/debug.rs @@ -0,0 +1,37 @@ +use i_float::float::compatible::FloatPointCompatible; + +/// The variable-stroke construction operation that emitted a raw pre-overlay edge. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VariableStrokeDebugEdgeKind { + /// One of the two tangent boundaries of a drawable centerline section. + SectionBoundary, + /// One chord of a round join. + JoinArc, + /// A straight edge closing the gap between adjacent sections. + JoinClosure, + /// One chord of a round end cap. + CapArc, + /// A butt edge closing an end cap. + CapClosure, + /// One chord of a circle emitted for an isolated drawable vertex. + CircleArc, +} + +/// One directed edge submitted by `SegmentBuilder` before overlay processing. +#[derive(Debug, Clone, Copy)] +pub struct VariableStrokeDebugEdge { + pub a: P, + pub b: P, + pub kind: VariableStrokeDebugEdgeKind, + /// Index of the source variable-width path. + pub path_index: usize, + /// Global insertion order across all source paths. + pub order: usize, +} + +/// The raw construction edges and the regular post-overlay stroke result. +#[derive(Debug, Clone)] +pub struct VariableStrokeDebugResult { + pub edges: alloc::vec::Vec>, + pub shapes: i_shape::base::data::Shapes

, +} diff --git a/iOverlay/src/mesh/variable_stroke/mod.rs b/iOverlay/src/mesh/variable_stroke/mod.rs new file mode 100644 index 00000000..64ff3ae1 --- /dev/null +++ b/iOverlay/src/mesh/variable_stroke/mod.rs @@ -0,0 +1,14 @@ +mod builder; +#[cfg(feature = "variable_stroke_debug")] +mod debug; +pub mod offset; +mod resource; +mod section; +mod style; + +#[cfg(feature = "variable_stroke_debug")] +pub use debug::{VariableStrokeDebugEdge, VariableStrokeDebugEdgeKind, VariableStrokeDebugResult}; +#[cfg(feature = "variable_stroke_debug")] +pub use offset::VariableStrokeDebug; +pub use resource::VariableStrokeSource; +pub use style::{StrokeVertex, VariableStrokeStyle}; diff --git a/iOverlay/src/mesh/variable_stroke/offset.rs b/iOverlay/src/mesh/variable_stroke/offset.rs new file mode 100644 index 00000000..bc889d3b --- /dev/null +++ b/iOverlay/src/mesh/variable_stroke/offset.rs @@ -0,0 +1,768 @@ +use crate::core::fill_rule::FillRule; +use crate::core::integer::OverlayInt; +use crate::core::overlay::Overlay; +use crate::core::overlay_rule::OverlayRule; +use crate::float::overlay::OverlayOptions; +use crate::float::scale::FixedScaleOverlayError; +use crate::mesh::variable_stroke::builder::VariableStrokeBuilder; +use crate::mesh::variable_stroke::resource::VariableStrokeSource; +use crate::mesh::variable_stroke::style::VariableStrokeStyle; +use alloc::vec; +use alloc::vec::Vec; +use i_float::adapter::FloatPointAdapter; +use i_float::float::compatible::FloatPointCompatible; +use i_float::float::number::FloatNumber; +use i_float::float::rect::FloatRect; +use i_float::int::number::int::IntNumber; +use i_float::int::number::uint::UIntNumber; +use i_float::int::number::wide_int::WideIntNumber; +use i_shape::base::data::Shapes; +use i_shape::flat::buffer::FlatContoursBuffer; +use i_shape::flat::float::FloatFlatContoursBuffer; +use i_shape::float::adapter::ShapesToFloat; +use i_shape::float::despike::DeSpikeContour; +use i_shape::float::simple::SimplifyContour; + +#[cfg(feature = "variable_stroke_debug")] +use crate::mesh::variable_stroke::VariableStrokeDebugResult; + +/// Builds round-cap, round-join strokes whose width is stored at each centerline vertex. +pub trait VariableStrokeOffset

: VariableStrokeSource

+where + P: FloatPointCompatible + 'static, +{ + fn variable_stroke(&self, style: VariableStrokeStyle) -> Shapes

{ + self.variable_stroke_custom(style, Default::default()) + } + + fn variable_stroke_into( + &self, + style: VariableStrokeStyle, + output: &mut FloatFlatContoursBuffer

, + ) { + self.variable_stroke_custom_into(style, Default::default(), output) + } + + fn variable_stroke_custom( + &self, + style: VariableStrokeStyle, + options: OverlayOptions, + ) -> Shapes

{ + self.variable_stroke_custom_as::(style, options) + } + + fn variable_stroke_custom_into( + &self, + style: VariableStrokeStyle, + options: OverlayOptions, + output: &mut FloatFlatContoursBuffer

, + ) { + self.variable_stroke_custom_into_as::(style, options, output) + } + + fn variable_stroke_fixed_scale( + &self, + style: VariableStrokeStyle, + scale: P::Scalar, + ) -> Result, FixedScaleOverlayError> { + self.variable_stroke_custom_fixed_scale(style, Default::default(), scale) + } + + fn variable_stroke_fixed_scale_into( + &self, + style: VariableStrokeStyle, + scale: P::Scalar, + output: &mut FloatFlatContoursBuffer

, + ) -> Result<(), FixedScaleOverlayError> { + self.variable_stroke_custom_fixed_scale_into(style, Default::default(), scale, output) + } + + fn variable_stroke_custom_fixed_scale( + &self, + style: VariableStrokeStyle, + options: OverlayOptions, + scale: P::Scalar, + ) -> Result, FixedScaleOverlayError> { + self.variable_stroke_custom_fixed_scale_as::(style, options, scale) + } + + fn variable_stroke_custom_fixed_scale_into( + &self, + style: VariableStrokeStyle, + options: OverlayOptions, + scale: P::Scalar, + output: &mut FloatFlatContoursBuffer

, + ) -> Result<(), FixedScaleOverlayError> { + self.variable_stroke_custom_fixed_scale_into_as::(style, options, scale, output) + } + + fn variable_stroke_as(&self, style: VariableStrokeStyle) -> Shapes

+ where + I: OverlayInt + 'static, + { + self.variable_stroke_custom_as::(style, Default::default()) + } + + fn variable_stroke_into_as( + &self, + style: VariableStrokeStyle, + output: &mut FloatFlatContoursBuffer

, + ) where + I: OverlayInt + 'static, + { + self.variable_stroke_custom_into_as::(style, Default::default(), output) + } + + fn variable_stroke_custom_as( + &self, + style: VariableStrokeStyle, + options: OverlayOptions, + ) -> Shapes

+ where + I: OverlayInt + 'static, + { + match VariableStrokeSolver::::prepare(self, style) { + Some(solver) => solver.build(self, options), + None => vec![], + } + } + + fn variable_stroke_custom_into_as( + &self, + style: VariableStrokeStyle, + options: OverlayOptions, + output: &mut FloatFlatContoursBuffer

, + ) where + I: OverlayInt + 'static, + { + match VariableStrokeSolver::::prepare(self, style) { + Some(solver) => solver.build_into(self, options, output), + None => output.clear_and_reserve(0, 0), + } + } + + fn variable_stroke_fixed_scale_as( + &self, + style: VariableStrokeStyle, + scale: P::Scalar, + ) -> Result, FixedScaleOverlayError> + where + I: OverlayInt + 'static, + { + self.variable_stroke_custom_fixed_scale_as::(style, Default::default(), scale) + } + + fn variable_stroke_fixed_scale_into_as( + &self, + style: VariableStrokeStyle, + scale: P::Scalar, + output: &mut FloatFlatContoursBuffer

, + ) -> Result<(), FixedScaleOverlayError> + where + I: OverlayInt + 'static, + { + self.variable_stroke_custom_fixed_scale_into_as::(style, Default::default(), scale, output) + } + + fn variable_stroke_custom_fixed_scale_as( + &self, + style: VariableStrokeStyle, + options: OverlayOptions, + scale: P::Scalar, + ) -> Result, FixedScaleOverlayError> + where + I: OverlayInt + 'static, + { + let mut solver = match VariableStrokeSolver::::prepare(self, style) { + Some(solver) => solver, + None => return Ok(vec![]), + }; + solver.apply_scale(scale)?; + Ok(solver.build(self, options)) + } + + fn variable_stroke_custom_fixed_scale_into_as( + &self, + style: VariableStrokeStyle, + options: OverlayOptions, + scale: P::Scalar, + output: &mut FloatFlatContoursBuffer

, + ) -> Result<(), FixedScaleOverlayError> + where + I: OverlayInt + 'static, + { + let mut solver = match VariableStrokeSolver::::prepare(self, style) { + Some(solver) => solver, + None => { + output.clear_and_reserve(0, 0); + return Ok(()); + } + }; + solver.apply_scale(scale)?; + solver.build_into(self, options, output); + Ok(()) + } +} + +impl VariableStrokeOffset

for S +where + S: VariableStrokeSource

, + P: FloatPointCompatible + 'static, +{ +} + +/// Debug-only companion to [`VariableStrokeOffset`] that exposes every directed edge submitted +/// to the overlay engine. Enable the `variable_stroke_debug` crate feature to use it. +#[cfg(feature = "variable_stroke_debug")] +pub trait VariableStrokeDebug

: VariableStrokeSource

+where + P: FloatPointCompatible + 'static, +{ + fn variable_stroke_debug(&self, style: VariableStrokeStyle) -> VariableStrokeDebugResult

{ + match VariableStrokeSolver::::prepare(self, style) { + Some(solver) => solver.build_debug(self, Default::default()), + None => VariableStrokeDebugResult { + edges: vec![], + shapes: vec![], + }, + } + } +} + +#[cfg(feature = "variable_stroke_debug")] +impl VariableStrokeDebug

for S +where + S: VariableStrokeSource

, + P: FloatPointCompatible + 'static, +{ +} + +struct VariableStrokeSolver { + max_radius: P::Scalar, + builder: VariableStrokeBuilder, + adapter: FloatPointAdapter, + paths_count: usize, + points_count: usize, +} + +impl VariableStrokeSolver +where + P: FloatPointCompatible + 'static, + I: OverlayInt + 'static, +{ + fn prepare + ?Sized>( + source: &S, + style: VariableStrokeStyle, + ) -> Option { + let mut max_radius = P::Scalar::ZERO; + let mut paths_count = 0; + let mut points_count = 0; + let mut rect: Option> = None; + + for path in source.iter_variable_paths() { + if path.is_empty() { + continue; + } + paths_count += 1; + points_count += path.len(); + for vertex in path { + max_radius = max_radius.max(vertex.radius()); + if let Some(rect) = rect.as_mut() { + rect.add_point(&vertex.point); + } else { + rect = Some(FloatRect::with_point(vertex.point)); + } + } + } + + if paths_count == 0 || points_count < 2 || max_radius <= P::Scalar::ZERO { + return None; + } + + let builder = VariableStrokeBuilder::new(style); + let mut rect = rect?; + rect.add_offset(builder.additional_offset(max_radius)); + let adapter = FloatPointAdapter::::new(rect); + + Some(Self { + max_radius, + builder, + adapter, + paths_count, + points_count, + }) + } + + fn apply_scale(&mut self, scale: P::Scalar) -> Result<(), FixedScaleOverlayError> { + self.adapter = FloatPointAdapter::try_with_scale(*self.adapter.rect(), scale)?; + Ok(()) + } + + fn build + ?Sized>( + self, + source: &S, + options: OverlayOptions, + ) -> Shapes

{ + if self.radius_is_too_small() { + return vec![]; + } + + let mut segments = Vec::with_capacity(self.builder.capacity(self.paths_count, self.points_count)); + for path in source.iter_variable_paths() { + self.builder.build(path, &self.adapter, &mut segments); + } + + let mut overlay = Overlay::with_segments(segments); + overlay.options = options.int_with_adapter(&self.adapter); + let shapes = overlay.overlay(OverlayRule::Subject, FillRule::Positive); + let mut float = shapes.to_float(&self.adapter); + + if options.clean_result { + if options.preserve_output_collinear { + float.despike_contour(&self.adapter); + } else { + float.simplify_contour(&self.adapter); + } + } + float + } + + fn build_into + ?Sized>( + self, + source: &S, + options: OverlayOptions, + output: &mut FloatFlatContoursBuffer

, + ) { + if self.radius_is_too_small() { + output.clear_and_reserve(0, 0); + return; + } + + let mut segments = Vec::with_capacity(self.builder.capacity(self.paths_count, self.points_count)); + for path in source.iter_variable_paths() { + self.builder.build(path, &self.adapter, &mut segments); + } + + let mut overlay = Overlay::with_segments(segments); + overlay.options = options.int_with_adapter(&self.adapter); + let mut int_output = FlatContoursBuffer::::with_capacity(0); + overlay.overlay_into(OverlayRule::Subject, FillRule::Positive, &mut int_output); + + let iter = int_output + .points + .iter() + .map(|point| self.adapter.int_to_float(point)); + output.set_with_iter(iter, &int_output.ranges); + if options.clean_result { + if options.preserve_output_collinear { + output.despike_contour(&self.adapter); + } else { + output.simplify_contour(&self.adapter); + } + } + } + + #[cfg(feature = "variable_stroke_debug")] + fn build_debug + ?Sized>( + self, + source: &S, + options: OverlayOptions, + ) -> VariableStrokeDebugResult

{ + if self.radius_is_too_small() { + return VariableStrokeDebugResult { + edges: vec![], + shapes: vec![], + }; + } + + let mut segments = Vec::with_capacity(self.builder.capacity(self.paths_count, self.points_count)); + let mut edges = Vec::with_capacity(segments.capacity()); + for (path_index, path) in source.iter_variable_paths().enumerate() { + self.builder + .build_debug(path, path_index, &self.adapter, &mut segments, &mut edges); + } + + let mut overlay = Overlay::with_segments(segments); + overlay.options = options.int_with_adapter(&self.adapter); + let shapes = overlay.overlay(OverlayRule::Subject, FillRule::Positive); + let mut shapes = shapes.to_float(&self.adapter); + + if options.clean_result { + if options.preserve_output_collinear { + shapes.despike_contour(&self.adapter); + } else { + shapes.simplify_contour(&self.adapter); + } + } + + VariableStrokeDebugResult { edges, shapes } + } + + #[inline] + fn radius_is_too_small(&self) -> bool { + let radius = self + .adapter + .round_len_to_int(self.max_radius) + .to_wide() + .unsigned_abs(); + radius <= I::WideUInt::ONE + } +} + +#[cfg(test)] +mod tests { + use super::VariableStrokeOffset; + use crate::float::overlay::OverlayOptions; + use crate::mesh::stroke::offset::StrokeOffset; + use crate::mesh::style::{LineCap, LineJoin, StrokeStyle}; + use crate::mesh::variable_stroke::{StrokeVertex, VariableStrokeStyle}; + use alloc::vec; + use alloc::vec::Vec; + use i_shape::flat::float::FloatFlatContoursBuffer; + use i_shape::float::area::Area; + + #[cfg(feature = "variable_stroke_debug")] + use crate::mesh::variable_stroke::{VariableStrokeDebug, VariableStrokeDebugEdgeKind}; + + #[cfg(feature = "variable_stroke_debug")] + #[test] + fn debug_trace_preserves_order_and_edge_categories() { + let paths = vec![vec![ + StrokeVertex::new([0.0_f32, 0.0], 10.0), + StrokeVertex::new([100.0, 0.0], 40.0), + StrokeVertex::new([100.0, -100.0], 10.0), + ]]; + let result = paths.variable_stroke_debug(VariableStrokeStyle::new().round_angle(0.2)); + + assert!(!result.shapes.is_empty()); + assert!(!result.edges.is_empty()); + assert!( + result + .edges + .iter() + .any(|edge| edge.kind == VariableStrokeDebugEdgeKind::SectionBoundary) + ); + assert!( + result + .edges + .iter() + .any(|edge| edge.kind == VariableStrokeDebugEdgeKind::JoinArc) + ); + assert!( + result + .edges + .iter() + .any(|edge| edge.kind == VariableStrokeDebugEdgeKind::CapArc) + ); + assert!( + result + .edges + .iter() + .enumerate() + .all(|(order, edge)| edge.order == order) + ); + } + + #[test] + fn equal_width_builds_round_stroke() { + let path = vec![ + StrokeVertex::new([0.0, 0.0], 4.0), + StrokeVertex::new([10.0, 0.0], 4.0), + ]; + let shapes = path.variable_stroke(VariableStrokeStyle::new()); + assert_eq!(shapes.len(), 1); + assert_eq!(shapes[0].len(), 1); + } + + #[test] + fn supports_i64_engine() { + let path = [ + StrokeVertex::new([0.0, 0.0], 2.0), + StrokeVertex::new([10.0, 0.0], 6.0), + ]; + let shapes = path.variable_stroke_as::(VariableStrokeStyle::new()); + assert!(!shapes.is_empty()); + } + + #[test] + fn zero_width_is_empty() { + let path = [ + StrokeVertex::new([0.0, 0.0], 0.0), + StrokeVertex::new([10.0, 0.0], 0.0), + ]; + let shapes = path.variable_stroke(VariableStrokeStyle::new()); + assert!(shapes.is_empty()); + } + + fn assert_flat_output_matches( + shapes: Vec>>, + output: &FloatFlatContoursBuffer<[f32; 2]>, + ) { + let contours: Vec<_> = shapes.into_iter().flatten().collect(); + assert_eq!(output.to_contours(), contours); + } + + #[test] + fn flat_output_variants_match_allocating_variants() { + let path = [ + StrokeVertex::new([0.0_f32, 0.0], 4.0), + StrokeVertex::new([10.0, 2.0], 8.0), + StrokeVertex::new([20.0, -1.0], 5.0), + ]; + let style = VariableStrokeStyle::new().round_angle(0.2); + let mut output = FloatFlatContoursBuffer::default(); + + let expected = path.variable_stroke(style); + path.variable_stroke_into(style, &mut output); + assert_flat_output_matches(expected, &output); + + let mut options = OverlayOptions::::default(); + options.preserve_output_collinear = true; + let expected = path.variable_stroke_custom(style, options); + path.variable_stroke_custom_into(style, options, &mut output); + assert_flat_output_matches(expected, &output); + + let expected = path.variable_stroke_fixed_scale(style, 1_000.0).unwrap(); + path.variable_stroke_fixed_scale_into(style, 1_000.0, &mut output) + .unwrap(); + assert_flat_output_matches(expected, &output); + + let expected = path + .variable_stroke_custom_fixed_scale(style, options, 1_000.0) + .unwrap(); + path.variable_stroke_custom_fixed_scale_into(style, options, 1_000.0, &mut output) + .unwrap(); + assert_flat_output_matches(expected, &output); + + let expected = path.variable_stroke_as::(style); + path.variable_stroke_into_as::(style, &mut output); + assert_flat_output_matches(expected, &output); + + let mut options_i64 = OverlayOptions::::default(); + options_i64.preserve_output_collinear = true; + let expected = path.variable_stroke_custom_as::(style, options_i64); + path.variable_stroke_custom_into_as::(style, options_i64, &mut output); + assert_flat_output_matches(expected, &output); + + let expected = path + .variable_stroke_fixed_scale_as::(style, 1_000.0) + .unwrap(); + path.variable_stroke_fixed_scale_into_as::(style, 1_000.0, &mut output) + .unwrap(); + assert_flat_output_matches(expected, &output); + + let expected = path + .variable_stroke_custom_fixed_scale_as::(style, options_i64, 1_000.0) + .unwrap(); + path.variable_stroke_custom_fixed_scale_into_as::(style, options_i64, 1_000.0, &mut output) + .unwrap(); + assert_flat_output_matches(expected, &output); + } + + #[test] + fn empty_and_subpixel_inputs_clear_flat_output() { + let drawable = [ + StrokeVertex::new([0.0_f32, 0.0], 4.0), + StrokeVertex::new([10.0, 0.0], 6.0), + ]; + let empty: [StrokeVertex<[f32; 2]>; 0] = []; + let single = [StrokeVertex::new([0.0_f32, 0.0], 4.0)]; + let style = VariableStrokeStyle::new(); + let mut output = FloatFlatContoursBuffer::default(); + + drawable.variable_stroke_into(style, &mut output); + assert!(!output.points.is_empty()); + empty.variable_stroke_into(style, &mut output); + assert!(output.points.is_empty()); + assert!(output.ranges.is_empty()); + + drawable.variable_stroke_into(style, &mut output); + single + .variable_stroke_custom_fixed_scale_into_as::( + style, + OverlayOptions::default(), + 1_000.0, + &mut output, + ) + .unwrap(); + assert!(output.points.is_empty()); + assert!(output.ranges.is_empty()); + + let paths = vec![vec![], drawable.to_vec()]; + assert!(!paths.variable_stroke(style).is_empty()); + + assert!( + drawable + .variable_stroke_fixed_scale(style, 0.1) + .unwrap() + .is_empty() + ); + drawable + .variable_stroke_fixed_scale_into(style, 0.1, &mut output) + .unwrap(); + assert!(output.points.is_empty()); + assert!(output.ranges.is_empty()); + } + + #[test] + fn constant_width_matches_static_round_stroke_area() { + let points = [[0.0f64, 0.0], [10.0, 0.0], [15.0, 8.0]]; + let path = points.map(|point| StrokeVertex::new(point, 4.0)); + let angle = 0.1; + let actual = path + .variable_stroke_fixed_scale(VariableStrokeStyle::new().round_angle(angle), 1_000.0) + .unwrap(); + let expected = points + .stroke_fixed_scale( + StrokeStyle::new(4.0) + .start_cap(LineCap::Round(angle)) + .end_cap(LineCap::Round(angle)) + .line_join(LineJoin::Round(angle)), + false, + 1_000.0, + ) + .unwrap(); + + let delta = (actual.area() - expected.area()).abs(); + assert!(delta < 0.1, "area delta: {delta}"); + } + + #[test] + fn reversing_regular_path_preserves_area() { + let path = vec![ + StrokeVertex::new([0.0f64, 0.0], 2.0), + StrokeVertex::new([10.0, 3.0], 7.0), + StrokeVertex::new([18.0, -2.0], 4.0), + ]; + let mut reversed = path.clone(); + reversed.reverse(); + let style = VariableStrokeStyle::new().round_angle(0.08); + let forward = path.variable_stroke_fixed_scale(style, 10_000.0).unwrap(); + let backward = reversed.variable_stroke_fixed_scale(style, 10_000.0).unwrap(); + + assert!((forward.area() - backward.area()).abs() < 0.01); + } + + #[test] + fn variable_tangent_outline_does_not_leave_center_notch() { + let paths = vec![vec![ + StrokeVertex::new([0.0_f32, 0.0_f32], 6.0_f32), + StrokeVertex::new([46.829_998_f32, 14.88_f32], 18.0_f32), + StrokeVertex::new([70.0_f32, 0.0_f32], 42.0_f32), + StrokeVertex::new([105.0_f32, 25.0_f32], 15.0_f32), + StrokeVertex::new([140.0_f32, 15.0_f32], 30.0_f32), + ]]; + let style = VariableStrokeStyle::new().round_angle(0.179_999_99_f32); + let result = paths.variable_stroke(style); + let join = paths[0][1].point; + let has_center_notch = result + .iter() + .flatten() + .flatten() + .any(|point| (point[0] - join[0]).abs() < 0.001 && (point[1] - join[1]).abs() < 0.001); + + assert!(!has_center_notch); + } + + #[test] + fn reversed_tangent_order_builds_outline() { + let paths = vec![vec![ + StrokeVertex::new([0.0_f32, 0.0_f32], 4.5_f32), + StrokeVertex::new([45.32_f32, 9.559_999_f32], 13.5_f32), + StrokeVertex::new([91.299_995_f32, 0.89_f32], 31.5_f32), + StrokeVertex::new([102.88_f32, -2.44_f32], 11.25_f32), + StrokeVertex::new([140.0_f32, 15.0_f32], 22.5_f32), + ]]; + let style = VariableStrokeStyle::new().round_angle(0.179_999_99_f32); + let result = paths.variable_stroke(style); + + assert_eq!(result.len(), 1); + } + + #[test] + fn near_u_turn_preserves_both_edge_widths() { + let paths = vec![vec![ + StrokeVertex::new([0.0_f32, 0.0_f32], 6.0_f32), + StrokeVertex::new([96.93_f32, 0.669_999_96_f32], 18.0_f32), + StrokeVertex::new([70.0_f32, 0.0_f32], 42.0_f32), + StrokeVertex::new([105.0_f32, 25.0_f32], 15.0_f32), + StrokeVertex::new([140.0_f32, 15.0_f32], 30.0_f32), + ]]; + let style = VariableStrokeStyle::new().round_angle(0.179_999_99_f32); + let result = paths.variable_stroke(style); + + assert_eq!(result.len(), 1); + } + + #[test] + fn u_turn_keeps_round_outline_at_reversal_vertex() { + let paths = vec![vec![ + StrokeVertex::new([0.0_f32, 0.0_f32], 8.0_f32), + StrokeVertex::new([60.0_f32, 0.0_f32], 20.0_f32), + StrokeVertex::new([12.309_999_f32, 5.91_f32], 10.0_f32), + StrokeVertex::new([65.0_f32, 20.0_f32], 16.0_f32), + ]]; + let style = VariableStrokeStyle::new().round_angle(0.179_999_99_f32); + let result = paths.variable_stroke(style); + let min_y_near_reversal = result + .iter() + .flatten() + .flatten() + .filter(|point| point[0] > 50.0) + .map(|point| point[1]) + .fold(f32::MAX, f32::min); + + assert_eq!(result.len(), 1); + assert!( + min_y_near_reversal < -9.5, + "round outline at the reversal vertex was lost: y={min_y_near_reversal}" + ); + } + + #[test] + fn wide_turn_preserves_outer_radius() { + let paths = vec![vec![ + StrokeVertex::new([-11.599_999_f32, 35.16_f32], 6.0_f32), + StrokeVertex::new([149.599_99_f32, 86.88_f32], 18.0_f32), + StrokeVertex::new([70.0_f32, 0.0_f32], 42.0_f32), + StrokeVertex::new([107.149_994_f32, 27.82_f32], 15.0_f32), + StrokeVertex::new([156.72_f32, -34.079_998_f32], 30.0_f32), + ]]; + let style = VariableStrokeStyle::new().round_angle(0.179_999_99_f32); + let result = paths.variable_stroke(style); + + assert_eq!(result.len(), 1); + } + + #[test] + fn covered_open_taper_uses_larger_vertex_as_round_start() { + let paths = vec![vec![ + StrokeVertex::new([0.0_f32, 0.0_f32], 6.0_f32), + StrokeVertex::new([3.86_f32, 0.28_f32], 18.0_f32), + StrokeVertex::new([75.06_f32, 40.12_f32], 42.0_f32), + StrokeVertex::new([145.72_f32, 11.719_999_f32], 15.0_f32), + StrokeVertex::new([159.519_99_f32, 60.34_f32], 30.0_f32), + ]]; + let style = VariableStrokeStyle::new().round_angle(0.179_999_99_f32); + let result = paths.variable_stroke(style); + + assert_eq!(result.len(), 1); + + let center = paths[0][1].point; + let next = paths[0][2].point; + let vector = [next[0] - center[0], next[1] - center[1]]; + let length = (vector[0] * vector[0] + vector[1] * vector[1]).sqrt(); + let direction = [vector[0] / length, vector[1] / length]; + let min_projection = result + .iter() + .flatten() + .flatten() + .map(|point| (point[0] - center[0]) * direction[0] + (point[1] - center[1]) * direction[1]) + .fold(f32::MAX, f32::min); + + assert!( + min_projection < -8.5, + "round start does not cover the larger circle: projection={min_projection}" + ); + } +} diff --git a/iOverlay/src/mesh/variable_stroke/resource.rs b/iOverlay/src/mesh/variable_stroke/resource.rs new file mode 100644 index 00000000..cb70982b --- /dev/null +++ b/iOverlay/src/mesh/variable_stroke/resource.rs @@ -0,0 +1,254 @@ +use crate::mesh::variable_stroke::style::StrokeVertex; +use alloc::vec::Vec; +use i_float::float::compatible::FloatPointCompatible; + +pub trait VariableStrokeSource

+where + P: FloatPointCompatible, +{ + type ResourceIter<'a>: Iterator]> + where + P: 'a, + Self: 'a; + + fn iter_variable_paths(&self) -> Self::ResourceIter<'_>; +} + +pub struct ContourResourceIterator<'a, P: FloatPointCompatible> { + slice: &'a [StrokeVertex

], + finished: bool, +} + +impl<'a, P: FloatPointCompatible> ContourResourceIterator<'a, P> { + #[inline] + fn with_slice(slice: &'a [StrokeVertex

]) -> Self { + Self { + slice, + finished: false, + } + } +} + +impl<'a, P: FloatPointCompatible> Iterator for ContourResourceIterator<'a, P> { + type Item = &'a [StrokeVertex

]; + + #[inline] + fn next(&mut self) -> Option { + if self.finished { + return None; + } + self.finished = true; + Some(self.slice) + } + + #[inline] + fn count(self) -> usize { + 1 + } +} + +impl VariableStrokeSource

for [StrokeVertex

] { + type ResourceIter<'a> + = ContourResourceIterator<'a, P> + where + P: 'a, + Self: 'a; + + #[inline] + fn iter_variable_paths(&self) -> Self::ResourceIter<'_> { + ContourResourceIterator::with_slice(self) + } +} + +impl VariableStrokeSource

for [StrokeVertex

; N] { + type ResourceIter<'a> + = ContourResourceIterator<'a, P> + where + P: 'a, + Self: 'a; + + #[inline] + fn iter_variable_paths(&self) -> Self::ResourceIter<'_> { + ContourResourceIterator::with_slice(self) + } +} + +impl VariableStrokeSource

for Vec> { + type ResourceIter<'a> + = ContourResourceIterator<'a, P> + where + P: 'a, + Self: 'a; + + #[inline] + fn iter_variable_paths(&self) -> Self::ResourceIter<'_> { + ContourResourceIterator::with_slice(self.as_slice()) + } +} + +impl<'b, P: FloatPointCompatible> VariableStrokeSource

for &'b [StrokeVertex

] { + type ResourceIter<'a> + = ContourResourceIterator<'a, P> + where + P: 'a, + Self: 'a; + + #[inline] + fn iter_variable_paths(&self) -> Self::ResourceIter<'b> { + ContourResourceIterator::with_slice(self) + } +} + +pub struct ShapeResourceIterator<'a, P: FloatPointCompatible> { + slice: &'a [Vec>], + index: usize, +} + +impl<'a, P: FloatPointCompatible> Iterator for ShapeResourceIterator<'a, P> { + type Item = &'a [StrokeVertex

]; + + #[inline] + fn next(&mut self) -> Option { + let path = self.slice.get(self.index)?; + self.index += 1; + Some(path.as_slice()) + } + + #[inline] + fn count(self) -> usize { + self.slice.len() + } +} + +impl VariableStrokeSource

for [Vec>] { + type ResourceIter<'a> + = ShapeResourceIterator<'a, P> + where + P: 'a, + Self: 'a; + + #[inline] + fn iter_variable_paths(&self) -> Self::ResourceIter<'_> { + ShapeResourceIterator { + slice: self, + index: 0, + } + } +} + +impl VariableStrokeSource

for [Vec>; N] { + type ResourceIter<'a> + = ShapeResourceIterator<'a, P> + where + P: 'a, + Self: 'a; + + #[inline] + fn iter_variable_paths(&self) -> Self::ResourceIter<'_> { + ShapeResourceIterator { + slice: self, + index: 0, + } + } +} + +impl VariableStrokeSource

for Vec>> { + type ResourceIter<'a> + = ShapeResourceIterator<'a, P> + where + P: 'a, + Self: 'a; + + #[inline] + fn iter_variable_paths(&self) -> Self::ResourceIter<'_> { + ShapeResourceIterator { + slice: self.as_slice(), + index: 0, + } + } +} + +impl<'b, P: FloatPointCompatible> VariableStrokeSource

for &'b [Vec>] { + type ResourceIter<'a> + = ShapeResourceIterator<'a, P> + where + P: 'a, + Self: 'a; + + #[inline] + fn iter_variable_paths(&self) -> Self::ResourceIter<'b> { + ShapeResourceIterator { + slice: self, + index: 0, + } + } +} + +#[cfg(test)] +mod tests { + use super::VariableStrokeSource; + use crate::mesh::variable_stroke::StrokeVertex; + use alloc::vec; + use alloc::vec::Vec; + + type Vertex = StrokeVertex<[f64; 2]>; + + fn path(y: f64) -> Vec { + vec![ + StrokeVertex::new([0.0, y], 2.0), + StrokeVertex::new([10.0, y], 4.0), + ] + } + + #[test] + fn contour_resource_forms_yield_exactly_one_path() { + let array = [ + StrokeVertex::new([0.0, 0.0], 2.0), + StrokeVertex::new([10.0, 0.0], 4.0), + ]; + let slice: &[Vertex] = &array; + let owned = array.to_vec(); + + assert_eq!( + <[Vertex; 2] as VariableStrokeSource<_>>::iter_variable_paths(&array).count(), + 1 + ); + assert_eq!( + as VariableStrokeSource<_>>::iter_variable_paths(&owned).count(), + 1 + ); + + let mut slice_iter = <[Vertex] as VariableStrokeSource<_>>::iter_variable_paths(slice); + assert_eq!(slice_iter.next().unwrap().len(), 2); + assert!(slice_iter.next().is_none()); + + let mut reference_iter = <&[Vertex] as VariableStrokeSource<_>>::iter_variable_paths(&slice); + assert_eq!(reference_iter.next().unwrap()[1].point, [10.0, 0.0]); + assert!(reference_iter.next().is_none()); + } + + #[test] + fn shape_resource_forms_preserve_path_order_and_count() { + let array = [path(0.0), path(10.0)]; + let slice: &[Vec] = &array; + let owned = array.to_vec(); + + assert_eq!( + <[Vec; 2] as VariableStrokeSource<_>>::iter_variable_paths(&array).count(), + 2 + ); + assert_eq!( + <[Vec] as VariableStrokeSource<_>>::iter_variable_paths(slice).count(), + 2 + ); + assert_eq!( + > as VariableStrokeSource<_>>::iter_variable_paths(&owned).count(), + 2 + ); + + let mut reference_iter = <&[Vec] as VariableStrokeSource<_>>::iter_variable_paths(&slice); + assert_eq!(reference_iter.next().unwrap()[0].point, [0.0, 0.0]); + assert_eq!(reference_iter.next().unwrap()[0].point, [0.0, 10.0]); + assert!(reference_iter.next().is_none()); + } +} diff --git a/iOverlay/src/mesh/variable_stroke/section.rs b/iOverlay/src/mesh/variable_stroke/section.rs new file mode 100644 index 00000000..1e6d9a16 --- /dev/null +++ b/iOverlay/src/mesh/variable_stroke/section.rs @@ -0,0 +1,164 @@ +use crate::mesh::math::Math; +use crate::mesh::variable_stroke::style::StrokeVertex; +use i_float::adapter::FloatPointAdapter; +use i_float::float::compatible::FloatPointCompatible; +use i_float::float::number::FloatNumber; +use i_float::float::vector::FloatPointMath; +use i_float::int::number::int::IntNumber; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum RadiusTrend { + Decreasing, + Constant, + Increasing, +} + +#[derive(Clone, Copy)] +pub(super) struct Section { + pub(super) a: P, + pub(super) b: P, + pub(super) a_left: P, + pub(super) b_left: P, + pub(super) a_right: P, + pub(super) b_right: P, + pub(super) radius_trend: RadiusTrend, +} + +impl Section

{ + pub(super) fn try_new( + a: &StrokeVertex

, + b: &StrokeVertex

, + adapter: &FloatPointAdapter, + ) -> Option { + let int_a = adapter.float_to_int(&a.point); + let int_b = adapter.float_to_int(&b.point); + if int_a == int_b { + return None; + } + + let int_a_radius = adapter.round_len_to_int(a.radius()); + let int_b_radius = adapter.round_len_to_int(b.radius()); + if int_a_radius.max(int_b_radius) <= I::ONE { + return None; + } + let radius_trend = if int_a_radius < int_b_radius { + RadiusTrend::Increasing + } else if int_a_radius > int_b_radius { + RadiusTrend::Decreasing + } else { + RadiusTrend::Constant + }; + + let int_radius_delta = int_a_radius.to_wide() - int_b_radius.to_wide(); + let vector = int_b - int_a; + let int_distance_sqr = vector.sqr_length(); + + if int_radius_delta * int_radius_delta >= int_distance_sqr { + return None; + } + + let a = adapter.int_to_float(&int_a); + let b = adapter.int_to_float(&int_b); + let a_radius = adapter.len_to_float(int_a_radius); + let b_radius = adapter.len_to_float(int_b_radius); + + Some(Self::new(a_radius, b_radius, &a, &b, radius_trend)) + } + + fn new(a_radius: P::Scalar, b_radius: P::Scalar, a: &P, b: &P, radius_trend: RadiusTrend) -> Self { + let direction = Math::normal(b, a); + let center_vector = FloatPointMath::sub(b, a); + let distance_sqr = FloatPointMath::sqr_length(¢er_vector); + let distance = distance_sqr.sqrt(); + let radius_delta = a_radius - b_radius; + let k = radius_delta / distance; + let h = (P::Scalar::ONE - k * k).max(P::Scalar::ZERO).sqrt(); + + let normal = P::from_xy(-direction.y(), direction.x()); + let left_normal = P::from_xy( + k * direction.x() + h * normal.x(), + k * direction.y() + h * normal.y(), + ); + let right_normal = P::from_xy( + k * direction.x() - h * normal.x(), + k * direction.y() - h * normal.y(), + ); + + let a_left = FloatPointMath::add(a, &FloatPointMath::scale(&left_normal, a_radius)); + let b_left = FloatPointMath::add(b, &FloatPointMath::scale(&left_normal, b_radius)); + let a_right = FloatPointMath::add(a, &FloatPointMath::scale(&right_normal, a_radius)); + let b_right = FloatPointMath::add(b, &FloatPointMath::scale(&right_normal, b_radius)); + + Self { + a: *a, + b: *b, + a_left, + b_left, + a_right, + b_right, + radius_trend, + } + } +} + +#[cfg(test)] +mod tests { + use super::{RadiusTrend, Section}; + use crate::mesh::variable_stroke::StrokeVertex; + use i_float::adapter::FloatPointAdapter; + use i_float::float::rect::FloatRect; + + fn adapter() -> FloatPointAdapter<[f64; 2], i32> { + FloatPointAdapter::with_scale(FloatRect::new(-100.0, 100.0, -100.0, 100.0), 1.0) + } + + #[test] + fn equal_width_has_parallel_tangents() { + let a = StrokeVertex::new([0.0, 0.0], 4.0); + let b = StrokeVertex::new([10.0, 0.0], 4.0); + let section = Section::try_new(&a, &b, &adapter()).unwrap(); + + assert_eq!(section.a_left, [0.0, 2.0]); + assert_eq!(section.b_left, [10.0, 2.0]); + assert_eq!(section.a_right, [0.0, -2.0]); + assert_eq!(section.b_right, [10.0, -2.0]); + assert_eq!(section.radius_trend, RadiusTrend::Constant); + } + + #[test] + fn radius_trend_uses_adapter_radii() { + let increasing = Section::try_new( + &StrokeVertex::new([0.0, 0.0], 4.0), + &StrokeVertex::new([10.0, 0.0], 6.0), + &adapter(), + ) + .unwrap(); + let decreasing = Section::try_new( + &StrokeVertex::new([0.0, 0.0], 6.0), + &StrokeVertex::new([10.0, 0.0], 4.0), + &adapter(), + ) + .unwrap(); + + assert_eq!(increasing.radius_trend, RadiusTrend::Increasing); + assert_eq!(decreasing.radius_trend, RadiusTrend::Decreasing); + } + + #[test] + fn points_equal_in_int_space_are_zero() { + let adapter: FloatPointAdapter<[f64; 2], i32> = + FloatPointAdapter::with_scale(FloatRect::new(-100.0, 100.0, -100.0, 100.0), 10.0); + let a = StrokeVertex::new([0.01, 0.01], 4.0); + let b = StrokeVertex::new([0.04, 0.04], 4.0); + + assert!(Section::try_new(&a, &b, &adapter).is_none()); + } + + #[test] + fn radius_at_most_one_in_int_space_is_zero() { + let a = StrokeVertex::new([0.0, 0.0], 2.0); + let b = StrokeVertex::new([10.0, 0.0], 2.0); + + assert!(Section::try_new(&a, &b, &adapter()).is_none()); + } +} diff --git a/iOverlay/src/mesh/variable_stroke/style.rs b/iOverlay/src/mesh/variable_stroke/style.rs new file mode 100644 index 00000000..83188ff5 --- /dev/null +++ b/iOverlay/src/mesh/variable_stroke/style.rs @@ -0,0 +1,63 @@ +use core::f64::consts::PI; +use i_float::float::compatible::FloatPointCompatible; +use i_float::float::number::FloatNumber; + +/// A point on a variable-width centerline. +#[derive(Debug, Clone, Copy)] +pub struct StrokeVertex { + pub point: P, + pub width: P::Scalar, +} + +impl StrokeVertex

{ + #[inline] + pub fn new(point: P, width: P::Scalar) -> Self { + Self { point, width } + } + + #[inline] + pub(super) fn radius(&self) -> P::Scalar { + P::Scalar::HALF * self.width.max(P::Scalar::ZERO) + } +} + +/// Round-only style for variable-width strokes. +#[derive(Debug, Clone, Copy)] +pub struct VariableStrokeStyle { + /// Maximum angular step used to approximate round joins and caps, in radians. + pub round_angle: T, +} + +impl VariableStrokeStyle { + #[inline] + pub fn new() -> Self { + Self::default() + } + + #[inline] + pub fn round_angle(mut self, angle: T) -> Self { + self.round_angle = Self::normalize_angle(angle); + self + } + + #[inline] + pub(super) fn normalized(self) -> Self { + Self { + round_angle: Self::normalize_angle(self.round_angle), + } + } + + #[inline] + fn normalize_angle(angle: T) -> T { + let value = angle.to_f64().clamp(0.01 * PI, 0.25 * PI); + T::from_float(value) + } +} + +impl Default for VariableStrokeStyle { + fn default() -> Self { + Self { + round_angle: T::from_float(0.1), + } + } +} diff --git a/iOverlay/src/vector/extract.rs b/iOverlay/src/vector/extract.rs index c92ae50a..e1373073 100644 --- a/iOverlay/src/vector/extract.rs +++ b/iOverlay/src/vector/extract.rs @@ -286,7 +286,7 @@ where segments.sort_by_a_then_by_angle(); - let solution = ShapeBinder::bind(self.len(), hole_segments, segments); + let solution = ShapeBinder::bind_required(self.len(), hole_segments, segments); for (shape_index, &capacity) in solution.children_count_for_parent.iter().enumerate() { self[shape_index].reserve_exact(capacity); diff --git a/iOverlay/tests/hierarchy_stress.rs b/iOverlay/tests/hierarchy_stress.rs new file mode 100644 index 00000000..4fbfdade --- /dev/null +++ b/iOverlay/tests/hierarchy_stress.rs @@ -0,0 +1,273 @@ +use i_float::int::point::IntPoint; +use i_overlay::core::fill_rule::FillRule; +use i_overlay::core::hierarchy::{ChildLink, FlatShapeHierarchy}; +use i_overlay::core::overlay::{ContourDirection, Overlay}; +use i_overlay::core::overlay_rule::OverlayRule; +use i_shape::int::path::ContourExtension; +use std::panic::{AssertUnwindSafe, catch_unwind}; + +#[test] +fn randomized_boolean_hierarchy_matches_containment() { + for iteration in 0..4_096 { + let seed = next_stress_seed(iteration as u64); + let result = catch_unwind(AssertUnwindSafe(|| run_stress_case(seed))); + + if let Err(payload) = result { + panic!( + "hierarchy stress failed: iteration={iteration} seed={seed} panic={}", + panic_message(payload) + ); + } + } +} + +fn run_stress_case(seed: u64) { + let mut rng = StressRng::new(seed); + let mut contours = Vec::new(); + let root_count = rng.range_usize(1, 4); + + for root_index in 0..root_count { + let x = (root_index % 2) as i32 * 2_000_000 + rng.range_i32(0, 100_000); + let y = (root_index / 2) as i32 * 2_000_000 + rng.range_i32(-100_000, 100_000); + let width = rng.range_i32(800_000, 1_200_000); + let height = rng.range_i32(800_000, 1_200_000); + let depth = rng.range_usize(0, 3); + append_shape_tree( + Rect::new(x, y, x + width, y + height), + depth, + &mut rng, + &mut contours, + ); + } + + let (subject, clip, overlay_rule) = if seed & 1 == 0 { + (contours, Vec::new(), OverlayRule::Subject) + } else { + let mut subject = Vec::new(); + let mut clip = Vec::new(); + for contour in contours { + if rng.next_u32() & 1 == 0 { + subject.push(contour); + } else { + clip.push(contour); + } + } + (subject, clip, OverlayRule::Xor) + }; + + let mut overlay = Overlay::with_contours(&subject, &clip); + if seed & 2 != 0 { + overlay.options.output_direction = ContourDirection::Clockwise; + } + + let hierarchy = overlay.overlay_hierarchy(overlay_rule, FillRule::EvenOdd); + assert_hierarchy_matches_containment(&hierarchy, seed); + assert_full_forest_iteration(&hierarchy, seed); +} + +fn append_shape_tree(hull: Rect, depth: usize, rng: &mut StressRng, contours: &mut Vec>>) { + contours.push(hull.to_contour(rng.next_u32() & 1 == 0)); + if depth == 0 || hull.width() < 1_000 || hull.height() < 1_000 { + return; + } + + let x_inset = hull.width() / rng.range_i32(8, 14); + let y_inset = hull.height() / rng.range_i32(8, 14); + let inner = hull.inset(x_inset, y_inset); + let hole_count = rng.range_usize(1, 2); + let hole_gap = inner.height() / 25; + let hole_height = (inner.height() - hole_gap * (hole_count as i32 - 1)) / hole_count as i32; + + for hole_index in 0..hole_count { + let y0 = inner.y0 + hole_index as i32 * (hole_height + hole_gap); + let hole = Rect::new(inner.x0, y0, inner.x1, y0 + hole_height); + contours.push(hole.to_contour(rng.next_u32() & 1 == 0)); + + let child_count = rng.range_usize(0, 2); + if child_count == 0 { + continue; + } + + let x_padding = hole.width() / 16; + let y_padding = hole.height() / 10; + let child_space = hole.inset(x_padding, y_padding); + let child_gap = child_space.width() / 30; + let child_width = (child_space.width() - child_gap * (child_count as i32 - 1)) / child_count as i32; + + for child_index in 0..child_count { + let x0 = child_space.x0 + child_index as i32 * (child_width + child_gap); + let child = Rect::new(x0, child_space.y0, x0 + child_width, child_space.y1); + append_shape_tree(child, depth - 1, rng, contours); + } + } +} + +fn assert_hierarchy_matches_containment(hierarchy: &FlatShapeHierarchy, seed: u64) { + let shapes = &hierarchy.shapes; + let mut expected = Vec::new(); + + for (child_shape_index, shape_range) in shapes.shape_ranges.iter().enumerate() { + let hull_range = &shapes.contour_ranges[shape_range.start]; + let sample = shapes.points[hull_range.start]; + let mut parent: Option<(u64, usize, usize)> = None; + + for (parent_shape_index, parent_shape_range) in shapes.shape_ranges.iter().enumerate() { + for parent_contour_index in parent_shape_range.start + 1..parent_shape_range.end { + let contour_range = &shapes.contour_ranges[parent_contour_index]; + let contour = &shapes.points[contour_range.clone()]; + if !contour.contains_point(sample) { + continue; + } + + let area = contour.unsafe_area().unsigned_abs(); + if parent.is_none_or(|candidate| area < candidate.0) { + parent = Some((area, parent_shape_index, parent_contour_index)); + } + } + } + + if let Some((_, parent_shape_index, parent_contour_index)) = parent { + expected.push(ChildLink { + parent_shape_index, + parent_contour_index, + child_shape_index, + }); + } + } + + expected.sort_unstable(); + assert_eq!(hierarchy.links, expected, "seed={seed}"); + + for pair in hierarchy.links.windows(2) { + assert!(pair[0] <= pair[1], "links are not sorted: seed={seed}"); + } +} + +fn assert_full_forest_iteration(hierarchy: &FlatShapeHierarchy, seed: u64) { + let shape_count = hierarchy.shapes.shape_ranges.len(); + let mut children = vec![Vec::new(); shape_count]; + let mut incoming = vec![0usize; shape_count]; + let mut linked = vec![false; shape_count]; + + for link in &hierarchy.links { + assert!(link.parent_shape_index < shape_count, "seed={seed}"); + assert!(link.child_shape_index < shape_count, "seed={seed}"); + let parent_range = &hierarchy.shapes.shape_ranges[link.parent_shape_index]; + assert!( + parent_range.start < link.parent_contour_index && link.parent_contour_index < parent_range.end, + "seed={seed} link={link:?}" + ); + + children[link.parent_shape_index].push(link.child_shape_index); + incoming[link.child_shape_index] += 1; + linked[link.parent_shape_index] = true; + linked[link.child_shape_index] = true; + } + + assert!(incoming.iter().all(|&count| count <= 1), "seed={seed}"); + + let mut visited = vec![false; shape_count]; + let mut stack = Vec::new(); + for shape_index in 0..shape_count { + if incoming[shape_index] == 0 && !children[shape_index].is_empty() { + stack.push(shape_index); + } + } + + while let Some(shape_index) = stack.pop() { + assert!(!visited[shape_index], "cycle or duplicate visit: seed={seed}"); + visited[shape_index] = true; + stack.extend(children[shape_index].iter().copied()); + } + + for shape_index in 0..shape_count { + if !visited[shape_index] { + assert!(!linked[shape_index], "unreachable linked shape: seed={seed}"); + visited[shape_index] = true; + } + } + + assert!(visited.into_iter().all(|value| value), "seed={seed}"); +} + +#[derive(Clone, Copy)] +struct Rect { + x0: i32, + y0: i32, + x1: i32, + y1: i32, +} + +impl Rect { + fn new(x0: i32, y0: i32, x1: i32, y1: i32) -> Self { + Self { x0, y0, x1, y1 } + } + + fn width(self) -> i32 { + self.x1 - self.x0 + } + + fn height(self) -> i32 { + self.y1 - self.y0 + } + + fn inset(self, x: i32, y: i32) -> Self { + Self::new(self.x0 + x, self.y0 + y, self.x1 - x, self.y1 - y) + } + + fn to_contour(self, reversed: bool) -> Vec> { + let mut contour = vec![ + IntPoint::new(self.x0, self.y0), + IntPoint::new(self.x1, self.y0), + IntPoint::new(self.x1, self.y1), + IntPoint::new(self.x0, self.y1), + ]; + if reversed { + contour.reverse(); + } + contour + } +} + +struct StressRng { + state: u64, +} + +impl StressRng { + fn new(seed: u64) -> Self { + Self { + state: seed ^ 0xa076_1d64_78bd_642f, + } + } + + fn range_usize(&mut self, min: usize, max: usize) -> usize { + min + self.next_u32() as usize % (max - min + 1) + } + + fn range_i32(&mut self, min: i32, max: i32) -> i32 { + min + (self.next_u32() % (max - min + 1) as u32) as i32 + } + + fn next_u32(&mut self) -> u32 { + self.state = self + .state + .wrapping_mul(0xe703_7ed1_a0b4_28db) + .wrapping_add(0x8ebc_6af0_9c88_c6e3); + (self.state >> 32) as u32 + } +} + +fn next_stress_seed(seed: u64) -> u64 { + seed.wrapping_mul(0xe703_7ed1_a0b4_28db) + .wrapping_add(0x8ebc_6af0_9c88_c6e3) +} + +fn panic_message(payload: Box) -> String { + if let Some(message) = payload.downcast_ref::<&str>() { + (*message).to_owned() + } else if let Some(message) = payload.downcast_ref::() { + message.clone() + } else { + "non-string panic payload".to_owned() + } +} diff --git a/iOverlay/tests/variable_stroke_stress.rs b/iOverlay/tests/variable_stroke_stress.rs new file mode 100644 index 00000000..2784923a --- /dev/null +++ b/iOverlay/tests/variable_stroke_stress.rs @@ -0,0 +1,164 @@ +use i_overlay::mesh::variable_stroke::offset::VariableStrokeOffset; +use i_overlay::mesh::variable_stroke::{StrokeVertex, VariableStrokeStyle}; +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::time::{Duration, Instant}; + +#[test] +fn randomized_variable_stroke_does_not_panic() { + for iteration in 0..512 { + let seed = next_stress_seed(iteration as u64); + run_variable_stroke_stress_case(seed, iteration); + } +} + +#[test] +fn disconnected_drawable_sections_do_not_build_join_between_centers() { + let seed = 11_958_792_495_002_733_140; + run_variable_stroke_stress_case(seed, 163); +} + +#[test] +#[ignore = "long randomized variable-stroke stress test"] +fn randomized_variable_stroke_stress() { + let seconds = env_u64("VARIABLE_STROKE_STRESS_SECONDS", 600); + let mut seed = env_u64("VARIABLE_STROKE_STRESS_SEED", 0xa076_1d64_78bd_642f); + let deadline = Instant::now() + Duration::from_secs(seconds); + let mut iteration = 0usize; + + while Instant::now() < deadline { + run_variable_stroke_stress_case(seed, iteration); + seed = next_stress_seed(seed); + iteration += 1; + } + + eprintln!("variable-stroke stress completed: iterations={iteration} seconds={seconds}"); +} + +fn run_variable_stroke_stress_case(seed: u64, iteration: usize) { + let mut rng = StressRng::new(seed); + let path = random_variable_stroke_path(&mut rng); + let round_angle = rng.range_f32(0.01, 0.8); + let style = VariableStrokeStyle::new().round_angle(round_angle); + + let result = catch_unwind(AssertUnwindSafe(|| { + let shapes = path.variable_stroke(style); + assert_valid_shapes(&shapes, seed); + + if seed & 1 == 0 { + let shapes = path.variable_stroke_as::(style); + assert_valid_shapes(&shapes, seed); + } + + let mut reversed = path.clone(); + reversed.reverse(); + let shapes = reversed.variable_stroke(style); + assert_valid_shapes(&shapes, seed); + })); + + if let Err(payload) = result { + panic!( + "variable-stroke stress failed: iteration={iteration} seed={seed} path={path:?} panic={}", + panic_message(payload) + ); + } +} + +fn random_variable_stroke_path(rng: &mut StressRng) -> Vec> { + let count = rng.range_usize(3, 16); + let mut path = Vec::with_capacity(count); + let mut point = [rng.range_f32(-200.0, 200.0), rng.range_f32(-200.0, 200.0)]; + let mut direction = rng.range_f32(0.0, 2.0 * core::f32::consts::PI); + + for index in 0..count { + let width = match rng.next_u32() % 12 { + 0 => 0.0, + 1 => rng.range_f32(0.0001, 0.01), + 2 => rng.range_f32(200.0, 600.0), + _ => rng.range_f32(0.01, 200.0), + }; + path.push(StrokeVertex::new(point, width)); + + if index + 1 == count || rng.next_u32() % 20 == 0 { + continue; + } + + let turn = match rng.next_u32() % 6 { + 0 => core::f32::consts::PI + rng.range_f32(-0.02, 0.02), + 1 => core::f32::consts::FRAC_PI_2 + rng.range_f32(-0.02, 0.02), + 2 => -core::f32::consts::FRAC_PI_2 + rng.range_f32(-0.02, 0.02), + _ => rng.range_f32(-core::f32::consts::PI, core::f32::consts::PI), + }; + direction += turn; + + let length = match rng.next_u32() % 8 { + 0 => rng.range_f32(0.0001, 0.01), + _ => rng.range_f32(0.01, 160.0), + }; + point[0] += length * direction.cos(); + point[1] += length * direction.sin(); + } + + path +} + +fn assert_valid_shapes(shapes: &[Vec>], seed: u64) { + for contour in shapes.iter().flatten() { + assert!(contour.len() >= 3, "seed={seed} contour={contour:?}"); + assert!( + contour + .iter() + .all(|point| point[0].is_finite() && point[1].is_finite()), + "seed={seed} contour contains a non-finite point: {contour:?}" + ); + } +} + +struct StressRng { + state: u64, +} + +impl StressRng { + fn new(seed: u64) -> Self { + Self { + state: seed ^ 0xa076_1d64_78bd_642f, + } + } + + fn range_usize(&mut self, min: usize, max: usize) -> usize { + min + self.next_u32() as usize % (max - min + 1) + } + + fn range_f32(&mut self, min: f32, max: f32) -> f32 { + min + (max - min) * self.next_u32() as f32 / u32::MAX as f32 + } + + fn next_u32(&mut self) -> u32 { + self.state = self + .state + .wrapping_mul(0xe703_7ed1_a0b4_28db) + .wrapping_add(0x8ebc_6af0_9c88_c6e3); + (self.state >> 32) as u32 + } +} + +fn next_stress_seed(seed: u64) -> u64 { + seed.wrapping_mul(0xe703_7ed1_a0b4_28db) + .wrapping_add(0x8ebc_6af0_9c88_c6e3) +} + +fn env_u64(name: &str, default: u64) -> u64 { + std::env::var(name) + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(default) +} + +fn panic_message(payload: Box) -> String { + if let Some(message) = payload.downcast_ref::<&str>() { + return (*message).to_string(); + } + if let Some(message) = payload.downcast_ref::() { + return message.clone(); + } + "non-string panic payload".to_string() +} diff --git a/readme/shape_hierarchy.svg b/readme/shape_hierarchy.svg new file mode 100644 index 00000000..0dc946b4 --- /dev/null +++ b/readme/shape_hierarchy.svg @@ -0,0 +1,173 @@ + + + +