diff --git a/examples/overlay_editor/Cargo.toml b/examples/overlay_editor/Cargo.toml index fcb98aef..95f0ec02 100644 --- a/examples/overlay_editor/Cargo.toml +++ b/examples/overlay_editor/Cargo.toml @@ -10,26 +10,19 @@ lto = false codegen-units = 1 [lib] -crate-type = ["cdylib"] +crate-type = ["cdylib", "rlib"] [dependencies] - -#iced = { path = "../../../../iced", features = ["wgpu", "advanced"] } -iced = { version = "0.14.0", features = ["wgpu", "advanced", "fira-sans"] } - +eframe = { version = "0.34.3", default-features = false, features = ["default_fonts", "wgpu", "x11", "wayland"] } serde = { version = "^1.0", features = ["derive"] } serde_json = "^1.0" - -wasm-bindgen = "~0.2.95" - -log = "0.4.22" -console_log = "^1.0.0" -console_error_panic_hook = "^0" - -#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_triangle = { path = "../../../../iShape/iTriangle/iTriangle", features = ["serde"] } i_mesh = { path = "../../../../iShape/iMesh/iMesh" } -#ICED_BACKEND=wgpu cargo r -r +[target.'cfg(target_arch = "wasm32")'.dependencies] +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +web-sys = { version = "0.3", features = ["Window", "Document", "HtmlCanvasElement", "HtmlElement", "CssStyleDeclaration", "Node"] } +log = "0.4" +console_log = "1.0" +console_error_panic_hook = "0.1" diff --git a/examples/overlay_editor/README.md b/examples/overlay_editor/README.md new file mode 100644 index 00000000..0639f10c --- /dev/null +++ b/examples/overlay_editor/README.md @@ -0,0 +1,65 @@ +# Overlay Editor + +Native and WebAssembly editor built with `eframe`/`egui`. The existing Boolean, +String, Stroke, Variable Stroke, and Outline modes share the original JSON fixtures. + +## Native + +From this directory: + +```sh +cargo run --release +``` + +Fixtures are resolved relative to `CARGO_MANIFEST_DIR`, so running with +`cargo run --manifest-path /path/to/overlay_editor/Cargo.toml` also works. + +- Drag a point to edit its integer coordinates. +- Drag empty canvas space to pan. +- Scroll over the canvas to zoom around the pointer. +- Use Up/Down to change fixtures when a parameter control does not have focus. + +## WebAssembly + +```sh +cargo check --target wasm32-unknown-unknown --lib +wasm-pack build --release --target web +``` + +The exported `WebApp` keeps its five JSON input strings. Startup now returns a +Promise; keep the instance alive and await startup to handle graphics errors: + +```js +import init, { WebApp } from "./pkg/overlay_editor.js"; +await init(); +const app = new WebApp(); +await app.start(booleanJson, stringJson, strokeJson, variableStrokeJson, outlineJson); +// app.destroy() releases the runner when removing the editor. +``` + +The runner uses ``. If it is absent, startup +creates a full-window canvas in the document body. An existing canvas can be sized +by the embedding page. + +## Rendering and layout + +`i_triangle` remains a local path dependency and uses the local `i_overlay` +checkout through its own manifest. It triangulates polygon fills, including holes. +The local `i_mesh` dependency builds contours, open paths, and arrows with +`RoundStrokeBuilder` (round joins and caps), rendered as `egui::Mesh`. Point markers +and the grid use native egui painting APIs. `iced` is no longer required. + +The original `app/*/{content,control,workspace}`, `draw`, `geom`, `sheet`, +`point_editor`, and `data` modules are retained. Parameters occupy the area above +the canvas; geometry and fixture calculations remain in the content modules. + +## Checks + +```sh +cargo test --lib +cargo check --all-targets +cargo check --target wasm32-unknown-unknown --lib +``` + +Tests cover all fixture workspaces, Boolean/String modes, polygon holes, +degenerate strokes, point dragging, panning, zoom anchoring, and arrow navigation. diff --git a/examples/overlay_editor/src/app/boolean/content.rs b/examples/overlay_editor/src/app/boolean/content.rs index 4ce07cd7..a2b968e0 100644 --- a/examples/overlay_editor/src/app/boolean/content.rs +++ b/examples/overlay_editor/src/app/boolean/content.rs @@ -1,6 +1,5 @@ use crate::app::boolean::control::ModeOption; use crate::app::boolean::workspace::WorkspaceState; -use crate::app::design; use crate::app::fill_option::FillOption; use crate::app::main::{AppMessage, EditorApp}; use crate::app::solver_option::SolverOption; @@ -8,12 +7,10 @@ use crate::data::boolean::BooleanResource; use crate::geom::camera::Camera; use crate::point_editor::point::PathsToEditorPoints; use crate::point_editor::widget::PointEditUpdate; +use eframe::egui::{self, Vec2}; use i_triangle::i_overlay::core::overlay::Overlay; use i_triangle::i_overlay::i_float::int::rect::IntRect; use i_triangle::i_overlay::i_shape::int::count::PointsCount; -use iced::widget::scrollable; -use iced::widget::{Button, Column, Container, Row, Space, Text}; -use iced::{Alignment, Length, Padding, Size, Vector}; use std::collections::HashMap; pub(crate) struct BooleanState { @@ -22,7 +19,7 @@ pub(crate) struct BooleanState { pub(crate) mode: ModeOption, pub(crate) solver: SolverOption, pub(crate) workspace: WorkspaceState, - pub(crate) size: Size, + pub(crate) size: Vec2, pub(crate) cameras: HashMap, } @@ -33,65 +30,33 @@ pub(crate) enum BooleanMessage { ModeSelected(ModeOption), SolverSelected(SolverOption), PointEdited(PointEditUpdate), - WorkspaceSized(Size), - WorkspaceZoomed(Camera), - WorkspaceDragged(Vector), + WorkspaceSized(Vec2), } impl EditorApp { - fn boolean_sidebar(&self) -> Column<'_, AppMessage> { - let count = self.app_resource.boolean.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.boolean.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::Bool(BooleanMessage::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 boolean_content(&self) -> Row<'_, AppMessage> { - Row::new() - .push( - scrollable( - Container::new(self.boolean_sidebar()) - .width(Length::Fixed(160.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.boolean_workspace()) + pub(crate) fn boolean_content(&mut self, ui: &mut egui::Ui) { + egui::Panel::left("boolean_tests") + .exact_size(150.0) + .resizable(false) + .show_inside(ui, |ui| { + egui::ScrollArea::vertical().show(ui, |ui| { + for index in 0..self.app_resource.boolean.count { + let response = ui.selectable_label( + self.state.boolean.test == index, + format!("test_{index}"), + ); + if response.clicked() { + response.surrender_focus(); + self.update(AppMessage::Bool(BooleanMessage::TestSelected(index))); + } + } + }); + }); + egui::CentralPanel::default().show_inside(ui, |ui| { + self.boolean_control(ui); + ui.separator(); + self.boolean_workspace(ui); + }); } pub(crate) fn boolean_update(&mut self, message: BooleanMessage) { @@ -102,8 +67,6 @@ impl EditorApp { BooleanMessage::ModeSelected(mode) => self.boolean_update_mode(mode), BooleanMessage::PointEdited(update) => self.boolean_update_point(update), BooleanMessage::WorkspaceSized(size) => self.boolean_update_size(size), - BooleanMessage::WorkspaceZoomed(zoom) => self.boolean_update_zoom(zoom), - BooleanMessage::WorkspaceDragged(drag) => self.boolean_update_drag(drag), } } @@ -119,7 +82,7 @@ impl EditorApp { } pub(crate) fn boolean_next_test(&mut self) { - let next_test = self.state.boolean.test + 1; + let next_test = self.state.boolean.test.saturating_add(1); if next_test < self.app_resource.boolean.count { self.boolean_set_test(next_test); } @@ -132,7 +95,7 @@ impl EditorApp { } } - fn boolean_update_size(&mut self, size: Size) { + fn boolean_update_size(&mut self, size: Vec2) { self.state.boolean.size = size; let points = &self.state.boolean.workspace.points; if self.state.boolean.workspace.camera.is_empty() && !points.is_empty() { @@ -170,7 +133,7 @@ impl BooleanState { solver: SolverOption::Auto, workspace: Default::default(), cameras: HashMap::with_capacity(resource.count), - size: Size::ZERO, + size: Vec2::ZERO, }; state.load_test(0, resource); @@ -197,13 +160,15 @@ impl BooleanState { self.cameras.insert(self.test, self.workspace.camera); let mut camera = *self.cameras.get(&index).unwrap_or(&Camera::empty()); - if camera.is_empty() && self.size.width > 0.001 { + if camera.is_empty() && self.size.x > 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.workspace.sheet_state = Default::default(); + self.workspace.point_state = Default::default(); self.test = index; } diff --git a/examples/overlay_editor/src/app/boolean/control.rs b/examples/overlay_editor/src/app/boolean/control.rs index 1809c34d..440fa267 100644 --- a/examples/overlay_editor/src/app/boolean/control.rs +++ b/examples/overlay_editor/src/app/boolean/control.rs @@ -1,104 +1,9 @@ use crate::app::boolean::content::BooleanMessage; -use crate::app::fill_option::FillOption; +use crate::app::design::{controls, select}; use crate::app::main::{AppMessage, EditorApp}; -use crate::app::solver_option::SolverOption; +use crate::app::{fill_option::FillOption, solver_option::SolverOption}; +use eframe::egui; use i_triangle::i_overlay::core::overlay_rule::OverlayRule; -use iced::widget::{pick_list, Column, Container, Row, Space, Text}; -use iced::{Alignment, Length}; - -impl EditorApp { - pub(crate) fn boolean_control(&self) -> Column<'_, AppMessage> { - let solver_pick_list = Row::new() - .push( - Text::new("Solver:") - .width(Length::Fixed(90.0)) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .push( - Container::new( - pick_list( - &SolverOption::ALL[..], - Some(self.state.boolean.solver), - on_select_solver, - ) - .width(Length::Fixed(160.0)), - ) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .height(Length::Fixed(40.0)); - - let fill_pick_list = Row::new() - .push( - Text::new("Fill Rule:") - .width(Length::Fixed(90.0)) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .push( - Container::new( - pick_list( - &FillOption::ALL[..], - Some(self.state.boolean.fill), - on_select_fill, - ) - .width(Length::Fixed(160.0)), - ) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .height(Length::Fixed(40.0)); - - let mode_pick_list = Row::new() - .push( - Text::new("Mode:") - .width(Length::Fixed(90.0)) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .push( - Container::new( - pick_list( - &ModeOption::ALL[..], - Some(self.state.boolean.mode), - on_select_mode, - ) - .width(Length::Fixed(160.0)), - ) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .height(Length::Fixed(40.0)); - - Column::new() - .push(solver_pick_list) - .push( - Space::new() - .width(Length::Shrink) - .height(Length::Fixed(4.0)), - ) - .push(fill_pick_list) - .push( - Space::new() - .width(Length::Shrink) - .height(Length::Fixed(4.0)), - ) - .push(mode_pick_list) - } -} - -fn on_select_fill(option: FillOption) -> AppMessage { - AppMessage::Bool(BooleanMessage::FillSelected(option)) -} - -fn on_select_mode(option: ModeOption) -> AppMessage { - AppMessage::Bool(BooleanMessage::ModeSelected(option)) -} - -fn on_select_solver(option: SolverOption) -> AppMessage { - AppMessage::Bool(BooleanMessage::SolverSelected(option)) -} #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub(crate) enum ModeOption { @@ -160,3 +65,22 @@ impl std::fmt::Display for ModeOption { ) } } + +impl EditorApp { + pub(crate) fn boolean_control(&mut self, ui: &mut egui::Ui) { + controls(ui, "boolean_controls", |ui| { + let mut solver = self.state.boolean.solver; + if select(ui, "Solver", &mut solver, &SolverOption::ALL) { + self.update(AppMessage::Bool(BooleanMessage::SolverSelected(solver))); + } + let mut fill = self.state.boolean.fill; + if select(ui, "Fill Rule", &mut fill, &FillOption::ALL) { + self.update(AppMessage::Bool(BooleanMessage::FillSelected(fill))); + } + let mut mode = self.state.boolean.mode; + if select(ui, "Mode", &mut mode, &ModeOption::ALL) { + self.update(AppMessage::Bool(BooleanMessage::ModeSelected(mode))); + } + }); + } +} diff --git a/examples/overlay_editor/src/app/boolean/mod.rs b/examples/overlay_editor/src/app/boolean/mod.rs index 2ac0d54b..7c23db47 100644 --- a/examples/overlay_editor/src/app/boolean/mod.rs +++ b/examples/overlay_editor/src/app/boolean/mod.rs @@ -1,3 +1,3 @@ pub(crate) mod content; -mod control; +pub(super) mod control; mod workspace; diff --git a/examples/overlay_editor/src/app/boolean/workspace.rs b/examples/overlay_editor/src/app/boolean/workspace.rs index 27720ae5..3a633f42 100644 --- a/examples/overlay_editor/src/app/boolean/workspace.rs +++ b/examples/overlay_editor/src/app/boolean/workspace.rs @@ -1,19 +1,18 @@ use crate::app::boolean::content::BooleanMessage; use crate::app::boolean::control::ModeOption; -use crate::app::design::{style_sheet_background, Design}; +use crate::app::design::Design; use crate::app::main::{AppMessage, EditorApp}; use crate::draw::shape::ShapeWidget; use crate::draw::vectors::VectorsWidget; use crate::geom::camera::Camera; use crate::point_editor::point::EditorPoint; -use crate::point_editor::widget::{PointEditUpdate, PointsEditorWidget}; +use crate::point_editor::{state::PointsEditorState, widget::PointsEditorWidget}; +use crate::sheet::state::SheetState; use crate::sheet::widget::SheetWidget; +use eframe::egui; use i_triangle::i_overlay::i_shape::int::count::IntShapes as RawIntShapes; use i_triangle::i_overlay::i_shape::int::path::IntPaths as RawIntPaths; use i_triangle::i_overlay::vector::edge::DataVectorEdge; -use iced::widget::Container; -use iced::widget::Stack; -use iced::{Length, Padding, Size, Vector}; type IntPaths = RawIntPaths; type IntShapes = RawIntShapes; @@ -21,6 +20,8 @@ type VectorEdge = DataVectorEdge; pub(crate) struct WorkspaceState { pub(crate) camera: Camera, + pub(crate) sheet_state: SheetState, + pub(crate) point_state: PointsEditorState, pub(crate) subj: IntPaths, pub(crate) clip: IntPaths, pub(crate) solution: IntShapes, @@ -29,161 +30,107 @@ pub(crate) struct WorkspaceState { } impl EditorApp { - pub(crate) fn boolean_workspace(&self) -> Container<'_, AppMessage> { - Container::new({ - let mut stack = Stack::new(); - stack = stack.push( - Container::new(SheetWidget::new( - self.state.boolean.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.boolean.workspace.camera.is_not_empty() { - match self.state.boolean.mode { - ModeOption::Edit => { - stack = stack - .push( - Container::new(ShapeWidget::with_paths( - &self.state.boolean.workspace.subj, - self.state.boolean.workspace.camera, - Some(self.state.boolean.fill.fill_rule()), - Some(Design::subject_color().scale_alpha(0.2)), - Some(Design::subject_color()), - 2.0, - )) - .width(Length::Fill) - .height(Length::Fill), - ) - .push( - Container::new(ShapeWidget::with_paths( - &self.state.boolean.workspace.clip, - self.state.boolean.workspace.camera, - Some(self.state.boolean.fill.fill_rule()), - Some(Design::clip_color().scale_alpha(0.2)), - Some(Design::clip_color()), - 2.0, - )) - .width(Length::Fill) - .height(Length::Fill), - ) - } - ModeOption::Debug => { - stack = stack.push( - Container::new(VectorsWidget::with_vectors( - &self.state.boolean.workspace.vectors, - self.state.boolean.workspace.camera, - Design::subject_color(), - Design::clip_color(), - Design::both_color(), - 2.0, - )) - .width(Length::Fill) - .height(Length::Fill), - ) - } - _ => { - stack = stack - .push( - Container::new(ShapeWidget::with_paths( - &self.state.boolean.workspace.subj, - self.state.boolean.workspace.camera, - Some(self.state.boolean.fill.fill_rule()), - None, - Some(Design::subject_color()), - 1.0, - )) - .width(Length::Fill) - .height(Length::Fill), - ) - .push( - Container::new(ShapeWidget::with_paths( - &self.state.boolean.workspace.clip, - self.state.boolean.workspace.camera, - Some(self.state.boolean.fill.fill_rule()), - None, - Some(Design::clip_color()), - 1.0, - )) - .width(Length::Fill) - .height(Length::Fill), - ) - .push( - Container::new(ShapeWidget::with_shapes( - &self.state.boolean.workspace.solution, - self.state.boolean.workspace.camera, - None, - Some(Design::solution_color().scale_alpha(0.2)), - Some(Design::solution_color()), - 2.0, - )) - .width(Length::Fill) - .height(Length::Fill), - ) - } + pub(crate) fn boolean_workspace(&mut self, ui: &mut egui::Ui) { + self.update(AppMessage::Bool(BooleanMessage::WorkspaceSized( + ui.available_size(), + ))); + let workspace = &mut self.state.boolean.workspace; + let (painter, update) = SheetWidget::show( + ui, + &mut workspace.camera, + &workspace.points, + &mut workspace.sheet_state, + &mut workspace.point_state, + ); + if let Some(update) = update { + self.update(AppMessage::Bool(BooleanMessage::PointEdited(update))); + ui.ctx().request_repaint(); + } + let workspace = &self.state.boolean.workspace; + if workspace.camera.is_not_empty() { + match self.state.boolean.mode { + ModeOption::Edit => { + ShapeWidget::with_paths( + &workspace.subj, + workspace.camera, + Some(self.state.boolean.fill.fill_rule()), + Some(Design::subject_color().gamma_multiply(0.2)), + Some(Design::subject_color()), + 2.0, + ) + .paint(&painter); + ShapeWidget::with_paths( + &workspace.clip, + workspace.camera, + Some(self.state.boolean.fill.fill_rule()), + Some(Design::clip_color().gamma_multiply(0.2)), + Some(Design::clip_color()), + 2.0, + ) + .paint(&painter); } - stack = stack.push( - Container::new( - PointsEditorWidget::new( - &self.state.boolean.workspace.points, - self.state.boolean.workspace.camera, - on_update_point, - ) - .set_drag_color(Design::accent_color()) - .set_hover_color(Design::negative_color()), + ModeOption::Debug => { + VectorsWidget::with_vectors( + &workspace.vectors, + workspace.camera, + Design::subject_color(), + Design::clip_color(), + Design::both_color(), + 2.0, + ) + .paint(&painter); + } + _ => { + ShapeWidget::with_paths( + &workspace.subj, + workspace.camera, + Some(self.state.boolean.fill.fill_rule()), + None, + Some(Design::subject_color()), + 1.0, + ) + .paint(&painter); + ShapeWidget::with_paths( + &workspace.clip, + workspace.camera, + Some(self.state.boolean.fill.fill_rule()), + None, + Some(Design::clip_color()), + 1.0, ) - .width(Length::Fill) - .height(Length::Fill), - ); + .paint(&painter); + ShapeWidget::with_shapes( + &workspace.solution, + workspace.camera, + None, + Some(Design::solution_color().gamma_multiply(0.2)), + Some(Design::solution_color()), + 2.0, + ) + .paint(&painter); + } } - - stack.push( - Container::new(self.boolean_control()) - .width(Length::Shrink) - .height(Length::Shrink) - .padding(Padding::new(8.0)), - ) - }) - .style(style_sheet_background) + } + PointsEditorWidget::paint( + &painter, + workspace.camera, + &workspace.points, + &workspace.point_state, + ); } - - pub(super) fn boolean_update_point(&mut self, update: PointEditUpdate) { + pub(super) fn boolean_update_point( + &mut self, + update: crate::point_editor::widget::PointEditUpdate, + ) { self.state.boolean.boolean_update_point(update); } - - pub(super) fn boolean_update_zoom(&mut self, camera: Camera) { - self.state.boolean.workspace.camera = camera; - } - - pub(super) fn boolean_update_drag(&mut self, new_pos: Vector) { - self.state.boolean.workspace.camera.pos = new_pos; - } -} - -fn on_update_point(event: PointEditUpdate) -> AppMessage { - AppMessage::Bool(BooleanMessage::PointEdited(event)) -} - -fn on_update_size(size: Size) -> AppMessage { - AppMessage::Bool(BooleanMessage::WorkspaceSized(size)) -} - -fn on_update_zoom(zoom: Camera) -> AppMessage { - AppMessage::Bool(BooleanMessage::WorkspaceZoomed(zoom)) } - -fn on_update_drag(drag: Vector) -> AppMessage { - AppMessage::Bool(BooleanMessage::WorkspaceDragged(drag)) -} - impl Default for WorkspaceState { fn default() -> Self { WorkspaceState { camera: Camera::empty(), + sheet_state: Default::default(), + point_state: Default::default(), subj: vec![], clip: vec![], solution: vec![], diff --git a/examples/overlay_editor/src/app/design.rs b/examples/overlay_editor/src/app/design.rs index ca2d249d..b0cb20f2 100644 --- a/examples/overlay_editor/src/app/design.rs +++ b/examples/overlay_editor/src/app/design.rs @@ -1,150 +1,76 @@ -use iced::widget::button; -use iced::widget::container; -use iced::widget::rule; -use iced::widget::text; -use iced::{border, Background, Color, Padding, Theme}; +use eframe::egui::{self, Color32}; -pub(super) struct Design { - pub(super) action_separator: f32, -} +pub(crate) struct Design; impl Design { - pub(crate) fn solution_color() -> Color { - Color::from_rgb8(32, 199, 32) - } - - pub(crate) fn subject_color() -> Color { - Color::from_rgb8(255, 51, 51) + pub(crate) fn solution_color() -> Color32 { + Color32::from_rgb(32, 199, 32) } - - pub(crate) fn clip_color() -> Color { - Color::from_rgb8(26, 142, 255) + pub(crate) fn subject_color() -> Color32 { + Color32::from_rgb(255, 51, 51) } - - pub(crate) fn negative_color() -> Color { - if Theme::Dark.extended_palette().is_dark { - Color::from_rgb8(224, 224, 224) - } else { - Color::from_rgb8(32, 32, 32) - } - } - - pub(crate) fn accent_color() -> Color { - Color::from_rgb8(255, 140, 0) - } - - pub(crate) fn both_color() -> Color { - Color::from_rgb8(76, 217, 100) - } - - pub(super) fn new() -> Self { - Self { - action_separator: 3.0, - } - } - - pub(super) fn action_padding(&self) -> Padding { - Padding { - top: self.action_separator, - right: 2.0 * self.action_separator, - bottom: self.action_separator, - left: 2.0 * self.action_separator, - } + pub(crate) fn clip_color() -> Color32 { + Color32::from_rgb(26, 142, 255) } -} - -// Sidebar - -pub(super) fn style_sidebar_button(theme: &Theme, status: button::Status) -> button::Style { - let palette = theme.extended_palette(); - let text_color = theme.palette().text; - let base = button::Style { - background: Some(Background::Color(Color::TRANSPARENT)), - text_color, - border: border::rounded(6), - ..button::Style::default() - }; - - match status { - button::Status::Pressed | button::Status::Hovered => button::Style { - background: Some(Background::Color( - palette.background.weak.color.scale_alpha(0.2), - )), - ..base - }, - button::Status::Disabled | button::Status::Active => base, + pub(crate) fn negative_color() -> Color32 { + Color32::from_rgb(224, 224, 224) } -} - -pub(super) fn style_sidebar_button_selected( - theme: &Theme, - status: button::Status, -) -> button::Style { - let palette = theme.extended_palette(); - let base = button::Style { - background: Some(Background::Color(palette.primary.strong.color)), - text_color: palette.primary.strong.text, - border: border::rounded(6), - ..button::Style::default() - }; - - match status { - button::Status::Pressed | button::Status::Active => base, - button::Status::Hovered => button::Style { - background: Some(Background::Color(palette.primary.base.color)), - ..base - }, - button::Status::Disabled => button::Style { - background: Some(Background::Color(Color::TRANSPARENT)), - ..base - }, + pub(crate) fn accent_color() -> Color32 { + Color32::from_rgb(255, 140, 0) } -} - -pub(super) fn style_sidebar_text(theme: &Theme) -> text::Style { - let palette = theme.palette(); - text::Style { - color: Some(palette.text.scale_alpha(0.7)), + pub(crate) fn both_color() -> Color32 { + Color32::from_rgb(76, 217, 100) } } -pub(super) fn style_sidebar_text_selected(theme: &Theme) -> text::Style { - let palette = theme.palette(); - text::Style { - color: Some(palette.text), - } +const CONTROL_WIDTH: f32 = 232.0; + +pub(crate) fn controls(ui: &mut egui::Ui, id: &str, content: impl FnOnce(&mut egui::Ui)) { + ui.scope(|ui| { + ui.spacing_mut().item_spacing = egui::vec2(8.0, 6.0); + ui.spacing_mut().interact_size = egui::vec2(64.0, 26.0); + ui.spacing_mut().slider_width = 160.0; + egui::Grid::new(id) + .num_columns(2) + .min_col_width(130.0) + .min_row_height(26.0) + .spacing([16.0, 6.0]) + .show(ui, content); + }); } -pub(super) fn style_sidebar_background(theme: &Theme) -> container::Style { - container::Style::default().background( - theme - .extended_palette() - .background - .weak - .color - .scale_alpha(0.1), - ) +pub(crate) fn slider(ui: &mut egui::Ui, label: &str, slider: egui::Slider<'_>) -> bool { + let label = ui.label(label); + let changed = ui.add(slider).labelled_by(label.id).changed(); + ui.end_row(); + changed } -pub(super) fn style_separator(theme: &Theme) -> rule::Style { - let color = if theme.extended_palette().is_dark { - Color::from_rgba(0.0, 0.0, 0.0, 0.8) - } else { - Color::from_rgba(1.0, 1.0, 1.0, 0.8) - }; - - rule::Style { - color, - radius: border::Radius::new(0), - fill_mode: rule::FillMode::Padded(0), - snap: true, - } +pub(crate) fn checkbox(ui: &mut egui::Ui, label: &str, value: &mut bool) -> bool { + let label = ui.label(label); + let changed = ui.checkbox(value, "").labelled_by(label.id).changed(); + ui.end_row(); + changed } -pub(super) fn style_sheet_background(theme: &Theme) -> container::Style { - if theme.extended_palette().is_dark { - container::Style::default().background(Color::BLACK.scale_alpha(0.4)) - } else { - container::Style::default().background(Color::WHITE.scale_alpha(0.4)) - } +pub(crate) fn select( + ui: &mut egui::Ui, + label: &str, + value: &mut T, + options: &[T], +) -> bool { + let before = *value; + let label_response = ui.label(label); + egui::ComboBox::from_id_salt(label) + .width(CONTROL_WIDTH) + .selected_text(value.to_string()) + .show_ui(ui, |ui| { + for &option in options { + ui.selectable_value(value, option, option.to_string()); + } + }) + .response + .labelled_by(label_response.id); + ui.end_row(); + *value != before } diff --git a/examples/overlay_editor/src/app/main.rs b/examples/overlay_editor/src/app/main.rs index 8d57c76e..df585835 100644 --- a/examples/overlay_editor/src/app/main.rs +++ b/examples/overlay_editor/src/app/main.rs @@ -7,22 +7,14 @@ 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}; -use iced::widget::{Button, Column, Container, Row, Text}; -use iced::{keyboard, Alignment, Element, Length}; -use iced::{Subscription, Task}; - -use crate::app::design::style_separator; -use crate::app::design::{style_sidebar_button, style_sidebar_button_selected, Design}; +use eframe::egui; + use crate::data::resource::AppResource; pub struct EditorApp { main_actions: Vec, pub(super) state: MainState, pub(super) app_resource: AppResource, - pub(super) design: Design, } pub(super) struct MainState { @@ -91,13 +83,12 @@ impl EditorApp { outline: OutlineState::new(&mut app_resource.outline), }, app_resource, - design: Design::new(), } } } impl EditorApp { - pub fn update(&mut self, message: AppMessage) -> Task { + pub(crate) fn update(&mut self, message: AppMessage) { match message { AppMessage::Main(msg) => self.update_main(msg), AppMessage::Bool(msg) => self.boolean_update(msg), @@ -120,19 +111,6 @@ impl EditorApp { MainAction::Outline => self.outline_prev_test(), }, } - - Task::none() - } - - pub fn subscription(&self) -> Subscription { - keyboard::listen().filter_map(|event| match event { - keyboard::Event::KeyPressed { key, .. } => match key { - Key::Named(Named::ArrowDown) => Some(AppMessage::NextTest), - Key::Named(Named::ArrowUp) => Some(AppMessage::PrevTest), - _ => None, - }, - _ => None, - }) } fn update_main(&mut self, message: MainMessage) { @@ -150,54 +128,211 @@ impl EditorApp { } } - pub fn view(&self) -> Element<'_, AppMessage> { - let content = Row::new().push( - Container::new(self.main_navigation()) - .width(Length::Fixed(160.0)) - .height(Length::Shrink) - .align_x(Alignment::Start), - ); + pub(crate) fn view(&mut self, ui: &mut egui::Ui) { + if !ui.ctx().egui_wants_keyboard_input() { + if ui.input(|i| i.key_pressed(egui::Key::ArrowDown)) { + self.update(AppMessage::NextTest); + } + if ui.input(|i| i.key_pressed(egui::Key::ArrowUp)) { + self.update(AppMessage::PrevTest); + } + } + egui::Panel::left("navigation") + .exact_size(150.0) + .resizable(false) + .show_inside(ui, |ui| { + for action in self.main_actions.clone() { + let response = + ui.selectable_label(self.state.selected_action == action, action.title()); + if response.clicked() { + response.surrender_focus(); + self.update(AppMessage::Main(MainMessage::ActionSelected(action))); + } + } + }); + match self.state.selected_action { + MainAction::Boolean => self.boolean_content(ui), + MainAction::String => self.string_content(ui), + MainAction::Stroke => self.stroke_content(ui), + MainAction::VariableStroke => self.variable_stroke_content(ui), + MainAction::Outline => self.outline_content(ui), + } + } +} - let content = match self.state.selected_action { - MainAction::Boolean => content - .push(rule::vertical(1).style(style_separator)) - .push(self.boolean_content()), - MainAction::String => content - .push(rule::vertical(1).style(style_separator)) - .push(self.string_content()), - 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()), - }; +impl eframe::App for EditorApp { + fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + self.view(ui); + } +} - content.height(Length::Fill).into() +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use super::*; + use eframe::egui::{self, RawInput, Rect, Vec2}; + + fn editor() -> EditorApp { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../tests"); + let path = |name| root.join(name).to_str().unwrap().to_owned(); + EditorApp::with_resource(AppResource::with_paths( + &path("boolean"), + &path("string"), + &path("stroke"), + &path("variable_stroke"), + &path("outline"), + )) } - fn main_navigation(&self) -> Column<'_, AppMessage> { - self.main_actions.iter().fold( - Column::new().push(Space::new().width(Length::Fill).height(Length::Fixed(2.0))), - |column, item| { - let is_selected = self.state.selected_action.eq(item); - column.push( - Container::new( - Button::new(Text::new(item.title())) - .width(Length::Fill) - .on_press(AppMessage::Main(MainMessage::ActionSelected(item.clone()))) - .style(if is_selected { - style_sidebar_button_selected - } else { - style_sidebar_button - }), - ) - .padding(self.design.action_padding()), - ) + fn render(app: &mut EditorApp, ctx: &egui::Context) { + let output = ctx.run_ui( + RawInput { + screen_rect: Some(Rect::from_min_size( + egui::Pos2::ZERO, + Vec2::new(1280.0, 800.0), + )), + ..Default::default() }, - ) + |ui| app.view(ui), + ); + let primitives = ctx.tessellate(output.shapes, output.pixels_per_point); + assert!(!primitives.is_empty()); + for primitive in primitives { + if let egui::epaint::Primitive::Mesh(mesh) = primitive.primitive { + assert!(mesh.is_valid()); + assert!( + mesh.vertices.iter().all(|vertex| vertex.pos.is_finite()), + "invalid mesh in {:?}, tests: {}, {}, {}, {}, {}; bad vertices: {:?}", + app.state.selected_action, + app.state.boolean.test, + app.state.string.test, + app.state.stroke.test, + app.state.variable_stroke.test, + app.state.outline.test, + mesh.vertices + .iter() + .filter(|v| !v.pos.is_finite()) + .take(4) + .collect::>() + ); + } + } + } + + #[test] + fn all_fixture_workspaces_render() { + let mut app = editor(); + let ctx = egui::Context::default(); + for action in app.main_actions.clone() { + app.update(AppMessage::Main(MainMessage::ActionSelected( + action.clone(), + ))); + let count = match action { + MainAction::Boolean => app.app_resource.boolean.count, + MainAction::String => app.app_resource.string.count, + MainAction::Stroke => app.app_resource.stroke.count, + MainAction::VariableStroke => app.app_resource.variable_stroke.count, + MainAction::Outline => app.app_resource.outline.count, + }; + assert!(count > 0); + for index in 0..count { + let message = match action { + MainAction::Boolean => AppMessage::Bool(BooleanMessage::TestSelected(index)), + MainAction::String => AppMessage::String(StringMessage::TestSelected(index)), + MainAction::Stroke => AppMessage::Stroke(StrokeMessage::TestSelected(index)), + MainAction::VariableStroke => { + AppMessage::VariableStroke(VariableStrokeMessage::TestSelected(index)) + } + MainAction::Outline => AppMessage::Outline(OutlineMessage::TestSelected(index)), + }; + app.update(message); + render(&mut app, &ctx); + } + } + } + + #[test] + fn boolean_and_string_render_modes() { + use crate::app::{ + boolean::control::ModeOption as BooleanMode, string::control::ModeOption as StringMode, + }; + let mut app = editor(); + let ctx = egui::Context::default(); + for mode in [ + BooleanMode::Edit, + BooleanMode::Debug, + BooleanMode::Subject, + BooleanMode::Clip, + BooleanMode::Intersect, + BooleanMode::Union, + BooleanMode::Difference, + BooleanMode::InverseDifference, + BooleanMode::Xor, + ] { + app.update(AppMessage::Bool(BooleanMessage::ModeSelected(mode))); + render(&mut app, &ctx); + } + app.update(AppMessage::Main(MainMessage::ActionSelected( + MainAction::String, + ))); + for mode in [ + StringMode::Edit, + StringMode::Debug, + StringMode::Slice, + StringMode::ClipDirect, + StringMode::ClipInvert, + ] { + app.update(AppMessage::String(StringMessage::ModeSelected(mode))); + render(&mut app, &ctx); + } + } + #[test] + fn arrow_navigation_works_after_canvas_and_sidebar_clicks() { + let mut app = editor(); + let ctx = egui::Context::default(); + let mut frame = |events| { + let _ = ctx.run_ui( + RawInput { + screen_rect: Some(Rect::from_min_size( + egui::Pos2::ZERO, + Vec2::new(1280.0, 800.0), + )), + events, + ..Default::default() + }, + |ui| app.view(ui), + ); + app.state.boolean.test + }; + frame(vec![]); + for pos in [egui::pos2(1100.0, 700.0), egui::pos2(170.0, 13.0)] { + frame(vec![egui::Event::PointerMoved(pos)]); + frame(vec![egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: Default::default(), + }]); + let before = frame(vec![egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: Default::default(), + }]); + let after = frame(vec![egui::Event::Key { + key: egui::Key::ArrowDown, + physical_key: None, + pressed: true, + repeat: false, + modifiers: Default::default(), + }]); + assert_eq!(after, before + 1); + frame(vec![egui::Event::Key { + key: egui::Key::ArrowDown, + physical_key: None, + pressed: false, + repeat: false, + modifiers: Default::default(), + }]); + } } } diff --git a/examples/overlay_editor/src/app/mod.rs b/examples/overlay_editor/src/app/mod.rs index 6f50052c..293b7b8e 100644 --- a/examples/overlay_editor/src/app/mod.rs +++ b/examples/overlay_editor/src/app/mod.rs @@ -1,5 +1,5 @@ mod boolean; -mod design; +pub(crate) mod design; mod fill_option; pub mod main; mod outline; diff --git a/examples/overlay_editor/src/app/outline/content.rs b/examples/overlay_editor/src/app/outline/content.rs index 3032493f..dbaa6d19 100644 --- a/examples/overlay_editor/src/app/outline/content.rs +++ b/examples/overlay_editor/src/app/outline/content.rs @@ -1,4 +1,3 @@ -use crate::app::design; use crate::app::main::{AppMessage, EditorApp}; use crate::app::outline::control::JoinOption; use crate::app::outline::workspace::WorkspaceState; @@ -6,12 +5,11 @@ use crate::data::outline::OutlineResource; use crate::geom::camera::Camera; use crate::point_editor::point::PathsToEditorPoints; use crate::point_editor::widget::PointEditUpdate; +use eframe::egui::{self, Vec2}; 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::outline::offset::OutlineOffset; -use i_triangle::i_overlay::mesh::style::{LineJoin, OutlineStyle}; -use iced::widget::{scrollable, Button, Column, Container, Row, Space, Text}; -use iced::{Alignment, Length, Padding, Size, Vector}; +use i_triangle::i_overlay::mesh::float::outline::offset::OutlineOffset; +use i_triangle::i_overlay::mesh::float::style::{LineJoin, OutlineStyle}; use std::collections::HashMap; pub(crate) struct OutlineState { @@ -21,7 +19,7 @@ pub(crate) struct OutlineState { pub(crate) join: JoinOption, pub(crate) join_value: u8, pub(crate) workspace: WorkspaceState, - pub(crate) size: Size, + pub(crate) size: Vec2, pub(crate) cameras: HashMap, } @@ -33,64 +31,33 @@ pub(crate) enum OutlineMessage { JoinSelected(JoinOption), JoinValueUpdated(u8), PointEdited(PointEditUpdate), - WorkspaceSized(Size), - WorkspaceZoomed(Camera), - WorkspaceDragged(Vector), + WorkspaceSized(Vec2), } impl EditorApp { - fn outline_sidebar(&self) -> Column<'_, AppMessage> { - let count = self.app_resource.outline.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.outline.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::Outline(OutlineMessage::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 outline_content(&self) -> Row<'_, AppMessage> { - Row::new() - .push( - scrollable( - Container::new(self.outline_sidebar()) - .width(Length::Fixed(160.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.outline_workspace()) + pub(crate) fn outline_content(&mut self, ui: &mut egui::Ui) { + egui::Panel::left("outline_tests") + .exact_size(150.0) + .resizable(false) + .show_inside(ui, |ui| { + egui::ScrollArea::vertical().show(ui, |ui| { + for index in 0..self.app_resource.outline.count { + let response = ui.selectable_label( + self.state.outline.test == index, + format!("test_{index}"), + ); + if response.clicked() { + response.surrender_focus(); + self.update(AppMessage::Outline(OutlineMessage::TestSelected(index))); + } + } + }); + }); + egui::CentralPanel::default().show_inside(ui, |ui| { + self.outline_control(ui); + ui.separator(); + self.outline_workspace(ui); + }); } pub(crate) fn outline_update(&mut self, message: OutlineMessage) { @@ -106,8 +73,6 @@ impl EditorApp { OutlineMessage::JoinValueUpdated(value) => self.outline_update_join_value(value), OutlineMessage::PointEdited(update) => self.outline_update_point(update), OutlineMessage::WorkspaceSized(size) => self.outline_update_size(size), - OutlineMessage::WorkspaceZoomed(zoom) => self.outline_update_zoom(zoom), - OutlineMessage::WorkspaceDragged(drag) => self.outline_update_drag(drag), } } @@ -123,7 +88,7 @@ impl EditorApp { } pub(crate) fn outline_next_test(&mut self) { - let next_test = self.state.outline.test + 1; + let next_test = self.state.outline.test.saturating_add(1); if next_test < self.app_resource.outline.count { self.outline_set_test(next_test); } @@ -136,7 +101,7 @@ impl EditorApp { } } - fn outline_update_size(&mut self, size: Size) { + fn outline_update_size(&mut self, size: Vec2) { self.state.outline.size = size; let points = &self.state.outline.workspace.points; if self.state.outline.workspace.camera.is_empty() && !points.is_empty() { @@ -180,7 +145,7 @@ impl OutlineState { join_value: 50, workspace: Default::default(), cameras: HashMap::with_capacity(resource.count), - size: Size::ZERO, + size: Vec2::ZERO, }; state.set_test(0, resource); @@ -217,13 +182,15 @@ impl OutlineState { self.cameras.insert(self.test, self.workspace.camera); let mut camera = *self.cameras.get(&index).unwrap_or(&Camera::empty()); - if camera.is_empty() && self.size.width > 0.001 { + if camera.is_empty() && self.size.x > 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.workspace.sheet_state = Default::default(); + self.workspace.point_state = Default::default(); self.test = index; } diff --git a/examples/overlay_editor/src/app/outline/control.rs b/examples/overlay_editor/src/app/outline/control.rs index 622a9cf9..f7aa37c4 100644 --- a/examples/overlay_editor/src/app/outline/control.rs +++ b/examples/overlay_editor/src/app/outline/control.rs @@ -1,7 +1,7 @@ +use crate::app::design::{controls, select, slider}; use crate::app::main::{AppMessage, EditorApp}; use crate::app::outline::content::OutlineMessage; -use iced::widget::{pick_list, slider, Column, Container, Row, Space, Text}; -use iced::{Alignment, Length, Padding}; +use eframe::egui; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub(crate) enum JoinOption { @@ -30,109 +30,44 @@ impl std::fmt::Display for JoinOption { } impl EditorApp { - pub(crate) fn outline_control(&self) -> Column<'_, AppMessage> { - let outer_offset_list = Row::new() - .push( - Text::new("Outer Offset:") - .width(Length::Fixed(120.0)) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .push( - Container::new( - slider( - -50.0f32..=50.0f32, - self.state.outline.outer_offset, - on_update_outer_offset, - ) - .step(0.01f32), - ) - .width(410) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .height(Length::Fixed(40.0)); - let inner_offset_list = Row::new() - .push( - Text::new("Inner Offset:") - .width(Length::Fixed(120.0)) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .push( - Container::new( - slider( - -50.0f32..=50.0f32, - self.state.outline.inner_offset, - on_update_inner_offset, - ) - .step(0.01f32), - ) - .width(410) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .height(Length::Fixed(40.0)); - - let mut join_pick_list = Row::new() - .push( - Text::new("Line Join:") - .width(Length::Fixed(120.0)) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .push( - Container::new( - pick_list( - &JoinOption::ALL[..], - Some(self.state.outline.join), - on_select_join, - ) - .width(Length::Fixed(160.0)), - ) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .height(Length::Fixed(40.0)); - - if self.state.outline.join != JoinOption::Bevel { - let slider = slider(1..=100, self.state.outline.join_value, on_update_join_value) - .default(50) - .shift_step(5); - - join_pick_list = join_pick_list.push( - Container::new(slider) - .padding(Padding::new(0.0).left(20.0)) - .width(250) - .height(Length::Fill) - .align_y(Alignment::Center), - ); - } - - Column::new() - .push(outer_offset_list) - .push(inner_offset_list) - .push( - Space::new() - .width(Length::Shrink) - .height(Length::Fixed(4.0)), - ) - .push(join_pick_list) + pub(crate) fn outline_control(&mut self, ui: &mut egui::Ui) { + controls(ui, "outline_controls", |ui| { + let mut outer_offset = self.state.outline.outer_offset; + if slider( + ui, + "Outer Offset", + egui::Slider::new(&mut outer_offset, -50.0..=50.0).step_by(0.01), + ) { + self.update(AppMessage::Outline( + OutlineMessage::OuterOffsetValueUpdated(outer_offset), + )); + } + let mut inner_offset = self.state.outline.inner_offset; + if slider( + ui, + "Inner Offset", + egui::Slider::new(&mut inner_offset, -50.0..=50.0).step_by(0.01), + ) { + self.update(AppMessage::Outline( + OutlineMessage::InnerOffsetValueUpdated(inner_offset), + )); + } + let mut join = self.state.outline.join; + if select(ui, "Line Join", &mut join, &JoinOption::ALL) { + self.update(AppMessage::Outline(OutlineMessage::JoinSelected(join))); + } + if self.state.outline.join != JoinOption::Bevel { + let mut join_value = self.state.outline.join_value; + if slider( + ui, + "Join Detail", + egui::Slider::new(&mut join_value, 1..=100), + ) { + self.update(AppMessage::Outline(OutlineMessage::JoinValueUpdated( + join_value, + ))); + } + } + }); } } - -fn on_update_outer_offset(value: f32) -> AppMessage { - AppMessage::Outline(OutlineMessage::OuterOffsetValueUpdated(value)) -} - -fn on_update_inner_offset(value: f32) -> AppMessage { - AppMessage::Outline(OutlineMessage::InnerOffsetValueUpdated(value)) -} - -fn on_select_join(option: JoinOption) -> AppMessage { - AppMessage::Outline(OutlineMessage::JoinSelected(option)) -} - -fn on_update_join_value(value: u8) -> AppMessage { - AppMessage::Outline(OutlineMessage::JoinValueUpdated(value)) -} diff --git a/examples/overlay_editor/src/app/outline/workspace.rs b/examples/overlay_editor/src/app/outline/workspace.rs index 7bac2501..f073b595 100644 --- a/examples/overlay_editor/src/app/outline/workspace.rs +++ b/examples/overlay_editor/src/app/outline/workspace.rs @@ -1,21 +1,22 @@ -use crate::app::design::{style_sheet_background, Design}; +use crate::app::design::Design; use crate::app::main::{AppMessage, EditorApp}; use crate::app::outline::content::OutlineMessage; 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::point_editor::{state::PointsEditorState, widget::PointsEditorWidget}; +use crate::sheet::state::SheetState; use crate::sheet::widget::SheetWidget; +use eframe::egui; use i_triangle::i_overlay::core::fill_rule::FillRule; use i_triangle::i_overlay::i_shape::int::path::IntPaths as RawIntPaths; -use iced::widget::Container; -use iced::widget::Stack; -use iced::{Length, Padding, Size, Vector}; type IntPaths = RawIntPaths; pub(crate) struct WorkspaceState { pub(crate) camera: Camera, + pub(crate) sheet_state: SheetState, + pub(crate) point_state: PointsEditorState, pub(crate) scale: f32, pub(crate) outline_input: IntPaths, pub(crate) outline_output: IntPaths, @@ -23,105 +24,64 @@ pub(crate) struct WorkspaceState { } impl EditorApp { - pub(crate) fn outline_workspace(&self) -> Container<'_, AppMessage> { - Container::new({ - let mut stack = Stack::new(); - stack = stack.push( - Container::new(SheetWidget::new( - self.state.outline.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.outline.workspace.camera.is_not_empty() { - stack = stack.push( - Container::new(ShapeWidget::with_paths( - &self.state.outline.workspace.outline_output, - self.state.outline.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(ShapeWidget::with_paths( - &self.state.outline.workspace.outline_input, - self.state.outline.workspace.camera, - Some(FillRule::NonZero), - Some(Design::subject_color().scale_alpha(0.1)), - Some(Design::subject_color()), - 1.0, - )) - .width(Length::Fill) - .height(Length::Fill), - ); - stack = stack.push( - Container::new( - PointsEditorWidget::new( - &self.state.outline.workspace.points, - self.state.outline.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.outline_control()) - .width(Length::Shrink) - .height(Length::Shrink) - .padding(Padding::new(8.0)), + pub(crate) fn outline_workspace(&mut self, ui: &mut egui::Ui) { + self.update(AppMessage::Outline(OutlineMessage::WorkspaceSized( + ui.available_size(), + ))); + let workspace = &mut self.state.outline.workspace; + let (painter, update) = SheetWidget::show( + ui, + &mut workspace.camera, + &workspace.points, + &mut workspace.sheet_state, + &mut workspace.point_state, + ); + if let Some(update) = update { + self.update(AppMessage::Outline(OutlineMessage::PointEdited(update))); + ui.ctx().request_repaint(); + } + let workspace = &self.state.outline.workspace; + if workspace.camera.is_not_empty() { + ShapeWidget::with_paths( + &workspace.outline_output, + workspace.camera, + Some(FillRule::NonZero), + Some(Design::solution_color().gamma_multiply(0.1)), + Some(Design::solution_color()), + 2.0, ) - }) - .style(style_sheet_background) + .paint(&painter); + ShapeWidget::with_paths( + &workspace.outline_input, + workspace.camera, + Some(FillRule::NonZero), + Some(Design::subject_color().gamma_multiply(0.1)), + Some(Design::subject_color()), + 1.0, + ) + .paint(&painter); + } + PointsEditorWidget::paint( + &painter, + workspace.camera, + &workspace.points, + &workspace.point_state, + ); } - - pub(super) fn outline_update_point(&mut self, update: PointEditUpdate) { + pub(super) fn outline_update_point( + &mut self, + update: crate::point_editor::widget::PointEditUpdate, + ) { self.state.outline.outline_update_point(update); } - - pub(super) fn outline_update_zoom(&mut self, camera: Camera) { - self.state.outline.workspace.camera = camera; - } - - pub(super) fn outline_update_drag(&mut self, new_pos: Vector) { - self.state.outline.workspace.camera.pos = new_pos; - } } - -fn on_update_point(event: PointEditUpdate) -> AppMessage { - AppMessage::Outline(OutlineMessage::PointEdited(event)) -} - -fn on_update_size(size: Size) -> AppMessage { - AppMessage::Outline(OutlineMessage::WorkspaceSized(size)) -} - -fn on_update_zoom(zoom: Camera) -> AppMessage { - AppMessage::Outline(OutlineMessage::WorkspaceZoomed(zoom)) -} - -fn on_update_drag(drag: Vector) -> AppMessage { - AppMessage::Outline(OutlineMessage::WorkspaceDragged(drag)) -} - impl Default for WorkspaceState { fn default() -> Self { WorkspaceState { scale: 1.0, camera: Camera::empty(), + sheet_state: Default::default(), + point_state: Default::default(), outline_input: vec![], outline_output: vec![], points: vec![], diff --git a/examples/overlay_editor/src/app/string/content.rs b/examples/overlay_editor/src/app/string/content.rs index 1d475d2c..596d4a6a 100644 --- a/examples/overlay_editor/src/app/string/content.rs +++ b/examples/overlay_editor/src/app/string/content.rs @@ -1,4 +1,3 @@ -use crate::app::design; use crate::app::fill_option::FillOption; use crate::app::main::{AppMessage, EditorApp}; use crate::app::solver_option::SolverOption; @@ -8,13 +7,11 @@ use crate::data::string::StringResource; use crate::geom::camera::Camera; use crate::point_editor::point::PathsToEditorPoints; use crate::point_editor::widget::PointEditUpdate; +use eframe::egui::{self, Vec2}; use i_triangle::i_overlay::i_float::int::rect::IntRect; use i_triangle::i_overlay::i_shape::int::count::PointsCount; use i_triangle::i_overlay::string::clip::{ClipRule, IntClip}; use i_triangle::i_overlay::string::slice::IntSlice; -use iced::widget::scrollable; -use iced::widget::{Button, Column, Container, Row, Space, Text}; -use iced::{Alignment, Length, Padding, Size, Vector}; use std::collections::HashMap; pub(crate) struct StringState { @@ -23,7 +20,7 @@ pub(crate) struct StringState { pub(crate) mode: ModeOption, pub(crate) solver: SolverOption, pub(crate) workspace: WorkspaceState, - pub(crate) size: Size, + pub(crate) size: Vec2, pub(crate) cameras: HashMap, } @@ -34,64 +31,33 @@ pub(crate) enum StringMessage { ModeSelected(ModeOption), SolverSelected(SolverOption), PointEdited(PointEditUpdate), - WorkspaceSized(Size), - WorkspaceZoomed(Camera), - WorkspaceDragged(Vector), + WorkspaceSized(Vec2), } impl EditorApp { - fn string_sidebar(&self) -> Column<'_, AppMessage> { - let count = self.app_resource.string.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.string.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::String(StringMessage::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 string_content(&self) -> Row<'_, AppMessage> { - Row::new() - .push( - scrollable( - Container::new(self.string_sidebar()) - .width(Length::Fixed(160.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.string_workspace()) + pub(crate) fn string_content(&mut self, ui: &mut egui::Ui) { + egui::Panel::left("string_tests") + .exact_size(150.0) + .resizable(false) + .show_inside(ui, |ui| { + egui::ScrollArea::vertical().show(ui, |ui| { + for index in 0..self.app_resource.string.count { + let response = ui.selectable_label( + self.state.string.test == index, + format!("test_{index}"), + ); + if response.clicked() { + response.surrender_focus(); + self.update(AppMessage::String(StringMessage::TestSelected(index))); + } + } + }); + }); + egui::CentralPanel::default().show_inside(ui, |ui| { + self.string_control(ui); + ui.separator(); + self.string_workspace(ui); + }); } pub(crate) fn string_update(&mut self, message: StringMessage) { @@ -102,8 +68,6 @@ impl EditorApp { StringMessage::ModeSelected(mode) => self.string_update_mode(mode), StringMessage::PointEdited(update) => self.string_update_point(update), StringMessage::WorkspaceSized(size) => self.string_update_size(size), - StringMessage::WorkspaceZoomed(zoom) => self.string_update_zoom(zoom), - StringMessage::WorkspaceDragged(drag) => self.string_update_drag(drag), } } @@ -119,7 +83,7 @@ impl EditorApp { } pub(crate) fn string_next_test(&mut self) { - let next_test = self.state.string.test + 1; + let next_test = self.state.string.test.saturating_add(1); if next_test < self.app_resource.string.count { self.string_set_test(next_test); } @@ -132,7 +96,7 @@ impl EditorApp { } } - fn string_update_size(&mut self, size: Size) { + fn string_update_size(&mut self, size: Vec2) { self.state.string.size = size; let points = &self.state.string.workspace.points; if self.state.string.workspace.camera.is_empty() && !points.is_empty() { @@ -170,7 +134,7 @@ impl StringState { solver: SolverOption::Auto, workspace: Default::default(), cameras: HashMap::with_capacity(resource.count), - size: Size::ZERO, + size: Vec2::ZERO, }; state.set_test(0, resource); @@ -196,13 +160,15 @@ impl StringState { self.cameras.insert(self.test, self.workspace.camera); let mut camera = *self.cameras.get(&index).unwrap_or(&Camera::empty()); - if camera.is_empty() && self.size.width > 0.001 { + if camera.is_empty() && self.size.x > 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.workspace.sheet_state = Default::default(); + self.workspace.point_state = Default::default(); self.test = index; } diff --git a/examples/overlay_editor/src/app/string/control.rs b/examples/overlay_editor/src/app/string/control.rs index 35026592..2744940c 100644 --- a/examples/overlay_editor/src/app/string/control.rs +++ b/examples/overlay_editor/src/app/string/control.rs @@ -1,103 +1,8 @@ -use crate::app::fill_option::FillOption; +use crate::app::design::{controls, select}; use crate::app::main::{AppMessage, EditorApp}; -use crate::app::solver_option::SolverOption; use crate::app::string::content::StringMessage; -use iced::widget::{pick_list, Column, Container, Row, Space, Text}; -use iced::{Alignment, Length}; - -impl EditorApp { - pub(crate) fn string_control(&self) -> Column<'_, AppMessage> { - let solver_pick_list = Row::new() - .push( - Text::new("Solver:") - .width(Length::Fixed(90.0)) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .push( - Container::new( - pick_list( - &SolverOption::ALL[..], - Some(self.state.string.solver), - on_select_solver, - ) - .width(Length::Fixed(160.0)), - ) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .height(Length::Fixed(40.0)); - - let fill_pick_list = Row::new() - .push( - Text::new("Fill Rule:") - .width(Length::Fixed(90.0)) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .push( - Container::new( - pick_list( - &FillOption::ALL[..], - Some(self.state.string.fill), - on_select_fill, - ) - .width(Length::Fixed(160.0)), - ) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .height(Length::Fixed(40.0)); - - let mode_pick_list = Row::new() - .push( - Text::new("Mode:") - .width(Length::Fixed(90.0)) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .push( - Container::new( - pick_list( - &ModeOption::ALL[..], - Some(self.state.string.mode), - on_select_mode, - ) - .width(Length::Fixed(160.0)), - ) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .height(Length::Fixed(40.0)); - - Column::new() - .push(solver_pick_list) - .push( - Space::new() - .width(Length::Shrink) - .height(Length::Fixed(4.0)), - ) - .push(fill_pick_list) - .push( - Space::new() - .width(Length::Shrink) - .height(Length::Fixed(4.0)), - ) - .push(mode_pick_list) - } -} - -fn on_select_fill(option: FillOption) -> AppMessage { - AppMessage::String(StringMessage::FillSelected(option)) -} - -fn on_select_mode(option: ModeOption) -> AppMessage { - AppMessage::String(StringMessage::ModeSelected(option)) -} - -fn on_select_solver(option: SolverOption) -> AppMessage { - AppMessage::String(StringMessage::SolverSelected(option)) -} +use crate::app::{fill_option::FillOption, solver_option::SolverOption}; +use eframe::egui; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub(crate) enum ModeOption { @@ -134,3 +39,22 @@ impl std::fmt::Display for ModeOption { ) } } + +impl EditorApp { + pub(crate) fn string_control(&mut self, ui: &mut egui::Ui) { + controls(ui, "string_controls", |ui| { + let mut solver = self.state.string.solver; + if select(ui, "Solver", &mut solver, &SolverOption::ALL) { + self.update(AppMessage::String(StringMessage::SolverSelected(solver))); + } + let mut fill = self.state.string.fill; + if select(ui, "Fill Rule", &mut fill, &FillOption::ALL) { + self.update(AppMessage::String(StringMessage::FillSelected(fill))); + } + let mut mode = self.state.string.mode; + if select(ui, "Mode", &mut mode, &ModeOption::ALL) { + self.update(AppMessage::String(StringMessage::ModeSelected(mode))); + } + }); + } +} diff --git a/examples/overlay_editor/src/app/string/mod.rs b/examples/overlay_editor/src/app/string/mod.rs index 2ac0d54b..7c23db47 100644 --- a/examples/overlay_editor/src/app/string/mod.rs +++ b/examples/overlay_editor/src/app/string/mod.rs @@ -1,3 +1,3 @@ pub(crate) mod content; -mod control; +pub(super) mod control; mod workspace; diff --git a/examples/overlay_editor/src/app/string/workspace.rs b/examples/overlay_editor/src/app/string/workspace.rs index 977db253..e33e309b 100644 --- a/examples/overlay_editor/src/app/string/workspace.rs +++ b/examples/overlay_editor/src/app/string/workspace.rs @@ -1,4 +1,4 @@ -use crate::app::design::{style_sheet_background, Design}; +use crate::app::design::Design; use crate::app::main::{AppMessage, EditorApp}; use crate::app::string::content::StringMessage; use crate::app::string::control::ModeOption; @@ -7,13 +7,12 @@ use crate::draw::shape::ShapeWidget; use crate::draw::varicolored::VaricoloredWidget; use crate::geom::camera::Camera; use crate::point_editor::point::EditorPoint; -use crate::point_editor::widget::{PointEditUpdate, PointsEditorWidget}; +use crate::point_editor::{state::PointsEditorState, widget::PointsEditorWidget}; +use crate::sheet::state::SheetState; use crate::sheet::widget::SheetWidget; +use eframe::egui; use i_triangle::i_overlay::i_shape::int::count::IntShapes as RawIntShapes; use i_triangle::i_overlay::i_shape::int::path::IntPaths as RawIntPaths; -use iced::widget::Container; -use iced::widget::Stack; -use iced::{Length, Padding, Size, Vector}; type IntPaths = RawIntPaths; type IntShapes = RawIntShapes; @@ -26,6 +25,8 @@ pub(crate) enum Solution { pub(crate) struct WorkspaceState { pub(crate) camera: Camera, + pub(crate) sheet_state: SheetState, + pub(crate) point_state: PointsEditorState, pub(crate) body: IntPaths, pub(crate) string: IntPaths, pub(crate) solution: Solution, @@ -33,171 +34,109 @@ pub(crate) struct WorkspaceState { } impl EditorApp { - pub(crate) fn string_workspace(&self) -> Container<'_, AppMessage> { - Container::new({ - let mut stack = Stack::new(); - stack = stack.push( - Container::new(SheetWidget::new( - self.state.string.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.string.workspace.camera.is_not_empty() { - match self.state.string.mode { - ModeOption::Slice => { - if let Solution::Shapes(shapes) = &self.state.string.workspace.solution { - stack = stack.push( - Container::new(VaricoloredWidget::with_shapes( - shapes, - self.state.string.workspace.camera, - 2.0, - )) - .width(Length::Fill) - .height(Length::Fill), - ); - } - stack = stack.push( - Container::new(PathWidget::with_paths( - &self.state.string.workspace.string, - self.state.string.workspace.camera, - Design::negative_color(), - 2.0, - true, - )) - .width(Length::Fill) - .height(Length::Fill), - ) - } - ModeOption::ClipDirect | ModeOption::ClipInvert => { - stack = stack - .push( - Container::new(ShapeWidget::with_paths( - &self.state.string.workspace.body, - self.state.string.workspace.camera, - Some(self.state.string.fill.fill_rule()), - Some(Design::clip_color().scale_alpha(0.3)), - Some(Design::clip_color()), - 2.0, - )) - .width(Length::Fill) - .height(Length::Fill), - ) - .push( - Container::new(PathWidget::with_paths( - &self.state.string.workspace.string, - self.state.string.workspace.camera, - Design::negative_color(), - 2.0, - true, - )) - .width(Length::Fill) - .height(Length::Fill), - ); - if let Solution::Paths(paths) = &self.state.string.workspace.solution { - stack = stack.push( - Container::new(PathWidget::with_paths( - paths, - self.state.string.workspace.camera, - Design::subject_color(), - 2.0, - true, - )) - .width(Length::Fill) - .height(Length::Fill), - ); - } - } - _ => { - stack = stack - .push( - Container::new(ShapeWidget::with_paths( - &self.state.string.workspace.body, - self.state.string.workspace.camera, - Some(self.state.string.fill.fill_rule()), - Some(Design::subject_color().scale_alpha(0.2)), - Some(Design::subject_color()), - 2.0, - )) - .width(Length::Fill) - .height(Length::Fill), - ) - .push( - Container::new(PathWidget::with_paths( - &self.state.string.workspace.string, - self.state.string.workspace.camera, - Design::negative_color(), - 2.0, - true, - )) - .width(Length::Fill) - .height(Length::Fill), - ) + pub(crate) fn string_workspace(&mut self, ui: &mut egui::Ui) { + self.update(AppMessage::String(StringMessage::WorkspaceSized( + ui.available_size(), + ))); + let workspace = &mut self.state.string.workspace; + let (painter, update) = SheetWidget::show( + ui, + &mut workspace.camera, + &workspace.points, + &mut workspace.sheet_state, + &mut workspace.point_state, + ); + if let Some(update) = update { + self.update(AppMessage::String(StringMessage::PointEdited(update))); + ui.ctx().request_repaint(); + } + let workspace = &self.state.string.workspace; + if workspace.camera.is_not_empty() { + match self.state.string.mode { + ModeOption::Slice => { + if let Solution::Shapes(shapes) = &workspace.solution { + VaricoloredWidget::with_shapes(shapes, workspace.camera, 2.0) + .paint(&painter); } + PathWidget::with_paths( + &workspace.string, + workspace.camera, + Design::negative_color(), + 2.0, + true, + ) + .paint(&painter); } - stack = stack.push( - Container::new( - PointsEditorWidget::new( - &self.state.string.workspace.points, - self.state.string.workspace.camera, - on_update_point, + ModeOption::ClipDirect | ModeOption::ClipInvert => { + ShapeWidget::with_paths( + &workspace.body, + workspace.camera, + Some(self.state.string.fill.fill_rule()), + Some(Design::clip_color().gamma_multiply(0.3)), + Some(Design::clip_color()), + 2.0, + ) + .paint(&painter); + PathWidget::with_paths( + &workspace.string, + workspace.camera, + Design::negative_color(), + 2.0, + true, + ) + .paint(&painter); + if let Solution::Paths(paths) = &workspace.solution { + PathWidget::with_paths( + paths, + workspace.camera, + Design::subject_color(), + 2.0, + true, ) - .set_drag_color(Design::accent_color()) - .set_hover_color(Design::negative_color()), + .paint(&painter); + } + } + _ => { + ShapeWidget::with_paths( + &workspace.body, + workspace.camera, + Some(self.state.string.fill.fill_rule()), + Some(Design::subject_color().gamma_multiply(0.2)), + Some(Design::subject_color()), + 2.0, + ) + .paint(&painter); + PathWidget::with_paths( + &workspace.string, + workspace.camera, + Design::negative_color(), + 2.0, + true, ) - .width(Length::Fill) - .height(Length::Fill), - ); + .paint(&painter); + } } - - stack.push( - Container::new(self.string_control()) - .width(Length::Shrink) - .height(Length::Shrink) - .padding(Padding::new(8.0)), - ) - }) - .style(style_sheet_background) + } + PointsEditorWidget::paint( + &painter, + workspace.camera, + &workspace.points, + &workspace.point_state, + ); } - - pub(super) fn string_update_point(&mut self, update: PointEditUpdate) { + pub(super) fn string_update_point( + &mut self, + update: crate::point_editor::widget::PointEditUpdate, + ) { self.state.string.string_update_point(update); } - - pub(super) fn string_update_zoom(&mut self, camera: Camera) { - self.state.string.workspace.camera = camera; - } - - pub(super) fn string_update_drag(&mut self, new_pos: Vector) { - self.state.string.workspace.camera.pos = new_pos; - } -} - -fn on_update_point(event: PointEditUpdate) -> AppMessage { - AppMessage::String(StringMessage::PointEdited(event)) } - -fn on_update_size(size: Size) -> AppMessage { - AppMessage::String(StringMessage::WorkspaceSized(size)) -} - -fn on_update_zoom(zoom: Camera) -> AppMessage { - AppMessage::String(StringMessage::WorkspaceZoomed(zoom)) -} - -fn on_update_drag(drag: Vector) -> AppMessage { - AppMessage::String(StringMessage::WorkspaceDragged(drag)) -} - impl Default for WorkspaceState { fn default() -> Self { WorkspaceState { camera: Camera::empty(), + sheet_state: Default::default(), + point_state: Default::default(), body: vec![], string: vec![], solution: Solution::None, diff --git a/examples/overlay_editor/src/app/stroke/content.rs b/examples/overlay_editor/src/app/stroke/content.rs index 0d1fd498..86afe93f 100644 --- a/examples/overlay_editor/src/app/stroke/content.rs +++ b/examples/overlay_editor/src/app/stroke/content.rs @@ -1,4 +1,3 @@ -use crate::app::design; use crate::app::main::{AppMessage, EditorApp}; use crate::app::stroke::control::{CapOption, JoinOption}; use crate::app::stroke::workspace::WorkspaceState; @@ -6,12 +5,11 @@ use crate::data::stroke::StrokeResource; use crate::geom::camera::Camera; use crate::point_editor::point::PathsToEditorPoints; use crate::point_editor::widget::PointEditUpdate; +use eframe::egui::{self, Vec2}; 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::stroke::offset::StrokeOffset; -use i_triangle::i_overlay::mesh::style::{LineCap, LineJoin, StrokeStyle}; -use iced::widget::{scrollable, Button, Column, Container, Row, Space, Text}; -use iced::{Alignment, Length, Padding, Size, Vector}; +use i_triangle::i_overlay::mesh::float::stroke::offset::StrokeOffset; +use i_triangle::i_overlay::mesh::float::style::{LineCap, LineJoin, StrokeStyle}; use std::collections::HashMap; use std::rc::Rc; @@ -26,7 +24,7 @@ pub(crate) struct StrokeState { pub(crate) join: JoinOption, pub(crate) join_value: u8, pub(crate) workspace: WorkspaceState, - pub(crate) size: Size, + pub(crate) size: Vec2, pub(crate) cameras: HashMap, } @@ -42,64 +40,33 @@ pub(crate) enum StrokeMessage { JoinSelected(JoinOption), JoinValueUpdated(u8), PointEdited(PointEditUpdate), - WorkspaceSized(Size), - WorkspaceZoomed(Camera), - WorkspaceDragged(Vector), + WorkspaceSized(Vec2), } impl EditorApp { - fn stroke_sidebar(&self) -> Column<'_, AppMessage> { - let count = self.app_resource.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.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::Stroke(StrokeMessage::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 stroke_content(&self) -> Row<'_, AppMessage> { - Row::new() - .push( - scrollable( - Container::new(self.stroke_sidebar()) - .width(Length::Fixed(160.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.stroke_workspace()) + pub(crate) fn stroke_content(&mut self, ui: &mut egui::Ui) { + egui::Panel::left("stroke_tests") + .exact_size(150.0) + .resizable(false) + .show_inside(ui, |ui| { + egui::ScrollArea::vertical().show(ui, |ui| { + for index in 0..self.app_resource.stroke.count { + let response = ui.selectable_label( + self.state.stroke.test == index, + format!("test_{index}"), + ); + if response.clicked() { + response.surrender_focus(); + self.update(AppMessage::Stroke(StrokeMessage::TestSelected(index))); + } + } + }); + }); + egui::CentralPanel::default().show_inside(ui, |ui| { + self.stroke_control(ui); + ui.separator(); + self.stroke_workspace(ui); + }); } pub(crate) fn stroke_update(&mut self, message: StrokeMessage) { @@ -115,8 +82,6 @@ impl EditorApp { StrokeMessage::JoinValueUpdated(value) => self.stroke_update_join_value(value), StrokeMessage::PointEdited(update) => self.stroke_update_point(update), StrokeMessage::WorkspaceSized(size) => self.stroke_update_size(size), - StrokeMessage::WorkspaceZoomed(zoom) => self.stroke_update_zoom(zoom), - StrokeMessage::WorkspaceDragged(drag) => self.stroke_update_drag(drag), } } @@ -132,7 +97,7 @@ impl EditorApp { } pub(crate) fn stroke_next_test(&mut self) { - let next_test = self.state.stroke.test + 1; + let next_test = self.state.stroke.test.saturating_add(1); if next_test < self.app_resource.stroke.count { self.stroke_set_test(next_test); } @@ -145,7 +110,7 @@ impl EditorApp { } } - fn stroke_update_size(&mut self, size: Size) { + fn stroke_update_size(&mut self, size: Vec2) { self.state.stroke.size = size; let points = &self.state.stroke.workspace.points; if self.state.stroke.workspace.camera.is_empty() && !points.is_empty() { @@ -213,7 +178,7 @@ impl StrokeState { join_value: 50, workspace: Default::default(), cameras: HashMap::with_capacity(resource.count), - size: Size::ZERO, + size: Vec2::ZERO, }; state.set_test(0, resource); @@ -250,13 +215,15 @@ impl StrokeState { self.cameras.insert(self.test, self.workspace.camera); let mut camera = *self.cameras.get(&index).unwrap_or(&Camera::empty()); - if camera.is_empty() && self.size.width > 0.001 { + if camera.is_empty() && self.size.x > 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.workspace.sheet_state = Default::default(); + self.workspace.point_state = Default::default(); self.test = index; } diff --git a/examples/overlay_editor/src/app/stroke/control.rs b/examples/overlay_editor/src/app/stroke/control.rs index db5b86b3..c27d70b3 100644 --- a/examples/overlay_editor/src/app/stroke/control.rs +++ b/examples/overlay_editor/src/app/stroke/control.rs @@ -1,7 +1,7 @@ +use crate::app::design::{checkbox, controls, select, slider}; use crate::app::main::{AppMessage, EditorApp}; use crate::app::stroke::content::StrokeMessage; -use iced::widget::{checkbox, pick_list, slider, Column, Container, Row, Space, Text}; -use iced::{Alignment, Length, Padding}; +use eframe::egui; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub(crate) enum CapOption { @@ -63,189 +63,72 @@ impl std::fmt::Display for JoinOption { } impl EditorApp { - pub(crate) fn stroke_control(&self) -> Column<'_, AppMessage> { - let width_list = Row::new() - .push( - Text::new("Stroke Width:") - .width(Length::Fixed(120.0)) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .push( - Container::new( - slider(0.1f32..=10.0f32, self.state.stroke.width, on_update_width) - .step(0.01f32), - ) - .width(160) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .height(Length::Fixed(40.0)); - - let mut start_cap_pick_list = Row::new() - .push( - Text::new("Start Cap:") - .width(Length::Fixed(120.0)) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .push( - Container::new( - pick_list( - &CapOption::ALL[..], - Some(self.state.stroke.start_cap), - on_select_start_cap, - ) - .width(Length::Fixed(160.0)), - ) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .height(Length::Fixed(40.0)); - - if self.state.stroke.start_cap == CapOption::Round { - let slider = slider( - 1..=100, - self.state.stroke.start_cap_value, - on_update_start_cap_value, - ) - .default(50) - .shift_step(5); - - start_cap_pick_list = start_cap_pick_list.push( - Container::new(slider) - .padding(Padding::new(0.0).left(20.0)) - .width(250) - .height(Length::Fill) - .align_y(Alignment::Center), - ); - } - - let mut end_cap_pick_list = Row::new() - .push( - Text::new("End Cap:") - .width(Length::Fixed(120.0)) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .push( - Container::new( - pick_list( - &CapOption::ALL[..], - Some(self.state.stroke.end_cap), - on_select_end_cap, - ) - .width(Length::Fixed(160.0)), - ) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .height(Length::Fixed(40.0)); - - if self.state.stroke.end_cap == CapOption::Round { - let slider = slider( - 1..=100, - self.state.stroke.end_cap_value, - on_update_end_cap_value, - ) - .default(50) - .shift_step(5); - - end_cap_pick_list = end_cap_pick_list.push( - Container::new(slider) - .padding(Padding::new(0.0).left(20.0)) - .width(250) - .height(Length::Fill) - .align_y(Alignment::Center), - ); - } - - let mut join_pick_list = Row::new() - .push( - Text::new("Line Join:") - .width(Length::Fixed(120.0)) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .push( - Container::new( - pick_list( - &JoinOption::ALL[..], - Some(self.state.stroke.join), - on_select_join, - ) - .width(Length::Fixed(160.0)), - ) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .height(Length::Fixed(40.0)); - - if self.state.stroke.join != JoinOption::Bevel { - let slider = slider(1..=100, self.state.stroke.join_value, on_update_join_value) - .default(50) - .shift_step(5); - - join_pick_list = join_pick_list.push( - Container::new(slider) - .padding(Padding::new(0.0).left(20.0)) - .width(250) - .height(Length::Fill) - .align_y(Alignment::Center), - ); - } - - Column::new() - .push(width_list) - .push(start_cap_pick_list) - .push( - Space::new() - .width(Length::Shrink) - .height(Length::Fixed(4.0)), - ) - .push(end_cap_pick_list) - .push( - Space::new() - .width(Length::Shrink) - .height(Length::Fixed(4.0)), - ) - .push(join_pick_list) - .push( - checkbox(self.state.stroke.is_closed) - .label("Is Closed") - .on_toggle(on_set_is_closed), - ) + pub(crate) fn stroke_control(&mut self, ui: &mut egui::Ui) { + controls(ui, "stroke_controls", |ui| { + let mut width = self.state.stroke.width; + if slider( + ui, + "Stroke Width", + egui::Slider::new(&mut width, 0.1..=10.0).step_by(0.01), + ) { + self.update(AppMessage::Stroke(StrokeMessage::WidthValueUpdated(width))); + } + let mut start_cap = self.state.stroke.start_cap; + if select(ui, "Start Cap", &mut start_cap, &CapOption::ALL) { + self.update(AppMessage::Stroke(StrokeMessage::StartCapSelected( + start_cap, + ))); + } + if self.state.stroke.start_cap == CapOption::Round { + let mut start_cap_value = self.state.stroke.start_cap_value; + if slider( + ui, + "Start Cap Detail", + egui::Slider::new(&mut start_cap_value, 1..=100), + ) { + self.update(AppMessage::Stroke(StrokeMessage::StartCapValueUpdated( + start_cap_value, + ))); + } + } + let mut end_cap = self.state.stroke.end_cap; + if select(ui, "End Cap", &mut end_cap, &CapOption::ALL) { + self.update(AppMessage::Stroke(StrokeMessage::EndCapSelected(end_cap))); + } + if self.state.stroke.end_cap == CapOption::Round { + let mut end_cap_value = self.state.stroke.end_cap_value; + if slider( + ui, + "End Cap Detail", + egui::Slider::new(&mut end_cap_value, 1..=100), + ) { + self.update(AppMessage::Stroke(StrokeMessage::EndCapValueUpdated( + end_cap_value, + ))); + } + } + let mut join = self.state.stroke.join; + if select(ui, "Line Join", &mut join, &JoinOption::ALL) { + self.update(AppMessage::Stroke(StrokeMessage::JoinSelected(join))); + } + if self.state.stroke.join != JoinOption::Bevel { + let mut join_value = self.state.stroke.join_value; + if slider( + ui, + "Join Detail", + egui::Slider::new(&mut join_value, 1..=100), + ) { + self.update(AppMessage::Stroke(StrokeMessage::JoinValueUpdated( + join_value, + ))); + } + } + let mut is_closed = self.state.stroke.is_closed; + if checkbox(ui, "Is Closed", &mut is_closed) { + self.update(AppMessage::Stroke(StrokeMessage::IsClosedUpdated( + is_closed, + ))); + } + }); } } - -fn on_update_width(value: f32) -> AppMessage { - AppMessage::Stroke(StrokeMessage::WidthValueUpdated(value)) -} - -fn on_set_is_closed(value: bool) -> AppMessage { - AppMessage::Stroke(StrokeMessage::IsClosedUpdated(value)) -} - -fn on_select_start_cap(option: CapOption) -> AppMessage { - AppMessage::Stroke(StrokeMessage::StartCapSelected(option)) -} - -fn on_update_start_cap_value(value: u8) -> AppMessage { - AppMessage::Stroke(StrokeMessage::StartCapValueUpdated(value)) -} - -fn on_select_end_cap(option: CapOption) -> AppMessage { - AppMessage::Stroke(StrokeMessage::EndCapSelected(option)) -} - -fn on_update_end_cap_value(value: u8) -> AppMessage { - AppMessage::Stroke(StrokeMessage::EndCapValueUpdated(value)) -} - -fn on_select_join(option: JoinOption) -> AppMessage { - AppMessage::Stroke(StrokeMessage::JoinSelected(option)) -} - -fn on_update_join_value(value: u8) -> AppMessage { - AppMessage::Stroke(StrokeMessage::JoinValueUpdated(value)) -} diff --git a/examples/overlay_editor/src/app/stroke/workspace.rs b/examples/overlay_editor/src/app/stroke/workspace.rs index 2abedd8b..8a7e962f 100644 --- a/examples/overlay_editor/src/app/stroke/workspace.rs +++ b/examples/overlay_editor/src/app/stroke/workspace.rs @@ -1,22 +1,23 @@ -use crate::app::design::{style_sheet_background, Design}; +use crate::app::design::Design; use crate::app::main::{AppMessage, EditorApp}; use crate::app::stroke::content::StrokeMessage; 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::point_editor::{state::PointsEditorState, widget::PointsEditorWidget}; +use crate::sheet::state::SheetState; use crate::sheet::widget::SheetWidget; +use eframe::egui; use i_triangle::i_overlay::core::fill_rule::FillRule; use i_triangle::i_overlay::i_shape::int::path::IntPaths as RawIntPaths; -use iced::widget::Container; -use iced::widget::Stack; -use iced::{Length, Padding, Size, Vector}; type IntPaths = RawIntPaths; pub(crate) struct WorkspaceState { pub(crate) camera: Camera, + pub(crate) sheet_state: SheetState, + pub(crate) point_state: PointsEditorState, pub(crate) scale: f32, pub(crate) stroke_input: IntPaths, pub(crate) stroke_output: IntPaths, @@ -24,107 +25,66 @@ pub(crate) struct WorkspaceState { } impl EditorApp { - pub(crate) fn stroke_workspace(&self) -> Container<'_, AppMessage> { - Container::new({ - let mut stack = Stack::new(); - stack = stack.push( - Container::new(SheetWidget::new( - self.state.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.stroke.workspace.camera.is_not_empty() { - let shapes = &self.state.stroke.workspace.stroke_output; - if !shapes.is_empty() { - stack = stack.push( - Container::new(ShapeWidget::with_paths( - &self.state.stroke.workspace.stroke_output, - self.state.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.stroke.workspace.stroke_input, - self.state.stroke.workspace.camera, - Design::subject_color(), - 1.0, - false, - )) - .width(Length::Fill) - .height(Length::Fill), - ); - stack = stack.push( - Container::new( - PointsEditorWidget::new( - &self.state.stroke.workspace.points, - self.state.stroke.workspace.camera, - on_update_point, - ) - .set_drag_color(Design::accent_color()) - .set_hover_color(Design::negative_color()), - ) - .width(Length::Fill) - .height(Length::Fill), - ); + pub(crate) fn stroke_workspace(&mut self, ui: &mut egui::Ui) { + self.update(AppMessage::Stroke(StrokeMessage::WorkspaceSized( + ui.available_size(), + ))); + let workspace = &mut self.state.stroke.workspace; + let (painter, update) = SheetWidget::show( + ui, + &mut workspace.camera, + &workspace.points, + &mut workspace.sheet_state, + &mut workspace.point_state, + ); + if let Some(update) = update { + self.update(AppMessage::Stroke(StrokeMessage::PointEdited(update))); + ui.ctx().request_repaint(); + } + let workspace = &self.state.stroke.workspace; + if workspace.camera.is_not_empty() { + let shapes = &workspace.stroke_output; + if !shapes.is_empty() { + ShapeWidget::with_paths( + &workspace.stroke_output, + workspace.camera, + Some(FillRule::NonZero), + Some(Design::solution_color().gamma_multiply(0.1)), + Some(Design::solution_color()), + 2.0, + ) + .paint(&painter); } - - stack.push( - Container::new(self.stroke_control()) - .width(Length::Shrink) - .height(Length::Shrink) - .padding(Padding::new(8.0)), + PathWidget::with_paths( + &workspace.stroke_input, + workspace.camera, + Design::subject_color(), + 1.0, + false, ) - }) - .style(style_sheet_background) + .paint(&painter); + } + PointsEditorWidget::paint( + &painter, + workspace.camera, + &workspace.points, + &workspace.point_state, + ); } - - pub(super) fn stroke_update_point(&mut self, update: PointEditUpdate) { + pub(super) fn stroke_update_point( + &mut self, + update: crate::point_editor::widget::PointEditUpdate, + ) { self.state.stroke.stroke_update_point(update); } - - pub(super) fn stroke_update_zoom(&mut self, camera: Camera) { - self.state.stroke.workspace.camera = camera; - } - - pub(super) fn stroke_update_drag(&mut self, new_pos: Vector) { - self.state.stroke.workspace.camera.pos = new_pos; - } } - -fn on_update_point(event: PointEditUpdate) -> AppMessage { - AppMessage::Stroke(StrokeMessage::PointEdited(event)) -} - -fn on_update_size(size: Size) -> AppMessage { - AppMessage::Stroke(StrokeMessage::WorkspaceSized(size)) -} - -fn on_update_zoom(zoom: Camera) -> AppMessage { - AppMessage::Stroke(StrokeMessage::WorkspaceZoomed(zoom)) -} - -fn on_update_drag(drag: Vector) -> AppMessage { - AppMessage::Stroke(StrokeMessage::WorkspaceDragged(drag)) -} - impl Default for WorkspaceState { fn default() -> Self { WorkspaceState { scale: 1.0, camera: Camera::empty(), + sheet_state: Default::default(), + point_state: Default::default(), stroke_input: vec![], stroke_output: vec![], points: vec![], diff --git a/examples/overlay_editor/src/app/variable_stroke/content.rs b/examples/overlay_editor/src/app/variable_stroke/content.rs index aead8718..0b138f9e 100644 --- a/examples/overlay_editor/src/app/variable_stroke/content.rs +++ b/examples/overlay_editor/src/app/variable_stroke/content.rs @@ -1,16 +1,14 @@ -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 eframe::egui::{self, Vec2}; 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 i_triangle::i_overlay::mesh::float::variable_stroke::offset::VariableStrokeOffset; +use i_triangle::i_overlay::mesh::float::variable_stroke::{StrokeVertex, VariableStrokeStyle}; use std::collections::HashMap; use std::fmt::Write; @@ -25,7 +23,7 @@ pub(crate) struct VariableStrokeState { pub(crate) width_scale: f32, pub(crate) round_angle: u8, pub(crate) workspace: WorkspaceState, - pub(crate) size: Size, + pub(crate) size: Vec2, pub(crate) cameras: HashMap, } @@ -35,66 +33,35 @@ pub(crate) enum VariableStrokeMessage { WidthScaleUpdated(f32), RoundAngleUpdated(u8), PointEdited(PointEditUpdate), - WorkspaceSized(Size), - WorkspaceZoomed(Camera), - WorkspaceDragged(Vector), + WorkspaceSized(Vec2), } 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_content(&mut self, ui: &mut egui::Ui) { + egui::Panel::left("variable_stroke_tests") + .exact_size(150.0) + .resizable(false) + .show_inside(ui, |ui| { + egui::ScrollArea::vertical().show(ui, |ui| { + for index in 0..self.app_resource.variable_stroke.count { + let response = ui.selectable_label( + self.state.variable_stroke.test == index, + format!("test_{index}"), + ); + if response.clicked() { + response.surrender_focus(); + self.update(AppMessage::VariableStroke( + VariableStrokeMessage::TestSelected(index), + )); + } + } + }); + }); + egui::CentralPanel::default().show_inside(ui, |ui| { + self.variable_stroke_control(ui); + ui.separator(); + self.variable_stroke_workspace(ui); + }); } pub(crate) fn variable_stroke_update(&mut self, message: VariableStrokeMessage) { @@ -108,8 +75,6 @@ impl EditorApp { } 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), } } @@ -125,7 +90,7 @@ impl EditorApp { } pub(crate) fn variable_stroke_next_test(&mut self) { - let next_test = self.state.variable_stroke.test + 1; + let next_test = self.state.variable_stroke.test.saturating_add(1); if next_test < self.app_resource.variable_stroke.count { self.variable_stroke_set_test(next_test); } @@ -138,7 +103,7 @@ impl EditorApp { } } - fn variable_stroke_update_size(&mut self, size: Size) { + fn variable_stroke_update_size(&mut self, size: Vec2) { 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() { @@ -170,7 +135,7 @@ impl VariableStrokeState { round_angle: 12, workspace: Default::default(), cameras: HashMap::with_capacity(resource.count), - size: Size::ZERO, + size: Vec2::ZERO, }; state.set_test(0, resource); @@ -215,13 +180,15 @@ impl VariableStrokeState { .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 { + if camera.is_empty() && self.size.x > 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.workspace.sheet_state = Default::default(); + self.workspace.point_state = Default::default(); self.test = index; } diff --git a/examples/overlay_editor/src/app/variable_stroke/control.rs b/examples/overlay_editor/src/app/variable_stroke/control.rs index 43481197..9453f185 100644 --- a/examples/overlay_editor/src/app/variable_stroke/control.rs +++ b/examples/overlay_editor/src/app/variable_stroke/control.rs @@ -1,60 +1,31 @@ +use crate::app::design::{controls, slider}; 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}; +use eframe::egui; 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) + pub(crate) fn variable_stroke_control(&mut self, ui: &mut egui::Ui) { + controls(ui, "variable_stroke_controls", |ui| { + let mut width_scale = self.state.variable_stroke.width_scale; + if slider( + ui, + "Width Scale", + egui::Slider::new(&mut width_scale, 0.1..=3.0).step_by(0.01), + ) { + self.update(AppMessage::VariableStroke( + VariableStrokeMessage::WidthScaleUpdated(width_scale), + )); + } + let mut round_angle = self.state.variable_stroke.round_angle; + if slider( + ui, + "Round Detail", + egui::Slider::new(&mut round_angle, 1..=50), + ) { + self.update(AppMessage::VariableStroke( + VariableStrokeMessage::RoundAngleUpdated(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/workspace.rs b/examples/overlay_editor/src/app/variable_stroke/workspace.rs index 6335f44c..123e2365 100644 --- a/examples/overlay_editor/src/app/variable_stroke/workspace.rs +++ b/examples/overlay_editor/src/app/variable_stroke/workspace.rs @@ -1,20 +1,21 @@ -use crate::app::design::{style_sheet_background, Design}; +use crate::app::design::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::point_editor::{state::PointsEditorState, widget::PointsEditorWidget}; +use crate::sheet::state::SheetState; use crate::sheet::widget::SheetWidget; +use eframe::egui; 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) sheet_state: SheetState, + pub(crate) point_state: PointsEditorState, pub(crate) scale: f32, pub(crate) variable_input: Vec>, pub(crate) centerline_input: IntPaths, @@ -23,109 +24,70 @@ pub(crate) struct WorkspaceState { } 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), - ); + pub(crate) fn variable_stroke_workspace(&mut self, ui: &mut egui::Ui) { + self.update(AppMessage::VariableStroke( + VariableStrokeMessage::WorkspaceSized(ui.available_size()), + )); + let workspace = &mut self.state.variable_stroke.workspace; + let (painter, update) = SheetWidget::show( + ui, + &mut workspace.camera, + &workspace.points, + &mut workspace.sheet_state, + &mut workspace.point_state, + ); + if let Some(update) = update { + self.update(AppMessage::VariableStroke( + VariableStrokeMessage::PointEdited(update), + )); + ui.ctx().request_repaint(); + } + let workspace = &self.state.variable_stroke.workspace; + if workspace.camera.is_not_empty() { + let shapes = &workspace.stroke_output; + if !shapes.is_empty() { + ShapeWidget::with_paths( + &workspace.stroke_output, + workspace.camera, + Some(FillRule::NonZero), + Some(Design::solution_color().gamma_multiply(0.1)), + Some(Design::solution_color()), + 2.0, + ) + .paint(&painter); } - - stack.push( - Container::new(self.variable_stroke_control()) - .width(Length::Shrink) - .height(Length::Shrink) - .padding(Padding::new(8.0)), + PathWidget::with_paths( + &workspace.centerline_input, + workspace.camera, + Design::subject_color(), + 1.0, + false, ) - }) - .style(style_sheet_background) + .paint(&painter); + } + PointsEditorWidget::paint( + &painter, + workspace.camera, + &workspace.points, + &workspace.point_state, + ); } - - pub(super) fn variable_stroke_update_point(&mut self, update: PointEditUpdate) { + pub(super) fn variable_stroke_update_point( + &mut self, + update: crate::point_editor::widget::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(), + sheet_state: Default::default(), + point_state: Default::default(), variable_input: vec![], centerline_input: vec![], stroke_output: vec![], diff --git a/examples/overlay_editor/src/data/boolean.rs b/examples/overlay_editor/src/data/boolean.rs index 16b21653..8a92fb34 100644 --- a/examples/overlay_editor/src/data/boolean.rs +++ b/examples/overlay_editor/src/data/boolean.rs @@ -37,6 +37,7 @@ impl BooleanTest { } } + #[cfg(not(target_arch = "wasm32"))] fn tests_count(folder: &str) -> usize { let folder_path = PathBuf::from(folder); match std::fs::read_dir(folder_path) { diff --git a/examples/overlay_editor/src/data/outline.rs b/examples/overlay_editor/src/data/outline.rs index f91b4b32..0afbc3f2 100644 --- a/examples/overlay_editor/src/data/outline.rs +++ b/examples/overlay_editor/src/data/outline.rs @@ -33,6 +33,7 @@ impl OutlineTest { } } + #[cfg(not(target_arch = "wasm32"))] fn tests_count(folder: &str) -> usize { let folder_path = PathBuf::from(folder); match std::fs::read_dir(folder_path) { diff --git a/examples/overlay_editor/src/data/string.rs b/examples/overlay_editor/src/data/string.rs index 71a75f4f..08fc4c86 100644 --- a/examples/overlay_editor/src/data/string.rs +++ b/examples/overlay_editor/src/data/string.rs @@ -39,6 +39,7 @@ impl StringTest { } } + #[cfg(not(target_arch = "wasm32"))] fn tests_count(folder: &str) -> usize { let folder_path = PathBuf::from(folder); match std::fs::read_dir(folder_path) { diff --git a/examples/overlay_editor/src/data/stroke.rs b/examples/overlay_editor/src/data/stroke.rs index 2e583aef..894994d0 100644 --- a/examples/overlay_editor/src/data/stroke.rs +++ b/examples/overlay_editor/src/data/stroke.rs @@ -33,6 +33,7 @@ impl StrokeTest { } } + #[cfg(not(target_arch = "wasm32"))] fn tests_count(folder: &str) -> usize { let folder_path = PathBuf::from(folder); match std::fs::read_dir(folder_path) { diff --git a/examples/overlay_editor/src/data/variable_stroke.rs b/examples/overlay_editor/src/data/variable_stroke.rs index 291d0189..304d214f 100644 --- a/examples/overlay_editor/src/data/variable_stroke.rs +++ b/examples/overlay_editor/src/data/variable_stroke.rs @@ -37,6 +37,7 @@ impl VariableStrokeTest { } } + #[cfg(not(target_arch = "wasm32"))] fn tests_count(folder: &str) -> usize { let folder_path = PathBuf::from(folder); match std::fs::read_dir(folder_path) { diff --git a/examples/overlay_editor/src/draw/path.rs b/examples/overlay_editor/src/draw/path.rs index b9efcfaf..a4766844 100644 --- a/examples/overlay_editor/src/draw/path.rs +++ b/examples/overlay_editor/src/draw/path.rs @@ -1,197 +1,160 @@ use crate::geom::camera::Camera; -use crate::geom::vector::VectorExt; -use i_mesh::path::butt::ButtStrokeBuilder; -use i_mesh::path::style::StrokeStyle; -use i_triangle::float::builder::TriangulationBuilder; -use i_triangle::float::triangulation::Triangulation; -use i_triangle::i_overlay::i_float::float::point::FloatPoint; -use i_triangle::i_overlay::i_float::int::point::IntPoint; -use i_triangle::i_overlay::i_shape::int::path::{IntPath as RawIntPath, IntPaths as RawIntPaths}; -use iced::advanced::graphics::color::pack; -use iced::advanced::graphics::mesh::{Indexed, SolidVertex2D}; -use iced::advanced::graphics::Mesh; -use iced::advanced::layout::{self, Layout}; -use iced::advanced::renderer; -use iced::advanced::widget::{Tree, Widget}; -use iced::{mouse, Color, Transformation, Vector}; -use iced::{Element, Length, Rectangle, Renderer, Size, Theme}; - -type IntPath = RawIntPath; -type IntPaths = RawIntPaths; +use eframe::egui::{Color32, Mesh, Painter, Pos2, Shape, Stroke, Vec2}; +use i_mesh::path::{round::RoundStrokeBuilder, style::StrokeStyle}; +use i_triangle::i_overlay::i_shape::int::path::IntPaths; pub(crate) struct PathWidget { - stroke: Option, + shapes: Vec, } impl PathWidget { pub(crate) fn with_paths( - paths: &IntPaths, - camera: Camera, - stroke_color: Color, - stroke_width: f32, - arrows: bool, - ) -> Self { - let offset = Self::offset_for_paths(paths, camera); - let stroke = - Self::stroke_mesh_for_paths(paths, camera, offset, stroke_color, stroke_width, arrows); - Self { stroke } - } - - fn stroke_mesh_for_paths( - paths: &IntPaths, + paths: &IntPaths, camera: Camera, - offset: Vector, - color: Color, + color: Color32, width: f32, arrows: bool, - ) -> Option { - if paths.is_empty() { - return None; - } - - let mut builder = TriangulationBuilder::default(); - - for path in paths.iter() { - Self::append_path(&mut builder, camera, path, width, arrows); - } - - let s = if arrows { 2.5 * width } else { 0.5 * width }; - - let offset = Vector::new(offset.x - s, offset.y - s); - - let triangulation = builder.build(); - - Self::stroke_mesh_for_triangulation(triangulation, offset, color) - } - - fn stroke_mesh_for_triangulation( - triangulation: Triangulation, usize>, - offset: Vector, - color: Color, - ) -> Option { - if triangulation.indices.is_empty() { - return None; + ) -> Self { + let stroke = Stroke::new(width, color); + let mut shapes = Vec::new(); + for path in paths { + let points: Vec<_> = path + .iter() + .map(|&p| camera.int_world_to_view(p).to_pos2()) + .collect(); + if arrows { + for segment in points.windows(2) { + if let Some(arrow) = arrow_shape( + segment[0], + segment[1], + segment[0].lerp(segment[1], 0.5), + stroke, + ) { + shapes.push(arrow); + } + } + } + if let Some(shape) = stroke_path(points, false, stroke) { + shapes.push(shape); + } } - let color_pack = pack(color); - let vertices = triangulation - .points - .iter() - .map(|&p| SolidVertex2D { - position: [p.x - offset.x, p.y - offset.y], - color: color_pack, - }) - .collect(); - - let indices = triangulation.indices.iter().map(|&i| i as u32).collect(); - - Some(Mesh::Solid { - buffers: Indexed { vertices, indices }, - transformation: Transformation::translate(offset.x, offset.y), - clip_bounds: Rectangle::INFINITE, - }) + Self { shapes } } - fn offset_for_paths(paths: &IntPaths, camera: Camera) -> Vector { - if paths.is_empty() { - return Vector::new(0.0, 0.0); - } - - let mut min_x = i32::MAX; - let mut max_y = i32::MIN; - - for p in paths.iter().flatten() { - min_x = min_x.min(p.x); - max_y = max_y.max(p.y); + pub(crate) fn paint(self, painter: &Painter) { + for mut shape in self.shapes { + shape.translate(painter.clip_rect().min.to_vec2()); + painter.add(shape); } - - camera.int_world_to_view(IntPoint::new(min_x, max_y)) } +} - fn append_path( - builder: &mut TriangulationBuilder, usize>, - camera: Camera, - path: &IntPath, - width: f32, - arrows: bool, - ) { - let stroke_builder = ButtStrokeBuilder::new(StrokeStyle::with_width(width)); - let screen_path: Vec<_> = path - .iter() - .map(|&p| { - let v = camera.int_world_to_view(p); - FloatPoint::new(v.x, v.y) - }) - .collect(); - - let sub_triangulation = stroke_builder.build_open_path_mesh::(&screen_path); - builder.append(sub_triangulation); - - let r2 = 2.0 * width; - let r4 = 4.0 * width; - - if arrows { - let mut a = screen_path[0]; - for &b in screen_path.iter().skip(1) { - let m = (a + b) * 0.5; - let n = (b - a).normalize(); - let m0 = m - n * r4; - let t0 = FloatPoint::new(-n.y, n.x) * r2; - let t1 = FloatPoint::new(n.y, -n.x) * r2; - let v0 = m0 + t0; - let v1 = m0 + t1; - - let arrow_triangulation = - stroke_builder.build_open_path_mesh::(&[v0, m, v1]); - builder.append(arrow_triangulation); - - a = b; - } - } +pub(super) fn arrow_shape(a: Pos2, b: Pos2, tip: Pos2, stroke: Stroke) -> Option { + let delta = b - a; + if delta.length_sq() < 1e-10 { + return None; } + let n = delta.normalized(); + let base = tip - n * (4.0 * stroke.width); + let side = Vec2::new(-n.y, n.x) * (2.0 * stroke.width); + stroke_path(vec![base + side, tip, base - side], false, stroke) } -impl Widget for PathWidget { - fn size(&self) -> Size { - Size { - width: Length::Fill, - height: Length::Fill, - } +/// Build screen-space round joins/caps without changing editable geometry. +pub(super) fn stroke_path(mut points: Vec, closed: bool, stroke: Stroke) -> Option { + points.dedup(); + if closed && points.first() == points.last() { + points.pop(); } - - fn layout( - &mut self, - _tree: &mut Tree, - _renderer: &Renderer, - limits: &layout::Limits, - ) -> layout::Node { - layout::Node::new(limits.max()) + if points.len() < 2 || stroke.is_empty() { + return None; } - fn draw( - &self, - _tree: &Tree, - renderer: &mut Renderer, - _theme: &Theme, - _style: &renderer::Style, - layout: Layout<'_>, - _cursor: mouse::Cursor, - _viewport: &Rectangle, - ) { - use iced::advanced::graphics::mesh::Renderer as _; - use iced::advanced::Renderer as _; + let path: Vec<[f32; 2]> = points.iter().map(|p| [p.x, p.y]).collect(); + let builder = RoundStrokeBuilder::new(StrokeStyle::with_width(stroke.width)); + let triangulation = if closed { + builder.build_closed_path_mesh::(&path) + } else { + builder.build_open_path_mesh::(&path) + }; + let mut mesh = Mesh::default(); + for [x, y] in triangulation.points { + mesh.colored_vertex(Pos2::new(x, y), stroke.color); + } + mesh.indices = triangulation.indices; + Some(Shape::mesh(mesh)) +} - let bounds = layout.bounds(); - renderer.with_layer(bounds, |renderer| { - let offset = Vector::point(layout.position()); - if let Some(mesh) = &self.stroke { - renderer.with_translation(offset, |renderer| renderer.draw_mesh(mesh.clone())); +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn degenerate_strokes_tessellate_to_finite_vertices() { + let ctx = eframe::egui::Context::default(); + let a = Pos2::new(10.0, 10.0); + let b = Pos2::new(20.0, 10.0); + let c = Pos2::new(30.0, 10.0); + for points in [ + vec![], + vec![a], + vec![a, a, a], + vec![a, b], + vec![a, a, b, b, b, c, a], + vec![a, b, a, c], + ] { + for closed in [false, true] { + let output = ctx.run_ui(Default::default(), |ui| { + if let Some(shape) = + stroke_path(points.clone(), closed, Stroke::new(2.0_f32, Color32::WHITE)) + { + ui.painter().add(shape); + } + }); + for primitive in ctx.tessellate(output.shapes, output.pixels_per_point) { + if let eframe::egui::epaint::Primitive::Mesh(mesh) = primitive.primitive { + assert!(mesh.vertices.iter().all(|v| v.pos.is_finite())); + } + } } - }); + } + } + #[test] + fn round_strokes_stay_within_half_width_of_path_bounds() { + let width = 4.0; + for closed in [false, true] { + // Nearly reversing at the tip previously produced long miter spikes. + let points = vec![ + Pos2::new(100.0, 100.0), + Pos2::new(300.0, 100.0), + Pos2::new(100.0, 100.4), + ]; + let bounds = eframe::egui::Rect::from_points(&points).expand(width * 0.5 + 0.001); + let Shape::Mesh(mesh) = + stroke_path(points, closed, Stroke::new(width, Color32::WHITE)).unwrap() + else { + panic!("expected stroke mesh"); + }; + assert!(mesh.is_valid()); + assert!(!mesh.indices.is_empty()); + assert!(mesh.vertices.iter().all(|v| bounds.contains(v.pos))); + } } -} -impl<'a, Message: 'a> From for Element<'a, Message> { - fn from(editor: PathWidget) -> Self { - Self::new(editor) + #[test] + fn open_stroke_has_round_caps() { + let Shape::Mesh(mesh) = stroke_path( + vec![Pos2::new(10.0, 10.0), Pos2::new(30.0, 10.0)], + false, + Stroke::new(4.0_f32, Color32::WHITE), + ) + .unwrap() else { + panic!("expected stroke mesh"); + }; + assert!(mesh.vertices.iter().any(|v| v.pos.x < 8.1)); + assert!(mesh.vertices.iter().any(|v| v.pos.x > 31.9)); + assert!(mesh.vertices.iter().all(|v| { + let nearest = Pos2::new(v.pos.x.clamp(10.0, 30.0), 10.0); + v.pos.distance(nearest) <= 2.001 + })); } } diff --git a/examples/overlay_editor/src/draw/shape.rs b/examples/overlay_editor/src/draw/shape.rs index 64ae6bce..013ec365 100644 --- a/examples/overlay_editor/src/draw/shape.rs +++ b/examples/overlay_editor/src/draw/shape.rs @@ -1,315 +1,163 @@ use crate::geom::camera::Camera; -use crate::geom::vector::VectorExt; -use i_mesh::path::butt::ButtStrokeBuilder; -use i_mesh::path::style::StrokeStyle; -use i_triangle::float::builder::TriangulationBuilder; -use i_triangle::float::triangulation::Triangulation; +use eframe::egui::{Color32, Mesh, Painter, Shape, Stroke}; use i_triangle::i_overlay::core::fill_rule::FillRule; -use i_triangle::i_overlay::i_float::float::point::FloatPoint; -use i_triangle::i_overlay::i_float::int::point::IntPoint; -use i_triangle::i_overlay::i_shape::int::count::PointsCount; -use i_triangle::i_overlay::i_shape::int::path::IntPaths as RawIntPaths; -use i_triangle::i_overlay::i_shape::int::shape::IntShapes as RawIntShapes; -use i_triangle::int::triangulation::IntTriangulation; -use i_triangle::int::triangulator::IntTriangulator; -use i_triangle::int::validation::Validation; -use iced::advanced::graphics::color::pack; -use iced::advanced::graphics::mesh::{Indexed, SolidVertex2D}; -use iced::advanced::graphics::Mesh; -use iced::advanced::layout::{self, Layout}; -use iced::advanced::renderer; -use iced::advanced::widget::{Tree, Widget}; -use iced::{mouse, Color, Transformation, Vector}; -use iced::{Element, Length, Rectangle, Renderer, Size, Theme}; - -type IntPaths = RawIntPaths; -type IntShapes = RawIntShapes; +use i_triangle::i_overlay::i_shape::int::{count::PointsCount, path::IntPaths, shape::IntShapes}; +use i_triangle::int::{ + triangulation::IntTriangulation, triangulator::IntTriangulator, validation::Validation, +}; pub(crate) struct ShapeWidget { fill: Option, - stroke: Option, + strokes: Vec, } impl ShapeWidget { pub(crate) fn with_shapes( - shapes: &IntShapes, + shapes: &IntShapes, camera: Camera, fill_rule: Option, - fill_color: Option, - stroke_color: Option, + fill_color: Option, + stroke_color: Option, stroke_width: f32, ) -> Self { - let offset = Self::offset_for_shapes(shapes, camera); - let fill = Self::fill_mesh_for_shapes(shapes, camera, offset, fill_rule, fill_color); - let stroke = - Self::stroke_mesh_for_shapes(shapes, camera, offset, stroke_color, stroke_width); - Self { fill, stroke } + let fill = fill_color.filter(|_| !shapes.is_empty()).map(|color| { + let triangulation = IntTriangulator::new( + shapes.points_count(), + Validation::with_fill_rule(fill_rule.unwrap_or_default()), + Default::default(), + ) + .triangulate_shapes(shapes); + Self::fill_mesh(triangulation, camera, color) + }); + let strokes = shapes + .iter() + .flat_map(|paths| Self::strokes(paths, camera, stroke_color, stroke_width)) + .collect(); + Self { fill, strokes } } pub(crate) fn with_paths( - paths: &IntPaths, + paths: &IntPaths, camera: Camera, fill_rule: Option, - fill_color: Option, - stroke_color: Option, + fill_color: Option, + stroke_color: Option, stroke_width: f32, ) -> Self { - let offset = Self::offset_for_paths(paths, camera); - let fill = Self::fill_mesh_for_paths(paths, camera, offset, fill_rule, fill_color); - let stroke = Self::stroke_mesh_for_paths(paths, camera, offset, stroke_color, stroke_width); - Self { fill, stroke } - } - - fn fill_mesh_for_shapes( - shapes: &IntShapes, - camera: Camera, - offset: Vector, - fill_rule: Option, - color: Option, - ) -> Option { - if shapes.is_empty() { - return None; - } - let color = color?; - let validation = Validation::with_fill_rule(fill_rule.unwrap_or_default()); - let triangulation = - IntTriangulator::new(shapes.points_count(), validation, Default::default()) - .triangulate_shapes(shapes); - - Self::fill_mesh_for_triangulation(triangulation, camera, offset, color) - } - - fn fill_mesh_for_paths( - paths: &IntPaths, - camera: Camera, - offset: Vector, - fill_rule: Option, - color: Option, - ) -> Option { - if paths.is_empty() { - return None; + let fill = fill_color.filter(|_| !paths.is_empty()).map(|color| { + let triangulation = IntTriangulator::new( + paths.points_count(), + Validation::with_fill_rule(fill_rule.unwrap_or_default()), + Default::default(), + ) + .triangulate_shape(paths); + Self::fill_mesh(triangulation, camera, color) + }); + Self { + fill, + strokes: Self::strokes(paths, camera, stroke_color, stroke_width), } - let color = color?; - - let validation = Validation::with_fill_rule(fill_rule.unwrap_or_default()); - let triangulation = - IntTriangulator::new(paths.points_count(), validation, Default::default()) - .triangulate_shape(paths); - - // let triangulation = paths.triangulate().into_triangulation(); - - Self::fill_mesh_for_triangulation(triangulation, camera, offset, color) } - fn fill_mesh_for_triangulation( + fn fill_mesh( triangulation: IntTriangulation, camera: Camera, - offset: Vector, - color: Color, - ) -> Option { - let indices = triangulation.indices; - if indices.is_empty() { - return None; + color: Color32, + ) -> Mesh { + let mut mesh = Mesh::default(); + for p in triangulation.points { + mesh.colored_vertex(camera.int_world_to_view(p).to_pos2(), color); } - let color_pack = pack(color); - let vertices = triangulation - .points - .iter() - .map(|&p| { - let v = camera.int_world_to_view(p); - SolidVertex2D { - position: [v.x - offset.x, v.y - offset.y], - color: color_pack, - } - }) + mesh.indices = triangulation + .indices + .into_iter() + .map(|i| i as u32) .collect(); - - let indices = indices.iter().map(|&i| i as u32).collect(); - - Some(Mesh::Solid { - buffers: Indexed { vertices, indices }, - transformation: Transformation::translate(offset.x, offset.y), - clip_bounds: Rectangle::INFINITE, - }) + mesh } - fn stroke_mesh_for_shapes( - shapes: &IntShapes, + fn strokes( + paths: &IntPaths, camera: Camera, - offset: Vector, - color: Option, + color: Option, width: f32, - ) -> Option { - if shapes.is_empty() { - return None; - } - let color = color?; - let stroke_builder = ButtStrokeBuilder::new(StrokeStyle::with_width(width)); - - let mut builder = TriangulationBuilder::default(); - for shape in shapes.iter() { - for path in shape.iter() { - let world_path: Vec> = path - .iter() - .map(|&p| { - let v = camera.int_world_to_view(p); - FloatPoint::new(v.x, v.y) - }) - .collect(); - - let sub_triangulation = stroke_builder.build_closed_path_mesh::(&world_path); - builder.append(sub_triangulation); - } - } - let r = 0.5 * width; - let offset = Vector::new(offset.x - r, offset.y - r); - - let triangulation = builder.build(); - - Self::stroke_mesh_for_triangulation(triangulation, offset, color) - } - - fn stroke_mesh_for_paths( - paths: &IntPaths, - camera: Camera, - offset: Vector, - color: Option, - width: f32, - ) -> Option { - if paths.is_empty() { - return None; - } - let color = color?; - let stroke_builder = ButtStrokeBuilder::new(StrokeStyle::with_width(width)); - - let mut builder = TriangulationBuilder::default(); - - for path in paths.iter() { - let world_path: Vec<_> = path - .iter() - .map(|&p| { - let v = camera.int_world_to_view(p); - FloatPoint::new(v.x, v.y) - }) - .collect(); - - let sub_triangulation = stroke_builder.build_closed_path_mesh::(&world_path); - builder.append(sub_triangulation); - } - - let r = 0.5 * width; - let offset = Vector::new(offset.x - r, offset.y - r); - - let triangulation = builder.build(); - - Self::stroke_mesh_for_triangulation(triangulation, offset, color) - } - - fn stroke_mesh_for_triangulation( - triangulation: Triangulation, usize>, - offset: Vector, - color: Color, - ) -> Option { - if triangulation.indices.is_empty() { - return None; - } - let color_pack = pack(color); - let vertices = triangulation - .points + ) -> Vec { + let Some(color) = color else { + return Vec::new(); + }; + paths .iter() - .map(|&p| SolidVertex2D { - position: [p.x - offset.x, p.y - offset.y], - color: color_pack, + .filter_map(|path| { + crate::draw::path::stroke_path( + path.iter() + .map(|&p| camera.int_world_to_view(p).to_pos2()) + .collect(), + true, + Stroke::new(width, color), + ) }) - .collect(); - - let indices = triangulation.indices.iter().map(|&i| i as u32).collect(); - - Some(Mesh::Solid { - buffers: Indexed { vertices, indices }, - transformation: Transformation::translate(offset.x, offset.y), - clip_bounds: Rectangle::INFINITE, - }) + .collect() } - fn offset_for_shapes(shapes: &IntShapes, camera: Camera) -> Vector { - if shapes.is_empty() { - return Vector::new(0.0, 0.0); + pub(crate) fn paint(self, painter: &Painter) { + let offset = painter.clip_rect().min.to_vec2(); + if let Some(mut mesh) = self.fill { + mesh.translate(offset); + painter.add(mesh); } - - let mut min_x = i32::MAX; - let mut max_y = i32::MIN; - - for p in shapes.iter().flatten().flatten() { - min_x = min_x.min(p.x); - max_y = max_y.max(p.y); + for mut shape in self.strokes { + shape.translate(offset); + painter.add(shape); } - - camera.int_world_to_view(IntPoint::new(min_x, max_y)) - } - - fn offset_for_paths(paths: &IntPaths, camera: Camera) -> Vector { - if paths.is_empty() { - return Vector::new(0.0, 0.0); - } - - let mut min_x = i32::MAX; - let mut max_y = i32::MIN; - - for p in paths.iter().flatten() { - min_x = min_x.min(p.x); - max_y = max_y.max(p.y); - } - - camera.int_world_to_view(IntPoint::new(min_x, max_y)) - } -} - -impl Widget for ShapeWidget { - fn size(&self) -> Size { - Size { - width: Length::Fill, - height: 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::graphics::mesh::Renderer as _; - use iced::advanced::Renderer as _; - - let bounds = layout.bounds(); - renderer.with_layer(bounds, |renderer| { - let offset = Vector::point(layout.position()); - if let Some(mesh) = &self.fill { - renderer.with_translation(offset, |renderer| renderer.draw_mesh(mesh.clone())); - } - if let Some(mesh) = &self.stroke { - renderer.with_translation(offset, |renderer| renderer.draw_mesh(mesh.clone())); - } - }); } } -impl<'a, Message: 'a> From for Element<'a, Message> { - fn from(editor: ShapeWidget) -> Self { - Self::new(editor) +#[cfg(test)] +mod tests { + use super::*; + use eframe::egui::Vec2; + use i_triangle::i_overlay::i_float::int::{point::IntPoint, rect::IntRect}; + + #[test] + fn fill_preserves_holes_and_strokes_are_stroke_paths() { + let paths = vec![ + vec![ + IntPoint::new(0, 0), + IntPoint::new(0, 100), + IntPoint::new(100, 100), + IntPoint::new(100, 0), + ], + vec![ + IntPoint::new(25, 25), + IntPoint::new(75, 25), + IntPoint::new(75, 75), + IntPoint::new(25, 75), + ], + ]; + let camera = Camera::new(IntRect::new(0, 100, 0, 100), Vec2::new(200.0, 200.0)); + let widget = ShapeWidget::with_paths( + &paths, + camera, + Some(FillRule::EvenOdd), + Some(Color32::WHITE), + Some(Color32::RED), + 2.0, + ); + let mesh = widget.fill.unwrap(); + let area: f32 = mesh + .indices + .chunks_exact(3) + .map(|t| { + let a = mesh.vertices[t[0] as usize].pos; + let b = mesh.vertices[t[1] as usize].pos; + let c = mesh.vertices[t[2] as usize].pos; + ((b - a).x * (c - a).y - (b - a).y * (c - a).x).abs() * 0.5 + }) + .sum(); + assert!((area - 7500.0).abs() < 0.01); + assert_eq!(widget.strokes.len(), 2); + assert!(widget.strokes.iter().all( + |s| matches!(s, Shape::Mesh(mesh) if mesh.is_valid() && !mesh.indices.is_empty()) + )); } } diff --git a/examples/overlay_editor/src/draw/varicolored.rs b/examples/overlay_editor/src/draw/varicolored.rs index 191bac72..5118f035 100644 --- a/examples/overlay_editor/src/draw/varicolored.rs +++ b/examples/overlay_editor/src/draw/varicolored.rs @@ -1,249 +1,49 @@ -use crate::geom::camera::Camera; -use crate::geom::vector::VectorExt; -use i_mesh::path::butt::ButtStrokeBuilder; -use i_mesh::path::style::StrokeStyle; -use i_triangle::float::builder::TriangulationBuilder; -use i_triangle::float::triangulation::Triangulation; -use i_triangle::i_overlay::i_float::float::point::FloatPoint; -use i_triangle::i_overlay::i_float::int::point::IntPoint; -use i_triangle::i_overlay::i_shape::int::path::IntPaths as RawIntPaths; -use i_triangle::i_overlay::i_shape::int::shape::IntShapes as RawIntShapes; -use i_triangle::int::triangulatable::IntTriangulatable; -use i_triangle::int::triangulation::IntTriangulation; -use iced::advanced::graphics::color::pack; -use iced::advanced::graphics::mesh::{Indexed, SolidVertex2D}; -use iced::advanced::graphics::Mesh; -use iced::advanced::layout::{self, Layout}; -use iced::advanced::renderer; -use iced::advanced::widget::{Tree, Widget}; -use iced::{mouse, Color, Transformation, Vector}; -use iced::{Element, Length, Rectangle, Renderer, Size, Theme}; - -type IntPaths = RawIntPaths; -type IntShapes = RawIntShapes; +use crate::{draw::shape::ShapeWidget, geom::camera::Camera}; +use eframe::egui::{Color32, Painter}; +use i_triangle::i_overlay::i_shape::int::shape::IntShapes; pub(crate) struct VaricoloredWidget { - fill: Vec, - stroke: Vec, + shapes: Vec, } impl VaricoloredWidget { - const SHAPE_COLOR_STORE: [[u8; 3]; 12] = [ - [255, 149, 0], // Orange - [88, 86, 214], // Purple - [255, 45, 85], // Pink - [90, 200, 250], // Blue - [76, 217, 100], // Green - [255, 204, 0], // Yellow - [142, 142, 147], // Gray - [255, 59, 48], // Red - [52, 199, 89], // Green - [0, 122, 255], // Blue - [175, 82, 222], // Indigo - [255, 214, 10], // Teal + const COLORS: [[u8; 3]; 12] = [ + [255, 149, 0], + [88, 86, 214], + [255, 45, 85], + [90, 200, 250], + [76, 217, 100], + [255, 204, 0], + [142, 142, 147], + [255, 59, 48], + [52, 199, 89], + [0, 122, 255], + [175, 82, 222], + [255, 214, 10], ]; - - pub(crate) fn with_shapes(shapes: &IntShapes, camera: Camera, stroke_width: f32) -> Self { - let offset = Self::offset_for_shapes(shapes, camera); - - let mut fill = Vec::new(); - let mut stroke = Vec::new(); - for (index, shape) in shapes.iter().enumerate() { - let data = Self::SHAPE_COLOR_STORE[index % Self::SHAPE_COLOR_STORE.len()]; - let color = Color::from_rgb8(data[0], data[1], data[2]); - - if let Some(mesh) = - Self::fill_mesh_for_paths(shape, camera, offset, color.scale_alpha(0.2)) - { - fill.push(mesh); - } - if let Some(mesh) = - Self::stroke_mesh_for_paths(shape, camera, offset, color, stroke_width) - { - stroke.push(mesh); - } - } - - Self { fill, stroke } - } - - fn fill_mesh_for_paths( - paths: &IntPaths, - camera: Camera, - offset: Vector, - color: Color, - ) -> Option { - if paths.is_empty() { - return None; - } - - let triangulation = paths.triangulate().into_triangulation(); - Self::fill_mesh_for_triangulation(triangulation, camera, offset, color) - } - - fn fill_mesh_for_triangulation( - triangulation: IntTriangulation, - camera: Camera, - offset: Vector, - color: Color, - ) -> Option { - if triangulation.indices.is_empty() { - return None; - } - let color_pack = pack(color); - let vertices = triangulation - .points - .iter() - .map(|&p| { - let v = camera.int_world_to_view(p); - SolidVertex2D { - position: [v.x - offset.x, v.y - offset.y], - color: color_pack, - } - }) - .collect(); - - let indices = triangulation.indices.iter().map(|&i| i as u32).collect(); - - Some(Mesh::Solid { - buffers: Indexed { vertices, indices }, - transformation: Transformation::translate(offset.x, offset.y), - clip_bounds: Rectangle::INFINITE, - }) - } - - fn stroke_mesh_for_paths( - paths: &IntPaths, - camera: Camera, - offset: Vector, - color: Color, - width: f32, - ) -> Option { - if paths.is_empty() { - return None; - } - let stroke_builder = ButtStrokeBuilder::new(StrokeStyle::with_width(width)); - - let mut builder = TriangulationBuilder::default(); - - for path in paths.iter() { - let world_path: Vec<_> = path + pub(crate) fn with_shapes(shapes: &IntShapes, camera: Camera, width: f32) -> Self { + Self { + shapes: shapes .iter() - .map(|&p| { - let v = camera.int_world_to_view(p); - FloatPoint::new(v.x, v.y) + .enumerate() + .map(|(index, paths)| { + let [r, g, b] = Self::COLORS[index % Self::COLORS.len()]; + let color = Color32::from_rgb(r, g, b); + ShapeWidget::with_paths( + paths, + camera, + None, + Some(color.gamma_multiply(0.2)), + Some(color), + width, + ) }) - .collect(); - - let sub_triangulation: Triangulation, usize> = - stroke_builder.build_closed_path_mesh(&world_path); - - builder.append(sub_triangulation); + .collect(), } - - let r = 0.5 * width; - let offset = Vector::new(offset.x - r, offset.y - r); - - let triangulation = builder.build(); - - Self::stroke_mesh_for_triangulation(triangulation, offset, color) } - - fn stroke_mesh_for_triangulation( - triangulation: Triangulation, usize>, - offset: Vector, - color: Color, - ) -> Option { - if triangulation.indices.is_empty() { - return None; - } - let color_pack = pack(color); - let vertices = triangulation - .points - .iter() - .map(|&p| SolidVertex2D { - position: [p.x - offset.x, p.y - offset.y], - color: color_pack, - }) - .collect(); - - let indices = triangulation.indices.iter().map(|&i| i as u32).collect(); - - Some(Mesh::Solid { - buffers: Indexed { vertices, indices }, - transformation: Transformation::translate(offset.x, offset.y), - clip_bounds: Rectangle::INFINITE, - }) - } - - fn offset_for_shapes(shapes: &IntShapes, camera: Camera) -> Vector { - if shapes.is_empty() { - return Vector::new(0.0, 0.0); - } - - let mut min_x = i32::MAX; - let mut max_y = i32::MIN; - - for p in shapes.iter().flatten().flatten() { - min_x = min_x.min(p.x); - max_y = max_y.max(p.y); - } - - camera.int_world_to_view(IntPoint::new(min_x, max_y)) - } -} - -impl Widget for VaricoloredWidget { - fn size(&self) -> Size { - Size { - width: Length::Fill, - height: Length::Fill, + pub(crate) fn paint(self, painter: &Painter) { + for shape in self.shapes { + shape.paint(painter); } } - - 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::graphics::mesh::Renderer as _; - use iced::advanced::Renderer as _; - - let bounds = layout.bounds(); - renderer.with_layer(bounds, |renderer| { - let offset = Vector::point(layout.position()); - - renderer.with_translation(offset, |renderer| { - for mesh in self.fill.iter() { - renderer.draw_mesh(mesh.clone()) - } - }); - - renderer.with_translation(offset, |renderer| { - for mesh in self.stroke.iter() { - renderer.draw_mesh(mesh.clone()) - } - }); - }); - } -} - -impl<'a, Message: 'a> From for Element<'a, Message> { - fn from(editor: VaricoloredWidget) -> Self { - Self::new(editor) - } } diff --git a/examples/overlay_editor/src/draw/vectors.rs b/examples/overlay_editor/src/draw/vectors.rs index 46b7a031..e3d23577 100644 --- a/examples/overlay_editor/src/draw/vectors.rs +++ b/examples/overlay_editor/src/draw/vectors.rs @@ -1,320 +1,74 @@ +use crate::draw::path::{arrow_shape, stroke_path}; use crate::geom::camera::Camera; -use crate::geom::vector::VectorExt; -use i_mesh::path::butt::ButtStrokeBuilder; -use i_mesh::path::style::StrokeStyle; -use i_triangle::float::triangulation::Triangulation; -use i_triangle::i_overlay::i_float::float::point::FloatPoint; -use i_triangle::i_overlay::i_float::int::point::IntPoint; +use eframe::egui::{Color32, Painter, Shape, Stroke, Vec2}; use i_triangle::i_overlay::vector::edge::{ - DataVectorEdge, SideFill, CLIP_LEFT, CLIP_RIGHT, SUBJ_LEFT, SUBJ_RIGHT, + DataVectorEdge, CLIP_LEFT, CLIP_RIGHT, SUBJ_LEFT, SUBJ_RIGHT, }; -use iced::advanced::graphics::color::pack; -use iced::advanced::graphics::mesh::{Indexed, SolidVertex2D}; -use iced::advanced::graphics::{color, Mesh}; -use iced::advanced::layout::{self, Layout}; -use iced::advanced::renderer; -use iced::advanced::widget::{Tree, Widget}; -use iced::{mouse, Color, Transformation, Vector}; -use iced::{Element, Length, Rectangle, Renderer, Size, Theme}; -use std::f32::consts::PI; - -type VectorEdge = DataVectorEdge; - -struct ColorSchema { - subj: color::Packed, - subj_none: color::Packed, - clip: color::Packed, - clip_none: color::Packed, - both: color::Packed, - none: color::Packed, -} - -impl ColorSchema { - fn new(subj: Color, clip: Color, both: Color) -> Self { - Self { - subj: pack(subj), - subj_none: pack(subj.scale_alpha(0.05)), - clip: pack(clip), - clip_none: pack(clip.scale_alpha(0.05)), - both: pack(both), - none: pack(Color::from_rgb8(127, 127, 127)), - } - } - - fn color(&self, fill: SideFill) -> color::Packed { - let subj = fill & (SUBJ_LEFT | SUBJ_RIGHT) != 0; - let clip = fill & (CLIP_LEFT | CLIP_RIGHT) != 0; - match (subj, clip) { - (true, true) => self.both, - (true, false) => self.subj, - (false, true) => self.clip, - (false, false) => self.none, - } - } - - fn subj_right(&self, fill: SideFill) -> color::Packed { - if fill & SUBJ_RIGHT != 0 { - self.subj - } else { - self.subj_none - } - } - - fn subj_left(&self, fill: SideFill) -> color::Packed { - if fill & SUBJ_LEFT != 0 { - self.subj - } else { - self.subj_none - } - } - - fn clip_right(&self, fill: SideFill) -> color::Packed { - if fill & CLIP_RIGHT != 0 { - self.clip - } else { - self.clip_none - } - } - - fn clip_left(&self, fill: SideFill) -> color::Packed { - if fill & CLIP_LEFT != 0 { - self.clip - } else { - self.clip_none - } - } -} pub(crate) struct VectorsWidget { - stroke: Option, + shapes: Vec, } impl VectorsWidget { pub(crate) fn with_vectors( - vectors: &[VectorEdge], - camera: Camera, - subj: Color, - clip: Color, - both: Color, - stroke_width: f32, - ) -> Self { - let schema = ColorSchema::new(subj, clip, both); - let offset = Self::offset_for_vectors(vectors, camera); - let stroke = Self::stroke_mesh_for_paths(vectors, camera, offset, schema, stroke_width); - Self { stroke } - } - - fn stroke_mesh_for_paths( - vectors: &[VectorEdge], + vectors: &[DataVectorEdge], camera: Camera, - offset: Vector, - schema: ColorSchema, + subj: Color32, + clip: Color32, + both: Color32, width: f32, - ) -> Option { - if vectors.is_empty() { - return None; - } - - let mut builder = MeshBuilder::new(); - - let s = 8.0 * width; - let offset = Vector::new(offset.x - s, offset.y - s); - - for vector in vectors.iter() { - Self::append_vector(&mut builder, camera, vector, offset, &schema, width); - } - - let buffers = builder.build(); - - Some(Mesh::Solid { - buffers, - transformation: Transformation::translate(offset.x, offset.y), - clip_bounds: Rectangle::INFINITE, - }) - } - - fn offset_for_vectors(vectors: &[VectorEdge], camera: Camera) -> Vector { - if vectors.is_empty() { - return Vector::new(0.0, 0.0); - } - - let mut min_x = i32::MAX; - let mut max_y = i32::MIN; - - for v in vectors.iter() { - min_x = min_x.min(v.a.x); - min_x = min_x.min(v.b.x); - max_y = max_y.max(v.a.y); - max_y = max_y.max(v.b.y); - } - - camera.int_world_to_view(IntPoint::new(min_x, max_y)) - } - - fn append_vector( - builder: &mut MeshBuilder, - camera: Camera, - vector: &VectorEdge, - offset: Vector, - schema: &ColorSchema, - width: f32, - ) { - let stroke_builder = ButtStrokeBuilder::new(StrokeStyle::with_width(width)); - let path = [vector.a, vector.b]; - let screen_path: Vec<_> = path - .iter() - .map(|&p| { - let v = camera.int_world_to_view(p); - FloatPoint::new(v.x - offset.x, v.y - offset.y) - }) - .collect(); - - let segment_color = schema.color(vector.fill); - - let sub_triangulation = stroke_builder.build_open_path_mesh(&screen_path); - builder.append(sub_triangulation, segment_color); - - let r2 = 4.0 * width; - - let a = screen_path[0]; - let b = screen_path[1]; - - let n = (b - a).normalize(); - let m = (a + b) * 0.5; - let m0 = b - n * r2; - let t0 = FloatPoint::new(-n.y, n.x) * r2; - let t1 = FloatPoint::new(n.y, -n.x) * r2; - let s0 = n * r2; - let s1 = -n * r2; - let v0 = m0 + t0 * 0.5; - let v1 = m0 + t1 * 0.5; - - let subj_right = schema.subj_right(vector.fill); - let clip_right = schema.clip_right(vector.fill); - let subj_left = schema.subj_left(vector.fill); - let clip_left = schema.clip_left(vector.fill); - - let subj_right_pos = m + t0 + s0; - let subj_left_pos = m + t1 + s0; - - let clip_right_pos = m + t0 + s1; - let clip_left_pos = m + t1 + s1; - - Self::append_circle(builder, subj_right_pos, subj_right, 2.0 * width); - Self::append_circle(builder, clip_right_pos, clip_right, 2.0 * width); - Self::append_circle(builder, subj_left_pos, subj_left, 2.0 * width); - Self::append_circle(builder, clip_left_pos, clip_left, 2.0 * width); - - let arrow_triangulation = stroke_builder.build_open_path_mesh(&[v0, b, v1]); - builder.append(arrow_triangulation, segment_color); - } - - fn append_circle( - builder: &mut MeshBuilder, - pos: FloatPoint, - color: color::Packed, - radius: f32, - ) { - let n = 8; - let da = 2.0 * PI / n as f32; - let mut a = 0.0f32; - let mut indices = Vec::with_capacity(3 * n); - let mut points = Vec::with_capacity(n); - for i in 0..n { - let sc = a.sin_cos(); - let x = pos.x + sc.1 * radius; - let y = pos.y + sc.0 * radius; - points.push(FloatPoint::new(x, y)); - indices.extend(&[n, i, (i + 1) % n]); - a += da; - } - - points.push(pos); - - builder.append(Triangulation { points, indices }, color); - } -} - -impl Widget for VectorsWidget { - fn size(&self) -> Size { - Size { - width: Length::Fill, - height: 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::graphics::mesh::Renderer as _; - use iced::advanced::Renderer as _; - - let bounds = layout.bounds(); - renderer.with_layer(bounds, |renderer| { - let offset = Vector::point(layout.position()); - if let Some(mesh) = &self.stroke { - renderer.with_translation(offset, |renderer| renderer.draw_mesh(mesh.clone())); + ) -> Self { + let mut shapes = Vec::new(); + for vector in vectors { + let a = camera.int_world_to_view(vector.a).to_pos2(); + let b = camera.int_world_to_view(vector.b).to_pos2(); + if (b - a).length_sq() < 1e-10 { + continue; + } + let fill = vector.fill; + let color = match ( + fill & (SUBJ_LEFT | SUBJ_RIGHT) != 0, + fill & (CLIP_LEFT | CLIP_RIGHT) != 0, + ) { + (true, true) => both, + (true, false) => subj, + (false, true) => clip, + _ => Color32::GRAY, + }; + let stroke = Stroke::new(width, color); + if let Some(line) = stroke_path(vec![a, b], false, stroke) { + shapes.push(line); + } + if let Some(arrow) = arrow_shape(a, b, b, stroke) { + shapes.push(arrow); + } + let n = (b - a).normalized() * (4.0 * width); + let side = Vec2::new(-n.y, n.x); + let mid = a.lerp(b, 0.5); + for (pos, mask, color) in [ + (mid + side + n, SUBJ_RIGHT, subj), + (mid - side + n, SUBJ_LEFT, subj), + (mid + side - n, CLIP_RIGHT, clip), + (mid - side - n, CLIP_LEFT, clip), + ] { + shapes.push(Shape::circle_filled( + pos, + 2.0 * width, + if fill & mask != 0 { + color + } else { + color.gamma_multiply(0.05) + }, + )); } - }); - } -} - -impl<'a, Message: 'a> From for Element<'a, Message> { - fn from(editor: VectorsWidget) -> Self { - Self::new(editor) - } -} - -struct MeshBuilder { - vertices: Vec, - indices: Vec, -} - -impl MeshBuilder { - fn new() -> Self { - Self { - vertices: Vec::new(), - indices: Vec::new(), - } - } - - fn append( - &mut self, - triangulation: Triangulation, usize>, - color: color::Packed, - ) -> &mut Self { - let offset = self.vertices.len(); - for p in triangulation.points.iter() { - self.vertices.push(SolidVertex2D { - position: [p.x, p.y], - color, - }); } - self.indices - .extend(triangulation.indices.iter().map(|&i| (i + offset) as u32)); - self + Self { shapes } } - fn build(self) -> Indexed { - Indexed { - vertices: self.vertices, - indices: self.indices, + pub(crate) fn paint(self, painter: &Painter) { + for mut shape in self.shapes { + shape.translate(painter.clip_rect().min.to_vec2()); + painter.add(shape); } } } diff --git a/examples/overlay_editor/src/geom/camera.rs b/examples/overlay_editor/src/geom/camera.rs index d84a826e..9cd9e715 100644 --- a/examples/overlay_editor/src/geom/camera.rs +++ b/examples/overlay_editor/src/geom/camera.rs @@ -1,13 +1,13 @@ +use eframe::egui::Vec2; use i_triangle::i_overlay::i_float::int::point::IntPoint; use i_triangle::i_overlay::i_float::int::rect::IntRect; -use iced::{Size, Vector}; #[derive(Debug, Clone, Copy)] pub(crate) struct Camera { pub(crate) scale: f32, pub(crate) i_scale: f32, - pub(crate) size: Size, - pub(crate) pos: Vector, + pub(crate) size: Vec2, + pub(crate) pos: Vec2, } impl Camera { @@ -15,8 +15,8 @@ impl Camera { Self { scale: 0.0, i_scale: 0.0, - size: Size::ZERO, - pos: Vector::new(0.0, 0.0), + size: Vec2::ZERO, + pos: Vec2::new(0.0, 0.0), } } @@ -25,28 +25,22 @@ impl Camera { } pub(crate) fn set_scale(&mut self, scale: f32) { - self.scale = scale; - self.i_scale = 1.0 / scale; + self.scale = scale.clamp(1e-10, 1e8); + self.i_scale = 1.0 / self.scale; } pub(crate) fn is_not_empty(&self) -> bool { self.scale > 0.0 } - pub(crate) fn new(rect: IntRect, size: Size) -> Self { - 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; - let sw = size.width / width; - let sh = size.height / height; - - let scale = 0.25 * sw.min(sh); + pub(crate) fn new(rect: IntRect, size: Vec2) -> Self { + let width = (rect.max_x as f64 - rect.min_x as f64).max(1.0) as f32; + let height = (rect.max_y as f64 - rect.min_y as f64).max(1.0) as f32; + let scale = (0.5 * (size.x / width).min(size.y / height)).max(1e-10); let i_scale = 1.0 / scale; - let x = 0.5 * (rect.min_x + rect.max_x) as f32; - let y = 0.5 * (rect.min_y + rect.max_y) as f32; - let pos = Vector::new(x, y); + let x = (0.5 * (rect.min_x as f64 + rect.max_x as f64)) as f32; + let y = (0.5 * (rect.min_y as f64 + rect.max_y as f64)) as f32; + let pos = Vec2::new(x, y); Camera { scale, @@ -57,50 +51,37 @@ impl Camera { } #[inline] - pub(crate) fn world_to_screen( - &self, - view_left_top: Vector, - world: IntPoint, - ) -> Vector { - let x = - self.scale * (world.x as f32 - self.pos.x) + view_left_top.x + 0.5 * self.size.width; - let y = - self.scale * (self.pos.y - world.y as f32) + view_left_top.y + 0.5 * self.size.height; - Vector { x, y } - } - - #[inline] - pub(crate) fn int_world_to_view(&self, world: IntPoint) -> Vector { - self.world_to_view(Vector::new(world.x as f32, world.y as f32)) + pub(crate) fn int_world_to_view(&self, world: IntPoint) -> Vec2 { + self.world_to_view(Vec2::new(world.x as f32, world.y as f32)) } #[inline] - pub(crate) fn world_to_view(&self, world: Vector) -> Vector { - let x = self.scale * (world.x - self.pos.x) + 0.5 * self.size.width; - let y = self.scale * (self.pos.y - world.y) + 0.5 * self.size.height; - Vector { x, y } + pub(crate) fn world_to_view(&self, world: Vec2) -> Vec2 { + let x = self.scale * (world.x - self.pos.x) + 0.5 * self.size.x; + let y = self.scale * (self.pos.y - world.y) + 0.5 * self.size.y; + Vec2 { x, y } } #[inline] - pub(crate) fn view_to_world(&self, view: Vector) -> Vector { - let x = self.i_scale * (view.x - 0.5 * self.size.width) + self.pos.x; - let y = self.i_scale * (0.5 * self.size.height - view.y) + self.pos.y; - Vector { x, y } + pub(crate) fn view_to_world(&self, view: Vec2) -> Vec2 { + let x = self.i_scale * (view.x - 0.5 * self.size.x) + self.pos.x; + let y = self.i_scale * (0.5 * self.size.y - view.y) + self.pos.y; + Vec2 { x, y } } #[inline] - pub(crate) fn view_distance_to_world(&self, view_distance: Vector) -> Vector { + pub(crate) fn view_distance_to_world(&self, view_distance: Vec2) -> Vec2 { let x = view_distance.x * self.i_scale; let y = -view_distance.y * self.i_scale; - Vector { x, y } + Vec2 { x, y } } } #[cfg(test)] mod tests { use super::Camera; + use eframe::egui::Vec2; use i_triangle::i_overlay::i_float::int::rect::IntRect; - use iced::Size; #[test] fn camera_supports_degenerate_bounds() { @@ -111,7 +92,7 @@ mod tests { ]; for rect in rects { - let camera = Camera::new(rect, Size::new(800.0, 600.0)); + let camera = Camera::new(rect, Vec2::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/geom/vector.rs b/examples/overlay_editor/src/geom/vector.rs index 4051b5ea..323d2ff5 100644 --- a/examples/overlay_editor/src/geom/vector.rs +++ b/examples/overlay_editor/src/geom/vector.rs @@ -1,19 +1,6 @@ +use eframe::egui::Vec2; use i_triangle::i_overlay::i_float::int::point::IntPoint; -use iced::{Point, Vector}; -pub(crate) trait VectorExt { - fn round(&self) -> IntPoint; - fn point(point: Point) -> Self; -} - -impl VectorExt for Vector { - fn round(&self) -> IntPoint { - IntPoint::new(self.x.round() as i32, self.y.round() as i32) - } - fn point(point: Point) -> Self { - Self { - x: point.x, - y: point.y, - } - } +pub(crate) fn round_to_int(value: Vec2) -> IntPoint { + IntPoint::new(value.x.round() as i32, value.y.round() as i32) } diff --git a/examples/overlay_editor/src/lib.rs b/examples/overlay_editor/src/lib.rs index a72f900b..0aa7fe13 100644 --- a/examples/overlay_editor/src/lib.rs +++ b/examples/overlay_editor/src/lib.rs @@ -4,4 +4,28 @@ mod draw; mod geom; mod point_editor; mod sheet; +#[cfg(target_arch = "wasm32")] pub mod web; + +#[cfg(not(target_arch = "wasm32"))] +pub fn run_desktop() -> eframe::Result { + let resources = data::resource::AppResource::with_paths( + concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/boolean"), + concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/string"), + concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/stroke"), + concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/variable_stroke"), + concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/outline"), + ); + eframe::run_native( + "iOverlay Editor", + eframe::NativeOptions { + viewport: eframe::egui::ViewportBuilder::default().with_inner_size([1280.0, 800.0]), + centered: true, + ..Default::default() + }, + Box::new(move |cc| { + cc.egui_ctx.set_visuals(eframe::egui::Visuals::dark()); + Ok(Box::new(app::main::EditorApp::with_resource(resources))) + }), + ) +} diff --git a/examples/overlay_editor/src/main.rs b/examples/overlay_editor/src/main.rs index 3ad77010..bbbecbf9 100644 --- a/examples/overlay_editor/src/main.rs +++ b/examples/overlay_editor/src/main.rs @@ -1,37 +1,7 @@ -mod app; -mod data; -mod draw; -mod geom; -mod point_editor; -mod sheet; - -use crate::app::main::EditorApp; -use crate::data::resource::AppResource; -use iced::application; - #[cfg(not(target_arch = "wasm32"))] -fn main() -> iced::Result { - run_desktop() +fn main() -> eframe::Result { + overlay_editor::run_desktop() } -#[cfg(not(target_arch = "wasm32"))] -fn run_desktop() -> iced::Result { - let app_initializer = move || { - let app_resource = AppResource::with_paths( - "../tests/boolean", - "../tests/string", - "../tests/stroke", - "../tests/variable_stroke", - "../tests/outline", - ); - let app = EditorApp::with_resource(app_resource); - (app, iced::Task::none()) - }; - - application(app_initializer, EditorApp::update, EditorApp::view) - .resizable(true) - .centered() - .title("iOverlay Editor") - .subscription(EditorApp::subscription) - .run() -} +#[cfg(target_arch = "wasm32")] +fn main() {} diff --git a/examples/overlay_editor/src/point_editor/state.rs b/examples/overlay_editor/src/point_editor/state.rs index 83434033..9a0d3980 100644 --- a/examples/overlay_editor/src/point_editor/state.rs +++ b/examples/overlay_editor/src/point_editor/state.rs @@ -1,272 +1,72 @@ -use crate::point_editor::point::EditorPoint; -use iced::advanced::graphics::color::pack; -use iced::{Color, Rectangle, Transformation, Vector}; - -use crate::geom::camera::Camera; -use crate::geom::vector::VectorExt; -use crate::point_editor::widget::{PointEditUpdate, PointsEditorWidget}; -use iced::advanced::graphics::mesh::{Indexed, SolidVertex2D}; -use iced::advanced::graphics::Mesh; - -#[derive(Clone)] -pub(super) struct MeshCache { - radius: f32, - pub(super) main: Mesh, - pub(super) drag: Mesh, - pub(super) hover: Mesh, -} - -pub(super) enum SelectState { - Hover(usize), - Drag(Drag), - None, -} - -pub(super) struct Drag { - pub(super) index: usize, - editor_point: EditorPoint, - start_float: Vector, +use crate::{ + geom::{camera::Camera, vector::round_to_int}, + point_editor::{point::EditorPoint, widget::PointEditUpdate}, +}; +use eframe::egui::Vec2; + +pub(crate) struct Drag { + index: usize, + point: EditorPoint, + start: Vec2, } +#[derive(Default)] pub(crate) struct PointsEditorState { - pub(super) mesh_cache: Option, - pub(super) select: SelectState, + pub(crate) hover: Option, + drag: Option, } impl PointsEditorState { - pub(crate) fn update_mesh( - &mut self, - r: f32, - main_color: Color, - hover_color: Color, - drag_color: Color, - ) { - let radius = if let Some(cache) = &self.mesh_cache { - cache.radius - } else { - 0.0 - }; - - if (radius - r).abs() < 0.1 { - return; - } - - let sr = 1.2 * r; - - let mut main_vertices = Vec::with_capacity(4); - let mut hover_vertices = Vec::with_capacity(4); - let mut drag_vertices = Vec::with_capacity(4); - let mut indices = Vec::with_capacity(6); - let main_pack = pack(main_color); - let hover_pack = pack(hover_color); - let drag_pack = pack(drag_color); - - main_vertices.push(SolidVertex2D { - position: [0.0, r], - color: main_pack, - }); - main_vertices.push(SolidVertex2D { - position: [r, 2.0 * r], - color: main_pack, - }); - main_vertices.push(SolidVertex2D { - position: [2.0 * r, r], - color: main_pack, - }); - main_vertices.push(SolidVertex2D { - position: [r, 0.0], - color: main_pack, - }); - - hover_vertices.push(SolidVertex2D { - position: [0.0, sr], - color: hover_pack, - }); - hover_vertices.push(SolidVertex2D { - position: [r, 2.0 * sr], - color: hover_pack, - }); - hover_vertices.push(SolidVertex2D { - position: [2.0 * sr, sr], - color: hover_pack, - }); - hover_vertices.push(SolidVertex2D { - position: [sr, 0.0], - color: hover_pack, - }); - - drag_vertices.push(SolidVertex2D { - position: [0.0, r], - color: drag_pack, - }); - drag_vertices.push(SolidVertex2D { - position: [r, 2.0 * r], - color: drag_pack, - }); - drag_vertices.push(SolidVertex2D { - position: [2.0 * r, r], - color: drag_pack, - }); - drag_vertices.push(SolidVertex2D { - position: [r, 0.0], - color: drag_pack, - }); - - indices.push(0); - indices.push(1); - indices.push(2); - - indices.push(0); - indices.push(2); - indices.push(3); - - self.mesh_cache = Some(MeshCache { - radius: r, - main: Mesh::Solid { - buffers: Indexed { - vertices: main_vertices, - indices: indices.clone(), - }, - transformation: Transformation::IDENTITY, - clip_bounds: Rectangle::INFINITE, - }, - hover: Mesh::Solid { - buffers: Indexed { - vertices: hover_vertices, - indices: indices.clone(), - }, - transformation: Transformation::translate(r - sr, r - sr), - clip_bounds: Rectangle::INFINITE, - }, - drag: Mesh::Solid { - buffers: Indexed { - vertices: drag_vertices, - indices, - }, - transformation: Transformation::IDENTITY, - clip_bounds: Rectangle::INFINITE, - }, - }); + pub(crate) fn is_dragging(&self) -> bool { + self.drag.is_some() } - - pub(super) fn mouse_press( - &mut self, - widget: &PointsEditorWidget, - cursor: Vector, - ) -> bool { - let mut min_ds = widget.hover_radius * widget.hover_radius; - let mut min_index = usize::MAX; - // println!("cursor: {:?}", &cursor); - for (i, p) in widget.points.iter().enumerate() { - let view_pos = widget.camera.int_world_to_view(p.pos); - // println!("screen_pos: {:?}", &screen_pos); - let ds = Self::sqr_length(&cursor, &view_pos); - if ds <= min_ds { - min_ds = ds; - min_index = i; + pub(crate) fn selected(&self) -> Option { + self.drag.as_ref().map(|d| d.index).or(self.hover) + } + pub(crate) fn hover(&mut self, camera: Camera, points: &[EditorPoint], cursor: Vec2) { + let mut distance = 12.0_f32.powi(2); + self.hover = None; + for (index, point) in points.iter().enumerate() { + let d = (camera.int_world_to_view(point.pos) - cursor).length_sq(); + if d <= distance { + distance = d; + self.hover = Some(index); } } - - let is_catch = min_index != usize::MAX; - - if is_catch { - self.select = SelectState::Drag(Drag { - index: min_index, - start_float: cursor, - editor_point: widget.points[min_index].clone(), - }); - } - - is_catch } - - pub(super) fn mouse_release( - &mut self, - widget: &PointsEditorWidget, - cursor: Vector, - ) -> bool { - if let SelectState::Drag(_) = &self.select { - self.select = SelectState::None; - self.mouse_hover(widget.camera, widget.hover_radius, widget.points, cursor); - true - } else { - false - } + pub(crate) fn press(&mut self, camera: Camera, points: &[EditorPoint], cursor: Vec2) -> bool { + self.hover(camera, points, cursor); + self.drag = self.hover.map(|index| Drag { + index, + point: points[index].clone(), + start: cursor, + }); + self.is_dragging() } - - pub(super) fn mouse_move( - &mut self, - widget: &PointsEditorWidget, - cursor: Vector, - ) -> Option { - if let SelectState::Drag(drag) = &self.select { - Self::mouse_drag(drag, widget.camera, widget.points, cursor) - } else { - self.mouse_hover(widget.camera, widget.hover_radius, widget.points, cursor); - None - } + pub(crate) fn release(&mut self) { + self.drag = None; } - - fn mouse_drag( - drag: &Drag, + pub(crate) fn drag( + &self, camera: Camera, points: &[EditorPoint], - cursor: Vector, + cursor: Vec2, ) -> Option { - let translate = cursor - drag.start_float; - let world_dist = camera.view_distance_to_world(translate).round(); - let world_point = world_dist + drag.editor_point.pos; - let real_point = &points[drag.index]; - if world_point != real_point.pos { - return Some(PointEditUpdate { - index: drag.index, - point: EditorPoint { - pos: world_point, - index: drag.editor_point.index.clone(), - }, - }); - } - - None - } - - fn mouse_hover( - &mut self, - camera: Camera, - radius: f32, - points: &[EditorPoint], - cursor: Vector, - ) { - let mut min_ds = radius * radius; - let mut min_index = usize::MAX; - for (i, p) in points.iter().enumerate() { - let view_pos = camera.int_world_to_view(p.pos); - let ds = Self::sqr_length(&cursor, &view_pos); - if ds <= min_ds { - min_ds = ds; - min_index = i; - } - } - - if min_index == usize::MAX { - self.select = SelectState::None; - } else { - self.select = SelectState::Hover(min_index); - } - } - - fn sqr_length(a: &Vector, b: &Vector) -> f32 { - let dx = a.x - b.x; - let dy = a.y - b.y; - dx * dx + dy * dy - } -} - -impl Default for PointsEditorState { - fn default() -> Self { - Self { - select: SelectState::None, - mesh_cache: None, + let drag = self.drag.as_ref()?; + let delta = round_to_int(camera.view_distance_to_world(cursor - drag.start)); + let pos = i_triangle::i_overlay::i_float::int::point::IntPoint::new( + drag.point.pos.x.saturating_add(delta.x), + drag.point.pos.y.saturating_add(delta.y), + ); + if points.get(drag.index)?.pos == pos { + return None; } + Some(PointEditUpdate { + index: drag.index, + point: EditorPoint { + pos, + index: drag.point.index.clone(), + }, + }) } } diff --git a/examples/overlay_editor/src/point_editor/widget.rs b/examples/overlay_editor/src/point_editor/widget.rs index 374fc64c..3e6ab1fa 100644 --- a/examples/overlay_editor/src/point_editor/widget.rs +++ b/examples/overlay_editor/src/point_editor/widget.rs @@ -1,218 +1,46 @@ -use crate::geom::camera::Camera; -use crate::point_editor::point::EditorPoint; -use crate::point_editor::state::PointsEditorState; -use crate::point_editor::state::SelectState; -use iced::advanced::layout::{self, Layout}; -use iced::advanced::widget::tree; -use iced::advanced::widget::tree::State; -use iced::advanced::widget::{Tree, Widget}; -use iced::advanced::{renderer, Clipboard, Shell}; -use iced::{mouse, Color, Event, Point}; -use iced::{Element, Length, Rectangle, Renderer, Size, Theme}; +use crate::{ + app::design::Design, + geom::camera::Camera, + point_editor::{point::EditorPoint, state::PointsEditorState}, +}; +use eframe::egui::{Painter, Shape, Stroke, Vec2}; #[derive(Debug, Clone)] pub(crate) struct PointEditUpdate { - pub(crate) point: EditorPoint, pub(crate) index: usize, + pub(crate) point: EditorPoint, } -pub(crate) struct PointsEditorWidget<'a, Message> { - pub(super) points: &'a Vec, - pub(super) camera: Camera, - main_color: Color, - drag_color: Color, - hover_color: Color, - pub(super) mesh_radius: f32, - pub(super) hover_radius: f32, - on_update: Box Message + 'a>, -} +pub(crate) struct PointsEditorWidget; -impl<'a, Message> PointsEditorWidget<'a, Message> { - pub(crate) fn new( - points: &'a Vec, +impl PointsEditorWidget { + pub(crate) fn paint( + painter: &Painter, camera: Camera, - on_update: impl Fn(PointEditUpdate) -> Message + 'a, - ) -> Self { - let binding = Theme::Dark; - let palette = binding.extended_palette(); - - let (main_color, hover_color, drag_color) = if palette.is_dark { - ( - Color::WHITE, - palette.primary.base.color, - palette.primary.weak.color, - ) - } else { - ( - Color::BLACK, - palette.primary.base.color, - palette.primary.weak.color, - ) - }; - - Self { - points, - camera, - mesh_radius: 4.0, - hover_radius: 12.0, - main_color, - hover_color, - drag_color, - on_update: Box::new(on_update), - } - } - - pub(crate) fn set_hover_color(mut self, color: Color) -> Self { - self.hover_color = color; - self - } - - pub(crate) fn set_drag_color(mut self, color: Color) -> Self { - self.drag_color = color; - self - } -} - -impl Widget for PointsEditorWidget<'_, Message> { - fn tag(&self) -> tree::Tag { - tree::Tag::of::() - } - - fn state(&self) -> State { - State::new(PointsEditorState::default()) - } - - fn size(&self) -> Size { - Size { - width: Length::Fill, - height: Length::Fill, - } - } - - fn layout( - &mut self, - tree: &mut Tree, - _renderer: &Renderer, - limits: &layout::Limits, - ) -> layout::Node { - if let State::Some(state_box) = &mut tree.state { - state_box - .downcast_mut::() - .unwrap() - .update_mesh( - self.mesh_radius, - self.main_color, - self.hover_color, - self.drag_color, - ) - }; - - layout::Node::new(limits.max()) - } - - fn update( - &mut self, - tree: &mut Tree, - event: &Event, - layout: Layout<'_>, - cursor: mouse::Cursor, - _renderer: &Renderer, - _clipboard: &mut dyn Clipboard, - shell: &mut Shell<'_, Message>, - _viewport: &Rectangle, + points: &[EditorPoint], + state: &PointsEditorState, ) { - let bounds = layout.bounds(); - - let mouse_event = if let Event::Mouse(mouse_event) = event { - mouse_event - } else { - return; - }; - - let state = tree.state.downcast_mut::(); - match mouse_event { - mouse::Event::CursorMoved { position } => { - if bounds.contains(*position) { - let view_cursor = *position - bounds.position(); - if let Some(updated_point) = state.mouse_move(&*self, view_cursor) { - shell.publish((self.on_update)(updated_point)); - shell.capture_event(); - } - } - } - mouse::Event::ButtonPressed(mouse::Button::Left) => { - let position = cursor.position().unwrap_or(Point::ORIGIN); - if bounds.contains(position) { - let view_cursor = position - bounds.position(); - if state.mouse_press(&*self, view_cursor) { - shell.capture_event(); - } - } - } - mouse::Event::ButtonReleased(mouse::Button::Left) => { - let position = cursor.position().unwrap_or(Point::ORIGIN); - let view_cursor = position - bounds.position(); - if state.mouse_release(&*self, view_cursor) { - shell.capture_event(); - } - } - _ => {} + for (index, point) in points.iter().enumerate() { + let selected = state.selected() == Some(index); + let color = if selected && state.is_dragging() { + Design::accent_color() + } else if selected { + Design::negative_color() + } else { + Design::subject_color() + }; + let radius = if selected { 5.0 } else { 4.0 }; + let p = painter.clip_rect().min + camera.int_world_to_view(point.pos); + painter.add(Shape::convex_polygon( + vec![ + p + Vec2::new(-radius, 0.0), + p + Vec2::new(0.0, -radius), + p + Vec2::new(radius, 0.0), + p + Vec2::new(0.0, radius), + ], + color, + Stroke::NONE, + )); } } - - fn draw( - &self, - tree: &Tree, - renderer: &mut Renderer, - _theme: &Theme, - _style: &renderer::Style, - layout: Layout<'_>, - _cursor: mouse::Cursor, - _viewport: &Rectangle, - ) { - let state = tree.state.downcast_ref::(); - - let mesh = if let Some(mesh) = &state.mesh_cache { - mesh - } else { - return; - }; - - use iced::advanced::graphics::mesh::Renderer as _; - use iced::advanced::Renderer as _; - - let bounds = layout.bounds(); - renderer.with_layer(bounds, |renderer| { - let offset = layout.position() - Point::new(self.mesh_radius, self.mesh_radius); - - for (index, p) in self.points.iter().enumerate() { - let position = self.camera.world_to_screen(offset, p.pos); - let mesh = match &state.select { - SelectState::Hover(hover_index) => { - if index == *hover_index { - mesh.hover.clone() - } else { - mesh.main.clone() - } - } - SelectState::Drag(drag) => { - if index == drag.index { - mesh.drag.clone() - } else { - mesh.main.clone() - } - } - SelectState::None => mesh.main.clone(), - }; - - renderer.with_translation(position, |renderer| renderer.draw_mesh(mesh)); - } - }); - } -} - -impl<'a, Message: 'a> From> for Element<'a, Message> { - fn from(editor: PointsEditorWidget<'a, Message>) -> Self { - Self::new(editor) - } } diff --git a/examples/overlay_editor/src/sheet/mod.rs b/examples/overlay_editor/src/sheet/mod.rs index a73631c1..f5d327d0 100644 --- a/examples/overlay_editor/src/sheet/mod.rs +++ b/examples/overlay_editor/src/sheet/mod.rs @@ -1,2 +1,2 @@ -mod state; +pub(crate) mod state; pub(crate) mod widget; diff --git a/examples/overlay_editor/src/sheet/state.rs b/examples/overlay_editor/src/sheet/state.rs index d223d5b4..b3f3c31e 100644 --- a/examples/overlay_editor/src/sheet/state.rs +++ b/examples/overlay_editor/src/sheet/state.rs @@ -1,82 +1,42 @@ use crate::geom::camera::Camera; -use iced::mouse::ScrollDelta; -use iced::{Size, Vector}; +use eframe::egui::Vec2; -struct Drag { - start_screen: Vector, - start_world: Vector, -} - -enum DragState { - Drag(Drag), - None, -} - -pub(super) struct SheetState { - drag_state: DragState, +#[derive(Default)] +pub(crate) struct SheetState { + drag: Option<(Vec2, Vec2)>, } impl SheetState { - pub(super) fn mouse_press(&mut self, camera: Camera, view_cursor: Vector) { - self.drag_state = DragState::Drag(Drag { - start_screen: view_cursor, - start_world: camera.pos, - }); + pub(crate) fn press(&mut self, camera: Camera, cursor: Vec2) { + self.drag = Some((cursor, camera.pos)); } - - pub(super) fn mouse_release(&mut self) { - self.drag_state = DragState::None; + pub(crate) fn release(&mut self) { + self.drag = None; } - - pub(super) fn mouse_move( - &mut self, - camera: Camera, - view_cursor: Vector, - ) -> Option> { - if let DragState::Drag(drag) = &self.drag_state { - let translate = drag.start_screen - view_cursor; - let world_dist = camera.view_distance_to_world(translate); - let new_pos = Vector::new( - drag.start_world.x + world_dist.x, - drag.start_world.y + world_dist.y, - ); - Some(new_pos) - } else { - None + pub(crate) fn drag(&self, camera: &mut Camera, cursor: Vec2) { + if let Some((start, pos)) = self.drag { + camera.pos = pos + camera.view_distance_to_world(start - cursor); } } - - pub(super) fn mouse_wheel_scrolled( - &mut self, - camera: Camera, - viewport_size: Size, - delta: ScrollDelta, - view_cursor: Vector, - ) -> Option { - if let ScrollDelta::Pixels { x: _, y } = delta { - let s = 1.0 + y / viewport_size.height; - let mut new_camera = camera; - new_camera.set_scale(s * camera.scale); - - let world_pos = camera.view_to_world(view_cursor); - let new_view_pos = new_camera.world_to_view(world_pos); - - let view_distance = view_cursor - new_view_pos; - let world_distance = new_camera.view_distance_to_world(view_distance); - - new_camera.pos = new_camera.pos - world_distance; - - Some(new_camera) - } else { - None - } + pub(crate) fn zoom(camera: &mut Camera, cursor: Vec2, delta: f32) { + let world = camera.view_to_world(cursor); + camera.set_scale(camera.scale * (delta * 0.002).exp()); + camera.pos += world - camera.view_to_world(cursor); } } -impl Default for SheetState { - fn default() -> Self { - Self { - drag_state: DragState::None, - } +#[cfg(test)] +mod tests { + use super::*; + use i_triangle::i_overlay::i_float::int::rect::IntRect; + #[test] + fn zoom_preserves_world_position_under_pointer() { + let mut camera = Camera::new(IntRect::new(-100, 100, -100, 100), Vec2::new(800.0, 600.0)); + let cursor = Vec2::new(137.0, 251.0); + let before = camera.view_to_world(cursor); + SheetState::zoom(&mut camera, cursor, 120.0); + assert!((camera.view_to_world(cursor) - before).length() < 1e-4); + SheetState::zoom(&mut camera, cursor, -1e6); + assert!(camera.scale > 0.0 && camera.i_scale.is_finite()); } } diff --git a/examples/overlay_editor/src/sheet/widget.rs b/examples/overlay_editor/src/sheet/widget.rs index d63d7f89..3bf774ee 100644 --- a/examples/overlay_editor/src/sheet/widget.rs +++ b/examples/overlay_editor/src/sheet/widget.rs @@ -1,257 +1,169 @@ -use crate::geom::camera::Camera; -use crate::sheet::state::SheetState; -use iced::advanced::graphics::color::pack; -use iced::advanced::graphics::mesh::{Indexed, SolidVertex2D}; -use iced::advanced::graphics::Mesh; -use iced::advanced::layout::{self, Layout}; -use iced::advanced::widget::tree; -use iced::advanced::widget::{Tree, Widget}; -use iced::advanced::{renderer, Clipboard, Shell}; -use iced::{mouse, Event}; -use iced::{Color, Point, Transformation}; -use iced::{Element, Length, Rectangle, Renderer, Size, Theme, Vector}; - -pub(crate) struct SheetWidget<'a, Message> { - camera: Camera, - grid_color: Color, - on_size: Box Message + 'a>, - on_zoom: Box Message + 'a>, - on_drag: Box) -> Message + 'a>, -} - -impl<'a, Message: 'a> SheetWidget<'a, Message> { - pub(crate) fn new( - camera: Camera, - grid_color: Color, - on_size: impl Fn(Size) -> Message + 'a, - on_zoom: impl Fn(Camera) -> Message + 'a, - on_drag: impl Fn(Vector) -> Message + 'a, - ) -> Self { - Self { - camera, - grid_color, - on_size: Box::new(on_size), - on_zoom: Box::new(on_zoom), - on_drag: Box::new(on_drag), - } - } - - pub(super) fn is_size_changed(&self, size: Size) -> bool { - let w = (size.width - self.camera.size.width).abs(); - let h = (size.height - self.camera.size.height).abs(); - w > 0.01 || h > 0.01 - } - - fn line_mesh(&self, min_x: f32, min_y: f32, max_x: f32, max_y: f32, opacity: f32) -> Mesh { - let color_pack = pack(self.grid_color.scale_alpha(opacity)); - let mut vertices = Vec::with_capacity(4); - let mut indices = Vec::with_capacity(6); - - vertices.push(SolidVertex2D { - position: [min_x, min_y], - color: color_pack, - }); - vertices.push(SolidVertex2D { - position: [min_x, max_y], - color: color_pack, - }); - vertices.push(SolidVertex2D { - position: [max_x, max_y], - color: color_pack, - }); - vertices.push(SolidVertex2D { - position: [max_x, min_y], - color: color_pack, +use crate::{ + app::design::Design, + geom::camera::Camera, + point_editor::{point::EditorPoint, state::PointsEditorState, widget::PointEditUpdate}, + sheet::state::SheetState, +}; +use eframe::egui::{self, Color32, CursorIcon, Painter, Sense, Stroke, Vec2}; + +pub(crate) struct SheetWidget; + +impl SheetWidget { + pub(crate) fn show( + ui: &mut egui::Ui, + camera: &mut Camera, + points: &[EditorPoint], + sheet: &mut SheetState, + editor: &mut PointsEditorState, + ) -> (Painter, Option) { + let (response, painter) = ui.allocate_painter( + ui.available_size().max(Vec2::splat(1.0)), + Sense::CLICK | Sense::DRAG, + ); + painter.rect_filled(response.rect, 0.0, Color32::from_rgb(18, 18, 18)); + let mut update = None; + let (cursor, pressed, down, released, scroll) = ui.input(|i| { + ( + i.pointer.interact_pos(), + i.pointer.primary_pressed(), + i.pointer.primary_down(), + i.pointer.primary_released(), + i.smooth_scroll_delta.y, + ) }); - - indices.push(0); - indices.push(1); - indices.push(2); - - indices.push(0); - indices.push(2); - indices.push(3); - - Mesh::Solid { - buffers: Indexed { vertices, indices }, - transformation: Transformation::IDENTITY, - clip_bounds: Rectangle::INFINITE, - } - } -} - -impl Widget for SheetWidget<'_, Message> { - fn tag(&self) -> tree::Tag { - tree::Tag::of::() - } - - fn state(&self) -> tree::State { - tree::State::new(SheetState::default()) - } - - fn size(&self) -> Size { - Size { - width: Length::Fill, - height: Length::Fill, - } - } - - fn layout( - &mut self, - _tree: &mut Tree, - _renderer: &Renderer, - limits: &layout::Limits, - ) -> layout::Node { - layout::Node::new(limits.max()) - } - - fn update( - &mut self, - tree: &mut Tree, - event: &Event, - layout: Layout<'_>, - cursor: mouse::Cursor, - _renderer: &Renderer, - _clipboard: &mut dyn Clipboard, - shell: &mut Shell<'_, Message>, - _viewport: &Rectangle, - ) { - let bounds = layout.bounds(); - let size = bounds.size(); - if self.is_size_changed(size) { - shell.publish((self.on_size)(size)); - } - - let mouse_event = if let Event::Mouse(mouse_event) = event { - mouse_event - } else { - return; - }; - let state = tree.state.downcast_mut::(); - match mouse_event { - mouse::Event::CursorMoved { position } => { - if bounds.contains(*position) { - let view_cursor = *position - bounds.position(); - if let Some(drag) = state.mouse_move(self.camera, view_cursor) { - shell.publish((self.on_drag)(drag)); - shell.capture_event(); - return; - } + if let Some(pos) = cursor { + let local = pos - response.rect.min; + if response.hovered() { + editor.hover(*camera, points, local); + if pressed && !editor.press(*camera, points, local) { + sheet.press(*camera, local); } - } - mouse::Event::ButtonPressed(mouse::Button::Left) => { - let position = cursor.position().unwrap_or(Point::ORIGIN); - if bounds.contains(position) { - let view_cursor = position - bounds.position(); - state.mouse_press(self.camera, view_cursor); - shell.capture_event(); - return; + if scroll != 0.0 && !down { + SheetState::zoom(camera, local, scroll); } + } else { + editor.hover = None; } - mouse::Event::ButtonReleased(mouse::Button::Left) => { - state.mouse_release(); - shell.capture_event(); - return; - } - mouse::Event::WheelScrolled { delta } => { - let position = cursor.position().unwrap_or(Point::ORIGIN); - if bounds.contains(position) { - let cursor = position - bounds.position(); - if let Some(scale) = - state.mouse_wheel_scrolled(self.camera, bounds.size(), *delta, cursor) - { - shell.publish((self.on_zoom)(scale)); - shell.capture_event(); - return; - } + if down || released { + if editor.is_dragging() { + update = editor.drag(*camera, points, local); + } else { + sheet.drag(camera, local); } } - _ => { - // println!("other mouse event: {:?}", mouse_event); + if response.hovered() || editor.is_dragging() { + response.on_hover_cursor(if editor.selected().is_some() { + CursorIcon::PointingHand + } else { + CursorIcon::Grab + }); } } + if released || !down { + editor.release(); + sheet.release(); + } + Self::grid(&painter, *camera); + (painter, update) } - fn draw( - &self, - _tree: &Tree, - renderer: &mut Renderer, - _theme: &Theme, - _style: &renderer::Style, - layout: Layout<'_>, - _cursor: mouse::Cursor, - _viewport: &Rectangle, - ) { - const MIN_SCALE: f32 = 20.0; - let scale = self.camera.scale - MIN_SCALE; - if scale <= 0.0 { + fn grid(painter: &Painter, camera: Camera) { + if camera.scale <= 20.0 { return; } - const SCALE_RANGE: f32 = 50.0; - const INVERT_RANGE: f32 = 1.0 / SCALE_RANGE; - - // normalize scale - let s = (scale * INVERT_RANGE).min(1.0); - // stroke radius - let r = 1.0; - - use iced::advanced::graphics::mesh::Renderer as _; - use iced::advanced::Renderer as _; - - let rect = layout.bounds(); - renderer.with_layer(rect, |renderer| { - let view_min = Vector::new(0.0, 0.0); - let view_max = Vector::new(rect.width, rect.height); - - let world_min = self.camera.view_to_world(view_min); - let world_max = self.camera.view_to_world(view_max); - - let round_world_min_x = world_min.x.ceil(); - let round_world_min_y = world_min.y.ceil(); - - let round_world_max_x = world_max.x.trunc(); - let round_world_max_y = world_max.y.trunc(); - - let nfx = (round_world_max_x - round_world_min_x + 0.001) - .round() - .abs(); - let nfy = (round_world_max_y - round_world_min_y + 0.001) - .round() - .abs(); - - let nx = nfx as usize; - let ny = nfy as usize; - - let vr_mesh = self.line_mesh(-r, view_min.y, r, view_max.y, s); - let hz_mesh = self.line_mesh(view_min.x, -r, view_max.x, r, s); - - let round_view_min = self - .camera - .world_to_view(Vector::new(round_world_min_x, round_world_min_y)); - let round_view_max = self - .camera - .world_to_view(Vector::new(round_world_max_x, round_world_max_y)); - - let dx = (round_view_max.x - round_view_min.x) / nfx; - let dy = (round_view_max.y - round_view_min.y) / nfy; - - let mut position = Vector::new(round_view_min.x + rect.x, view_min.y + rect.y); - for _ in 0..=nx { - renderer.with_translation(position, |renderer| renderer.draw_mesh(vr_mesh.clone())); - position.x += dx; - } - - let mut position = Vector::new(view_min.x + rect.x, round_view_min.y + rect.y); - for _ in 0..=ny { - renderer.with_translation(position, |renderer| renderer.draw_mesh(hz_mesh.clone())); - position.y += dy; - } - }); + let alpha = ((camera.scale - 20.0) / 50.0).min(1.0) * 0.5; + let stroke = Stroke::new(1.0_f32, Design::negative_color().gamma_multiply(alpha)); + let rect = painter.clip_rect(); + let top_left = camera.view_to_world(Vec2::ZERO); + let bottom_right = camera.view_to_world(rect.size()); + // Index from the first visible line: never increment large f32 world coordinates by one. + let first_x = camera.world_to_view(Vec2::new(top_left.x.ceil(), 0.0)).x; + let first_y = camera.world_to_view(Vec2::new(0.0, top_left.y.floor())).y; + for i in 0..=((bottom_right.x - top_left.x).abs().ceil() as usize).min(4096) { + let x = rect.left() + first_x + i as f32 * camera.scale; + painter.line_segment( + [egui::pos2(x, rect.top()), egui::pos2(x, rect.bottom())], + stroke, + ); + } + for i in 0..=((top_left.y - bottom_right.y).abs().ceil() as usize).min(4096) { + let y = rect.top() + first_y + i as f32 * camera.scale; + painter.line_segment( + [egui::pos2(rect.left(), y), egui::pos2(rect.right(), y)], + stroke, + ); + } } } -impl<'a, Message: 'a> From> for Element<'a, Message> { - fn from(sheet: SheetWidget<'a, Message>) -> Self { - Self::new(sheet) +#[cfg(test)] +mod tests { + use super::*; + use crate::point_editor::point::MultiIndex; + use i_triangle::i_overlay::i_float::int::{point::IntPoint, rect::IntRect}; + + fn button(pos: egui::Pos2, pressed: bool) -> egui::Event { + egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed, + modifiers: Default::default(), + } + } + + #[test] + fn pointer_drag_edits_points_without_panning_and_background_drag_pans() { + let ctx = egui::Context::default(); + let mut camera = Camera::new(IntRect::new(-100, 100, -100, 100), Vec2::new(800.0, 600.0)); + let mut points = vec![EditorPoint { + pos: IntPoint::new(0, 0), + index: MultiIndex { + group_index: 0, + path_index: 0, + point_index: 0, + }, + }]; + let mut sheet = SheetState::default(); + let mut editor = PointsEditorState::default(); + let mut frame = |events: Vec| { + let _ = ctx.run_ui( + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + Vec2::new(800.0, 600.0), + )), + events, + ..Default::default() + }, + |ui| { + let (_, update) = + SheetWidget::show(ui, &mut camera, &points, &mut sheet, &mut editor); + if let Some(update) = update { + points[update.index] = update.point; + } + }, + ); + (camera, points[0].pos) + }; + frame(vec![]); + let center = egui::pos2(400.0, 300.0); + frame(vec![egui::Event::PointerMoved(center)]); + frame(vec![button(center, true)]); + let moved = center + Vec2::new(30.0, -15.0); + let (after, point) = frame(vec![egui::Event::PointerMoved(moved)]); + assert_eq!(after.pos, Vec2::ZERO); + assert_eq!(point, IntPoint::new(20, 10)); + frame(vec![button(moved, false)]); + let background = egui::pos2(700.0, 500.0); + frame(vec![egui::Event::PointerMoved(background)]); + frame(vec![button(background, true)]); + let (after, point) = frame(vec![egui::Event::PointerMoved( + background + Vec2::new(30.0, 15.0), + )]); + assert_eq!(after.pos, Vec2::new(-20.0, 10.0)); + assert_eq!(point, IntPoint::new(20, 10)); + frame(vec![button(background + Vec2::new(30.0, 15.0), false)]); + let (after, _) = frame(vec![egui::Event::PointerMoved(background)]); + assert_eq!(after.pos, Vec2::new(-20.0, 10.0)); } } diff --git a/examples/overlay_editor/src/web.rs b/examples/overlay_editor/src/web.rs index 662ae197..41a26ebc 100644 --- a/examples/overlay_editor/src/web.rs +++ b/examples/overlay_editor/src/web.rs @@ -1,64 +1,78 @@ -use std::panic; -use std::sync::Once; +use crate::{app::main::EditorApp, data::resource::AppResource}; use wasm_bindgen::prelude::*; #[wasm_bindgen] -pub struct WebApp {} - -static INIT_LOGGER: Once = Once::new(); +pub struct WebApp { + runner: eframe::WebRunner, +} -#[cfg(target_arch = "wasm32")] #[wasm_bindgen] impl WebApp { #[wasm_bindgen(constructor)] pub fn create() -> Self { - Self {} + console_error_panic_hook::set_once(); + let _ = console_log::init_with_level(log::Level::Debug); + Self { + runner: eframe::WebRunner::new(), + } } - #[wasm_bindgen] - pub fn start( - &mut self, + /// Keeps the existing five JSON arguments. Await the returned Promise to report startup errors. + pub async fn start( + &self, boolean_data: String, string_data: String, stroke_data: String, variable_stroke_data: String, outline_data: String, - ) { - use iced::application; - use log::info; - - use crate::app::main::EditorApp; - use crate::data::resource::AppResource; - - panic::set_hook(Box::new(console_error_panic_hook::hook)); - INIT_LOGGER.call_once(|| { - console_log::init_with_level(log::Level::Debug).expect("error initializing log"); - }); - - info!("wasm start"); - - let app_initializer = move || { - info!("wasm init"); - 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()) + ) -> Result<(), JsValue> { + let document = web_sys::window() + .and_then(|window| window.document()) + .ok_or_else(|| JsValue::from_str("Browser document is unavailable"))?; + let canvas = match document.get_element_by_id("overlay-editor-canvas") { + Some(element) => element.dyn_into::()?, + None => { + let canvas = document + .create_element("canvas")? + .dyn_into::()?; + canvas.set_id("overlay-editor-canvas"); + canvas.style().set_property("width", "100vw")?; + canvas.style().set_property("height", "100vh")?; + canvas.style().set_property("display", "block")?; + let body = document + .body() + .ok_or_else(|| JsValue::from_str("Browser body is unavailable"))?; + body.style().set_property("margin", "0")?; + body.append_child(&canvas)?; + canvas + } }; + let resource = AppResource::with_content( + &boolean_data, + &string_data, + &stroke_data, + &variable_stroke_data, + &outline_data, + ); + self.runner + .start( + canvas, + eframe::WebOptions::default(), + Box::new(move |cc| { + cc.egui_ctx.set_visuals(eframe::egui::Visuals::dark()); + Ok(Box::new(EditorApp::with_resource(resource))) + }), + ) + .await + } - application(app_initializer, EditorApp::update, EditorApp::view) - .resizable(true) - .centered() - .title("iOverlay Editor") - .subscription(EditorApp::subscription) - .run() - .unwrap(); + pub fn destroy(&self) { + self.runner.destroy(); + } +} - info!("wasm app run"); +impl Default for WebApp { + fn default() -> Self { + Self::create() } } diff --git a/examples/tests/outline/test_8.json b/examples/tests/outline/test_8.json deleted file mode 100644 index 1ff8b013..00000000 --- a/examples/tests/outline/test_8.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "scale": 100.0, - "outline": [ - [ - [53.0, 42.0], - [35.0, 66.0], - [26.0, 75.0], - [27.0, 74.0], - [53.0, 42.0] - ] - ] -} \ No newline at end of file diff --git a/examples/tests/outline/test_9.json b/examples/tests/outline/test_9.json deleted file mode 100644 index 534d4a5b..00000000 --- a/examples/tests/outline/test_9.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "scale": 100.0, - "outline": [ - [ - [0.0, 0.0], - [100.0, 0.0], - [100.0, 100.0], - [0.0, 100.0] - ], - [ - [53.0, 42.0], - [27.0, 74.0], - [26.0, 75.0], - [35.0, 66.0], - [53.0, 42.0] - ] - ] -} \ No newline at end of file diff --git a/iOverlay/Cargo.toml b/iOverlay/Cargo.toml index 9256547f..c06a6390 100644 --- a/iOverlay/Cargo.toml +++ b/iOverlay/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "i_overlay" -version = "8.1.2" +version = "9.0.0" authors = ["Nail Sharipov "] edition = "2024" rust-version = "1.88" @@ -15,8 +15,8 @@ categories = ["algorithms", "graphics", "science::geo", "mathematics", "no-std"] i_tree = { version = "^0.19.0" } i_key_sort = { version = "^0.11.0" } -i_float = { version = "^4.1.0"} -i_shape = { version = "^4.0.0"} +i_float = { version = "^5.0.0"} +i_shape = { version = "^5.0.0"} #i_float = { path = "../../iFloat"} #i_shape = { path = "../../iShape"} @@ -38,7 +38,7 @@ variable_stroke_debug = [] serde = { version = "^1.0", features = ["derive"] } serde_json = "^1.0" rand = { version = "~0.10", features = ["alloc"] } -i_float = { version = "^4.1.0", features = ["serde"] } -i_shape = { version = "^4.0.0", features = ["serde"] } +i_float = { version = "^5.0.0", features = ["serde"] } +i_shape = { version = "^5.0.0", features = ["serde"] } #i_float = { path = "../../iFloat", features = ["serde"] } #i_shape = { path = "../../iShape", features = ["serde"] } diff --git a/iOverlay/README.md b/iOverlay/README.md index 0465743d..61d7c055 100644 --- a/iOverlay/README.md +++ b/iOverlay/README.md @@ -38,6 +38,7 @@ For specialized geometry, see [iCurve](https://github.com/iShape-Rust/iCurve) fo - [LineCap](#linecap) - [LineJoin](#linejoin) - [Integer Coordinate Limits](#integer-coordinate-limits) +- [Floating-Point Coordinate Limits](#floating-point-coordinate-limits) - [FAQ](#faq) - [License](#license) @@ -214,7 +215,7 @@ let subject = int_shape![ [[20, 20], [80, 20], [80, 80], [20, 80]], ]; -let mut overlay = Overlay::with_contours(&subject, &[]); +let mut overlay = Overlay::from_subj(&subject); let result = overlay.overlay_hierarchy(OverlayRule::Subject, FillRule::EvenOdd); assert_eq!(result.shapes.shape_ranges.len(), 2); @@ -490,12 +491,53 @@ println!("result: {:?}", result); ## Buffering +Outline, stroke, and variable-width stroke geometry is built by `mesh::int`. +The `mesh::float` APIs select a scale, convert input and styles, delegate +construction to the integer core, and convert the result back. Fixed-scale +methods retain the supplied grid. Integer rounding and CORDIC arc subdivision can change individual vertices +compared with the former float builders. + +Use `IntOutlineOffset`, `IntStrokeOffset`, or `IntVariableStrokeOffset` from the +corresponding `mesh::int::{outline,stroke,variable_stroke}::offset` module. +Outline and stroke styles live in `mesh::int::style`; variable-width vertices and +styles live in `mesh::int::variable_stroke`. Bevel, clipped miter, and round joins +are supported. Stroke caps can be butt, square, round, or custom; variable-width +strokes use round caps and joins. Round geometry uses `ArcOptions`, including +configurable CORDIC rotation precision (default: 5). + +Integer construction math limits miter joins to a minimum interior angle of +5 degrees, clipping sharper corners. Float construction math retains its +1.8-degree minimum. In both modes, almost straight corners with interior angles +above 175 degrees use bevel joins by default to avoid unstable intersections of +rounded offset lines. Stroke and outline styles expose `.miter_min_turn(angle)` +to configure this minimum turn independently of the sharp-corner clipping angle: +use `Angle` in the integer API or radians in the float API. The default is 5 degrees +for both math modes. Zero disables this guard; smaller values allow less stable +intersections. + +Integer distances use input coordinate units without automatic rescaling. +Stroke radius is `ceil(max(width, 0) / 2)`; radii at most 1 are degenerate. +Use `validate_outline(&style)`, `validate_stroke(&style)`, or +`validate_variable_stroke()` for an optional conservative coordinate-range check. +Construction requires input and temporary coordinates to stay in the safe range. +All three APIs provide `*_into` methods that replace a reusable flat output buffer. + +Constant-width stroke also offers float construction math through +`StrokeStyle::math(MathMode::Float)` or `IntStrokeStyle::math(MathMode::Float)`, +with `MathMode` in `mesh::math`. Directions are stored as `UnitIntVector`; integer +coordinates and boolean operations are retained. `Integer` remains the default. + +Choose `MathMode::Integer` for cross-platform deterministic construction; +otherwise prefer `MathMode::Float` for higher precision and generally better speed. +Outline and variable-width stroke currently use Integer only. +See [stroke construction math](docs/stroke_math.md). + ### Offsetting a Path Path Example ```rust -use i_overlay::mesh::stroke::offset::StrokeOffset; -use i_overlay::mesh::style::{LineCap, LineJoin, StrokeStyle}; +use i_overlay::mesh::float::stroke::offset::StrokeOffset; +use i_overlay::mesh::float::style::{LineCap, LineJoin, StrokeStyle}; let path = [ [ 2.0, 1.0], @@ -523,8 +565,8 @@ println!("result: {:?}", shapes); Path Example ```rust -use i_overlay::mesh::outline::offset::OutlineOffset; -use i_overlay::mesh::style::{LineJoin, OutlineStyle}; +use i_overlay::mesh::float::outline::offset::OutlineOffset; +use i_overlay::mesh::float::style::{LineJoin, OutlineStyle}; let shape = vec![ vec![ @@ -600,8 +642,24 @@ to all inputs and solver strategies. Integer APIs do not check them; exceeding these bounds can cause overflow or incorrect results. Use a wider engine or rescale larger inputs. See the [range derivation and arithmetic audit](readme/integer_range.md) for details. -For float APIs, the limits apply after conversion. An explicit conservative budget -is `FloatPointAdapter::with_coordinate_bits(rect, I::BITS - 3)`. +For float APIs, the limits apply after conversion. Automatic conversion and checked +fixed-scale methods use `FloatPointAdapter::CONSERVATIVE_COORDINATE_BITS` +(`I::BITS - 3`), reserving an extra bit for rounding inside the arithmetic range. +Custom adapters must respect the integer range; use +`FloatPointAdapter::new_conservative(rect)` for the same budget. + +## Floating-Point Coordinate Limits + +Input coordinates must be finite, with absolute values at most `2^60` for `f32` +or `2^500` for `f64`. Stroke and outline bounds, including padding for widths, +offsets, joins, and caps, must also fit these limits. + +Infallible APIs panic on invalid bounds. Fixed-scale APIs return +`FixedScaleOverlayError::InvalidRect`. Scales must be positive and finite, have a +finite reciprocal in the input scalar type, and fit the coordinate budget. +A scale whose reciprocal overflows returns `ScaleTooSmall`; one exceeding the +budget returns `ScaleTooLarge`. Coordinate limits do not themselves limit scales. +Empty valid inputs remain supported. ## FAQ ### 1. When should I use `FloatOverlay`, `SingleFloatOverlay`, or `FloatOverlayGraph`? diff --git a/iOverlay/docs/stroke_math.md b/iOverlay/docs/stroke_math.md new file mode 100644 index 00000000..5a098e73 --- /dev/null +++ b/iOverlay/docs/stroke_math.md @@ -0,0 +1,95 @@ +# Mesh construction math + +The integer and floating-point styles for stroke, outline, and variable-width +stroke all accept `.math(MathMode::Float)` from `i_overlay::mesh::math`. This +selects construction arithmetic, independently of the input coordinate type. + +Use `MathMode::Integer` when cross-platform deterministic construction is +required. For end-to-end reproducibility, use `mesh::int` with identical integer +inputs, styles, integer engine, and library version. Otherwise, prefer +`MathMode::Float`: its normalization and arc construction are more accurate and +generally faster than the approximate integer math. Total stroke performance +also depends on the geometry and the boolean operation. + +`Integer` remains the default; select `Float` explicitly. The `mesh::float` +namespace selects floating-point input coordinates, not construction arithmetic. +Both input APIs support either math mode, and both modes retain integer +coordinates and boolean operations internally. + +Integer miter construction clamps the requested minimum interior angle to at +least 5 degrees and clips sharper corners. In both math modes, interior angles +above 175 degrees use bevel joins by default to avoid unstable intersections of rounded +offset lines. This uses the turn angle already computed for the join, without +additional normalization or trigonometry. Float mode retains its existing +minimum interior angle for sharp corners. +For `IntLineJoin::Miter`, the lower limit on the requested minimum is 5 degrees in +Integer mode or 0.01*pi (1.8 degrees) in Float mode; the upper limit is one +`Angle` unit below pi. The floating-point `LineJoin::Miter` style first clamps +its parameter to 0.01*pi..=0.99*pi (1.8..=178.2 degrees), before the selected +math mode applies its construction limits. Angular thresholds are quantized to +the `Angle` representation. + +Stroke and outline styles provide `.miter_min_turn(angle)` for the near-straight +bevel cutoff, independent of the sharp-corner clipping limit. It defaults to +5 degrees in both math modes. Integer styles accept `Angle`; float styles accept +radians. Construction clamps the cutoff to 0..=pi; float NaN uses the default. +Zero disables the cutoff, and smaller values allow less stable intersections of +rounded offset lines. Bevel and round joins ignore this setting. + +```rust +use i_overlay::mesh::float::{stroke::offset::StrokeOffset, style::StrokeStyle}; +use i_overlay::mesh::math::MathMode; + +let path = [[0.0, 0.0], [3.0, 4.0]]; +let style = StrokeStyle::new(1.0).math(MathMode::Float); +let result = path.stroke(style, false); +``` + +Construction dispatches once to a generic builder using `IntegerMath` or `FloatMath`. Float math +computes normalization and trigonometry in f64, then stores directions in +`UnitIntVector`. Normalization uses `UnitIntVector::normalize_with_float`, and arc +samples use `UnitIntVector::from_float_unchecked`. Scaling, custom-cap rotation, +miter intersections, integer coordinates, and the boolean engine stay integer. +Point differences are formed before float conversion to preserve local geometry +at large i64 origins; angle calculations form direction cross/dot products before +converting them to f64. + +Float arcs cache a rotation and reuse their output allocation. Floating rotation state is retained between samples; quantization of an emitted direction is not fed back into the next rotation. The step reserves angular error for f64 arithmetic and fixed-scale component truncation. The original integer endpoints are retained. `ArcOptions::max_step` still applies, but `rotation_precision` is specific to the Integer mode and ignored by Float. + +Conversion truncates fixed-scale components toward zero without checking the +integer squared norm. Float normalization and rotation are approximate: directions +may be slightly longer than one. There is no contraction step or guarantee of an +exact norm bound. Float stroke and outline bounds reserve a margin for numerical drift and +coordinate rounding. Final points still lie on the integer grid, so Float mode +does not eliminate coordinate quantization or rounding-sensitive intersections. + +The modes may produce different rounded vertices and arc tessellations. Float +mode does not promise cross-platform bitwise reproducibility. Code using +exhaustive stroke and outline style struct literals must supply the `math` and +`miter_min_turn` fields. + +Variable-width strokes also select the tangent-contact calculation through a +private `VariableStrokeMath` extension of `MeshMath`. Integer mode retains the +scaled integer square root and rounded multiply/divide calculation. Float mode +uses the same external-tangent formula in f64 and rounds local offsets before +adding integer centers. The squared-length difference is computed in integers +before conversion, preserving small positive differences near containment. +Containment and orientation predicates remain integer operations. + +Variable-width paths are consumed once with a current chain and previous section; +the float adapter converts vertices lazily. There are no intermediate path or +subsegment collections. Arc storage is reused across paths. Debug edge collection +uses the same traversal and records the same emitted geometry. The style-free +`validate_variable_stroke()` uses conservative padding valid for both math modes. + +## Validation + +- Integer mesh/arc/outline regression tests exercise the default construction mode. +- Miter regressions cover almost straight strokes, including translated Float inputs near the coordinate limit, shrinking outlines, the shared 175-degree bevel cutoff, and the Integer-only 5-degree clipping floor. +- Both math modes cover constant-width round-stroke vertex disks, path reversal, coarse caps, empty paths, duplicates, and reused flat output. +- Float arc tests check ordered samples, maximum angular gaps, approximate unit length within a numerical tolerance, and allocation reuse for i16/i32/i64. +- Large-origin i64 tests translate the input by 2^60 and compare the translated-back stroke. + +- Both modes cover outline expansion/contraction and variable-width stroke areas for i16/i32/i64, flat output, and large-origin translation. +- A deterministic randomized test compares streaming integer variable-stroke segments with the former partitioned traversal. +- Variable-width stress tests exercise both construction modes, including reversed paths and the i64 engine. diff --git a/iOverlay/src/build/string.rs b/iOverlay/src/build/string.rs index b91063fc..1c7c0946 100644 --- a/iOverlay/src/build/string.rs +++ b/iOverlay/src/build/string.rs @@ -246,7 +246,7 @@ mod tests { } fn clip_path(shape: &[IntPoint], path: &[IntPoint], invert: bool) -> Vec>> { - let mut overlay = StringOverlay::with_shape_contour(shape); + let mut overlay = StringOverlay::from_shape(shape); overlay.add_string_path(path); overlay.clip_string_lines(FillRule::NonZero, clip_rule(invert)) } diff --git a/iOverlay/src/core/divide.rs b/iOverlay/src/core/divide.rs deleted file mode 100644 index 1b61680b..00000000 --- a/iOverlay/src/core/divide.rs +++ /dev/null @@ -1,289 +0,0 @@ -use crate::geom::id_point::IdPoint; -use alloc::vec; -use alloc::vec::Vec; -use i_float::int::number::int::IntNumber; -use i_float::int::point::IntPoint; -use i_shape::int::path::IntPath; -use i_shape::int::shape::IntContour; - -struct SubPath { - last: usize, - node: IntPoint, - path: IntPath, -} - -impl SubPath { - fn start(point: IdPoint) -> Self { - Self { - last: point.id + 1, - node: point.point, - path: vec![point.point], - } - } - - fn join(&mut self, point: IdPoint, source: &IntContour) { - self.path.extend_from_slice(&source[self.last..point.id]); - self.last = point.id; - } - - fn shift(&mut self, point: IdPoint) { - self.last = point.id; - } -} - -pub trait ContourDecomposition { - fn decompose_contours(&self) -> Option>>; -} - -impl ContourDecomposition for IntContour { - fn decompose_contours(&self) -> Option>> { - if self.len() < 3 { - return None; - } - let mut id_points: Vec<_> = self - .iter() - .enumerate() - .map(|(i, &p)| IdPoint::new(i, p)) - .collect(); - - id_points.sort_unstable_by(|p0, p1| p0.point.cmp(&p1.point).then_with(|| p0.id.cmp(&p1.id))); - - let mut p0 = id_points.first().unwrap().point; - let mut anchors = Vec::new(); - let mut n = 0; - for (i, idp) in id_points.iter().enumerate().skip(1) { - if p0 == idp.point { - n += 1; - continue; - } - - if n > 0 { - anchors.extend_from_slice(&id_points[i - n - 1..i]); - n = 0; - } - - p0 = idp.point; - } - - if anchors.is_empty() { - return None; - } - - anchors.sort_by_key(|p0| p0.id); - - let mut contours = Vec::with_capacity((anchors.len() >> 1) + 1); - - let mut queue = vec![]; - - let mut i = 0; - while i < anchors.len() { - let a = anchors[i]; - let mut sub_path: SubPath = if let Some(sub_path) = queue.pop() { - sub_path - } else { - queue.push(SubPath::::start(a)); - i += 1; - continue; - }; - - if sub_path.node == a.point { - sub_path.join(a, self); - contours.push(sub_path.path); - if let Some(prev) = queue.last_mut() { - prev.shift(a); - } else { - queue.push(SubPath::::start(a)); - } - } else { - sub_path.join(a, self); - queue.push(sub_path); - queue.push(SubPath::::start(a)); - } - i += 1; - } - - let mut sub_path: SubPath = queue.pop().unwrap(); - - if sub_path.last < self.len() { - sub_path.path.extend_from_slice(&self[sub_path.last..]); - } - - let i0 = anchors.first().unwrap().id; - if i0 > 0 { - sub_path.path.extend_from_slice(&self[..i0]); - } - - contours.push(sub_path.path); - - Some(contours) - } -} - -#[cfg(test)] -mod tests { - use crate::core::divide::ContourDecomposition; - use alloc::vec; - use i_float::int::point::IntPoint; - use i_shape::int::shape::IntContour; - - #[test] - fn test_0() { - let origin = vec![ - IntPoint::new(0, 0), - IntPoint::new(0, 2), - IntPoint::new(2, 0), - IntPoint::new(4, 2), - IntPoint::new(4, 0), - IntPoint::new(2, 0), - ]; - - let contours = origin.decompose_contours().unwrap(); - - assert_eq!(contours.len(), 2); - } - - #[test] - fn test_0_rotate() { - let origin = vec![ - IntPoint::new(0, 0), - IntPoint::new(0, 2), - IntPoint::new(2, 0), - IntPoint::new(4, 2), - IntPoint::new(4, 0), - IntPoint::new(2, 0), - ]; - - for i in 0..origin.len() { - let contour = rotate(&origin, i); - let contours = contour.decompose_contours().unwrap(); - assert_eq!(contours.len(), 2); - assert_eq!(contours.iter().fold(0, |s, c| s + c.len()), origin.len()); - } - } - - #[test] - fn test_1_0() { - let origin = vec![ - IntPoint::new(0, 0), - IntPoint::new(-2, 2), - IntPoint::new(0, 2), - IntPoint::new(-2, 4), - IntPoint::new(0, 4), - IntPoint::new(-2, 6), - IntPoint::new(2, 6), - IntPoint::new(0, 4), - IntPoint::new(2, 4), - IntPoint::new(0, 2), - IntPoint::new(2, 2), - ]; - - let contours = origin.decompose_contours().unwrap(); - - assert_eq!(contours.len(), 3); - } - - #[test] - fn test_1_1() { - let origin = vec![ - IntPoint::new(-2, 4), - IntPoint::new(0, 4), - IntPoint::new(-2, 6), - IntPoint::new(2, 6), - IntPoint::new(0, 4), - IntPoint::new(2, 4), - IntPoint::new(0, 2), - IntPoint::new(2, 2), - IntPoint::new(0, 0), - IntPoint::new(-2, 2), - IntPoint::new(0, 2), - ]; - - let contours = origin.decompose_contours().unwrap(); - - assert_eq!(contours.len(), 3); - } - - #[test] - fn test_1_rotate() { - let origin = vec![ - IntPoint::new(0, 0), - IntPoint::new(-2, 2), - IntPoint::new(0, 2), - IntPoint::new(-2, 4), - IntPoint::new(0, 4), - IntPoint::new(-2, 6), - IntPoint::new(2, 6), - IntPoint::new(0, 4), - IntPoint::new(2, 4), - IntPoint::new(0, 2), - IntPoint::new(2, 2), - ]; - - let n = origin.len(); - for i in 0..n { - let contour = rotate(&origin, i); - let contours = contour.decompose_contours().unwrap(); - assert_eq!(contours.len(), 3); - let len = contours.iter().fold(0, |s, c| s + c.len()); - assert_eq!(len, n); - } - } - - #[test] - fn test_2() { - let origin = vec![ - IntPoint::new(0, 0), - IntPoint::new(-2, -1), - IntPoint::new(-2, 1), - IntPoint::new(0, 0), - IntPoint::new(-1, 2), - IntPoint::new(1, 2), - IntPoint::new(0, 0), - IntPoint::new(2, 1), - IntPoint::new(2, -1), - IntPoint::new(0, 0), - IntPoint::new(1, -2), - IntPoint::new(-1, -2), - ]; - - let contours = origin.decompose_contours().unwrap(); - assert_eq!(contours.len(), 4); - } - - #[test] - fn test_2_rotate() { - let origin = vec![ - IntPoint::new(0, 0), - IntPoint::new(-2, -1), - IntPoint::new(-2, 1), - IntPoint::new(0, 0), - IntPoint::new(-1, 2), - IntPoint::new(1, 2), - IntPoint::new(0, 0), - IntPoint::new(2, 1), - IntPoint::new(2, -1), - IntPoint::new(0, 0), - IntPoint::new(1, -2), - IntPoint::new(-1, -2), - ]; - - let n = origin.len(); - for i in 0..n { - let contour = rotate(&origin, i); - let contours = contour.decompose_contours().unwrap(); - assert_eq!(contours.len(), 4); - let len = contours.iter().fold(0, |s, c| s + c.len()); - assert_eq!(len, n); - } - } - - fn rotate(contour: &IntContour, s: usize) -> IntContour { - contour - .iter() - .cycle() - .skip(s) - .take(contour.len()) - .cloned() - .collect() - } -} diff --git a/iOverlay/src/core/extract.rs b/iOverlay/src/core/extract.rs index 1989acf7..6ba1d6c9 100644 --- a/iOverlay/src/core/extract.rs +++ b/iOverlay/src/core/extract.rs @@ -16,7 +16,7 @@ use i_float::int::number::uint::UIntNumber; use i_float::int::number::wide_int::WideIntNumber; use i_float::int::point::IntPoint; use i_float::triangle::Triangle; -use i_shape::int::path::ContourExtension; +use i_shape::int::area::UnsafeArea; use i_shape::int::shape::{IntContour, IntShapes}; use i_shape::int::simple::Simplify; @@ -337,7 +337,7 @@ impl GraphContour for IntContour { if min_output_area == I::WideUInt::ZERO { return true; } - let area = self.unsafe_area(); + let area = self.iter().copied().unsafe_area(); let abs_area = area.unsigned_abs() >> 1; abs_area >= min_output_area @@ -610,7 +610,7 @@ mod tests { ]; let mut buffer = Default::default(); - let mut overlay = Overlay::with_contours(&subj, &[]); + let mut overlay = Overlay::from_subj(&subj); let shapes_0 = overlay .build_graph_view(FillRule::NonZero) diff --git a/iOverlay/src/core/hierarchy.rs b/iOverlay/src/core/hierarchy.rs index 52bcbf7b..1185ff0b 100644 --- a/iOverlay/src/core/hierarchy.rs +++ b/iOverlay/src/core/hierarchy.rs @@ -198,9 +198,9 @@ mod tests { [[40, 40], [60, 40], [60, 60], [40, 60]], ]; - let mut overlay = Overlay::with_contours(&subject, &[]); + let mut overlay = Overlay::from_subj(&subject); let hierarchy = overlay.overlay_hierarchy(OverlayRule::Subject, FillRule::EvenOdd); - let mut regular_overlay = Overlay::with_contours(&subject, &[]); + let mut regular_overlay = Overlay::from_subj(&subject); let regular_shapes = regular_overlay.overlay(OverlayRule::Subject, FillRule::EvenOdd); assert_eq!(hierarchy.shapes.to_shapes(), regular_shapes); @@ -232,7 +232,7 @@ mod tests { [[60, 60], [70, 60], [70, 70], [60, 70]], ]; - let mut overlay = Overlay::with_contours(&subject, &[]); + let mut overlay = Overlay::from_subj(&subject); let hierarchy = overlay.overlay_hierarchy(OverlayRule::Subject, FillRule::EvenOdd); assert_eq!(hierarchy.shapes.shape_ranges, vec![0..2, 2..3, 3..4]); @@ -263,7 +263,7 @@ mod tests { [[200, 0], [210, 0], [210, 10], [200, 10]], ]; - let mut overlay = Overlay::with_contours(&subject, &[]); + let mut overlay = Overlay::from_subj(&subject); overlay.options.output_direction = ContourDirection::Clockwise; let hierarchy = overlay.overlay_hierarchy(OverlayRule::Subject, FillRule::EvenOdd); diff --git a/iOverlay/src/core/integer.rs b/iOverlay/src/core/integer.rs index 44027701..6902e395 100644 --- a/iOverlay/src/core/integer.rs +++ b/iOverlay/src/core/integer.rs @@ -20,11 +20,14 @@ //! ## Floating-point conversion //! //! These limits concern the integer coordinates after conversion, not the -//! original floating-point coordinates. For an explicit conservative bound, -//! use [`FloatPointAdapter::with_coordinate_bits`](i_float::adapter::FloatPointAdapter::with_coordinate_bits) -//! with `coordinate_bits = I::BITS - 3`. This bounds the converted magnitude by -//! `2^(N - 3)` (8,192 for `i16`), with both endpoints included. A custom unchecked -//! scale must respect the integer range too. +//! original floating-point coordinates, which have their own [limits](crate::float). +//! Automatic float APIs and checked fixed-scale APIs use the adapter's +//! conservative coordinate budget, `CONSERVATIVE_COORDINATE_BITS = I::BITS - 3`. +//! This reserves an extra bit for rounding inside the arithmetic range. +//! For a custom adapter with the same budget, use +//! [`FloatPointAdapter::new_conservative`](i_float::adapter::FloatPointAdapter::new_conservative). +//! Converted magnitudes are bounded by `2^(N - 3)` (8,192 for `i16`), including +//! both endpoints. A custom unchecked scale must respect the integer range too. use i_float::int::number::int::IntNumber; use i_key_sort::sort::key::SortKey; diff --git a/iOverlay/src/core/mod.rs b/iOverlay/src/core/mod.rs index 5ff21ca7..2caaeac4 100644 --- a/iOverlay/src/core/mod.rs +++ b/iOverlay/src/core/mod.rs @@ -1,4 +1,3 @@ -pub mod divide; pub mod edge_data; pub mod edge_overlay; pub mod extract; @@ -15,4 +14,5 @@ pub mod point_location; pub mod predicate; pub mod relate; pub mod simplify; +pub mod single; pub mod solver; diff --git a/iOverlay/src/core/overlay.rs b/iOverlay/src/core/overlay.rs index d9596309..ea221322 100644 --- a/iOverlay/src/core/overlay.rs +++ b/iOverlay/src/core/overlay.rs @@ -18,8 +18,8 @@ use crate::vector::edge::{DataVectorEdge, VectorShape}; use alloc::vec::Vec; use i_float::int::number::uint::UIntNumber; use i_float::int::point::IntPoint; -use i_shape::int::count::PointsCount; use i_shape::int::shape::{IntContour, IntShape, IntShapes}; +use i_shape::source::int::resource::IntShapeResource; use super::graph::{OverlayGraph, OverlayNode}; @@ -110,14 +110,87 @@ where } } + /// Creates an overlay from subject and clip resources, which may use different storage types. + /// Paths are interpreted as closed contours; fill rules determine their interiors. + /// Accepts contours, shapes, collections of shapes, borrowed paths, and flat buffers + /// through [`IntShapeResource`], without copying them into intermediate containers. + pub fn from_subj_and_clip(subj: &R0, clip: &R1) -> Self + where + R0: IntShapeResource + ?Sized, + R1: IntShapeResource + ?Sized, + { + Self::from_subj_and_clip_custom(subj, clip, Default::default(), Default::default()) + } + + /// Creates an overlay from resources with custom output options and solver. + pub fn from_subj_and_clip_custom( + subj: &R0, + clip: &R1, + options: IntOverlayOptions, + solver: Solver, + ) -> Self + where + R0: IntShapeResource + ?Sized, + R1: IntShapeResource + ?Sized, + { + let capacity = subj + .iter_paths() + .chain(clip.iter_paths()) + .map(|path| path.len()) + .sum(); + let mut overlay = Self::new_custom(capacity, options, solver); + overlay.add_source(subj, ShapeType::Subject); + overlay.add_source(clip, ShapeType::Clip); + overlay + } + + /// Creates an overlay from a subject resource, interpreting each path as a closed contour. + pub fn from_subj + ?Sized>(subj: &R) -> Self { + Self::from_subj_custom(subj, Default::default(), Default::default()) + } + + /// Creates a subject-only overlay with custom output options and solver. + pub fn from_subj_custom + ?Sized>( + subj: &R, + options: IntOverlayOptions, + solver: Solver, + ) -> Self { + let capacity = subj.iter_paths().map(|path| path.len()).sum(); + let mut overlay = Self::new_custom(capacity, options, solver); + overlay.add_source(subj, ShapeType::Subject); + overlay + } + + /// Adds all resource paths as closed contours without intermediate storage. + pub fn add_source + ?Sized>(&mut self, resource: &R, shape_type: ShapeType) { + for contour in resource.iter_paths() { + self.add_contour(contour, shape_type); + } + } + + /// Replaces the geometry while retaining allocated storage, options, and solver. + pub fn reinit_with_subj_and_clip(&mut self, subj: &R0, clip: &R1) + where + R0: IntShapeResource + ?Sized, + R1: IntShapeResource + ?Sized, + { + self.clear(); + self.add_source(subj, ShapeType::Subject); + self.add_source(clip, ShapeType::Clip); + } + + /// Replaces the geometry with a subject resource, retaining storage and configuration. + pub fn reinit_with_subj + ?Sized>(&mut self, subj: &R) { + self.clear(); + self.add_source(subj, ShapeType::Subject); + } + /// Creates a new `Overlay` instance and initializes it with subject and clip contours. /// - `subj`: An array of contours that together define the subject. /// - `clip`: An array of contours that together define the clip. + #[deprecated(note = "Use `from_subj_and_clip` instead.")] pub fn with_contour(subj: &[IntPoint], clip: &[IntPoint]) -> Self { - let mut overlay = Self::new(subj.len() + clip.len()); - overlay.add_contour(subj, ShapeType::Subject); - overlay.add_contour(clip, ShapeType::Clip); - overlay + Self::from_subj_and_clip(subj, clip) } /// Creates a new `Overlay` instance and initializes it with subject and clip contours. @@ -125,26 +198,22 @@ where /// - `clip`: An array of contours that together define the clip. /// - `options`: Adjust custom behavior. /// - `solver`: Type of solver to use. + #[deprecated(note = "Use `from_subj_and_clip_custom` instead.")] pub fn with_contour_custom( subj: &[IntPoint], clip: &[IntPoint], options: IntOverlayOptions, solver: Solver, ) -> Self { - let mut overlay = Self::new_custom(subj.len() + clip.len(), options, solver); - overlay.add_contour(subj, ShapeType::Subject); - overlay.add_contour(clip, ShapeType::Clip); - overlay + Self::from_subj_and_clip_custom(subj, clip, options, solver) } /// Creates a new `Overlay` instance and initializes it with subject and clip contours. /// - `subj`: An array of contours that together define the subject shape. /// - `clip`: An array of contours that together define the clip shape. + #[deprecated(note = "Use `from_subj_and_clip` instead.")] pub fn with_contours(subj: &[IntContour], clip: &[IntContour]) -> Self { - let mut overlay = Self::new(subj.points_count() + clip.points_count()); - overlay.add_contours(subj, ShapeType::Subject); - overlay.add_contours(clip, ShapeType::Clip); - overlay + Self::from_subj_and_clip(subj, clip) } /// Creates a new `Overlay` instance and initializes it with subject and clip contours. @@ -152,26 +221,22 @@ where /// - `clip`: An array of contours that together define the clip shape. /// - `options`: Adjust custom behavior. /// - `solver`: Type of solver to use. + #[deprecated(note = "Use `from_subj_and_clip_custom` instead.")] pub fn with_contours_custom( subj: &[IntContour], clip: &[IntContour], options: IntOverlayOptions, solver: Solver, ) -> Self { - let mut overlay = Self::new_custom(subj.points_count() + clip.points_count(), options, solver); - overlay.add_contours(subj, ShapeType::Subject); - overlay.add_contours(clip, ShapeType::Clip); - overlay + Self::from_subj_and_clip_custom(subj, clip, options, solver) } /// Creates a new `Overlay` instance and initializes it with subject and clip shapes. /// - `subj`: An array of shapes to be used as the subject in the overlay operation. /// - `clip`: An array of shapes to be used as the clip in the overlay operation. + #[deprecated(note = "Use `from_subj_and_clip` instead.")] pub fn with_shapes(subj: &[IntShape], clip: &[IntShape]) -> Self { - let mut overlay = Self::new(subj.points_count() + clip.points_count()); - overlay.add_shapes(subj, ShapeType::Subject); - overlay.add_shapes(clip, ShapeType::Clip); - overlay + Self::from_subj_and_clip(subj, clip) } /// Creates a new `Overlay` instance and initializes it with subject and clip shapes. @@ -179,16 +244,14 @@ where /// - `clip`: An array of shapes to be used as the clip in the overlay operation. /// - `options`: Adjust custom behavior. /// - `solver`: Type of solver to use. + #[deprecated(note = "Use `from_subj_and_clip_custom` instead.")] pub fn with_shapes_options( subj: &[IntShape], clip: &[IntShape], options: IntOverlayOptions, solver: Solver, ) -> Self { - let mut overlay = Self::new_custom(subj.points_count() + clip.points_count(), options, solver); - overlay.add_shapes(subj, ShapeType::Subject); - overlay.add_shapes(clip, ShapeType::Clip); - overlay + Self::from_subj_and_clip_custom(subj, clip, options, solver) } /// Adds a path to the overlay using an iterator, allowing for more flexible path input. @@ -218,6 +281,7 @@ where /// - `contours`: An array of `IntContour` instances to be added to the overlay. /// - `shape_type`: Specifies the role of the added paths in the overlay operation, either as `Subject` or `Clip`. #[inline] + #[deprecated(note = "Use `add_source` instead.")] pub fn add_contours(&mut self, contours: &[IntContour], shape_type: ShapeType) { for contour in contours.iter() { self.add_contour(contour, shape_type); @@ -228,17 +292,19 @@ where /// - `shape`: A reference to a `IntShape` instance to be added. /// - `shape_type`: Specifies the role of the added shape in the overlay operation, either as `Subject` or `Clip`. #[inline] + #[deprecated(note = "Use `add_source` instead.")] pub fn add_shape(&mut self, shape: &IntShape, shape_type: ShapeType) { - self.add_contours(shape, shape_type); + self.add_source(shape, shape_type); } /// Adds multiple shapes to the overlay as either subject or clip shapes. /// - `shapes`: An array of `IntShape` instances to be added to the overlay. /// - `shape_type`: Specifies the role of the added shapes in the overlay operation, either as `Subject` or `Clip`. #[inline] + #[deprecated(note = "Use `add_source` instead.")] pub fn add_shapes(&mut self, shapes: &[IntShape], shape_type: ShapeType) { for shape in shapes.iter() { - self.add_contours(shape, shape_type); + self.add_source(shape, shape_type); } } @@ -251,6 +317,7 @@ where /// - `buffer`: A buffer of `IntShapes` instances to be added to the overlay. /// - `shape_type`: Specifies the role of the added shapes in the overlay operation, either as `Subject` or `Clip`. #[inline] + #[deprecated(note = "Use `add_source` instead.")] pub fn add_flat_buffer(&mut self, buffer: &FlatContoursBuffer, shape_type: ShapeType) { for range in buffer.ranges.iter() { let contour = &buffer.points[range.clone()]; @@ -346,7 +413,7 @@ where /// /// let left_rect = [int_pnt!(0, 0), int_pnt!(0, 10), int_pnt!(10, 10), int_pnt!(10, 0)]; /// let right_rect = [int_pnt!(10, 0), int_pnt!(10, 10), int_pnt!(20, 10), int_pnt!(20, 0)]; - /// let mut overlay = Overlay::with_contour(&left_rect, &right_rect); + /// let mut overlay = Overlay::from_subj_and_clip(&left_rect, &right_rect); /// /// let result = overlay.overlay(OverlayRule::Union, FillRule::EvenOdd); /// ``` @@ -502,7 +569,7 @@ mod tests { IntPoint::new(0, 10), ]]; - let mut overlay = Overlay::with_contours(&subj, &[]); + let mut overlay = Overlay::from_subj(&subj); let result = overlay.overlay(OverlayRule::Subject, FillRule::EvenOdd); assert_eq!(result.len(), 1); @@ -529,7 +596,7 @@ mod tests { ], ]; - let mut overlay = Overlay::with_contours(&subj, &[]); + let mut overlay = Overlay::from_subj(&subj); let result = overlay.overlay(OverlayRule::Subject, FillRule::EvenOdd); assert_eq!(result.len(), 1); @@ -556,7 +623,7 @@ mod tests { ], ]; - let mut overlay = Overlay::with_contours(&subj, &[]); + let mut overlay = Overlay::from_subj(&subj); let result = overlay.overlay(OverlayRule::Subject, FillRule::EvenOdd); assert_eq!(result.len(), 1); @@ -585,7 +652,7 @@ mod tests { ], ]; - let mut overlay = Overlay::with_contours(&subj, &[]); + let mut overlay = Overlay::from_subj(&subj); let result = overlay.overlay(OverlayRule::Subject, FillRule::NonZero); assert_eq!(result.len(), 1); @@ -617,7 +684,7 @@ mod tests { ], ]; - let mut overlay = Overlay::with_contours(&subj, &[]); + let mut overlay = Overlay::from_subj(&subj); let result = overlay.overlay(OverlayRule::Subject, FillRule::NonZero); assert_eq!(result.len(), 1); @@ -643,7 +710,7 @@ mod tests { ], ]; - let mut overlay = Overlay::with_contours(&subj, &[]); + let mut overlay = Overlay::from_subj(&subj); let result = overlay.overlay(OverlayRule::Subject, FillRule::NonZero); assert_eq!(result.len(), 1); @@ -681,7 +748,7 @@ mod tests { ], ]; - let mut overlay = Overlay::with_contours(&subj, &[]); + let mut overlay = Overlay::from_subj(&subj); let result = overlay.overlay(OverlayRule::Subject, FillRule::NonZero); assert_eq!(result.len(), 1); @@ -707,7 +774,7 @@ mod tests { ], ]; - let mut overlay = Overlay::with_contours(&subj, &[]); + let mut overlay = Overlay::from_subj(&subj); let result = overlay.overlay(OverlayRule::Subject, FillRule::NonZero); assert_eq!(result.len(), 1); @@ -733,7 +800,7 @@ mod tests { ], ]; - let mut overlay = Overlay::with_contours(&subj, &[]); + let mut overlay = Overlay::from_subj(&subj); let result = overlay.overlay(OverlayRule::Subject, FillRule::NonZero); assert_eq!(result.len(), 1); @@ -759,7 +826,7 @@ mod tests { ], ]; - let mut overlay = Overlay::with_contours(&subj, &[]); + let mut overlay = Overlay::from_subj(&subj); let result = overlay.overlay(OverlayRule::Subject, FillRule::NonZero); assert_eq!(result.len(), 2); @@ -785,7 +852,7 @@ mod tests { ], ]; - let mut overlay = Overlay::with_contours(&subj, &[]); + let mut overlay = Overlay::from_subj(&subj); let result = overlay.overlay(OverlayRule::Subject, FillRule::NonZero); assert_eq!(result.len(), 1); @@ -810,7 +877,7 @@ mod tests { ], ]; - let mut overlay = Overlay::with_contours(&subj, &[]); + let mut overlay = Overlay::from_subj(&subj); let result = overlay.overlay(OverlayRule::Subject, FillRule::NonZero); assert_eq!(result.len(), 1); @@ -835,7 +902,7 @@ mod tests { ], ]; - let mut overlay = Overlay::with_contours(&subj, &[]); + let mut overlay = Overlay::from_subj(&subj); let result = overlay.overlay(OverlayRule::Subject, FillRule::NonZero); assert_eq!(result.len(), 1); @@ -860,7 +927,7 @@ mod tests { ], ]; - let mut overlay = Overlay::with_contours(&subj, &[]); + let mut overlay = Overlay::from_subj(&subj); let result = overlay.overlay(OverlayRule::Subject, FillRule::NonZero); assert_eq!(result.len(), 1); @@ -877,7 +944,7 @@ mod tests { vec![IntPoint::new(0, 0), IntPoint::new(-2, 0), IntPoint::new(0, 2)], ]; - let mut overlay = Overlay::with_contours(&subj, &[]); + let mut overlay = Overlay::from_subj(&subj); let result = overlay.overlay(OverlayRule::Subject, FillRule::NonZero); assert_eq!(result.len(), 1); @@ -889,7 +956,7 @@ mod tests { fn test_empty_input() { let subj: &[IntContour] = &[]; - let mut overlay = Overlay::with_contours(subj, &[]); + let mut overlay = Overlay::from_subj(subj); let result = overlay.overlay(OverlayRule::Subject, FillRule::NonZero); assert_eq!(result.len(), 0); diff --git a/iOverlay/src/core/relate.rs b/iOverlay/src/core/relate.rs index 3ba65ac2..0d1be28c 100644 --- a/iOverlay/src/core/relate.rs +++ b/iOverlay/src/core/relate.rs @@ -13,6 +13,7 @@ use crate::split::solver::SplitSolver; use alloc::vec::Vec; use i_float::int::point::IntPoint; use i_shape::int::shape::{IntContour, IntShape}; +use i_shape::source::int::resource::IntShapeResource; /// Overlay structure optimized for spatial predicate evaluation. /// @@ -62,6 +63,57 @@ where } } + /// Creates a predicate overlay from closed subject and clip paths using even-odd fill. + pub fn from_subj_and_clip(subj: &R0, clip: &R1) -> Self + where + R0: IntShapeResource + ?Sized, + R1: IntShapeResource + ?Sized, + { + Self::from_subj_and_clip_custom(subj, clip, FillRule::EvenOdd, Default::default()) + } + + /// Creates a predicate overlay with a custom fill rule and solver. + pub fn from_subj_and_clip_custom( + subj: &R0, + clip: &R1, + fill_rule: FillRule, + solver: Solver, + ) -> Self + where + R0: IntShapeResource + ?Sized, + R1: IntShapeResource + ?Sized, + { + let capacity = subj + .iter_paths() + .chain(clip.iter_paths()) + .map(|path| path.len()) + .sum(); + let mut overlay = Self::new(capacity); + overlay.fill_rule = fill_rule; + overlay.solver = solver; + overlay.add_source(subj, ShapeType::Subject); + overlay.add_source(clip, ShapeType::Clip); + overlay + } + + /// Adds all resource paths as closed subject or clip contours. + pub fn add_source + ?Sized>(&mut self, resource: &R, shape_type: ShapeType) { + for contour in resource.iter_paths() { + self.add_contour(contour, shape_type); + } + } + + /// Replaces geometry while retaining allocated storage, fill rule, and solver. + pub fn reinit_with_subj_and_clip(&mut self, subj: &R0, clip: &R1) + where + R0: IntShapeResource + ?Sized, + R1: IntShapeResource + ?Sized, + { + self.clear(); + self.add_source(subj, ShapeType::Subject); + self.add_source(clip, ShapeType::Clip); + } + fn evaluate>(&mut self, handler: H) -> T { if self.segments.is_empty() { return T::default(); @@ -144,6 +196,7 @@ where /// - `contours`: An array of `IntContour` instances to be added to the overlay. /// - `shape_type`: Specifies the role of the added paths in the overlay operation, either as `Subject` or `Clip`. #[inline] + #[deprecated(note = "Use `add_source` instead.")] pub fn add_contours(&mut self, contours: &[IntContour], shape_type: ShapeType) { for contour in contours.iter() { self.add_contour(contour, shape_type); @@ -154,17 +207,19 @@ where /// - `shape`: A reference to a `IntShape` instance to be added. /// - `shape_type`: Specifies the role of the added shape in the overlay operation, either as `Subject` or `Clip`. #[inline] + #[deprecated(note = "Use `add_source` instead.")] pub fn add_shape(&mut self, shape: &IntShape, shape_type: ShapeType) { - self.add_contours(shape, shape_type); + self.add_source(shape, shape_type); } /// Adds multiple shapes to the overlay as either subject or clip shapes. /// - `shapes`: An array of `IntShape` instances to be added to the overlay. /// - `shape_type`: Specifies the role of the added shapes in the overlay operation, either as `Subject` or `Clip`. #[inline] + #[deprecated(note = "Use `add_source` instead.")] pub fn add_shapes(&mut self, shapes: &[IntShape], shape_type: ShapeType) { for shape in shapes.iter() { - self.add_contours(shape, shape_type); + self.add_source(shape, shape_type); } } @@ -174,6 +229,85 @@ where } } +/// One-shot spatial predicates on closed integer shape resources using even-odd fill. +/// Use [`PredicateOverlay::from_subj_and_clip_custom`] for another fill rule or solver. +/// +/// ``` +/// use i_overlay::core::relate::IntRelate; +/// use i_overlay::i_float::int::point::IntPoint; +/// let square = [IntPoint::new(0, 0), IntPoint::new(10, 0), +/// IntPoint::new(10, 10), IntPoint::new(0, 10)]; +/// assert!(square.intersects(&square[..])); +/// ``` +pub trait IntRelate +where + R: IntShapeResource + ?Sized, + I: OverlayInt, +{ + /// Returns true if the resources share any point. + fn intersects(&self, other: &R) -> bool; + + /// Returns true if the interiors overlap. + fn interiors_intersect(&self, other: &R) -> bool; + + /// Returns true if boundaries intersect but interiors do not. + fn touches(&self, other: &R) -> bool; + + /// Returns true if the resources intersect by point coincidence only. + fn point_intersects(&self, other: &R) -> bool; + + /// Returns true if this resource is completely within the other. + fn within(&self, other: &R) -> bool; + + /// Returns true if the resources have no shared points. + fn disjoint(&self, other: &R) -> bool; + + /// Returns true if this resource completely covers the other. + fn covers(&self, other: &R) -> bool; +} + +impl IntRelate for R0 +where + R0: IntShapeResource + ?Sized, + R1: IntShapeResource + ?Sized, + I: OverlayInt, +{ + #[inline] + fn intersects(&self, other: &R1) -> bool { + PredicateOverlay::from_subj_and_clip(self, other).intersects() + } + + #[inline] + fn interiors_intersect(&self, other: &R1) -> bool { + PredicateOverlay::from_subj_and_clip(self, other).interiors_intersect() + } + + #[inline] + fn touches(&self, other: &R1) -> bool { + PredicateOverlay::from_subj_and_clip(self, other).touches() + } + + #[inline] + fn point_intersects(&self, other: &R1) -> bool { + PredicateOverlay::from_subj_and_clip(self, other).point_intersects() + } + + #[inline] + fn within(&self, other: &R1) -> bool { + PredicateOverlay::from_subj_and_clip(self, other).within() + } + + #[inline] + fn disjoint(&self, other: &R1) -> bool { + !PredicateOverlay::from_subj_and_clip(self, other).intersects() + } + + #[inline] + fn covers(&self, other: &R1) -> bool { + PredicateOverlay::from_subj_and_clip(other, self).within() + } +} + #[cfg(test)] mod tests { use super::*; @@ -250,6 +384,7 @@ mod tests { } #[test] + #[allow(deprecated)] fn test_add_contours() { let mut overlay = PredicateOverlay::new(16); let contours = vec![square(0, 0, 5), square(10, 10, 5)]; @@ -259,6 +394,7 @@ mod tests { } #[test] + #[allow(deprecated)] fn test_add_shape() { let mut overlay = PredicateOverlay::new(16); let shape = vec![square(0, 0, 10)]; @@ -268,6 +404,7 @@ mod tests { } #[test] + #[allow(deprecated)] fn test_add_shapes() { let mut overlay = PredicateOverlay::new(16); let shapes = vec![vec![square(0, 0, 5)], vec![square(20, 20, 5)]]; @@ -433,7 +570,7 @@ mod tests { // are still correctly tracked for point coincidence detection. let mut overlay = PredicateOverlay::new(32); let doughnut_shape = doughnut(0, 0, 30, 10, 10, 10); - overlay.add_shape(&doughnut_shape, ShapeType::Subject); + overlay.add_source(&doughnut_shape, ShapeType::Subject); overlay.add_contour(&diamond(15, 15, 5), ShapeType::Clip); assert!( overlay.intersects(), @@ -446,7 +583,7 @@ mod tests { // Same setup: diamond corners touch the hole boundary but don't overlap let mut overlay = PredicateOverlay::new(32); let doughnut_shape = doughnut(0, 0, 30, 10, 10, 10); - overlay.add_shape(&doughnut_shape, ShapeType::Subject); + overlay.add_source(&doughnut_shape, ShapeType::Subject); overlay.add_contour(&diamond(15, 15, 5), ShapeType::Clip); assert!(overlay.touches(), "diamond touching hole boundary should touch"); } @@ -456,7 +593,7 @@ mod tests { // Same setup: diamond only touches at boundary points, interiors don't overlap let mut overlay = PredicateOverlay::new(32); let doughnut_shape = doughnut(0, 0, 30, 10, 10, 10); - overlay.add_shape(&doughnut_shape, ShapeType::Subject); + overlay.add_source(&doughnut_shape, ShapeType::Subject); overlay.add_contour(&diamond(15, 15, 5), ShapeType::Clip); assert!( !overlay.interiors_intersect(), @@ -470,7 +607,7 @@ mod tests { // Diamond centered at (15,15) with radius 2 (corners at 13,15,17,15 etc) let mut overlay = PredicateOverlay::new(32); let doughnut_shape = doughnut(0, 0, 30, 10, 10, 10); - overlay.add_shape(&doughnut_shape, ShapeType::Subject); + overlay.add_source(&doughnut_shape, ShapeType::Subject); overlay.add_contour(&diamond(15, 15, 2), ShapeType::Clip); assert!(!overlay.intersects(), "diamond inside hole should not intersect"); assert!(!overlay.touches(), "diamond inside hole should not touch"); @@ -491,7 +628,7 @@ mod tests { ]; let mut overlay = PredicateOverlay::new(32); - overlay.add_shape(&doughnut(0, 0, 30, 10, 10, 10), ShapeType::Subject); + overlay.add_source(&doughnut(0, 0, 30, 10, 10, 10), ShapeType::Subject); overlay.add_contour(&diamond_touching_corner, ShapeType::Clip); assert!( @@ -516,7 +653,7 @@ mod tests { ]; let mut overlay = PredicateOverlay::new(32); - overlay.add_shape(&doughnut(0, 0, 30, 10, 10, 10), ShapeType::Subject); + overlay.add_source(&doughnut(0, 0, 30, 10, 10, 10), ShapeType::Subject); overlay.add_contour(&diamond_outside, ShapeType::Clip); assert!( diff --git a/iOverlay/src/core/simplify.rs b/iOverlay/src/core/simplify.rs index 12ee8b1b..e5fc1a74 100644 --- a/iOverlay/src/core/simplify.rs +++ b/iOverlay/src/core/simplify.rs @@ -13,9 +13,10 @@ use alloc::vec; use i_shape::flat::buffer::FlatContoursBuffer; use crate::segm::build::BuildSegments; -use i_shape::int::count::PointsCount; +use i_float::int::number::uint::UIntNumber; use i_shape::int::path::ContourExtension; use i_shape::int::shape::{IntContour, IntShape, IntShapes}; +use i_shape::source::int::resource::IntShapeResource; /// Trait `Simplify` provides a method to simplify geometric shapes by reducing the number of points in contours or shapes /// while preserving overall shape and topology. The method applies a minimum area threshold and a build rule to @@ -35,39 +36,15 @@ pub trait Simplify { fn simplify(&self, fill_rule: FillRule, options: IntOverlayOptions) -> IntShapes; } -impl Simplify for [IntPoint] +impl Simplify for R where I: OverlayInt, + R: IntShapeResource + ?Sized, { #[inline] fn simplify(&self, fill_rule: FillRule, options: IntOverlayOptions) -> IntShapes { - match Overlay::new_custom(self.len(), options, Default::default()).simplify_contour(self, fill_rule) { - Some(shapes) => shapes, - None => vec![vec![self.to_vec()]], - } - } -} - -impl Simplify for [IntContour] -where - I: OverlayInt, -{ - #[inline] - fn simplify(&self, fill_rule: FillRule, options: IntOverlayOptions) -> IntShapes { - match Overlay::new_custom(self.len(), options, Default::default()).simplify_shape(self, fill_rule) { - Some(shapes) => shapes, - None => vec![self.to_vec()], - } - } -} - -impl Simplify for [IntShape] -where - I: OverlayInt, -{ - #[inline] - fn simplify(&self, fill_rule: FillRule, options: IntOverlayOptions) -> IntShapes { - Overlay::new_custom(self.points_count(), options, Default::default()).simplify_shapes(self, fill_rule) + let capacity = self.iter_paths().map(|path| path.len()).sum(); + Overlay::new_custom(capacity, options, Default::default()).simplify_source(self, fill_rule) } } @@ -81,6 +58,26 @@ impl Overlay where I: OverlayInt, { + /// Simplifies a resource, reusing this overlay's storage and configuration. + /// A single contour uses the fast path when no output area filter is requested. + pub fn simplify_source + ?Sized>( + &mut self, + resource: &R, + fill_rule: FillRule, + ) -> IntShapes { + let mut paths = resource.iter_paths(); + if let Some(contour) = paths.next() + && paths.next().is_none() + && self.options.min_output_area == I::WideUInt::ZERO + { + return self + .simplify_contour(contour, fill_rule) + .unwrap_or_else(|| vec![vec![contour.to_vec()]]); + } + self.reinit_with_subj(resource); + self.overlay(OverlayRule::Subject, fill_rule) + } + /// Fast-path simplification for a single contour. /// /// Skips full overlay if the contour is already simple (no splits, no loops, no collinear issues). @@ -134,30 +131,19 @@ where contour: &[IntPoint], ) -> ContourFillDirection { let contour_clockwise = contour.is_clockwise_ordered(); - let output_clockwise = output_direction == Clockwise; - - match fill_rule { - FillRule::EvenOdd | FillRule::NonZero => { - if contour_clockwise != output_clockwise { - ContourFillDirection::Reverse - } else { - ContourFillDirection::Correct - } - } - FillRule::Positive => { - if contour_clockwise == output_clockwise { - ContourFillDirection::Correct - } else { - ContourFillDirection::Empty - } - } - FillRule::Negative => { - if contour_clockwise != output_clockwise { - ContourFillDirection::Correct - } else { - ContourFillDirection::Empty - } - } + // Fill is determined by the input winding, independently of output orientation. + let filled = match fill_rule { + FillRule::EvenOdd | FillRule::NonZero => true, + FillRule::Positive => !contour_clockwise, + FillRule::Negative => contour_clockwise, + }; + + if !filled { + ContourFillDirection::Empty + } else if contour_clockwise != (output_direction == Clockwise) { + ContourFillDirection::Reverse + } else { + ContourFillDirection::Correct } } @@ -167,14 +153,15 @@ where return self.simplify_contour(&shape[0], fill_rule); } self.clear(); - self.add_contours(shape, ShapeType::Subject); + self.add_source(shape, ShapeType::Subject); Some(self.overlay(OverlayRule::Subject, fill_rule)) } #[inline] + #[deprecated(note = "Use `simplify_source` instead.")] pub fn simplify_shapes(&mut self, shapes: &[IntShape], fill_rule: FillRule) -> IntShapes { self.clear(); - self.add_shapes(shapes, ShapeType::Subject); + self.add_source(shapes, ShapeType::Subject); self.overlay(OverlayRule::Subject, fill_rule) } @@ -203,7 +190,7 @@ where return; } } else { - self.add_flat_buffer(flat_buffer, ShapeType::Subject); + self.add_source(flat_buffer, ShapeType::Subject); self.split_solver.split_segments(&mut self.segments, &self.solver); if self.segments.is_empty() { flat_buffer.clear_and_reserve(0, 0); diff --git a/iOverlay/src/core/single.rs b/iOverlay/src/core/single.rs new file mode 100644 index 00000000..1c1857d8 --- /dev/null +++ b/iOverlay/src/core/single.rs @@ -0,0 +1,40 @@ +//! One-shot Boolean operations on integer shape resources. +use crate::core::fill_rule::FillRule; +use crate::core::integer::OverlayInt; +use crate::core::overlay::Overlay; +use crate::core::overlay_rule::OverlayRule; +use i_shape::int::shape::IntShapes; +use i_shape::source::int::resource::IntShapeResource; + +/// Boolean operations between resources with independently chosen storage types. +/// Each input path is interpreted as a closed contour. +/// +/// ``` +/// use i_overlay::core::{fill_rule::FillRule, overlay_rule::OverlayRule, single::SingleIntOverlay}; +/// use i_overlay::i_float::int::point::IntPoint; +/// +/// let square = [IntPoint::new(0, 0), IntPoint::new(10, 0), +/// IntPoint::new(10, 10), IntPoint::new(0, 10)]; +/// let result = square.overlay(&square[..], OverlayRule::Intersect, FillRule::NonZero); +/// assert_eq!(result.len(), 1); +/// ``` +pub trait SingleIntOverlay +where + R: IntShapeResource + ?Sized, + I: OverlayInt, +{ + /// Applies a Boolean operation using the supplied fill rule for both resources. + fn overlay(&self, source: &R, overlay_rule: OverlayRule, fill_rule: FillRule) -> IntShapes; +} + +impl SingleIntOverlay for R0 +where + R0: IntShapeResource + ?Sized, + R1: IntShapeResource + ?Sized, + I: OverlayInt, +{ + #[inline] + fn overlay(&self, source: &R1, overlay_rule: OverlayRule, fill_rule: FillRule) -> IntShapes { + Overlay::from_subj_and_clip(self, source).overlay(overlay_rule, fill_rule) + } +} diff --git a/iOverlay/src/float/clip.rs b/iOverlay/src/float/clip.rs index a438d002..5844e1ac 100644 --- a/iOverlay/src/float/clip.rs +++ b/iOverlay/src/float/clip.rs @@ -6,7 +6,7 @@ use crate::float::string_overlay::FloatStringOverlay; use crate::string::clip::ClipRule; use i_float::float::compatible::FloatPointCompatible; use i_shape::base::data::Paths; -use i_shape::source::resource::ShapeResource; +use i_shape::source::float::resource::ShapeResource; /// Trait for clipping float string paths by float shapes. /// diff --git a/iOverlay/src/float/hierarchy.rs b/iOverlay/src/float/hierarchy.rs index 964a70ea..68a60014 100644 --- a/iOverlay/src/float/hierarchy.rs +++ b/iOverlay/src/float/hierarchy.rs @@ -227,13 +227,15 @@ mod tests { IntPoint::new(10, 10), IntPoint::new(0, 10), ], - contour_ranges: vec![0..4], - shape_ranges: vec![0..1], + contour_ranges: core::iter::once(0..4).collect(), + shape_ranges: core::iter::once(0..1).collect(), }, links: vec![], }; - let adapter = - FloatPointAdapter::<[f64; 2], i32>::with_scale(FloatRect::new(-10.0, 20.0, -10.0, 20.0), 1.0); + let adapter = FloatPointAdapter::<[f64; 2], i32>::with_scale( + FloatRect::new(-10.0, 20.0, -10.0, 20.0).unwrap(), + 1.0, + ); let hierarchy = FloatFlatShapeHierarchy::from_int(int_hierarchy, &adapter, true, true); @@ -284,8 +286,10 @@ mod tests { }, ], }; - let adapter = - FloatPointAdapter::<[f64; 2], i32>::with_scale(FloatRect::new(-10.0, 30.0, -10.0, 30.0), 1.0); + let adapter = FloatPointAdapter::<[f64; 2], i32>::with_scale( + FloatRect::new(-10.0, 30.0, -10.0, 30.0).unwrap(), + 1.0, + ); let hierarchy = FloatFlatShapeHierarchy::from_int(int_hierarchy, &adapter, true, false); @@ -326,8 +330,10 @@ mod tests { 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 adapter = FloatPointAdapter::<[f64; 2], i32>::with_scale( + FloatRect::new(-10.0, 20.0, -10.0, 20.0).unwrap(), + 1.0, + ); let hierarchy = FloatFlatShapeHierarchy::from_int(int_hierarchy, &adapter, true, false); diff --git a/iOverlay/src/float/mod.rs b/iOverlay/src/float/mod.rs index 5d81ace5..f09340e7 100644 --- a/iOverlay/src/float/mod.rs +++ b/iOverlay/src/float/mod.rs @@ -1,3 +1,15 @@ +//! Floating-point inputs must be finite, with absolute coordinates at most +//! `2^60` for `f32` or `2^500` for `f64`. Stroke and outline bounds, including +//! their padding, must also fit. Infallible APIs panic on invalid bounds; +//! fixed-scale APIs return an error. Empty valid input remains supported. +//! +//! Automatic conversion uses the conservative coordinate budget +//! (`FloatPointAdapter::CONSERVATIVE_COORDINATE_BITS`, or `I::BITS - 3`), +//! reserving an extra bit for rounding inside the arithmetic range. Fixed-scale APIs +//! enforce the same budget and require a positive finite scale with a finite +//! reciprocal in the input scalar type. Custom adapters remain the caller's +//! responsibility; see the integer coordinate contract in [`crate::core::integer`]. + pub mod clip; pub mod graph; pub mod hierarchy; diff --git a/iOverlay/src/float/overlay.rs b/iOverlay/src/float/overlay.rs index 3bb3141f..6ecf17f4 100644 --- a/iOverlay/src/float/overlay.rs +++ b/iOverlay/src/float/overlay.rs @@ -9,7 +9,7 @@ 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 crate::i_shape::source::float::resource::ShapeResource; use core::marker::PhantomData; use i_float::adapter::FloatPointAdapter; use i_float::float::compatible::FloatPointCompatible; @@ -112,7 +112,7 @@ where #[inline] pub fn new_empty(options: OverlayOptions, solver: Solver, capacity: usize) -> Self { let clean_result = options.clean_result; - let adapter = FloatPointAdapter::new(FloatRect::zero()); + let adapter = FloatPointAdapter::new_conservative(FloatRect::zero()); let overlay = Overlay::new_custom(capacity, options.int_default(), solver); Self { overlay, @@ -150,7 +150,7 @@ where R1: ShapeResource

+ ?Sized, { let iter = subj.iter_paths().chain(clip.iter_paths()).flatten(); - let adapter = FloatPointAdapter::with_iter(iter); + let adapter = FloatPointAdapter::with_iter_conservative(iter); let subj_capacity = subj.iter_paths().fold(0, |s, c| s + c.len()); let clip_capacity = clip.iter_paths().fold(0, |s, c| s + c.len()); @@ -179,7 +179,7 @@ where R1: ShapeResource

+ ?Sized, { let iter = subj.iter_paths().chain(clip.iter_paths()).flatten(); - let adapter = FloatPointAdapter::with_iter(iter); + let adapter = FloatPointAdapter::with_iter_conservative(iter); let subj_capacity = subj.iter_paths().fold(0, |s, c| s + c.len()); let clip_capacity = clip.iter_paths().fold(0, |s, c| s + c.len()); @@ -199,7 +199,7 @@ where R: ShapeResource

+ ?Sized, { let iter = subj.iter_paths().flatten(); - let adapter = FloatPointAdapter::with_iter(iter); + let adapter = FloatPointAdapter::with_iter_conservative(iter); let subj_capacity = subj.iter_paths().fold(0, |s, c| s + c.len()); Self::with_adapter(adapter, subj_capacity).unsafe_add_source(subj, ShapeType::Subject) @@ -218,7 +218,7 @@ where R: ShapeResource

+ ?Sized, { let iter = subj.iter_paths().flatten(); - let adapter = FloatPointAdapter::with_iter(iter); + let adapter = FloatPointAdapter::with_iter_conservative(iter); let subj_capacity = subj.iter_paths().fold(0, |s, c| s + c.len()); Self::new_custom(adapter, options, solver, subj_capacity).unsafe_add_source(subj, ShapeType::Subject) @@ -299,7 +299,7 @@ where self.clear(); let iter = subj.iter_paths().chain(clip.iter_paths()).flatten(); - self.update_adapter(FloatPointAdapter::with_iter(iter)); + self.update_adapter(FloatPointAdapter::with_iter_conservative(iter)); self.add_source(subj, ShapeType::Subject); self.add_source(clip, ShapeType::Clip); } @@ -323,7 +323,7 @@ where { self.clear(); let iter = subj.iter_paths().flatten(); - self.update_adapter(FloatPointAdapter::with_iter(iter)); + self.update_adapter(FloatPointAdapter::with_iter_conservative(iter)); self.add_source(subj, ShapeType::Subject); } diff --git a/iOverlay/src/float/relate.rs b/iOverlay/src/float/relate.rs index 0145741d..013c863f 100644 --- a/iOverlay/src/float/relate.rs +++ b/iOverlay/src/float/relate.rs @@ -5,12 +5,12 @@ use crate::core::relate::PredicateOverlay; use crate::core::solver::Solver; use i_float::adapter::FloatPointAdapter; use i_float::float::compatible::FloatPointCompatible; -use i_shape::source::resource::ShapeResource; +use i_shape::source::float::resource::ShapeResource; /// Float-coordinate wrapper for spatial predicate evaluation. /// /// `FloatPredicateOverlay` handles conversion from floating-point coordinates to -/// the internal integer representation, then delegates to [`PredicateOverlay`](crate::core::relate::PredicateOverlay) +/// the internal integer representation, then delegates to [`PredicateOverlay`] /// for efficient predicate evaluation. /// /// # Example @@ -81,7 +81,7 @@ where R1: ShapeResource

+ ?Sized, { let iter = subj.iter_paths().chain(clip.iter_paths()).flatten(); - let adapter = FloatPointAdapter::<_, I>::with_iter(iter); + let adapter = FloatPointAdapter::<_, I>::with_iter_conservative(iter); let subj_capacity = subj.iter_paths().fold(0, |s, c| s + c.len()); let clip_capacity = clip.iter_paths().fold(0, |s, c| s + c.len()); @@ -106,7 +106,7 @@ where R1: ShapeResource

+ ?Sized, { let iter = subj.iter_paths().chain(clip.iter_paths()).flatten(); - let adapter = FloatPointAdapter::<_, I>::with_iter(iter); + let adapter = FloatPointAdapter::<_, I>::with_iter_conservative(iter); let subj_capacity = subj.iter_paths().fold(0, |s, c| s + c.len()); let clip_capacity = clip.iter_paths().fold(0, |s, c| s + c.len()); diff --git a/iOverlay/src/float/scale.rs b/iOverlay/src/float/scale.rs index 7476bc73..ea11e524 100644 --- a/iOverlay/src/float/scale.rs +++ b/iOverlay/src/float/scale.rs @@ -8,13 +8,18 @@ use crate::float::relate::FloatPredicateOverlay; use i_float::adapter::{FloatPointAdapter, FloatPointAdapterScaleError}; use i_float::float::compatible::FloatPointCompatible; use i_float::float::number::FloatNumber; +use i_float::float::rect::FloatRectError; use i_shape::base::data::Shapes; -use i_shape::source::resource::ShapeResource; +use i_shape::source::float::resource::ShapeResource; -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FixedScaleOverlayError { + /// Input or padded bounds violate the floating-point coordinate contract. + InvalidRect(FloatRectError), /// Requested scale is larger than the safe adapter scale for the input bounds. ScaleTooLarge, + /// Scale has a non-finite reciprocal in the input scalar type. + ScaleTooSmall, /// Requested scale is zero or negative. ScaleNonPositive, /// Requested scale is NaN or infinite. @@ -31,6 +36,9 @@ impl FixedScaleOverlayError { if s <= 0.0 { return Err(Self::ScaleNonPositive); } + if !(T::ONE / scale).is_finite() { + return Err(Self::ScaleTooSmall); + } Ok(s) } } @@ -39,6 +47,8 @@ impl From for FixedScaleOverlayError { #[inline] fn from(error: FloatPointAdapterScaleError) -> Self { match error { + FloatPointAdapterScaleError::InvalidRect(error) => Self::InvalidRect(error), + FloatPointAdapterScaleError::ScaleTooSmall => Self::ScaleTooSmall, FloatPointAdapterScaleError::ScaleTooLarge => Self::ScaleTooLarge, FloatPointAdapterScaleError::ScaleNonPositive => Self::ScaleNonPositive, FloatPointAdapterScaleError::ScaleNotFinite => Self::ScaleNotFinite, @@ -46,6 +56,12 @@ impl From for FixedScaleOverlayError { } } +impl From for FixedScaleOverlayError { + fn from(error: FloatRectError) -> Self { + Self::InvalidRect(error) + } +} + /// Trait `FixedScaleFloatOverlay` provides methods for overlay operations between various geometric entities. /// This trait supports boolean operations on contours, shapes, and collections of shapes, using customizable overlay and build rules. /// @@ -177,7 +193,7 @@ where R1: ShapeResource

+ ?Sized, { let iter = subj.iter_paths().chain(clip.iter_paths()).flatten(); - let adapter = FloatPointAdapter::with_iter_and_scale_checked(iter, scale)?; + let adapter = FloatPointAdapter::try_with_iter_and_scale_conservative(iter, scale)?; let subj_capacity = subj.iter_paths().fold(0, |s, c| s + c.len()); let clip_capacity = clip.iter_paths().fold(0, |s, c| s + c.len()); @@ -213,7 +229,7 @@ where R1: ShapeResource

+ ?Sized, { let iter = subj.iter_paths().chain(clip.iter_paths()).flatten(); - let adapter = FloatPointAdapter::with_iter_and_scale_checked(iter, scale)?; + let adapter = FloatPointAdapter::try_with_iter_and_scale_conservative(iter, scale)?; let subj_capacity = subj.iter_paths().fold(0, |s, c| s + c.len()); let clip_capacity = clip.iter_paths().fold(0, |s, c| s + c.len()); @@ -286,7 +302,7 @@ where R1: ShapeResource

+ ?Sized, { let iter = subj.iter_paths().chain(clip.iter_paths()).flatten(); - let adapter = FloatPointAdapter::with_iter_and_scale_checked(iter, scale)?; + let adapter = FloatPointAdapter::try_with_iter_and_scale_conservative(iter, scale)?; let subj_capacity = subj.iter_paths().fold(0, |s, c| s + c.len()); let clip_capacity = clip.iter_paths().fold(0, |s, c| s + c.len()); @@ -318,7 +334,7 @@ where R1: ShapeResource

+ ?Sized, { let iter = subj.iter_paths().chain(clip.iter_paths()).flatten(); - let adapter = FloatPointAdapter::with_iter_and_scale_checked(iter, scale)?; + let adapter = FloatPointAdapter::try_with_iter_and_scale_conservative(iter, scale)?; let subj_capacity = subj.iter_paths().fold(0, |s, c| s + c.len()); let clip_capacity = clip.iter_paths().fold(0, |s, c| s + c.len()); diff --git a/iOverlay/src/float/simplify.rs b/iOverlay/src/float/simplify.rs index 295aeb9a..59f2233f 100644 --- a/iOverlay/src/float/simplify.rs +++ b/iOverlay/src/float/simplify.rs @@ -5,7 +5,7 @@ use crate::core::solver::Solver; use crate::float::overlay::{FloatOverlay, OverlayOptions}; use i_float::float::compatible::FloatPointCompatible; use i_shape::base::data::Shapes; -use i_shape::source::resource::ShapeResource; +use i_shape::source::float::resource::ShapeResource; /// Trait `Simplify` provides a method to simplify geometric shapes by reducing the number of points in contours or shapes /// while preserving overall shape and topology. The method applies a minimum area threshold and a build rule to @@ -42,7 +42,7 @@ pub trait SimplifyShape { /// Simplifies the shape or collection of points, contours, or shapes, based on a specified minimum area threshold. /// - `options`: Adjust custom behavior. /// - `solver`: Type of solver to use. - /// - Returns: A collection of Shapes

that represents the simplified geometry. + /// - Returns: A collection of `Shapes

` that represents the simplified geometry. /// /// Note: Outer boundary paths have a **main_direction** order, and holes have an opposite to **main_direction** order. fn simplify_shape_custom( diff --git a/iOverlay/src/float/single.rs b/iOverlay/src/float/single.rs index a1cfa463..931600e7 100644 --- a/iOverlay/src/float/single.rs +++ b/iOverlay/src/float/single.rs @@ -4,7 +4,7 @@ use crate::core::overlay_rule::OverlayRule; use crate::float::overlay::FloatOverlay; use i_float::float::compatible::FloatPointCompatible; use i_shape::base::data::Shapes; -use i_shape::source::resource::ShapeResource; +use i_shape::source::float::resource::ShapeResource; /// Trait `SingleFloatOverlay` provides methods for overlay operations between various geometric entities. /// This trait supports boolean operations on contours, shapes, and collections of shapes, using customizable overlay and build rules. diff --git a/iOverlay/src/float/slice.rs b/iOverlay/src/float/slice.rs index 4a732606..fc04e880 100644 --- a/iOverlay/src/float/slice.rs +++ b/iOverlay/src/float/slice.rs @@ -7,7 +7,7 @@ use crate::float::string_overlay::FloatStringOverlay; use crate::string::rule::StringRule; use i_float::float::compatible::FloatPointCompatible; use i_shape::base::data::Shapes; -use i_shape::source::resource::ShapeResource; +use i_shape::source::float::resource::ShapeResource; /// The `FloatSlice` trait provides methods to slice geometric shapes using a given path or set of paths, /// allowing for boolean operations based on the specified build rule. diff --git a/iOverlay/src/float/string_overlay.rs b/iOverlay/src/float/string_overlay.rs index 53bd4113..56fdca24 100644 --- a/iOverlay/src/float/string_overlay.rs +++ b/iOverlay/src/float/string_overlay.rs @@ -9,7 +9,7 @@ use i_float::adapter::FloatPointAdapter; use i_float::float::compatible::FloatPointCompatible; use i_shape::base::data::Paths; use i_shape::float::adapter::ShapeToFloat; -use i_shape::source::resource::ShapeResource; +use i_shape::source::float::resource::ShapeResource; /// The `FloatStringOverlay` struct is a builder for overlaying geometric shapes by converting /// floating-point geometry to integer space. It provides methods for adding paths and shapes, @@ -32,8 +32,9 @@ where /// /// - `adapter`: A `FloatPointAdapter` instance responsible for coordinate conversion between /// float and integer values, ensuring accuracy during geometric transformations. - /// Use `FloatPointAdapter::with_scale` to set a fixed scale, or `FloatPointAdapter::new` - /// for automatic scaling based on bounds. + /// Use `FloatPointAdapter::with_scale` to set a fixed scale, or + /// `FloatPointAdapter::new_conservative(rect)` for automatic + /// scaling. Custom scales must respect the integer coordinate range. /// - `capacity`: Initial capacity for storing segments, ideally matching the total number of /// segments for efficient memory allocation. #[inline] @@ -63,7 +64,7 @@ where R1: ShapeResource

, { let iter = shape.iter_paths().chain(string.iter_paths()).flatten(); - let adapter = FloatPointAdapter::with_iter(iter); + let adapter = FloatPointAdapter::with_iter_conservative(iter); let shape_capacity = shape.iter_paths().fold(0, |s, c| s + c.len()); let string_capacity = string.iter_paths().fold(0, |s, c| s + c.len()); @@ -86,7 +87,7 @@ where R1: ShapeResource

, { let iter = shape.iter_paths().chain(string.iter_paths()).flatten(); - let adapter = FloatPointAdapter::with_iter_and_scale_checked(iter, scale)?; + let adapter = FloatPointAdapter::try_with_iter_and_scale_conservative(iter, scale)?; let shape_capacity = shape.iter_paths().fold(0, |s, c| s + c.len()); let string_capacity = string.iter_paths().fold(0, |s, c| s + c.len()); diff --git a/iOverlay/src/lib.rs b/iOverlay/src/lib.rs index 6db3f922..bd41fe15 100644 --- a/iOverlay/src/lib.rs +++ b/iOverlay/src/lib.rs @@ -16,6 +16,12 @@ //! engine; for example, `-16_384..=16_383` for `i16`. The full storage-type range //! is not supported. See [coordinate ranges and their derivation](core::integer). //! +//! ## Floating-point coordinate limits +//! +//! Input coordinates must be finite, with absolute values at most `2^60` for +//! `f32` or `2^500` for `f64`. Stroke and outline padding must fit these bounds +//! too. See the [floating-point contract](float) for errors and scale limits. +//! //! ## Simple Example //! ![Simple Example](https://raw.githubusercontent.com/iShape-Rust/iOverlay/main/readme/example_union.svg) //! Here's an example of performing a union operation between two polygons: diff --git a/iOverlay/src/mesh/float/mod.rs b/iOverlay/src/mesh/float/mod.rs new file mode 100644 index 00000000..79a1f1c0 --- /dev/null +++ b/iOverlay/src/mesh/float/mod.rs @@ -0,0 +1,5 @@ +//! Mesh operations on floating-point input geometry. +pub mod outline; +pub mod stroke; +pub mod style; +pub mod variable_stroke; diff --git a/iOverlay/src/mesh/float/outline/mod.rs b/iOverlay/src/mesh/float/outline/mod.rs new file mode 100644 index 00000000..01590761 --- /dev/null +++ b/iOverlay/src/mesh/float/outline/mod.rs @@ -0,0 +1 @@ +pub mod offset; diff --git a/iOverlay/src/mesh/outline/offset.rs b/iOverlay/src/mesh/float/outline/offset.rs similarity index 88% rename from iOverlay/src/mesh/outline/offset.rs rename to iOverlay/src/mesh/float/outline/offset.rs index 96c8bf95..9444425e 100644 --- a/iOverlay/src/mesh/outline/offset.rs +++ b/iOverlay/src/mesh/float/outline/offset.rs @@ -1,30 +1,24 @@ -use crate::core::extract::BooleanExtractionBuffer; use crate::core::fill_rule::FillRule; use crate::core::integer::OverlayInt; -use crate::core::overlay::ShapeType::Subject; -use crate::core::overlay::{ContourDirection, Overlay}; use crate::core::overlay_rule::OverlayRule; use crate::float::overlay::OverlayOptions; use crate::float::scale::FixedScaleOverlayError; -use crate::mesh::outline::builder::OutlineBuilder; -use crate::mesh::style::OutlineStyle; +use crate::mesh::float::style::OutlineStyle; +use crate::mesh::int::outline::BuildOutlineOverlay; +use crate::mesh::int::style::IntOutlineStyle; 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::float::rect::{FloatRect, FloatRectError}; 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::adapter::{ResourceToIntIter, ShapesToFloat}; use i_shape::float::despike::DeSpikeContour; -use i_shape::float::int_area::IntArea; use i_shape::float::simple::SimplifyContour; -use i_shape::source::resource::ShapeResource; +use i_shape::source::float::resource::ShapeResource; /// Trait for offsetting float contours and shapes. /// @@ -34,8 +28,8 @@ use i_shape::source::resource::ShapeResource; /// # Example /// /// ``` -/// use i_overlay::mesh::outline::offset::OutlineOffset; -/// use i_overlay::mesh::style::OutlineStyle; +/// use i_overlay::mesh::float::outline::offset::OutlineOffset; +/// use i_overlay::mesh::float::style::OutlineStyle; /// /// let path = [[0.0, 0.0], [10.0, 0.0], [0.0, 10.0]]; /// let style = OutlineStyle::new(1.0); @@ -229,10 +223,7 @@ where style: &OutlineStyle, options: OverlayOptions, ) -> Shapes

{ - match OutlineSolver::::prepare(self, style) { - Some(solver) => solver.build(self, options), - None => vec![], - } + self.outline_custom_as::(style, options) } fn outline_custom_into( @@ -241,10 +232,7 @@ where options: OverlayOptions, output: &mut FloatFlatContoursBuffer

, ) { - match OutlineSolver::::prepare(self, style) { - Some(solver) => solver.build_into(self, options, output), - None => output.clear_and_reserve(0, 0), - } + self.outline_custom_into_as::(style, options, output) } fn outline_fixed_scale( @@ -270,13 +258,7 @@ where options: OverlayOptions, scale: P::Scalar, ) -> Result, FixedScaleOverlayError> { - let s = FixedScaleOverlayError::validate_scale(scale)?; - let mut solver = match OutlineSolver::::prepare(self, style) { - Some(solver) => solver, - None => return Ok(vec![]), - }; - solver.apply_scale(s)?; - Ok(solver.build(self, options)) + self.outline_custom_fixed_scale_as::(style, options, scale) } fn outline_custom_fixed_scale_into( @@ -286,17 +268,7 @@ where scale: P::Scalar, output: &mut FloatFlatContoursBuffer

, ) -> Result<(), FixedScaleOverlayError> { - let s = FixedScaleOverlayError::validate_scale(scale)?; - let mut solver = match OutlineSolver::::prepare(self, style) { - Some(solver) => solver, - None => { - output.clear_and_reserve(0, 0); - return Ok(()); - } - }; - solver.apply_scale(s)?; - solver.build_into(self, options, output); - Ok(()) + self.outline_custom_fixed_scale_into_as::(style, options, scale, output) } fn outline_as(&self, style: &OutlineStyle) -> Shapes

@@ -321,9 +293,10 @@ where where I: OverlayInt + 'static, { - match OutlineSolver::::prepare(self, style) { - Some(solver) => solver.build(self, options), - None => vec![], + if let Some(solver) = OutlineSolver::::prepare(self, style).expect("Invalid offset bounds") { + solver.build(self, options) + } else { + vec![] } } @@ -335,9 +308,10 @@ where ) where I: OverlayInt + 'static, { - match OutlineSolver::::prepare(self, style) { - Some(solver) => solver.build_into(self, options, output), - None => output.clear_and_reserve(0, 0), + if let Some(solver) = OutlineSolver::::prepare(self, style).expect("Invalid offset bounds") { + solver.build_into(self, options, output) + } else { + output.clear_and_reserve(0, 0) } } @@ -374,7 +348,7 @@ where I: OverlayInt + 'static, { let s = FixedScaleOverlayError::validate_scale(scale)?; - let mut solver = match OutlineSolver::::prepare(self, style) { + let mut solver = match OutlineSolver::::prepare(self, style)? { Some(solver) => solver, None => return Ok(vec![]), }; @@ -393,7 +367,7 @@ where I: OverlayInt + 'static, { let s = FixedScaleOverlayError::validate_scale(scale)?; - let mut solver = match OutlineSolver::::prepare(self, style) { + let mut solver = match OutlineSolver::::prepare(self, style)? { Some(solver) => solver, None => { output.clear_and_reserve(0, 0); @@ -407,10 +381,8 @@ where } struct OutlineSolver { - outer_builder: OutlineBuilder, - inner_builder: OutlineBuilder, + style: OutlineStyle, adapter: FloatPointAdapter, - points_count: usize, } impl OutlineSolver @@ -418,111 +390,58 @@ where P: FloatPointCompatible + 'static, I: OverlayInt + 'static, { - fn prepare>(source: &S, style: &OutlineStyle) -> Option { - let (points_count, paths_count) = { - let mut points_count = 0; - let mut paths_count = 0; - for path in source.iter_paths() { - points_count += path.len(); - paths_count += 1; - } - (points_count, paths_count) - }; - - if paths_count == 0 { - return None; + fn prepare>( + source: &S, + style: &OutlineStyle, + ) -> Result, FloatRectError> { + if source.iter_paths().next().is_none() { + return Ok(None); } - let join = style.join.clone().normalize(); - let outer_builder: OutlineBuilder = OutlineBuilder::new(-style.outer_offset, &join); - let inner_builder: OutlineBuilder = OutlineBuilder::new(-style.inner_offset, &join); - - let outer_radius = style.outer_offset; - let inner_radius = style.inner_offset; - - let outer_additional_offset = outer_builder.additional_offset(outer_radius); - let inner_additional_offset = inner_builder.additional_offset(inner_radius); - - let additional_offset = outer_additional_offset.abs() + inner_additional_offset.abs(); - - let mut rect = FloatRect::with_iter(source.iter_paths().flatten()).unwrap_or(FloatRect::zero()); - rect.add_offset(additional_offset); - - let adapter = FloatPointAdapter::::new(rect); - - Some(Self { - outer_builder, - inner_builder, + let additional_offset = P::Scalar::from_float( + (style.outer_offset.to_f64().abs() + style.inner_offset.to_f64().abs()) + * style.join.padding_factor(), + ); + let mut rect = FloatRect::with_iter(source.iter_paths().flatten())?.unwrap_or(FloatRect::zero()); + rect.add_offset(additional_offset)?; + + let adapter = FloatPointAdapter::::new_conservative(rect); + + Ok(Some(Self { + style: OutlineStyle { + outer_offset: style.outer_offset, + inner_offset: style.inner_offset, + join: style.join.clone(), + miter_min_turn: style.miter_min_turn, + math: style.math, + }, adapter, - points_count, - }) + })) } fn apply_scale(&mut self, scale: f64) -> Result<(), FixedScaleOverlayError> { let s = P::Scalar::from_float(scale); - self.adapter = FloatPointAdapter::try_with_scale(*self.adapter.rect(), s)?; + self.adapter = FloatPointAdapter::try_with_scale_conservative(*self.adapter.rect(), s)?; Ok(()) } - fn build_overlay>( - &self, - source: &S, - options: OverlayOptions, - ) -> Overlay { - let total_capacity = self.outer_builder.capacity(self.points_count); - let mut overlay = Overlay::new_custom( - total_capacity, - options.int_with_adapter(&self.adapter), - Default::default(), - ); - - let mut offset_overlay = Overlay::new(16); - offset_overlay.options = overlay.options; - - let mut segments = Vec::new(); - let mut bool_buffer = BooleanExtractionBuffer::default(); - let mut flat_buffer = FlatContoursBuffer::::with_capacity(0); - - for path in source.iter_paths() { - let area = path.unsafe_int_area(&self.adapter); - if area.unsigned_abs() <= ::from_u64(1) { - // ignore degenerate paths - continue; - } - - offset_overlay.clear(); - segments.clear(); - - let contour_fill_rule = if area > I::Wide::ZERO { - offset_overlay.options.output_direction = ContourDirection::CounterClockwise; - segments.reserve(self.outer_builder.capacity(path.len())); - self.outer_builder.build(path, &self.adapter, &mut segments); - FillRule::Positive - } else { - offset_overlay.options.output_direction = ContourDirection::Clockwise; - segments.reserve(self.inner_builder.capacity(path.len())); - self.inner_builder.build(path, &self.adapter, &mut segments); - - FillRule::Negative - }; - - offset_overlay.add_segments(&segments); - - if let Some(graph) = offset_overlay.build_graph_view(contour_fill_rule) { - graph.extract_contours_into(OverlayRule::Subject, &mut bool_buffer, &mut flat_buffer); - } - - overlay.add_flat_buffer(&flat_buffer, Subject); + fn int_style(&self) -> IntOutlineStyle { + IntOutlineStyle { + outer_offset: self.adapter.round_len_to_int(self.style.outer_offset), + inner_offset: self.adapter.round_len_to_int(self.style.inner_offset), + join: (&self.style.join).into(), + miter_min_turn: super::super::style::miter_min_turn_angle(self.style.miter_min_turn), + math: self.style.math, } - - overlay } fn build>(self, source: &S, options: OverlayOptions) -> Shapes

{ let preserve_output_collinear = options.preserve_output_collinear; let clean_result = options.clean_result; - let mut overlay = self.build_overlay(source, options); - let shapes = overlay.overlay(OverlayRule::Subject, FillRule::Positive); + let iter_int_paths = source.iter_int_paths(&self.adapter); + let shapes = iter_int_paths + .build_overlay(&self.int_style(), options.int_with_adapter(&self.adapter)) + .overlay(OverlayRule::Subject, FillRule::Positive); if clean_result { let mut float = shapes.to_float(&self.adapter); @@ -545,10 +464,11 @@ where ) { let preserve_output_collinear = options.preserve_output_collinear; let clean_result = options.clean_result; - let mut overlay = self.build_overlay(source, options); - + let iter_int_paths = source.iter_int_paths(&self.adapter); let mut int_output = FlatContoursBuffer::::with_capacity(0); - overlay.overlay_into(OverlayRule::Subject, FillRule::Positive, &mut int_output); + iter_int_paths + .build_overlay(&self.int_style(), options.int_with_adapter(&self.adapter)) + .overlay_into(OverlayRule::Subject, FillRule::Positive, &mut int_output); let iter = int_output.points.iter().map(|p| self.adapter.int_to_float(p)); output.set_with_iter(iter, &int_output.ranges); @@ -566,8 +486,8 @@ where mod tests { use crate::core::fill_rule::FillRule; use crate::float::simplify::SimplifyShape; - use crate::mesh::outline::offset::OutlineOffset; - use crate::mesh::style::{LineJoin, OutlineStyle}; + use crate::mesh::float::outline::offset::OutlineOffset; + use crate::mesh::float::style::{LineJoin, OutlineStyle}; use alloc::vec; use alloc::vec::Vec; use core::f32::consts::PI; @@ -1107,7 +1027,8 @@ mod tests { assert_eq!(shape.len(), 1); let path = shape.first().unwrap(); - assert_eq!(path.len(), 8); + assert_eq!(path.len(), 4); + assert!(path.iter().all(|p| p[0].abs() == 11.0 && p[1].abs() == 11.0)); let result_sign = path.area().signum(); assert_eq!(original_sign, result_sign); diff --git a/iOverlay/src/mesh/float/stroke/mod.rs b/iOverlay/src/mesh/float/stroke/mod.rs new file mode 100644 index 00000000..01590761 --- /dev/null +++ b/iOverlay/src/mesh/float/stroke/mod.rs @@ -0,0 +1 @@ +pub mod offset; diff --git a/iOverlay/src/mesh/stroke/offset.rs b/iOverlay/src/mesh/float/stroke/offset.rs similarity index 87% rename from iOverlay/src/mesh/stroke/offset.rs rename to iOverlay/src/mesh/float/stroke/offset.rs index 3b18c960..cf88aa0b 100644 --- a/iOverlay/src/mesh/stroke/offset.rs +++ b/iOverlay/src/mesh/float/stroke/offset.rs @@ -1,27 +1,25 @@ 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::i_shape::source::resource::ShapeResource; -use crate::mesh::stroke::builder::StrokeBuilder; -use crate::mesh::stroke::offset::vec::Vec; -use crate::mesh::style::StrokeStyle; +use crate::mesh::float::style::StrokeStyle; +use crate::mesh::int::stroke::build_stroke_overlay_iter; use alloc::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::float::rect::{FloatRect, FloatRectError}; 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::adapter::{ResourceToIntIter, ShapesToFloat}; use i_shape::float::despike::DeSpikeContour; use i_shape::float::simple::SimplifyContour; +use i_shape::source::float::resource::ShapeResource; /// Trait for generating stroke outlines from float paths. /// @@ -31,8 +29,8 @@ use i_shape::float::simple::SimplifyContour; /// # Example /// /// ``` -/// use i_overlay::mesh::stroke::offset::StrokeOffset; -/// use i_overlay::mesh::style::StrokeStyle; +/// use i_overlay::mesh::float::stroke::offset::StrokeOffset; +/// use i_overlay::mesh::float::style::StrokeStyle; /// /// let path = [[0.0, 0.0], [10.0, 0.0]]; /// let style = StrokeStyle::new(2.0); @@ -259,48 +257,42 @@ where self.stroke_custom_into(style, is_closed_path, Default::default(), output) } - fn stroke_fixed_scale( + fn stroke_custom( &self, style: StrokeStyle

, is_closed_path: bool, - scale: P::Scalar, - ) -> Result, FixedScaleOverlayError> { - self.stroke_custom_fixed_scale(style, is_closed_path, Default::default(), scale) + options: OverlayOptions, + ) -> Shapes

{ + self.stroke_custom_as::(style, is_closed_path, options) } - fn stroke_fixed_scale_into( + fn stroke_custom_into( &self, style: StrokeStyle

, is_closed_path: bool, - scale: P::Scalar, + options: OverlayOptions, output: &mut FloatFlatContoursBuffer

, - ) -> Result<(), FixedScaleOverlayError> { - self.stroke_custom_fixed_scale_into(style, is_closed_path, Default::default(), scale, output) + ) { + self.stroke_custom_into_as::(style, is_closed_path, options, output) } - fn stroke_custom( + fn stroke_fixed_scale( &self, style: StrokeStyle

, is_closed_path: bool, - options: OverlayOptions, - ) -> Shapes

{ - match StrokeSolver::::prepare(self, style) { - Some(solver) => solver.build(self, is_closed_path, options), - None => vec![], - } + scale: P::Scalar, + ) -> Result, FixedScaleOverlayError> { + self.stroke_custom_fixed_scale(style, is_closed_path, Default::default(), scale) } - fn stroke_custom_into( + fn stroke_fixed_scale_into( &self, style: StrokeStyle

, is_closed_path: bool, - options: OverlayOptions, + scale: P::Scalar, output: &mut FloatFlatContoursBuffer

, - ) { - match StrokeSolver::::prepare(self, style) { - Some(solver) => solver.build_into(self, is_closed_path, options, output), - None => output.clear_and_reserve(0, 0), - } + ) -> Result<(), FixedScaleOverlayError> { + self.stroke_custom_fixed_scale_into(style, is_closed_path, Default::default(), scale, output) } fn stroke_custom_fixed_scale( @@ -310,12 +302,7 @@ where options: OverlayOptions, scale: P::Scalar, ) -> Result, FixedScaleOverlayError> { - let mut solver = match StrokeSolver::::prepare(self, style) { - Some(solver) => solver, - None => return Ok(vec![]), - }; - solver.apply_scale(scale)?; - Ok(solver.build(self, is_closed_path, options)) + self.stroke_custom_fixed_scale_as::(style, is_closed_path, options, scale) } fn stroke_custom_fixed_scale_into( @@ -326,16 +313,7 @@ where scale: P::Scalar, output: &mut FloatFlatContoursBuffer

, ) -> Result<(), FixedScaleOverlayError> { - let mut solver = match StrokeSolver::::prepare(self, style) { - Some(solver) => solver, - None => { - output.clear_and_reserve(0, 0); - return Ok(()); - } - }; - solver.apply_scale(scale)?; - solver.build_into(self, is_closed_path, options, output); - Ok(()) + self.stroke_custom_fixed_scale_into_as::(style, is_closed_path, options, scale, output) } fn stroke_as(&self, style: StrokeStyle

, is_closed_path: bool) -> Shapes

@@ -365,7 +343,7 @@ where where I: OverlayInt + 'static, { - match StrokeSolver::::prepare(self, style) { + match StrokeSolver::::prepare(self, style).expect("Invalid offset bounds") { Some(solver) => solver.build(self, is_closed_path, options), None => vec![], } @@ -380,7 +358,7 @@ where ) where I: OverlayInt + 'static, { - match StrokeSolver::::prepare(self, style) { + match StrokeSolver::::prepare(self, style).expect("Invalid offset bounds") { Some(solver) => solver.build_into(self, is_closed_path, options, output), None => output.clear_and_reserve(0, 0), } @@ -421,7 +399,8 @@ where where I: OverlayInt + 'static, { - let mut solver = match StrokeSolver::::prepare(self, style) { + FixedScaleOverlayError::validate_scale(scale)?; + let mut solver = match StrokeSolver::::prepare(self, style)? { Some(solver) => solver, None => return Ok(vec![]), }; @@ -440,7 +419,8 @@ where where I: OverlayInt + 'static, { - let mut solver = match StrokeSolver::::prepare(self, style) { + FixedScaleOverlayError::validate_scale(scale)?; + let mut solver = match StrokeSolver::::prepare(self, style)? { Some(solver) => solver, None => { output.clear_and_reserve(0, 0); @@ -455,10 +435,8 @@ where struct StrokeSolver { r: P::Scalar, - builder: StrokeBuilder, + style: StrokeStyle

, adapter: FloatPointAdapter, - paths_count: usize, - points_count: usize, } impl StrokeSolver @@ -466,37 +444,26 @@ where P: 'static + FloatPointCompatible, I: OverlayInt + 'static, { - fn prepare>(source: &S, style: StrokeStyle

) -> Option { - let mut paths_count = 0; - let mut points_count = 0; - for path in source.iter_paths() { - paths_count += 1; - points_count += path.len(); - } - - if paths_count == 0 { - return None; + fn prepare>( + source: &S, + style: StrokeStyle

, + ) -> Result, FloatRectError> { + if source.iter_paths().next().is_none() { + return Ok(None); } let r = P::Scalar::from_float(0.5 * style.width.to_f64()); - let builder = StrokeBuilder::::new(style); - let a = builder.additional_offset(r); + let a = style.padding(); - let mut rect = FloatRect::with_iter(source.iter_paths().flatten()).unwrap_or(FloatRect::zero()); - rect.add_offset(a); - let adapter = FloatPointAdapter::::new(rect); + let mut rect = FloatRect::with_iter(source.iter_paths().flatten())?.unwrap_or(FloatRect::zero()); + rect.add_offset(a)?; + let adapter = FloatPointAdapter::::new_conservative(rect); - Some(Self { - r, - builder, - adapter, - paths_count, - points_count, - }) + Ok(Some(Self { r, style, adapter })) } fn apply_scale(&mut self, scale: P::Scalar) -> Result<(), FixedScaleOverlayError> { - self.adapter = FloatPointAdapter::try_with_scale(*self.adapter.rect(), scale)?; + self.adapter = FloatPointAdapter::try_with_scale_conservative(*self.adapter.rect(), scale)?; Ok(()) } @@ -512,20 +479,16 @@ where return vec![]; } - let capacity = self - .builder - .capacity(self.paths_count, self.points_count, is_closed_path); - let mut segments = Vec::with_capacity(capacity); - - for path in source.iter_paths() { - self.builder - .build(path, is_closed_path, &self.adapter, &mut segments); - } - - let mut overlay = Overlay::with_segments(segments); - overlay.options = options.int_with_adapter(&self.adapter); + let iter_int_paths = source.iter_int_paths(&self.adapter); - let shapes = overlay.overlay(OverlayRule::Subject, FillRule::Positive); + let style = self.style.to_int(&self.adapter); + let shapes = build_stroke_overlay_iter( + iter_int_paths, + &style, + is_closed_path, + options.int_with_adapter(&self.adapter), + ) + .overlay(OverlayRule::Subject, FillRule::Positive); let mut float = shapes.to_float(&self.adapter); @@ -554,21 +517,17 @@ where return; } - let capacity = self - .builder - .capacity(self.paths_count, self.points_count, is_closed_path); - let mut segments = Vec::with_capacity(capacity); - - for path in source.iter_paths() { - self.builder - .build(path, is_closed_path, &self.adapter, &mut segments); - } - - let mut overlay = Overlay::with_segments(segments); - overlay.options = options.int_with_adapter(&self.adapter); + let iter_int_paths = source.iter_int_paths(&self.adapter); + let style = self.style.to_int(&self.adapter); let mut int_output = FlatContoursBuffer::::with_capacity(0); - overlay.overlay_into(OverlayRule::Subject, FillRule::Positive, &mut int_output); + build_stroke_overlay_iter( + iter_int_paths, + &style, + is_closed_path, + options.int_with_adapter(&self.adapter), + ) + .overlay_into(OverlayRule::Subject, FillRule::Positive, &mut int_output); let iter = int_output.points.iter().map(|p| self.adapter.int_to_float(p)); output.set_with_iter(iter, &int_output.ranges); @@ -585,13 +544,40 @@ where #[cfg(test)] mod tests { - use crate::mesh::stroke::offset::StrokeOffset; - use crate::mesh::style::{LineCap, LineJoin, StrokeStyle}; + use crate::mesh::float::stroke::offset::StrokeOffset; + use crate::mesh::float::style::{LineCap, LineJoin, StrokeStyle}; + use alloc::rc::Rc; use alloc::vec; use alloc::vec::Vec; use core::f32::consts::PI; use i_shape::flat::float::FloatFlatContoursBuffer; + #[test] + fn test_custom_cap_extent_is_included_in_stroke_bounds() { + let path = [[0.0_f64, 0.0], [10.0, 0.0]]; + // The template has zero width and height, but extends 100 radii + // from the endpoint after rotation and translation. + let cap = LineCap::Custom(Rc::from(vec![[100.0, 0.0]])); + let style = StrokeStyle::new(2.0).start_cap(cap.clone()).end_cap(cap); + + let shapes = path.stroke(style, false); + + assert_eq!(shapes.len(), 1); + assert_eq!(shapes[0].len(), 1); + let contour = &shapes[0][0]; + assert_eq!(contour.len(), 6); + for point in [ + [-100.0, 0.0], + [0.0, -1.0], + [10.0, -1.0], + [110.0, 0.0], + [10.0, 1.0], + [0.0, 1.0], + ] { + assert!(contour.contains(&point), "missing cap/stroke vertex: {point:?}"); + } + } + #[test] fn test_doc() { let path = [ diff --git a/iOverlay/src/mesh/float/style.rs b/iOverlay/src/mesh/float/style.rs new file mode 100644 index 00000000..ab558426 --- /dev/null +++ b/iOverlay/src/mesh/float/style.rs @@ -0,0 +1,328 @@ +use crate::mesh::int::arc::ArcOptions; +use crate::mesh::int::style::{IntLineCap, IntLineJoin, IntStrokeStyle}; +use crate::mesh::math::MathMode; +use alloc::rc::Rc; +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::int::angle::Angle; +use i_float::int::number::int::IntNumber; +use i_float::int::point::IntPoint; + +pub(super) fn angle_from_radians(radians: T) -> Angle { + Angle::from_radians(radians).unwrap_or_else(|| { + // Normalization clamps infinities, but preserves NaN. Use the same + // minimum as floating-point joins and caps instead of panicking. + Angle::from_radians(T::from_float(0.01 * PI)).expect("minimum style angle is finite") + }) +} + +pub(super) fn miter_min_turn_angle(radians: T) -> Angle { + let angle = radians.to_f64(); + let angle = if angle.is_nan() { + PI / 36.0 + } else { + angle.clamp(0.0, PI) + }; + angle_from_radians(angle) +} + +/// The endpoint style of a line. +#[derive(Debug, Clone)] +pub enum LineCap { + /// A line with a squared-off end. This is the default. + Butt, + /// A line with a rounded end. The line ends with a semicircular arc with a radius of 1/2 the line’s width, centered on the endpoint. + /// Takes a parameter `Angle` in radians. + Round(P::Scalar), + /// A line with a squared-off end. An extended distance equal to half the line width. + Square, + /// Set a custom end with template points. + Custom(Rc<[P]>), +} + +/// The join style of a line. +#[derive(Debug, Clone)] +pub enum LineJoin { + /// Cuts off the corner where two lines meet. This is the default. + Bevel, + /// Creates a sharp corner where two lines meet. + /// The parameter is the minimum interior angle in radians, clamped to + /// 0.01*pi..=0.99*pi (1.8..=178.2 degrees) before integer conversion. + /// With Integer construction math, the effective minimum is at least + /// 5 degrees. Both math modes default to bevel joins for turns below + /// 5 degrees; the style's `miter_min_turn` configures this cutoff independently. + Miter(T), + /// Creates an arc corner where two lines meet. + /// The arc is approximated using a group of segments, where the parameter `Angle` + /// is defined as `L / R`, with `L` being the maximum segment length and `R` being the arc radius. + Round(T), +} + +/// Defines the stroke style for outlining paths. +#[derive(Debug, Clone)] +pub struct StrokeStyle { + /// The width of the stroke. + pub width: P::Scalar, + /// The cap style at the start of the stroke. + pub start_cap: LineCap

, + /// The cap style at the end of the stroke. + pub end_cap: LineCap

, + /// The join style where two lines meet. + pub join: LineJoin, + /// Miter turns below this angle in radians use bevel joins in both math modes. + /// Defaults to 5 degrees (pi/36); clamped to 0..=pi, with NaN using the default. + /// Zero disables the cutoff. Smaller values allow less stable intersections. + /// Ignored for bevel and round joins. + pub miter_min_turn: P::Scalar, + /// Arithmetic for stroke construction. Integer remains the default. + pub math: MathMode, +} + +/// Defines the outline style for offsetting shapes. +#[derive(Debug)] +pub struct OutlineStyle { + pub outer_offset: T, + pub inner_offset: T, + pub join: LineJoin, + /// Miter turns below this angle in radians use bevel joins in both math modes. + /// Defaults to 5 degrees (pi/36); clamped to 0..=pi, with NaN using the default. + /// Zero disables the cutoff. Smaller values allow less stable intersections. + /// Ignored for bevel and round joins. + pub miter_min_turn: T, + /// Arithmetic used to construct offsets and joins. + pub math: MathMode, +} + +impl LineCap

{ + pub(crate) fn normalize(self) -> Self { + if let LineCap::Round(angle) = self { + let a = angle.to_f64().clamp(0.01 * PI, 0.25 * PI); + LineCap::Round(P::Scalar::from_float(a)) + } else { + self + } + } +} + +impl From<&LineJoin> for IntLineJoin { + /// Converts a floating-point join, normalizing its angle before quantization. + fn from(join: &LineJoin) -> Self { + match join.clone().normalize() { + LineJoin::Bevel => IntLineJoin::Bevel, + LineJoin::Miter(a) => IntLineJoin::Miter(angle_from_radians(a)), + LineJoin::Round(a) => IntLineJoin::Round(ArcOptions { + max_step: angle_from_radians(a), + ..ArcOptions::default() + }), + } + } +} + +impl LineJoin { + /// Conservative multiplier for the join's reach relative to the offset radius. + pub(super) fn padding_factor(&self) -> f64 { + match IntLineJoin::from(self) { + IntLineJoin::Miter(minimum) => { + let sin = Angle::from_bits(minimum.bits() / 2).sin() as f64; + 1.1 * (1u32 << 30) as f64 / sin + } + _ => 1.1, + } + } + + pub(crate) fn normalize(self) -> Self { + match self { + LineJoin::Miter(ratio) => { + let a = ratio.to_f64().clamp(0.01 * PI, 0.99 * PI); + LineJoin::Miter(T::from_float(a)) + } + LineJoin::Round(angle) => { + let a = angle.to_f64().clamp(0.01 * PI, 0.25 * PI); + LineJoin::Round(T::from_float(a)) + } + _ => self, + } + } +} + +impl StrokeStyle

{ + /// Creates a new `StrokeStyle` with the specified width. + pub fn new(width: P::Scalar) -> Self { + Self { + width, + ..Default::default() + } + } + + /// Sets the stroke width. + pub fn width(mut self, width: P::Scalar) -> Self { + self.width = P::Scalar::from_float(width.to_f64().max(0.0)); + self + } + + /// Sets the cap style at the start of the stroke. + pub fn start_cap(mut self, cap: LineCap

) -> Self { + self.start_cap = cap.normalize(); + self + } + + /// Sets the cap style at the end of the stroke. + pub fn end_cap(mut self, cap: LineCap

) -> Self { + self.end_cap = cap.normalize(); + self + } + + /// Sets the line join style. + pub fn line_join(mut self, join: LineJoin) -> Self { + self.join = join.normalize(); + self + } + + /// Sets the near-straight bevel cutoff in radians, independently of the miter clipping angle. + pub fn miter_min_turn(mut self, angle: P::Scalar) -> Self { + self.miter_min_turn = angle; + self + } + + /// Selects construction arithmetic; the final boolean operation stays integer. + pub fn math(mut self, math: MathMode) -> Self { + self.math = math; + self + } + + pub(super) fn to_int(&self, adapter: &FloatPointAdapter) -> IntStrokeStyle { + let radius = P::Scalar::from_float(0.5 * self.width.to_f64().max(0.0)); + let cap = |cap: &LineCap

| match cap.clone().normalize() { + LineCap::Butt => IntLineCap::Butt, + LineCap::Square => IntLineCap::Square, + LineCap::Round(a) => IntLineCap::Round(ArcOptions { + max_step: angle_from_radians(a), + ..ArcOptions::default() + }), + LineCap::Custom(points) => IntLineCap::Custom( + points + .iter() + .map(|p| { + IntPoint::new( + adapter.round_len_to_int(p.x() * radius), + adapter.round_len_to_int(p.y() * radius), + ) + }) + .collect::>() + .into(), + ), + }; + let radius = adapter.round_len_to_int(radius); + IntStrokeStyle { + width: I::from_wide(radius.to_wide() + radius.to_wide()), + start_cap: cap(&self.start_cap), + end_cap: cap(&self.end_cap), + join: IntLineJoin::from(&self.join), + miter_min_turn: miter_min_turn_angle(self.miter_min_turn), + math: self.math, + } + } + + /// Conservative distance by which the stroke can extend beyond its input bounds. + pub(super) fn padding(&self) -> P::Scalar { + let cap = |cap: &LineCap

| match cap { + LineCap::Square => 2.0, + LineCap::Custom(points) => { + points + .iter() + .map(|p| { + let x = p.x().to_f64(); + let y = p.y().to_f64(); + FloatNumber::sqrt(x * x + y * y) + }) + .fold(1.0_f64, f64::max) + * 1.1 + } + _ => 1.1, + }; + let r = 0.5 * self.width.to_f64().max(0.0); + + let join_factor = self.join.padding_factor(); + let start_cap_factor = cap(&self.start_cap); + let end_cap_factor = cap(&self.end_cap); + + let factor = join_factor.max(start_cap_factor).max(end_cap_factor); + P::Scalar::from_float(r * factor) + } +} + +impl Default for StrokeStyle

{ + fn default() -> Self { + Self { + width: P::Scalar::from_float(1.0), + start_cap: LineCap::Butt, + end_cap: LineCap::Butt, + join: LineJoin::Bevel, + miter_min_turn: P::Scalar::from_float(PI / 36.0), + math: MathMode::Integer, + } + } +} + +impl OutlineStyle { + /// Creates a new `OutlineStyle` with the specified offset. + pub fn new(offset: T) -> Self { + Self { + outer_offset: offset, + inner_offset: offset, + ..Default::default() + } + } + + /// Selects construction arithmetic; boolean operations stay integer. + pub fn math(mut self, math: MathMode) -> Self { + self.math = math; + self + } + + /// Sets the offset distance. + pub fn offset(mut self, offset: T) -> Self { + self.outer_offset = offset; + self.inner_offset = offset; + self + } + + /// Sets the outer distance. + pub fn outer_offset(mut self, outer_offset: T) -> Self { + self.outer_offset = outer_offset; + self + } + + /// Sets the inner distance. + pub fn inner_offset(mut self, inner_offset: T) -> Self { + self.inner_offset = inner_offset; + self + } + + /// Sets the line join style for the offset path. + pub fn line_join(mut self, join: LineJoin) -> Self { + self.join = join; + self + } + + /// Sets the near-straight bevel cutoff in radians, independently of the miter clipping angle. + pub fn miter_min_turn(mut self, angle: T) -> Self { + self.miter_min_turn = angle; + self + } +} + +impl Default for OutlineStyle { + fn default() -> Self { + Self { + outer_offset: T::from_float(1.0), + inner_offset: T::from_float(1.0), + join: LineJoin::Bevel, + miter_min_turn: T::from_float(PI / 36.0), + math: MathMode::Integer, + } + } +} diff --git a/iOverlay/src/mesh/float/variable_stroke/debug.rs b/iOverlay/src/mesh/float/variable_stroke/debug.rs new file mode 100644 index 00000000..11dadad0 --- /dev/null +++ b/iOverlay/src/mesh/float/variable_stroke/debug.rs @@ -0,0 +1,22 @@ +use i_float::float::compatible::FloatPointCompatible; + +pub use crate::mesh::int::variable_stroke::debug::VariableStrokeDebugEdgeKind; + +/// 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/float/variable_stroke/mod.rs similarity index 93% rename from iOverlay/src/mesh/variable_stroke/mod.rs rename to iOverlay/src/mesh/float/variable_stroke/mod.rs index 64ff3ae1..023140f6 100644 --- a/iOverlay/src/mesh/variable_stroke/mod.rs +++ b/iOverlay/src/mesh/float/variable_stroke/mod.rs @@ -1,9 +1,7 @@ -mod builder; #[cfg(feature = "variable_stroke_debug")] mod debug; pub mod offset; mod resource; -mod section; mod style; #[cfg(feature = "variable_stroke_debug")] diff --git a/iOverlay/src/mesh/variable_stroke/offset.rs b/iOverlay/src/mesh/float/variable_stroke/offset.rs similarity index 86% rename from iOverlay/src/mesh/variable_stroke/offset.rs rename to iOverlay/src/mesh/float/variable_stroke/offset.rs index bc889d3b..de1d677f 100644 --- a/iOverlay/src/mesh/variable_stroke/offset.rs +++ b/iOverlay/src/mesh/float/variable_stroke/offset.rs @@ -1,18 +1,17 @@ -use crate::core::fill_rule::FillRule; use crate::core::integer::OverlayInt; -use crate::core::overlay::Overlay; -use crate::core::overlay_rule::OverlayRule; +use crate::core::{fill_rule::FillRule, 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 crate::mesh::float::style::angle_from_radians; +use crate::mesh::float::variable_stroke::resource::VariableStrokeSource; +use crate::mesh::float::variable_stroke::style::VariableStrokeStyle; +use crate::mesh::int::variable_stroke::build::build_variable_overlay_iter; +use crate::mesh::int::variable_stroke::{IntStrokeVertex, IntVariableStrokeStyle}; 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::float::rect::{FloatRect, FloatRectError}; use i_float::int::number::int::IntNumber; use i_float::int::number::uint::UIntNumber; use i_float::int::number::wide_int::WideIntNumber; @@ -24,7 +23,7 @@ use i_shape::float::despike::DeSpikeContour; use i_shape::float::simple::SimplifyContour; #[cfg(feature = "variable_stroke_debug")] -use crate::mesh::variable_stroke::VariableStrokeDebugResult; +use crate::mesh::float::variable_stroke::VariableStrokeDebugResult; /// Builds round-cap, round-join strokes whose width is stored at each centerline vertex. pub trait VariableStrokeOffset

: VariableStrokeSource

@@ -121,7 +120,7 @@ where where I: OverlayInt + 'static, { - match VariableStrokeSolver::::prepare(self, style) { + match VariableStrokeSolver::::prepare(self, style).expect("Invalid offset bounds") { Some(solver) => solver.build(self, options), None => vec![], } @@ -135,7 +134,7 @@ where ) where I: OverlayInt + 'static, { - match VariableStrokeSolver::::prepare(self, style) { + match VariableStrokeSolver::::prepare(self, style).expect("Invalid offset bounds") { Some(solver) => solver.build_into(self, options, output), None => output.clear_and_reserve(0, 0), } @@ -173,7 +172,8 @@ where where I: OverlayInt + 'static, { - let mut solver = match VariableStrokeSolver::::prepare(self, style) { + FixedScaleOverlayError::validate_scale(scale)?; + let mut solver = match VariableStrokeSolver::::prepare(self, style)? { Some(solver) => solver, None => return Ok(vec![]), }; @@ -191,7 +191,8 @@ where where I: OverlayInt + 'static, { - let mut solver = match VariableStrokeSolver::::prepare(self, style) { + FixedScaleOverlayError::validate_scale(scale)?; + let mut solver = match VariableStrokeSolver::::prepare(self, style)? { Some(solver) => solver, None => { output.clear_and_reserve(0, 0); @@ -219,7 +220,7 @@ where P: FloatPointCompatible + 'static, { fn variable_stroke_debug(&self, style: VariableStrokeStyle) -> VariableStrokeDebugResult

{ - match VariableStrokeSolver::::prepare(self, style) { + match VariableStrokeSolver::::prepare(self, style).expect("Invalid offset bounds") { Some(solver) => solver.build_debug(self, Default::default()), None => VariableStrokeDebugResult { edges: vec![], @@ -239,10 +240,8 @@ where struct VariableStrokeSolver { max_radius: P::Scalar, - builder: VariableStrokeBuilder, + style: VariableStrokeStyle, adapter: FloatPointAdapter, - paths_count: usize, - points_count: usize, } impl VariableStrokeSolver @@ -253,7 +252,7 @@ where fn prepare + ?Sized>( source: &S, style: VariableStrokeStyle, - ) -> Option { + ) -> Result, FloatRectError> { let mut max_radius = P::Scalar::ZERO; let mut paths_count = 0; let mut points_count = 0; @@ -268,36 +267,57 @@ where for vertex in path { max_radius = max_radius.max(vertex.radius()); if let Some(rect) = rect.as_mut() { - rect.add_point(&vertex.point); + rect.add_point(&vertex.point)?; } else { - rect = Some(FloatRect::with_point(vertex.point)); + rect = Some(FloatRect::with_point(vertex.point)?); } } } if paths_count == 0 || points_count < 2 || max_radius <= P::Scalar::ZERO { - return None; + return Ok(None); } - let builder = VariableStrokeBuilder::new(style); - let mut rect = rect?; - rect.add_offset(builder.additional_offset(max_radius)); - let adapter = FloatPointAdapter::::new(rect); + let style = style.normalized(); + let Some(mut rect) = rect else { + return Ok(None); + }; + rect.add_offset(P::Scalar::from_float(1.1) * max_radius)?; + let adapter = FloatPointAdapter::::new_conservative(rect); - Some(Self { + Ok(Some(Self { max_radius, - builder, + style, 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)?; + self.adapter = FloatPointAdapter::try_with_scale_conservative(*self.adapter.rect(), scale)?; Ok(()) } + fn int_style(&self) -> IntVariableStrokeStyle { + IntVariableStrokeStyle { + math: self.style.math, + arc: crate::mesh::int::arc::ArcOptions { + max_step: angle_from_radians(self.style.round_angle), + ..Default::default() + }, + } + } + fn int_paths<'a, S: VariableStrokeSource

+ ?Sized>( + &'a self, + source: &'a S, + ) -> impl Iterator> + 'a> + 'a { + source.iter_variable_paths().map(|path| { + path.iter().map(|v| { + let radius = self.adapter.round_len_to_int(v.radius()).to_wide(); + IntStrokeVertex::new(self.adapter.float_to_int(&v.point), I::from_wide(radius + radius)) + }) + }) + } + fn build + ?Sized>( self, source: &S, @@ -307,14 +327,14 @@ where 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 shapes = build_variable_overlay_iter( + self.int_paths(source), + self.int_style(), + options.int_with_adapter(&self.adapter), + #[cfg(feature = "variable_stroke_debug")] + None, + ) + .overlay(OverlayRule::Subject, FillRule::Positive); let mut float = shapes.to_float(&self.adapter); if options.clean_result { @@ -338,15 +358,15 @@ where 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); + build_variable_overlay_iter( + self.int_paths(source), + self.int_style(), + options.int_with_adapter(&self.adapter), + #[cfg(feature = "variable_stroke_debug")] + None, + ) + .overlay_into(OverlayRule::Subject, FillRule::Positive, &mut int_output); let iter = int_output .points @@ -375,16 +395,26 @@ where }; } - 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 edges = alloc::vec::Vec::new(); + let shapes = build_variable_overlay_iter( + self.int_paths(source), + self.int_style(), + options.int_with_adapter(&self.adapter), + Some(&mut edges), + ) + .overlay(OverlayRule::Subject, FillRule::Positive); + let edges = edges + .into_iter() + .map( + |edge| crate::mesh::float::variable_stroke::VariableStrokeDebugEdge { + a: self.adapter.int_to_float(&edge.a), + b: self.adapter.int_to_float(&edge.b), + kind: edge.kind, + path_index: edge.path_index, + order: edge.order, + }, + ) + .collect(); let mut shapes = shapes.to_float(&self.adapter); if options.clean_result { @@ -413,16 +443,16 @@ where 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 crate::mesh::float::stroke::offset::StrokeOffset; + use crate::mesh::float::style::{LineCap, LineJoin, StrokeStyle}; + use crate::mesh::float::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}; + use crate::mesh::float::variable_stroke::{VariableStrokeDebug, VariableStrokeDebugEdgeKind}; #[cfg(feature = "variable_stroke_debug")] #[test] diff --git a/iOverlay/src/mesh/variable_stroke/resource.rs b/iOverlay/src/mesh/float/variable_stroke/resource.rs similarity index 98% rename from iOverlay/src/mesh/variable_stroke/resource.rs rename to iOverlay/src/mesh/float/variable_stroke/resource.rs index cb70982b..4e9296c8 100644 --- a/iOverlay/src/mesh/variable_stroke/resource.rs +++ b/iOverlay/src/mesh/float/variable_stroke/resource.rs @@ -1,4 +1,4 @@ -use crate::mesh::variable_stroke::style::StrokeVertex; +use crate::mesh::float::variable_stroke::style::StrokeVertex; use alloc::vec::Vec; use i_float::float::compatible::FloatPointCompatible; @@ -187,7 +187,7 @@ impl<'b, P: FloatPointCompatible> VariableStrokeSource

for &'b [Vec StrokeVertex

{ pub struct VariableStrokeStyle { /// Maximum angular step used to approximate round joins and caps, in radians. pub round_angle: T, + /// Arithmetic used to construct tangent contacts and arcs. + pub math: MathMode, } impl VariableStrokeStyle { @@ -34,6 +37,11 @@ impl VariableStrokeStyle { Self::default() } + pub fn math(mut self, math: MathMode) -> Self { + self.math = math; + self + } + #[inline] pub fn round_angle(mut self, angle: T) -> Self { self.round_angle = Self::normalize_angle(angle); @@ -44,6 +52,7 @@ impl VariableStrokeStyle { pub(super) fn normalized(self) -> Self { Self { round_angle: Self::normalize_angle(self.round_angle), + math: self.math, } } @@ -58,6 +67,7 @@ impl Default for VariableStrokeStyle { fn default() -> Self { Self { round_angle: T::from_float(0.1), + math: MathMode::Integer, } } } diff --git a/iOverlay/src/mesh/int/arc.rs b/iOverlay/src/mesh/int/arc.rs new file mode 100644 index 00000000..8b532013 --- /dev/null +++ b/iOverlay/src/mesh/int/arc.rs @@ -0,0 +1,74 @@ +//! Directed unit-circle arcs with reusable storage. +//! +//! Integer and floating-point implementations share traversal and angular-gap +//! guarantees, but their error budgets and intermediate directions differ. + +mod float; +mod integer; + +#[cfg(test)] +mod tests; + +pub(crate) use float::FloatArc; +pub(crate) use integer::IntegerArc; +// Preserve the public integer builder API. +pub use integer::IntegerArc as ArcBuilder; + +use i_float::int::angle::Angle; + +/// Direction of traversal in Cartesian coordinates (y increases upward). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ArcDirection { + Clockwise, + Counterclockwise, +} + +/// Settings for directed arcs built with integer or floating-point arithmetic. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ArcOptions { + /// Maximum angular gap, including the final gap to the original endpoint. + /// Clamped to [`Self::MIN_STEP`]..=[`Self::MAX_STEP`] on construction. + pub max_step: Angle, + /// Relative matrix-angle accuracy exponent: 4 allows 1/16 error, 5 allows + /// 1/32, and 6 allows 1/64. Clamped to 4..=32; 32 selects full precision. + /// This changes integer matrix construction cost, not the coefficient format. + /// Ignored by the floating-point implementation. + pub rotation_precision: u32, +} + +impl ArcOptions { + /// 1/1024 turn, or 0.3515625 degrees. + pub const MIN_STEP: Angle = Angle::from_bits(1 << 22); + /// 1/8 turn, or 45 degrees. + pub const MAX_STEP: Angle = Angle::from_bits(1 << 29); + + pub(crate) fn clamped(self) -> Self { + Self { + max_step: Angle::from_bits( + self.max_step + .bits() + .clamp(Self::MIN_STEP.bits(), Self::MAX_STEP.bits()), + ), + rotation_precision: self.rotation_precision.clamp(4, 32), + } + } +} + +impl Default for ArcOptions { + fn default() -> Self { + Self { + max_step: Self::MAX_STEP, + rotation_precision: 5, + } + } +} + +/// Reusable directed arcs, excluding both input endpoints. +/// Equal rays produce an empty arc; opposite rays and major arcs are supported. +/// Each implementation budgets its own numerical error to keep all angular +/// gaps, including the final gap to the endpoint, within the clamped max_step. +/// Point counts and coordinates need not match between implementations. +pub(crate) trait ArcMath { + fn new(options: ArcOptions) -> Self; + fn build(&mut self, from: D, to: D, direction: ArcDirection) -> &[D]; +} diff --git a/iOverlay/src/mesh/int/arc/float.rs b/iOverlay/src/mesh/int/arc/float.rs new file mode 100644 index 00000000..dde5961e --- /dev/null +++ b/iOverlay/src/mesh/int/arc/float.rs @@ -0,0 +1,206 @@ +use super::{ArcDirection, ArcMath, ArcOptions}; +use alloc::vec::Vec; +use core::f64::consts::TAU; +use i_float::float::number::FloatNumber; +#[cfg(test)] +use i_float::int::angle::Angle; +use i_float::int::number::{int::IntNumber, wide_int::WideIntNumber}; +use i_float::int::unit_vector::UnitIntVector; + +#[derive(Clone, Copy, Debug)] +struct FloatUnitVector { + x: f64, + y: f64, +} + +impl FloatUnitVector { + #[cfg(test)] + fn cross(self, other: Self) -> f64 { + self.x * other.y - self.y * other.x + } + #[cfg(test)] + fn dot(self, other: Self) -> f64 { + self.x * other.x + self.y * other.y + } +} + +/// Cached f64 rotations with reusable storage. Endpoints remain the original +/// integer contact points in the caller, never reconstructed from these rays. +/// Direction lengths are approximate and may drift slightly above one. +pub(crate) struct FloatArc { + step: f64, + angle_error: f64, + sin: f64, + cos: f64, + directions: Vec>, +} + +impl FloatArc { + const MAX_ROTATIONS: usize = 1536; + // A generous angular budget for atan2 and at most 1536 matrix applications. + // The smaller cached step leaves room for this error in the final gap. + const ANGLE_ERROR: f64 = 64.0 * Self::MAX_ROTATIONS as f64 * f64::EPSILON; +} + +impl ArcMath> for FloatArc { + fn new(options: ArcOptions) -> Self { + let options = options.clamped(); + let max_step = options.max_step.bits() as f64 * (TAU / 4294967296.0); + // Component truncation adds at most about sqrt(2)/S angular error + // per output ray; reserve 2/S for each end of a gap. + let angle_error = Self::ANGLE_ERROR + 2.0 / UnitIntVector::::DENOMINATOR.to_f64(); + let step = max_step - 2.0 * angle_error; + let (sin, cos) = FloatNumber::sin_cos(step); + Self { + step, + angle_error, + sin, + cos, + directions: Vec::new(), + } + } + + fn build( + &mut self, + from: UnitIntVector, + to: UnitIntVector, + direction: ArcDirection, + ) -> &[UnitIntVector] { + self.directions.clear(); + let a = crate::mesh::int::math::vector(from); + let b = crate::mesh::int::math::vector(to); + let cross = a.cross_product(b).to_f64(); + let dot = a.dot_product(b).to_f64(); + let scale = UnitIntVector::::DENOMINATOR.to_f64(); + let from = float_vector(from); + let sign = match direction { + ArcDirection::Clockwise => -1.0, + ArcDirection::Counterclockwise => 1.0, + }; + let cross = sign * cross; + // Conservatively skip short arcs without atan2. Account for unit-vector + // normalization error when comparing the dot product with cos(step). + if cross >= 0.0 && dot / (scale * scale) >= self.cos + 8.0 * f64::EPSILON { + return &self.directions; + } + let mut sweep = FloatNumber::atan2(cross, dot); + if sweep < 0.0 { + sweep += TAU; + } + // The quotient is nonnegative, so truncation is equivalent to floor. + let count = ((sweep - self.angle_error).max(0.0) / self.step) as usize; + debug_assert!(count <= Self::MAX_ROTATIONS); + self.directions.reserve(count); + let sin = sign * self.sin; + let mut current = from; + for _ in 0..count { + current = FloatUnitVector { + x: self.cos * current.x - sin * current.y, + y: sin * current.x + self.cos * current.y, + }; + // Keep f64 rotation state between steps; quantization does not + // accumulate. Small length drift from float rounding is accepted. + self.directions + .push(UnitIntVector::from_float_unchecked(current.x, current.y)); + } + &self.directions + } +} + +fn float_vector(direction: UnitIntVector) -> FloatUnitVector { + let scale = UnitIntVector::::DENOMINATOR.to_f64(); + FloatUnitVector { + x: direction.x().to_wide().to_f64() / scale, + y: direction.y().to_wide().to_f64() / scale, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn check_arcs() { + let scale = UnitIntVector::::DENOMINATOR; + let unit = |angle: f64| { + let (sin, cos) = FloatNumber::sin_cos(angle); + UnitIntVector::::from_float_unchecked(cos, sin) + }; + let sweep = |a: UnitIntVector, b: UnitIntVector, sign: f64| { + let a = float_vector(a); + let b = float_vector(b); + let angle = FloatNumber::atan2(sign * a.cross(b), a.dot(b)); + if angle < 0.0 { angle + TAU } else { angle } + }; + for bits in [ArcOptions::MIN_STEP.bits(), 1 << 26, ArcOptions::MAX_STEP.bits()] { + let max_step = bits as f64 * TAU / 4294967296.0; + let mut arc = FloatArc::::new(ArcOptions { + max_step: Angle::from_bits(bits), + rotation_precision: 5, + }); + for start in [0.0, 0.3, 1.7, 3.1, 4.9] { + for extent in [ + 0.0, + 1e-8, + max_step, + max_step + 1e-6, + 1.0, + core::f64::consts::PI, + TAU - 1e-8, + ] { + for direction in [ArcDirection::Clockwise, ArcDirection::Counterclockwise] { + let sign = if direction == ArcDirection::Clockwise { + -1.0 + } else { + 1.0 + }; + let from = unit(start); + let to = unit(start + sign * extent); + let total = sweep(from, to, sign); + let mut previous = 0.0; + for &point in arc.build(from, to, direction) { + let x = point.x().to_f64() / scale.to_f64(); + let y = point.y().to_f64() / scale.to_f64(); + let norm = FloatNumber::sqrt(x * x + y * y); + let tolerance = FloatArc::::ANGLE_ERROR + 4.0 / scale.to_f64(); + assert!((norm - 1.0).abs() <= tolerance); + let progress = sweep(from, point, sign); + assert!( + progress > previous && progress < total, + "bits={}, progress={progress}, previous={previous}, total={total}", + I::BITS + ); + assert!(progress - previous <= max_step + 1e-12); + previous = progress; + } + assert!(total - previous <= max_step + 1e-12); + } + } + } + arc.build(unit(0.0), unit(3.0), ArcDirection::Clockwise); + let capacity = arc.directions.capacity(); + let ptr = arc.directions.as_ptr(); + for _ in 0..3 { + assert!( + arc.build(unit(0.0), unit(0.0), ArcDirection::Clockwise) + .is_empty() + ); + arc.build(unit(0.0), unit(3.0), ArcDirection::Clockwise); + assert_eq!(arc.directions.capacity(), capacity); + assert_eq!(arc.directions.as_ptr(), ptr); + } + } + } + + #[test] + fn arc_bounds_and_reuse_i16() { + check_arcs::(); + } + #[test] + fn arc_bounds_and_reuse_i32() { + check_arcs::(); + } + #[test] + fn arc_bounds_and_reuse_i64() { + check_arcs::(); + } +} diff --git a/iOverlay/src/mesh/int/arc/integer.rs b/iOverlay/src/mesh/int/arc/integer.rs new file mode 100644 index 00000000..4c5319f7 --- /dev/null +++ b/iOverlay/src/mesh/int/arc/integer.rs @@ -0,0 +1,225 @@ +use super::{ArcDirection, ArcMath, ArcOptions}; +use alloc::vec::Vec; +use i_float::int::angle::{Angle, Rotation}; +use i_float::int::number::{int::IntNumber, wide_int::WideIntNumber}; +use i_float::int::unit_vector::UnitIntVector; + +/// Reusable storage and two cached rotation matrices for directed arcs. +/// +/// Output contains intermediate directions in traversal order, excluding both +/// input endpoints. The caller applies the center and radius and retains the +/// original contact points to avoid rounding seams. Equal rays describe an +/// empty arc; full turns are not supported. Opposite rays describe a semicircle +/// whose side is selected by `ArcDirection`. Major arcs are supported too. +/// +/// Matrices are constructed once, with [`Rotation::with_precision`]. Each build +/// measures the sweep with [`Angle::between`] and applies the appropriate matrix +/// repeatedly, without per-point CORDIC, square roots, or normalization. Steps +/// use the achieved matrix angle; the last gap can be shorter than the others. +/// Reversing a build need not produce the same intermediate directions. +/// +/// Inputs should be freshly normalized directions: length at least 0.99 for +/// `i32`/`i64`, or 0.95 for `i16`. The step error budget includes rounding during +/// at most 1536 applications. `i16` also checks the endpoint geometrically; +/// its coarse storage can noticeably contract long fine arcs. For those arcs, +/// prefer `i32` or `i64`. Output length never exceeds the starting length. +/// +/// ``` +/// use i_float::int::vector::IntVector; +/// use i_overlay::mesh::int::arc::{ArcBuilder, ArcDirection, ArcOptions}; +/// +/// let from = IntVector::::new(1, 0).fast_normalize().unwrap(); +/// let to = IntVector::::new(0, 1).fast_normalize().unwrap(); +/// let mut builder = ArcBuilder::new(ArcOptions::default()); +/// let directions = builder.build(from, to, ArcDirection::Counterclockwise); +/// assert!(!directions.is_empty()); +/// let offset = directions[0].scale(1024); +/// assert!(offset.x > 0 && offset.y > 0); +/// ``` +pub struct IntegerArc { + options: ArcOptions, + short_arc_dot: I::Wide, + clockwise: ArcRotation, + counterclockwise: ArcRotation, + directions: Vec>, +} + +struct ArcRotation { + matrix: Rotation, + upper_step: u64, +} + +impl IntegerArc { + const MAX_ROTATIONS: u64 = 1536; + // i32/i64: two metadata units plus component rounding at length >= 0.99. + // i16: metadata plus component rounding, even after contraction to 0.68. + const STEP_ERROR: u64 = if I::BITS == 16 { 1 << 18 } else { 4 }; + + /// Prepares both traversal directions. The result buffer starts empty. + pub fn new(options: ArcOptions) -> Self { + let options = options.clamped(); + // Counting by the upper step prevents endpoint overshoot. Budget the + // accumulated difference from the lower step in the final gap too. + // i16 stops geometrically, so it needs only a single-step reserve. + let reserve = if I::BITS == 16 { + 2 * Self::STEP_ERROR + } else { + 2 * Self::STEP_ERROR * (Self::MAX_ROTATIONS + 1) + 2 * Angle::MAX_ERROR as u64 + }; + let divisor = 1u64 << options.rotation_precision; + let available = options.max_step.bits() as u64 - reserve; + let requested = (available * divisor / (divisor + 1)) as u32; + let prepare = |bits| { + let matrix = Rotation::::with_precision(Angle::from_bits(bits), options.rotation_precision); + let step = (matrix.angle().bits() as i32).unsigned_abs() as u64; + debug_assert!(step > Self::STEP_ERROR); + ArcRotation { + matrix, + upper_step: step + Self::STEP_ERROR, + } + }; + // A sufficient small-arc test: input norms are <=1, so an unnormalized + // dot above cos(max_step) also bounds the normalized dot from below. + // Add eight Q30 units to cover the full CORDIC coefficient error, then + // round upward when narrowing to the Q28 dot scale of i16. + let cosine = options.max_step.cos() as u32 + 8; + let dot_bits = 2 * (I::BITS - 2); + let short_arc_dot = if dot_bits >= 30 { + I::Wide::from_u32(cosine) << (dot_bits - 30) + } else { + I::Wide::from_u32(cosine.div_ceil(1 << (30 - dot_bits))) + }; + Self { + options, + short_arc_dot, + clockwise: prepare(requested.wrapping_neg()), + counterclockwise: prepare(requested), + directions: Vec::new(), + } + } + + /// Returns the effective settings, after clamping to the supported range. + pub fn options(&self) -> ArcOptions { + self.options + } + + /// Clears the previous result, retaining its capacity, and returns the + /// intermediate directions. Settings and cached matrices remain reusable. + pub fn build( + &mut self, + from: UnitIntVector, + to: UnitIntVector, + direction: ArcDirection, + ) -> &[UnitIntVector] { + self.directions.clear(); + // Most shallow joins need no intermediate points. Avoid vectoring for + // those arcs, while distinguishing the directed minor and major arcs. + if cross(from, to, direction) >= I::Wide::ZERO && dot(from, to) >= self.short_arc_dot { + return &self.directions; + } + let (sweep, rotation) = match direction { + ArcDirection::Clockwise => (Angle::between(to, from), &self.clockwise), + ArcDirection::Counterclockwise => (Angle::between(from, to), &self.counterclockwise), + }; + if sweep.bits() == 0 + || sweep.bits() as u64 + Angle::MAX_ERROR as u64 <= self.options.max_step.bits() as u64 + { + return &self.directions; + } + + if I::BITS == 16 { + // Low precision storage accumulates too much error for a fixed + // count. Stop just before the next rotated direction reaches the + // endpoint. Each step is <90 degrees, so cross/dot suffice. + let mut current = from; + for _ in 0..Self::MAX_ROTATIONS { + let next = rotation.matrix.apply(current); + let near_end = + cross(current, to, direction) >= I::Wide::ZERO && dot(current, to) >= I::Wide::ZERO; + if cross(current, next, direction) <= I::Wide::ZERO + || (near_end && cross(next, to, direction) <= I::Wide::ZERO) + { + return &self.directions; + } + self.directions.push(next); + current = next; + } + debug_assert!(false, "freshly normalized directions must reach the arc endpoint"); + } else { + // Strictly below the lower bound on the sweep: both input endpoints + // are excluded even when the arc is an exact multiple of the step. + let lower = sweep.bits().saturating_sub(Angle::MAX_ERROR) as u64; + let count = lower.saturating_sub(1) / rotation.upper_step; + debug_assert!(count < Self::MAX_ROTATIONS); + self.directions.reserve(count as usize); + let mut current = from; + for _ in 0..count { + current = rotation.matrix.apply(current); + self.directions.push(current); + } + } + &self.directions + } +} + +impl Default for IntegerArc { + fn default() -> Self { + Self::new(ArcOptions::default()) + } +} + +impl ArcMath> for IntegerArc { + fn new(options: ArcOptions) -> Self { + IntegerArc::new(options) + } + fn build( + &mut self, + from: UnitIntVector, + to: UnitIntVector, + direction: ArcDirection, + ) -> &[UnitIntVector] { + IntegerArc::build(self, from, to, direction) + } +} + +fn cross(a: UnitIntVector, b: UnitIntVector, direction: ArcDirection) -> I::Wide { + let cross = a.x().to_wide() * b.y().to_wide() - a.y().to_wide() * b.x().to_wide(); + match direction { + ArcDirection::Counterclockwise => cross, + ArcDirection::Clockwise => -cross, + } +} + +fn dot(a: UnitIntVector, b: UnitIntVector) -> I::Wide { + a.x().to_wide() * b.x().to_wide() + a.y().to_wide() * b.y().to_wide() +} + +#[cfg(test)] +mod tests { + use super::{ArcDirection, ArcOptions, IntegerArc}; + use i_float::int::vector::IntVector; + + #[test] + fn repeated_builds_reuse_buffer_and_rotations() { + let from = IntVector::::new(1, 0).fast_normalize().unwrap(); + let to = IntVector::::new(0, -1).fast_normalize().unwrap(); + let mut builder = IntegerArc::new(ArcOptions { + max_step: ArcOptions::MIN_STEP, + ..ArcOptions::default() + }); + builder.build(from, to, ArcDirection::Counterclockwise); + let output_ptr = builder.directions.as_ptr(); + let output_capacity = builder.directions.capacity(); + let cw = builder.clockwise.matrix.apply(from); + let ccw = builder.counterclockwise.matrix.apply(from); + assert!(output_capacity > 0); + for _ in 0..3 { + assert!(builder.build(from, from, ArcDirection::Clockwise).is_empty()); + builder.build(from, to, ArcDirection::Counterclockwise); + assert_eq!(builder.directions.as_ptr(), output_ptr); + assert_eq!(builder.directions.capacity(), output_capacity); + assert_eq!(builder.clockwise.matrix.apply(from), cw); + assert_eq!(builder.counterclockwise.matrix.apply(from), ccw); + } + } +} diff --git a/iOverlay/src/mesh/int/arc/tests.rs b/iOverlay/src/mesh/int/arc/tests.rs new file mode 100644 index 00000000..e10cdcb2 --- /dev/null +++ b/iOverlay/src/mesh/int/arc/tests.rs @@ -0,0 +1,119 @@ +use super::{ArcDirection, ArcMath, ArcOptions, FloatArc, IntegerArc}; +use alloc::vec::Vec; +use core::f64::consts::TAU; +use i_float::float::number::FloatNumber; +use i_float::int::angle::Angle; +use i_float::int::number::{int::IntNumber, wide_int::WideIntNumber}; +use i_float::int::{unit_vector::UnitIntVector, vector::IntVector}; + +fn unit(x: i32, y: i32) -> UnitIntVector { + let coordinate = |value: i32| { + let magnitude = I::Wide::from_u32(value.unsigned_abs()); + if value < 0 { -magnitude } else { magnitude } + }; + IntVector::::new(coordinate(x), coordinate(y)) + .fast_normalize() + .unwrap() +} + +fn angle(ray: UnitIntVector) -> f64 { + FloatNumber::atan2(ray.y().to_f64(), ray.x().to_f64()) +} + +// Both implementations must satisfy the same traversal and gap guarantees. +// Their point counts, coordinates, and length error are intentionally not compared. +fn check_contract>>() { + let rays: Vec<_> = [ + (1, 0), + (10000, 1), + (3, 4), + (1, 1), + (0, 1), + (-1, 10000), + (-4, 3), + (-1, 0), + (-10000, -1), + (-3, -4), + (0, -1), + (10000, -1), + ] + .into_iter() + .map(|(x, y)| unit::(x, y)) + .collect(); + + for max_step in [0, 1 << 26, u32::MAX] { + for rotation_precision in [0, 5, u32::MAX] { + let options = ArcOptions { + max_step: Angle::from_bits(max_step), + rotation_precision, + }; + let max_gap = options.clamped().max_step.bits() as f64 * TAU / 4294967296.0; + let mut arc = A::new(options); + for &from in &rays { + for &to in &rays { + for direction in [ArcDirection::Clockwise, ArcDirection::Counterclockwise] { + let sign = match direction { + ArcDirection::Clockwise => -1.0, + ArcDirection::Counterclockwise => 1.0, + }; + let progress = |ray| { + let delta = sign * (angle(ray) - angle(from)); + if delta < 0.0 { delta + TAU } else { delta } + }; + let total = progress(to); + let output = arc.build(from, to, direction); + if total == 0.0 { + assert!(output.is_empty()); + continue; + } + let mut previous = 0.0; + for &ray in output { + let current = progress(ray); + assert!(current > previous && current < total); + assert!(current - previous <= max_gap + 1e-12); + previous = current; + } + assert!(total - previous <= max_gap + 1e-12); + } + } + } + + let from = unit::(1, 0); + let direction = ArcDirection::Counterclockwise; + assert!(!arc.build(from, unit(-1, 0), direction).is_empty()); + assert!(arc.build(from, from, direction).is_empty()); + assert!(!arc.build(from, unit(-1, 0), direction).is_empty()); + assert!(arc.build(from, unit(10000, 1), direction).is_empty()); + } + } +} + +#[test] +fn integer_contract_i16() { + check_contract::>(); +} + +#[test] +fn integer_contract_i32() { + check_contract::>(); +} + +#[test] +fn integer_contract_i64() { + check_contract::>(); +} + +#[test] +fn float_contract_i16() { + check_contract::>(); +} + +#[test] +fn float_contract_i32() { + check_contract::>(); +} + +#[test] +fn float_contract_i64() { + check_contract::>(); +} diff --git a/iOverlay/src/mesh/int/bounds.rs b/iOverlay/src/mesh/int/bounds.rs new file mode 100644 index 00000000..6e9d388f --- /dev/null +++ b/iOverlay/src/mesh/int/bounds.rs @@ -0,0 +1,12 @@ +use i_float::int::{number::int::IntNumber, rect::IntRect}; +pub(super) fn expanded_is_safe(rect: IntRect, padding: I::Wide) -> bool { + let min = I::MIN.to_wide(); + let max = I::MAX.to_wide(); + IntRect::new( + I::from_wide((rect.min_x.to_wide() - padding).max(min)), + I::from_wide((rect.max_x.to_wide() + padding).min(max)), + I::from_wide((rect.min_y.to_wide() - padding).max(min)), + I::from_wide((rect.max_y.to_wide() + padding).min(max)), + ) + .is_in_safe_range() +} diff --git a/iOverlay/src/mesh/int/join.rs b/iOverlay/src/mesh/int/join.rs new file mode 100644 index 00000000..bae91deb --- /dev/null +++ b/iOverlay/src/mesh/int/join.rs @@ -0,0 +1,150 @@ +use super::arc::{ArcDirection, ArcMath}; +use super::math::backend::MeshMath; +use super::math::integer::IntegerMath; +use super::math::{abs, mul_div, point, scaled_point, vector}; +use super::style::IntLineJoin; +use crate::mesh::subject::SubjectSegments; +use crate::segm::{boolean::ShapeCountBoolean, segment::Segment}; +use alloc::vec::Vec; +use i_float::int::angle::Angle; +use i_float::int::number::{int::IntNumber, wide_int::WideIntNumber}; +use i_float::int::{point::IntPoint, unit_vector::UnitIntVector}; + +pub(super) enum Join = IntegerMath> { + Bevel, + Miter { + minimum: u32, + min_turn: u32, + sin: i32, + cos: i32, + }, + Round(M::Arc), +} + +impl> Join { + pub(super) fn new(style: IntLineJoin, min_turn: Angle) -> Self { + match style { + IntLineJoin::Bevel => Self::Bevel, + IntLineJoin::Round(options) => Self::Round(M::Arc::new(options)), + IntLineJoin::Miter(angle) => { + let min_angle = ((1u32 << 31) / 100).max(M::MIN_MITER_ANGLE); + let max_angle = (1u32 << 31) - 1; + let minimum = angle.bits().clamp(min_angle, max_angle); + let (sin, cos) = M::sin_cos(Angle::from_bits(minimum / 2)); + Self::Miter { + minimum, + min_turn: min_turn.bits().min(1u32 << 31), + sin, + cos, + } + } + } + } + + pub(super) fn padding(style: IntLineJoin, radius: I) -> I::Wide { + let radius = abs(radius); + if radius == I::Wide::ZERO { + return radius; + } + match style { + IntLineJoin::Miter(_) => { + // The near-straight cutoff does not change the miter reach limit. + let Self::Miter { sin, .. } = Self::new(style, Angle::from_bits(0)) else { + unreachable!() + }; + mul_div::(radius, I::Wide::from_u32(1 << 30), I::Wide::from_u32(sin as u32)) + I::Wide::TWO + } + _ => radius, + } + } + + /// Joins the two outward offset rays, in boundary traversal order. + #[allow(clippy::too_many_arguments)] + pub(super) fn add( + &mut self, + center: IntPoint, + a: IntPoint, + b: IntPoint, + incoming: UnitIntVector, + outgoing: UnitIntVector, + radius: I, + direction: ArcDirection, + segments: &mut Vec>, + ) { + if a == b { + return; + } + match self { + Self::Bevel => segments.push_non_degenerate(a, b), + Self::Round(arc) => { + let Some(from) = M::normalize(a - center) else { + segments.push_non_degenerate(a, b); + return; + }; + let Some(to) = M::normalize(b - center) else { + segments.push_non_degenerate(a, b); + return; + }; + let mut previous = a; + for &direction in arc.build(from, to, direction) { + let next = scaled_point(center, direction, radius); + segments.push_non_degenerate(previous, next); + previous = next; + } + segments.push_non_degenerate(previous, b); + } + Self::Miter { + minimum, + min_turn, + sin, + cos, + } => { + let va = vector(incoming); + let vb = vector(outgoing); + let cross = va.cross_product(vb); + if cross == I::Wide::ZERO { + segments.push_non_degenerate(a, b); + return; + } + let turn = M::angle_between(incoming, outgoing).bits(); + let turn = turn.min(turn.wrapping_neg()); + if turn < *min_turn { + // Almost straight: rounded offset points need not lie on + // intersecting rays near the vertex. Close the gap directly. + segments.push_non_degenerate(a, b); + return; + } + if (1u32 << 31) - turn < *minimum { + let extension = mul_div::( + abs(radius), + I::Wide::from_u32(*cos as u32), + I::Wide::from_u32(*sin as u32), + ); + let scale = UnitIntVector::::DENOMINATOR; + let ac = point( + a, + mul_div::(va.x, extension, scale), + mul_div::(va.y, extension, scale), + ); + let bc = point( + b, + -mul_div::(vb.x, extension, scale), + -mul_div::(vb.y, extension, scale), + ); + segments.push_non_degenerate(a, ac); + segments.push_non_degenerate(ac, bc); + segments.push_non_degenerate(bc, b); + } else { + let numerator = (b - a).cross_product(vb); + let peak = point( + a, + mul_div::(va.x, numerator, cross), + mul_div::(va.y, numerator, cross), + ); + segments.push_non_degenerate(a, peak); + segments.push_non_degenerate(peak, b); + } + } + } + } +} diff --git a/iOverlay/src/mesh/int/math.rs b/iOverlay/src/mesh/int/math.rs new file mode 100644 index 00000000..a8996d6a --- /dev/null +++ b/iOverlay/src/mesh/int/math.rs @@ -0,0 +1,51 @@ +pub(crate) mod backend; +pub(crate) mod float; +pub(crate) mod integer; + +use i_float::int::number::{ + int::IntNumber, product_uint::UIntProduct, uint::UIntNumber, wide_int::WideIntNumber, +}; +use i_float::int::{point::IntPoint, unit_vector::UnitIntVector, vector::IntVector}; + +pub(super) fn mul_div(a: I::Wide, b: I::Wide, divisor: I::Wide) -> I::Wide { + let negative = (a < I::Wide::ZERO) ^ (b < I::Wide::ZERO) ^ (divisor < I::Wide::ZERO); + let product = ::Product::multiply(a.unsigned_abs(), b.unsigned_abs()); + let value = I::Wide::from_uint(product.divide_with_rounding(divisor.unsigned_abs())); + if negative { -value } else { value } +} + +pub(super) fn vector(direction: UnitIntVector) -> IntVector { + IntVector::new(direction.x().to_wide(), direction.y().to_wide()) +} + +pub(super) fn point(center: IntPoint, dx: I::Wide, dy: I::Wide) -> IntPoint { + let x = center.x.to_wide() + dx; + let y = center.y.to_wide() + dy; + debug_assert!(x > -UnitIntVector::::DENOMINATOR && x < UnitIntVector::::DENOMINATOR); + debug_assert!(y > -UnitIntVector::::DENOMINATOR && y < UnitIntVector::::DENOMINATOR); + IntPoint::new(I::from_wide(x), I::from_wide(y)) +} + +pub(super) fn scaled_point( + center: IntPoint, + direction: UnitIntVector, + radius: I, +) -> IntPoint { + let offset = direction.scale(radius); + point(center, offset.x, offset.y) +} + +pub(super) fn abs(value: I) -> I::Wide { + let wide = value.to_wide(); + if wide < I::Wide::ZERO { -wide } else { wide } +} + +pub(super) fn direction(v: IntVector) -> Option> { + if v.x == I::Wide::ZERO { + IntVector::::new(I::Wide::ZERO, v.y.signum()).fast_normalize() + } else if v.y == I::Wide::ZERO { + IntVector::::new(v.x.signum(), I::Wide::ZERO).fast_normalize() + } else { + v.fast_normalize() + } +} diff --git a/iOverlay/src/mesh/int/math/backend.rs b/iOverlay/src/mesh/int/math/backend.rs new file mode 100644 index 00000000..6903916d --- /dev/null +++ b/iOverlay/src/mesh/int/math/backend.rs @@ -0,0 +1,22 @@ +use crate::mesh::int::arc::ArcMath; +use i_float::int::{ + angle::Angle, number::int::IntNumber, point::IntPoint, unit_vector::UnitIntVector, vector::IntVector, +}; + +/// Five degrees: avoid amplifying rounding errors in nearly parallel offset lines. +pub(crate) const DEFAULT_MITER_MIN_TURN: u32 = (1u32 << 31) / 36; + +/// Construction arithmetic only; coordinates and topology remain integer. +pub(crate) trait MeshMath: Copy { + type Arc: ArcMath>; + /// Backend-specific minimum interior angle, in Angle bits. + /// The configurable near-straight cutoff is independent of this clipping limit. + const MIN_MITER_ANGLE: u32; + fn sin_cos(angle: Angle) -> (i32, i32); + fn angle_between(from: UnitIntVector, to: UnitIntVector) -> Angle; + + fn normalize(vector: IntVector) -> Option>; + fn scale(direction: UnitIntVector, distance: I) -> IntVector; + fn rotate(direction: UnitIntVector, local: IntPoint) -> IntVector; + fn guard_padding(padding: I::Wide) -> I::Wide; +} diff --git a/iOverlay/src/mesh/int/math/float.rs b/iOverlay/src/mesh/int/math/float.rs new file mode 100644 index 00000000..6c9dd498 --- /dev/null +++ b/iOverlay/src/mesh/int/math/float.rs @@ -0,0 +1,47 @@ +use super::backend::MeshMath; +use super::integer::IntegerMath; +use crate::mesh::int::arc::FloatArc; +use i_float::int::number::{int::IntNumber, wide_int::WideIntNumber}; +use i_float::int::{angle::Angle, point::IntPoint, unit_vector::UnitIntVector, vector::IntVector}; + +#[derive(Clone, Copy)] +pub(crate) struct FloatMath; + +impl MeshMath for FloatMath { + type Arc = FloatArc; + const MIN_MITER_ANGLE: u32 = 0; + fn sin_cos(angle: Angle) -> (i32, i32) { + angle.sin_cos_with_float() + } + + fn angle_between(from: UnitIntVector, to: UnitIntVector) -> Angle { + Angle::between_with_float(from, to) + } + + #[inline] + fn normalize(v: IntVector) -> Option> { + UnitIntVector::normalize_with_float(v) + } + + #[inline] + fn scale(direction: UnitIntVector, distance: I) -> IntVector { + direction.scale(distance) + } + + #[inline] + fn rotate(direction: UnitIntVector, local: IntPoint) -> IntVector { + >::rotate(direction, local) + } + + fn guard_padding(padding: I::Wide) -> I::Wide { + // Reserve 2^-34 relative error for normalization/rotation drift, plus + // four grid units for final coordinate rounding. i16 only needs the + // absolute reserve and its wide type cannot be shifted by 34. + let relative = if I::BITS > 16 { + padding >> 34 + } else { + I::Wide::ZERO + }; + padding + relative + I::Wide::FOUR + } +} diff --git a/iOverlay/src/mesh/int/math/integer.rs b/iOverlay/src/mesh/int/math/integer.rs new file mode 100644 index 00000000..14f76bed --- /dev/null +++ b/iOverlay/src/mesh/int/math/integer.rs @@ -0,0 +1,46 @@ +use super::backend::{DEFAULT_MITER_MIN_TURN, MeshMath}; +use super::{direction, vector}; +use crate::mesh::int::arc::IntegerArc; +use i_float::int::number::{int::IntNumber, wide_int::WideIntNumber}; +use i_float::int::{angle::Angle, point::IntPoint, unit_vector::UnitIntVector, vector::IntVector}; + +#[derive(Clone, Copy)] +pub(crate) struct IntegerMath; + +impl MeshMath for IntegerMath { + type Arc = IntegerArc; + const MIN_MITER_ANGLE: u32 = DEFAULT_MITER_MIN_TURN; + #[inline] + fn sin_cos(angle: Angle) -> (i32, i32) { + angle.sin_cos() + } + #[inline] + fn angle_between(from: UnitIntVector, to: UnitIntVector) -> Angle { + Angle::between(from, to) + } + + #[inline] + fn normalize(v: IntVector) -> Option> { + direction(v) + } + + #[inline] + fn scale(direction: UnitIntVector, distance: I) -> IntVector { + direction.scale(distance) + } + + #[inline] + fn rotate(direction: UnitIntVector, local: IntPoint) -> IntVector { + let v = vector(direction); + let shift = UnitIntVector::::DENOMINATOR.ilog2(); + IntVector::new( + (v.x * local.x.to_wide() - v.y * local.y.to_wide()).shr_round(shift), + (v.y * local.x.to_wide() + v.x * local.y.to_wide()).shr_round(shift), + ) + } + + #[inline] + fn guard_padding(padding: I::Wide) -> I::Wide { + padding + } +} diff --git a/iOverlay/src/mesh/int/mod.rs b/iOverlay/src/mesh/int/mod.rs new file mode 100644 index 00000000..11250f28 --- /dev/null +++ b/iOverlay/src/mesh/int/mod.rs @@ -0,0 +1,12 @@ +//! Mesh operations on integer input geometry. +pub mod arc; +pub mod outline; +pub mod style; + +mod join; +mod math; + +mod bounds; +pub mod stroke; + +pub mod variable_stroke; diff --git a/iOverlay/src/mesh/int/outline/bounds.rs b/iOverlay/src/mesh/int/outline/bounds.rs new file mode 100644 index 00000000..449235c8 --- /dev/null +++ b/iOverlay/src/mesh/int/outline/bounds.rs @@ -0,0 +1,35 @@ +use super::offset::IntOutlineError; +use crate::mesh::int::join::Join; +use crate::mesh::int::math::{backend::MeshMath, float::FloatMath, integer::IntegerMath}; +use crate::mesh::int::style::IntOutlineStyle; +use crate::mesh::math::MathMode; +use i_float::int::number::int::IntNumber; +use i_float::int::rect::IntRect; +use i_shape::source::int::resource::IntShapeResource; + +pub(super) trait OutlineBounds: IntShapeResource { + fn validate_outline_bounds(&self, style: &IntOutlineStyle) -> Result<(), IntOutlineError> { + let padding = match style.math { + MathMode::Integer => outline_padding::(style), + MathMode::Float => outline_padding::(style), + }; + let Some(rect) = IntRect::with_iter(self.iter_paths().flatten()) else { + return Ok(()); + }; + + if crate::mesh::int::bounds::expanded_is_safe(rect, padding) { + Ok(()) + } else { + Err(IntOutlineError::CoordinateOutOfRange) + } + } +} + +impl + ?Sized> OutlineBounds for S {} + +fn outline_padding>(style: &IntOutlineStyle) -> I::Wide { + M::guard_padding( + Join::::padding(style.join, style.outer_offset) + .max(Join::::padding(style.join, style.inner_offset)), + ) +} diff --git a/iOverlay/src/mesh/int/outline/build.rs b/iOverlay/src/mesh/int/outline/build.rs new file mode 100644 index 00000000..0085d842 --- /dev/null +++ b/iOverlay/src/mesh/int/outline/build.rs @@ -0,0 +1,162 @@ +use super::builder::OutlineBuilder; +use crate::core::extract::BooleanExtractionBuffer; +use crate::core::fill_rule::FillRule; +use crate::core::integer::OverlayInt; +use crate::core::overlay::{ContourDirection, IntOverlayOptions, Overlay, ShapeType}; +use crate::core::overlay_rule::OverlayRule; +use crate::mesh::int::join::Join; +use crate::mesh::int::math::{backend::MeshMath, float::FloatMath, integer::IntegerMath}; +use crate::mesh::int::style::IntOutlineStyle; +use crate::mesh::math::MathMode; +use alloc::vec::Vec; +use i_float::int::number::uint::UIntNumber; +use i_float::int::number::wide_int::WideIntNumber; +use i_float::int::point::IntPoint; +use i_shape::flat::buffer::FlatContoursBuffer; +use i_shape::int::area::IteratorArea; + +pub(crate) trait BuildOutlineOverlay: Sized { + fn build_overlay(self, style: &IntOutlineStyle, options: IntOverlayOptions) + -> Overlay; +} + +impl BuildOutlineOverlay for Paths +where + Paths: IntoIterator, + Path: ExactSizeIterator> + Clone, +{ + fn build_overlay( + self, + style: &IntOutlineStyle, + options: IntOverlayOptions, + ) -> Overlay { + match style.math { + MathMode::Integer => { + build_outline_overlay_with_math::(self, style, options) + } + MathMode::Float => build_outline_overlay_with_math::(self, style, options), + } + } +} + +fn build_outline_overlay_with_math( + paths: Paths, + style: &IntOutlineStyle, + options: IntOverlayOptions, +) -> Overlay +where + I: OverlayInt, + M: MeshMath, + Paths: IntoIterator, + Path: ExactSizeIterator> + Clone, +{ + let mut outer_builder = OutlineBuilder::new( + style.outer_offset, + Join::::new(style.join, style.miter_min_turn), + ); + let mut inner_builder = OutlineBuilder::new( + style.inner_offset, + Join::::new(style.join, style.miter_min_turn), + ); + + let mut overlay = Overlay::new_custom(0, options, Default::default()); + let mut contour_options = options; + + // Contours below the threshold can join into a larger surviving result. + contour_options.min_output_area = I::WideUInt::ZERO; + + let mut contour_overlay = Overlay::new_custom(0, contour_options, Default::default()); + let mut segments = Vec::new(); + let mut extraction = BooleanExtractionBuffer::default(); + let mut contours = FlatContoursBuffer::default(); + + for path in paths { + if path.len() < 3 { + continue; + } + let area = path.clone().area_two(); + if area == I::Wide::ZERO { + continue; + } + let (builder, direction, fill) = if area > I::Wide::ZERO { + ( + &mut outer_builder, + ContourDirection::CounterClockwise, + FillRule::Positive, + ) + } else { + ( + &mut inner_builder, + ContourDirection::Clockwise, + FillRule::Negative, + ) + }; + segments.clear(); + builder.build(path, &mut segments); + + contour_overlay.options.output_direction = direction; + contour_overlay.clear(); + contour_overlay.add_segments(&segments); + if let Some(graph) = contour_overlay.build_graph_view(fill) { + graph.extract_contours_into(OverlayRule::Subject, &mut extraction, &mut contours); + overlay.add_source(&contours, ShapeType::Subject); + } + } + overlay +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mesh::int::outline::offset::IntOutlineOffset; + use alloc::vec; + use core::cell::Cell; + + #[test] + fn cloned_path_iterators_match_resource_outlines() { + let paths = vec![ + vec![], + vec![IntPoint::new(0, 0), IntPoint::new(0, 0)], + vec![ + IntPoint::new(0, 0), + IntPoint::new(1024, 0), + IntPoint::new(2048, 0), + ], + vec![ + IntPoint::new(0, 0), + IntPoint::new(0, 0), + IntPoint::new(8192, 0), + IntPoint::new(8192, 8192), + IntPoint::new(0, 8192), + ], + vec![ + IntPoint::new(2048, 2048), + IntPoint::new(2048, 6144), + IntPoint::new(6144, 6144), + IntPoint::new(6144, 2048), + ], + ]; + let style = IntOutlineStyle::new(1024); + let expected = paths.outline(&style).unwrap(); + + let visited = Cell::new(0); + let paths_visited = Cell::new(0); + let iter = paths.iter().map(|path| { + paths_visited.set(paths_visited.get() + 1); + path.iter().copied().inspect(|_| { + visited.set(visited.get() + 1); + }) + }); + + let actual = iter + .build_overlay(&style, Default::default()) + .overlay(OverlayRule::Subject, FillRule::Positive); + assert_eq!(actual, expected); + assert_eq!(paths_visited.get(), paths.len()); + // Short paths are skipped; zero-area paths need only the area pass. + assert_eq!( + visited.get(), + paths[2].len() + 2 * (paths[3].len() + paths[4].len()) + ); + } +} diff --git a/iOverlay/src/mesh/int/outline/builder.rs b/iOverlay/src/mesh/int/outline/builder.rs new file mode 100644 index 00000000..2f04faf2 --- /dev/null +++ b/iOverlay/src/mesh/int/outline/builder.rs @@ -0,0 +1,69 @@ +use super::builder_join::JoinBuilder; +use super::section::OffsetSection; +use crate::mesh::int::{join::Join, math::backend::MeshMath}; +use crate::mesh::subject::SubjectSegments; +use crate::mesh::uniq_iter::UniqueSegmentsIter; +use crate::segm::boolean::ShapeCountBoolean; +use crate::segm::segment::Segment; +use alloc::vec::Vec; +use i_float::int::number::int::IntNumber; +use i_float::int::number::wide_int::WideIntNumber; +use i_float::int::point::IntPoint; + +pub(super) struct OutlineBuilder> { + offset: I, + join_builder: Join, +} + +impl> OutlineBuilder { + pub(super) fn new(offset: I, join_builder: Join) -> Self { + Self { offset, join_builder } + } + + pub(super) fn build(&mut self, path: Path, segments: &mut Vec>) + where + Path: Iterator>, + { + let Some(mut iter) = UniqueSegmentsIter::new(path) else { + return; + }; + let Some(first) = iter.next() else { + return; + }; + let first = OffsetSection::new::(first, self.offset); + segments.push_non_degenerate(first.a_top, first.b_top); + let mut previous = first; + for segment in iter { + let next = OffsetSection::new::(segment, self.offset); + segments.push_non_degenerate(next.a_top, next.b_top); + self.feed_join(&previous, &next, segments); + previous = next; + } + self.feed_join(&previous, &first, segments); + } + + #[inline] + fn feed_join( + &mut self, + previous: &OffsetSection, + next: &OffsetSection, + segments: &mut Vec>, + ) { + let vi = next.b - next.a; + let vp = previous.b - previous.a; + let cross = vi.cross_product(vp); + let outer_corner = if cross != I::Wide::ZERO { + (cross > I::Wide::ZERO) == (self.offset < I::ZERO) + } else { + vi.dot_product(vp) < I::Wide::ZERO + }; + if outer_corner { + if previous.b_top != next.a_top { + self.join_builder.add_join(previous, next, segments); + } + } else { + segments.push_non_degenerate(previous.b_top, previous.b); + segments.push_non_degenerate(next.a, next.a_top); + } + } +} diff --git a/iOverlay/src/mesh/int/outline/builder_join.rs b/iOverlay/src/mesh/int/outline/builder_join.rs new file mode 100644 index 00000000..77e70225 --- /dev/null +++ b/iOverlay/src/mesh/int/outline/builder_join.rs @@ -0,0 +1,43 @@ +use super::section::OffsetSection; +use crate::segm::boolean::ShapeCountBoolean; +use crate::segm::segment::Segment; +use alloc::vec::Vec; +use i_float::int::number::int::IntNumber; + +pub(super) trait JoinBuilder { + /// Connects the offset endpoints of an outer corner. The endpoints differ. + fn add_join( + &mut self, + previous: &OffsetSection, + next: &OffsetSection, + segments: &mut Vec>, + ); +} + +impl> JoinBuilder + for crate::mesh::int::join::Join +{ + fn add_join( + &mut self, + previous: &OffsetSection, + next: &OffsetSection, + segments: &mut Vec>, + ) { + let direction = if previous.offset >= I::ZERO { + crate::mesh::int::arc::ArcDirection::Counterclockwise + } else { + crate::mesh::int::arc::ArcDirection::Clockwise + }; + let radius = I::from_wide(crate::mesh::int::math::abs(previous.offset)); + self.add( + previous.b, + previous.b_top, + next.a_top, + previous.direction, + next.direction, + radius, + direction, + segments, + ); + } +} diff --git a/iOverlay/src/mesh/int/outline/mod.rs b/iOverlay/src/mesh/int/outline/mod.rs new file mode 100644 index 00000000..2e32f2de --- /dev/null +++ b/iOverlay/src/mesh/int/outline/mod.rs @@ -0,0 +1,8 @@ +mod bounds; +mod build; +mod builder; +mod builder_join; +pub mod offset; +mod section; + +pub(crate) use build::BuildOutlineOverlay; diff --git a/iOverlay/src/mesh/int/outline/offset.rs b/iOverlay/src/mesh/int/outline/offset.rs new file mode 100644 index 00000000..cd4c6381 --- /dev/null +++ b/iOverlay/src/mesh/int/outline/offset.rs @@ -0,0 +1,115 @@ +//! Integer outline construction used directly and by the float adapter. +use super::bounds::OutlineBounds; +use super::build::BuildOutlineOverlay; +use crate::core::fill_rule::FillRule; +use crate::core::integer::OverlayInt; +use crate::core::overlay::IntOverlayOptions; +use crate::core::overlay_rule::OverlayRule; +use crate::mesh::int::style::IntOutlineStyle; +use i_shape::flat::buffer::FlatContoursBuffer; +use i_shape::int::shape::IntShapes; +use i_shape::source::int::resource::IntShapeResource; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IntOutlineError { + /// The conservative expanded input bounds fail the coordinate-range check. + /// Returned by optional mesh validation, not by construction. + CoordinateOutOfRange, +} + +impl core::fmt::Display for IntOutlineError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::CoordinateOutOfRange => f.write_str("mesh coordinate exceeds the integer engine range"), + } + } +} + +impl core::error::Error for IntOutlineError {} + +/// Offsets integer contours with CCW outer boundaries and CW holes. +/// +/// Positive offsets expand filled geometry; negative offsets shrink it. Zero +/// offset still runs contour cleanup and union. Degenerate zero-area contours +/// are ignored. As with float outline, input winding determines outer/hole roles. +/// Input and constructed coordinates must satisfy +/// [`IntRect::is_in_safe_range`](i_float::int::rect::IntRect::is_in_safe_range). +/// Use [`Self::validate_outline`] for an optional conservative bounds check. +/// Construction trusts the caller to satisfy this precondition. +/// Contour signed double areas must also fit in `I::Wide` +/// (including repeated winding). +/// +/// Construction arithmetic is selected by [`IntOutlineStyle::math`]. +/// Integer math is deterministic; float math may produce different rounded vertices. +/// +/// ``` +/// use i_overlay::i_float::int::point::IntPoint; +/// use i_overlay::mesh::int::style::IntOutlineStyle; +/// use i_overlay::mesh::int::outline::offset::IntOutlineOffset; +/// +/// let contour = [ +/// IntPoint::new(0_i32, 0), IntPoint::new(16_384, 0), +/// IntPoint::new(16_384, 16_384), IntPoint::new(0, 16_384), +/// ]; +/// let style = IntOutlineStyle::new(1_024); +/// contour.validate_outline(&style)?; // Optional bounds check before construction. +/// let result = contour.outline(&style)?; +/// assert_eq!(result.len(), 1); +/// # Ok::<(), i_overlay::mesh::int::outline::offset::IntOutlineError>(()) +/// ``` +pub trait IntOutlineOffset: IntShapeResource { + /// Checks the coordinate range of the prospective operation. + /// + /// Computes input bounds, expands by the maximum outer/inner join padding, + /// and checks `IntRect::is_in_safe_range`. Padding uses absolute offsets even + /// for shrinking outlines, since temporary edges must also stay in range. + /// The estimate is conservative and may reject geometry whose actual points + /// would fit. Empty input passes. This does not validate + /// winding, topology, or the accumulated area of repeated winding. + fn validate_outline(&self, style: &IntOutlineStyle) -> Result<(), IntOutlineError> { + self.validate_outline_bounds(style) + } + + fn outline(&self, style: &IntOutlineStyle) -> Result, IntOutlineError> { + self.outline_custom(style, Default::default()) + } + + /// Replaces output on success, including an empty result. Errors leave it unchanged. + fn outline_into( + &self, + style: &IntOutlineStyle, + output: &mut FlatContoursBuffer, + ) -> Result<(), IntOutlineError> { + self.outline_custom_into(style, Default::default(), output) + } + + fn outline_custom( + &self, + style: &IntOutlineStyle, + options: IntOverlayOptions, + ) -> Result, IntOutlineError> { + let mut overlay = self + .iter_paths() + .map(|path| path.iter().copied()) + .build_overlay(style, options); + Ok(overlay.overlay(OverlayRule::Subject, FillRule::Positive)) + } + + /// Replaces output on success; errors leave it unchanged. + /// Area filtering is applied after contour union. + fn outline_custom_into( + &self, + style: &IntOutlineStyle, + options: IntOverlayOptions, + output: &mut FlatContoursBuffer, + ) -> Result<(), IntOutlineError> { + let mut overlay = self + .iter_paths() + .map(|path| path.iter().copied()) + .build_overlay(style, options); + overlay.overlay_into(OverlayRule::Subject, FillRule::Positive, output); + Ok(()) + } +} + +impl + ?Sized> IntOutlineOffset for S {} diff --git a/iOverlay/src/mesh/int/outline/section.rs b/iOverlay/src/mesh/int/outline/section.rs new file mode 100644 index 00000000..62d30484 --- /dev/null +++ b/iOverlay/src/mesh/int/outline/section.rs @@ -0,0 +1,47 @@ +use crate::mesh::int::math::{backend::MeshMath, point}; +use crate::mesh::uniq_iter::UniqueSegment; +use i_float::int::number::int::IntNumber; +use i_float::int::point::IntPoint; +use i_float::int::unit_vector::UnitIntVector; + +#[derive(Clone, Copy)] +pub(super) struct OffsetSection { + pub(super) offset: I, + pub(super) direction: UnitIntVector, + pub(super) a: IntPoint, + pub(super) b: IntPoint, + pub(super) a_top: IntPoint, + pub(super) b_top: IntPoint, +} + +impl OffsetSection { + pub(super) fn new>(segment: UniqueSegment, offset: I) -> Self { + let (a, b) = (segment.a, segment.b); + let direction = M::normalize(b - a).expect("unique segment"); + if offset == I::ZERO { + return Self { + offset, + direction, + a, + b, + a_top: a, + b_top: b, + }; + } + let scaled = M::scale(direction, offset); + + let dx = scaled.y; + let dy = -scaled.x; + let a_top = point(a, dx, dy); + let b_top = point(b, dx, dy); + + Self { + offset, + direction, + a, + b, + a_top, + b_top, + } + } +} diff --git a/iOverlay/src/mesh/int/stroke/bounds.rs b/iOverlay/src/mesh/int/stroke/bounds.rs new file mode 100644 index 00000000..8cc6b100 --- /dev/null +++ b/iOverlay/src/mesh/int/stroke/bounds.rs @@ -0,0 +1,63 @@ +use super::offset::IntStrokeError; +use crate::core::integer::OverlayInt; +use crate::mesh::int::{ + join::Join, + math::{backend::MeshMath, float::FloatMath, integer::IntegerMath}, + style::{IntLineCap, IntStrokeStyle}, +}; +use crate::mesh::math::MathMode; +use i_float::int::{ + number::{int::IntNumber, uint::UIntNumber, wide_int::WideIntNumber}, + rect::IntRect, +}; +use i_shape::source::int::resource::IntShapeResource; + +pub(super) trait StrokeBounds: IntShapeResource { + fn validate_stroke_bounds(&self, style: &IntStrokeStyle) -> Result<(), IntStrokeError> { + if let Some(rect) = IntRect::with_iter(self.iter_paths().flatten()) + && !crate::mesh::int::bounds::expanded_is_safe(rect, stroke_padding(style)) + { + return Err(IntStrokeError::CoordinateOutOfRange); + } + Ok(()) + } +} + +impl + ?Sized> StrokeBounds for S {} + +fn stroke_padding(style: &IntStrokeStyle) -> I::Wide { + match style.math { + MathMode::Integer => stroke_padding_with_math::(style), + MathMode::Float => stroke_padding_with_math::(style), + } +} + +pub(super) fn stroke_radius(style: &IntStrokeStyle) -> I { + I::from_wide((style.width.max(I::ZERO).to_wide() + I::Wide::ONE) / I::Wide::TWO) +} +pub(super) fn stroke_padding_with_math>(style: &IntStrokeStyle) -> I::Wide { + let radius = stroke_radius(style); + let cap_padding = |cap: &IntLineCap| match cap { + IntLineCap::Square => radius.to_wide() * I::Wide::TWO, + IntLineCap::Custom(points) => points + .iter() + .map(|p| { + let d = i_float::int::vector::IntVector::::new(p.x.to_wide(), p.y.to_wide()).sqr_length(); + let root = d.isqrt(); + I::Wide::from_uint(root) + + if root * root < d { + I::Wide::ONE + } else { + I::Wide::ZERO + } + }) + .max() + .unwrap_or(I::Wide::ZERO), + _ => radius.to_wide(), + }; + M::guard_padding( + Join::::padding(style.join, radius) + .max(cap_padding(&style.start_cap)) + .max(cap_padding(&style.end_cap)), + ) +} diff --git a/iOverlay/src/mesh/int/stroke/build.rs b/iOverlay/src/mesh/int/stroke/build.rs new file mode 100644 index 00000000..b76495c6 --- /dev/null +++ b/iOverlay/src/mesh/int/stroke/build.rs @@ -0,0 +1,153 @@ +#[cfg(debug_assertions)] +use super::bounds::stroke_padding_with_math; +use super::{builder::StrokeBuilder, offset::IntStrokeOffset}; +use crate::core::{ + integer::OverlayInt, + overlay::{IntOverlayOptions, Overlay}, +}; +use crate::mesh::int::{ + math::{backend::MeshMath, float::FloatMath, integer::IntegerMath}, + style::IntStrokeStyle, +}; +use crate::mesh::math::MathMode; +use alloc::vec::Vec; +use i_float::int::point::IntPoint; +#[cfg(debug_assertions)] +use i_float::int::rect::IntRect; + +pub(super) trait BuildStrokeOverlay: IntStrokeOffset { + fn build_stroke_overlay( + &self, + style: &IntStrokeStyle, + closed: bool, + options: IntOverlayOptions, + ) -> Overlay { + build_stroke_overlay_iter( + self.iter_paths().map(|path| path.iter().copied()), + style, + closed, + options, + ) + } +} +impl + ?Sized> BuildStrokeOverlay for S {} + +/// Builds one overlay from paths consumed once, without retaining input points. +pub(crate) fn build_stroke_overlay_iter( + paths: Paths, + style: &IntStrokeStyle, + closed: bool, + options: IntOverlayOptions, +) -> Overlay +where + I: OverlayInt, + Paths: IntoIterator, + Path: IntoIterator>, +{ + match style.math { + MathMode::Integer => { + build_stroke_overlay_with_math::(paths, style, closed, options) + } + MathMode::Float => { + build_stroke_overlay_with_math::(paths, style, closed, options) + } + } +} + +fn build_stroke_overlay_with_math( + paths: Paths, + style: &IntStrokeStyle, + closed: bool, + options: IntOverlayOptions, +) -> Overlay +where + I: OverlayInt, + M: MeshMath, + Paths: IntoIterator, + Path: IntoIterator>, +{ + let mut builder = StrokeBuilder::::new(style); + let mut segments = Vec::new(); + #[cfg(debug_assertions)] + let padding = stroke_padding_with_math::(style); + for path in paths { + #[cfg(debug_assertions)] + let path = path.into_iter().inspect(|point| { + // Checking each expanded point is equivalent to checking the expanded + // input rectangle, and happens before the point enters geometry math. + debug_assert!( + crate::mesh::int::bounds::expanded_is_safe(IntRect::with_point(*point), padding), + "stroke bounds exceed the safe coordinate range" + ); + }); + builder.build(path, closed, &mut segments); + } + let mut overlay = Overlay::with_segments(segments); + overlay.options = options; + overlay +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::{fill_rule::FillRule, overlay_rule::OverlayRule}; + use alloc::vec; + use core::cell::Cell; + + #[test] + fn one_pass_paths_match_resource_strokes() { + let paths = vec![ + vec![], + vec![IntPoint::new(0, 0)], + vec![ + IntPoint::new(0, 0), + IntPoint::new(0, 0), + IntPoint::new(8192, 0), + IntPoint::new(8192, 8192), + ], + vec![IntPoint::new(4096, -4096), IntPoint::new(4096, 4096)], + vec![], + ]; + for math in [MathMode::Integer, MathMode::Float] { + let style = IntStrokeStyle::new(1024).math(math); + for closed in [false, true] { + let expected = paths.stroke(&style, closed).unwrap(); + let visited = Cell::new(0); + let visited_ref = &visited; + let mut expected_visited = 0; + let iter = paths.clone().into_iter().map(move |path| { + // Finish each path before requesting the next one. + assert_eq!(visited_ref.get(), expected_visited); + expected_visited += path.len(); + let mut points = path.into_iter(); + // A single-pass point iterator; no Clone or slice access required. + core::iter::from_fn(move || { + let point = points.next()?; + visited_ref.set(visited_ref.get() + 1); + Some(point) + }) + }); + let actual = build_stroke_overlay_iter(iter, &style, closed, Default::default()) + .overlay(OverlayRule::Subject, FillRule::Positive); + assert_eq!(actual, expected); + assert_eq!(visited.get(), paths.iter().map(Vec::len).sum::()); + } + } + } + + #[cfg(debug_assertions)] + #[test] + #[should_panic(expected = "stroke bounds exceed the safe coordinate range")] + fn iterator_checks_range_before_building_section() { + let paths = [[IntPoint::new(0, 0), IntPoint::new(i32::MAX, 0)]]; + build_stroke_overlay_iter(paths, &IntStrokeStyle::new(1024), false, Default::default()); + } + + #[cfg(debug_assertions)] + #[test] + #[should_panic(expected = "stroke bounds exceed the safe coordinate range")] + fn zero_radius_still_checks_range() { + let paths = [[IntPoint::new(i32::MAX, 0)]]; + build_stroke_overlay_iter(paths, &IntStrokeStyle::new(0), false, Default::default()); + } +} diff --git a/iOverlay/src/mesh/int/stroke/builder.rs b/iOverlay/src/mesh/int/stroke/builder.rs new file mode 100644 index 00000000..6905dc59 --- /dev/null +++ b/iOverlay/src/mesh/int/stroke/builder.rs @@ -0,0 +1,144 @@ +use super::{bounds::stroke_radius, builder_join::JoinBuilder, cap::Cap, section::Section}; +use crate::mesh::int::{ + join::Join, + math::{backend::MeshMath, integer::IntegerMath}, + style::IntStrokeStyle, +}; +use crate::segm::{boolean::ShapeCountBoolean, segment::Segment}; +use alloc::vec::Vec; +use i_float::int::{number::int::IntNumber, point::IntPoint}; + +pub(super) struct StrokeBuilder = IntegerMath> { + radius: I, + join: Join, + start: Cap, + end: Cap, + sections: Vec>, +} +impl> StrokeBuilder { + pub(super) fn new(style: &IntStrokeStyle) -> Self { + Self { + radius: stroke_radius(style), + join: Join::new(style.join, style.miter_min_turn), + start: Cap::new(&style.start_cap), + end: Cap::new(&style.end_cap), + sections: Vec::new(), + } + } + pub(super) fn build( + &mut self, + path: impl IntoIterator>, + closed: bool, + segments: &mut Vec>, + ) { + self.sections.clear(); + if self.radius <= I::ONE { + // Still consume points for the caller's debug range validation. + #[cfg(debug_assertions)] + for _ in path {} + return; + } + let mut path = path.into_iter(); + let Some(first) = path.next() else { + return; + }; + let mut previous = first; + for next in path { + if previous != next { + self.sections.push(Section::new::(previous, next, self.radius)); + previous = next; + } + } + if closed && previous != first { + self.sections + .push(Section::new::(previous, first, self.radius)); + } + if self.sections.is_empty() { + return; + } + for s in &self.sections { + s.add(segments); + } + for i in 1..self.sections.len() { + self.join + .add_join(self.sections[i - 1], self.sections[i], self.radius, segments); + } + let first = self.sections[0]; + let last = *self.sections.last().unwrap(); + if closed { + self.join.add_join(last, first, self.radius, segments); + } else { + let backward = M::normalize(first.a - first.b).unwrap(); + self.start.add( + first.a, + first.a_left, + first.a_right, + backward, + self.radius, + segments, + ); + self.end + .add(last.b, last.b_right, last.b_left, last.dir, self.radius, segments); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mesh::int::{ + arc::ArcOptions, + style::{IntLineCap, IntLineJoin}, + }; + use i_float::int::angle::Angle; + + #[test] + fn joins_and_caps_skip_collapsed_edges() { + for width in [0, 1, 2, 4, 1024] { + let radius = (width + 1) / 2; + for cap in [ + IntLineCap::Butt, + IntLineCap::Square, + IntLineCap::Round(ArcOptions::default()), + IntLineCap::Custom( + alloc::vec![ + IntPoint::new(0, -radius), + IntPoint::new(0, -radius), + IntPoint::new(radius, 0), + IntPoint::new(0, radius) + ] + .into(), + ), + ] { + for join in [ + IntLineJoin::Bevel, + IntLineJoin::Miter(Angle::from_bits(1 << 26)), + IntLineJoin::Miter(Angle::from_bits(2_000_000_000)), + IntLineJoin::Round(ArcOptions::default()), + ] { + let style = IntStrokeStyle::new(width) + .start_cap(cap.clone()) + .end_cap(cap.clone()) + .line_join(join); + let mut builder = StrokeBuilder::::new(&style); + for closed in [false, true] { + for end in [IntPoint::new(10, 10), IntPoint::new(0, 0), IntPoint::new(20, 1)] { + let path = [ + IntPoint::new(0, 0), + IntPoint::new(0, 0), + IntPoint::new(10, 0), + end, + ]; + let mut segments = Vec::new(); + builder.build(path, closed, &mut segments); + assert!(segments.iter().all(|s| s.x_segment.a < s.x_segment.b)); + if width >= 4 { + assert!(!segments.is_empty()); + } + } + } + } + } + } + } +} diff --git a/iOverlay/src/mesh/int/stroke/builder_join.rs b/iOverlay/src/mesh/int/stroke/builder_join.rs new file mode 100644 index 00000000..fe638808 --- /dev/null +++ b/iOverlay/src/mesh/int/stroke/builder_join.rs @@ -0,0 +1,53 @@ +use super::section::Section; +use crate::mesh::int::{arc::ArcDirection, join::Join, math::backend::MeshMath}; +use crate::mesh::subject::SubjectSegments; +use crate::segm::{boolean::ShapeCountBoolean, segment::Segment}; +use alloc::vec::Vec; +use i_float::int::number::{int::IntNumber, wide_int::WideIntNumber}; + +pub(super) trait JoinBuilder { + fn add_join( + &mut self, + a: Section, + b: Section, + radius: I, + segments: &mut Vec>, + ); +} + +impl> JoinBuilder for Join { + fn add_join( + &mut self, + a: Section, + b: Section, + radius: I, + segments: &mut Vec>, + ) { + let cross = (a.b - a.a).cross_product(b.b - b.a); + let (from, to, incoming, outgoing) = if cross >= I::Wide::ZERO { + (a.b_right, b.a_right, a.dir, b.dir) + } else { + ( + b.a_left, + a.b_left, + M::normalize(b.a - b.b).unwrap(), + M::normalize(a.a - a.b).unwrap(), + ) + }; + if cross >= I::Wide::ZERO { + segments.push_non_degenerate(b.a_left, a.b_left); + } else { + segments.push_non_degenerate(a.b_right, b.a_right); + } + self.add( + a.b, + from, + to, + incoming, + outgoing, + radius, + ArcDirection::Counterclockwise, + segments, + ); + } +} diff --git a/iOverlay/src/mesh/int/stroke/cap.rs b/iOverlay/src/mesh/int/stroke/cap.rs new file mode 100644 index 00000000..9d07f8ff --- /dev/null +++ b/iOverlay/src/mesh/int/stroke/cap.rs @@ -0,0 +1,70 @@ +use crate::mesh::int::{ + arc::{ArcDirection, ArcMath}, + math::{backend::MeshMath, point}, + style::IntLineCap, +}; +use crate::mesh::subject::SubjectSegments; +use crate::segm::{boolean::ShapeCountBoolean, segment::Segment}; +use alloc::vec::Vec; +use i_float::int::{number::int::IntNumber, point::IntPoint, unit_vector::UnitIntVector}; + +pub(super) enum Cap> { + Butt, + Square, + Round(M::Arc), + Custom(alloc::rc::Rc<[IntPoint]>), +} +impl> Cap { + pub(super) fn new(cap: &IntLineCap) -> Self { + match cap { + IntLineCap::Butt => Self::Butt, + IntLineCap::Square => Self::Square, + IntLineCap::Round(options) => Self::Round(M::Arc::new(*options)), + IntLineCap::Custom(points) => Self::Custom(points.clone()), + } + } + pub(super) fn add( + &mut self, + center: IntPoint, + from: IntPoint, + to: IntPoint, + outward: UnitIntVector, + radius: I, + segments: &mut Vec>, + ) { + if matches!(self, Self::Butt) { + segments.push_non_degenerate(from, to); + return; + } + let mut previous = from; + match self { + Self::Round(arc) => { + let a = M::normalize(from - center).expect("positive radius"); + let b = M::normalize(to - center).expect("positive radius"); + for &dir in arc.build(a, b, ArcDirection::Counterclockwise) { + let offset = M::scale(dir, radius); + let next = point(center, offset.x, offset.y); + segments.push_non_degenerate(previous, next); + previous = next; + } + } + Self::Square => { + let v = M::scale(outward, radius); + for next in [point(from, v.x, v.y), point(to, v.x, v.y)] { + segments.push_non_degenerate(previous, next); + previous = next; + } + } + Self::Custom(points) => { + for p in points.iter() { + let v = M::rotate(outward, *p); + let next = point(center, v.x, v.y); + segments.push_non_degenerate(previous, next); + previous = next; + } + } + Self::Butt => {} + } + segments.push_non_degenerate(previous, to); + } +} diff --git a/iOverlay/src/mesh/int/stroke/mod.rs b/iOverlay/src/mesh/int/stroke/mod.rs new file mode 100644 index 00000000..94c5c18f --- /dev/null +++ b/iOverlay/src/mesh/int/stroke/mod.rs @@ -0,0 +1,9 @@ +mod bounds; +mod build; +mod builder; +mod builder_join; +mod cap; +pub mod offset; +mod section; + +pub(crate) use build::build_stroke_overlay_iter; diff --git a/iOverlay/src/mesh/int/stroke/offset.rs b/iOverlay/src/mesh/int/stroke/offset.rs new file mode 100644 index 00000000..8632b696 --- /dev/null +++ b/iOverlay/src/mesh/int/stroke/offset.rs @@ -0,0 +1,66 @@ +use super::{bounds::StrokeBounds, build::BuildStrokeOverlay}; +use crate::core::{ + fill_rule::FillRule, integer::OverlayInt, overlay::IntOverlayOptions, overlay_rule::OverlayRule, +}; +use crate::mesh::int::outline::offset::IntOutlineError; +use crate::mesh::int::style::IntStrokeStyle; +use i_shape::{ + flat::buffer::FlatContoursBuffer, int::shape::IntShapes, source::int::resource::IntShapeResource, +}; + +pub type IntStrokeError = IntOutlineError; + +/// Strokes integer paths with radius `ceil(max(width, 0) / 2)`. +/// Radii at most one produce no geometry. +/// Coordinates, caps and temporary joins must stay in the engine's safe range. +/// Use [`Self::validate_stroke`] for an optional conservative bounds check. +/// +/// ``` +/// use i_float::int::point::IntPoint; +/// use i_overlay::mesh::int::{stroke::offset::IntStrokeOffset, style::IntStrokeStyle}; +/// let path = [IntPoint::new(0, 0), IntPoint::new(8192, 0)]; +/// let style = IntStrokeStyle::new(2048); +/// path.validate_stroke(&style).unwrap(); +/// assert_eq!(path.stroke(&style, false).unwrap().len(), 1); +/// ``` +pub trait IntStrokeOffset: IntShapeResource { + fn validate_stroke(&self, style: &IntStrokeStyle) -> Result<(), IntStrokeError> { + self.validate_stroke_bounds(style) + } + fn stroke(&self, style: &IntStrokeStyle, closed: bool) -> Result, IntStrokeError> { + self.stroke_custom(style, closed, Default::default()) + } + fn stroke_into( + &self, + style: &IntStrokeStyle, + closed: bool, + output: &mut FlatContoursBuffer, + ) -> Result<(), IntStrokeError> { + self.stroke_custom_into(style, closed, Default::default(), output) + } + fn stroke_custom( + &self, + style: &IntStrokeStyle, + closed: bool, + options: IntOverlayOptions, + ) -> Result, IntStrokeError> { + Ok(self + .build_stroke_overlay(style, closed, options) + .overlay(OverlayRule::Subject, FillRule::Positive)) + } + fn stroke_custom_into( + &self, + style: &IntStrokeStyle, + closed: bool, + options: IntOverlayOptions, + output: &mut FlatContoursBuffer, + ) -> Result<(), IntStrokeError> { + self.build_stroke_overlay(style, closed, options).overlay_into( + OverlayRule::Subject, + FillRule::Positive, + output, + ); + Ok(()) + } +} +impl + ?Sized> IntStrokeOffset for S {} diff --git a/iOverlay/src/mesh/int/stroke/section.rs b/iOverlay/src/mesh/int/stroke/section.rs new file mode 100644 index 00000000..82792bee --- /dev/null +++ b/iOverlay/src/mesh/int/stroke/section.rs @@ -0,0 +1,37 @@ +use crate::mesh::int::math::{backend::MeshMath, point}; +use crate::mesh::subject::SubjectSegments; +use crate::segm::{boolean::ShapeCountBoolean, segment::Segment}; +use alloc::vec::Vec; +use i_float::int::{number::int::IntNumber, point::IntPoint, unit_vector::UnitIntVector}; + +#[derive(Clone, Copy)] +pub(super) struct Section { + pub(super) a: IntPoint, + pub(super) b: IntPoint, + pub(super) a_left: IntPoint, + pub(super) a_right: IntPoint, + pub(super) b_left: IntPoint, + pub(super) b_right: IntPoint, + pub(super) dir: UnitIntVector, +} + +impl Section { + pub(super) fn new>(a: IntPoint, b: IntPoint, radius: I) -> Self { + let dir = M::normalize(b - a).expect("unique section"); + let v = M::scale(dir, radius); + Self { + a, + b, + dir, + a_left: point(a, -v.y, v.x), + a_right: point(a, v.y, -v.x), + b_left: point(b, -v.y, v.x), + b_right: point(b, v.y, -v.x), + } + } + pub(super) fn add(&self, segments: &mut Vec>) { + for (a, b) in [(self.a_right, self.b_right), (self.b_left, self.a_left)] { + segments.push_non_degenerate(a, b); + } + } +} diff --git a/iOverlay/src/mesh/int/style.rs b/iOverlay/src/mesh/int/style.rs new file mode 100644 index 00000000..48b13694 --- /dev/null +++ b/iOverlay/src/mesh/int/style.rs @@ -0,0 +1,151 @@ +use super::arc::ArcOptions; +use super::math::backend::DEFAULT_MITER_MIN_TURN; +use crate::mesh::math::MathMode; +use i_float::int::angle::Angle; +use i_float::int::number::int::IntNumber; + +/// Join styles for integer mesh operations. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum IntLineJoin { + #[default] + Bevel, + /// Clipped miter, limited by the minimum interior angle. + /// Integer math clamps the minimum interior angle to at least 5 degrees. + /// Float math clamps the minimum to 0.01*pi (1.8 degrees). + /// Both default to bevel joins for turns below 5 degrees; the style's + /// `miter_min_turn` configures this cutoff independently. Both clamp the maximum to one Angle unit + /// below pi. The floating-point LineJoin adapter additionally clamps its + /// input angle to 0.01*pi..=0.99*pi before conversion to this type. + Miter(Angle), + /// Rounded join with reusable integer rotation settings. + Round(ArcOptions), +} + +/// Signed offsets in input coordinate units. Positive values expand the filled +/// shape: the outer boundary grows and holes shrink. Negative values reverse it. +/// Distances have no fractional part and are never automatically rescaled. +#[derive(Debug, Clone, Copy)] +pub struct IntOutlineStyle { + pub outer_offset: I, + pub inner_offset: I, + pub join: IntLineJoin, + /// Miter turns below this angle use bevel joins in both math modes. + /// Defaults to 5 degrees; clamped to 0..=pi during construction. + /// Zero disables the cutoff. Smaller values allow less stable intersections. + /// Ignored for bevel and round joins. + pub miter_min_turn: Angle, + /// Arithmetic used to construct offsets and joins. + pub math: MathMode, +} + +impl IntOutlineStyle { + pub fn new(offset: I) -> Self { + Self { + outer_offset: offset, + inner_offset: offset, + join: IntLineJoin::Bevel, + miter_min_turn: Angle::from_bits(DEFAULT_MITER_MIN_TURN), + math: MathMode::Integer, + } + } + + /// Selects construction arithmetic; boolean operations stay integer. + pub fn math(mut self, math: MathMode) -> Self { + self.math = math; + self + } + + pub fn offset(mut self, offset: I) -> Self { + self.outer_offset = offset; + self.inner_offset = offset; + self + } + + pub fn outer_offset(mut self, offset: I) -> Self { + self.outer_offset = offset; + self + } + + pub fn inner_offset(mut self, offset: I) -> Self { + self.inner_offset = offset; + self + } + + pub fn line_join(mut self, join: IntLineJoin) -> Self { + self.join = join; + self + } + + /// Sets the near-straight bevel cutoff, independently of the miter clipping angle. + pub fn miter_min_turn(mut self, angle: Angle) -> Self { + self.miter_min_turn = angle; + self + } +} + +/// End caps for integer strokes. Custom points are local displacements in input +/// coordinate units, with x pointing outward and y pointing to its left. They +/// are already scaled to the desired size; no fractional template scale is used. +#[derive(Debug, Clone)] +pub enum IntLineCap { + Butt, + Square, + Round(ArcOptions), + Custom(alloc::rc::Rc<[i_float::int::point::IntPoint]>), +} + +#[derive(Debug, Clone)] +pub struct IntStrokeStyle { + pub width: I, + pub start_cap: IntLineCap, + pub end_cap: IntLineCap, + pub join: IntLineJoin, + /// Miter turns below this angle use bevel joins in both math modes. + /// Defaults to 5 degrees; clamped to 0..=pi during construction. + /// Zero disables the cutoff. Smaller values allow less stable intersections. + /// Ignored for bevel and round joins. + pub miter_min_turn: Angle, + /// Arithmetic for stroke construction. Float mode may change rounded output. + pub math: MathMode, +} + +impl IntStrokeStyle { + pub fn new(width: I) -> Self { + Self { + width, + start_cap: IntLineCap::Butt, + end_cap: IntLineCap::Butt, + join: IntLineJoin::Bevel, + miter_min_turn: Angle::from_bits(DEFAULT_MITER_MIN_TURN), + math: MathMode::Integer, + } + } + /// Selects construction arithmetic; coordinates and boolean operations stay integer. + pub fn math(mut self, math: MathMode) -> Self { + self.math = math; + self + } + + pub fn width(mut self, width: I) -> Self { + self.width = width; + self + } + pub fn start_cap(mut self, cap: IntLineCap) -> Self { + self.start_cap = cap; + self + } + pub fn end_cap(mut self, cap: IntLineCap) -> Self { + self.end_cap = cap; + self + } + pub fn line_join(mut self, join: IntLineJoin) -> Self { + self.join = join; + self + } + + /// Sets the near-straight bevel cutoff, independently of the miter clipping angle. + pub fn miter_min_turn(mut self, angle: Angle) -> Self { + self.miter_min_turn = angle; + self + } +} diff --git a/iOverlay/src/mesh/int/variable_stroke/build.rs b/iOverlay/src/mesh/int/variable_stroke/build.rs new file mode 100644 index 00000000..7ed403e0 --- /dev/null +++ b/iOverlay/src/mesh/int/variable_stroke/build.rs @@ -0,0 +1,162 @@ +#[cfg(feature = "variable_stroke_debug")] +use super::debug::IntVariableStrokeDebugEdge; +use super::offset::IntVariableStrokeOffset; +use super::{ + IntStrokeVertex, IntVariableStrokeStyle, builder::VariableStrokeBuilder, math::VariableStrokeMath, +}; +use crate::core::{ + integer::OverlayInt, + overlay::{IntOverlayOptions, Overlay}, +}; +use crate::mesh::{ + int::math::{float::FloatMath, integer::IntegerMath}, + math::MathMode, +}; +use alloc::vec::Vec; +pub(super) trait BuildVariableOverlay: IntVariableStrokeOffset { + fn build_variable_overlay( + &self, + style: IntVariableStrokeStyle, + options: IntOverlayOptions, + ) -> Overlay { + build_variable_overlay_iter( + self.iter_variable_paths().map(|path| path.iter().copied()), + style, + options, + #[cfg(feature = "variable_stroke_debug")] + None, + ) + } +} +impl + ?Sized> BuildVariableOverlay for S {} + +/// Consumes each path once, including float inputs mapped to integer vertices. +pub(crate) fn build_variable_overlay_iter( + paths: Paths, + style: IntVariableStrokeStyle, + options: IntOverlayOptions, + #[cfg(feature = "variable_stroke_debug")] edges: Option<&mut Vec>>, +) -> Overlay +where + I: OverlayInt, + Paths: IntoIterator, + Path: IntoIterator>, +{ + match style.math { + MathMode::Integer => build_with_math::( + paths, + style, + options, + #[cfg(feature = "variable_stroke_debug")] + edges, + ), + MathMode::Float => build_with_math::( + paths, + style, + options, + #[cfg(feature = "variable_stroke_debug")] + edges, + ), + } +} + +fn build_with_math( + paths: Paths, + style: IntVariableStrokeStyle, + options: IntOverlayOptions, + #[cfg(feature = "variable_stroke_debug")] mut edges: Option<&mut Vec>>, +) -> Overlay +where + I: OverlayInt, + M: VariableStrokeMath, + Paths: IntoIterator, + Path: IntoIterator>, +{ + let mut builder = VariableStrokeBuilder::::new(style); + let mut segments = Vec::new(); + for (_index, path) in paths.into_iter().enumerate() { + #[cfg(debug_assertions)] + let path = path.into_iter().inspect(|vertex| { + debug_assert!( + crate::mesh::int::bounds::expanded_is_safe( + i_float::int::rect::IntRect::with_point(vertex.point), + M::guard_padding(vertex.radius().to_wide()), + ), + "variable stroke exceeds coordinate range" + ); + }); + builder.build( + path, + &mut segments, + #[cfg(feature = "variable_stroke_debug")] + edges.as_deref_mut(), + #[cfg(feature = "variable_stroke_debug")] + _index, + ); + } + let mut overlay = Overlay::with_segments(segments); + overlay.options = options; + overlay +} + +#[cfg(test)] +mod tests { + use super::super::offset::IntVariableStrokeOffset; + use super::*; + use crate::core::{fill_rule::FillRule, overlay_rule::OverlayRule}; + use alloc::vec; + use core::cell::Cell; + use i_float::int::point::IntPoint; + + #[test] + fn paths_and_vertices_are_consumed_once_in_order() { + let vertex = |x, y, width| IntStrokeVertex::new(IntPoint::new(x, y), width); + let paths = vec![ + vec![], + vec![vertex(0, 0, 1000)], + vec![vertex(0, 0, 200), vertex(0, 0, 400), vertex(4000, 3000, 2000)], + vec![], + vec![vertex(0, 0, 0)], + ]; + for math in [MathMode::Integer, MathMode::Float] { + let style = IntVariableStrokeStyle::new().math(math); + let expected = paths.variable_stroke(style).unwrap(); + let visited = Cell::new(0); + let visited_ref = &visited; + let mut expected_visited = 0; + let iter = paths.iter().map(|path| { + assert_eq!(visited.get(), expected_visited); + expected_visited += path.len(); + let mut vertices = path.iter().copied(); + core::iter::from_fn(move || { + let vertex = vertices.next()?; + visited_ref.set(visited_ref.get() + 1); + Some(vertex) + }) + }); + let actual = build_variable_overlay_iter( + iter, + style, + Default::default(), + #[cfg(feature = "variable_stroke_debug")] + None, + ) + .overlay(OverlayRule::Subject, FillRule::Positive); + assert_eq!(actual, expected); + assert_eq!(visited.get(), paths.iter().map(Vec::len).sum::()); + } + } + + #[cfg(debug_assertions)] + #[test] + #[should_panic(expected = "variable stroke exceeds coordinate range")] + fn invalid_zero_width_vertex_is_checked_before_geometry() { + build_variable_overlay_iter( + [[IntStrokeVertex::new(IntPoint::new(i32::MAX, 0), 0)]], + IntVariableStrokeStyle::new().math(MathMode::Float), + Default::default(), + #[cfg(feature = "variable_stroke_debug")] + None, + ); + } +} diff --git a/iOverlay/src/mesh/int/variable_stroke/builder.rs b/iOverlay/src/mesh/int/variable_stroke/builder.rs new file mode 100644 index 00000000..7c837205 --- /dev/null +++ b/iOverlay/src/mesh/int/variable_stroke/builder.rs @@ -0,0 +1,134 @@ +#[cfg(feature = "variable_stroke_debug")] +use super::debug::{IntVariableStrokeDebugEdge, VariableStrokeDebugEdgeKind}; +use super::{ + builder_join::{Cap, SegmentBuilder}, + math::VariableStrokeMath, + section::Section, + style::{IntStrokeVertex, IntVariableStrokeStyle}, +}; +use crate::mesh::int::{arc::ArcMath, math::integer::IntegerMath}; +use crate::segm::{boolean::ShapeCountBoolean, segment::Segment}; +use alloc::vec::Vec; +use i_float::int::number::int::IntNumber; + +pub(super) struct VariableStrokeBuilder = IntegerMath> { + arc: M::Arc, +} + +impl> VariableStrokeBuilder { + pub(super) fn new(style: IntVariableStrokeStyle) -> Self { + Self { + arc: M::Arc::new(style.arc), + } + } + + pub(super) fn build( + &mut self, + path: impl IntoIterator>, + segments: &mut Vec>, + #[cfg(feature = "variable_stroke_debug")] debug_edges: Option< + &mut Vec>, + >, + #[cfg(feature = "variable_stroke_debug")] path_index: usize, + ) { + let mut output = SegmentBuilder:: { + arc: &mut self.arc, + segments, + #[cfg(feature = "variable_stroke_debug")] + debug_edges, + #[cfg(feature = "variable_stroke_debug")] + debug_path_index: path_index, + }; + let mut path = path.into_iter(); + let Some(mut a) = path.next() else { + return; + }; + let mut chain = Chain::new(a, Cap::Round); + let mut previous_section: Option> = None; + let mut previous_radius = I::ZERO; + let mut end_cap = Cap::Round; + + for b in path { + end_cap = Cap::Round; + let section = Section::try_new::(&a, &b); + if let Some((end, start)) = break_caps(&a, &b) { + chain.finish(end, &mut output); + chain = Chain::new(b, start); + } else { + if previous_radius > b.radius() + && previous_section.is_some_and(|s| s.covers_circle(b.point, b.radius())) + { + chain.finish(Cap::Round, &mut output); + chain = Chain::new(a, Cap::Butt); + end_cap = Cap::Butt; + } + chain.has_edge = true; + if let Some(section) = section { + output.add_section(§ion); + if let Some(previous) = chain.last { + output.add_join(&previous, §ion); + } else { + output.add_start_cap(§ion, chain.start_cap); + } + chain.last = Some(section); + } + } + previous_section = section; + previous_radius = a.radius().max(b.radius()); + a = b; + } + chain.finish(end_cap, &mut output); + } +} + +struct Chain { + start: IntStrokeVertex, + start_cap: Cap, + last: Option>, + has_edge: bool, +} + +impl Chain { + fn new(start: IntStrokeVertex, start_cap: Cap) -> Self { + Self { + start, + start_cap, + last: None, + has_edge: false, + } + } + + fn finish>(&self, end_cap: Cap, output: &mut SegmentBuilder) { + if let Some(last) = self.last { + output.add_end_cap(&last, end_cap); + } else if !self.has_edge && (self.start_cap != Cap::Butt || end_cap != Cap::Butt) { + output.add_circle( + &self.start.point, + self.start.radius(), + #[cfg(feature = "variable_stroke_debug")] + VariableStrokeDebugEdgeKind::CircleArc, + ); + } + } +} + +fn break_caps(a: &IntStrokeVertex, b: &IntStrokeVertex) -> Option<(Cap, Cap)> { + use i_float::int::number::wide_int::WideIntNumber; + let ra = a.radius(); + let rb = b.radius(); + let delta = ra.to_wide() - rb.to_wide(); + if (delta * delta).to_uint() < (b.point - a.point).sqr_length() { + None + } else if ra >= rb { + Some((Cap::Round, Cap::Butt)) + } else { + Some((Cap::Butt, Cap::Round)) + } +} + +#[cfg(test)] +#[path = "tests_reference.rs"] +mod reference; +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/iOverlay/src/mesh/int/variable_stroke/builder_join.rs b/iOverlay/src/mesh/int/variable_stroke/builder_join.rs new file mode 100644 index 00000000..68341b47 --- /dev/null +++ b/iOverlay/src/mesh/int/variable_stroke/builder_join.rs @@ -0,0 +1,356 @@ +#[cfg(feature = "variable_stroke_debug")] +use super::debug::{IntVariableStrokeDebugEdge, VariableStrokeDebugEdgeKind}; +use super::section::{RadiusTrend, Section}; +use crate::mesh::{ + int::{ + arc::{ArcDirection, ArcMath}, + math::{backend::MeshMath, integer::IntegerMath, point, scaled_point}, + }, + subject::SubjectSegments, +}; +use crate::segm::{boolean::ShapeCountBoolean, segment::Segment}; +use alloc::vec::Vec; +use i_float::int::{ + number::{int::IntNumber, uint::UIntNumber, wide_int::WideIntNumber}, + point::IntPoint, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Cap { + Butt, + Round, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum ArcSweep { + Minor, + Major, +} + +pub(super) struct SegmentBuilder<'a, I: IntNumber, M: MeshMath = IntegerMath> { + pub(super) arc: &'a mut M::Arc, + pub(super) segments: &'a mut Vec>, + #[cfg(feature = "variable_stroke_debug")] + pub(super) debug_edges: Option<&'a mut Vec>>, + #[cfg(feature = "variable_stroke_debug")] + pub(super) debug_path_index: usize, +} + +impl> SegmentBuilder<'_, I, M> { + pub(super) fn add_circle( + &mut self, + center: &IntPoint, + radius: I, + #[cfg(feature = "variable_stroke_debug")] kind: VariableStrokeDebugEdgeKind, + ) { + if radius <= I::ONE { + return; + } + let right = point(*center, radius.to_wide(), I::Wide::ZERO); + let left = point(*center, -radius.to_wide(), I::Wide::ZERO); + self.arc_edges( + center, + &right, + &left, + radius, + #[cfg(feature = "variable_stroke_debug")] + kind, + ); + self.arc_edges( + center, + &left, + &right, + radius, + #[cfg(feature = "variable_stroke_debug")] + kind, + ); + } + + #[inline] + pub(super) 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, + ); + } + + pub(super) fn add_join(&mut self, prev: &Section, next: &Section) -> usize { + let prev_center = prev.b; + let next_center = 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); + self.add_start_cap(next, Cap::Butt); + return 0; + } + + let prev_a_left = prev.a_left; + let prev_b_left = prev.b_left; + let prev_a_right = prev.a_right; + let prev_b_right = prev.b_right; + let next_a_left = next.a_left; + let next_b_left = next.b_left; + let next_a_right = next.a_right; + let next_b_right = 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 = prev.a; + let prev_b = prev_center; + let next_a = next.a; + let next_b = 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, + 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, + 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 + } + + pub(super) fn add_start_cap(&mut self, section: &Section, cap: Cap) { + 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, + sweep, + #[cfg(feature = "variable_stroke_debug")] + VariableStrokeDebugEdgeKind::CapArc, + ); + } + } + } + + pub(super) fn add_end_cap(&mut self, section: &Section, cap: Cap) { + 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, + sweep, + #[cfg(feature = "variable_stroke_debug")] + VariableStrokeDebugEdgeKind::CapArc, + ); + } + } + } + + pub(super) fn arc_sweep_ccw( + &self, + center: &IntPoint, + from: &IntPoint, + to: &IntPoint, + aligned_sweep: ArcSweep, + ) -> ArcSweep { + let center = *center; + let from_vector = *from - center; + let to_vector = *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 + } + } + + pub(super) fn add_arc_ccw( + &mut self, + center: &IntPoint, + from: &IntPoint, + to: &IntPoint, + 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 && *from == *to { + return false; + } + + let Some(from_unit) = M::normalize(*from - *center) else { + return false; + }; + let Some(to_unit) = M::normalize(*to - *center) else { + return false; + }; + let from_vector = *from - *center; + let radius = I::from_uint(from_vector.sqr_length().isqrt()); + if M::angle_between(from_unit, to_unit).bits() == 0 && sweep == ArcSweep::Major { + self.add_circle( + center, + radius, + #[cfg(feature = "variable_stroke_debug")] + edge_kind, + ); + self.add_edge( + from, + to, + #[cfg(feature = "variable_stroke_debug")] + edge_kind, + ); + } else { + self.arc_edges( + center, + from, + to, + radius, + #[cfg(feature = "variable_stroke_debug")] + edge_kind, + ); + } + true + } + + pub(super) fn arc_edges( + &mut self, + center: &IntPoint, + from: &IntPoint, + to: &IntPoint, + radius: I, + #[cfg(feature = "variable_stroke_debug")] kind: VariableStrokeDebugEdgeKind, + ) { + let Some(a) = M::normalize(*from - *center) else { + return; + }; + let Some(b) = M::normalize(*to - *center) else { + return; + }; + let directions = self.arc.build(a, b, ArcDirection::Counterclockwise); + let mut previous = *from; + // Split borrows: the reusable arc buffer stays borrowed while emitting. + for &dir in directions { + let next = scaled_point(*center, dir, radius); + if previous != next { + #[cfg(feature = "variable_stroke_debug")] + if let Some(edges) = self.debug_edges.as_mut() { + edges.push(IntVariableStrokeDebugEdge { + a: previous, + b: next, + kind, + path_index: self.debug_path_index, + order: edges.len(), + }); + } + self.segments.push_non_degenerate(previous, next); + } + previous = next; + } + self.add_edge( + &previous, + to, + #[cfg(feature = "variable_stroke_debug")] + kind, + ); + } + + #[inline] + pub(super) fn add_edge( + &mut self, + a: &IntPoint, + b: &IntPoint, + #[cfg(feature = "variable_stroke_debug")] kind: VariableStrokeDebugEdgeKind, + ) { + let a = *a; + let b = *b; + if a != b { + #[cfg(feature = "variable_stroke_debug")] + if let Some(debug_edges) = self.debug_edges.as_mut() { + debug_edges.push(IntVariableStrokeDebugEdge { + a, + b, + kind, + path_index: self.debug_path_index, + order: debug_edges.len(), + }); + } + self.segments.push(Segment::subject(a, b)); + } + } +} diff --git a/iOverlay/src/mesh/variable_stroke/debug.rs b/iOverlay/src/mesh/int/variable_stroke/debug.rs similarity index 75% rename from iOverlay/src/mesh/variable_stroke/debug.rs rename to iOverlay/src/mesh/int/variable_stroke/debug.rs index 205f46bd..b673aa18 100644 --- a/iOverlay/src/mesh/variable_stroke/debug.rs +++ b/iOverlay/src/mesh/int/variable_stroke/debug.rs @@ -1,4 +1,4 @@ -use i_float::float::compatible::FloatPointCompatible; +use i_float::int::{number::int::IntNumber, point::IntPoint}; /// The variable-stroke construction operation that emitted a raw pre-overlay edge. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -19,9 +19,9 @@ pub enum VariableStrokeDebugEdgeKind { /// One directed edge submitted by `SegmentBuilder` before overlay processing. #[derive(Debug, Clone, Copy)] -pub struct VariableStrokeDebugEdge { - pub a: P, - pub b: P, +pub struct IntVariableStrokeDebugEdge { + pub a: IntPoint, + pub b: IntPoint, pub kind: VariableStrokeDebugEdgeKind, /// Index of the source variable-width path. pub path_index: usize, @@ -31,7 +31,7 @@ pub struct VariableStrokeDebugEdge { /// 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

, +pub struct IntVariableStrokeDebugResult { + pub edges: alloc::vec::Vec>, + pub shapes: i_shape::int::shape::IntShapes, } diff --git a/iOverlay/src/mesh/int/variable_stroke/math.rs b/iOverlay/src/mesh/int/variable_stroke/math.rs new file mode 100644 index 00000000..2251a87e --- /dev/null +++ b/iOverlay/src/mesh/int/variable_stroke/math.rs @@ -0,0 +1,108 @@ +use crate::mesh::int::math::{backend::MeshMath, float::FloatMath, integer::IntegerMath, mul_div}; +use i_float::float::number::FloatNumber; +use i_float::int::{ + number::{int::IntNumber, uint::UIntNumber, wide_int::WideIntNumber}, + vector::IntVector, +}; + +/// Construction of the external tangent contacts of two non-containing circles. +pub(super) trait VariableStrokeMath: MeshMath { + /// Offsets from the centers, ordered as a-left, a-right, b-left, b-right. + /// The caller guarantees |ra - rb|² < |v|². + fn tangent_offsets(v: IntVector, ra: I, rb: I) -> [IntVector; 4]; +} + +impl VariableStrokeMath for IntegerMath { + fn tangent_offsets(v: IntVector, ra: I, rb: I) -> [IntVector; 4] { + let delta = ra.to_wide() - rb.to_wide(); + let sq = v.sqr_length(); + // Keep fractional bits of sqrt(length² - delta²). Taking an unscaled + // integer root would noticeably shrink wide strokes on short sections. + // Using sq to choose the shift also bounds every numerator by sq << shift. + let shift = (sq.leading_zeros() - 1) / 2; + let tangent = I::Wide::from_uint(((sq - (delta * delta).to_uint()) << (2 * shift)).isqrt()); + let denominator = I::Wide::from_uint(sq << shift); + let dx = (delta * v.x) << shift; + let dy = (delta * v.y) << shift; + let left_x = dx - tangent * v.y; + let left_y = dy + tangent * v.x; + let right_x = dx + tangent * v.y; + let right_y = dy - tangent * v.x; + let contact = |r: I, x, y| { + IntVector::new( + mul_div::(r.to_wide(), x, denominator), + mul_div::(r.to_wide(), y, denominator), + ) + }; + [ + contact(ra, left_x, left_y), + contact(ra, right_x, right_y), + contact(rb, left_x, left_y), + contact(rb, right_x, right_y), + ] + } +} + +impl VariableStrokeMath for FloatMath { + fn tangent_offsets(v: IntVector, ra: I, rb: I) -> [IntVector; 4] { + let delta = ra.to_wide() - rb.to_wide(); + let sq = v.sqr_length(); + // Subtract before converting to float: nearly contained circles can + // have a tiny positive difference even at large integer coordinates. + let tangent = FloatNumber::sqrt((sq - (delta * delta).to_uint()).to_f64()); + let denominator = sq.to_f64(); + let dx = (delta * v.x).to_f64(); + let dy = (delta * v.y).to_f64(); + let tx = tangent * v.x.to_f64(); + let ty = tangent * v.y.to_f64(); + let contact = |r: I, x: f64, y: f64| { + let scale = r.to_f64() / denominator; + IntVector::new( + I::Wide::from_rounded_float(scale * x), + I::Wide::from_rounded_float(scale * y), + ) + }; + [ + contact(ra, dx - ty, dy + tx), + contact(ra, dx + ty, dy - tx), + contact(rb, dx - ty, dy + tx), + contact(rb, dx + ty, dy - tx), + ] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn float_tangents_keep_the_small_difference_near_containment() { + let d = 1_i64 << 59; + let [al, ar, _, _] = FloatMath::tangent_offsets(IntVector::::new(d as i128, 1), d + 2, 2); + assert_eq!(al.y, 2); + assert_eq!(ar.y, 0); + } + + #[test] + fn both_backends_preserve_short_section_contact_precision() { + fn check>() { + for (x, y) in [(1, 1), (2, 2), (3, 2), (1, 7)] { + for delta in [0, 1] { + let offsets = M::tangent_offsets(IntVector::new(x, y), 4096, 4096 - delta); + let sq = (x * x + y * y) as f64; + let tangent = (sq - (delta * delta) as f64).sqrt(); + for (i, p) in offsets.iter().enumerate() { + let radius = if i < 2 { 4096.0 } else { (4096 - delta) as f64 }; + let sign = if i % 2 == 0 { 1.0 } else { -1.0 }; + let ex = radius * (delta as f64 * x as f64 - sign * tangent * y as f64) / sq; + let ey = radius * (delta as f64 * y as f64 + sign * tangent * x as f64) / sq; + assert!((p.x as f64 - ex).abs() <= 0.51); + assert!((p.y as f64 - ey).abs() <= 0.51); + } + } + } + } + check::(); + check::(); + } +} diff --git a/iOverlay/src/mesh/int/variable_stroke/mod.rs b/iOverlay/src/mesh/int/variable_stroke/mod.rs new file mode 100644 index 00000000..78744e80 --- /dev/null +++ b/iOverlay/src/mesh/int/variable_stroke/mod.rs @@ -0,0 +1,13 @@ +pub(crate) mod build; +mod builder; +mod builder_join; +#[cfg(feature = "variable_stroke_debug")] +pub mod debug; +mod math; +mod resource; +mod section; +mod style; +pub use resource::IntVariableStrokeSource; +pub use style::{IntStrokeVertex, IntVariableStrokeStyle}; + +pub mod offset; diff --git a/iOverlay/src/mesh/int/variable_stroke/offset.rs b/iOverlay/src/mesh/int/variable_stroke/offset.rs new file mode 100644 index 00000000..c17ea5b2 --- /dev/null +++ b/iOverlay/src/mesh/int/variable_stroke/offset.rs @@ -0,0 +1,99 @@ +#[cfg(feature = "variable_stroke_debug")] +use super::build::build_variable_overlay_iter; +use super::{IntVariableStrokeSource, IntVariableStrokeStyle, build::BuildVariableOverlay}; +use crate::core::{ + fill_rule::FillRule, integer::OverlayInt, overlay::IntOverlayOptions, overlay_rule::OverlayRule, +}; +use crate::mesh::int::{ + math::{backend::MeshMath, float::FloatMath}, + outline::offset::IntOutlineError, +}; +#[cfg(feature = "variable_stroke_debug")] +use alloc::vec::Vec; +use i_float::int::rect::IntRect; +use i_shape::{flat::buffer::FlatContoursBuffer, int::shape::IntShapes}; + +pub type IntVariableStrokeError = IntOutlineError; + +/// Integer variable-width strokes with round caps and joins. Widths and +/// coordinates are in input units. Call validation separately when necessary; +/// release construction trusts the coordinate-range precondition. +/// +/// ``` +/// use i_float::int::point::IntPoint; +/// use i_overlay::mesh::int::variable_stroke::{IntStrokeVertex, IntVariableStrokeStyle, +/// offset::IntVariableStrokeOffset}; +/// let path = [IntStrokeVertex::new(IntPoint::new(0, 0), 2048), +/// IntStrokeVertex::new(IntPoint::new(8192, 0), 4096)]; +/// path.validate_variable_stroke().unwrap(); +/// assert_eq!(path.variable_stroke(IntVariableStrokeStyle::new()).unwrap().len(), 1); +/// ``` +pub trait IntVariableStrokeOffset: IntVariableStrokeSource { + /// Uses conservative padding valid for either construction math mode. + fn validate_variable_stroke(&self) -> Result<(), IntVariableStrokeError> { + let radius = self + .iter_variable_paths() + .flatten() + .map(|v| v.radius()) + .max() + .unwrap_or(I::ZERO); + if let Some(rect) = IntRect::with_iter(self.iter_variable_paths().flatten().map(|v| &v.point)) { + let padding = >::guard_padding(radius.to_wide()); + if !crate::mesh::int::bounds::expanded_is_safe(rect, padding) { + return Err(IntVariableStrokeError::CoordinateOutOfRange); + } + } + Ok(()) + } + fn variable_stroke(&self, style: IntVariableStrokeStyle) -> Result, IntVariableStrokeError> { + self.variable_stroke_custom(style, Default::default()) + } + fn variable_stroke_into( + &self, + style: IntVariableStrokeStyle, + output: &mut FlatContoursBuffer, + ) -> Result<(), IntVariableStrokeError> { + self.variable_stroke_custom_into(style, Default::default(), output) + } + fn variable_stroke_custom( + &self, + style: IntVariableStrokeStyle, + options: IntOverlayOptions, + ) -> Result, IntVariableStrokeError> { + Ok(self + .build_variable_overlay(style, options) + .overlay(OverlayRule::Subject, FillRule::Positive)) + } + fn variable_stroke_custom_into( + &self, + style: IntVariableStrokeStyle, + options: IntOverlayOptions, + output: &mut FlatContoursBuffer, + ) -> Result<(), IntVariableStrokeError> { + self.build_variable_overlay(style, options).overlay_into( + OverlayRule::Subject, + FillRule::Positive, + output, + ); + Ok(()) + } + #[cfg(feature = "variable_stroke_debug")] + fn variable_stroke_debug( + &self, + style: IntVariableStrokeStyle, + options: IntOverlayOptions, + ) -> Result, IntVariableStrokeError> { + let mut edges = Vec::new(); + let mut overlay = build_variable_overlay_iter( + self.iter_variable_paths().map(|path| path.iter().copied()), + style, + options, + Some(&mut edges), + ); + Ok(super::debug::IntVariableStrokeDebugResult { + edges, + shapes: overlay.overlay(OverlayRule::Subject, FillRule::Positive), + }) + } +} +impl + ?Sized> IntVariableStrokeOffset for S {} diff --git a/iOverlay/src/mesh/int/variable_stroke/resource.rs b/iOverlay/src/mesh/int/variable_stroke/resource.rs new file mode 100644 index 00000000..b2ad933a --- /dev/null +++ b/iOverlay/src/mesh/int/variable_stroke/resource.rs @@ -0,0 +1,185 @@ +use crate::mesh::int::variable_stroke::style::IntStrokeVertex; +use alloc::vec::Vec; +use i_float::int::number::int::IntNumber; + +pub trait IntVariableStrokeSource +where + I: IntNumber, +{ + type ResourceIter<'a>: Iterator]> + where + I: 'a, + Self: 'a; + + fn iter_variable_paths(&self) -> Self::ResourceIter<'_>; +} + +pub struct ContourResourceIterator<'a, I: IntNumber> { + slice: &'a [IntStrokeVertex], + finished: bool, +} + +impl<'a, I: IntNumber> ContourResourceIterator<'a, I> { + #[inline] + fn with_slice(slice: &'a [IntStrokeVertex]) -> Self { + Self { + slice, + finished: false, + } + } +} + +impl<'a, I: IntNumber> Iterator for ContourResourceIterator<'a, I> { + type Item = &'a [IntStrokeVertex]; + + #[inline] + fn next(&mut self) -> Option { + if self.finished { + return None; + } + self.finished = true; + Some(self.slice) + } + + #[inline] + fn count(self) -> usize { + usize::from(!self.finished) + } +} + +impl IntVariableStrokeSource for [IntStrokeVertex] { + type ResourceIter<'a> + = ContourResourceIterator<'a, I> + where + I: 'a, + Self: 'a; + + #[inline] + fn iter_variable_paths(&self) -> Self::ResourceIter<'_> { + ContourResourceIterator::with_slice(self) + } +} + +impl IntVariableStrokeSource for [IntStrokeVertex; N] { + type ResourceIter<'a> + = ContourResourceIterator<'a, I> + where + I: 'a, + Self: 'a; + + #[inline] + fn iter_variable_paths(&self) -> Self::ResourceIter<'_> { + ContourResourceIterator::with_slice(self) + } +} + +impl IntVariableStrokeSource for Vec> { + type ResourceIter<'a> + = ContourResourceIterator<'a, I> + where + I: 'a, + Self: 'a; + + #[inline] + fn iter_variable_paths(&self) -> Self::ResourceIter<'_> { + ContourResourceIterator::with_slice(self.as_slice()) + } +} + +impl<'b, I: IntNumber> IntVariableStrokeSource for &'b [IntStrokeVertex] { + type ResourceIter<'a> + = ContourResourceIterator<'a, I> + where + I: 'a, + Self: 'a; + + #[inline] + fn iter_variable_paths(&self) -> Self::ResourceIter<'b> { + ContourResourceIterator::with_slice(self) + } +} + +pub struct ShapeResourceIterator<'a, I: IntNumber> { + slice: &'a [Vec>], + index: usize, +} + +impl<'a, I: IntNumber> Iterator for ShapeResourceIterator<'a, I> { + type Item = &'a [IntStrokeVertex]; + + #[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() - self.index + } +} + +impl IntVariableStrokeSource for [Vec>] { + type ResourceIter<'a> + = ShapeResourceIterator<'a, I> + where + I: 'a, + Self: 'a; + + #[inline] + fn iter_variable_paths(&self) -> Self::ResourceIter<'_> { + ShapeResourceIterator { + slice: self, + index: 0, + } + } +} + +impl IntVariableStrokeSource for [Vec>; N] { + type ResourceIter<'a> + = ShapeResourceIterator<'a, I> + where + I: 'a, + Self: 'a; + + #[inline] + fn iter_variable_paths(&self) -> Self::ResourceIter<'_> { + ShapeResourceIterator { + slice: self, + index: 0, + } + } +} + +impl IntVariableStrokeSource for Vec>> { + type ResourceIter<'a> + = ShapeResourceIterator<'a, I> + where + I: 'a, + Self: 'a; + + #[inline] + fn iter_variable_paths(&self) -> Self::ResourceIter<'_> { + ShapeResourceIterator { + slice: self.as_slice(), + index: 0, + } + } +} + +impl<'b, I: IntNumber> IntVariableStrokeSource for &'b [Vec>] { + type ResourceIter<'a> + = ShapeResourceIterator<'a, I> + where + I: 'a, + Self: 'a; + + #[inline] + fn iter_variable_paths(&self) -> Self::ResourceIter<'b> { + ShapeResourceIterator { + slice: self, + index: 0, + } + } +} diff --git a/iOverlay/src/mesh/int/variable_stroke/section.rs b/iOverlay/src/mesh/int/variable_stroke/section.rs new file mode 100644 index 00000000..4e3d621f --- /dev/null +++ b/iOverlay/src/mesh/int/variable_stroke/section.rs @@ -0,0 +1,89 @@ +use super::math::VariableStrokeMath; +use super::style::IntStrokeVertex; +use crate::mesh::int::math::point; +use i_float::int::{ + number::{int::IntNumber, uint::UIntNumber, wide_int::WideIntNumber}, + point::IntPoint, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum RadiusTrend { + Decreasing, + Constant, + Increasing, +} +#[derive(Clone, Copy)] +pub(super) struct Section { + pub(super) a: IntPoint, + pub(super) b: IntPoint, + pub(super) a_left: IntPoint, + pub(super) a_right: IntPoint, + pub(super) b_left: IntPoint, + pub(super) b_right: IntPoint, + pub(super) radius_trend: RadiusTrend, +} +impl Section { + pub(super) fn try_new>( + a: &IntStrokeVertex, + b: &IntStrokeVertex, + ) -> Option { + let ra = a.radius(); + let rb = b.radius(); + if a.point == b.point || ra.max(rb) <= I::ONE { + return None; + } + let delta = ra.to_wide() - rb.to_wide(); + let v = b.point - a.point; + let sq = v.sqr_length(); + if (delta * delta).to_uint() >= sq { + return None; + } + let [al, ar, bl, br] = M::tangent_offsets(v, ra, rb); + Some(Self { + a: a.point, + b: b.point, + a_left: point(a.point, al.x, al.y), + a_right: point(a.point, ar.x, ar.y), + b_left: point(b.point, bl.x, bl.y), + b_right: point(b.point, br.x, br.y), + radius_trend: if ra < rb { + RadiusTrend::Increasing + } else if ra > rb { + RadiusTrend::Decreasing + } else { + RadiusTrend::Constant + }, + }) + } + pub(super) fn covers_circle(&self, center: IntPoint, radius: I) -> bool { + let points = [self.a_left, self.b_left, self.b_right, self.a_right]; + let radius = radius.to_wide().to_uint(); + 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 += I::WideUInt::ONE; + } + if interior_distance.to_uint() < radius * length { + return false; + } + } + + true + } +} diff --git a/iOverlay/src/mesh/int/variable_stroke/style.rs b/iOverlay/src/mesh/int/variable_stroke/style.rs new file mode 100644 index 00000000..878b86fb --- /dev/null +++ b/iOverlay/src/mesh/int/variable_stroke/style.rs @@ -0,0 +1,51 @@ +use crate::mesh::int::arc::ArcOptions; +use crate::mesh::math::MathMode; +use i_float::int::{ + number::{int::IntNumber, wide_int::WideIntNumber}, + point::IntPoint, +}; + +#[derive(Debug, Clone, Copy)] +pub struct IntStrokeVertex { + pub point: IntPoint, + pub width: I, +} +impl IntStrokeVertex { + pub fn new(point: IntPoint, width: I) -> Self { + Self { point, width } + } + pub(super) fn radius(&self) -> I { + I::from_wide((self.width.max(I::ZERO).to_wide() + I::Wide::ONE) / I::Wide::TWO) + } +} + +#[derive(Debug, Clone, Copy)] +pub struct IntVariableStrokeStyle { + pub arc: ArcOptions, + /// Arithmetic used to construct tangent contacts and arcs. + pub math: MathMode, +} +impl Default for IntVariableStrokeStyle { + fn default() -> Self { + Self { + math: MathMode::Integer, + arc: ArcOptions { + max_step: i_float::int::angle::Angle::from_bits(68_356_528), + ..ArcOptions::default() + }, + } + } +} +impl IntVariableStrokeStyle { + pub fn new() -> Self { + Self::default() + } + pub fn math(mut self, math: MathMode) -> Self { + self.math = math; + self + } + pub fn arc(mut self, arc: ArcOptions) -> Self { + self.arc = arc; + self + } +} diff --git a/iOverlay/src/mesh/int/variable_stroke/tests.rs b/iOverlay/src/mesh/int/variable_stroke/tests.rs new file mode 100644 index 00000000..a598de7e --- /dev/null +++ b/iOverlay/src/mesh/int/variable_stroke/tests.rs @@ -0,0 +1,322 @@ +use super::super::builder_join::ArcSweep; +use super::reference::*; +use super::*; +use crate::mesh::int::arc::ArcBuilder; +use crate::mesh::int::arc::ArcOptions; +use crate::mesh::int::variable_stroke::offset::IntVariableStrokeOffset; +use i_float::int::{angle::Angle, point::IntPoint}; + +fn vertex(x: i32, y: i32, width: i32) -> IntStrokeVertex { + IntStrokeVertex::new(IntPoint::new(x, y), width) +} + +fn output<'a>( + arc: &'a mut ArcBuilder, + segments: &'a mut Vec>, +) -> SegmentBuilder<'a, i32> { + SegmentBuilder { + arc, + segments, + #[cfg(feature = "variable_stroke_debug")] + debug_edges: None, + #[cfg(feature = "variable_stroke_debug")] + debug_path_index: 0, + } +} + +#[test] +fn covered_break_closes_only_the_larger_side() { + for (small, large, end_cap, start_cap) in [ + (4000, 20000, Cap::Butt, Cap::Round), + (20000, 4000, Cap::Round, Cap::Butt), + ] { + let path = [ + vertex(-20000, 0, small), + vertex(0, 0, small), + vertex(2000, 0, large), + vertex(22000, 0, large), + ]; + let parts = VariableStrokeBuilder::find_subsegments(&path); + assert_eq!(parts.len(), 2); + assert_eq!((parts[0].start, parts[0].end, parts[0].end_cap), (0, 1, end_cap)); + assert_eq!( + (parts[1].start, parts[1].end, parts[1].start_cap), + (2, 3, start_cap) + ); + } +} + +#[test] +fn near_covered_sections_stay_connected() { + let path = [vertex(0, 0, 600), vertex(757, 386, 1800), vertex(1920, 712, 4200)]; + assert_eq!( + VariableStrokeBuilder::find_subsegments(&path), + [SubSegment { + start: 0, + end: 2, + start_cap: Cap::Round, + end_cap: Cap::Round + }] + ); +} + +#[test] +fn covered_zero_length_butt_section_emits_nothing() { + let path = [ + vertex(-2000, 0, 20000), + vertex(0, 0, 2000), + vertex(2000, 0, 20000), + ]; + let parts = VariableStrokeBuilder::find_subsegments(&path); + assert_eq!(parts.len(), 3); + assert_eq!( + parts[1], + SubSegment { + start: 1, + end: 1, + start_cap: Cap::Butt, + end_cap: Cap::Butt + } + ); + let mut arc = ArcBuilder::default(); + let mut segments = Vec::new(); + VariableStrokeBuilder::add_subsegment(&parts[1], &path, &mut output(&mut arc, &mut segments)); + assert!(segments.is_empty()); +} + +#[test] +fn coverage_requires_a_larger_circle() { + let c = vertex(50000, 0, 20000); + for (width, covered) in [(20000, false), (40000, true)] { + assert_eq!( + VariableStrokeBuilder::circle_is_covered_by_section( + &vertex(0, 0, width), + &vertex(100000, 0, width), + &c + ), + covered + ); + } +} + +#[test] +fn joins_preserve_contacts_and_select_all_exposed_arcs() { + let cases = [ + ( + [ + vertex(-10000, 0, 4000), + vertex(0, 0, 10000), + vertex(10000, 0, 4000), + ], + 2, + ), + ( + [ + vertex(-10000, 0, 4000), + vertex(0, 0, 4000), + vertex(0, 10000, 4000), + ], + 1, + ), + ( + [ + vertex(-86000, 2000, 10000), + vertex(100000, 0, 100000), + vertex(99000, -45000, 10000), + ], + 2, + ), + ( + [ + vertex(0, 0, 22000), + vertex(100000, 0, 220000), + vertex(100000, -100000, 22000), + ], + 2, + ), + ( + [ + vertex(0, 0, 8800), + vertex(100000, 0, 88000), + vertex(100000, -100000, 8800), + ], + 1, + ), + ]; + for (path, expected) in cases { + let prev = Section::try_new::(&path[0], &path[1]).unwrap(); + let next = Section::try_new::(&path[1], &path[2]).unwrap(); + let mut arc = ArcBuilder::new(IntVariableStrokeStyle::default().arc); + let mut segments = Vec::new(); + assert_eq!(output(&mut arc, &mut segments).add_join(&prev, &next), expected); + for p in [prev.b_left, prev.b_right, next.a_left, next.a_right] { + assert!( + segments.iter().any(|s| s.x_segment.a == p || s.x_segment.b == p), + "lost tangent contact {p:?}" + ); + } + assert_eq!( + path.variable_stroke(IntVariableStrokeStyle::default()) + .unwrap() + .len(), + 1 + ); + } +} + +#[test] +fn coarse_arc_preserves_exact_contacts_and_full_major_arc() { + let center = IntPoint::new(0, 0); + let from = IntPoint::new(10000, 0); + let to = IntPoint::new(9950, 998); + let mut arc = ArcBuilder::default(); + let mut segments = Vec::new(); + assert!(output(&mut arc, &mut segments).add_arc_ccw( + ¢er, + &from, + &to, + ArcSweep::Minor, + #[cfg(feature = "variable_stroke_debug")] + VariableStrokeDebugEdgeKind::JoinArc + )); + assert_eq!(segments.len(), 1); + let edge = segments[0].x_segment; + assert!((edge.a == from && edge.b == to) || (edge.b == from && edge.a == to)); + segments.clear(); + assert!(output(&mut arc, &mut segments).add_arc_ccw( + ¢er, + &from, + &from, + ArcSweep::Major, + #[cfg(feature = "variable_stroke_debug")] + VariableStrokeDebugEdgeKind::JoinArc + )); + assert!(segments.len() >= 8); +} + +#[test] +fn reversals_close_both_sections_without_an_interior_tooth() { + for sign in [-1, 1] { + let path = [ + vertex(-86000, 2000 * sign, 21800), + vertex(100000, 0, 218000), + vertex(-20700, -16030 * sign, 21800), + ]; + let parts = VariableStrokeBuilder::find_subsegments(&path); + assert_eq!(parts.len(), 2); + assert_eq!(parts[0].end_cap, Cap::Round); + assert_eq!((parts[1].start_cap, parts[1].end_cap), (Cap::Butt, Cap::Butt)); + let style = IntVariableStrokeStyle::new().arc(ArcOptions { + max_step: Angle::from_bits(512_673_957), + ..ArcOptions::default() + }); + let shapes = path.variable_stroke(style).unwrap(); + assert_eq!(shapes.len(), 1); + assert!(!shapes.iter().flatten().flatten().any(|p| { + let dx = i64::from(p.x) - 100000; + let dy = i64::from(p.y); + p.x > 20000 && p.y * sign < -70000 && dx * dx + dy * dy < 108500_i64.pow(2) + })); + } +} + +#[test] +fn duplicates_and_small_widths_never_emit_zero_length_edges() { + for width in [0, 1, 2, 4, 1024] { + for end in [(10, 10), (0, 0), (20, 0)] { + let path = [ + vertex(0, 0, width), + vertex(0, 0, width), + vertex(10, 0, 2 * width), + vertex(end.0, end.1, width), + ]; + let mut builder = VariableStrokeBuilder::::new(IntVariableStrokeStyle::default()); + let mut segments = Vec::new(); + builder.build( + path, + &mut segments, + #[cfg(feature = "variable_stroke_debug")] + None, + #[cfg(feature = "variable_stroke_debug")] + 0, + ); + assert!(segments.iter().all(|s| s.x_segment.a < s.x_segment.b)); + if width >= 4 { + assert!(!segments.is_empty()); + } + } + } +} + +#[test] +fn short_sections_keep_tangent_precision_at_large_widths() { + for (x, y) in [(1, 1), (2, 2), (3, 2), (1, 7)] { + for delta in [0, 1] { + let a = vertex(0, 0, 8192); + let b = vertex(x, y, 8192 - 2 * delta); + let section = Section::try_new::(&a, &b).unwrap(); + let sq = f64::from(x * x + y * y); + let tangent = (sq - f64::from(delta * delta)).sqrt(); + for (left, p) in [(true, section.a_left), (false, section.a_right)] { + let sign = if left { 1.0 } else { -1.0 }; + let expected_x = 4096.0 * (f64::from(delta * x) - sign * tangent * f64::from(y)) / sq; + let expected_y = 4096.0 * (f64::from(delta * y) + sign * tangent * f64::from(x)) / sq; + assert!( + (f64::from(p.x) - expected_x).abs() <= 0.51, + "x={x},y={y},delta={delta}" + ); + assert!( + (f64::from(p.y) - expected_y).abs() <= 0.51, + "x={x},y={y},delta={delta}" + ); + } + } + } +} + +#[test] +fn streaming_matches_original_partitioned_segments() { + use rand::{RngExt, SeedableRng, rngs::StdRng}; + let mut rng = StdRng::seed_from_u64(0x7365_6374_696f_6e73); + let style = IntVariableStrokeStyle::default(); + let mut builder = VariableStrokeBuilder::::new(style); + let mut arc = ArcBuilder::new(style.arc); + let mut actual = Vec::new(); + let mut expected = Vec::new(); + for _ in 0..2048 { + let mut path = Vec::new(); + for _ in 0..rng.random_range(0..24) { + let next = if !path.is_empty() && rng.random_range(0..5) == 0 { + let prev: IntStrokeVertex = path[path.len() - 1]; + vertex(prev.point.x, prev.point.y, rng.random_range(-2..20000)) + } else { + vertex( + rng.random_range(-10000..10000), + rng.random_range(-10000..10000), + rng.random_range(-2..20000), + ) + }; + path.push(next); + } + actual.clear(); + expected.clear(); + builder.build( + path.iter().copied(), + &mut actual, + #[cfg(feature = "variable_stroke_debug")] + None, + #[cfg(feature = "variable_stroke_debug")] + 0, + ); + for part in VariableStrokeBuilder::find_subsegments(&path) { + VariableStrokeBuilder::add_subsegment(&part, &path, &mut output(&mut arc, &mut expected)); + } + let signature = + |s: &Segment| (s.x_segment.a, s.x_segment.b, s.count.subj, s.count.clip); + assert_eq!( + actual.iter().map(signature).collect::>(), + expected.iter().map(signature).collect::>(), + "path={path:?}" + ); + } +} diff --git a/iOverlay/src/mesh/int/variable_stroke/tests_reference.rs b/iOverlay/src/mesh/int/variable_stroke/tests_reference.rs new file mode 100644 index 00000000..5c42d1df --- /dev/null +++ b/iOverlay/src/mesh/int/variable_stroke/tests_reference.rs @@ -0,0 +1,164 @@ +// The original partition-then-build traversal, retained only as a regression oracle. +use super::*; +use i_float::int::number::{uint::UIntNumber, wide_int::WideIntNumber}; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct SubSegment { + pub(super) start: usize, + pub(super) end: usize, + pub(super) start_cap: Cap, + pub(super) end_cap: Cap, +} + +impl VariableStrokeBuilder { + pub(super) fn add_subsegment( + subsegment: &SubSegment, + path: &[IntStrokeVertex], + output: &mut SegmentBuilder, + ) { + 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(), + #[cfg(feature = "variable_stroke_debug")] + VariableStrokeDebugEdgeKind::CircleArc, + ); + } + return; + } + + let mut sections = (subsegment.start..subsegment.end) + .filter_map(|index| Section::try_new::(&path[index], &path[index + 1])); + let Some(mut previous) = sections.next() else { + return; + }; + + output.add_section(&previous); + output.add_start_cap(&previous, subsegment.start_cap); + + for section in sections { + output.add_section(§ion); + output.add_join(&previous, §ion); + previous = section; + } + + output.add_end_cap(&previous, subsegment.end_cap); + } + + pub(super) fn find_subsegments(path: &[IntStrokeVertex]) -> Vec { + 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]) { + 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]) { + 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 + } + + pub(super) fn break_caps(a: &IntStrokeVertex, b: &IntStrokeVertex) -> Option<(Cap, Cap)> { + let int_a = a.point; + let int_b = b.point; + let a_radius = a.radius(); + let b_radius = 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).to_uint() < distance_sqr { + return None; + } + + if a_radius >= b_radius { + Some((Cap::Round, Cap::Butt)) + } else { + Some((Cap::Butt, Cap::Round)) + } + } + + pub(super) fn circle_is_covered_by_section( + a: &IntStrokeVertex, + b: &IntStrokeVertex, + c: &IntStrokeVertex, + ) -> bool { + let a_radius = a.radius(); + let b_radius = b.radius(); + let c_radius = c.radius(); + if a_radius.max(b_radius) <= c_radius { + return false; + } + + let Some(section) = Section::try_new::(a, b) else { + return false; + }; + + let points = [section.a_left, section.b_left, section.b_right, section.a_right]; + let center = c.point; + let radius = c_radius.to_wide().to_uint(); + 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 += I::WideUInt::ONE; + } + if interior_distance.to_uint() < radius * length { + return false; + } + } + + true + } +} diff --git a/iOverlay/src/mesh/math.rs b/iOverlay/src/mesh/math.rs index f32475bb..386f672f 100644 --- a/iOverlay/src/mesh/math.rs +++ b/iOverlay/src/mesh/math.rs @@ -1,19 +1,20 @@ -use i_float::float::compatible::FloatPointCompatible; -use i_float::float::vector::FloatPointMath; - -pub(crate) struct Math

{ - _phantom: core::marker::PhantomData

, -} -impl Math

{ - #[inline(always)] - pub(crate) fn normal(a: &P, b: &P) -> P { - let c = FloatPointMath::sub(a, b); - FloatPointMath::normalize(&c) - } - - #[inline(always)] - pub(crate) fn ortho_and_scale(p: &P, s: P::Scalar) -> P { - let t = P::from_xy(-p.y(), p.x()); - FloatPointMath::scale(&t, s) - } +/// Arithmetic used to construct outline offsets, strokes, and variable-width strokes. +/// +/// Both modes retain integer coordinates and use the same integer boolean engine. +/// They can produce different rounded vertices and arc tessellations. +/// Use Integer for cross-platform deterministic construction with identical +/// integer inputs, settings, engine, and library version. Otherwise, prefer Float +/// for more accurate normalization and arcs and generally better performance. +/// The input namespace (`mesh::int` or `mesh::float`) does not select this mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum MathMode { + /// Fixed-point directions and integer trigonometry. This is the default. + #[default] + Integer, + /// f64 normalization, trigonometry, and variable-width tangent contacts. + /// Directions are stored as UnitIntVector + /// without a norm check, then scaled with integer arithmetic. Their length + /// may slightly exceed one. Cross-platform bitwise reproducibility is not + /// promised. Arc rotation_precision is ignored. + Float, } diff --git a/iOverlay/src/mesh/miter.rs b/iOverlay/src/mesh/miter.rs deleted file mode 100644 index 55a2c51c..00000000 --- a/iOverlay/src/mesh/miter.rs +++ /dev/null @@ -1,67 +0,0 @@ -use i_float::adapter::FloatPointAdapter; -use i_float::float::compatible::FloatPointCompatible; -use i_float::float::number::FloatNumber; -use i_float::int::number::int::IntNumber; -use i_float::int::point::IntPoint; - -pub(super) struct Miter; - -pub(super) enum SharpMiter { - Degenerate, - AB(IntPoint, IntPoint), - AcB(IntPoint, IntPoint, IntPoint), -} - -impl Miter { - #[inline] - pub(super) fn sharp( - pa: P, - pb: P, - va: P, - vb: P, - adapter: &FloatPointAdapter, - ) -> SharpMiter { - let ia = adapter.float_to_int(&pa); - let ib = adapter.float_to_int(&pb); - - if ia == ib { - return SharpMiter::Degenerate; - } - - let c = Self::peak(pa, pb, va, vb); - - let ic = adapter.float_to_int(&c); - - if ia == ic || ib == ic { - SharpMiter::AB(ia, ib) - } else { - SharpMiter::AcB(ia, ic, ib) - } - } - - #[inline] - pub(super) fn peak(pa: P, pb: P, va: P, vb: P) -> P { - let pax = pa.x(); - let pay = pa.y(); - let pbx = pb.x(); - let pby = pb.y(); - let vax = va.x(); - let vay = va.y(); - let vbx = vb.x(); - let vby = vb.y(); - - let xx = vax + vbx; - let yy = vay + vby; - - let k = if xx.abs() > yy.abs() { - (pbx - pax) / xx - } else { - (pby - pay) / yy - }; - - let x = pax + k * vax; - let y = pay + k * vay; - - P::from_xy(x, y) - } -} diff --git a/iOverlay/src/mesh/mod.rs b/iOverlay/src/mesh/mod.rs index 3350e09b..e3cec929 100644 --- a/iOverlay/src/mesh/mod.rs +++ b/iOverlay/src/mesh/mod.rs @@ -1,9 +1,6 @@ -pub(crate) mod math; -mod miter; -pub mod outline; +pub mod float; +pub mod int; +pub mod math; mod overlay; -mod rotator; -pub mod stroke; -pub mod style; mod subject; -pub mod variable_stroke; +mod uniq_iter; diff --git a/iOverlay/src/mesh/outline/builder.rs b/iOverlay/src/mesh/outline/builder.rs deleted file mode 100644 index 5262a812..00000000 --- a/iOverlay/src/mesh/outline/builder.rs +++ /dev/null @@ -1,243 +0,0 @@ -use crate::mesh::math::Math; -use crate::mesh::outline::builder_join::JoinBuilder; -use crate::mesh::outline::builder_join::{BevelJoinBuilder, MiterJoinBuilder, RoundJoinBuilder}; -use crate::mesh::outline::section::OffsetSection; -use crate::mesh::outline::uniq_iter::{UniqueSegment, UniqueSegmentsIter}; -use crate::mesh::style::LineJoin; -use crate::segm::boolean::ShapeCountBoolean; -use crate::segm::segment::Segment; -use alloc::boxed::Box; -use alloc::vec::Vec; -use core::marker::PhantomData; -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; - -trait OutlineBuild { - fn build( - &self, - path: &[P], - adapter: &FloatPointAdapter, - segments: &mut Vec>, - ); - - fn capacity(&self, points_count: usize) -> usize; - fn additional_offset(&self, radius: P::Scalar) -> P::Scalar; -} - -pub(super) struct OutlineBuilder { - builder: Box>, -} - -struct Builder, P: FloatPointCompatible, I: IntNumber> { - extend: bool, - radius: P::Scalar, - join_builder: J, - _phantom: PhantomData<(P, I)>, -} - -impl OutlineBuilder { - pub(super) fn new(radius: P::Scalar, join: &LineJoin) -> OutlineBuilder { - let extend = radius > P::Scalar::from_float(0.0); - let builder: Box> = { - match join { - LineJoin::Miter(ratio) => Box::new(Builder { - extend, - radius, - join_builder: MiterJoinBuilder::new(*ratio, radius), - _phantom: Default::default(), - }), - LineJoin::Round(ratio) => Box::new(Builder { - extend, - radius, - join_builder: RoundJoinBuilder::new(*ratio, radius), - _phantom: Default::default(), - }), - LineJoin::Bevel => Box::new(Builder { - extend, - radius, - join_builder: BevelJoinBuilder {}, - _phantom: Default::default(), - }), - } - }; - - Self { builder } - } - - #[inline] - pub(super) fn build( - &self, - path: &[P], - adapter: &FloatPointAdapter, - segments: &mut Vec>, - ) { - self.builder.build(path, adapter, segments); - } - - #[inline] - pub(super) fn capacity(&self, points_count: usize) -> usize { - self.builder.capacity(points_count) - } - - #[inline] - pub(super) fn additional_offset(&self, radius: P::Scalar) -> P::Scalar { - self.builder.additional_offset(radius) - } -} - -impl, P: FloatPointCompatible, I: IntNumber> OutlineBuild for Builder { - #[inline] - fn build( - &self, - path: &[P], - adapter: &FloatPointAdapter, - segments: &mut Vec>, - ) { - if path.len() < 2 { - return; - } - - self.build(path, adapter, segments); - } - - #[inline] - fn capacity(&self, points_count: usize) -> usize { - self.join_builder.capacity() * points_count - } - - #[inline] - fn additional_offset(&self, radius: P::Scalar) -> P::Scalar { - self.join_builder.additional_offset(radius) - } -} - -impl, P: FloatPointCompatible, I: IntNumber> Builder { - fn build( - &self, - path: &[P], - adapter: &FloatPointAdapter, - segments: &mut Vec>, - ) { - let iter = path.iter().map(|p| adapter.float_to_int(p)); - let mut uniq_segments = if let Some(iter) = UniqueSegmentsIter::new(iter) { - iter - } else { - return; - }; - - let us0 = if let Some(us) = uniq_segments.next() { - us - } else { - return; - }; - - let s0 = OffsetSection::new(self.radius, &us0, adapter); - let mut sk = s0.clone(); - - segments.push_some(sk.top_segment()); - - for usi in uniq_segments { - let si = OffsetSection::new(self.radius, &usi, adapter); - segments.push_some(si.top_segment()); - self.feed_join(&sk, &si, adapter, segments); - sk = si; - } - self.feed_join(&sk, &s0, adapter, segments); - } - - #[inline] - fn feed_join( - &self, - s0: &OffsetSection, - s1: &OffsetSection, - adapter: &FloatPointAdapter, - segments: &mut Vec>, - ) { - let vi = s1.b - s1.a; - let vp = s0.b - s0.a; - - let cross = vi.cross_product(vp); - - let outer_corner = if cross != I::Wide::ZERO { - (cross > I::Wide::ZERO) == self.extend - } else { - vi.dot_product(vp) < I::Wide::ZERO - }; - - if outer_corner { - if s0.b_top != s1.a_top { - self.join_builder.add_join(s0, s1, adapter, segments); - } - } else { - // no join - segments.push_some(s0.b_segment()); - segments.push_some(s1.a_segment()); - } - } -} - -impl OffsetSection { - #[inline] - fn new(radius: P::Scalar, s: &UniqueSegment, adapter: &FloatPointAdapter) -> Self { - let a = adapter.int_to_float(&s.a); - let b = adapter.int_to_float(&s.b); - let ab = FloatPointMath::sub(&b, &a); - let dir = FloatPointMath::normalize(&ab); - let t = Math::ortho_and_scale(&dir, radius); - - let at = FloatPointMath::add(&a, &t); - let bt = FloatPointMath::add(&b, &t); - let a_top = adapter.float_to_int(&at); - let b_top = adapter.float_to_int(&bt); - - Self { - a: s.a, - b: s.b, - a_top, - b_top, - dir, - } - } -} - -trait VecPushSome { - fn push_some(&mut self, value: Option); -} - -impl VecPushSome for Vec { - #[inline] - fn push_some(&mut self, value: Option) { - if let Some(v) = value { - self.push(v); - } - } -} - -#[cfg(test)] -mod tests { - use crate::mesh::outline::builder::OutlineBuilder; - use crate::mesh::style::LineJoin; - use crate::segm::boolean::ShapeCountBoolean; - use crate::segm::segment::Segment; - use alloc::vec::Vec; - use i_float::adapter::FloatPointAdapter; - use i_float::float::rect::FloatRect; - - #[test] - fn test_i64_builder() { - let path = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]]; - let mut rect = FloatRect::with_iter(path.iter()).unwrap(); - rect.add_offset(2.0); - let adapter = FloatPointAdapter::<[f64; 2], i64>::new(rect); - let builder = OutlineBuilder::<[f64; 2], i64>::new(1.0, &LineJoin::Bevel); - - let mut segments = Vec::>::new(); - builder.build(&path, &adapter, &mut segments); - - assert!(!segments.is_empty()); - } -} diff --git a/iOverlay/src/mesh/outline/builder_join.rs b/iOverlay/src/mesh/outline/builder_join.rs deleted file mode 100644 index 835ca768..00000000 --- a/iOverlay/src/mesh/outline/builder_join.rs +++ /dev/null @@ -1,250 +0,0 @@ -use crate::mesh::miter::Miter; -use crate::mesh::outline::section::OffsetSection; -use crate::mesh::rotator::Rotator; -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; - -pub(super) trait JoinBuilder { - fn add_join( - &self, - s0: &OffsetSection, - s1: &OffsetSection, - adapter: &FloatPointAdapter, - segments: &mut Vec>, - ); - fn capacity(&self) -> usize; - fn additional_offset(&self, radius: P::Scalar) -> P::Scalar; -} - -pub(super) struct BevelJoinBuilder; - -impl BevelJoinBuilder { - #[inline] - fn join( - s0: &OffsetSection, - s1: &OffsetSection, - _adapter: &FloatPointAdapter, - segments: &mut Vec>, - ) { - debug_assert!(s0.b_top != s1.a_top, "must be validated before"); - segments.push(Segment::subject(s0.b_top, s1.a_top)); - } -} - -impl JoinBuilder for BevelJoinBuilder { - #[inline] - fn add_join( - &self, - s0: &OffsetSection, - s1: &OffsetSection, - adapter: &FloatPointAdapter, - segments: &mut Vec>, - ) { - Self::join(s0, s1, adapter, segments); - } - - #[inline] - fn capacity(&self) -> usize { - 2 - } - - #[inline] - fn additional_offset(&self, radius: P::Scalar) -> P::Scalar { - // add extra 10% to avoid problems with floating point precision. - P::Scalar::from_float(1.1) * radius - } -} - -pub(super) struct MiterJoinBuilder { - limit_dot_product: T, - max_offset: T, - max_length: T, -} - -impl MiterJoinBuilder { - pub(super) fn new(angle: T, radius: T) -> Self { - // angle - min possible angle - let fixed_angle = angle.to_f64().max(0.01); - let limit_dot_product = -T::from_float(fixed_angle.cos()); - - let half_angle = 0.5 * fixed_angle; - let tan = half_angle.tan(); - - let r = radius.to_f64().abs(); - let l = r / tan; - - // add extra 10% to avoid problems with floating point precision. - let max_offset = T::from_float(1.1 * (r * r + l * l).sqrt()); - let max_length = T::from_float(l); - - Self { - limit_dot_product, - max_offset, - max_length, - } - } -} - -impl JoinBuilder for MiterJoinBuilder { - fn add_join( - &self, - s0: &OffsetSection, - s1: &OffsetSection, - adapter: &FloatPointAdapter, - segments: &mut Vec>, - ) { - let ia = s0.b_top; - let ib = s1.a_top; - - let sq_len = ia.sqr_distance(ib); - if sq_len < I::Wide::from_usize(4) { - BevelJoinBuilder::join(s0, s1, adapter, segments); - return; - } - - let dot_product = FloatPointMath::dot_product(&s0.dir, &s1.dir); - let is_limited = self.limit_dot_product > dot_product; - - let pa = adapter.int_to_float(&ia); - let pb = adapter.int_to_float(&ib); - - if is_limited { - let (va, vb) = (s0.dir, s1.dir); - - let ax = pa.x() + self.max_length * va.x(); - let ay = pa.y() + self.max_length * va.y(); - let bx = pb.x() - self.max_length * vb.x(); - let by = pb.y() - self.max_length * vb.y(); - - let ac = P::from_xy(ax, ay); - let bc = P::from_xy(bx, by); - - let iac = adapter.float_to_int(&ac); - let ibc = adapter.float_to_int(&bc); - - if ia != iac { - segments.push(Segment::subject(ia, iac)); - } - if iac != ibc { - segments.push(Segment::subject(iac, ibc)); - } - if ibc != ib { - segments.push(Segment::subject(ibc, ib)); - } - } else { - let c = Miter::peak(pa, pb, s0.dir, s1.dir); - debug_assert!(ia != ib); - - let ic = adapter.float_to_int(&c); - if ia == ic || ib == ic { - segments.push(Segment::subject(ia, ib)) - } else { - segments.push(Segment::subject(ia, ic)); - segments.push(Segment::subject(ic, ib)); - } - } - } - - #[inline] - fn capacity(&self) -> usize { - 4 - } - - #[inline] - fn additional_offset(&self, _radius: P::Scalar) -> P::Scalar { - self.max_offset - } -} - -pub(super) struct RoundJoinBuilder { - inv_ratio: T, - average_count: usize, - radius: T, - limit_dot_product: T, - rot_dir: T, -} - -impl RoundJoinBuilder { - pub(super) fn new(ratio: T, radius: T) -> Self { - // ratio = A / R - let fixed_ratio = ratio.min(T::from_float(0.25 * PI)); - let limit_dot_product = fixed_ratio.cos(); - let average_count = (T::from_float(0.6 * PI) / fixed_ratio).to_usize() + 2; - let rot_dir = if radius >= T::from_float(0.0) { - T::from_float(-1.0) - } else { - T::from_float(1.0) - }; - - Self { - inv_ratio: T::from_float(1.0) / fixed_ratio, - average_count, - radius, - limit_dot_product, - rot_dir, - } - } -} -impl JoinBuilder for RoundJoinBuilder { - fn add_join( - &self, - s0: &OffsetSection, - s1: &OffsetSection, - adapter: &FloatPointAdapter, - segments: &mut Vec>, - ) { - let dot_product = FloatPointMath::dot_product(&s0.dir, &s1.dir); - if self.limit_dot_product < dot_product { - BevelJoinBuilder::join(s0, s1, adapter, segments); - return; - } - - let angle = dot_product.acos(); - let n = (angle * self.inv_ratio).to_usize(); - let delta_angle = angle / P::Scalar::from_usize(n); - - let start = s0.b_top; - let end = s1.a_top; - - let dir = P::from_xy(-s0.dir.y(), s0.dir.x()); - - let rotator = Rotator::::with_angle(self.rot_dir * delta_angle); - - let center = adapter.int_to_float(&s0.b); - let mut v = dir; - let mut a = start; - for _ in 1..n { - v = rotator.rotate(&v); - let p = FloatPointMath::add(¢er, &FloatPointMath::scale(&v, self.radius)); - - let b = adapter.float_to_int(&p); - if a != b { - segments.push(Segment::subject(a, b)); - a = b; - } - } - - if a != end { - segments.push(Segment::subject(a, end)); - } - } - - #[inline] - fn capacity(&self) -> usize { - self.average_count - } - - #[inline] - fn additional_offset(&self, radius: P::Scalar) -> P::Scalar { - // add extra 10% to avoid problems with floating point precision. - P::Scalar::from_float(1.1) * radius - } -} diff --git a/iOverlay/src/mesh/outline/mod.rs b/iOverlay/src/mesh/outline/mod.rs deleted file mode 100644 index b161db59..00000000 --- a/iOverlay/src/mesh/outline/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -mod builder; -mod builder_join; -pub mod offset; -mod section; -mod uniq_iter; diff --git a/iOverlay/src/mesh/outline/section.rs b/iOverlay/src/mesh/outline/section.rs deleted file mode 100644 index 9f95ad58..00000000 --- a/iOverlay/src/mesh/outline/section.rs +++ /dev/null @@ -1,43 +0,0 @@ -use crate::segm::boolean::ShapeCountBoolean; -use crate::segm::segment::Segment; -use i_float::float::compatible::FloatPointCompatible; -use i_float::int::number::int::IntNumber; -use i_float::int::point::IntPoint; - -#[derive(Clone)] -pub(super) struct OffsetSection { - pub(super) a: IntPoint, - pub(super) b: IntPoint, - pub(super) a_top: IntPoint, - pub(super) b_top: IntPoint, - pub(super) dir: P, -} - -impl OffsetSection { - #[inline] - pub(super) fn top_segment(&self) -> Option> { - if self.a_top != self.b_top { - Some(Segment::subject(self.a_top, self.b_top)) - } else { - None - } - } - - #[inline] - pub(super) fn a_segment(&self) -> Option> { - if self.a_top != self.a { - Some(Segment::subject(self.a, self.a_top)) - } else { - None - } - } - - #[inline] - pub(super) fn b_segment(&self) -> Option> { - if self.b_top != self.b { - Some(Segment::subject(self.b_top, self.b)) - } else { - None - } - } -} diff --git a/iOverlay/src/mesh/rotator.rs b/iOverlay/src/mesh/rotator.rs deleted file mode 100644 index 8e660abe..00000000 --- a/iOverlay/src/mesh/rotator.rs +++ /dev/null @@ -1,84 +0,0 @@ -use i_float::float::compatible::FloatPointCompatible; -use i_float::float::number::FloatNumber; - -pub(crate) struct Rotator { - a_x: T, - a_y: T, - b_x: T, - b_y: T, -} - -impl Rotator { - #[inline] - pub(crate) fn new(cs: T, sn: T) -> Self { - let a_x = cs; - let a_y = sn; - let b_x = -a_y; - let b_y = a_x; - - Self { a_x, a_y, b_x, b_y } - } - - #[inline] - pub(crate) fn with_angle(angle: T) -> Self { - let (sin, cos) = angle.sin_cos(); - Self::new(cos, sin) - } - - #[inline] - pub(crate) fn with_vector>(v: &P) -> Self { - Self::new(v.x(), v.y()) - } - - #[inline] - pub(crate) fn rotate>(&self, v: &P) -> P { - let v_x = v.x(); - let v_y = v.y(); - let x = self.a_x * v_x + self.b_x * v_y; - let y = self.a_y * v_x + self.b_y * v_y; - P::from_xy(x, y) - } -} - -#[cfg(test)] -mod tests { - use crate::mesh::rotator::Rotator; - use core::f64::consts::PI; - - #[test] - fn test_ccw_rotate() { - let deg_45 = 0.25 * PI; - let rotator = Rotator::with_angle(deg_45); - let v0 = [1.0, 0.0]; - let v1 = rotator.rotate(&v0); - let v2 = rotator.rotate(&v1); - let v3 = rotator.rotate(&v2); - - let i_sqrt2 = 1.0 / 2.0f64.sqrt(); - - compare_vecs(v1, [i_sqrt2, i_sqrt2]); - compare_vecs(v2, [0.0, 1.0]); - compare_vecs(v3, [-i_sqrt2, i_sqrt2]); - } - - #[test] - fn test_cw_rotate() { - let deg_45 = -0.25 * PI; - let rotator = Rotator::with_angle(deg_45); - let v0 = [1.0, 0.0]; - let v1 = rotator.rotate(&v0); - let v2 = rotator.rotate(&v1); - let v3 = rotator.rotate(&v2); - - let i_sqrt2 = 1.0 / 2.0f64.sqrt(); - - compare_vecs(v1, [i_sqrt2, -i_sqrt2]); - compare_vecs(v2, [0.0, -1.0]); - compare_vecs(v3, [-i_sqrt2, -i_sqrt2]); - } - - fn compare_vecs(v0: [f64; 2], v1: [f64; 2]) { - assert!((v0[0] - v1[0]).abs() < 0.0001); - assert!((v0[1] - v1[1]).abs() < 0.0001); - } -} diff --git a/iOverlay/src/mesh/stroke/builder.rs b/iOverlay/src/mesh/stroke/builder.rs deleted file mode 100644 index d5ac0b77..00000000 --- a/iOverlay/src/mesh/stroke/builder.rs +++ /dev/null @@ -1,257 +0,0 @@ -use crate::mesh::stroke::builder_cap::CapBuilder; -use crate::mesh::stroke::builder_join::{BevelJoinBuilder, JoinBuilder, MiterJoinBuilder, RoundJoinBuilder}; -use crate::mesh::stroke::section::{Section, SectionToSegment}; -use crate::mesh::style::{LineJoin, StrokeStyle}; -use crate::segm::boolean::ShapeCountBoolean; -use crate::segm::segment::Segment; -use alloc::boxed::Box; -use alloc::vec::Vec; -use core::marker::PhantomData; -use i_float::adapter::FloatPointAdapter; -use i_float::float::compatible::FloatPointCompatible; -use i_float::float::number::FloatNumber; -use i_float::int::number::int::IntNumber; - -trait StrokeBuild { - fn build( - &self, - path: &[P], - is_closed_path: bool, - adapter: &FloatPointAdapter, - segments: &mut Vec>, - ); - - fn capacity(&self, paths_count: usize, points_count: usize, is_closed_path: bool) -> usize; - fn additional_offset(&self, radius: P::Scalar) -> P::Scalar; -} - -pub(super) struct StrokeBuilder { - builder: Box>, -} - -struct Builder, P: FloatPointCompatible, I: IntNumber> { - radius: P::Scalar, - join_builder: J, - start_cap_builder: CapBuilder

, - end_cap_builder: CapBuilder

, - _phantom: PhantomData, -} - -impl StrokeBuilder { - pub(super) fn new(style: StrokeStyle

) -> StrokeBuilder { - let radius = P::Scalar::from_float(0.5 * style.width.to_f64().max(0.0)); - - let start_cap_builder = CapBuilder::new(style.start_cap.normalize(), radius); - let end_cap_builder = CapBuilder::new(style.end_cap.normalize(), radius); - - let builder: Box> = match style.join.normalize() { - LineJoin::Miter(ratio) => Box::new(Builder { - radius, - join_builder: MiterJoinBuilder::new(ratio, radius), - start_cap_builder, - end_cap_builder, - _phantom: Default::default(), - }), - LineJoin::Round(ratio) => Box::new(Builder { - radius, - join_builder: RoundJoinBuilder::new(ratio, radius), - start_cap_builder, - end_cap_builder, - _phantom: Default::default(), - }), - LineJoin::Bevel => Box::new(Builder { - radius, - join_builder: BevelJoinBuilder {}, - start_cap_builder, - end_cap_builder, - _phantom: Default::default(), - }), - }; - - Self { builder } - } - - #[inline] - pub(super) fn build( - &self, - path: &[P], - is_closed_path: bool, - adapter: &FloatPointAdapter, - segments: &mut Vec>, - ) { - self.builder.build(path, is_closed_path, adapter, segments); - } - - #[inline] - pub(super) fn capacity(&self, paths_count: usize, points_count: usize, is_closed_path: bool) -> usize { - self.builder.capacity(paths_count, points_count, is_closed_path) - } - - #[inline] - pub(super) fn additional_offset(&self, radius: P::Scalar) -> P::Scalar { - self.builder.additional_offset(radius) - } -} - -impl, P: FloatPointCompatible, I: IntNumber> StrokeBuild for Builder { - #[inline] - fn build( - &self, - path: &[P], - is_closed_path: bool, - adapter: &FloatPointAdapter, - segments: &mut Vec>, - ) { - if is_closed_path { - self.closed_segments(path, adapter, segments); - } else { - self.open_segments(path, adapter, segments); - } - } - - #[inline] - fn capacity(&self, paths_count: usize, points_count: usize, is_closed_path: bool) -> usize { - if is_closed_path { - self.join_builder.capacity() * points_count - 2 - } else { - self.join_builder.capacity() * (points_count.saturating_sub(1)) - + paths_count * (self.end_cap_builder.capacity() + self.start_cap_builder.capacity()) - } - } - - #[inline] - fn additional_offset(&self, radius: P::Scalar) -> P::Scalar { - let start_cap = self.start_cap_builder.additional_offset(); - let end_cap = self.end_cap_builder.additional_offset(); - let join = self.join_builder.additional_offset(radius); - join.max(start_cap.max(end_cap)) - } -} - -impl, P: FloatPointCompatible, I: IntNumber> Builder { - fn open_segments( - &self, - path: &[P], - adapter: &FloatPointAdapter, - segments: &mut Vec>, - ) { - // build segments only from points which are not equal in int space - - let n = path.len(); - if n < 2 { - return; - } - - let mut ip0 = adapter.float_to_int(&path[0]); - let mut ip = adapter.float_to_int(&path[1]); - let mut j = 1; - while ip == ip0 { - j += 1; - if j >= n { - return; - } - ip = adapter.float_to_int(&path[j]); - } - - let mut s0 = Section::new(self.radius, &path[0], &path[j]); - - self.start_cap_builder.add_to_start(&s0, adapter, segments); - - segments.add_section(&s0, adapter); - - ip0 = ip; - j += 1; - 'main_loop: while j < n { - let mut p = &path[j]; - ip = adapter.float_to_int(p); - while ip == ip0 { - j += 1; - if j >= n { - break 'main_loop; - } - p = &path[j]; - ip = adapter.float_to_int(p); - } - let s1 = Section::new(self.radius, &s0.b, p); - self.join_builder.add_join(&s0, &s1, adapter, segments); - segments.add_section(&s1, adapter); - s0 = s1; - ip0 = ip; - } - - self.end_cap_builder.add_to_end(&s0, adapter, segments); - } - - fn closed_segments( - &self, - path: &[P], - adapter: &FloatPointAdapter, - segments: &mut Vec>, - ) { - if path.len() < 2 { - return; - } - - // build segments only from points which are not equal in int space - let i0 = path.len() - 1; - let i1 = Self::next_unique_point(i0, 0, path, adapter); - if i1 == usize::MAX { - return; - } - - let start = Section::new(self.radius, &path[i0], &path[i1]); - let mut s0 = start.clone(); - segments.add_section(&s0, adapter); - - let mut i = i1; - i = Self::next_unique_point(i, i + 1, path, adapter); - while i != usize::MAX { - let si = Section::new(self.radius, &s0.b, &path[i]); - self.join_builder.add_join(&s0, &si, adapter, segments); - segments.add_section(&si, adapter); - - i = Self::next_unique_point(i, i + 1, path, adapter); - s0 = si; - } - - self.join_builder.add_join(&s0, &start, adapter, segments); - } - - #[inline] - fn next_unique_point(start: usize, index: usize, path: &[P], adapter: &FloatPointAdapter) -> usize { - let a = adapter.float_to_int(&path[start]); - for (j, p) in path.iter().enumerate().skip(index) { - let b = adapter.float_to_int(p); - if a != b { - return j; - } - } - - usize::MAX - } -} - -#[cfg(test)] -mod tests { - use crate::mesh::stroke::builder::StrokeBuilder; - use crate::mesh::style::StrokeStyle; - use crate::segm::boolean::ShapeCountBoolean; - use crate::segm::segment::Segment; - use alloc::vec::Vec; - use i_float::adapter::FloatPointAdapter; - use i_float::float::rect::FloatRect; - - #[test] - fn test_i64_builder() { - let path = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0]]; - let mut rect = FloatRect::with_iter(path.iter()).unwrap(); - rect.add_offset(2.0); - let adapter = FloatPointAdapter::<[f64; 2], i64>::new(rect); - let builder = StrokeBuilder::<[f64; 2], i64>::new(StrokeStyle::new(2.0)); - - let mut segments = Vec::>::new(); - builder.build(&path, false, &adapter, &mut segments); - - assert!(!segments.is_empty()); - } -} diff --git a/iOverlay/src/mesh/stroke/builder_cap.rs b/iOverlay/src/mesh/stroke/builder_cap.rs deleted file mode 100644 index d580a4f5..00000000 --- a/iOverlay/src/mesh/stroke/builder_cap.rs +++ /dev/null @@ -1,133 +0,0 @@ -use crate::mesh::rotator::Rotator; -use crate::mesh::stroke::section::Section; -use crate::mesh::style::LineCap; -use crate::segm::boolean::ShapeCountBoolean; -use crate::segm::segment::Segment; -use alloc::vec; -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::rect::FloatRect; -use i_float::float::vector::FloatPointMath; -use i_float::int::number::int::IntNumber; - -pub(super) struct CapBuilder

{ - points: Option>, -} - -impl CapBuilder

{ - pub(super) fn new(cap: LineCap

, radius: P::Scalar) -> Self { - let points = match cap { - LineCap::Butt => None, - LineCap::Round(ratio) => Some(Self::round_points(ratio, radius)), - LineCap::Square => Some(Self::square_points(radius)), - LineCap::Custom(points) => Some(Self::custom_points(points.to_vec(), radius)), - }; - - Self { points } - } - - pub(super) fn round_points(angle: P::Scalar, r: P::Scalar) -> Vec

{ - let angle_f64 = angle.to_f64(); - let n = if angle_f64 > 0.0 { - let count = PI / angle_f64; - (count as usize).clamp(2, 1024) - } else { - 1024 - }; - - let fix_angle = P::Scalar::from_float(PI / n as f64); - let rotator = Rotator::with_angle(fix_angle); - let mut v = P::from_xy(P::Scalar::from_float(0.0), P::Scalar::from_float(-1.0)); - let mut points = Vec::with_capacity(n); - for _ in 1..n { - v = rotator.rotate(&v); - let p = FloatPointMath::scale(&v, r); - points.push(p); - } - - points - } - - pub(super) fn square_points(r: P::Scalar) -> Vec

{ - vec![P::from_xy(r, -r), P::from_xy(r, r)] - } - - pub(super) fn custom_points(points: Vec

, r: P::Scalar) -> Vec

{ - let mut scaled = points; - let mut i = 0; - while i < scaled.len() { - let p = &scaled[i]; - scaled[i] = FloatPointMath::scale(p, r); - i += 1 - } - scaled - } - - pub(super) fn add_to_start( - &self, - section: &Section

, - adapter: &FloatPointAdapter, - segments: &mut Vec>, - ) { - let mut a = adapter.float_to_int(§ion.a_top); - if let Some(points) = &self.points { - let dir = P::from_xy(-section.dir.x(), -section.dir.y()); - let rotator = Rotator::with_vector(&dir); - for p in points.iter() { - let r = rotator.rotate(p); - let q = FloatPointMath::add(&r, §ion.a); - let b = adapter.float_to_int(&q); - segments.push(Segment::subject(a, b)); - a = b; - } - } - let last = adapter.float_to_int(§ion.a_bot); - segments.push(Segment::subject(a, last)); - } - - pub(super) fn add_to_end( - &self, - section: &Section

, - adapter: &FloatPointAdapter, - segments: &mut Vec>, - ) { - let mut a = adapter.float_to_int(§ion.b_bot); - if let Some(points) = &self.points { - let rotator = Rotator::with_vector(§ion.dir); - for p in points.iter() { - let r = rotator.rotate(p); - let q = FloatPointMath::add(&r, §ion.b); - let b = adapter.float_to_int(&q); - segments.push(Segment::subject(a, b)); - a = b; - } - } - let last = adapter.float_to_int(§ion.b_top); - segments.push(Segment::subject(a, last)); - } - - #[inline] - pub(super) fn capacity(&self) -> usize { - if let Some(points) = &self.points { - 1 + points.len() - } else { - 1 - } - } - - #[inline] - pub(super) fn additional_offset(&self) -> P::Scalar { - if let Some(points) = &self.points { - if let Some(rect) = FloatRect::with_iter(points.iter()) { - rect.width() + rect.height() - } else { - P::Scalar::from_float(0.0) - } - } else { - P::Scalar::from_float(0.0) - } - } -} diff --git a/iOverlay/src/mesh/stroke/builder_join.rs b/iOverlay/src/mesh/stroke/builder_join.rs deleted file mode 100644 index 14ff729e..00000000 --- a/iOverlay/src/mesh/stroke/builder_join.rs +++ /dev/null @@ -1,390 +0,0 @@ -use crate::mesh::miter::{Miter, SharpMiter}; -use crate::mesh::rotator::Rotator; -use crate::mesh::stroke::section::Section; -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; - -pub(super) trait JoinBuilder { - fn add_join( - &self, - s0: &Section

, - s1: &Section

, - adapter: &FloatPointAdapter, - segments: &mut Vec>, - ); - fn capacity(&self) -> usize; - fn additional_offset(&self, radius: P::Scalar) -> P::Scalar; -} - -pub(super) struct BevelJoinBuilder; - -impl BevelJoinBuilder { - #[inline] - fn join_top( - s0: &Section

, - s1: &Section

, - adapter: &FloatPointAdapter, - segments: &mut Vec>, - ) { - Self::add_segment(&s0.b_top, &s1.a_top, adapter, segments); - } - - #[inline] - fn join_bot( - s0: &Section

, - s1: &Section

, - adapter: &FloatPointAdapter, - segments: &mut Vec>, - ) { - Self::add_segment(&s1.a_bot, &s0.b_bot, adapter, segments); - } - - #[inline] - fn add_segment( - a: &P, - b: &P, - adapter: &FloatPointAdapter, - segments: &mut Vec>, - ) { - let ia = adapter.float_to_int(a); - let ib = adapter.float_to_int(b); - if ia != ib { - segments.push(Segment::subject(ib, ia)); - } - } -} - -impl JoinBuilder for BevelJoinBuilder { - #[inline] - fn add_join( - &self, - s0: &Section

, - s1: &Section

, - adapter: &FloatPointAdapter, - segments: &mut Vec>, - ) { - Self::join_top(s0, s1, adapter, segments); - Self::join_bot(s0, s1, adapter, segments); - } - - #[inline] - fn capacity(&self) -> usize { - 2 - } - - #[inline] - fn additional_offset(&self, radius: P::Scalar) -> P::Scalar { - // add extra 10% to avoid problems with floating point precision. - P::Scalar::from_float(1.1) * radius - } -} - -pub(super) struct MiterJoinBuilder { - limit_dot_product: T, - max_offset: T, - max_length: T, -} - -impl MiterJoinBuilder { - pub(super) fn new(angle: T, radius: T) -> Self { - // angle - min possible angle - let fixed_angle = angle.max(T::from_float(0.01)); - let limit_dot_product = -fixed_angle.cos(); - - let half_angle = T::from_float(0.5) * fixed_angle; - let tan = half_angle.tan(); - - let r = radius; - let max_length = r / tan; - let sqr_len = max_length * max_length; - let sqr_rad = r * r; - // add extra 10% to avoid problems with floating point precision. - let extra_scale = T::from_float(1.1); - - let max_offset = extra_scale * (sqr_rad + sqr_len).sqrt(); - - Self { - limit_dot_product, - max_offset, - max_length, - } - } -} - -impl JoinBuilder for MiterJoinBuilder { - fn add_join( - &self, - s0: &Section

, - s1: &Section

, - adapter: &FloatPointAdapter, - segments: &mut Vec>, - ) { - let cross_product = FloatPointMath::cross_product(&s0.dir, &s1.dir); - if cross_product.abs() < P::Scalar::from_float(0.0001) { - BevelJoinBuilder::join_top(s0, s1, adapter, segments); - BevelJoinBuilder::join_bot(s0, s1, adapter, segments); - return; - } - - let turn = cross_product > P::Scalar::from_float(0.0); - - let dot_product = FloatPointMath::dot_product(&s0.dir, &s1.dir); - - let is_limited = self.limit_dot_product > dot_product; - - if is_limited { - let (pa, pb, ac, bc) = if turn { - BevelJoinBuilder::join_top(s0, s1, adapter, segments); - let (pa, pb, va, vb) = (s1.a_bot, s0.b_bot, s1.dir, s0.dir); - - let ax = pa.x() - self.max_length * va.x(); - let ay = pa.y() - self.max_length * va.y(); - let bx = pb.x() + self.max_length * vb.x(); - let by = pb.y() + self.max_length * vb.y(); - - let ac = P::from_xy(ax, ay); - let bc = P::from_xy(bx, by); - - (pa, pb, ac, bc) - } else { - BevelJoinBuilder::join_bot(s0, s1, adapter, segments); - let (pa, pb, va, vb) = (s0.b_top, s1.a_top, s0.dir, s1.dir); - - let ax = pa.x() + self.max_length * va.x(); - let ay = pa.y() + self.max_length * va.y(); - let bx = pb.x() - self.max_length * vb.x(); - let by = pb.y() - self.max_length * vb.y(); - - let ac = P::from_xy(ax, ay); - let bc = P::from_xy(bx, by); - - (pa, pb, ac, bc) - }; - - let ia = adapter.float_to_int(&pa); - let ib = adapter.float_to_int(&pb); - - if ia == ib { - return; - } - - let iac = adapter.float_to_int(&ac); - let ibc = adapter.float_to_int(&bc); - - if ia != iac { - segments.push(Segment::subject(iac, ia)); - } - if iac != ibc { - segments.push(Segment::subject(ibc, iac)); - } - if ibc != ib { - segments.push(Segment::subject(ib, ibc)); - } - } else { - let (pa, pb, va, vb) = if turn { - BevelJoinBuilder::join_top(s0, s1, adapter, segments); - (s1.a_bot, s0.b_bot, s1.dir, s0.dir) - } else { - BevelJoinBuilder::join_bot(s0, s1, adapter, segments); - (s0.b_top, s1.a_top, s0.dir, s1.dir) - }; - match Miter::sharp(pa, pb, va, vb, adapter) { - SharpMiter::AB(a, b) => segments.push(Segment::subject(b, a)), - SharpMiter::AcB(a, c, b) => { - segments.push(Segment::subject(c, a)); - segments.push(Segment::subject(b, c)); - } - SharpMiter::Degenerate => {} - } - } - } - - #[inline] - fn capacity(&self) -> usize { - 4 - } - - #[inline] - fn additional_offset(&self, _radius: P::Scalar) -> P::Scalar { - self.max_offset - } -} - -pub(super) struct RoundJoinBuilder { - inv_ratio: T, - average_count: usize, - radius: T, - limit_dot_product: T, -} - -impl RoundJoinBuilder { - pub(super) fn new(ratio: T, radius: T) -> Self { - // ratio = A / R - let fixed_ratio = ratio.min(T::from_float(0.25 * PI)); - let limit_dot_product = fixed_ratio.cos(); - let average_count = (T::from_float(0.6 * PI) / fixed_ratio).to_usize() + 2; - Self { - inv_ratio: T::from_float(1.0) / fixed_ratio, - average_count, - radius, - limit_dot_product, - } - } -} -impl JoinBuilder for RoundJoinBuilder { - fn add_join( - &self, - s0: &Section

, - s1: &Section

, - adapter: &FloatPointAdapter, - segments: &mut Vec>, - ) { - let dot_product = FloatPointMath::dot_product(&s0.dir, &s1.dir); - if self.limit_dot_product < dot_product { - BevelJoinBuilder::join_top(s0, s1, adapter, segments); - BevelJoinBuilder::join_bot(s0, s1, adapter, segments); - return; - } - - let angle = dot_product.acos(); - let n = (angle * self.inv_ratio).to_usize(); - let delta_angle = angle / P::Scalar::from_usize(n); - - let cross_product = FloatPointMath::cross_product(&s0.dir, &s1.dir); - let (start, end, dir) = if cross_product > P::Scalar::from_float(0.0) { - BevelJoinBuilder::join_top(s0, s1, adapter, segments); - let ortho = P::from_xy(s1.dir.y(), -s1.dir.x()); - (s1.a_bot, s0.b_bot, ortho) - } else { - BevelJoinBuilder::join_bot(s0, s1, adapter, segments); - let ortho = P::from_xy(-s0.dir.y(), s0.dir.x()); - (s0.b_top, s1.a_top, ortho) - }; - let rotator = Rotator::::with_angle(-delta_angle); - - let center = s0.b; - let mut v = dir; - let mut a = adapter.float_to_int(&start); - for _ in 1..n { - v = rotator.rotate(&v); - let p = FloatPointMath::add(¢er, &FloatPointMath::scale(&v, self.radius)); - - let b = adapter.float_to_int(&p); - if a != b { - segments.push(Segment::subject(b, a)); - a = b; - } - } - - let b = adapter.float_to_int(&end); - if a != b { - segments.push(Segment::subject(b, a)); - } - } - - #[inline] - fn capacity(&self) -> usize { - self.average_count - } - - #[inline] - fn additional_offset(&self, radius: P::Scalar) -> P::Scalar { - // add extra 10% to avoid problems with floating point precision. - P::Scalar::from_float(1.1) * radius - } -} - -#[cfg(test)] -mod tests { - use super::{BevelJoinBuilder, JoinBuilder, MiterJoinBuilder, RoundJoinBuilder}; - use crate::mesh::stroke::section::Section; - 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::rect::FloatRect; - - type TestSegment = Segment; - - fn build_join>( - builder: &J, - radius: f64, - a: [f64; 2], - b: [f64; 2], - c: [f64; 2], - scale: f64, - ) -> Vec { - let rect = FloatRect::new(-20.0, 20.0, -20.0, 20.0); - let adapter = FloatPointAdapter::try_with_scale(rect, scale).unwrap(); - let s0 = Section::new(radius, &a, &b); - let s1 = Section::new(radius, &b, &c); - let mut segments = Vec::new(); - - builder.add_join(&s0, &s1, &adapter, &mut segments); - - segments - } - - #[test] - fn acute_angle_uses_each_requested_join_type() { - let radius = 1.0; - let a = [-10.0, 0.0]; - let b = [0.0, 0.0]; - let c = [-10.0, 0.1]; - let scale = 1_000.0; - - let bevel = build_join(&BevelJoinBuilder, radius, a, b, c, scale); - let miter = build_join(&MiterJoinBuilder::new(PI / 6.0, radius), radius, a, b, c, scale); - let round = build_join(&RoundJoinBuilder::new(PI / 12.0, radius), radius, a, b, c, scale); - - assert_eq!(bevel.len(), 2); - assert_eq!(miter.len(), 4); - assert!(round.len() > miter.len()); - assert_ne!(miter, bevel); - assert_ne!(round, bevel); - } - - #[test] - fn near_collinear_segments_fall_back_to_stable_bevel_join() { - let radius = 1.0; - let a = [-10.0, 0.0]; - let b = [0.0, 0.0]; - let c = [10.0, 0.000_01]; - let scale = 1_000_000.0; - - let bevel = build_join(&BevelJoinBuilder, radius, a, b, c, scale); - let miter = build_join(&MiterJoinBuilder::new(PI / 6.0, radius), radius, a, b, c, scale); - let round = build_join(&RoundJoinBuilder::new(PI / 12.0, radius), radius, a, b, c, scale); - - assert_eq!(miter, bevel); - assert_eq!(round, bevel); - - let repeated = build_join(&MiterJoinBuilder::new(PI / 6.0, radius), radius, a, b, c, scale); - assert_eq!(repeated, miter); - } - - #[test] - fn tiny_offset_does_not_create_degenerate_join_segments() { - let radius = 0.01; - let a = [-10.0, 0.0]; - let b = [0.0, 0.0]; - let c = [0.0, 10.0]; - let scale = 10.0; - - let bevel = build_join(&BevelJoinBuilder, radius, a, b, c, scale); - let miter = build_join(&MiterJoinBuilder::new(PI / 6.0, radius), radius, a, b, c, scale); - let round = build_join(&RoundJoinBuilder::new(PI / 12.0, radius), radius, a, b, c, scale); - - assert!(bevel.is_empty()); - assert!(miter.is_empty()); - assert!(round.is_empty()); - } -} diff --git a/iOverlay/src/mesh/stroke/mod.rs b/iOverlay/src/mesh/stroke/mod.rs deleted file mode 100644 index 4d1e1384..00000000 --- a/iOverlay/src/mesh/stroke/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -mod builder; -mod builder_cap; -mod builder_join; -pub mod offset; -mod section; diff --git a/iOverlay/src/mesh/stroke/section.rs b/iOverlay/src/mesh/stroke/section.rs deleted file mode 100644 index d647b0f4..00000000 --- a/iOverlay/src/mesh/stroke/section.rs +++ /dev/null @@ -1,62 +0,0 @@ -use crate::mesh::math::Math; -use crate::segm::boolean::ShapeCountBoolean; -use crate::segm::segment::Segment; -use alloc::vec::Vec; -use i_float::adapter::FloatPointAdapter; -use i_float::float::compatible::FloatPointCompatible; -use i_float::float::vector::FloatPointMath; -use i_float::int::number::int::IntNumber; - -#[derive(Clone)] -pub(super) struct Section { - pub(super) a: P, - pub(super) b: P, - pub(super) a_top: P, - pub(super) b_top: P, - pub(super) a_bot: P, - pub(super) b_bot: P, - pub(super) dir: P, -} - -impl Section

{ - pub(crate) fn new(radius: P::Scalar, a: &P, b: &P) -> Self { - let dir = Math::normal(b, a); - let t = Math::ortho_and_scale(&dir, radius); - - let a_top = FloatPointMath::add(a, &t); - let a_bot = FloatPointMath::sub(a, &t); - - let b_top = FloatPointMath::add(b, &t); - let b_bot = FloatPointMath::sub(b, &t); - - Section { - a: *a, - b: *b, - a_top, - b_top, - a_bot, - b_bot, - dir, - } - } -} - -pub(crate) trait SectionToSegment { - fn add_section(&mut self, section: &Section

, adapter: &FloatPointAdapter); -} - -impl SectionToSegment for Vec> { - fn add_section(&mut self, section: &Section

, adapter: &FloatPointAdapter) { - let a_top = adapter.float_to_int(§ion.a_top); - let b_top = adapter.float_to_int(§ion.b_top); - let a_bot = adapter.float_to_int(§ion.a_bot); - let b_bot = adapter.float_to_int(§ion.b_bot); - - if a_top != b_top { - self.push(Segment::subject(b_top, a_top)); - } - if a_bot != b_bot { - self.push(Segment::subject(a_bot, b_bot)); - } - } -} diff --git a/iOverlay/src/mesh/style.rs b/iOverlay/src/mesh/style.rs deleted file mode 100644 index 5d0d4c6e..00000000 --- a/iOverlay/src/mesh/style.rs +++ /dev/null @@ -1,172 +0,0 @@ -use alloc::rc::Rc; -use core::f64::consts::PI; -use i_float::float::compatible::FloatPointCompatible; -use i_float::float::number::FloatNumber; - -/// The endpoint style of a line. -#[derive(Debug, Clone)] -pub enum LineCap { - /// A line with a squared-off end. This is the default. - Butt, - /// A line with a rounded end. The line ends with a semicircular arc with a radius of 1/2 the line’s width, centered on the endpoint. - /// Takes a parameter `Angle` in radians. - Round(P::Scalar), - /// A line with a squared-off end. An extended distance equal to half the line width. - Square, - /// Set a custom end with template points. - Custom(Rc<[P]>), -} - -/// The join style of a line. -#[derive(Debug, Clone)] -pub enum LineJoin { - /// Cuts off the corner where two lines meet. This is the default. - Bevel, - /// Creates a sharp corner where two lines meet. - /// The corner is limited by a miter, where the parameter `Angle` - /// is a minimum sharp angle - Miter(T), - /// Creates an arc corner where two lines meet. - /// The arc is approximated using a group of segments, where the parameter `Angle` - /// is defined as `L / R`, with `L` being the maximum segment length and `R` being the arc radius. - Round(T), -} - -/// Defines the stroke style for outlining paths. -#[derive(Debug, Clone)] -pub struct StrokeStyle { - /// The width of the stroke. - pub width: P::Scalar, - /// The cap style at the start of the stroke. - pub start_cap: LineCap

, - /// The cap style at the end of the stroke. - pub end_cap: LineCap

, - /// The join style where two lines meet. - pub join: LineJoin, -} - -/// Defines the outline style for offsetting shapes. -#[derive(Debug)] -pub struct OutlineStyle { - pub outer_offset: T, - pub inner_offset: T, - pub join: LineJoin, -} - -impl LineCap

{ - pub(crate) fn normalize(self) -> Self { - if let LineCap::Round(angle) = self { - let a = angle.to_f64().clamp(0.01 * PI, 0.25 * PI); - LineCap::Round(P::Scalar::from_float(a)) - } else { - self - } - } -} - -impl LineJoin { - pub(crate) fn normalize(self) -> Self { - match self { - LineJoin::Miter(ratio) => { - let a = ratio.to_f64().clamp(0.01 * PI, 0.99 * PI); - LineJoin::Miter(T::from_float(a)) - } - LineJoin::Round(angle) => { - let a = angle.to_f64().clamp(0.01 * PI, 0.25 * PI); - LineJoin::Round(T::from_float(a)) - } - _ => self, - } - } -} - -impl StrokeStyle

{ - /// Creates a new `StrokeStyle` with the specified width. - pub fn new(width: P::Scalar) -> Self { - Self { - width, - ..Default::default() - } - } - - /// Sets the stroke width. - pub fn width(mut self, width: P::Scalar) -> Self { - self.width = P::Scalar::from_float(width.to_f64().max(0.0)); - self - } - - /// Sets the cap style at the start of the stroke. - pub fn start_cap(mut self, cap: LineCap

) -> Self { - self.start_cap = cap.normalize(); - self - } - - /// Sets the cap style at the end of the stroke. - pub fn end_cap(mut self, cap: LineCap

) -> Self { - self.end_cap = cap.normalize(); - self - } - - /// Sets the line join style. - pub fn line_join(mut self, join: LineJoin) -> Self { - self.join = join.normalize(); - self - } -} - -impl Default for StrokeStyle

{ - fn default() -> Self { - Self { - width: P::Scalar::from_float(1.0), - start_cap: LineCap::Butt, - end_cap: LineCap::Butt, - join: LineJoin::Bevel, - } - } -} - -impl OutlineStyle { - /// Creates a new `OutlineStyle` with the specified offset. - pub fn new(offset: T) -> Self { - Self { - outer_offset: offset, - inner_offset: offset, - ..Default::default() - } - } - - /// Sets the offset distance. - pub fn offset(mut self, offset: T) -> Self { - self.outer_offset = offset; - self.inner_offset = offset; - self - } - - /// Sets the outer distance. - pub fn outer_offset(mut self, outer_offset: T) -> Self { - self.outer_offset = outer_offset; - self - } - - /// Sets the inner distance. - pub fn inner_offset(mut self, inner_offset: T) -> Self { - self.inner_offset = inner_offset; - self - } - - /// Sets the line join style for the offset path. - pub fn line_join(mut self, join: LineJoin) -> Self { - self.join = join; - self - } -} - -impl Default for OutlineStyle { - fn default() -> Self { - Self { - outer_offset: T::from_float(1.0), - inner_offset: T::from_float(1.0), - join: LineJoin::Bevel, - } - } -} diff --git a/iOverlay/src/mesh/subject.rs b/iOverlay/src/mesh/subject.rs index 3cde1bcf..11202c63 100644 --- a/iOverlay/src/mesh/subject.rs +++ b/iOverlay/src/mesh/subject.rs @@ -1,12 +1,28 @@ use crate::geom::x_segment::XSegment; use crate::segm::boolean::ShapeCountBoolean; use crate::segm::segment::Segment; +use alloc::vec::Vec; use i_float::int::number::int::IntNumber; use i_float::int::point::IntPoint; +pub(super) trait SubjectSegments { + /// Adds a subject segment only when its endpoints differ. + fn push_non_degenerate(&mut self, a: IntPoint, b: IntPoint); +} + +impl SubjectSegments for Vec> { + #[inline] + fn push_non_degenerate(&mut self, a: IntPoint, b: IntPoint) { + if a != b { + self.push(Segment::subject(a, b)); + } + } +} + impl Segment { #[inline] pub(crate) fn subject(p0: IntPoint, p1: IntPoint) -> Self { + debug_assert!(p0 != p1, "zero-length edges must be filtered before construction"); if p0 < p1 { Self { x_segment: XSegment { a: p0, b: p1 }, diff --git a/iOverlay/src/mesh/outline/uniq_iter.rs b/iOverlay/src/mesh/uniq_iter.rs similarity index 98% rename from iOverlay/src/mesh/outline/uniq_iter.rs rename to iOverlay/src/mesh/uniq_iter.rs index c4733201..104128b9 100644 --- a/iOverlay/src/mesh/outline/uniq_iter.rs +++ b/iOverlay/src/mesh/uniq_iter.rs @@ -106,7 +106,7 @@ fn include_point(p0: IntPoint, p1: IntPoint, p2: IntPoint } #[cfg(test)] mod tests { - use crate::mesh::outline::uniq_iter::{UniqueSegment, UniqueSegmentsIter}; + use crate::mesh::uniq_iter::{UniqueSegment, UniqueSegmentsIter}; use alloc::vec::Vec; use i_float::int::point::IntPoint; use i_shape::int_path; diff --git a/iOverlay/src/mesh/variable_stroke/builder.rs b/iOverlay/src/mesh/variable_stroke/builder.rs deleted file mode 100644 index a4bbb5d0..00000000 --- a/iOverlay/src/mesh/variable_stroke/builder.rs +++ /dev/null @@ -1,1059 +0,0 @@ -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/section.rs b/iOverlay/src/mesh/variable_stroke/section.rs deleted file mode 100644 index 1e6d9a16..00000000 --- a/iOverlay/src/mesh/variable_stroke/section.rs +++ /dev/null @@ -1,164 +0,0 @@ -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/segm/build.rs b/iOverlay/src/segm/build.rs index 5293fbe2..bf6d2344 100644 --- a/iOverlay/src/segm/build.rs +++ b/iOverlay/src/segm/build.rs @@ -129,6 +129,7 @@ impl PointFilter for DropCollinear { impl Segment { #[inline] pub(crate) fn with_ab(p0: IntPoint, p1: IntPoint, direct: C, invert: C) -> Self { + debug_assert!(p0 != p1, "zero-length edges must be filtered before construction"); if p0 < p1 { Self { x_segment: XSegment { a: p0, b: p1 }, @@ -155,6 +156,32 @@ mod tests { use alloc::vec::Vec; use i_float::int::point::IntPoint; + #[test] + fn repeated_path_points_do_not_create_zero_length_segments() { + let points = [ + IntPoint::new(0, 0), + IntPoint::new(1, 0), + IntPoint::new(2, 0), + IntPoint::new(1, 1), + ]; + // All six-point paths on this grid include duplicates or collinear runs. + for mut code in 0..4_usize.pow(6) { + let path: [_; 6] = core::array::from_fn(|_| { + let p = points[code % 4]; + code /= 4; + p + }); + for keep in [false, true] { + let mut segments = Vec::>::new(); + segments.append_path_iter(path.into_iter(), ShapeType::Subject, keep); + assert!( + segments.iter().all(|s| s.x_segment.a < s.x_segment.b), + "path={path:?}, keep={keep}" + ); + } + } + } + #[test] fn test_0() { let points = [ diff --git a/iOverlay/src/segm/segment.rs b/iOverlay/src/segm/segment.rs index 1c0f6883..ba44a11e 100644 --- a/iOverlay/src/segm/segment.rs +++ b/iOverlay/src/segm/segment.rs @@ -39,6 +39,7 @@ impl> Segment { data: D, store: &mut D::Store, ) -> Self { + debug_assert!(a != b, "split points must differ from the segment endpoints"); if a < b { Self { x_segment: XSegment { a, b }, diff --git a/iOverlay/src/split/cross_solver.rs b/iOverlay/src/split/cross_solver.rs index c33938fa..2a0f3f5b 100644 --- a/iOverlay/src/split/cross_solver.rs +++ b/iOverlay/src/split/cross_solver.rs @@ -76,7 +76,7 @@ impl CrossSolver { pub(super) fn cross( target: &XSegment, other: &XSegment, - radius_squared: I::Wide, + radius_squared: I::WideUInt, ) -> Option> { let a0b0a1 = Triangle::clock_direction(target.a, target.b, other.a); let a0b0b1 = Triangle::clock_direction(target.a, target.b, other.b); @@ -164,7 +164,7 @@ impl CrossSolver { fn middle_cross( target: &XSegment, other: &XSegment, - radius_squared: I::Wide, + radius_squared: I::WideUInt, ) -> Option> { let p = CrossSolver::cross_point(target, other); diff --git a/iOverlay/src/split/snap_radius.rs b/iOverlay/src/split/snap_radius.rs index 32540ddd..a6ce734b 100644 --- a/iOverlay/src/split/snap_radius.rs +++ b/iOverlay/src/split/snap_radius.rs @@ -1,6 +1,6 @@ use crate::core::solver::Solver; use i_float::int::number::int::IntNumber; -use i_float::int::number::wide_int::WideIntNumber; +use i_float::int::number::uint::UIntNumber; pub(super) struct SnapRadius { current: usize, @@ -13,9 +13,9 @@ impl SnapRadius { } /// Squared-distance threshold for snapping to an existing endpoint. - pub(super) fn radius_squared(&self) -> I::Wide { + pub(super) fn radius_squared(&self) -> I::WideUInt { let exponent = self.current.min((2 * (I::BITS - 4)) as usize) as u32; - I::Wide::ONE << exponent + I::WideUInt::ONE << exponent } } @@ -49,7 +49,7 @@ mod tests { current: $exponent - 1, step: 1, }; - let limit: <$int as i_float::int::number::int::IntNumber>::Wide = 1 << $exponent; + let limit: <$int as i_float::int::number::int::IntNumber>::WideUInt = 1 << $exponent; assert_eq!(snap.radius_squared::<$int>(), limit / 2); snap.increment(); assert_eq!(snap.radius_squared::<$int>(), limit); diff --git a/iOverlay/src/split/solver.rs b/iOverlay/src/split/solver.rs index afdffe6e..16ed5b1a 100644 --- a/iOverlay/src/split/solver.rs +++ b/iOverlay/src/split/solver.rs @@ -85,7 +85,7 @@ where ei: &XSegment, ej: &XSegment, marks: &mut Vec>, - radius_squared: I::Wide, + radius_squared: I::WideUInt, ) -> bool { let cross = if let Some(cross) = CrossSolver::::cross(ei, ej, radius_squared) { cross @@ -301,3 +301,53 @@ where } } } + +#[cfg(test)] +mod non_degenerate_tests { + use super::*; + use crate::segm::boolean::ShapeCountBoolean; + use i_float::int::point::IntPoint; + + #[test] + fn grid_intersections_do_not_create_zero_length_segments() { + let points: Vec<_> = (-2..=2) + .flat_map(|x| (-2..=2).map(move |y| IntPoint::new(x, y))) + .collect(); + let mut edges = Vec::new(); + for (i, &a) in points.iter().enumerate() { + for &b in &points[i + 1..] { + edges.push(Segment::::subject(a, b)); + } + } + let mut splitter = SplitSolver::new(); + let mut buffer = Vec::new(); + for (i, &a) in edges.iter().enumerate() { + for &b in &edges[i + 1..] { + for radius_squared in [1, 2, 4, 16] { + splitter.marks.clear(); + SplitSolver::cross( + 0, + 1, + &a.x_segment, + &b.x_segment, + &mut splitter.marks, + radius_squared, + ); + let mut segments = alloc::vec![a, b]; + for mark in &splitter.marks { + let edge = segments[mark.index].x_segment; + assert!( + mark.point != edge.a && mark.point != edge.b, + "endpoint split: {edge:?}, {:?}", + mark.point + ); + } + // Multiple intersections can produce the same mark. + splitter.marks.extend_from_within(..); + splitter.apply(&mut segments, &mut buffer, &Solver::LIST, &mut ()); + assert!(segments.iter().all(|s| s.x_segment.a < s.x_segment.b)); + } + } + } + } +} diff --git a/iOverlay/src/split/solver_fragment.rs b/iOverlay/src/split/solver_fragment.rs index 95eebdd7..266afbee 100644 --- a/iOverlay/src/split/solver_fragment.rs +++ b/iOverlay/src/split/solver_fragment.rs @@ -81,7 +81,12 @@ where } #[inline] - fn process(&mut self, radius_squared: I::Wide, buffer: &mut FragmentBuffer, _solver: &Solver) -> bool { + fn process( + &mut self, + radius_squared: I::WideUInt, + buffer: &mut FragmentBuffer, + _solver: &Solver, + ) -> bool { #[cfg(feature = "allow_multithreading")] { if _solver.multithreading.is_some() { @@ -93,7 +98,7 @@ where } #[inline] - fn serial_split(&mut self, radius_squared: I::Wide, buffer: &mut FragmentBuffer) -> bool { + fn serial_split(&mut self, radius_squared: I::WideUInt, buffer: &mut FragmentBuffer) -> bool { let mut is_any_round = false; for group in buffer.groups.iter_mut() { if group.is_empty() { @@ -106,7 +111,7 @@ where } #[cfg(feature = "allow_multithreading")] - fn parallel_split(&mut self, radius_squared: I::Wide, buffer: &mut FragmentBuffer) -> bool { + fn parallel_split(&mut self, radius_squared: I::WideUInt, buffer: &mut FragmentBuffer) -> bool { use rayon::iter::IntoParallelRefMutIterator; use rayon::iter::ParallelIterator; @@ -152,7 +157,7 @@ where } fn bin_split( - radius_squared: I::Wide, + radius_squared: I::WideUInt, fragments: &mut [Fragment], marks: &mut Vec>, ) -> bool { @@ -227,7 +232,7 @@ where fn cross_fragments( fi: &Fragment, fj: &Fragment, - radius_squared: I::Wide, + radius_squared: I::WideUInt, marks: &mut Vec>, ) -> bool { // Fragments select candidate pairs; marks belong to the complete segments. diff --git a/iOverlay/src/string/clip.rs b/iOverlay/src/string/clip.rs index 90d25be2..e4e4d24a 100644 --- a/iOverlay/src/string/clip.rs +++ b/iOverlay/src/string/clip.rs @@ -9,9 +9,8 @@ use crate::string::line::IntLine; use crate::string::overlay::StringOverlay; use alloc::vec::Vec; use i_float::int::number::int::IntNumber; -use i_float::int::point::IntPoint; use i_shape::int::path::IntPath; -use i_shape::int::shape::{IntShape, IntShapes}; +use i_shape::source::int::resource::IntShapeResource; #[derive(Debug, Clone, Copy)] pub struct ClipRule { @@ -157,103 +156,52 @@ pub trait IntClip { /// # Returns /// A vector of `IntPath` instances containing the clipped portions of the input paths. fn clip_paths(&self, paths: &[IntPath], fill_rule: FillRule, clip_rule: ClipRule) -> Vec>; -} - -impl IntClip for IntShapes -where - I: OverlayInt, -{ - #[inline] - fn clip_line(&self, line: IntLine, fill_rule: FillRule, clip_rule: ClipRule) -> Vec> { - let mut overlay = StringOverlay::with_shapes(self); - overlay.add_string_line(line); - overlay.clip_string_lines(fill_rule, clip_rule) - } - - #[inline] - fn clip_lines(&self, lines: &[IntLine], fill_rule: FillRule, clip_rule: ClipRule) -> Vec> { - let mut overlay = StringOverlay::with_shapes(self); - overlay.add_string_lines(lines); - overlay.clip_string_lines(fill_rule, clip_rule) - } - - #[inline] - fn clip_path(&self, path: &IntPath, fill_rule: FillRule, clip_rule: ClipRule) -> Vec> { - let mut overlay = StringOverlay::with_shapes(self); - overlay.add_string_path(path); - overlay.clip_string_lines(fill_rule, clip_rule) - } - - #[inline] - fn clip_paths(&self, paths: &[IntPath], fill_rule: FillRule, clip_rule: ClipRule) -> Vec> { - let mut overlay = StringOverlay::with_shapes(self); - overlay.add_string_paths(paths); - overlay.clip_string_lines(fill_rule, clip_rule) - } -} - -impl IntClip for IntShape -where - I: OverlayInt, -{ - #[inline] - fn clip_line(&self, line: IntLine, fill_rule: FillRule, clip_rule: ClipRule) -> Vec> { - let mut overlay = StringOverlay::with_shape(self); - overlay.add_string_line(line); - overlay.clip_string_lines(fill_rule, clip_rule) - } - - #[inline] - fn clip_lines(&self, lines: &[IntLine], fill_rule: FillRule, clip_rule: ClipRule) -> Vec> { - let mut overlay = StringOverlay::with_shape(self); - overlay.add_string_lines(lines); - overlay.clip_string_lines(fill_rule, clip_rule) - } - #[inline] - fn clip_path(&self, path: &IntPath, fill_rule: FillRule, clip_rule: ClipRule) -> Vec> { - let mut overlay = StringOverlay::with_shape(self); - overlay.add_string_path(path); - overlay.clip_string_lines(fill_rule, clip_rule) - } - - #[inline] - fn clip_paths(&self, paths: &[IntPath], fill_rule: FillRule, clip_rule: ClipRule) -> Vec> { - let mut overlay = StringOverlay::with_shape(self); - overlay.add_string_paths(paths); - overlay.clip_string_lines(fill_rule, clip_rule) + /// Clips every resource path as an open string against this polygon resource. + fn clip_source + ?Sized>( + &self, + source: &R, + fill_rule: FillRule, + clip_rule: ClipRule, + ) -> Vec> + where + Self: IntShapeResource, + I: OverlayInt, + { + StringOverlay::from_shape_and_string(self, source).clip_string_lines(fill_rule, clip_rule) } } -impl IntClip for [IntPoint] +impl IntClip for R where I: OverlayInt, + R: IntShapeResource + ?Sized, { #[inline] fn clip_line(&self, line: IntLine, fill_rule: FillRule, clip_rule: ClipRule) -> Vec> { - let mut overlay = StringOverlay::with_shape_contour(self); + let mut overlay = StringOverlay::from_shape(self); overlay.add_string_line(line); overlay.clip_string_lines(fill_rule, clip_rule) } #[inline] fn clip_lines(&self, lines: &[IntLine], fill_rule: FillRule, clip_rule: ClipRule) -> Vec> { - let mut overlay = StringOverlay::with_shape_contour(self); + let mut overlay = StringOverlay::from_shape(self); overlay.add_string_lines(lines); overlay.clip_string_lines(fill_rule, clip_rule) } #[inline] fn clip_path(&self, path: &IntPath, fill_rule: FillRule, clip_rule: ClipRule) -> Vec> { - let mut overlay = StringOverlay::with_shape_contour(self); + let mut overlay = StringOverlay::from_shape(self); overlay.add_string_path(path); overlay.clip_string_lines(fill_rule, clip_rule) } #[inline] fn clip_paths(&self, paths: &[IntPath], fill_rule: FillRule, clip_rule: ClipRule) -> Vec> { - let mut overlay = StringOverlay::with_shape_contour(self); - overlay.add_string_paths(paths); + let mut overlay = StringOverlay::from_shape(self); + overlay.add_string_source(paths); overlay.clip_string_lines(fill_rule, clip_rule) } } diff --git a/iOverlay/src/string/extract.rs b/iOverlay/src/string/extract.rs index ffe272ba..23e79d78 100644 --- a/iOverlay/src/string/extract.rs +++ b/iOverlay/src/string/extract.rs @@ -255,7 +255,7 @@ mod tests { IntPoint::new(5, -5), ]; - let mut overlay = StringOverlay::with_shape(&paths); + let mut overlay = StringOverlay::from_shape(&paths); overlay.add_string_contour(&window); let graph = overlay.build_graph_view(FillRule::NonZero).unwrap(); @@ -280,7 +280,7 @@ mod tests { IntPoint::new(5, -5), ]; - let mut overlay = StringOverlay::::with_shape(&paths); + let mut overlay = StringOverlay::::from_shape(&paths); overlay.add_string_contour(&window); let graph = overlay.build_graph_view(FillRule::NonZero).unwrap(); diff --git a/iOverlay/src/string/overlay.rs b/iOverlay/src/string/overlay.rs index 1da56493..b36378da 100644 --- a/iOverlay/src/string/overlay.rs +++ b/iOverlay/src/string/overlay.rs @@ -16,9 +16,9 @@ use crate::string::line::IntLine; use alloc::vec::Vec; use core::cmp::Ordering; use i_float::int::point::IntPoint; -use i_shape::int::count::PointsCount; use i_shape::int::path::IntPath; use i_shape::int::shape::{IntContour, IntShape}; +use i_shape::source::int::resource::IntShapeResource; /// Integer polygon and string overlay builder. /// @@ -61,40 +61,104 @@ where } } + /// Creates an overlay from polygon paths, interpreted as closed contours. + pub fn from_shape + ?Sized>(shape: &R) -> Self { + Self::from_shape_custom(shape, Default::default()) + } + + /// Creates a polygon overlay with custom output options. + pub fn from_shape_custom + ?Sized>( + shape: &R, + options: IntOverlayOptions, + ) -> Self { + let capacity = shape.iter_paths().map(|path| path.len()).sum(); + let mut overlay = Self::with_options(capacity, options); + overlay.add_shape_source(shape); + overlay + } + + /// Creates an overlay from closed polygon contours and open string paths. + /// String paths are not implicitly closed, regardless of their storage type. + pub fn from_shape_and_string(shape: &R0, string: &R1) -> Self + where + R0: IntShapeResource + ?Sized, + R1: IntShapeResource + ?Sized, + { + Self::from_shape_and_string_custom(shape, string, Default::default()) + } + + /// Creates an overlay from closed polygon contours and open strings with custom options. + pub fn from_shape_and_string_custom( + shape: &R0, + string: &R1, + options: IntOverlayOptions, + ) -> Self + where + R0: IntShapeResource + ?Sized, + R1: IntShapeResource + ?Sized, + { + let capacity = shape + .iter_paths() + .chain(string.iter_paths()) + .map(|path| path.len()) + .sum(); + let mut overlay = Self::with_options(capacity, options); + overlay.add_shape_source(shape); + overlay.add_string_source(string); + overlay + } + + /// Adds resource paths as closed polygon contours. + pub fn add_shape_source + ?Sized>(&mut self, resource: &R) { + for contour in resource.iter_paths() { + self.add_shape_contour(contour); + } + } + + /// Adds resource paths as open strings, without a closing edge. + pub fn add_string_source + ?Sized>(&mut self, resource: &R) { + for path in resource.iter_paths() { + self.add_string_path(path); + } + } + + /// Adds resource paths as closed strings, including the last-to-first edge. + pub fn add_string_contour_source + ?Sized>(&mut self, resource: &R) { + for contour in resource.iter_paths() { + self.add_string_contour(contour); + } + } + /// Creates a new `StringOverlay` instance and initializes it with a single shape contour. /// - `contour`: An array of points that form a closed path. #[inline] + #[deprecated(note = "Use `from_shape` instead.")] pub fn with_shape_contour(contour: &[IntPoint]) -> Self { - let mut overlay = Self::new(contour.len()); - overlay.add_shape_contour(contour); - overlay + Self::from_shape(contour) } /// Creates a new `StringOverlay` instance and initializes it with multiple shape contours. /// - `contours`: An array of `IntContour` instances to be added to the overlay. #[inline] + #[deprecated(note = "Use `from_shape` instead.")] pub fn with_shape_contours(contours: &[IntContour]) -> Self { - let mut overlay = Self::new(contours.points_count()); - overlay.add_shape_contours(contours); - overlay + Self::from_shape(contours) } /// Creates a new `StringOverlay` instance and initializes it with s shape. /// - `shape`: An `IntShape` instances to be added to the overlay. #[inline] + #[deprecated(note = "Use `from_shape` instead.")] pub fn with_shape(shape: &[IntContour]) -> Self { - let mut overlay = Self::new(shape.points_count()); - overlay.add_shape_contours(shape); - overlay + Self::from_shape(shape) } /// Creates a new `StringOverlay` instance and initializes it with subject and clip shapes. /// - `shapes`: An array of `IntShape` instances to be added to the overlay. #[inline] + #[deprecated(note = "Use `from_shape` instead.")] pub fn with_shapes(shapes: &[IntShape]) -> Self { - let mut overlay = Self::new(shapes.points_count()); - overlay.add_shapes(shapes); - overlay + Self::from_shape(shapes) } /// Adds a path to the overlay using an iterator, allowing for more flexible path input. @@ -115,6 +179,7 @@ where /// Adds multiple paths to the overlay as shape paths. /// - `contours`: An array of `IntContour` instances to be added to the overlay. + #[deprecated(note = "Use `add_shape_source` instead.")] pub fn add_shape_contours(&mut self, contours: &[IntContour]) { for contour in contours.iter() { self.add_shape_contour(contour); @@ -124,9 +189,10 @@ where /// Adds a list of shape to the overlay. /// - `shapes`: An array of `IntShape` instances to be added to the overlay. #[inline] + #[deprecated(note = "Use `add_shape_source` instead.")] pub fn add_shapes(&mut self, shapes: &[IntShape]) { for shape in shapes { - self.add_shape_contours(shape); + self.add_shape_source(shape); } } @@ -207,6 +273,7 @@ where /// Adds a string line paths to the overlay. /// - `paths`: A collection of paths, each representing a string line. #[inline] + #[deprecated(note = "Use `add_string_source` instead.")] pub fn add_string_paths(&mut self, paths: &[IntPath]) { for path in paths { self.add_string_path(path); @@ -216,6 +283,7 @@ where /// Adds a string line contours to the overlay. /// - `contours`: A collection of contours, each representing a string line closed path. #[inline] + #[deprecated(note = "Use `add_string_contour_source` instead.")] pub fn add_string_contours(&mut self, contours: &[IntContour]) { for contour in contours { self.add_string_contour(contour); diff --git a/iOverlay/src/string/slice.rs b/iOverlay/src/string/slice.rs index a2f1559d..1e882625 100644 --- a/iOverlay/src/string/slice.rs +++ b/iOverlay/src/string/slice.rs @@ -4,114 +4,41 @@ use crate::string::line::IntLine; use crate::string::overlay::StringOverlay; use crate::string::rule::StringRule; use i_float::int::number::int::IntNumber; -use i_float::int::point::IntPoint; use i_shape::int::path::IntPath; -use i_shape::int::shape::{IntShape, IntShapes}; +use i_shape::int::shape::IntShapes; +use i_shape::source::int::resource::IntShapeResource; pub trait IntSlice { fn slice_by_line(&self, line: IntLine, fill_rule: FillRule) -> IntShapes; fn slice_by_lines(&self, lines: &[IntLine], fill_rule: FillRule) -> IntShapes; fn slice_by_path(&self, path: &IntPath, fill_rule: FillRule) -> IntShapes; fn slice_by_paths(&self, paths: &[IntPath], fill_rule: FillRule) -> IntShapes; -} - -impl IntSlice for IntShapes -where - I: OverlayInt, -{ - #[inline] - fn slice_by_line(&self, line: IntLine, fill_rule: FillRule) -> IntShapes { - let mut overlay = StringOverlay::with_shapes(self); - overlay.add_string_line(line); - overlay - .build_graph_view(fill_rule) - .map(|graph| graph.extract_shapes(StringRule::Slice)) - .unwrap_or_default() - } - - #[inline] - fn slice_by_lines(&self, lines: &[IntLine], fill_rule: FillRule) -> IntShapes { - let mut overlay = StringOverlay::with_shapes(self); - overlay.add_string_lines(lines); - overlay - .build_graph_view(fill_rule) - .map(|graph| graph.extract_shapes(StringRule::Slice)) - .unwrap_or_default() - } - - #[inline] - fn slice_by_path(&self, path: &IntPath, fill_rule: FillRule) -> IntShapes { - let mut overlay = StringOverlay::with_shapes(self); - overlay.add_string_path(path); - overlay - .build_graph_view(fill_rule) - .map(|graph| graph.extract_shapes(StringRule::Slice)) - .unwrap_or_default() - } - - #[inline] - fn slice_by_paths(&self, paths: &[IntPath], fill_rule: FillRule) -> IntShapes { - let mut overlay = StringOverlay::with_shapes(self); - overlay.add_string_paths(paths); - overlay - .build_graph_view(fill_rule) - .map(|graph| graph.extract_shapes(StringRule::Slice)) - .unwrap_or_default() - } -} - -impl IntSlice for IntShape -where - I: OverlayInt, -{ - #[inline] - fn slice_by_line(&self, line: IntLine, fill_rule: FillRule) -> IntShapes { - let mut overlay = StringOverlay::with_shape(self); - overlay.add_string_line(line); - overlay - .build_graph_view(fill_rule) - .map(|graph| graph.extract_shapes(StringRule::Slice)) - .unwrap_or_default() - } - - #[inline] - fn slice_by_lines(&self, lines: &[IntLine], fill_rule: FillRule) -> IntShapes { - let mut overlay = StringOverlay::with_shape(self); - overlay.add_string_lines(lines); - overlay - .build_graph_view(fill_rule) - .map(|graph| graph.extract_shapes(StringRule::Slice)) - .unwrap_or_default() - } - #[inline] - fn slice_by_path(&self, path: &IntPath, fill_rule: FillRule) -> IntShapes { - let mut overlay = StringOverlay::with_shape(self); - overlay.add_string_path(path); - overlay - .build_graph_view(fill_rule) - .map(|graph| graph.extract_shapes(StringRule::Slice)) - .unwrap_or_default() - } - - #[inline] - fn slice_by_paths(&self, paths: &[IntPath], fill_rule: FillRule) -> IntShapes { - let mut overlay = StringOverlay::with_shape(self); - overlay.add_string_paths(paths); - overlay + /// Slices this polygon resource by open paths from another resource. + fn slice_by_source + ?Sized>( + &self, + source: &R, + fill_rule: FillRule, + ) -> IntShapes + where + Self: IntShapeResource, + I: OverlayInt, + { + StringOverlay::from_shape_and_string(self, source) .build_graph_view(fill_rule) .map(|graph| graph.extract_shapes(StringRule::Slice)) .unwrap_or_default() } } -impl IntSlice for [IntPoint] +impl IntSlice for R where I: OverlayInt, + R: IntShapeResource + ?Sized, { #[inline] fn slice_by_line(&self, line: IntLine, fill_rule: FillRule) -> IntShapes { - let mut overlay = StringOverlay::with_shape_contour(self); + let mut overlay = StringOverlay::from_shape(self); overlay.add_string_line(line); overlay .build_graph_view(fill_rule) @@ -121,7 +48,7 @@ where #[inline] fn slice_by_lines(&self, lines: &[IntLine], fill_rule: FillRule) -> IntShapes { - let mut overlay = StringOverlay::with_shape_contour(self); + let mut overlay = StringOverlay::from_shape(self); overlay.add_string_lines(lines); overlay .build_graph_view(fill_rule) @@ -131,7 +58,7 @@ where #[inline] fn slice_by_path(&self, path: &IntPath, fill_rule: FillRule) -> IntShapes { - let mut overlay = StringOverlay::with_shape_contour(self); + let mut overlay = StringOverlay::from_shape(self); overlay.add_string_path(path); overlay .build_graph_view(fill_rule) @@ -141,8 +68,8 @@ where #[inline] fn slice_by_paths(&self, paths: &[IntPath], fill_rule: FillRule) -> IntShapes { - let mut overlay = StringOverlay::with_shape_contour(self); - overlay.add_string_paths(paths); + let mut overlay = StringOverlay::from_shape(self); + overlay.add_string_source(paths); overlay .build_graph_view(fill_rule) .map(|graph| graph.extract_shapes(StringRule::Slice)) @@ -161,11 +88,8 @@ mod tests { #[test] fn test_empty_input() { - #[rustfmt::skip] - let shapes = [].slice_by_line( - [IntPoint::new(0, 0), IntPoint::new(0, 0)], - FillRule::NonZero, - ); + let empty: &[IntPoint] = &[]; + let shapes = empty.slice_by_line([IntPoint::new(0, 0), IntPoint::new(0, 0)], FillRule::NonZero); assert_eq!(shapes.len(), 0); } diff --git a/iOverlay/src/string/split.rs b/iOverlay/src/string/split.rs index a1dcee3f..2da28dff 100644 --- a/iOverlay/src/string/split.rs +++ b/iOverlay/src/string/split.rs @@ -3,7 +3,7 @@ use i_float::int::number::int::IntNumber; use i_float::int::number::uint::UIntNumber; use i_float::int::number::wide_int::WideIntNumber; use i_float::int::point::IntPoint; -use i_shape::int::path::ContourExtension; +use i_shape::int::area::UnsafeArea; use i_shape::int::shape::IntContour; pub(super) trait Split { @@ -170,7 +170,7 @@ impl ValidateArea for IntContour { if min_area == I::WideUInt::ZERO { return true; } - let abs_area = self.unsafe_area().unsigned_abs() >> 1; + let abs_area = self.iter().copied().unsafe_area().unsigned_abs() >> 1; abs_area >= min_area } } diff --git a/iOverlay/src/vector/extract.rs b/iOverlay/src/vector/extract.rs index 3f632175..3d1c69cd 100644 --- a/iOverlay/src/vector/extract.rs +++ b/iOverlay/src/vector/extract.rs @@ -402,7 +402,7 @@ mod tests { ]; let mut buffer = Default::default(); - let mut overlay = Overlay::with_contours(&subj, &[]); + let mut overlay = Overlay::from_subj(&subj); overlay.options = IntOverlayOptions::keep_all_points(); let shapes = overlay .build_graph_view(FillRule::NonZero) @@ -411,7 +411,7 @@ mod tests { debug_assert!(shapes[0][0].len() == 6); - let mut overlay = Overlay::with_contours(&subj, &[]); + let mut overlay = Overlay::from_subj(&subj); overlay.options = IntOverlayOptions::default(); let shapes = overlay .build_graph_view(FillRule::NonZero) @@ -429,7 +429,7 @@ mod tests { ]; let mut buffer = Default::default(); - let mut overlay = Overlay::with_contours(&subj, &[]); + let mut overlay = Overlay::from_subj(&subj); overlay.options = IntOverlayOptions::default(); let shapes = overlay .build_graph_view(FillRule::NonZero) @@ -448,7 +448,7 @@ mod tests { ]; let mut buffer = Default::default(); - let mut overlay = Overlay::with_contours(&subj, &[]); + let mut overlay = Overlay::from_subj(&subj); let shapes_0 = overlay .build_graph_view(FillRule::NonZero) @@ -475,7 +475,7 @@ mod tests { [[1, 3], [1, 4], [2, 4], [2, 3]] ]; let mut buffer = Default::default(); - let mut overlay = Overlay::with_contours(&subj, &[]); + let mut overlay = Overlay::from_subj(&subj); overlay.options = IntOverlayOptions::default(); let shapes = overlay .build_graph_view(FillRule::NonZero) @@ -493,7 +493,7 @@ mod tests { [[0, 0], [3, 0], [3, -3], [2, -3], [2, 0], [-1, 0], [-1, 3], [-2, 3], [-2, 2], [0, 2], [0, 1], [-3, 1], [-3, 4], [0, 4]], ]; let mut buffer = Default::default(); - let mut overlay = Overlay::with_contours(&subj, &[]); + let mut overlay = Overlay::from_subj(&subj); overlay.options = IntOverlayOptions::default(); let shapes = overlay .build_graph_view(FillRule::NonZero) @@ -516,7 +516,7 @@ mod tests { [[7, 1], [11, 1], [11, 5], [7, 5]], ]; let mut buffer = Default::default(); - let mut overlay = Overlay::with_contours(&subj, &[]); + let mut overlay = Overlay::from_subj(&subj); overlay.options = IntOverlayOptions::default(); let shapes = overlay .build_graph_view(FillRule::NonZero) @@ -535,7 +535,7 @@ mod tests { ]; let mut buffer = Default::default(); - let mut overlay = Overlay::with_contours(&subj, &[]); + let mut overlay = Overlay::from_subj(&subj); overlay.options = IntOverlayOptions { preserve_input_collinear: false, output_direction: ContourDirection::CounterClockwise, diff --git a/iOverlay/src/vector/simplify.rs b/iOverlay/src/vector/simplify.rs index 4e47ab8b..15b1f720 100644 --- a/iOverlay/src/vector/simplify.rs +++ b/iOverlay/src/vector/simplify.rs @@ -4,7 +4,6 @@ use alloc::vec; use alloc::vec::Vec; use i_float::int::number::int::IntNumber; use i_float::int::number::wide_int::WideIntNumber; -use i_float::int::point::IntPoint; use i_float::int::vector::IntVector; /// Simplifies vector contours by removing collinear points when possible. @@ -116,7 +115,10 @@ impl VectorSimpleContour for [DataVectorEdge VectorSimpleContour for [DataVectorEdge VectorSimpleContour for [DataVectorEdge(edge: &DataVectorEdge) -> IntVector { mod tests { use crate::core::edge_data::{EdgeDataMerge, OverlayEdgeData}; use crate::vector::edge::DataVectorEdge; - use crate::vector::simplify::{IntPoint, VectorSimplify}; + use crate::vector::simplify::VectorSimplify; use alloc::vec; use i_float::int_pnt; @@ -290,16 +295,32 @@ mod tests { } } + #[test] + fn test_collinear_different_fill() { + let mut contour = vec![ + DataVectorEdge::new(1, int_pnt!(0, 0), int_pnt!(2, 0), ()), + DataVectorEdge::new(5, int_pnt!(2, 0), int_pnt!(4, 0), ()), + DataVectorEdge::new(1, int_pnt!(4, 0), int_pnt!(4, 4), ()), + DataVectorEdge::new(1, int_pnt!(4, 4), int_pnt!(0, 4), ()), + DataVectorEdge::new(1, int_pnt!(0, 4), int_pnt!(0, 0), ()), + ]; + let expected = contour.clone(); + + assert!(!contour.simplify_contour()); + assert_eq!(contour, expected); + } + + // Geometry/data tests below use neutral fill so it does not prevent merging. #[test] fn test_0() { #[rustfmt::skip] let mut contour = vec![ - DataVectorEdge::new(1, int_pnt!(0, -1), int_pnt!(0, -3), ()), - DataVectorEdge::new(2, int_pnt!(0, -3), int_pnt!(1, -3), ()), - DataVectorEdge::new(3, int_pnt!(1, -3), int_pnt!(3, -3), ()), - DataVectorEdge::new(4, int_pnt!(3, -3), int_pnt!(3, 0), ()), - DataVectorEdge::new(5, int_pnt!(3, 0), int_pnt!(0, 0), ()), - DataVectorEdge::new(6, int_pnt!(0, 0), int_pnt!(0, -1), ()), + DataVectorEdge::new(0, int_pnt!(0, -1), int_pnt!(0, -3), ()), + DataVectorEdge::new(0, int_pnt!(0, -3), int_pnt!(1, -3), ()), + DataVectorEdge::new(0, int_pnt!(1, -3), int_pnt!(3, -3), ()), + DataVectorEdge::new(0, int_pnt!(3, -3), int_pnt!(3, 0), ()), + DataVectorEdge::new(0, int_pnt!(3, 0), int_pnt!(0, 0), ()), + DataVectorEdge::new(0, int_pnt!(0, 0), int_pnt!(0, -1), ()), ]; let result = contour.simplify_contour(); @@ -312,16 +333,16 @@ mod tests { fn test_duplicate_points() { #[rustfmt::skip] let mut contour = vec![ - DataVectorEdge::new(1, int_pnt!(-1, 3), int_pnt!(-1, 1), ()), - DataVectorEdge::new(2, int_pnt!(-1, 1), int_pnt!(-1, 1), ()), - DataVectorEdge::new(3, int_pnt!(-1, 1), int_pnt!(-3, 1), ()), - DataVectorEdge::new(4, int_pnt!(-3, 1), int_pnt!(-3, -2), ()), - DataVectorEdge::new(5, int_pnt!(-3, -2), int_pnt!(3, -2), ()), - DataVectorEdge::new(6, int_pnt!(3, -2), int_pnt!(3, 1), ()), - DataVectorEdge::new(7, int_pnt!(3, 1), int_pnt!(3, 1), ()), - DataVectorEdge::new(8, int_pnt!(3, 1), int_pnt!(1, 1), ()), - DataVectorEdge::new(9, int_pnt!(1, 1), int_pnt!(1, 3), ()), - DataVectorEdge::new(10, int_pnt!(1, 3), int_pnt!(-1, 3), ()), + DataVectorEdge::new(0, int_pnt!(-1, 3), int_pnt!(-1, 1), ()), + DataVectorEdge::new(0, int_pnt!(-1, 1), int_pnt!(-1, 1), ()), + DataVectorEdge::new(0, int_pnt!(-1, 1), int_pnt!(-3, 1), ()), + DataVectorEdge::new(0, int_pnt!(-3, 1), int_pnt!(-3, -2), ()), + DataVectorEdge::new(0, int_pnt!(-3, -2), int_pnt!(3, -2), ()), + DataVectorEdge::new(0, int_pnt!(3, -2), int_pnt!(3, 1), ()), + DataVectorEdge::new(0, int_pnt!(3, 1), int_pnt!(3, 1), ()), + DataVectorEdge::new(0, int_pnt!(3, 1), int_pnt!(1, 1), ()), + DataVectorEdge::new(0, int_pnt!(1, 1), int_pnt!(1, 3), ()), + DataVectorEdge::new(0, int_pnt!(1, 3), int_pnt!(-1, 3), ()), ]; let result = contour.simplify_contour(); @@ -334,12 +355,12 @@ mod tests { fn test_tiny_segments() { #[rustfmt::skip] let mut contour = vec![ - DataVectorEdge::new(1, int_pnt!(0, 2), int_pnt!(-1, 1), ()), - DataVectorEdge::new(2, int_pnt!(-1, 1), int_pnt!(-2, 0), ()), - DataVectorEdge::new(3, int_pnt!(-2, 0), int_pnt!(0, -1), ()), - DataVectorEdge::new(4, int_pnt!(0, -1), int_pnt!(2, 0), ()), - DataVectorEdge::new(5, int_pnt!(2, 0), int_pnt!(1, 1), ()), - DataVectorEdge::new(6, int_pnt!(1, 1), int_pnt!(0, 2), ()), + DataVectorEdge::new(0, int_pnt!(0, 2), int_pnt!(-1, 1), ()), + DataVectorEdge::new(0, int_pnt!(-1, 1), int_pnt!(-2, 0), ()), + DataVectorEdge::new(0, int_pnt!(-2, 0), int_pnt!(0, -1), ()), + DataVectorEdge::new(0, int_pnt!(0, -1), int_pnt!(2, 0), ()), + DataVectorEdge::new(0, int_pnt!(2, 0), int_pnt!(1, 1), ()), + DataVectorEdge::new(0, int_pnt!(1, 1), int_pnt!(0, 2), ()), ]; let result = contour.simplify_contour(); @@ -352,14 +373,14 @@ mod tests { fn test_collinear_runs() { #[rustfmt::skip] let mut contour = vec![ - DataVectorEdge::new(1, int_pnt!(-2, -2), int_pnt!(0, -2), ()), - DataVectorEdge::new(2, int_pnt!(0, -2), int_pnt!(2, -2), ()), - DataVectorEdge::new(3, int_pnt!(2, -2), int_pnt!(2, 0), ()), - DataVectorEdge::new(4, int_pnt!(2, 0), int_pnt!(2, 2), ()), - DataVectorEdge::new(5, int_pnt!(2, 2), int_pnt!(0, 2), ()), - DataVectorEdge::new(6, int_pnt!(0, 2), int_pnt!(-2, 2), ()), - DataVectorEdge::new(7, int_pnt!(-2, 2), int_pnt!(-2, 0), ()), - DataVectorEdge::new(8, int_pnt!(-2, 0), int_pnt!(-2, -2), ()), + DataVectorEdge::new(0, int_pnt!(-2, -2), int_pnt!(0, -2), ()), + DataVectorEdge::new(0, int_pnt!(0, -2), int_pnt!(2, -2), ()), + DataVectorEdge::new(0, int_pnt!(2, -2), int_pnt!(2, 0), ()), + DataVectorEdge::new(0, int_pnt!(2, 0), int_pnt!(2, 2), ()), + DataVectorEdge::new(0, int_pnt!(2, 2), int_pnt!(0, 2), ()), + DataVectorEdge::new(0, int_pnt!(0, 2), int_pnt!(-2, 2), ()), + DataVectorEdge::new(0, int_pnt!(-2, 2), int_pnt!(-2, 0), ()), + DataVectorEdge::new(0, int_pnt!(-2, 0), int_pnt!(-2, -2), ()), ]; let result = contour.simplify_contour(); @@ -372,11 +393,11 @@ mod tests { fn test_collinear_same_data() { #[rustfmt::skip] let mut contour = vec![ - DataVectorEdge::new(1, int_pnt!(0, 0), int_pnt!(2, 0), TestData::A), - DataVectorEdge::new(2, int_pnt!(2, 0), int_pnt!(4, 0), TestData::A), - DataVectorEdge::new(3, int_pnt!(4, 0), int_pnt!(4, 4), TestData::A), - DataVectorEdge::new(4, int_pnt!(4, 4), int_pnt!(0, 4), TestData::A), - DataVectorEdge::new(5, int_pnt!(0, 4), int_pnt!(0, 0), TestData::A), + DataVectorEdge::new(0, int_pnt!(0, 0), int_pnt!(2, 0), TestData::A), + DataVectorEdge::new(0, int_pnt!(2, 0), int_pnt!(4, 0), TestData::A), + DataVectorEdge::new(0, int_pnt!(4, 0), int_pnt!(4, 4), TestData::A), + DataVectorEdge::new(0, int_pnt!(4, 4), int_pnt!(0, 4), TestData::A), + DataVectorEdge::new(0, int_pnt!(0, 4), int_pnt!(0, 0), TestData::A), ]; let result = contour.simplify_contour(); @@ -389,11 +410,11 @@ mod tests { fn test_collinear_different_data() { #[rustfmt::skip] let mut contour = vec![ - DataVectorEdge::new(1, int_pnt!(0, 0), int_pnt!(2, 0), TestData::A), - DataVectorEdge::new(2, int_pnt!(2, 0), int_pnt!(4, 0), TestData::B), - DataVectorEdge::new(3, int_pnt!(4, 0), int_pnt!(4, 4), TestData::A), - DataVectorEdge::new(4, int_pnt!(4, 4), int_pnt!(0, 4), TestData::A), - DataVectorEdge::new(5, int_pnt!(0, 4), int_pnt!(0, 0), TestData::A), + DataVectorEdge::new(0, int_pnt!(0, 0), int_pnt!(2, 0), TestData::A), + DataVectorEdge::new(0, int_pnt!(2, 0), int_pnt!(4, 0), TestData::B), + DataVectorEdge::new(0, int_pnt!(4, 0), int_pnt!(4, 4), TestData::A), + DataVectorEdge::new(0, int_pnt!(4, 4), int_pnt!(0, 4), TestData::A), + DataVectorEdge::new(0, int_pnt!(0, 4), int_pnt!(0, 0), TestData::A), ]; let result = contour.simplify_contour(); @@ -406,9 +427,9 @@ mod tests { fn test_zero_area_path() { #[rustfmt::skip] let mut contour = vec![ - DataVectorEdge::new(1, int_pnt!(-3, 0), int_pnt!(0, 0), ()), - DataVectorEdge::new(2, int_pnt!(0, 0), int_pnt!(3, 0), ()), - DataVectorEdge::new(3, int_pnt!(3, 0), int_pnt!(-3, 0), ()), + DataVectorEdge::new(0, int_pnt!(-3, 0), int_pnt!(0, 0), ()), + DataVectorEdge::new(0, int_pnt!(0, 0), int_pnt!(3, 0), ()), + DataVectorEdge::new(0, int_pnt!(3, 0), int_pnt!(-3, 0), ()), ]; let result = contour.simplify_contour(); diff --git a/iOverlay/tests/angle_style_tests.rs b/iOverlay/tests/angle_style_tests.rs new file mode 100644 index 00000000..f377eef1 --- /dev/null +++ b/iOverlay/tests/angle_style_tests.rs @@ -0,0 +1,66 @@ +use i_overlay::mesh::{ + float::{ + outline::offset::OutlineOffset, + stroke::offset::StrokeOffset, + style::{LineCap, LineJoin, OutlineStyle, StrokeStyle}, + variable_stroke::{StrokeVertex, VariableStrokeStyle, offset::VariableStrokeOffset}, + }, + math::MathMode, +}; + +macro_rules! non_finite_style_angles { + ($name:ident, $scalar:ty) => { + #[test] + fn $name() { + let path = [[0.0 as $scalar, 0.0], [10.0, 0.0], [12.0, 8.0]]; + let variable = path.map(|point| StrokeVertex::new(point, 2.0)); + let minimum = (0.01 * core::f64::consts::PI) as $scalar; + for math in [MathMode::Integer, MathMode::Float] { + for (angle, round_control, miter_control) in [ + (<$scalar>::NAN, minimum, minimum), + (<$scalar>::NEG_INFINITY, minimum, minimum), + ( + <$scalar>::INFINITY, + (0.25 * core::f64::consts::PI) as $scalar, + (0.99 * core::f64::consts::PI) as $scalar, + ), + ] { + let stroke = |a| { + path.stroke( + StrokeStyle::new(2.0) + .math(math) + .line_join(LineJoin::Round(a)) + .start_cap(LineCap::Round(a)) + .end_cap(LineCap::Round(a)), + false, + ) + }; + let expected = stroke(round_control); + assert!(!expected.is_empty()); + assert_eq!(stroke(angle), expected); + + for (join, control) in [ + (LineJoin::Miter(angle), LineJoin::Miter(miter_control)), + (LineJoin::Round(angle), LineJoin::Round(round_control)), + ] { + let expected = path.outline(&OutlineStyle::new(1.0).math(math).line_join(control)); + assert!(!expected.is_empty()); + assert_eq!( + path.outline(&OutlineStyle::new(1.0).math(math).line_join(join)), + expected + ); + } + + let stroke = + |a| variable.variable_stroke(VariableStrokeStyle::new().math(math).round_angle(a)); + let expected = stroke(round_control); + assert!(!expected.is_empty()); + assert_eq!(stroke(angle), expected); + } + } + } + }; +} + +non_finite_style_angles!(non_finite_angles_f32, f32); +non_finite_style_angles!(non_finite_angles_f64, f64); diff --git a/iOverlay/tests/board_tests.rs b/iOverlay/tests/board_tests.rs index 7dc851aa..44ec57e2 100644 --- a/iOverlay/tests/board_tests.rs +++ b/iOverlay/tests/board_tests.rs @@ -40,8 +40,8 @@ mod tests { let clip_paths = many_squares(IntPoint::new(15, 15), 20, 30, n - 1); let mut overlay = Overlay::new(8 * n * n); - overlay.add_contours(&subj_paths, ShapeType::Subject); - overlay.add_contours(&clip_paths, ShapeType::Clip); + overlay.add_source(&subj_paths, ShapeType::Subject); + overlay.add_source(&clip_paths, ShapeType::Clip); let graph = overlay.build_graph_view(FillRule::NonZero).unwrap(); let result = graph.extract_shapes(rule, &mut Default::default()); diff --git a/iOverlay/tests/clip_coverage_tests.rs b/iOverlay/tests/clip_coverage_tests.rs new file mode 100644 index 00000000..a5a40988 --- /dev/null +++ b/iOverlay/tests/clip_coverage_tests.rs @@ -0,0 +1,192 @@ +use std::collections::BTreeSet; + +use i_float::int::point::IntPoint; +use i_overlay::core::fill_rule::FillRule; +use i_overlay::string::clip::ClipRule; +use i_overlay::string::overlay::StringOverlay; + +type Edge = (i32, i32, i32, i32); + +fn unit_edges(paths: &[Vec]) -> BTreeSet { + let mut edges = BTreeSet::new(); + for path in paths { + for pair in path.windows(2) { + let [a, b] = [pair[0], pair[1]]; + assert!(a != b && (a.x == b.x || a.y == b.y)); + let (dx, dy) = ((b.x - a.x).signum(), (b.y - a.y).signum()); + let (mut x, mut y) = (a.x, a.y); + while (x, y) != (b.x, b.y) { + assert!(edges.insert((x, y, x + dx, y + dy)), "duplicate directed edge"); + x += dx; + y += dy; + } + } + } + edges +} + +#[test] +fn grid_clipping_preserves_directed_coverage() { + let subject = [ + IntPoint::new(0, 0), + IntPoint::new(10, 0), + IntPoint::new(10, 10), + IntPoint::new(0, 10), + ]; + let mut seed = 0x7189_d91a_c29b_13f5_u64; + let mut next = || { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + ((seed >> 32) % 15) as i32 - 2 + }; + for case in 0..1000 { + let mut lines = Vec::new(); + let mut input = BTreeSet::new(); + for index in 0..40 { + let (x, y, t) = (next(), next(), next()); + let a = IntPoint::new(x, y); + let b = if index % 2 == 0 { + IntPoint::new(t, y) + } else { + IntPoint::new(x, t) + }; + lines.push([a, b]); + if a != b { + input.extend(unit_edges(&[vec![a, b]])); + } + } + for boundary_included in [false, true] { + for invert in [false, true] { + // Doubled midpoints classify unit edges without floating point + // or dependence on the clipping implementation. + let expected: BTreeSet<_> = input + .iter() + .copied() + .filter(|&(ax, ay, bx, by)| { + let (x, y) = (ax + bx, ay + by); + let inside = if boundary_included { + (0..=20).contains(&x) && (0..=20).contains(&y) + } else { + (1..20).contains(&x) && (1..20).contains(&y) + }; + inside != invert + }) + .collect(); + let mut overlay = StringOverlay::from_shape(&subject); + overlay.add_string_lines(&lines); + let result = overlay.clip_string_lines( + FillRule::NonZero, + ClipRule { + invert, + boundary_included, + }, + ); + assert_eq!( + unit_edges(&result), + expected, + "case={case}, invert={invert}, boundary={boundary_included}, lines={lines:?}, result={result:?}" + ); + } + } + } +} + +#[test] +fn clipping_overlapping_contours_uses_the_resolved_boundary() { + let mut seed = 0x173b_d902_aa9f_4ee1_u64; + let mut next = || { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + (seed >> 32) as u32 + }; + let mut lines = Vec::new(); + for k in -1..=9 { + for line in [ + [IntPoint::new(-1, k), IntPoint::new(9, k)], + [IntPoint::new(k, -1), IntPoint::new(k, 9)], + ] { + lines.push(line); + lines.push([line[1], line[0]]); + } + } + let input = unit_edges(&lines.iter().map(|p| p.to_vec()).collect::>()); + for case in 0..300 { + let mut contours = Vec::new(); + let mut winding = [[0_i32; 8]; 8]; + for _ in 0..1 + case % 8 { + let (x0, y0) = ((next() % 8) as usize, (next() % 8) as usize); + let (x1, y1) = ( + x0 + 1 + next() as usize % (8 - x0), + y0 + 1 + next() as usize % (8 - y0), + ); + let sign = if next() % 2 == 0 { 1 } else { -1 }; + for column in &mut winding[x0..x1] { + for count in &mut column[y0..y1] { + *count += sign; + } + } + let mut contour = vec![ + IntPoint::new(x0 as i32, y0 as i32), + IntPoint::new(x1 as i32, y0 as i32), + IntPoint::new(x1 as i32, y1 as i32), + IntPoint::new(x0 as i32, y1 as i32), + ]; + if sign < 0 { + contour.reverse(); + } + contours.push(contour); + } + for fill in [ + FillRule::EvenOdd, + FillRule::NonZero, + FillRule::Positive, + FillRule::Negative, + ] { + let filled = |x: i32, y: i32| { + let count = if (0..8).contains(&x) && (0..8).contains(&y) { + winding[x as usize][y as usize] + } else { + 0 + }; + match fill { + FillRule::EvenOdd => count % 2 != 0, + FillRule::NonZero => count != 0, + FillRule::Positive => count > 0, + FillRule::Negative => count < 0, + } + }; + for boundary_included in [false, true] { + for invert in [false, true] { + let expected: BTreeSet<_> = input + .iter() + .copied() + .filter(|&(ax, ay, bx, by)| { + let (x, y) = (ax.min(bx), ay.min(by)); + let (a, b) = if ay == by { + (filled(x, y), filled(x, y - 1)) + } else { + (filled(x, y), filled(x - 1, y)) + }; + // A boundary separates a filled cell from an empty one. + // An input edge with filled cells on both sides is interior. + let inside = if boundary_included { a || b } else { a && b }; + inside != invert + }) + .collect(); + let mut overlay = StringOverlay::from_shape(&contours); + overlay.add_string_lines(&lines); + let actual = overlay.clip_string_lines( + fill, + ClipRule { + invert, + boundary_included, + }, + ); + assert_eq!( + unit_edges(&actual), + expected, + "case={case}, fill={fill:?}, invert={invert}, boundary={boundary_included}, contours={contours:?}" + ); + } + } + } + } +} diff --git a/iOverlay/tests/clip_oblique_tests.rs b/iOverlay/tests/clip_oblique_tests.rs new file mode 100644 index 00000000..e6805cc5 --- /dev/null +++ b/iOverlay/tests/clip_oblique_tests.rs @@ -0,0 +1,116 @@ +use i_float::int::point::IntPoint; +use i_overlay::core::fill_rule::FillRule; +use i_overlay::core::solver::Solver; +use i_overlay::string::clip::ClipRule; +use i_overlay::string::overlay::StringOverlay; +use std::collections::BTreeSet; + +type Edge = (i64, i64, i64, i64); +fn edges(paths: &[Vec>], origin: i64) -> BTreeSet { + let mut result = BTreeSet::new(); + let inverse = |p: IntPoint| { + let (x, y) = (p.x - origin, p.y + origin); + assert_eq!( + (3 * x + 4 * y) % 25, + 0, + "off-lattice output {p:?}, origin={origin}" + ); + assert_eq!( + (-4 * x + 3 * y) % 25, + 0, + "off-lattice output {p:?}, origin={origin}" + ); + ((3 * x + 4 * y) / 25, (-4 * x + 3 * y) / 25) + }; + for path in paths { + for pair in path.windows(2) { + let (mut a, b) = (inverse(pair[0]), inverse(pair[1])); + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + assert!(dx != 0 || dy != 0); + assert!(dx == 0 || dy == 0 || dx == dy, "unexpected segment {a:?}..{b:?}"); + let step = (dx.signum(), dy.signum()); + while a != b { + let end = (a.0 + step.0, a.1 + step.1); + assert!( + result.insert((a.0, a.1, end.0, end.1)), + "duplicated directed segment" + ); + a = end; + } + } + } + result +} + +#[test] +fn oblique_clipping_preserves_exact_coverage_at_large_translations() { + let mut seed = 0x6817_f121_29ae_0912_u64; + let mut next = || { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + ((seed >> 32) % 17) as i64 - 3 + }; + for case in 0..200 { + let mut raw = Vec::new(); + for i in 0..40 { + let (x, y, t) = (next(), next(), next()); + let a = (x, y); + let b = match i % 3 { + 0 => (t, y), + 1 => (x, t), + _ => (x + t, y + t), + }; + raw.push((a, b)); + } + for origin in [0, 1_000_000_000, 1_i64 << 60, -(1_i64 << 60)] { + let transform = + |(x, y): (i64, i64)| IntPoint::new(origin + 3 * x - 4 * y, -origin + 4 * x + 3 * y); + let contour: Vec<_> = [(0, 0), (10, 0), (10, 10), (0, 10)] + .into_iter() + .map(transform) + .collect(); + let lines: Vec<_> = raw.iter().map(|&(a, b)| [transform(a), transform(b)]).collect(); + let input: Vec<_> = lines + .iter() + .filter(|l| l[0] != l[1]) + .map(|l| l.to_vec()) + .collect(); + // Coincident input edges represent one directed edge in the string overlay. + let mut all = BTreeSet::new(); + for path in input { + all.extend(edges(&[path], origin)); + } + for boundary_included in [false, true] { + for invert in [false, true] { + let expected: BTreeSet<_> = all + .iter() + .copied() + .filter(|&(ax, ay, bx, by)| { + let (x, y) = (ax + bx, ay + by); + let inside = if boundary_included { + (0..=20).contains(&x) && (0..=20).contains(&y) + } else { + (1..20).contains(&x) && (1..20).contains(&y) + }; + inside != invert + }) + .collect(); + let mut overlay = StringOverlay::from_shape(&contour); + overlay.add_string_lines(&lines); + let result = overlay.clip_string_lines_with_solver( + FillRule::NonZero, + ClipRule { + invert, + boundary_included, + }, + [Solver::LIST, Solver::TREE, Solver::FRAG][case % 3], + ); + assert_eq!( + edges(&result, origin), + expected, + "case={case}, origin={origin}, invert={invert}, boundary={boundary_included}" + ); + } + } + } + } +} diff --git a/iOverlay/tests/crash_tests.rs b/iOverlay/tests/crash_tests.rs index 03304894..f9f3197e 100644 --- a/iOverlay/tests/crash_tests.rs +++ b/iOverlay/tests/crash_tests.rs @@ -42,7 +42,7 @@ mod tests { }; let mut overlay = Overlay::new_custom(4, Default::default(), solver); - overlay.add_contours(&subj, ShapeType::Subject); + overlay.add_source(&subj, ShapeType::Subject); if let Some(graph) = overlay.build_graph_view(FillRule::NonZero) { graph.validate(); let result = graph.extract_shapes(OverlayRule::Subject, &mut Default::default()); @@ -53,10 +53,10 @@ mod tests { #[test] fn test_01() { let subj = [ - [-117.04171489206965, 1820.3621519926919], - [4619.6817058891429, -2133.11539650432], - [1902.5599837294722, -133.53167784432389], - [-3572.1275050425684, 3909.4677532724309], + [-117.04171489206965, 1_820.362_151_992_692], + [4_619.681_705_889_143, -2133.11539650432], + [1902.5599837294722, -133.531_677_844_323_9], + [-3572.1275050425684, 3_909.467_753_272_431], [3047.0491344383845, -4087.6336157702817], ]; @@ -91,7 +91,7 @@ mod tests { ]; for &solver in SOLVERS.iter() { let mut overlay = Overlay::new_custom(4, Default::default(), solver); - overlay.add_contours(&subj_paths, ShapeType::Subject); + overlay.add_source(&subj_paths, ShapeType::Subject); if let Some(graph) = overlay.build_graph_view(FillRule::NonZero) { graph.validate(); let result = graph.extract_shapes(OverlayRule::Subject, &mut Default::default()); diff --git a/iOverlay/tests/critical_hypotheses_tests.rs b/iOverlay/tests/critical_hypotheses_tests.rs new file mode 100644 index 00000000..07e0472e --- /dev/null +++ b/iOverlay/tests/critical_hypotheses_tests.rs @@ -0,0 +1,214 @@ +use i_float::int::point::IntPoint; +use i_overlay::core::fill_rule::FillRule; +use i_overlay::core::integer::OverlayInt; +use i_overlay::core::overlay::Overlay; +use i_overlay::core::overlay::ShapeType; +use i_overlay::core::overlay_rule::OverlayRule; +use i_overlay::core::relate::PredicateOverlay; +use i_overlay::core::solver::Solver; +use i_overlay::mesh::float::stroke::offset::StrokeOffset; +use i_overlay::mesh::float::style::StrokeStyle; + +fn rectangle(x0: i32, y0: i32, x1: i32, y1: i32) -> Vec { + vec![ + IntPoint::new(x0, y0), + IntPoint::new(x1, y0), + IntPoint::new(x1, y1), + IntPoint::new(x0, y1), + ] +} + +#[test] +fn hypothesis_closed_stroke_of_empty_path_returns_empty() { + let path: Vec<[f64; 2]> = Vec::new(); + assert!(path.stroke(StrokeStyle::new(1.0), true).is_empty()); +} + +#[test] +fn hypothesis_closed_stroke_of_empty_contours_returns_empty() { + let paths: Vec> = vec![Vec::new(), Vec::new()]; + assert!(paths.stroke(StrokeStyle::new(1.0), true).is_empty()); +} + +#[test] +fn hypothesis_fixed_scale_closed_stroke_of_empty_path_returns_empty() { + let path: Vec<[f64; 2]> = Vec::new(); + assert!( + path.stroke_fixed_scale(StrokeStyle::new(1.0), true, 100.0) + .unwrap() + .is_empty() + ); +} + +#[test] +fn hypothesis_closed_stroke_into_of_empty_path_returns_empty() { + let path: Vec<[f64; 2]> = Vec::new(); + let mut output = Default::default(); + path.stroke_into(StrokeStyle::new(1.0), true, &mut output); + assert!(output.points.is_empty()); + assert!(output.ranges.is_empty()); +} + +#[test] +fn empty_stroke_controls() { + let path: Vec<[f64; 2]> = Vec::new(); + assert!(path.stroke(StrokeStyle::new(1.0), false).is_empty()); + let paths: Vec> = Vec::new(); + assert!(paths.stroke(StrokeStyle::new(1.0), true).is_empty()); + let point = [[0.0, 0.0]]; + assert!(point.stroke(StrokeStyle::new(1.0), true).is_empty()); +} + +fn normalized_overlay + Into>( + subj: &[[i32; 2]], + clip: &[[i32; 2]], + rule: OverlayRule, + solver: Solver, +) -> Vec>> { + let convert = |path: &[[i32; 2]]| -> Vec> { + path.iter() + .map(|p| IntPoint::new(I::try_from(p[0]).ok().unwrap(), I::try_from(p[1]).ok().unwrap())) + .collect() + }; + let output = + Overlay::from_subj_and_clip_custom(&convert(subj), &convert(clip), Default::default(), solver) + .overlay(rule, FillRule::EvenOdd); + let mut output: Vec>> = output + .into_iter() + .map(|shape| { + let mut shape: Vec> = shape + .into_iter() + .map(|path| { + let mut path: Vec<_> = path.into_iter().map(|p| [p.x.into(), p.y.into()]).collect(); + let start = path.iter().enumerate().min_by_key(|(_, p)| **p).unwrap().0; + path.rotate_left(start); + path + }) + .collect(); + shape[1..].sort(); + shape + }) + .collect(); + output.sort(); + output +} + +#[test] +fn hypothesis_integer_engines_agree_near_i16_limits() { + let mut state = 0x47a3_9012_771b_81d1_u64; + let mut next = || { + state = state.wrapping_mul(6364136223846793005).wrapping_add(1); + ((state >> 32) % 32768) as i32 - 16384 + }; + for case in 0..2000 { + let subj: Vec<_> = (0..3 + case % 8).map(|_| [next(), next()]).collect(); + let clip: Vec<_> = (0..3 + case % 7).map(|_| [next(), next()]).collect(); + for rule in [ + OverlayRule::Intersect, + OverlayRule::Union, + OverlayRule::Difference, + OverlayRule::Xor, + ] { + let expected = normalized_overlay::(&subj, &clip, rule, Solver::LIST); + for solver in [Solver::LIST, Solver::TREE, Solver::FRAG] { + assert_eq!( + normalized_overlay::(&subj, &clip, rule, solver), + expected, + "i16 case={case}, rule={rule:?}, solver={:?}, subj={subj:?}, clip={clip:?}", + solver.strategy + ); + assert_eq!( + normalized_overlay::(&subj, &clip, rule, solver), + expected, + "i32 case={case}, rule={rule:?}, solver={:?}, subj={subj:?}, clip={clip:?}", + solver.strategy + ); + } + } + } +} + +#[test] +fn hypothesis_predicates_remain_correct_after_early_exit_and_clear() { + for solver in [Solver::LIST, Solver::TREE, Solver::FRAG] { + let mut overlay = PredicateOverlay::new(8); + overlay.solver = solver; + for x0 in -2..=2 { + for y0 in -2..=2 { + for x1 in x0 + 1..=3 { + for y1 in y0 + 1..=3 { + overlay.clear(); + overlay.add_contour(&rectangle(0, 0, 2, 2), ShapeType::Subject); + overlay.add_contour(&rectangle(x0, y0, x1, y1), ShapeType::Clip); + let intersects = x0 <= 2 && y0 <= 2 && x1 >= 0 && y1 >= 0; + let interiors = x0 < 2 && y0 < 2 && x1 > 0 && y1 > 0; + let within = x0 <= 0 && y0 <= 0 && x1 >= 2 && y1 >= 2; + for repeat in 0..2 { + let actual = ( + overlay.intersects(), + overlay.interiors_intersect(), + overlay.touches(), + overlay.within(), + ); + assert_eq!( + actual, + (intersects, interiors, intersects && !interiors, within), + "clip=({x0},{y0})..({x1},{y1}), solver={:?}, repeat={repeat}", + solver.strategy, + ); + } + } + } + } + } + } +} + +#[test] +fn hypothesis_predicates_agree_with_boolean_results_on_dense_inputs() { + let mut state = 0xb641_7890_a113_3145_u64; + let mut next = || { + state = state.wrapping_mul(6364136223846793005).wrapping_add(1); + ((state >> 32) % 17) as i32 - 8 + }; + for case in 0..5000 { + let subj: Vec<_> = (0..3 + case % 8).map(|_| IntPoint::new(next(), next())).collect(); + let clip: Vec<_> = (0..3 + case % 7).map(|_| IntPoint::new(next(), next())).collect(); + for fill_rule in [ + FillRule::EvenOdd, + FillRule::NonZero, + FillRule::Positive, + FillRule::Negative, + ] { + let mut overlay = Overlay::from_subj_and_clip(&subj, &clip); + let intersection = overlay.overlay(OverlayRule::Intersect, fill_rule); + let difference = overlay.overlay(OverlayRule::Difference, fill_rule); + let subject = overlay.overlay(OverlayRule::Subject, fill_rule); + let mut predicates = PredicateOverlay::new(subj.len() + clip.len()); + predicates.fill_rule = fill_rule; + predicates.add_contour(&subj, ShapeType::Subject); + predicates.add_contour(&clip, ShapeType::Clip); + assert_eq!( + predicates.interiors_intersect(), + !intersection.is_empty(), + "interiors: case={case}, fill={fill_rule:?}, subj={subj:?}, clip={clip:?}" + ); + assert_eq!( + predicates.within(), + !subject.is_empty() && difference.is_empty(), + "within: case={case}, fill={fill_rule:?}, subj={subj:?}, clip={clip:?}" + ); + if !intersection.is_empty() { + assert!( + predicates.intersects(), + "intersects: case={case}, fill={fill_rule:?}" + ); + assert!(!predicates.touches(), "touches: case={case}, fill={fill_rule:?}"); + assert!( + !predicates.point_intersects(), + "point: case={case}, fill={fill_rule:?}" + ); + } + } + } +} diff --git a/iOverlay/tests/direction_tests.rs b/iOverlay/tests/direction_tests.rs index 0c9cda4e..a864f2b6 100644 --- a/iOverlay/tests/direction_tests.rs +++ b/iOverlay/tests/direction_tests.rs @@ -101,7 +101,7 @@ mod tests { ]; // test default behavior - let r = Overlay::with_contours(&path, &[]).overlay(OverlayRule::Subject, FillRule::NonZero); + let r = Overlay::from_subj(&path).overlay(OverlayRule::Subject, FillRule::NonZero); assert!(r[0][0].area_two() > 0i64); assert!(r[0][1].area_two() < 0i64); } diff --git a/iOverlay/tests/dynamic_tests.rs b/iOverlay/tests/dynamic_tests.rs index 64e458fc..6dcf6e46 100644 --- a/iOverlay/tests/dynamic_tests.rs +++ b/iOverlay/tests/dynamic_tests.rs @@ -38,7 +38,7 @@ mod tests { let subj = create_star::(1.0, r, 7, a, scale); if let Some(graph) = - Overlay::with_contours_custom(&subj, &clip, Default::default(), solver) + Overlay::from_subj_and_clip_custom(&subj, &clip, Default::default(), solver) .build_graph_view(FillRule::NonZero) { graph.validate(); @@ -66,8 +66,9 @@ mod tests { let mut a = 0.0; while a < 4.0 * PI { let subj = create_star::(200.0, 30.0, 7, a, scale); - if let Some(graph) = Overlay::with_contours_custom(&subj, &clip, Default::default(), solver) - .build_graph_view(FillRule::NonZero) + if let Some(graph) = + Overlay::from_subj_and_clip_custom(&subj, &clip, Default::default(), solver) + .build_graph_view(FillRule::NonZero) { graph.validate(); let _ = graph.extract_shapes(OverlayRule::Xor, &mut Default::default()); @@ -92,8 +93,9 @@ mod tests { while a < 2.0 * PI { let subj = create_star::(202.5, 33.75, 24, a, scale); - if let Some(graph) = Overlay::with_contours_custom(&subj, &clip, Default::default(), solver) - .build_graph_view(FillRule::NonZero) + if let Some(graph) = + Overlay::from_subj_and_clip_custom(&subj, &clip, Default::default(), solver) + .build_graph_view(FillRule::NonZero) { graph.validate(); let _ = graph.extract_shapes(OverlayRule::Xor, &mut Default::default()); @@ -118,8 +120,9 @@ mod tests { while a < 4.0 * PI { let subj = create_star::(100.0, 10.0, 17, a, scale); - if let Some(graph) = Overlay::with_contours_custom(&subj, &clip, Default::default(), solver) - .build_graph_view(FillRule::NonZero) + if let Some(graph) = + Overlay::from_subj_and_clip_custom(&subj, &clip, Default::default(), solver) + .build_graph_view(FillRule::NonZero) { graph.validate(); let _ = graph.extract_shapes(OverlayRule::Xor, &mut Default::default()); @@ -144,8 +147,9 @@ mod tests { while a < 0.000_001 { let subj = create_star::(202.5, 33.75, 24, a, scale); - if let Some(graph) = Overlay::with_contours_custom(&subj, &clip, Default::default(), solver) - .build_graph_view(FillRule::NonZero) + if let Some(graph) = + Overlay::from_subj_and_clip_custom(&subj, &clip, Default::default(), solver) + .build_graph_view(FillRule::NonZero) { graph.validate(); let _ = graph.extract_shapes(OverlayRule::Xor, &mut Default::default()); @@ -170,7 +174,7 @@ mod tests { // println!("subj {:?}", subj); for &solver in SOLVERS.iter() { - if let Some(graph) = Overlay::with_contours_custom(&subj, &clip, Default::default(), solver) + if let Some(graph) = Overlay::from_subj_and_clip_custom(&subj, &clip, Default::default(), solver) .build_graph_view(FillRule::NonZero) { graph.validate(); @@ -194,8 +198,9 @@ mod tests { while a < 0.000_001 { let subj = create_star::(100.0, 50.0, 24, a, scale); - if let Some(graph) = Overlay::with_contours_custom(&subj, &clip, Default::default(), solver) - .build_graph_view(FillRule::NonZero) + if let Some(graph) = + Overlay::from_subj_and_clip_custom(&subj, &clip, Default::default(), solver) + .build_graph_view(FillRule::NonZero) { graph.validate(); let _ = graph.extract_shapes(OverlayRule::Xor, &mut Default::default()); @@ -276,7 +281,7 @@ mod tests { while a < 2.0 * PI { let subj = create_star::(r0, r, 4, a, scale); if let Some(graph) = - Overlay::with_contours_custom(&subj, &clip, Default::default(), solver) + Overlay::from_subj_and_clip_custom(&subj, &clip, Default::default(), solver) .build_graph_view(FillRule::NonZero) { graph.validate(); @@ -305,7 +310,7 @@ mod tests { let r = 1.01; let subj = create_star::(1.0, r, 7, a, scale); - if let Some(graph) = Overlay::with_contours_custom(&subj, &clip, Default::default(), solver) + if let Some(graph) = Overlay::from_subj_and_clip_custom(&subj, &clip, Default::default(), solver) .build_graph_view(FillRule::NonZero) { graph.validate(); @@ -411,7 +416,7 @@ mod tests { } let mut overlay = Overlay::new_custom(4, Default::default(), Default::default()); - overlay.add_contours(&subj_paths, ShapeType::Subject); + overlay.add_source(&subj_paths, ShapeType::Subject); if let Some(graph) = overlay.build_graph_view(FillRule::NonZero) { graph.validate(); let result = graph.extract_shapes(OverlayRule::Subject, &mut Default::default()); diff --git a/iOverlay/tests/edge_data_geometry_tests.rs b/iOverlay/tests/edge_data_geometry_tests.rs new file mode 100644 index 00000000..0001c796 --- /dev/null +++ b/iOverlay/tests/edge_data_geometry_tests.rs @@ -0,0 +1,135 @@ +use i_float::int::number::int::IntNumber; +use i_float::int::point::IntPoint; +use i_overlay::core::edge_data::{EdgeDataMerge, EdgeDataSplit, OverlayEdgeData}; +use i_overlay::core::edge_overlay::{EdgeOverlay, InputEdge}; +use i_overlay::core::fill_rule::FillRule; +use i_overlay::core::overlay::{ContourDirection, ShapeType}; +use i_overlay::core::overlay_rule::OverlayRule; +use i_overlay::core::solver::Solver; +use i_overlay::segm::boolean::ShapeCountBoolean; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Endpoints { + a: [i64; 2], + b: [i64; 2], +} + +#[derive(Default)] +struct Calls { + split: usize, + merge: usize, + reverse: usize, +} + +fn point(p: IntPoint) -> [i64; 2] { + // Test coordinates are small integers, exactly representable in f64. + [p.x.to_f64() as i64, p.y.to_f64() as i64] +} + +impl OverlayEdgeData for Endpoints { + type Store = Calls; + + fn reversed(self, store: &mut Calls) -> Self { + store.reverse += 1; + Self { a: self.b, b: self.a } + } + + fn split(self, ctx: EdgeDataSplit, store: &mut Calls) -> (Self, Self) { + store.split += 1; + let (a, p, b) = (point(ctx.a), point(ctx.p), point(ctx.b)); + assert_eq!( + self, + Self { a, b }, + "split context must match the current edge data" + ); + assert!(a != p && p != b); + (Self { a, b: p }, Self { a: p, b }) + } + + fn merge(ctx: EdgeDataMerge, store: &mut Calls) -> Self { + store.merge += 1; + assert_eq!( + ctx.lhs_data, ctx.rhs_data, + "merged edges must share directed endpoints" + ); + ctx.lhs_data + } +} + +fn edges(path: &[IntPoint]) -> Vec> { + path.iter() + .zip(path.iter().cycle().skip(1)) + .map(|(&a, &b)| InputEdge { + a, + b, + data: Endpoints { + a: point(a), + b: point(b), + }, + }) + .collect() +} + +#[test] +fn edge_data_tracks_splits_merges_and_output_direction() { + let subject = [[0, 0], [10, 0], [10, 10], [0, 10]].map(|p| IntPoint::new(p[0], p[1])); + let clip = [[5, 0], [15, 0], [15, 10], [5, 10]].map(|p| IntPoint::new(p[0], p[1])); + let mut seed = 0x1359_8472_fa99_217b_u64; + let mut next = || { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + ((seed >> 32) % 41) as i32 - 20 + }; + for case in 0..501 { + let (a, b) = if case == 0 { + (subject.to_vec(), clip.to_vec()) + } else { + ( + (0..3 + case % 8).map(|_| IntPoint::new(next(), next())).collect(), + (0..3 + case % 9).map(|_| IntPoint::new(next(), next())).collect(), + ) + }; + let mut overlay = EdgeOverlay::new(a.len() + b.len()); + overlay.solver = [Solver::LIST, Solver::TREE, Solver::FRAG][case % 3]; + overlay.add_edges(edges(&a), ShapeType::Subject); + overlay.add_edges(edges(&b), ShapeType::Clip); + for clockwise in [false, true] { + overlay.options.output_direction = if clockwise { + ContourDirection::Clockwise + } else { + ContourDirection::CounterClockwise + }; + for rule in [ + OverlayRule::Union, + OverlayRule::Intersect, + OverlayRule::Difference, + OverlayRule::Xor, + ] { + let shapes = overlay.build_vector_shapes(rule, FillRule::EvenOdd); + for edge in shapes.iter().flatten().flatten() { + assert_eq!( + edge.data, + Endpoints { + a: point(edge.a), + b: point(edge.b) + }, + "case={case}, clockwise={clockwise}, rule={rule:?}, edge={edge:?}" + ); + } + for edge in overlay.build_vectors(rule, FillRule::EvenOdd) { + assert_eq!( + edge.data, + Endpoints { + a: point(edge.a), + b: point(edge.b) + }, + "flat vector: case={case}, edge={edge:?}" + ); + } + } + } + if case == 0 { + let store = overlay.data_store(); + assert!(store.split > 0 && store.merge > 0 && store.reverse > 0); + } + } +} diff --git a/iOverlay/tests/edge_overlay_tests.rs b/iOverlay/tests/edge_overlay_tests.rs index cb937434..d79b2410 100644 --- a/iOverlay/tests/edge_overlay_tests.rs +++ b/iOverlay/tests/edge_overlay_tests.rs @@ -10,6 +10,52 @@ mod tests { use i_overlay::segm::boolean::ShapeCountBoolean; use i_overlay::vector::edge::DataVectorEdge; + #[test] + fn simplifying_collinear_edges_preserves_data_store() { + #[derive(Clone, Copy, PartialEq, Eq)] + struct StoredData(usize); + + impl OverlayEdgeData for StoredData { + // Maps each data index to its reversed counterpart. + type Store = Vec; + + fn reversed(self, store: &mut Self::Store) -> Self { + Self(store[self.0]) + } + + fn merge(ctx: EdgeDataMerge, _: &mut Self::Store) -> Self { + ctx.lhs_data + } + } + + // A rectangle with one redundant vertex on its bottom edge. + let path = [ + int_pnt!(0, 0), + int_pnt!(5, 0), + int_pnt!(10, 0), + int_pnt!(10, 10), + int_pnt!(0, 10), + ]; + let mut overlay = EdgeOverlay::::new(path.len()); + overlay.data_store_mut().push(0); + overlay.options.preserve_output_collinear = false; + for i in 0..path.len() { + overlay.add_edge( + InputEdge { + a: path[i], + b: path[(i + 1) % path.len()], + data: StoredData(0), + }, + ShapeType::Subject, + ); + } + + let shapes = overlay.build_vector_shapes(OverlayRule::Subject, FillRule::NonZero); + assert_eq!(shapes.len(), 1); + assert_eq!(shapes[0].len(), 1); + assert_eq!(shapes[0][0].len(), 4); + } + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Color { Red, diff --git a/iOverlay/tests/float_area_tests.rs b/iOverlay/tests/float_area_tests.rs new file mode 100644 index 00000000..46dc831e --- /dev/null +++ b/iOverlay/tests/float_area_tests.rs @@ -0,0 +1,66 @@ +use i_float::adapter::FloatPointAdapter; +use i_overlay::core::fill_rule::FillRule; +use i_overlay::core::overlay_rule::OverlayRule; +use i_overlay::core::solver::Solver; +use i_overlay::float::overlay::{FloatOverlay, OverlayOptions}; + +#[test] +fn f32_i64_area_filter_preserves_shapes_above_threshold() { + let square = [[0.0_f32, 0.0], [0.01, 0.0], [0.01, 0.01], [0.0, 0.01]]; + // Square area is 1e-4, one hundred times the filtering threshold. + let expected = + FloatOverlay::<[f32; 2], i64>::from_subj(&square).overlay(OverlayRule::Subject, FillRule::NonZero); + assert_eq!(expected.len(), 1); + let mut options = OverlayOptions::::default(); + options.min_output_area = 1e-6; + let actual = FloatOverlay::<[f32; 2], i64>::from_subj_custom(&square, options, Solver::AUTO) + .overlay(OverlayRule::Subject, FillRule::NonZero); + assert_eq!( + actual, expected, + "a positive area threshold below the square area must retain it" + ); +} + +#[test] +fn adapter_area_conversion_avoids_intermediate_overflow() { + let square = [[0.0_f32, 0.0], [0.01, 0.0], [0.01, 0.01], [0.0, 0.01]]; + let adapter = FloatPointAdapter::<[f32; 2], i64>::with_iter(square.iter()); + let threshold = 1e-6_f32; + let scale = f64::from(adapter.dir_scale()); + let expected = (scale * scale * f64::from(threshold)).round() as i128; + assert!(expected > 0 && expected < i128::MAX); + assert_eq!(adapter.round_sqr_len_to_int(threshold), expected); +} + +#[test] +fn f32_i32_area_filter_preserves_shapes_above_threshold() { + let square = [[0.0_f32, 0.0], [0.01, 0.0], [0.01, 0.01], [0.0, 0.01]]; + let mut options = OverlayOptions::::default(); + options.min_output_area = 1e-6; + let actual = FloatOverlay::<[f32; 2], i32>::from_subj_custom(&square, options, Solver::AUTO) + .overlay(OverlayRule::Subject, FillRule::NonZero); + assert_eq!(actual.len(), 1); +} + +#[test] +fn f64_i64_area_filter_preserves_shapes_above_threshold() { + let square = [[0.0_f64, 0.0], [0.01, 0.0], [0.01, 0.01], [0.0, 0.01]]; + let mut options = OverlayOptions::::default(); + options.min_output_area = 1e-6; + let actual = FloatOverlay::<[f64; 2], i64>::from_subj_custom(&square, options, Solver::AUTO) + .overlay(OverlayRule::Subject, FillRule::NonZero); + assert_eq!(actual.len(), 1); +} + +#[test] +fn f32_i32_area_filter_preserves_tiny_shapes() { + let square = [[0.0_f32, 0.0], [1e-12, 0.0], [1e-12, 1e-12], [0.0, 1e-12]]; + let expected = + FloatOverlay::<[f32; 2], i32>::from_subj(&square).overlay(OverlayRule::Subject, FillRule::NonZero); + assert_eq!(expected.len(), 1); + let mut options = OverlayOptions::::default(); + options.min_output_area = 1e-26; + let actual = FloatOverlay::<[f32; 2], i32>::from_subj_custom(&square, options, Solver::AUTO) + .overlay(OverlayRule::Subject, FillRule::NonZero); + assert_eq!(actual, expected); +} diff --git a/iOverlay/tests/float_conversion_tests.rs b/iOverlay/tests/float_conversion_tests.rs new file mode 100644 index 00000000..8e3437b7 --- /dev/null +++ b/iOverlay/tests/float_conversion_tests.rs @@ -0,0 +1,29 @@ +use i_overlay::core::fill_rule::FillRule; +use i_overlay::core::integer::OverlayInt; +use i_overlay::core::overlay_rule::OverlayRule; +use i_overlay::float::overlay::FloatOverlay; + +fn check_subject_near_scale_boundary(side: f64, grid_step: f64) { + let subject = [[0.0, 0.0], [side, side], [0.0, side]]; + let clip = [[side - grid_step, side], [side, side - grid_step], [side, side]]; + + // Both inputs have the same combined bounds as the subject alone. The + // small clip crosses its diagonal near the top-right corner. Extracting + // Subject must preserve that triangle, regardless of the clip. + let expected = + FloatOverlay::<[f64; 2], I>::from_subj(&subject).overlay(OverlayRule::Subject, FillRule::NonZero); + assert_eq!(expected.len(), 1); + let actual = FloatOverlay::<[f64; 2], I>::from_subj_and_clip(&subject, &clip) + .overlay(OverlayRule::Subject, FillRule::NonZero); + assert_eq!(actual, expected); +} + +#[test] +fn automatic_i16_scale_handles_intersection_near_extreme_corner() { + check_subject_near_scale_boundary::(3.9999, 1.0 / 8192.0); +} + +#[test] +fn automatic_i32_scale_handles_intersection_near_extreme_corner() { + check_subject_near_scale_boundary::(3.999999999, 1.0 / 536870912.0); +} diff --git a/iOverlay/tests/float_coordinate_contract_tests.rs b/iOverlay/tests/float_coordinate_contract_tests.rs new file mode 100644 index 00000000..56b79bd2 --- /dev/null +++ b/iOverlay/tests/float_coordinate_contract_tests.rs @@ -0,0 +1,108 @@ +use i_float::float::{number::FloatNumber, rect::FloatRectError}; +use i_overlay::core::{fill_rule::FillRule, overlay_rule::OverlayRule}; +use i_overlay::float::{overlay::FloatOverlay, relate::FloatPredicateOverlay, scale::FixedScaleOverlayError}; +use i_overlay::mesh::float::{ + outline::offset::OutlineOffset, + stroke::offset::StrokeOffset, + style::{OutlineStyle, StrokeStyle}, + variable_stroke::{StrokeVertex, VariableStrokeStyle, offset::VariableStrokeOffset}, +}; +use i_shape::flat::float::FloatFlatContoursBuffer; + +macro_rules! coordinate_contract { + ($name:ident, $scalar:ty) => { + #[test] + fn $name() { + let limit = <$scalar as FloatNumber>::MAX_COORDINATE; + let square = [[-limit, -limit], [limit, -limit], [limit, limit], [-limit, limit]]; + let mut overlay = FloatOverlay::with_subj(&square); + assert_eq!(overlay.overlay(OverlayRule::Subject, FillRule::NonZero).len(), 1); + assert!(FloatOverlay::with_subj_and_clip_fixed_scale(&square, &square, 1.0 / limit).is_ok()); + + let error = FixedScaleOverlayError::InvalidRect(FloatRectError::CoordinatesOutOfRange); + for invalid in [limit * 2.0, -limit * 2.0, <$scalar>::NAN, <$scalar>::INFINITY] { + // Exercise both initialization and expansion of variable-stroke bounds. + for points in [[[invalid, 0.0], [0.0, 0.0]], [[0.0, 0.0], [invalid, 0.0]]] { + assert!(matches!( + FloatOverlay::with_subj_and_clip_fixed_scale(&points, &square, 1.0), + Err(e) if e == error + )); + assert_eq!(points.stroke_fixed_scale(StrokeStyle::new(1.0), false, 1.0), Err(error)); + assert_eq!(points.outline_fixed_scale(&OutlineStyle::new(1.0), 1.0), Err(error)); + let vertices = points.map(|p| StrokeVertex::new(p, 1.0)); + assert_eq!(vertices.variable_stroke_fixed_scale(VariableStrokeStyle::new(), 1.0), Err(error)); + } + } + + // Valid source coordinates can still yield unsupported padded bounds. + let path = [[limit, 0.0], [limit, limit / 4.0]]; + assert_eq!(path.stroke_fixed_scale(StrokeStyle::new(limit), false, 1.0 / limit), Err(error)); + assert_eq!(path.outline_fixed_scale(&OutlineStyle::new(limit / 4.0), 1.0 / limit), Err(error)); + let vertices = path.map(|p| StrokeVertex::new(p, limit)); + assert_eq!(vertices.variable_stroke_fixed_scale(VariableStrokeStyle::new(), 1.0 / limit), Err(error)); + + // Bounds errors must not clear a caller's existing output buffer. + let mut output = FloatFlatContoursBuffer::default(); + output.add_contour(&[[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]]); + let original = output.clone(); + assert_eq!(path.stroke_fixed_scale_into(StrokeStyle::new(limit), false, 1.0 / limit, &mut output), Err(error)); + assert_eq!(path.outline_fixed_scale_into(&OutlineStyle::new(limit / 4.0), 1.0 / limit, &mut output), Err(error)); + assert_eq!(vertices.variable_stroke_fixed_scale_into(VariableStrokeStyle::new(), 1.0 / limit, &mut output), Err(error)); + assert_eq!(output.points, original.points); + assert_eq!(output.ranges, original.ranges); + + // Reciprocal overflow must be evaluated in the original scalar type. + let tiny = <$scalar>::from_bits(1); + assert_eq!(FixedScaleOverlayError::validate_scale(tiny), Err(FixedScaleOverlayError::ScaleTooSmall)); + for path in [vec![], vec![[0.0, 0.0], [1.0, 0.0]]] { + assert!(matches!(FloatOverlay::with_subj_and_clip_fixed_scale(&path, &path, tiny), Err(FixedScaleOverlayError::ScaleTooSmall))); + assert_eq!(path.stroke_fixed_scale(StrokeStyle::new(1.0), false, tiny), Err(FixedScaleOverlayError::ScaleTooSmall)); + assert_eq!(path.outline_fixed_scale(&OutlineStyle::new(1.0), tiny), Err(FixedScaleOverlayError::ScaleTooSmall)); + let vertices: Vec<_> = path.iter().map(|&p| StrokeVertex::new(p, 1.0)).collect(); + assert_eq!(vertices.variable_stroke_fixed_scale(VariableStrokeStyle::new(), tiny), Err(FixedScaleOverlayError::ScaleTooSmall)); + } + let empty: Vec<[$scalar; 2]> = vec![]; + assert!(empty.stroke_fixed_scale(StrokeStyle::new(1.0), false, 1.0).unwrap().is_empty()); + assert!(empty.outline_fixed_scale(&OutlineStyle::new(1.0), 1.0).unwrap().is_empty()); + let empty_vertices: Vec> = vec![]; + assert!(empty_vertices.variable_stroke_fixed_scale(VariableStrokeStyle::new(), 1.0).unwrap().is_empty()); + } + }; +} + +coordinate_contract!(f32_coordinate_contract, f32); +coordinate_contract!(f64_coordinate_contract, f64); + +#[test] +fn automatic_i16_budget_and_fixed_scale_rejection() { + let h = 1.99999; + let square = [[-h, -h], [h, -h], [h, h], [-h, h]]; + let mut overlay = FloatOverlay::<_, i16>::from_subj_and_clip(&square, &square); + let result = overlay.overlay(OverlayRule::Intersect, FillRule::NonZero); + assert_eq!(result.len(), 1); + assert_eq!(result[0][0].len(), 4); + for p in &result[0][0] { + assert!((p[0].abs() - 2.0).abs() < 0.001); + assert!((p[1].abs() - 2.0).abs() < 0.001); + } + let mut predicate = FloatPredicateOverlay::<_, i16>::from_subj_and_clip(&square, &square); + assert!(predicate.intersects()); + assert!(matches!( + FloatOverlay::<_, i16>::from_subj_and_clip_fixed_scale(&square, &square, 8192.0), + Err(FixedScaleOverlayError::ScaleTooLarge) + )); + assert!(FloatOverlay::<_, i16>::from_subj_and_clip_fixed_scale(&square, &square, 4096.0).is_ok()); +} + +#[test] +#[should_panic(expected = "Invalid adapter bounds")] +fn infallible_overlay_rejects_invalid_bounds() { + let _ = FloatOverlay::with_subj(&[[f64::NAN, 0.0]]); +} + +#[test] +#[should_panic(expected = "Invalid offset bounds")] +fn infallible_stroke_rejects_padded_bounds() { + let limit = ::MAX_COORDINATE; + let _ = [[limit, 0.0], [limit, 1.0]].stroke(StrokeStyle::new(limit), false); +} diff --git a/iOverlay/tests/float_point_adapter.rs b/iOverlay/tests/float_point_adapter.rs index 3580ba82..b094332c 100644 --- a/iOverlay/tests/float_point_adapter.rs +++ b/iOverlay/tests/float_point_adapter.rs @@ -6,7 +6,7 @@ mod tests { use i_overlay::core::overlay::ShapeType; use i_overlay::core::overlay_rule::OverlayRule; use i_overlay::float::overlay::FloatOverlay; - use i_shape::source::resource::ShapeResource; + use i_shape::source::float::resource::ShapeResource; #[test] fn test_adapter_with_rect() { @@ -18,8 +18,10 @@ mod tests { [s * 1.0, s * 0.0], ]]; - let adapter_100 = FloatPointAdapter::<_, i32>::new(FloatRect::new(-100.0, 100.0, -100.0, 100.0)); - let adapter_1000 = FloatPointAdapter::<_, i32>::new(FloatRect::new(-1000.0, 1000.0, -1000.0, 1000.0)); + let adapter_100 = + FloatPointAdapter::<_, i32>::new(FloatRect::new(-100.0, 100.0, -100.0, 100.0).unwrap()); + let adapter_1000 = + FloatPointAdapter::<_, i32>::new(FloatRect::new(-1000.0, 1000.0, -1000.0, 1000.0).unwrap()); let subj_100 = FloatOverlay::with_adapter(adapter_100, shape.len()) .unsafe_add_source(&shape, ShapeType::Subject) @@ -50,15 +52,18 @@ mod tests { [s * 1.0, s * 0.0], ]]; - let rect = FloatRect::with_iter(shape.iter_paths().flatten()).unwrap(); + let rect = FloatRect::with_iter(shape.iter_paths().flatten()) + .unwrap() + .unwrap(); let buffer_rect = FloatRect::new( rect.min_x - 0.1, rect.max_x + 0.1, rect.min_y - 0.1, rect.max_y + 0.1, - ); + ) + .unwrap(); - let adapter_100 = FloatPointAdapter::<_, i32>::with_scale(buffer_rect.clone(), 100.0); + let adapter_100 = FloatPointAdapter::<_, i32>::with_scale(buffer_rect, 100.0); let adapter_1000 = FloatPointAdapter::<_, i32>::with_scale(buffer_rect, 1000.0); let subj_100 = FloatOverlay::with_adapter(adapter_100, shape.len()) diff --git a/iOverlay/tests/fragment_tests.rs b/iOverlay/tests/fragment_tests.rs index 15ea6904..f3357eb1 100644 --- a/iOverlay/tests/fragment_tests.rs +++ b/iOverlay/tests/fragment_tests.rs @@ -26,7 +26,7 @@ mod tests { let results: Vec<_> = [Solver::LIST, Solver::TREE, Solver::AUTO, Solver::FRAG] .into_iter() .map(|solver| { - let shapes = Overlay::with_contours_custom(&subj, &[], Default::default(), solver) + let shapes = Overlay::from_subj_custom(&subj, Default::default(), solver) .overlay(OverlayRule::Subject, FillRule::EvenOdd); let area2: i64 = shapes .iter() @@ -59,17 +59,29 @@ mod tests { let subj_paths = many_squares(IntPoint::new(0, 0), 20, 30, n); let clip_paths = many_squares(IntPoint::new(15, 15), 20, 30, n - 1); - let list_result = - Overlay::with_contours_custom(&subj_paths, &clip_paths, Default::default(), Solver::LIST) - .overlay(rule, fill); - - let tree_result = - Overlay::with_contours_custom(&subj_paths, &clip_paths, Default::default(), Solver::TREE) - .overlay(rule, fill); - - let frag_result = - Overlay::with_contours_custom(&subj_paths, &clip_paths, Default::default(), Solver::FRAG) - .overlay(rule, fill); + let list_result = Overlay::from_subj_and_clip_custom( + &subj_paths, + &clip_paths, + Default::default(), + Solver::LIST, + ) + .overlay(rule, fill); + + let tree_result = Overlay::from_subj_and_clip_custom( + &subj_paths, + &clip_paths, + Default::default(), + Solver::TREE, + ) + .overlay(rule, fill); + + let frag_result = Overlay::from_subj_and_clip_custom( + &subj_paths, + &clip_paths, + Default::default(), + Solver::FRAG, + ) + .overlay(rule, fill); assert_eq!(list_result, tree_result); assert_eq!(list_result, frag_result); @@ -85,17 +97,29 @@ mod tests { let subj_paths = repeat_xy(square(0, 0, 2), 0, 0, 10, 10, n); let clip_paths = repeat_xy(romb(0, 0, 4), 5, 5, 10, 10, n - 1); - let list_result = - Overlay::with_contours_custom(&subj_paths, &clip_paths, Default::default(), Solver::LIST) - .overlay(rule, fill); - - let tree_result = - Overlay::with_contours_custom(&subj_paths, &clip_paths, Default::default(), Solver::TREE) - .overlay(rule, fill); - - let frag_result = - Overlay::with_contours_custom(&subj_paths, &clip_paths, Default::default(), Solver::FRAG) - .overlay(rule, fill); + let list_result = Overlay::from_subj_and_clip_custom( + &subj_paths, + &clip_paths, + Default::default(), + Solver::LIST, + ) + .overlay(rule, fill); + + let tree_result = Overlay::from_subj_and_clip_custom( + &subj_paths, + &clip_paths, + Default::default(), + Solver::TREE, + ) + .overlay(rule, fill); + + let frag_result = Overlay::from_subj_and_clip_custom( + &subj_paths, + &clip_paths, + Default::default(), + Solver::FRAG, + ) + .overlay(rule, fill); assert_eq!(list_result, tree_result); assert_eq!(list_result, frag_result); @@ -112,17 +136,29 @@ mod tests { let subj_paths = many_lines_x(20, n); let clip_paths = many_lines_y(20, n); - let list_result = - Overlay::with_contours_custom(&subj_paths, &clip_paths, Default::default(), Solver::LIST) - .overlay(rule, fill); - - let tree_result = - Overlay::with_contours_custom(&subj_paths, &clip_paths, Default::default(), Solver::TREE) - .overlay(rule, fill); - - let frag_result = - Overlay::with_contours_custom(&subj_paths, &clip_paths, Default::default(), Solver::FRAG) - .overlay(rule, fill); + let list_result = Overlay::from_subj_and_clip_custom( + &subj_paths, + &clip_paths, + Default::default(), + Solver::LIST, + ) + .overlay(rule, fill); + + let tree_result = Overlay::from_subj_and_clip_custom( + &subj_paths, + &clip_paths, + Default::default(), + Solver::TREE, + ) + .overlay(rule, fill); + + let frag_result = Overlay::from_subj_and_clip_custom( + &subj_paths, + &clip_paths, + Default::default(), + Solver::FRAG, + ) + .overlay(rule, fill); assert_eq!(list_result, tree_result); assert_eq!(list_result, frag_result); @@ -138,17 +174,17 @@ mod tests { let contours = discrete_spiral(n, 4); let mut list_overlay = Overlay::new(n * 8); - list_overlay.add_contours(&contours, ShapeType::Subject); + list_overlay.add_source(&contours, ShapeType::Subject); let list_result = list_overlay.overlay(rule, fill); let mut tree_overlay = Overlay::new(n * 8); - tree_overlay.add_contours(&contours, ShapeType::Subject); + tree_overlay.add_source(&contours, ShapeType::Subject); let tree_result = tree_overlay.overlay(rule, fill); let mut frag_overlay = Overlay::new(n * 8); - frag_overlay.add_contours(&contours, ShapeType::Subject); + frag_overlay.add_source(&contours, ShapeType::Subject); let frag_result = frag_overlay.overlay(rule, fill); diff --git a/iOverlay/tests/hierarchy_stress.rs b/iOverlay/tests/hierarchy_stress.rs index 4fbfdade..4951f318 100644 --- a/iOverlay/tests/hierarchy_stress.rs +++ b/iOverlay/tests/hierarchy_stress.rs @@ -3,6 +3,7 @@ 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::area::UnsafeArea; use i_shape::int::path::ContourExtension; use std::panic::{AssertUnwindSafe, catch_unwind}; @@ -55,7 +56,7 @@ fn run_stress_case(seed: u64) { (subject, clip, OverlayRule::Xor) }; - let mut overlay = Overlay::with_contours(&subject, &clip); + let mut overlay = Overlay::from_subj_and_clip(&subject, &clip); if seed & 2 != 0 { overlay.options.output_direction = ContourDirection::Clockwise; } @@ -119,7 +120,7 @@ fn assert_hierarchy_matches_containment(hierarchy: &FlatShapeHierarchy, see continue; } - let area = contour.unsafe_area().unsigned_abs(); + let area = contour.iter().copied().unsafe_area().unsigned_abs(); if parent.is_none_or(|candidate| area < candidate.0) { parent = Some((area, parent_shape_index, parent_contour_index)); } diff --git a/iOverlay/tests/hierarchy_touch_tests.rs b/iOverlay/tests/hierarchy_touch_tests.rs index 44aaaef0..1f78cd61 100644 --- a/iOverlay/tests/hierarchy_touch_tests.rs +++ b/iOverlay/tests/hierarchy_touch_tests.rs @@ -25,7 +25,7 @@ fn hierarchy_preserves_links_and_honors_collinear_output_option() { ogc, ..Default::default() }; - let mut overlay = Overlay::with_contours_custom(&subject, &[], options, Default::default()); + let mut overlay = Overlay::from_subj_custom(&subject, options, Default::default()); let hierarchy = overlay.overlay_hierarchy(OverlayRule::Subject, FillRule::NonZero); let shapes = overlay.overlay(OverlayRule::Subject, FillRule::NonZero); @@ -65,7 +65,7 @@ fn island_touching_hole_boundary_keeps_its_parent() { contour(&[[2, 2], [2, 18], [18, 18], [18, 2]]), contour(&island), ]; - let mut overlay = Overlay::with_contours(&subject, &[]); + let mut overlay = Overlay::from_subj(&subject); overlay.options.ogc = true; overlay.options.output_direction = if clockwise { ContourDirection::Clockwise @@ -92,3 +92,76 @@ fn island_touching_hole_boundary_keeps_its_parent() { } } } +#[test] +fn holes_sharing_one_vertex_keep_their_own_islands() { + let outer = contour(&[[0, 0], [40, 0], [40, 40], [0, 40]]); + let mut subject = vec![outer]; + for rotation in 0..4 { + let rotate = |mut p: [i32; 2]| { + for _ in 0..rotation { + p = [40 - p[1], p[0]]; + } + p + }; + // Clockwise holes touch at (20,20), with one CCW island per hole. + subject.push(contour(&[[20, 20], [14, 28], [20, 36], [26, 28]].map(rotate))); + subject.push(contour(&[[19, 27], [21, 27], [21, 29], [19, 29]].map(rotate))); + } + for clockwise in [false, true] { + for reversed_input_order in [false, true] { + if reversed_input_order { + subject.reverse(); + } + let mut overlay = Overlay::from_subj(&subject); + overlay.options.ogc = true; + overlay.options.output_direction = if clockwise { + ContourDirection::Clockwise + } else { + ContourDirection::CounterClockwise + }; + let hierarchy = overlay.overlay_hierarchy(OverlayRule::Subject, FillRule::NonZero); + let flat = &hierarchy.shapes; + assert_eq!(flat.shape_ranges.len(), 5, "{hierarchy:?}"); + assert_eq!(hierarchy.links.len(), 4, "{hierarchy:?}"); + let mut total_area_two = 0_i64; + for shape in &flat.shape_ranges { + for (local, range) in flat.contour_ranges[shape.clone()].iter().enumerate() { + let path = &flat.points[range.clone()]; + let area_two: i64 = path + .iter() + .zip(path.iter().cycle().skip(1)) + .map(|(a, b)| i64::from(a.x) * i64::from(b.y) - i64::from(a.y) * i64::from(b.x)) + .sum(); + assert_eq!(area_two < 0, clockwise != (local != 0)); + total_area_two += area_two; + let unique: std::collections::BTreeSet<_> = path.iter().map(|p| (p.x, p.y)).collect(); + assert_eq!(unique.len(), path.len(), "OGC contour repeats a vertex"); + } + } + assert_eq!(total_area_two.abs(), 2 * (1600 - 4 * 96 + 4 * 4)); + let mut owners = std::collections::BTreeSet::new(); + for link in &hierarchy.links { + assert!(owners.insert(link.parent_contour_index)); + let parent = &flat.shape_ranges[link.parent_shape_index]; + assert_eq!(parent.len(), 5); + assert!(link.parent_contour_index > parent.start && link.parent_contour_index < parent.end); + let child = &flat.shape_ranges[link.child_shape_index]; + assert_eq!(child.len(), 1); + let child_points = &flat.points[flat.contour_ranges[child.start].clone()]; + let hole_points = &flat.points[flat.contour_ranges[link.parent_contour_index].clone()]; + // Every island vertex must lie strictly inside its convex hole. + for p in child_points { + let crosses: Vec<_> = hole_points + .iter() + .zip(hole_points.iter().cycle().skip(1)) + .map(|(a, b)| (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x)) + .collect(); + assert!( + crosses.iter().all(|&c| c > 0) || crosses.iter().all(|&c| c < 0), + "wrong hole owner: {hierarchy:?}" + ); + } + } + } + } +} diff --git a/iOverlay/tests/int_arc_tests.rs b/iOverlay/tests/int_arc_tests.rs new file mode 100644 index 00000000..2719a7f5 --- /dev/null +++ b/iOverlay/tests/int_arc_tests.rs @@ -0,0 +1,251 @@ +use i_float::int::angle::Angle; +use i_float::int::number::int::IntNumber; +use i_float::int::number::wide_int::WideIntNumber; +use i_float::int::unit_vector::UnitIntVector; +use i_float::int::vector::IntVector; +use i_overlay::mesh::int::arc::{ArcBuilder, ArcDirection, ArcOptions}; +use std::f64::consts::TAU; + +fn unit(x: i32, y: i32) -> UnitIntVector { + let coordinate = |v: i32| { + let magnitude = I::Wide::from_u32(v.unsigned_abs()); + if v < 0 { -magnitude } else { magnitude } + }; + IntVector::::new(coordinate(x), coordinate(y)) + .fast_normalize() + .unwrap() +} + +fn angle(v: UnitIntVector) -> f64 { + v.y().to_f64().atan2(v.x().to_f64()) +} + +fn check_arc( + builder: &mut ArcBuilder, + from: UnitIntVector, + to: UnitIntVector, + direction: ArcDirection, +) { + let sign = if direction == ArcDirection::Counterclockwise { + 1.0 + } else { + -1.0 + }; + let expected = (sign * (angle(to) - angle(from))).rem_euclid(TAU); + let scale = UnitIntVector::::DENOMINATOR.to_f64(); + let max_angle = builder.options().max_step.bits() as f64 * TAU / 4294967296.0; + let result = builder.build(from, to, direction); + assert!(result.len() < 1536); + if expected == 0.0 { + assert!(result.is_empty()); + return; + } + + let mut previous = from; + let mut total = 0.0; + for current in result.iter().copied().chain(std::iter::once(to)) { + let delta = (sign * (angle(current) - angle(previous))).rem_euclid(TAU); + assert!(delta > 0.0, "duplicate direction"); + assert!( + delta <= max_angle + 1e-12, + "step {} exceeds {} for {}-bit vectors", + delta.to_degrees(), + max_angle.to_degrees(), + I::BITS, + ); + let x = current.x().to_f64() / scale; + let y = current.y().to_f64() / scale; + assert!((if I::BITS == 16 { 0.46 } else { 0.98 }..=1.000000000000001).contains(&(x * x + y * y))); + total += delta; + previous = current; + } + assert!((total - expected).abs() < 1e-10, "wrong directed arc"); +} + +fn check_type() { + // Axes, diagonals, uneven lengths, almost equal/opposite directions, + // and both sides of quadrant boundaries exercise minor and major arcs. + let pairs = [ + (1, 0), + (10000, 1), + (3, 4), + (1, 1), + (1, 10000), + (0, 1), + (-1, 10000), + (-4, 3), + (-1, 1), + (-1, 0), + (-10000, -1), + (-3, -4), + (-1, -1), + (-1, -10000), + (0, -1), + (1, -10000), + (4, -3), + (1, -1), + (10000, -1), + ]; + let directions: Vec<_> = pairs.into_iter().map(|(x, y)| unit::(x, y)).collect(); + for precision in [4, 5, 6, 32] { + for max_step in [ + ArcOptions::MAX_STEP, + ArcOptions::MIN_STEP, + Angle::from_bits(1 << 26), + ] { + let mut builder = ArcBuilder::::new(ArcOptions { + max_step, + rotation_precision: precision, + }); + for &from in &directions { + for &to in &directions { + for direction in [ArcDirection::Clockwise, ArcDirection::Counterclockwise] { + check_arc(&mut builder, from, to, direction); + } + } + } + } + } +} + +#[test] +fn arcs_i16() { + check_type::(); +} + +#[test] +fn arcs_i32() { + check_type::(); +} + +#[test] +fn arcs_i64() { + check_type::(); +} + +#[test] +fn settings_are_clamped_and_default_to_precision_five() { + let defaults = ArcBuilder::::default().options(); + assert_eq!(defaults.max_step, ArcOptions::MAX_STEP); + assert_eq!(defaults.rotation_precision, 5); + let low = ArcBuilder::::new(ArcOptions { + max_step: Angle::from_bits(0), + rotation_precision: 0, + }) + .options(); + assert_eq!(low.max_step, ArcOptions::MIN_STEP); + assert_eq!(low.rotation_precision, 4); + let high = ArcBuilder::::new(ArcOptions { + max_step: Angle::from_bits(u32::MAX), + rotation_precision: u32::MAX, + }) + .options(); + assert_eq!(high.max_step, ArcOptions::MAX_STEP); + assert_eq!(high.rotation_precision, 32); +} + +#[test] +fn opposite_directions_select_the_requested_semicircle() { + let mut builder = ArcBuilder::::default(); + let from = unit(1, 0); + let to = unit(-1, 0); + let upper = builder.build(from, to, ArcDirection::Counterclockwise); + assert!(!upper.is_empty()); + assert!(upper.iter().all(|v| v.y() > 0)); + let lower = builder.build(from, to, ArcDirection::Clockwise); + assert!(!lower.is_empty()); + assert!(lower.iter().all(|v| v.y() < 0)); +} + +#[test] +fn empty_and_short_arcs_clear_the_previous_result() { + let mut builder = ArcBuilder::::default(); + let from = unit(1, 0); + let turn = ArcDirection::Counterclockwise; + assert!(!builder.build(from, unit(-1, 0), turn).is_empty()); + assert!(builder.build(from, from, turn).is_empty()); + assert!(builder.build(from, unit(10, 1), turn).is_empty()); + // Collinear inputs may have unequal approximate lengths. + assert!(builder.build(unit(3, 4), unit(3000, 4000), turn).is_empty()); +} + +#[test] +fn gaps_around_integer_multiples_stay_within_limit() { + for precision in [4, 5, 6] { + for max_step in [ + ArcOptions::MIN_STEP, + Angle::from_bits(1 << 26), + ArcOptions::MAX_STEP, + ] { + let mut builder = ArcBuilder::::new(ArcOptions { + max_step, + rotation_precision: precision, + }); + let step = max_step.bits() as f64 * TAU / 4294967296.0; + for start in [0.0_f64, 0.137, 1.91, 4.21] { + for multiple in [1.0, 2.0, 3.0, 7.0, 31.0, 1000.0] { + for delta in [-1e-7, 0.0, 1e-7] { + let sweep = multiple * step + delta; + if sweep >= TAU { + continue; + } + let make = |a: f64| { + unit::((a.cos() * 1e8).round() as i32, (a.sin() * 1e8).round() as i32) + }; + for direction in [ArcDirection::Clockwise, ArcDirection::Counterclockwise] { + let sign = if direction == ArcDirection::Clockwise { + -1.0 + } else { + 1.0 + }; + check_arc(&mut builder, make(start), make(start + sign * sweep), direction); + } + } + } + } + } + } +} + +#[test] +fn long_arcs_accumulate_less_than_one_coordinate_unit_of_rotation_error() { + fn check() { + let scale = UnitIntVector::::DENOMINATOR.to_f64(); + for precision in [4, 5, 6] { + let mut builder = ArcBuilder::::new(ArcOptions { + max_step: ArcOptions::MIN_STEP, + rotation_precision: precision, + }); + for direction in [ArcDirection::Clockwise, ArcDirection::Counterclockwise] { + let from = unit::(3, 4); + let end = unit::(3001, 4000); + let to = if direction == ArcDirection::Clockwise { + unit::(3000, 4001) + } else { + end + }; + let points = builder.build(from, to, direction); + assert!(points.len() > 1000); + // Independently measure the first applied rotation, then compare + // every output with its ideal multiple. Includes contraction, + // coefficient error and application rounding, not initial normalization. + let step = angle(points[0]) - angle(from); + let length = (from.x().to_f64().hypot(from.y().to_f64())) / scale; + for (i, v) in points.iter().enumerate() { + let expected_angle = angle(from) + (i + 1) as f64 * step; + let dx = v.x().to_f64() / scale - length * expected_angle.cos(); + let dy = v.y().to_f64() / scale - length * expected_angle.sin(); + for radius in [1024.0, 4096.0, 65536.0] { + assert!( + dx.hypot(dy) * radius < 1.0, + "accumulated error exceeds one unit for {} bits", + I::BITS + ); + } + } + } + } + } + check::(); + check::(); +} diff --git a/iOverlay/tests/int_mesh_tests.rs b/iOverlay/tests/int_mesh_tests.rs new file mode 100644 index 00000000..414d5550 --- /dev/null +++ b/iOverlay/tests/int_mesh_tests.rs @@ -0,0 +1,285 @@ +use i_float::int::{angle::Angle, point::IntPoint}; +use i_overlay::mesh::int::{ + arc::ArcOptions, + outline::offset::IntOutlineOffset, + stroke::offset::IntStrokeOffset, + style::{IntLineCap, IntLineJoin, IntOutlineStyle, IntStrokeStyle}, + variable_stroke::{ + IntStrokeVertex, IntVariableStrokeSource, IntVariableStrokeStyle, offset::IntVariableStrokeOffset, + }, +}; +use i_shape::{flat::buffer::FlatContoursBuffer, int::area::Area}; + +macro_rules! engines { + ($name:ident,$int:ty) => { + #[test] + fn $name() { + let path = [IntPoint::<$int>::new(-4096, 0), IntPoint::new(4096, 0)]; + let mut style = IntStrokeStyle::new(2048); + let butt = path.stroke(&style, false).unwrap(); + assert_eq!(butt.len(), 1); + assert_eq!(butt.area() as i128, 8192 * 2048); + style.start_cap = IntLineCap::Square; + style.end_cap = IntLineCap::Square; + assert_eq!( + path.stroke(&style, false).unwrap().area() as i128, + 10240 * 2048 + ); + style.start_cap = IntLineCap::Round(ArcOptions::default()); + style.end_cap = style.start_cap.clone(); + let round = path.stroke(&style, false).unwrap(); + let capsule_area = round.area() as i128; + assert!((8192 * 2048 + 2800000..8192 * 2048 + 3400000).contains(&capsule_area)); + let variable = path.map(|p| IntStrokeVertex::new(p, 2048)); + let variable_style = IntVariableStrokeStyle::new().arc(ArcOptions::default()); + assert_eq!(variable.variable_stroke(variable_style).unwrap(), round); + let tapered = [ + IntStrokeVertex::new(path[0], 1024), + IntStrokeVertex::new(path[1], 3072), + ]; + assert_eq!(tapered.variable_stroke(variable_style).unwrap().len(), 1); + let corner = [path[0], path[1], IntPoint::new(4096, 4096)]; + for join in [ + IntLineJoin::Bevel, + IntLineJoin::Miter(Angle::from_bits(1 << 28)), + IntLineJoin::Round(ArcOptions::default()), + ] { + style.join = join; + let shapes = corner.stroke(&style, false).unwrap(); + assert_eq!(shapes.len(), 1); + assert!(shapes.area() as i128 > 12000 * 2048); + } + } + }; +} +engines!(mesh_i16, i16); +engines!(mesh_i32, i32); +engines!(mesh_i64, i64); + +#[test] +fn integer_strokes_preserve_precision_above_f64_exact_integers() { + let shift = (1i64 << 57) + 123; + let base = [ + IntPoint::new(-4096i64, 0), + IntPoint::new(4096, 0), + IntPoint::new(2048, 8192), + ]; + let translated = base.map(|p| IntPoint::new(p.x + shift, p.y - shift)); + let undo = |mut shapes: Vec>>>| { + for p in shapes.iter_mut().flatten().flatten() { + p.x -= shift; + p.y += shift; + } + shapes + }; + for join in [ + IntLineJoin::Bevel, + IntLineJoin::Miter(Angle::from_bits(1 << 28)), + IntLineJoin::Round(ArcOptions::default()), + ] { + let style = IntStrokeStyle::new(2048) + .line_join(join) + .start_cap(IntLineCap::Round(ArcOptions::default())) + .end_cap(IntLineCap::Round(ArcOptions::default())); + assert_eq!( + base.stroke(&style, false).unwrap(), + undo(translated.stroke(&style, false).unwrap()) + ); + let outline = IntOutlineStyle::new(1024).line_join(join); + assert_eq!( + base.outline(&outline).unwrap(), + undo(translated.outline(&outline).unwrap()) + ); + } + let variable = base + .into_iter() + .zip([1024, 4096, 2048]) + .map(|(p, w)| IntStrokeVertex::new(p, w)) + .collect::>(); + let shifted = translated + .into_iter() + .zip([1024, 4096, 2048]) + .map(|(p, w)| IntStrokeVertex::new(p, w)) + .collect::>(); + assert_eq!( + variable.variable_stroke(Default::default()).unwrap(), + undo(shifted.variable_stroke(Default::default()).unwrap()) + ); +} + +#[test] +fn flat_stroke_outputs_replace_previous_geometry() { + let path = [IntPoint::new(0, 0), IntPoint::new(8192, 0)]; + let style = IntStrokeStyle::new(2048); + let mut output = FlatContoursBuffer::default(); + let mut expected = FlatContoursBuffer::default(); + expected.set_with_shapes(&path.stroke(&style, false).unwrap()); + path.stroke_into(&style, false, &mut output).unwrap(); + assert_eq!(output, expected); + let empty: Vec = vec![]; + empty.stroke_into(&style, false, &mut output).unwrap(); + assert!(output.points.is_empty() && output.ranges.is_empty()); + let path = path.map(|p| IntStrokeVertex::new(p, 2048)); + expected.set_with_shapes(&path.variable_stroke(Default::default()).unwrap()); + path.variable_stroke_into(Default::default(), &mut output) + .unwrap(); + assert_eq!(output, expected); + let empty: Vec = vec![]; + empty + .variable_stroke_into(Default::default(), &mut output) + .unwrap(); + assert!(output.points.is_empty() && output.ranges.is_empty()); +} + +#[test] +fn validate_expanded_stroke_bounds_without_building() { + let edge = (1i32 << 30) - 512; + let path = [IntPoint::new(edge, 0), IntPoint::new(edge, 8192)]; + assert!(path.validate_stroke(&IntStrokeStyle::new(512)).is_ok()); + assert!(path.validate_stroke(&IntStrokeStyle::new(2048)).is_err()); + assert!( + path.map(|p| IntStrokeVertex::new(p, 2048)) + .validate_variable_stroke() + .is_err() + ); + assert!( + path.map(|p| IntStrokeVertex::new(p, 512)) + .validate_variable_stroke() + .is_ok() + ); +} + +#[test] +fn variable_resources_and_remaining_iterator_count() { + let path = vec![ + IntStrokeVertex::new(IntPoint::new(0, 0), 2048), + IntStrokeVertex::new(IntPoint::new(8192, 0), 4096), + ]; + let expected = path.variable_stroke(Default::default()).unwrap(); + assert_eq!( + path.as_slice().variable_stroke(Default::default()).unwrap(), + expected + ); + let paths = vec![path.clone(), Vec::new()]; + assert_eq!(paths.variable_stroke(Default::default()).unwrap(), expected); + let mut iter = path.iter_variable_paths(); + assert!(iter.next().is_some()); + assert_eq!(iter.count(), 0); + let mut iter = paths.iter_variable_paths(); + assert!(iter.next().is_some()); + assert_eq!(iter.count(), 1); +} + +#[test] +fn i64_large_spans_use_wide_products_for_contacts_and_miters() { + let unit = 1i64 << 56; + let path = [ + IntPoint::new(-16 * unit, 0), + IntPoint::new(0, 0), + IntPoint::new(15 * unit, unit), + ]; + let style = IntStrokeStyle::new(2 * unit).line_join(IntLineJoin::Miter(Angle::from_bits(1 << 28))); + assert_eq!(path.stroke(&style, false).unwrap().len(), 1); + let variable = path + .into_iter() + .zip([unit, 8 * unit, 2 * unit]) + .map(|(p, w)| IntStrokeVertex::new(p, w)) + .collect::>(); + assert_eq!(variable.variable_stroke(Default::default()).unwrap().len(), 1); +} + +#[test] +fn float_fixed_scale_matches_direct_integer_geometry() { + use i_overlay::mesh::float::{ + outline::offset::OutlineOffset, + stroke::offset::StrokeOffset, + style::{LineCap, LineJoin, OutlineStyle, StrokeStyle}, + variable_stroke::{StrokeVertex, VariableStrokeStyle, offset::VariableStrokeOffset}, + }; + let int_path = [ + IntPoint::new(-4096, -4096), + IntPoint::new(4096, -4096), + IntPoint::new(4096, 4096), + IntPoint::new(-4096, 4096), + ]; + let float_path = int_path.map(|p| [p.x as f64, p.y as f64]); + let points = |shapes: Vec>>| { + let mut pts = shapes + .into_iter() + .flatten() + .flatten() + .map(|p| (p.x, p.y)) + .collect::>(); + pts.sort_unstable(); + pts + }; + let float_points = |shapes: Vec>>| { + let mut pts = shapes + .into_iter() + .flatten() + .flatten() + .map(|p| { + assert_eq!(p[0], p[0].round()); + assert_eq!(p[1], p[1].round()); + (p[0] as i32, p[1] as i32) + }) + .collect::>(); + pts.sort_unstable(); + pts + }; + for (int_join, float_join) in [ + (IntLineJoin::Bevel, LineJoin::Bevel), + ( + IntLineJoin::Miter(Angle::from_bits(1 << 29)), + LineJoin::Miter(core::f64::consts::FRAC_PI_4), + ), + ( + IntLineJoin::Round(ArcOptions::default()), + LineJoin::Round(core::f64::consts::FRAC_PI_4), + ), + ] { + let int_style = IntOutlineStyle::new(1024).line_join(int_join); + let float_style = OutlineStyle::new(1024.0).line_join(float_join.clone()); + assert_eq!( + points(int_path.outline(&int_style).unwrap()), + float_points(float_path.outline_fixed_scale(&float_style, 1.0).unwrap()) + ); + let int_style = IntStrokeStyle::new(2048) + .line_join(int_join) + .start_cap(IntLineCap::Round(ArcOptions::default())) + .end_cap(IntLineCap::Square); + let float_style = StrokeStyle::new(2048.0) + .line_join(float_join) + .start_cap(LineCap::Round(core::f64::consts::FRAC_PI_4)) + .end_cap(LineCap::Square); + assert_eq!( + points(int_path.stroke(&int_style, false).unwrap()), + float_points(float_path.stroke_fixed_scale(float_style, false, 1.0).unwrap()) + ); + } + let int_variable = int_path + .into_iter() + .zip([1024, 4096, 2048, 1024]) + .map(|(p, w)| IntStrokeVertex::new(p, w)) + .collect::>(); + let float_variable = float_path + .into_iter() + .zip([1024.0, 4096.0, 2048.0, 1024.0]) + .map(|(p, w)| StrokeVertex::new(p, w)) + .collect::>(); + assert_eq!( + points( + int_variable + .variable_stroke(IntVariableStrokeStyle::new().arc(ArcOptions::default())) + .unwrap() + ), + float_points( + float_variable + .variable_stroke_fixed_scale( + VariableStrokeStyle::new().round_angle(core::f64::consts::FRAC_PI_4), + 1.0 + ) + .unwrap() + ) + ); +} diff --git a/iOverlay/tests/int_outline_bounds_tests.rs b/iOverlay/tests/int_outline_bounds_tests.rs new file mode 100644 index 00000000..cdd00a8d --- /dev/null +++ b/iOverlay/tests/int_outline_bounds_tests.rs @@ -0,0 +1,121 @@ +use core::cell::Cell; +use i_float::int::point::IntPoint; +use i_float::int::rect::IntRect; +use i_overlay::mesh::int::outline::offset::{IntOutlineError, IntOutlineOffset}; +use i_overlay::mesh::int::style::{IntLineJoin, IntOutlineStyle}; +use i_shape::source::int::resource::IntShapeResource; + +macro_rules! check_bounds { + ($name:ident, $int:ty) => { + #[test] + fn $name() { + let limit: $int = 1 << (<$int>::BITS - 2); + let radius: $int = 1024; + let lo = -limit + radius + 1; + let hi = limit - radius - 1; + let source = |rect: IntRect<$int>| { + [ + IntPoint::new(rect.min_x, rect.min_y), + IntPoint::new(rect.max_x, rect.min_y), + IntPoint::new(rect.max_x, rect.max_y), + IntPoint::new(rect.min_x, rect.max_y), + ] + }; + let valid = source(IntRect::new(lo, hi, lo, hi)); + for style in [ + IntOutlineStyle::new(radius), + IntOutlineStyle::new(-radius), + IntOutlineStyle::new(0).outer_offset(radius), + IntOutlineStyle::new(0).inner_offset(-radius), + ] { + assert_eq!(valid.validate_outline(&style), Ok(())); + for rect in [ + IntRect::new(lo - 1, hi, lo, hi), + IntRect::new(lo, hi + 1, lo, hi), + IntRect::new(lo, hi, lo - 1, hi), + IntRect::new(lo, hi, lo, hi + 1), + ] { + assert_eq!( + source(rect).validate_outline(&style), + Err(IntOutlineError::CoordinateOutOfRange) + ); + } + } + // Full storage-range inputs and offsets must reject without wrapping, + // including the magnitude of MIN, which cannot be represented in I. + for coordinate in [<$int>::MIN, -limit, limit, <$int>::MAX] { + let path = [IntPoint::new(coordinate, 0)]; + assert_eq!( + path.validate_outline(&IntOutlineStyle::new(0)), + Err(IntOutlineError::CoordinateOutOfRange) + ); + assert_eq!( + path.validate_outline(&IntOutlineStyle::new(<$int>::MIN)), + Err(IntOutlineError::CoordinateOutOfRange) + ); + } + for offset in [<$int>::MIN, <$int>::MAX] { + assert_eq!( + [IntPoint::<$int>::ZERO].validate_outline(&IntOutlineStyle::new(offset)), + Err(IntOutlineError::CoordinateOutOfRange) + ); + } + } + }; +} + +check_bounds!(expanded_bounds_i16, i16); +check_bounds!(expanded_bounds_i32, i32); +check_bounds!(expanded_bounds_i64, i64); + +#[test] +fn empty_input_needs_no_coordinate_space_for_any_join() { + let empty: Vec = vec![]; + assert_eq!(empty.validate_outline(&IntOutlineStyle::new(i32::MIN)), Ok(())); + for join in [ + IntLineJoin::Miter(i_float::int::angle::Angle::from_bits(1 << 26)), + IntLineJoin::Round(Default::default()), + ] { + assert_eq!( + empty.validate_outline(&IntOutlineStyle::new(0).line_join(join)), + Ok(()) + ); + } +} + +struct CountedResource<'p> { + path: &'p [IntPoint], + traversals: Cell, +} + +impl IntShapeResource for CountedResource<'_> { + type ResourceIter<'a> + = core::iter::Once<&'a [IntPoint]> + where + Self: 'a; + + fn iter_paths(&self) -> Self::ResourceIter<'_> { + self.traversals.set(self.traversals.get() + 1); + core::iter::once(self.path) + } +} + +#[test] +fn construction_does_not_repeat_the_bounds_pass() { + let path = [ + IntPoint::new(0, 0), + IntPoint::new(8192, 0), + IntPoint::new(8192, 8192), + IntPoint::new(0, 8192), + ]; + let source = CountedResource { + path: &path, + traversals: Cell::new(0), + }; + let style = IntOutlineStyle::new(1024); + assert_eq!(source.validate_outline(&style), Ok(())); + assert_eq!(source.traversals.get(), 1); + source.traversals.set(0); + assert_eq!(source.outline(&style).unwrap().len(), 1); + assert_eq!(source.traversals.get(), 1); +} diff --git a/iOverlay/tests/int_outline_tests.rs b/iOverlay/tests/int_outline_tests.rs new file mode 100644 index 00000000..4a897f75 --- /dev/null +++ b/iOverlay/tests/int_outline_tests.rs @@ -0,0 +1,288 @@ +use i_float::int::point::IntPoint; +use i_overlay::core::overlay::{ContourDirection, IntOverlayOptions}; +use i_overlay::mesh::int::outline::offset::{IntOutlineError, IntOutlineOffset}; +use i_overlay::mesh::int::style::{IntLineJoin, IntOutlineStyle}; +use i_shape::flat::buffer::{FlatContoursBuffer, FlatShapesBuffer}; +use i_shape::int::area::Area; +use i_shape::int::shape::IntShapes; + +fn rectangle(x0: i32, y0: i32, x1: i32, y1: i32) -> Vec { + vec![ + IntPoint::new(x0, y0), + IntPoint::new(x1, y0), + IntPoint::new(x1, y1), + IntPoint::new(x0, y1), + ] +} + +macro_rules! check_engine { + ($name:ident, $int:ty) => { + #[test] + fn $name() { + let path = [ + IntPoint::new(0 as $int, 0), + IntPoint::new(8192, 0), + IntPoint::new(8192, 8192), + IntPoint::new(0, 8192), + ]; + for offset in [-1024, 0, 1024] { + let result = path.outline(&IntOutlineStyle::new(offset)).unwrap(); + assert_eq!(result.len(), 1); + let expected: i64 = if offset > 0 { + 8192 * 8192 + 4 * 8192 * 1024 + 2 * 1024 * 1024 + } else if offset < 0 { + 6144 * 6144 + } else { + 8192 * 8192 + }; + assert_eq!(result.area() as i64, expected); + assert_eq!(result[0][0].len(), if offset > 0 { 8 } else { 4 }); + } + assert!( + path.outline(&IntOutlineStyle::new(-4096)) + .unwrap() + .is_empty() + ); + } + }; +} + +check_engine!(bevel_i16, i16); +check_engine!(bevel_i32, i32); +check_engine!(bevel_i64, i64); + +#[test] +fn offsets_in_the_expected_i32_width_range() { + for radius in [1 << 10, 1 << 12, 1 << 16] { + let side = radius * 16; + let result = rectangle(0, 0, side, side) + .outline(&IntOutlineStyle::new(radius)) + .unwrap(); + let (s, r) = (i64::from(side), i64::from(radius)); + assert_eq!(result.area(), s * s + 4 * s * r + 2 * r * r); + } +} + +#[test] +fn holes_use_their_own_offset_and_collapse_without_affecting_other_holes() { + let outer = rectangle(0, 0, 32768, 32768); + let mut hole = rectangle(8192, 8192, 16384, 16384); + hole.reverse(); + let mut small_hole = rectangle(2048, 2048, 4096, 4096); + small_hole.reverse(); + let style = IntOutlineStyle::new(2048).inner_offset(1024); + let expected = vec![outer.clone(), hole.clone()].outline(&style).unwrap(); + assert_eq!(expected[0].len(), 2); + assert_eq!( + expected.area(), + 32768_i64.pow(2) + 4 * 32768 * 2048 + 2 * 2048_i64.pow(2) - 6144_i64.pow(2) + ); + for source in [ + vec![outer.clone(), hole.clone(), small_hole.clone()], + vec![small_hole, outer, hole], + ] { + assert_eq!(source.outline(&style).unwrap(), expected); + } +} + +#[test] +fn area_filter_runs_after_union() { + // Each overlapping input rectangle has area 8*1024²; their union is 12*1024². + let source = [rectangle(0, 0, 4096, 2048), rectangle(2048, 0, 6144, 2048)]; + let style = IntOutlineStyle::new(0); + let expected = source.outline(&style).unwrap(); + assert_eq!(expected.area(), 12 * 1024 * 1024); + let mut options = IntOverlayOptions { + min_output_area: 10 * 1024 * 1024, + ..Default::default() + }; + assert_eq!(source.outline_custom(&style, options).unwrap(), expected); + options.min_output_area = 13 * 1024 * 1024; + assert!(source.outline_custom(&style, options).unwrap().is_empty()); +} + +#[test] +fn resource_forms_and_flat_output_agree() { + let path = rectangle(0, 0, 8192, 8192); + let shape = vec![path.clone()]; + let shapes = vec![shape.clone()]; + let mut contours = FlatContoursBuffer::default(); + contours.set_with_shapes(&shapes); + let flat_shapes = FlatShapesBuffer { + points: path.clone(), + contour_ranges: core::iter::once(0..4).collect(), + shape_ranges: core::iter::once(0..1).collect(), + }; + let style = IntOutlineStyle::new(1024); + let expected = path.outline(&style).unwrap(); + assert_eq!(path.as_slice().outline(&style).unwrap(), expected); + let borrowed_paths = [path.as_slice()]; + assert_eq!(borrowed_paths.outline(&style).unwrap(), expected); + assert_eq!(shape.outline(&style).unwrap(), expected); + assert_eq!(shapes.outline(&style).unwrap(), expected); + assert_eq!(contours.outline(&style).unwrap(), expected); + assert_eq!(flat_shapes.outline(&style).unwrap(), expected); + let mut output = FlatContoursBuffer::default(); + shapes.outline_into(&style, &mut output).unwrap(); + let mut expected_buffer = FlatContoursBuffer::default(); + expected_buffer.set_with_shapes(&expected); + assert_eq!(output, expected_buffer); + // Empty success must replace the previous output. + let empty: Vec = vec![]; + empty.outline_into(&style, &mut output).unwrap(); + assert!(output.points.is_empty() && output.ranges.is_empty()); +} + +#[test] +fn zero_offset_cleans_repeated_points_and_preserves_holes() { + let mut outer = rectangle(0, 0, 8192, 8192); + outer.insert(1, outer[0]); + outer.push(outer[0]); + let mut hole = rectangle(2048, 2048, 4096, 4096); + hole.reverse(); + let source = vec![outer, hole]; + let result = source.outline(&IntOutlineStyle::new(0)).unwrap(); + assert_eq!(result[0].len(), 2); + assert_eq!(result.area(), 8192_i64.pow(2) - 2048_i64.pow(2)); + assert!(result[0].iter().all(|path| path.len() == 4)); + let empty = vec![ + vec![], + vec![IntPoint::new(0, 0); 4], + vec![IntPoint::new(0, 0), IntPoint::new(1, 1), IntPoint::new(2, 2)], + ]; + assert!(empty.outline(&IntOutlineStyle::new(1024)).unwrap().is_empty()); +} + +#[test] +fn output_direction_is_configurable() { + let path = rectangle(0, 0, 8192, 8192); + let options = IntOverlayOptions { + output_direction: ContourDirection::Clockwise, + ..Default::default() + }; + let result = path.outline_custom(&IntOutlineStyle::new(1024), options).unwrap(); + assert!(result.area() < 0); +} + +#[test] +fn all_joins_build_into_reused_output() { + let path = rectangle(0, 0, 8192, 8192); + let mut output = FlatContoursBuffer::default(); + output.set_with_contour(&path); + for join in [ + IntLineJoin::Miter(i_float::int::angle::Angle::from_bits(1 << 26)), + IntLineJoin::Round(Default::default()), + ] { + let result = path.outline_into(&IntOutlineStyle::new(1024).line_join(join), &mut output); + assert_eq!(result, Ok(())); + assert!(!output.points.is_empty()); + assert!(output.points.iter().any(|p| p.x < 0 || p.y < 0)); + } +} + +#[test] +fn validation_rejects_out_of_range_operations_without_building() { + for (path, offset) in [ + (rectangle(0, 0, i32::MAX, 8192), 0), + (rectangle((1 << 30) - 8192, 0, (1 << 30) - 1, 8192), 1024), + (rectangle(0, 0, 8192, 8192), i32::MIN), + ] { + assert_eq!( + path.validate_outline(&IntOutlineStyle::new(offset)), + Err(IntOutlineError::CoordinateOutOfRange) + ); + } +} + +#[test] +fn i64_translation_above_float_precision_preserves_geometry() { + let path = [ + IntPoint::new(0_i64, 0), + IntPoint::new(8192, 0), + IntPoint::new(8192, 8192), + IntPoint::new(0, 8192), + ]; + let style = IntOutlineStyle::new(1024); + let expected = path.outline(&style).unwrap(); + let shift = (1_i64 << 60) + 17; + let translated = path.map(|p| IntPoint::new(p.x + shift, p.y - shift)); + let mut actual = translated.outline(&style).unwrap(); + for p in actual.iter_mut().flatten().flatten() { + p.x -= shift; + p.y += shift; + } + assert_eq!(actual, expected); +} + +fn canonical(mut shapes: IntShapes) -> IntShapes { + for shape in &mut shapes { + for path in shape.iter_mut() { + let first = path.iter().enumerate().min_by_key(|(_, p)| **p).unwrap().0; + path.rotate_left(first); + } + shape[1..].sort(); + } + shapes.sort(); + shapes +} + +#[test] +fn diagonal_bevel_uses_scaled_perpendiculars() { + // Every edge is a 3-4-5 triangle: the outward displacement is (±3072, ±4096). + let path = [ + IntPoint::new(0, -12288), + IntPoint::new(16384, 0), + IntPoint::new(0, 12288), + IntPoint::new(-16384, 0), + ]; + let expected = vec![vec![vec![ + IntPoint::new(3072, -16384), + IntPoint::new(19456, -4096), + IntPoint::new(19456, 4096), + IntPoint::new(3072, 16384), + IntPoint::new(-3072, 16384), + IntPoint::new(-19456, 4096), + IntPoint::new(-19456, -4096), + IntPoint::new(-3072, -16384), + ]]]; + let result = path.outline(&IntOutlineStyle::new(5120)).unwrap(); + assert_eq!(canonical(result), canonical(expected)); +} + +#[test] +fn bevel_matches_existing_float_pipeline_on_exact_axis_normals() { + use i_overlay::float::overlay::OverlayOptions; + use i_overlay::mesh::float::outline::offset::OutlineOffset; + use i_overlay::mesh::float::style::OutlineStyle; + let path = vec![ + IntPoint::new(0, 0), + IntPoint::new(8192, 0), + IntPoint::new(8192, 4096), + IntPoint::new(4096, 4096), + IntPoint::new(4096, 8192), + IntPoint::new(0, 8192), + ]; + let float: Vec<_> = path.iter().map(|p| [p.x as f64, p.y as f64]).collect(); + for offset in [-1024, 0, 1024] { + let actual = path.outline(&IntOutlineStyle::new(offset)).unwrap(); + let mut options = OverlayOptions::default(); + options.clean_result = false; + let expected = float + .outline_custom_fixed_scale(&OutlineStyle::new(offset as f64), options, 1.0) + .unwrap(); + let expected: IntShapes = expected + .iter() + .map(|shape| { + shape + .iter() + .map(|path| { + path.iter() + .map(|p| IntPoint::new(p[0].round() as i32, p[1].round() as i32)) + .collect() + }) + .collect() + }) + .collect(); + assert_eq!(canonical(actual), canonical(expected)); + } +} diff --git a/iOverlay/tests/int_resource_tests.rs b/iOverlay/tests/int_resource_tests.rs new file mode 100644 index 00000000..74b034ff --- /dev/null +++ b/iOverlay/tests/int_resource_tests.rs @@ -0,0 +1,237 @@ +// These tests intentionally compare the legacy constructors with the resource API. +#![allow(deprecated)] + +use i_overlay::core::fill_rule::FillRule; +use i_overlay::core::integer::OverlayInt; +use i_overlay::core::overlay::{ContourDirection, IntOverlayOptions, Overlay, ShapeType}; +use i_overlay::core::overlay_rule::OverlayRule; +use i_overlay::core::relate::{IntRelate, PredicateOverlay}; +use i_overlay::core::simplify::Simplify; +use i_overlay::core::single::SingleIntOverlay; +use i_overlay::core::solver::Solver; +use i_overlay::i_float::int::point::IntPoint; +use i_overlay::i_shape::flat::buffer::{FlatContoursBuffer, FlatShapesBuffer}; +use i_overlay::i_shape::int::shape::IntShapes; +use i_overlay::i_shape::source::int::resource::IntShapeResource; +use i_overlay::string::clip::{ClipRule, IntClip}; +use i_overlay::string::overlay::StringOverlay; +use i_overlay::string::slice::IntSlice; + +fn rect(x0: u32, y0: u32, x1: u32, y1: u32) -> [IntPoint; 4] { + [(x0, y0), (x1, y0), (x1, y1), (x0, y1)].map(|(x, y)| IntPoint::new(I::from_u32(x), I::from_u32(y))) +} + +// A user-defined resource must work without collecting its paths into owned containers. +struct BorrowedContour<'a, I: OverlayInt>(&'a [IntPoint]); + +impl IntShapeResource for BorrowedContour<'_, I> { + type ResourceIter<'a> + = core::iter::Once<&'a [IntPoint]> + where + Self: 'a, + I: 'a; + + fn iter_paths(&self) -> Self::ResourceIter<'_> { + core::iter::once(self.0) + } +} + +fn check_engines() { + let subj = rect::(0, 0, 10, 10); + let clip = rect::(5, 0, 15, 10); + let contours = vec![subj.to_vec()]; + let shapes = vec![contours.clone()]; + let borrowed = [subj.as_slice()]; + let custom = BorrowedContour(subj.as_slice()); + let mut flat_contours = FlatContoursBuffer::default(); + flat_contours.set_with_shape(&contours); + let mut flat_shapes = FlatShapesBuffer::default(); + flat_shapes.set_with_shapes(&[vec![clip.to_vec()]]); + for rule in [ + OverlayRule::Union, + OverlayRule::Intersect, + OverlayRule::Difference, + OverlayRule::Xor, + ] { + let expected = Overlay::with_contour(&subj, &clip).overlay(rule, FillRule::NonZero); + assert_eq!( + Overlay::from_subj_and_clip(&subj, &flat_shapes).overlay(rule, FillRule::NonZero), + expected + ); + assert_eq!( + Overlay::from_subj_and_clip(&contours[..], &clip[..]).overlay(rule, FillRule::NonZero), + expected + ); + assert_eq!( + Overlay::from_subj_and_clip(&shapes, &flat_shapes).overlay(rule, FillRule::NonZero), + expected + ); + assert_eq!( + Overlay::from_subj_and_clip(&borrowed[..], &clip).overlay(rule, FillRule::NonZero), + expected + ); + assert_eq!( + Overlay::from_subj_and_clip(&flat_contours, &flat_shapes).overlay(rule, FillRule::NonZero), + expected + ); + assert_eq!(custom.overlay(&clip[..], rule, FillRule::NonZero), expected); + assert_eq!(subj[..].overlay(&flat_shapes, rule, FillRule::NonZero), expected); + } + assert!(custom.intersects(&flat_shapes)); + assert!(flat_contours.interiors_intersect(&clip[..])); + assert!(!subj.touches(&clip)); + assert!(!subj.point_intersects(&clip)); + assert!(!subj.within(&clip)); + assert!(!subj.disjoint(&clip)); + assert!(subj.covers(&rect::(2, 2, 8, 8))); + let touch = rect::(10, 10, 20, 20); + assert!(subj.touches(&touch)); + assert!(subj.point_intersects(&touch)); + assert!(subj.disjoint(&rect::(20, 20, 30, 30))); + + let expected = subj[..].simplify(FillRule::NonZero, Default::default()); + assert_eq!(custom.simplify(FillRule::NonZero, Default::default()), expected); + assert_eq!( + flat_contours.simplify(FillRule::NonZero, Default::default()), + expected + ); + assert_eq!(shapes.simplify(FillRule::NonZero, Default::default()), expected); +} + +#[test] +fn resource_storage_and_integer_engines() { + check_engines::(); + check_engines::(); + check_engines::(); +} + +#[test] +fn overlay_reinit_retains_options_and_replaces_both_operands() { + let square = rect::(0, 0, 10, 10); + let other = rect::(5, 0, 15, 10); + let options = IntOverlayOptions { + output_direction: ContourDirection::Clockwise, + ..Default::default() + }; + let mut overlay = Overlay::from_subj_and_clip_custom(&square, &other, options, Solver::LIST); + overlay.overlay(OverlayRule::Union, FillRule::NonZero); + overlay.reinit_with_subj(&square[..]); + let expected = Overlay::from_subj_custom(&square, options, Solver::LIST) + .overlay(OverlayRule::Subject, FillRule::NonZero); + assert_eq!(overlay.overlay(OverlayRule::Subject, FillRule::NonZero), expected); + assert_eq!(overlay.options.output_direction, ContourDirection::Clockwise); + assert_eq!(overlay.solver.strategy, Solver::LIST.strategy); + overlay.reinit_with_subj_and_clip(&square, &square[..]); + assert!( + overlay + .overlay(OverlayRule::Difference, FillRule::NonZero) + .is_empty() + ); + let empty: &[IntPoint] = &[]; + overlay.reinit_with_subj(empty); + assert!( + overlay + .overlay(OverlayRule::Subject, FillRule::NonZero) + .is_empty() + ); + overlay.add_source(&square, ShapeType::Subject); + assert_eq!(overlay.overlay(OverlayRule::Subject, FillRule::NonZero), expected); +} + +#[test] +fn predicate_fill_rule_and_reinit() { + let square = rect::(0, 0, 10, 10); + let duplicate = [square.as_slice(), square.as_slice()]; + assert!(!PredicateOverlay::from_subj_and_clip(&duplicate, &square).interiors_intersect()); + let mut predicate = + PredicateOverlay::from_subj_and_clip_custom(&duplicate, &square, FillRule::NonZero, Solver::LIST); + assert!(predicate.interiors_intersect()); + predicate.reinit_with_subj_and_clip(&duplicate[..], &square[..]); + assert!(predicate.interiors_intersect()); + let empty: &[IntPoint] = &[]; + predicate.reinit_with_subj_and_clip(empty, &square); + assert!(!predicate.intersects()); + assert_eq!(predicate.solver.strategy, Solver::LIST.strategy); +} + +#[test] +fn string_resources_preserve_open_and_closed_semantics() { + let square = rect::(0, 0, 10, 10); + let path = [IntPoint::new(2, 2), IntPoint::new(8, 2), IntPoint::new(8, 8)]; + let rule = ClipRule { + invert: false, + boundary_included: true, + }; + let mut polygon = FlatShapesBuffer::default(); + polygon.set_with_contour(&square); + let mut strings = FlatContoursBuffer::default(); + strings.set_with_contour(&path); + let expected = square.clip_path(&path.to_vec(), FillRule::NonZero, rule); + assert_eq!(polygon.clip_source(&strings, FillRule::NonZero, rule), expected); + assert_eq!( + StringOverlay::from_shape_and_string(&polygon, &path[..]).clip_string_lines(FillRule::NonZero, rule), + expected + ); + let mut closed = StringOverlay::from_shape(&polygon); + closed.add_string_contour_source(&strings); + let mut legacy = StringOverlay::with_shape_contour(&square); + legacy.add_string_contour(&path); + let closed_result = closed.clip_string_lines(FillRule::NonZero, rule); + assert_eq!(closed_result, legacy.clip_string_lines(FillRule::NonZero, rule)); + assert_ne!(closed_result, expected); +} + +#[test] +fn slices_and_clips_work_on_borrowed_and_flat_resources() { + let square = rect::(0, 0, 10, 10); + let line = [IntPoint::new(-5, 5), IntPoint::new(15, 5)]; + let borrowed = [square.as_slice()]; + let mut flat = FlatContoursBuffer::default(); + flat.set_with_contour(&square); + let sliced = square.slice_by_line(line, FillRule::NonZero); + assert_eq!(sliced.len(), 2); + assert_eq!(borrowed[..].slice_by_source(&line, FillRule::NonZero), sliced); + assert_eq!(flat.slice_by_source(&line[..], FillRule::NonZero), sliced); + let rule = ClipRule { + invert: false, + boundary_included: false, + }; + assert_eq!( + flat.clip_source(&line[..], FillRule::NonZero, rule), + vec![vec![IntPoint::new(0, 5), IntPoint::new(10, 5)]] + ); +} + +#[test] +fn simplify_resource_handles_holes_empty_paths_and_area_filter() { + let outer = rect::(0, 0, 20, 20); + let mut hole = rect::(5, 5, 15, 15); + hole.reverse(); + let shape = vec![outer.to_vec(), hole.to_vec()]; + let mut flat = FlatShapesBuffer::default(); + flat.set_with_shape(&shape); + let expected = Overlay::with_contours(&shape, &[]).overlay(OverlayRule::Subject, FillRule::NonZero); + assert_eq!(expected[0].len(), 2); + assert_eq!(flat.simplify(FillRule::NonZero, Default::default()), expected); + let paths = [outer.as_slice(), &[], hole.as_slice()]; + assert_eq!( + paths[..].simplify(FillRule::NonZero, Default::default()), + expected + ); + let mut reusable = Overlay::from_subj(&outer); + let empty: IntShapes = vec![]; + assert!(reusable.simplify_source(&empty, FillRule::NonZero).is_empty()); + let degenerate = [IntPoint::new(0, 0), IntPoint::new(1, 1)]; + assert!( + degenerate + .simplify(FillRule::NonZero, Default::default()) + .is_empty() + ); + let options = IntOverlayOptions { + min_output_area: 1_000, + ..Default::default() + }; + assert!(outer.simplify(FillRule::NonZero, options).is_empty()); + flat.set_with_contour(&outer); + assert!(flat.simplify(FillRule::NonZero, options).is_empty()); +} diff --git a/iOverlay/tests/integer_grid_oracle_tests.rs b/iOverlay/tests/integer_grid_oracle_tests.rs new file mode 100644 index 00000000..57b9f8b4 --- /dev/null +++ b/iOverlay/tests/integer_grid_oracle_tests.rs @@ -0,0 +1,170 @@ +use i_float::int::point::IntPoint; +use i_overlay::core::fill_rule::FillRule; +use i_overlay::core::overlay::{ContourDirection, Overlay}; +use i_overlay::core::overlay_rule::OverlayRule; +use i_overlay::core::solver::Solver; + +#[derive(Debug)] +struct Rect { + x0: i32, + y0: i32, + x1: i32, + y1: i32, + sign: i32, +} + +impl Rect { + fn contour(&self) -> Vec { + let mut result = vec![ + IntPoint::new(2 * self.x0, 2 * self.y0), + IntPoint::new(2 * self.x1, 2 * self.y0), + IntPoint::new(2 * self.x1, 2 * self.y1), + IntPoint::new(2 * self.x0, 2 * self.y1), + ]; + if self.sign < 0 { + result.reverse(); + } + result + } +} + +fn fill(rects: &[Rect], x: i32, y: i32, rule: FillRule) -> bool { + let count: i32 = rects + .iter() + .filter(|r| r.x0 <= x && x < r.x1 && r.y0 <= y && y < r.y1) + .map(|r| r.sign) + .sum(); + match rule { + FillRule::EvenOdd => count % 2 != 0, + FillRule::NonZero => count != 0, + FillRule::Positive => count > 0, + FillRule::Negative => count < 0, + } +} + +fn contains(shape: &[Vec], x: i32, y: i32) -> bool { + let mut inside = false; + for path in shape { + let mut a = path[path.len() - 1]; + for &b in path { + assert!( + a.x == b.x || a.y == b.y, + "rectangle overlays must remain orthogonal" + ); + if (a.y > y) != (b.y > y) && a.x > x { + inside = !inside; + } + a = b; + } + } + inside +} + +#[test] +fn rectangle_overlays_match_independent_cell_winding() { + let mut state = 0x7114_58bb_aa16_5841_u64; + let mut next = || { + state = state.wrapping_mul(6364136223846793005).wrapping_add(1); + (state >> 32) as i32 & 0x7fff_ffff + }; + for case in 0..1000 { + let mut rects = |n| { + (0..n) + .map(|_| { + let x0 = next() % 8; + let y0 = next() % 8; + Rect { + x0, + y0, + x1: x0 + 1 + next() % (8 - x0), + y1: y0 + 1 + next() % (8 - y0), + sign: if next() % 2 == 0 { 1 } else { -1 }, + } + }) + .collect::>() + }; + let a = rects(1 + case % 7); + let b = rects(1 + case % 5); + let mut overlay = Overlay::from_subj_and_clip( + &a.iter().map(Rect::contour).collect::>(), + &b.iter().map(Rect::contour).collect::>(), + ); + overlay.solver = [Solver::LIST, Solver::TREE, Solver::FRAG][case % 3]; + overlay.options.ogc = case % 2 == 0; + let clockwise = case % 4 < 2; + overlay.options.output_direction = if clockwise { + ContourDirection::Clockwise + } else { + ContourDirection::CounterClockwise + }; + for fill_rule in [ + FillRule::EvenOdd, + FillRule::NonZero, + FillRule::Positive, + FillRule::Negative, + ] { + for rule in [ + OverlayRule::Union, + OverlayRule::Intersect, + OverlayRule::Difference, + OverlayRule::Xor, + ] { + let result = overlay.overlay(rule, fill_rule); + let mut cells = 0; + for x in 0..8 { + for y in 0..8 { + let sa = fill(&a, x, y, fill_rule); + let sb = fill(&b, x, y, fill_rule); + let expected = match rule { + OverlayRule::Union => sa || sb, + OverlayRule::Intersect => sa && sb, + OverlayRule::Difference => sa && !sb, + OverlayRule::Xor => sa != sb, + _ => unreachable!(), + }; + cells += i64::from(expected); + let covered = result + .iter() + .filter(|s| contains(s, 2 * x + 1, 2 * y + 1)) + .count(); + assert_eq!( + covered, + usize::from(expected), + "case={case}, cell=({x},{y}), fill={fill_rule:?}, rule={rule:?}, a={a:?}, b={b:?}" + ); + } + } + let mut double_area = 0_i64; + for shape in &result { + for (index, path) in shape.iter().enumerate() { + let area: i64 = path + .iter() + .zip(path.iter().cycle().skip(1)) + .map(|(a, b)| i64::from(a.x) * i64::from(b.y) - i64::from(a.y) * i64::from(b.x)) + .sum(); + assert_eq!( + area > 0, + (index == 0) != clockwise, + "case={case}, ogc={}, clockwise={clockwise}, fill={fill_rule:?}, rule={rule:?}, index={index}, path={path:?}, a={a:?}, b={b:?}", + overlay.options.ogc + ); + double_area += area; + if overlay.options.ogc { + let unique: std::collections::BTreeSet<_> = path.iter().collect(); + assert_eq!( + unique.len(), + path.len(), + "OGC contour repeats a vertex: case={case}, path={path:?}" + ); + } + } + } + assert_eq!( + double_area.abs(), + cells * 8, + "case={case}, fill={fill_rule:?}, rule={rule:?}" + ); + } + } + } +} diff --git a/iOverlay/tests/integer_invariance_tests.rs b/iOverlay/tests/integer_invariance_tests.rs new file mode 100644 index 00000000..f4a814ad --- /dev/null +++ b/iOverlay/tests/integer_invariance_tests.rs @@ -0,0 +1,98 @@ +use i_float::int::point::IntPoint; +use i_overlay::core::fill_rule::FillRule; +use i_overlay::core::overlay::{Overlay, ShapeType}; +use i_overlay::core::overlay_rule::OverlayRule; +use i_overlay::core::solver::Solver; + +type Shapes = Vec>>; + +fn canonical(mut shapes: Shapes) -> Shapes { + for shape in &mut shapes { + for path in shape.iter_mut() { + let start = (0..path.len()) + .min_by(|&a, &b| { + (0..path.len()) + .map(|i| path[(a + i) % path.len()]) + .cmp((0..path.len()).map(|i| path[(b + i) % path.len()])) + }) + .unwrap(); + path.rotate_left(start); + } + shape[1..].sort(); + } + shapes.sort(); + shapes +} + +#[test] +fn boolean_results_ignore_contour_start_direction_and_operand_order() { + let mut state = 0x7798_117b_13ac_7331_u64; + let mut next = || { + state = state.wrapping_mul(6364136223846793005).wrapping_add(1); + ((state >> 32) % 17) as i32 - 8 + }; + for case in 0..2000 { + let a: Vec<_> = (0..3 + case % 9).map(|_| IntPoint::new(next(), next())).collect(); + let b: Vec<_> = (0..3 + case % 7).map(|_| IntPoint::new(next(), next())).collect(); + let mut reversed_a = a.clone(); + let mut reversed_b = b.clone(); + reversed_a.reverse(); + reversed_b.reverse(); + reversed_a.rotate_left(case % a.len()); + reversed_b.rotate_left(case % b.len()); + for fill in [FillRule::EvenOdd, FillRule::NonZero] { + for rule in [OverlayRule::Intersect, OverlayRule::Union, OverlayRule::Xor] { + let expected = canonical(Overlay::from_subj_and_clip(&a, &b).overlay(rule, fill)); + let actual = + canonical(Overlay::from_subj_and_clip(&reversed_b, &reversed_a).overlay(rule, fill)); + assert_eq!( + actual, expected, + "case={case}, fill={fill:?}, rule={rule:?}, a={a:?}, b={b:?}" + ); + } + } + } +} + +#[test] +fn adding_rectangles_after_extraction_matches_a_fresh_overlay() { + let mut state = 0x43ba_2846_7311_9133_u64; + let mut next = || { + state = state.wrapping_mul(6364136223846793005).wrapping_add(1); + ((state >> 32) % 33) as i32 - 16 + }; + for case in 0..1000 { + // Keep intersections exact: sequential snapping can legitimately differ + // from splitting all arbitrary edges in a single pass. + let mut rectangle = || { + let x = next(); + let y = next(); + let w = next().abs() + 1; + let h = next().abs() + 1; + vec![ + IntPoint::new(x, y), + IntPoint::new(x + w, y), + IntPoint::new(x + w, y + h), + IntPoint::new(x, y + h), + ] + }; + let a = rectangle(); + let b = rectangle(); + let c = rectangle(); + for solver in [Solver::LIST, Solver::TREE, Solver::FRAG] { + let mut reused = Overlay::from_subj_and_clip(&a, &b); + reused.solver = solver; + reused.overlay(OverlayRule::Intersect, FillRule::EvenOdd); + reused.add_contour(&c, ShapeType::Subject); + let mut fresh = Overlay::from_subj_and_clip(&a, &b); + fresh.solver = solver; + fresh.add_contour(&c, ShapeType::Subject); + assert_eq!( + canonical(reused.overlay(OverlayRule::Union, FillRule::EvenOdd)), + canonical(fresh.overlay(OverlayRule::Union, FillRule::EvenOdd)), + "case={case}, solver={:?}, a={a:?}, b={b:?}, c={c:?}", + solver.strategy, + ); + } + } +} diff --git a/iOverlay/tests/integer_range_tests.rs b/iOverlay/tests/integer_range_tests.rs index 35922405..4511bc77 100644 --- a/iOverlay/tests/integer_range_tests.rs +++ b/iOverlay/tests/integer_range_tests.rs @@ -45,7 +45,7 @@ fn check + Into>( let clip: Vec<_> = clip.iter().map(|p| contour::(p)).collect(); let expected = canonical(expected); for solver in [Solver::LIST, Solver::TREE, Solver::FRAG, Solver::AUTO] { - let result = Overlay::::with_contours_custom(&subj, &clip, Default::default(), solver) + let result = Overlay::::from_subj_and_clip_custom(&subj, &clip, Default::default(), solver) .overlay(rule, FillRule::EvenOdd); // Also exercise the wide area accumulator at the maximum square size. for shape in &result { @@ -82,7 +82,7 @@ fn boundaries + Into>() { let hi = half - 1; let outer = square(lo, hi); check::( - &[outer.clone()], + core::slice::from_ref(&outer), &[], OverlayRule::Subject, vec![vec![outer.clone()]], @@ -91,17 +91,22 @@ fn boundaries + Into>() { // Keep unit-size features at both inclusive endpoints. for a in [lo, hi - 1] { let small = square(a, a + 1); - check::(&[small.clone()], &[], OverlayRule::Subject, vec![vec![small]]); + check::( + core::slice::from_ref(&small), + &[], + OverlayRule::Subject, + vec![vec![small.clone()]], + ); } let inner = square(-half / 2, half / 2); let mut hole = inner.clone(); hole.reverse(); check::( - &[outer.clone()], + core::slice::from_ref(&outer), &[inner], OverlayRule::Difference, - vec![vec![outer, hole]], + vec![vec![outer.clone(), hole]], ); // Issue #88's steep edge, stretched to the maximum supported span. @@ -185,13 +190,18 @@ fn i64_boundaries() { fn issue_88_with_a_wider_engine_or_rescaled_coordinates() { let triangle = vec![[0, 0], [129, -23169], [0, 9854]]; check::( - &[triangle.clone()], + core::slice::from_ref(&triangle), &[], OverlayRule::Subject, vec![vec![triangle.clone()]], ); let scaled: Contour = triangle.iter().map(|p| [p[0] / 2, p[1] / 2]).collect(); - check::(&[scaled.clone()], &[], OverlayRule::Subject, vec![vec![scaled]]); + check::( + core::slice::from_ref(&scaled), + &[], + OverlayRule::Subject, + vec![vec![scaled.clone()]], + ); } #[test] @@ -221,7 +231,7 @@ fn conservative_float_scale_keeps_rounded_i16_span_in_range() { use i_float::float::rect::FloatRect; let half_extent = 1.99999; - let rect = FloatRect::new(-half_extent, half_extent, -half_extent, half_extent); + let rect = FloatRect::new(-half_extent, half_extent, -half_extent, half_extent).unwrap(); let min = [-half_extent, -half_extent]; let max = [half_extent, half_extent]; @@ -252,7 +262,7 @@ fn explicit_float_coordinate_budget() { let limit = 1_i64 << (I::BITS - 3); // Power-of-two, non-power-of-two, and sub-unit bounds exercise scale rounding. for half_extent in [1.0, 1.5, 0.25] { - let rect = FloatRect::new(-half_extent, half_extent, -half_extent, half_extent); + let rect = FloatRect::new(-half_extent, half_extent, -half_extent, half_extent).unwrap(); let adapter = FloatPointAdapter::<[f64; 2], I>::with_coordinate_bits(rect, I::BITS - 3); let points = vec![ [-half_extent, -half_extent], @@ -321,7 +331,7 @@ fn spiral_vector_area_at_coordinate_limits() { ]); let input = vec![contour::(&points)]; for solver in [Solver::LIST, Solver::TREE, Solver::FRAG, Solver::AUTO] { - let mut overlay = Overlay::::with_contours_custom(&input, &[], Default::default(), solver); + let mut overlay = Overlay::::from_subj_custom(&input, Default::default(), solver); // Sum the nine rectangular runs of the corridor, subtracting their // corner overlaps. Check filtering at the exact area and one above. let area = I::MAX.to_wide() * I::Wide::from_u32(9) - I::Wide::from_u32(56); @@ -368,7 +378,7 @@ fn fragment_radius_at_coordinate_limits() { }, ..Solver::FRAG }; - let actual = Overlay::::with_contours_custom(&input, &[], Default::default(), solver) + let actual = Overlay::::from_subj_custom(&input, Default::default(), solver) .overlay(OverlayRule::Subject, FillRule::EvenOdd); let actual = actual .iter() @@ -419,14 +429,14 @@ fn seeded_boundary_overlays_match_a_wider_engine() { ]; let rule = rules[case % rules.len()]; for solver in [Solver::LIST, Solver::TREE, Solver::FRAG, Solver::AUTO] { - let wide = Overlay::::with_contours_custom( + let wide = Overlay::::from_subj_and_clip_custom( &[contour::(&subject)], &[contour::(&clip)], Default::default(), solver, ) .overlay(rule, FillRule::EvenOdd); - let narrow = Overlay::::with_contours_custom( + let narrow = Overlay::::from_subj_and_clip_custom( &[contour::(&subject)], &[contour::(&clip)], Default::default(), diff --git a/iOverlay/tests/issue_91_tests.rs b/iOverlay/tests/issue_91_tests.rs index fe49e70c..3ecff254 100644 --- a/iOverlay/tests/issue_91_tests.rs +++ b/iOverlay/tests/issue_91_tests.rs @@ -51,7 +51,7 @@ fn overlay( let x = 100 + 40 * i as i32; contours.push(contour(&[[x, 100], [x + 10, 100], [x + 10, 110], [x, 110]])); } - let mut overlay = Overlay::with_contours(&contours, &[]); + let mut overlay = Overlay::from_subj(&contours); overlay.options.ogc = ogc; overlay.options.preserve_output_collinear = preserve_collinear; overlay.options.output_direction = if clockwise { diff --git a/iOverlay/tests/mesh_scale_tests.rs b/iOverlay/tests/mesh_scale_tests.rs new file mode 100644 index 00000000..7937f25c --- /dev/null +++ b/iOverlay/tests/mesh_scale_tests.rs @@ -0,0 +1,143 @@ +use i_overlay::core::fill_rule::FillRule; +use i_overlay::core::overlay_rule::OverlayRule; +use i_overlay::float::overlay::FloatOverlay; +use i_overlay::mesh::float::outline::offset::OutlineOffset; +use i_overlay::mesh::float::stroke::offset::StrokeOffset; +use i_overlay::mesh::float::style::{LineJoin, OutlineStyle, StrokeStyle}; + +fn normalized_area(shapes: &[Vec>], scale: f64) -> f64 { + shapes + .iter() + .flatten() + .map(|path| { + let mut sum = 0.0; + let mut a = path[path.len() - 1].map(|v| v / scale); + for p in path { + let b = p.map(|v| v / scale); + sum += a[0] * b[1] - a[1] * b[0]; + a = b; + } + sum + }) + .sum::() + .abs() + * 0.5 +} + +#[test] +fn float_overlay_preserves_rectangles_at_extreme_scales() { + // Leave room for the rectangle width within the inclusive 2^500 limit. + for exponent in [496, -600] { + let scale = 2.0_f64.powi(exponent); + let rectangle = [ + [0.0, -scale], + [10.0 * scale, -scale], + [10.0 * scale, scale], + [0.0, scale], + ]; + let shapes = FloatOverlay::with_subj(&rectangle).overlay(OverlayRule::Subject, FillRule::NonZero); + let area = normalized_area(&shapes, scale); + assert!( + (area - 20.0).abs() < 0.001, + "exponent={exponent}, normalized area={area}" + ); + } +} + +#[test] +#[ignore = "Known extreme-scale mesh bug; deferred by request"] +fn stroke_preserves_relative_area_across_coordinate_scales() { + for exponent in [0, 400, -400, 496, -600] { + let scale = 2.0_f64.powi(exponent); + let path = [[0.0, 0.0], [10.0 * scale, 0.0]]; + let shapes = path.stroke(StrokeStyle::new(2.0 * scale), false); + let area = normalized_area(&shapes, scale); + assert!( + (area - 20.0).abs() < 0.001, + "exponent={exponent}, normalized area={area}, shapes={shapes:?}" + ); + } +} + +#[test] +#[ignore = "Known extreme-scale mesh bug; deferred by request"] +fn stroke_preserves_relative_area_at_small_coordinates() { + let scale = 2.0_f64.powi(-600); + let path = [[0.0, 0.0], [10.0 * scale, 0.0]]; + let shapes = path.stroke(StrokeStyle::new(2.0 * scale), false); + let area = normalized_area(&shapes, scale); + assert!( + (area - 20.0).abs() < 0.001, + "normalized area={area}, shapes={shapes:?}" + ); +} + +#[test] +#[ignore = "Known extreme-scale mesh bug; deferred by request"] +fn f32_stroke_preserves_relative_area_at_large_coordinates() { + // The path and stroke padding must fit within 2^60. + check_f32_stroke(56); +} + +#[test] +#[ignore = "Known extreme-scale mesh bug; deferred by request"] +fn f32_stroke_preserves_relative_area_at_small_coordinates() { + check_f32_stroke(-80); +} + +fn check_f32_stroke(exponent: i32) { + let scale = 2.0_f32.powi(exponent); + let path = [[0.0, 0.0], [10.0 * scale, 0.0]]; + let shapes = path.stroke(StrokeStyle::new(2.0 * scale), false); + let shapes: Vec>> = shapes + .into_iter() + .map(|s| { + s.into_iter() + .map(|c| c.into_iter().map(|p| p.map(f64::from)).collect()) + .collect() + }) + .collect(); + let area = normalized_area(&shapes, f64::from(scale)); + assert!( + (area - 20.0).abs() < 0.001, + "exponent={exponent}, normalized area={area}, shapes={shapes:?}" + ); +} + +#[test] +#[ignore = "Known extreme-scale mesh bug; deferred by request"] +fn bevel_outline_preserves_relative_area_at_large_coordinates() { + let scale = 2.0_f64.powi(496); + let path = [ + [0.0, 0.0], + [10.0 * scale, 0.0], + [10.0 * scale, 10.0 * scale], + [0.0, 10.0 * scale], + ]; + let shapes = path.outline(&OutlineStyle::new(scale)); + let area = normalized_area(&shapes, scale); + assert!( + (area - 142.0).abs() < 0.001, + "normalized area={area}, shapes={shapes:?}" + ); +} + +#[test] +#[ignore = "Known extreme-scale mesh bug; deferred by request"] +fn outline_preserves_relative_area_across_coordinate_scales() { + for exponent in [0, 400, -400, 496, -600] { + let scale = 2.0_f64.powi(exponent); + let path = [ + [0.0, 0.0], + [10.0 * scale, 0.0], + [10.0 * scale, 10.0 * scale], + [0.0, 10.0 * scale], + ]; + let shapes = path.outline(&OutlineStyle::new(scale).line_join(LineJoin::Miter(0.2))); + let area = normalized_area(&shapes, scale); + assert!( + (area - 144.0).abs() < 0.001, + "exponent={exponent}, normalized area={area}, shapes={shapes:?}" + ); + } +} diff --git a/iOverlay/tests/miter_regression_tests.rs b/iOverlay/tests/miter_regression_tests.rs new file mode 100644 index 00000000..9c15a4d9 --- /dev/null +++ b/iOverlay/tests/miter_regression_tests.rs @@ -0,0 +1,241 @@ +use i_float::int::{angle::Angle, point::IntPoint}; +use i_overlay::mesh::float::{ + outline::offset::OutlineOffset, + stroke::offset::StrokeOffset, + style::{LineJoin, OutlineStyle, StrokeStyle}, +}; +use i_overlay::mesh::int::{ + outline::offset::IntOutlineOffset, + stroke::offset::IntStrokeOffset, + style::{IntLineJoin, IntOutlineStyle, IntStrokeStyle}, +}; +use i_overlay::mesh::math::MathMode; + +#[test] +fn miter_on_nearly_collinear_path_stays_near_input() { + let path = [ + IntPoint::new(-879_382_i32, -1_393), + IntPoint::new(0, 0), + IntPoint::new(7_914_439, 12_537), + ]; + let width = 200_000; + let style = IntStrokeStyle::new(width).line_join(IntLineJoin::Miter(Angle::from_radians(0.1).unwrap())); + + path.validate_stroke(&style).unwrap(); + let shapes = path.stroke(&style, false).unwrap(); + assert_eq!(shapes.len(), 1); + + // The path is almost straight; a full width of padding is conservative. + // Previously release emitted the spurious point (426_437_379, 7_579_018), + // while debug panicked when constructing the peak. + let x_bounds = path[0].x - width..=path[2].x + width; + let y_bounds = path[0].y - width..=path[2].y + width; + for point in shapes.iter().flatten().flatten() { + assert!( + x_bounds.contains(&point.x) && y_bounds.contains(&point.y), + "miter vertex {point:?} escaped expected bounds: x={x_bounds:?}, y={y_bounds:?}" + ); + } +} + +#[test] +fn nearly_collinear_shrink_outline_stays_inside_input_bounds() { + let contour = [ + IntPoint::new(-879_382_i32, -1_393), + IntPoint::new(0, 0), + IntPoint::new(7_914_439, 12_537), + IntPoint::new(-2_000_000, 6_000_000), + ]; + let style = + IntOutlineStyle::new(-100_000).line_join(IntLineJoin::Miter(Angle::from_radians(0.1).unwrap())); + contour.validate_outline(&style).unwrap(); + let shapes = contour.outline(&style).unwrap(); + assert_eq!(shapes.len(), 1); + for point in shapes.iter().flatten().flatten() { + assert!((-2_000_000..=7_914_439).contains(&point.x)); + assert!((-1_393..=6_000_000).contains(&point.y)); + } +} + +#[test] +fn both_math_modes_bevel_turns_below_five_degrees() { + // Turns on either side of 5 degrees, for both traversal directions. + for (dy, bevel_expected) in [(8_700, true), (8_800, false)] { + let path = [ + IntPoint::new(-100_000_i32, 0), + IntPoint::new(0, 0), + IntPoint::new(100_000, dy), + ]; + for path in [path, [path[2], path[1], path[0]]] { + for math in [MathMode::Integer, MathMode::Float] { + let base = IntStrokeStyle::new(20_000).math(math); + let bevel = path.stroke(&base, false).unwrap(); + let miter = path + .stroke( + &base.line_join(IntLineJoin::Miter(Angle::from_radians(0.1).unwrap())), + false, + ) + .unwrap(); + assert_eq!(miter == bevel, bevel_expected, "dy={dy}, math={math:?}"); + } + } + } +} + +#[test] +fn nearly_collinear_float_miter_stays_in_bounds_after_translation() { + let path = [ + IntPoint::new(-556_890_i32, -800_383), + IntPoint::new(0, 0), + IntPoint::new(1_670_672, 2_401_151), + ]; + let width = 200_000; + // The translated input passes validation, but the old Float intersection + // produced an out-of-range peak and panicked in debug builds. + for shift in [0, 1_071_140_673] { + let path = path.map(|p| IntPoint::new(p.x, p.y + shift)); + for math in [MathMode::Integer, MathMode::Float] { + let style = IntStrokeStyle::new(width) + .math(math) + .line_join(IntLineJoin::Miter(Angle::from_radians(3.0).unwrap())); + path.validate_stroke(&style).unwrap(); + let shapes = path.stroke(&style, false).unwrap(); + assert_eq!(shapes.len(), 1); + let x_bounds = path[0].x - width..=path[2].x + width; + let y_bounds = path[0].y - width..=path[2].y + width; + for point in shapes.iter().flatten().flatten() { + assert!( + x_bounds.contains(&point.x) && y_bounds.contains(&point.y), + "math={math:?}, shift={shift}, unexpected miter vertex {point:?}" + ); + } + } + } +} + +#[test] +fn only_integer_math_clips_corners_sharper_than_five_degrees() { + // Interior angle approximately 4.9 degrees; radius 10_000. + // A 5-degree clipped miter reaches at most r / sin(2.5 degrees), + // about 229_256 units. The ordinary 4.9-degree miter reaches over 233_000. + let path = [ + IntPoint::new(-100_000_i32, 0), + IntPoint::new(0, 0), + IntPoint::new(-100_000, 8_573), + ]; + for math in [MathMode::Integer, MathMode::Float] { + let style = IntStrokeStyle::new(20_000) + .math(math) + .line_join(IntLineJoin::Miter(Angle::from_radians(0.01).unwrap())); + path.validate_stroke(&style).unwrap(); + let shapes = path.stroke(&style, false).unwrap(); + let max_x = shapes.iter().flatten().flatten().map(|p| p.x).max().unwrap(); + assert!(max_x > 200_000, "the sharp corner must retain a miter"); + assert_eq!( + max_x < 230_000, + math == MathMode::Integer, + "math={math:?}, max_x={max_x}" + ); + } +} + +#[test] +fn custom_miter_cutoff_applies_to_integer_stroke_and_outline() { + // The middle turn is about 2.86 degrees, between the two custom cutoffs. + let path = [ + IntPoint::new(-100_000_i32, 0), + IntPoint::new(0, 0), + IntPoint::new(100_000, 5_000), + ]; + let contour = [path[0], path[1], path[2], IntPoint::new(0, 100_000)]; + let angle = |degrees: f64| Angle::from_radians(degrees.to_radians()).unwrap(); + for math in [MathMode::Integer, MathMode::Float] { + let stroke = IntStrokeStyle::new(20_000) + .math(math) + .line_join(IntLineJoin::Miter(angle(10.0))); + let build_stroke = |cutoff| { + path.stroke(&stroke.clone().miter_min_turn(cutoff), false) + .unwrap() + }; + let low = build_stroke(angle(1.0)); + assert!(!low.is_empty()); + assert_eq!(low, build_stroke(angle(0.0))); + assert_ne!(low, build_stroke(angle(10.0))); + assert_eq!( + build_stroke(angle(10.0)), + path.stroke(&stroke.clone().line_join(IntLineJoin::Bevel), false) + .unwrap() + ); + assert_eq!( + build_stroke(Angle::from_bits(u32::MAX)), + build_stroke(angle(180.0)) + ); + + let outline = IntOutlineStyle::new(10_000) + .math(math) + .line_join(IntLineJoin::Miter(angle(10.0))); + let build_outline = |cutoff| contour.outline(&outline.miter_min_turn(cutoff)).unwrap(); + let low = build_outline(angle(1.0)); + assert!(!low.is_empty()); + assert_eq!(low, build_outline(angle(0.0))); + assert_ne!(low, build_outline(angle(10.0))); + } +} + +macro_rules! float_miter_cutoff { + ($name:ident, $scalar:ty) => { + #[test] + fn $name() { + let path = [ + [-100_000.0 as $scalar, 0.0], + [0.0, 0.0], + [100_000.0, 5_000.0], + ]; + let contour = [path[0], path[1], path[2], [0.0, 100_000.0]]; + let angle = |degrees: f64| degrees.to_radians() as $scalar; + for math in [MathMode::Integer, MathMode::Float] { + let stroke = StrokeStyle::new(20_000.0) + .math(math) + .line_join(LineJoin::Miter(angle(10.0))); + let build_stroke = |cutoff| { + path.stroke_fixed_scale(stroke.clone().miter_min_turn(cutoff), false, 1.0) + .unwrap() + }; + let low = build_stroke(angle(1.0)); + assert!(!low.is_empty()); + assert_eq!(low, build_stroke(0.0)); + assert_ne!(low, build_stroke(angle(10.0))); + assert_eq!( + build_stroke(angle(10.0)), + path.stroke_fixed_scale(stroke.clone().line_join(LineJoin::Bevel), false, 1.0) + .unwrap() + ); + assert_eq!(build_stroke(<$scalar>::NAN), build_stroke(angle(5.0))); + assert_eq!(build_stroke(<$scalar>::NEG_INFINITY), build_stroke(0.0)); + assert_eq!( + build_stroke(<$scalar>::INFINITY), + build_stroke(angle(180.0)) + ); + + let build_outline = |cutoff| { + contour + .outline_fixed_scale( + &OutlineStyle::new(10_000.0) + .math(math) + .line_join(LineJoin::Miter(angle(10.0))) + .miter_min_turn(cutoff), + 1.0, + ) + .unwrap() + }; + let low = build_outline(angle(1.0)); + assert!(!low.is_empty()); + assert_eq!(low, build_outline(0.0)); + assert_ne!(low, build_outline(angle(10.0))); + } + } + }; +} + +float_miter_cutoff!(custom_miter_cutoff_f32, f32); +float_miter_cutoff!(custom_miter_cutoff_f64, f64); diff --git a/iOverlay/tests/ocg_tests.rs b/iOverlay/tests/ocg_tests.rs index ccf97443..127a41f5 100644 --- a/iOverlay/tests/ocg_tests.rs +++ b/iOverlay/tests/ocg_tests.rs @@ -37,7 +37,7 @@ mod tests { [[2, 1], [2, 2], [3, 2], [3, 3], [4, 3], [4, 1]], ]; - let mut overlay = Overlay::with_contours_custom( + let mut overlay = Overlay::from_subj_and_clip_custom( &subj_paths, &clip_paths, IntOverlayOptions::ogc(), @@ -82,7 +82,8 @@ mod tests { let mut opts = IntOverlayOptions::ogc(); opts.output_direction = ContourDirection::Clockwise; - let mut overlay = Overlay::with_contours_custom(&subj_paths, &clip_paths, opts, Default::default()); + let mut overlay = + Overlay::from_subj_and_clip_custom(&subj_paths, &clip_paths, opts, Default::default()); let result = overlay.overlay(OverlayRule::Difference, FillRule::EvenOdd); @@ -118,7 +119,7 @@ mod tests { [[3, 2], [3, 3], [4, 3], [4, 2]], ]; - let mut overlay = Overlay::with_contours_custom( + let mut overlay = Overlay::from_subj_and_clip_custom( &subj_paths, &clip_paths, IntOverlayOptions::ogc(), @@ -168,7 +169,7 @@ mod tests { [[5, 3], [5, 4], [6, 4], [6, 3]], ]; - let mut overlay = Overlay::with_contours_custom( + let mut overlay = Overlay::from_subj_and_clip_custom( &subj_paths, &clip_paths, IntOverlayOptions::ogc(), @@ -199,7 +200,7 @@ mod tests { let subj_paths = int_shape![[[0, 3], [0, 0], [3, 0], [3, 2], [1, 2], [1, 1], [2, 1], [2, 3]]]; let mut overlay = - Overlay::with_contours_custom(&subj_paths, &[], IntOverlayOptions::ogc(), Default::default()); + Overlay::from_subj_custom(&subj_paths, IntOverlayOptions::ogc(), Default::default()); let result = overlay.overlay(OverlayRule::Union, FillRule::EvenOdd); @@ -226,7 +227,7 @@ mod tests { let clip_paths = int_shape![[[1, 2], [1, 1], [2, 1], [2, 2]], [[2, 3], [2, 2], [3, 2], [3, 3]],]; - let mut overlay = Overlay::with_contours_custom( + let mut overlay = Overlay::from_subj_and_clip_custom( &subj_paths, &clip_paths, IntOverlayOptions::ogc(), @@ -259,7 +260,7 @@ mod tests { let clip_paths = int_shape![[[1, 2], [1, 1], [2, 1], [2, 2]], [[2, 3], [2, 2], [3, 2], [3, 3]],]; - let mut overlay = Overlay::with_contours_custom( + let mut overlay = Overlay::from_subj_and_clip_custom( &subj_paths, &clip_paths, IntOverlayOptions::ogc(), @@ -295,7 +296,8 @@ mod tests { let mut opts = IntOverlayOptions::ogc(); opts.output_direction = ContourDirection::Clockwise; - let mut overlay = Overlay::with_contours_custom(&subj_paths, &clip_paths, opts, Default::default()); + let mut overlay = + Overlay::from_subj_and_clip_custom(&subj_paths, &clip_paths, opts, Default::default()); let result = overlay.overlay(OverlayRule::Difference, FillRule::EvenOdd); @@ -320,7 +322,7 @@ mod tests { let subj_paths = int_shape![[[0, 3], [0, 0], [5, 0], [5, 3], [3, 3], [3, 2], [2, 2], [2, 3]],]; let clip_paths = int_shape![[[1, 2], [1, 1], [2, 1], [2, 2]], [[3, 2], [3, 1], [4, 1], [4, 2]],]; - let mut overlay = Overlay::with_contours_custom( + let mut overlay = Overlay::from_subj_and_clip_custom( &subj_paths, &clip_paths, IntOverlayOptions::ogc(), @@ -355,7 +357,7 @@ mod tests { ]; let mut overlay = - Overlay::with_contours_custom(&subj_paths, &[], IntOverlayOptions::ogc(), Default::default()); + Overlay::from_subj_custom(&subj_paths, IntOverlayOptions::ogc(), Default::default()); let result = overlay.overlay(OverlayRule::Union, FillRule::EvenOdd); @@ -403,7 +405,7 @@ mod tests { ]]; let clip_paths = int_shape![[[2, 3], [2, 2], [3, 2], [3, 3]]]; - let mut overlay = Overlay::with_contours_custom( + let mut overlay = Overlay::from_subj_and_clip_custom( &subj_paths, &clip_paths, IntOverlayOptions::ogc(), @@ -427,7 +429,7 @@ mod tests { ]; let mut overlay = - Overlay::with_contours_custom(&subj_paths, &[], IntOverlayOptions::ogc(), Default::default()); + Overlay::from_subj_custom(&subj_paths, IntOverlayOptions::ogc(), Default::default()); let result = overlay.overlay(OverlayRule::Union, FillRule::EvenOdd); @@ -450,7 +452,7 @@ mod tests { ]; let mut overlay = - Overlay::with_contours_custom(&subj_paths, &[], IntOverlayOptions::ogc(), Default::default()); + Overlay::from_subj_custom(&subj_paths, IntOverlayOptions::ogc(), Default::default()); let shapes = overlay.overlay(OverlayRule::Union, FillRule::NonZero); assert_eq!(shapes[0].len(), 2); @@ -491,7 +493,7 @@ mod tests { ]; let mut overlay = - Overlay::with_contours_custom(&subj_paths, &[], IntOverlayOptions::ogc(), Default::default()); + Overlay::from_subj_custom(&subj_paths, IntOverlayOptions::ogc(), Default::default()); let shapes = overlay.overlay(OverlayRule::Union, FillRule::NonZero); assert_eq!(shapes.len(), 4); @@ -566,7 +568,7 @@ mod tests { } let mut overlay = - Overlay::with_contours_custom(&subj_paths, &[], IntOverlayOptions::ogc(), Default::default()); + Overlay::from_subj_custom(&subj_paths, IntOverlayOptions::ogc(), Default::default()); let result = overlay.overlay(OverlayRule::Subject, FillRule::EvenOdd); @@ -627,7 +629,7 @@ mod tests { } let mut overlay = - Overlay::with_contours_custom(&subj_paths, &[], IntOverlayOptions::ogc(), Default::default()); + Overlay::from_subj_custom(&subj_paths, IntOverlayOptions::ogc(), Default::default()); let result = overlay.overlay(OverlayRule::Subject, FillRule::EvenOdd); @@ -682,7 +684,7 @@ mod tests { } } - let mut overlay = Overlay::with_contours_custom( + let mut overlay = Overlay::from_subj_and_clip_custom( &subj_paths, &clip_paths, IntOverlayOptions::ogc(), @@ -766,7 +768,7 @@ mod tests { clip_paths.push(random_star_contour(&mut rng, 680, 60, 220)); } - let mut overlay = Overlay::with_contours_custom( + let mut overlay = Overlay::from_subj_and_clip_custom( &subj_paths, &clip_paths, IntOverlayOptions::ogc(), @@ -775,8 +777,7 @@ mod tests { let result = overlay.overlay(OverlayRule::Difference, FillRule::EvenOdd); - let mut overlay = - Overlay::with_shapes_options(&result, &[], IntOverlayOptions::ogc(), Default::default()); + let mut overlay = Overlay::from_subj_custom(&result, IntOverlayOptions::ogc(), Default::default()); let normalized = overlay.overlay(OverlayRule::Union, FillRule::EvenOdd); let result_area = result.area().abs(); diff --git a/iOverlay/tests/ogc_area_filter_tests.rs b/iOverlay/tests/ogc_area_filter_tests.rs new file mode 100644 index 00000000..688fc337 --- /dev/null +++ b/iOverlay/tests/ogc_area_filter_tests.rs @@ -0,0 +1,78 @@ +use i_float::int::point::IntPoint; +use i_overlay::core::fill_rule::FillRule; +use i_overlay::core::overlay::{ContourDirection, Overlay}; +use i_overlay::core::overlay_rule::OverlayRule; +use i_overlay::core::solver::Solver; + +type Shapes = Vec>>; + +fn area(path: &[IntPoint]) -> u64 { + path.iter() + .zip(path.iter().cycle().skip(1)) + .map(|(a, b)| i64::from(a.x) * i64::from(b.y) - i64::from(a.y) * i64::from(b.x)) + .sum::() + .unsigned_abs() + / 2 +} + +fn canonical(mut shapes: Shapes) -> Shapes { + for shape in &mut shapes { + for path in shape.iter_mut() { + let start = (0..path.len()) + .min_by(|&a, &b| { + (0..path.len()) + .map(|j| path[(a + j) % path.len()]) + .cmp((0..path.len()).map(|j| path[(b + j) % path.len()])) + }) + .unwrap(); + path.rotate_left(start); + } + shape[1..].sort(); + } + shapes.sort(); + shapes +} + +#[test] +fn ogc_area_filter_matches_filtering_resolved_contours() { + let mut seed = 0x728a_13dd_8741_990e_u64; + let mut next = || { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + ((seed >> 32) % 41) as i32 - 20 + }; + for case in 0..500 { + let paths: Vec> = (0..1 + case % 4) + .map(|_| (0..3 + case % 8).map(|_| IntPoint::new(next(), next())).collect()) + .collect(); + for clockwise in [false, true] { + let mut overlay = Overlay::from_subj(&paths); + overlay.options.ogc = true; + overlay.options.output_direction = if clockwise { + ContourDirection::Clockwise + } else { + ContourDirection::CounterClockwise + }; + overlay.solver = [Solver::LIST, Solver::TREE, Solver::FRAG][case % 3]; + let all = overlay.overlay(OverlayRule::Subject, FillRule::EvenOdd); + // The last zero threshold also checks reuse after an empty extraction. + for threshold in [0, 1, 2, 5, 10, 50, u64::MAX, 0] { + let expected: Shapes = all + .iter() + .filter(|s| area(&s[0]) >= threshold) + .map(|s| { + let mut shape = vec![s[0].clone()]; + shape.extend(s.iter().skip(1).filter(|p| area(p) >= threshold).cloned()); + shape + }) + .collect(); + overlay.options.min_output_area = threshold; + let actual = overlay.overlay(OverlayRule::Subject, FillRule::EvenOdd); + assert_eq!( + canonical(actual), + canonical(expected), + "case={case}, clockwise={clockwise}, threshold={threshold}, input={paths:?}" + ); + } + } + } +} diff --git a/iOverlay/tests/ogc_hole_orientation_tests.rs b/iOverlay/tests/ogc_hole_orientation_tests.rs index 71e3e0ab..2c925695 100644 --- a/iOverlay/tests/ogc_hole_orientation_tests.rs +++ b/iOverlay/tests/ogc_hole_orientation_tests.rs @@ -22,7 +22,7 @@ fn check_hole_direction(clockwise: bool) { for shift in 0..7 { let mut path = touching_hole(); path.rotate_left(shift); - let mut overlay = Overlay::with_contour(&path, &[]); + let mut overlay = Overlay::from_subj(&path); overlay.options.ogc = true; overlay.options.output_direction = if clockwise { ContourDirection::Clockwise @@ -55,11 +55,11 @@ fn clockwise_ogc_leftmost_touching_hole_has_opposite_winding() { #[test] fn ogc_leftmost_touching_hole_survives_nonzero_roundtrip() { - let mut overlay = Overlay::with_contour(&touching_hole(), &[]); + let mut overlay = Overlay::from_subj(&touching_hole()); overlay.options.ogc = true; let shapes = overlay.overlay(OverlayRule::Subject, FillRule::NonZero); let contours: Vec<_> = shapes.into_iter().flatten().collect(); - let result = Overlay::with_contours(&contours, &[]).overlay(OverlayRule::Subject, FillRule::NonZero); + let result = Overlay::from_subj(&contours).overlay(OverlayRule::Subject, FillRule::NonZero); let area: i64 = result.iter().flatten().map(|p| double_area(p)).sum(); assert_eq!( area, 10, @@ -69,8 +69,7 @@ fn ogc_leftmost_touching_hole_survives_nonzero_roundtrip() { #[test] fn ordinary_extraction_preserves_leftmost_touching_hole_area() { - let shapes = - Overlay::with_contour(&touching_hole(), &[]).overlay(OverlayRule::Subject, FillRule::NonZero); + let shapes = Overlay::from_subj(&touching_hole()).overlay(OverlayRule::Subject, FillRule::NonZero); let area: i64 = shapes.iter().flatten().map(|p| double_area(p)).sum(); assert_eq!(area, 10); } @@ -109,7 +108,7 @@ fn check_shared_leftmost_holes(input: &[Vec], expected: &[Vec], expected: &[Vec = shapes.into_iter().flatten().collect(); - let result = Overlay::with_contours(&contours, &[]).overlay(OverlayRule::Subject, FillRule::NonZero); + let result = Overlay::from_subj(&contours).overlay(OverlayRule::Subject, FillRule::NonZero); let area: i64 = result.iter().flatten().map(|p| double_area(p)).sum(); assert_eq!( area, 184, diff --git a/iOverlay/tests/outline_boundary_tests.rs b/iOverlay/tests/outline_boundary_tests.rs new file mode 100644 index 00000000..f3e048b6 --- /dev/null +++ b/iOverlay/tests/outline_boundary_tests.rs @@ -0,0 +1,128 @@ +use i_overlay::mesh::float::outline::offset::OutlineOffset; +use i_overlay::mesh::float::style::{LineJoin, OutlineStyle}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +// A broken subdivision count can make an outline loop effectively forever. +// Keep the regression bounded in both debug and release builds. +fn check_round_outline_in_subprocess(test_name: &str, angle: f64) { + const CHILD_ENV: &str = "I_OVERLAY_OUTLINE_BOUNDARY_TEST_CHILD"; + if std::env::var(CHILD_ENV).as_deref() == Ok(test_name) { + let square = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]]; + let style = OutlineStyle::new(1.0).line_join(LineJoin::Round(angle)); + let result = square.outline(&style); + assert_eq!(result.len(), 1); + assert!( + result + .iter() + .flatten() + .flatten() + .all(|p| p[0].is_finite() && p[1].is_finite()) + ); + return; + } + + let mut child = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", test_name, "--nocapture"]) + .env(CHILD_ENV, test_name) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(3); + loop { + if child.try_wait().unwrap().is_some() { + let output = child.wait_with_output().unwrap(); + assert!( + output.status.success(), + "outline with round angle {angle} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + return; + } + if Instant::now() >= deadline { + child.kill().unwrap(); + child.wait().unwrap(); + panic!( + "outline of a four-vertex square with round angle {angle} did not finish within 3 seconds" + ); + } + std::thread::sleep(Duration::from_millis(10)); + } +} + +#[test] +fn hypothesis_outline_zero_round_angle_terminates() { + check_round_outline_in_subprocess("hypothesis_outline_zero_round_angle_terminates", 0.0); +} + +#[test] +fn hypothesis_outline_tiny_positive_round_angle_terminates() { + check_round_outline_in_subprocess("hypothesis_outline_tiny_positive_round_angle_terminates", 1e-20); +} + +#[test] +fn outline_normal_round_angle_control() { + check_round_outline_in_subprocess("outline_normal_round_angle_control", 0.1); +} + +fn square(lo: f64, hi: f64, clockwise: bool) -> Vec<[f64; 2]> { + let mut path = vec![[lo, lo], [hi, lo], [hi, hi], [lo, hi]]; + if clockwise { + path.reverse(); + } + path +} + +#[test] +fn hypothesis_collapsed_hole_does_not_erase_other_holes() { + let outer = square(0.0, 30.0, false); + let collapsed_hole = square(3.0, 5.0, true); + let surviving_hole = square(10.0, 20.0, true); + let style = OutlineStyle::new(1.0); + + let expected = vec![outer.clone(), surviving_hole.clone()] + .outline_fixed_scale(&style, 100.0) + .unwrap(); + assert_eq!(expected.len(), 1); + assert_eq!(expected[0].len(), 2, "control must retain the large hole"); + + let actual = vec![outer, collapsed_hole, surviving_hole] + .outline_fixed_scale(&style, 100.0) + .unwrap(); + assert_eq!( + actual, expected, + "a collapsed hole must not change any other contour" + ); +} + +#[test] +fn rectangular_frame_offsets_match_analytic_area() { + let source = vec![square(0.0, 30.0, false), square(10.0, 20.0, true)]; + for outer_offset in -16..=6 { + for inner_offset in -6..=6 { + let style = OutlineStyle::new(0.0) + .outer_offset(outer_offset as f64) + .inner_offset(inner_offset as f64) + .line_join(LineJoin::Miter(0.1)); + let result = source.outline_fixed_scale(&style, 100.0).unwrap(); + let outer_side = (30.0 + 2.0 * outer_offset as f64).max(0.0); + let hole_side = (10.0 - 2.0 * inner_offset as f64).max(0.0); + let expected = (outer_side * outer_side - hole_side * hole_side).max(0.0); + let actual: f64 = result + .iter() + .flatten() + .map(|path| { + path.iter() + .zip(path.iter().cycle().skip(1)) + .map(|(a, b)| (a[0] * b[1] - a[1] * b[0]) * 0.5) + .sum::() + }) + .sum(); + assert!( + (actual - expected).abs() < 0.001, + "outer={outer_offset}, inner={inner_offset}, expected={expected}, actual={actual}, result={result:?}" + ); + } + } +} diff --git a/iOverlay/tests/outline_cell_oracle_tests.rs b/iOverlay/tests/outline_cell_oracle_tests.rs new file mode 100644 index 00000000..d5c63eb1 --- /dev/null +++ b/iOverlay/tests/outline_cell_oracle_tests.rs @@ -0,0 +1,101 @@ +use i_float::int::point::IntPoint; +use i_overlay::core::fill_rule::FillRule; +use i_overlay::core::overlay::Overlay; +use i_overlay::core::overlay_rule::OverlayRule; +use i_overlay::mesh::float::outline::offset::OutlineOffset; +use i_overlay::mesh::float::style::{LineJoin, OutlineStyle}; + +fn contains(shapes: &[Vec>], x: f64, y: f64) -> bool { + shapes.iter().any(|shape| { + let mut inside = false; + for path in shape { + for (a, b) in path.iter().zip(path.iter().cycle().skip(1)) { + assert!( + a[0] == b[0] || a[1] == b[1], + "miter offsets of orthogonal shapes must stay orthogonal" + ); + if (a[1] > y) != (b[1] > y) && a[0] > x { + inside = !inside; + } + } + } + inside + }) +} + +#[test] +fn orthogonal_miter_offsets_match_cell_dilation_and_erosion() { + let mut seed = 0x2917_8b42_357a_1de3_u64; + for case in 0..200 { + let mut cells = [[false; 6]; 6]; + let mut contours = Vec::new(); + for (x, column) in cells.iter_mut().enumerate() { + for (y, cell) in column.iter_mut().enumerate() { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + *cell = (seed >> 32) % 100 < 20 + case % 65; + if *cell { + let (x, y) = (4 * x as i32, 4 * y as i32); + contours.push(vec![ + IntPoint::new(x, y), + IntPoint::new(x + 4, y), + IntPoint::new(x + 4, y + 4), + IntPoint::new(x, y + 4), + ]); + } + } + } + let mut overlay = Overlay::from_subj(&contours); + overlay.options.ogc = true; + let subject = overlay.overlay(OverlayRule::Subject, FillRule::NonZero); + let source: Vec>> = subject + .iter() + .map(|s| { + s.iter() + .map(|p| p.iter().map(|p| [p.x as f64, p.y as f64]).collect()) + .collect() + }) + .collect(); + let occupied = |x: i32, y: i32| { + let (cx, cy) = (x.div_euclid(4), y.div_euclid(4)); + (0..6).contains(&cx) && (0..6).contains(&cy) && cells[cx as usize][cy as usize] + }; + for offset in [-2_i32, -1, 1, 2] { + let style = OutlineStyle::new(offset as f64).line_join(LineJoin::Miter(0.1)); + let result = source.outline_fixed_scale(&style, 100.0).unwrap(); + let mut expected_area = 0; + for x in -3..27 { + for y in -3..27 { + let r = offset.abs(); + // A miter offset of an orthogonal boundary is dilation or + // erosion by an axis-aligned square. Half-unit samples avoid + // every output boundary and cover all unit cells exactly. + let expected = if offset > 0 { + (-r..=r).any(|dx| (-r..=r).any(|dy| occupied(x + dx, y + dy))) + } else { + (-r..=r).all(|dx| (-r..=r).all(|dy| occupied(x + dx, y + dy))) + }; + expected_area += i32::from(expected); + assert_eq!( + contains(&result, x as f64 + 0.5, y as f64 + 0.5), + expected, + "case={case}, offset={offset}, sample=({x}.5,{y}.5), cells={cells:?}, source={source:?}, result={result:?}" + ); + } + } + let area: f64 = result + .iter() + .flatten() + .map(|p| { + p.iter() + .zip(p.iter().cycle().skip(1)) + .map(|(a, b)| 0.5 * (a[0] * b[1] - a[1] * b[0])) + .sum::() + }) + .sum(); + assert!( + (area - expected_area as f64).abs() < 1e-6, + "case={case}, offset={offset}, area={area}, expected={expected_area}" + ); + } + } +} diff --git a/iOverlay/tests/outline_grid_area_tests.rs b/iOverlay/tests/outline_grid_area_tests.rs new file mode 100644 index 00000000..ca6331c4 --- /dev/null +++ b/iOverlay/tests/outline_grid_area_tests.rs @@ -0,0 +1,86 @@ +use i_float::int::point::IntPoint; +use i_overlay::core::fill_rule::FillRule; +use i_overlay::core::overlay::Overlay; +use i_overlay::core::overlay_rule::OverlayRule; +use i_overlay::mesh::float::outline::offset::OutlineOffset; +use i_overlay::mesh::float::style::OutlineStyle; + +fn area(shapes: &[Vec>]) -> f64 { + shapes + .iter() + .flatten() + .map(|path| { + path.iter() + .zip(path.iter().cycle().skip(1)) + .map(|(a, b)| 0.5 * (a[0] * b[1] - a[1] * b[0])) + .sum::() + }) + .sum() +} + +#[test] +fn zero_outline_preserves_half_grid_cell_hole() { + // Symmetric bounds put the adapter origin at (0,0), so every vertex is + // exactly on the fixed grid. The clockwise triangle has double area -1. + let shape = vec![ + vec![[-10.0, -10.0], [10.0, -10.0], [10.0, 10.0], [-10.0, 10.0]], + vec![[0.0, 0.0], [0.0, 1.0], [1.0, 0.0]], + ]; + let result = shape.outline_fixed_scale(&OutlineStyle::new(0.0), 1.0).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!( + result[0].len(), + 2, + "a nonzero-area hole must survive zero offset: {result:?}" + ); + assert_eq!(area(&result), 399.5); +} + +#[test] +fn zero_outline_preserves_half_grid_cell_components() { + let shape = vec![ + vec![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]], + vec![[0.0, 0.0], [-1.0, 0.0], [0.0, -1.0]], + ]; + let result = shape.outline_fixed_scale(&OutlineStyle::new(0.0), 1.0).unwrap(); + assert_eq!( + area(&result), + 1.0, + "two nondegenerate triangles must survive: {result:?}" + ); +} + +#[test] +fn outward_outline_expands_half_grid_cell_components() { + let shape = vec![ + vec![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]], + vec![[0.0, 0.0], [-1.0, 0.0], [0.0, -1.0]], + ]; + let result = shape.outline_fixed_scale(&OutlineStyle::new(2.0), 1.0).unwrap(); + assert!( + area(&result) > 1.0, + "outward offset must expand the triangles, not discard them: {result:?}" + ); +} + +#[test] +fn integer_overlay_preserves_half_grid_cell_contours() { + let triangle = vec![IntPoint::new(0, 0), IntPoint::new(1, 0), IntPoint::new(0, 1)]; + let result = Overlay::from_subj(&triangle).overlay(OverlayRule::Subject, FillRule::Positive); + assert_eq!(result.len(), 1); + assert_eq!(result[0][0].len(), 3); + let mut hole = triangle; + hole.reverse(); + let shape = vec![ + vec![ + IntPoint::new(-10, -10), + IntPoint::new(10, -10), + IntPoint::new(10, 10), + IntPoint::new(-10, 10), + ], + hole, + ]; + let result = Overlay::from_subj(&shape).overlay(OverlayRule::Subject, FillRule::Positive); + assert_eq!(result.len(), 1); + assert_eq!(result[0].len(), 2); +} diff --git a/iOverlay/tests/outline_identity_tests.rs b/iOverlay/tests/outline_identity_tests.rs new file mode 100644 index 00000000..525bc2c7 --- /dev/null +++ b/iOverlay/tests/outline_identity_tests.rs @@ -0,0 +1,140 @@ +use i_float::int::point::IntPoint; +use i_overlay::core::fill_rule::FillRule; +use i_overlay::core::overlay::{ContourDirection, Overlay}; +use i_overlay::core::overlay_rule::OverlayRule; +use i_overlay::float::overlay::OverlayOptions; +use i_overlay::mesh::float::outline::offset::OutlineOffset; +use i_overlay::mesh::float::style::OutlineStyle; + +type Shapes = Vec>>; + +#[test] +fn zero_outline_preserves_touching_triangular_hole() { + let source = vec![ + vec![ + [-1.0, 0.0], + [0.0, -3.0], + [1.0, -4.0], + [1.0, -5.0], + [2.0, -5.0], + [1.0, -3.0], + [1.0, -2.0], + [2.0, -1.0], + [0.0, 1.0], + ], + vec![[-1.0, 0.0], [0.0, 0.0], [0.0, -2.0]], + ]; + let mut options = OverlayOptions::default(); + options.ogc = true; + options.clean_result = false; + let result = source + .outline_custom_fixed_scale(&OutlineStyle::new(0.0), options, 100.0) + .unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].len(), 2, "missing touching hole: {result:?}"); +} + +fn canonical(mut shapes: Shapes) -> Shapes { + for shape in &mut shapes { + for path in shape.iter_mut() { + let start = (0..path.len()) + .min_by(|&a, &b| { + (0..path.len()) + .map(|j| path[(a + j) % path.len()]) + .cmp((0..path.len()).map(|j| path[(b + j) % path.len()])) + }) + .unwrap(); + path.rotate_left(start); + } + shape[1..].sort(); + } + shapes.sort(); + shapes +} + +#[test] +fn ogc_output_preserves_geometry_through_zero_outline() { + let mut seed = 0xd2a1_8934_71bc_2259_u64; + let mut next = || { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + ((seed >> 32) % 41) as i32 - 20 + }; + for case in 0..1000 { + let paths: Vec> = (0..1 + case % 5) + .map(|_| { + (0..3 + case % 10) + .map(|_| IntPoint::new(next(), next())) + .collect() + }) + .collect(); + let mut overlay = Overlay::from_subj(&paths); + overlay.options.ogc = true; + let source = overlay.overlay(OverlayRule::Subject, FillRule::EvenOdd); + for shape in &source { + for (index, path) in shape.iter().enumerate() { + let area: i64 = path + .iter() + .zip(path.iter().cycle().skip(1)) + .map(|(a, b)| i64::from(a.x) * i64::from(b.y) - i64::from(a.y) * i64::from(b.x)) + .sum(); + assert_eq!( + area > 0, + index == 0, + "invalid source winding: case={case}, path={path:?}" + ); + } + } + let float: Vec>> = source + .iter() + .map(|s| { + s.iter() + .map(|p| p.iter().map(|p| [p.x as f64, p.y as f64]).collect()) + .collect() + }) + .collect(); + for clockwise in [false, true] { + let mut options = OverlayOptions::default(); + options.ogc = true; + options.clean_result = case % 2 == 0; + options.output_direction = if clockwise { + ContourDirection::Clockwise + } else { + ContourDirection::CounterClockwise + }; + let actual = float + .outline_custom_fixed_scale(&OutlineStyle::new(0.0), options, 100.0) + .unwrap(); + let actual = actual + .iter() + .map(|s| { + s.iter() + .map(|p| { + p.iter() + .map(|p| { + assert!( + (p[0] - p[0].round()).abs() < 1e-8 + && (p[1] - p[1].round()).abs() < 1e-8 + ); + IntPoint::new(p[0].round() as i32, p[1].round() as i32) + }) + .collect() + }) + .collect() + }) + .collect(); + let mut expected = source.clone(); + if clockwise { + for path in expected.iter_mut().flatten() { + path.reverse(); + } + } + let actual = canonical(actual); + let expected = canonical(expected); + if actual != expected { + let missing: Vec<_> = expected.iter().filter(|s| !actual.contains(s)).collect(); + let extra: Vec<_> = actual.iter().filter(|s| !expected.contains(s)).collect(); + panic!("case={case}, clockwise={clockwise}, missing={missing:?}, extra={extra:?}"); + } + } + } +} diff --git a/iOverlay/tests/outline_pipeline_tests.rs b/iOverlay/tests/outline_pipeline_tests.rs new file mode 100644 index 00000000..09f9cf0a --- /dev/null +++ b/iOverlay/tests/outline_pipeline_tests.rs @@ -0,0 +1,126 @@ +use i_overlay::float::overlay::OverlayOptions; +use i_overlay::mesh::float::outline::offset::OutlineOffset; +use i_overlay::mesh::float::stroke::offset::StrokeOffset; +use i_overlay::mesh::float::style::{LineJoin, OutlineStyle, StrokeStyle}; +use i_shape::flat::float::FloatFlatContoursBuffer; + +fn rectangle(x0: f64, y0: f64, x1: f64, y1: f64) -> Vec<[f64; 2]> { + vec![[x0, y0], [x1, y0], [x1, y1], [x0, y1]] +} + +#[test] +fn reused_mesh_output_matches_fresh_buffer_after_empty_results() { + let paths = [ + rectangle(0.0, 0.0, 10.0, 10.0), + vec![], + vec![[1.0, 1.0]], + vec![[1.0, 1.0]; 4], + vec![[0.0, 0.0], [1.0, 0.0], [2.0, 0.0]], + rectangle(20.0, 20.0, 21.0, 21.0), + rectangle(30.0, 30.0, 40.0, 40.0), + ]; + let mut reused = FloatFlatContoursBuffer::default(); + for fixed in [false, true] { + for offset in [-1.0, 0.0, 1.0] { + let outline = OutlineStyle::new(offset); + for path in &paths { + let mut fresh = FloatFlatContoursBuffer::default(); + for output in [&mut reused, &mut fresh] { + if fixed { + path.outline_fixed_scale_into(&outline, 100.0, output).unwrap(); + } else { + path.outline_into(&outline, output); + } + } + assert_eq!( + reused.points, fresh.points, + "outline offset={offset}, path={path:?}" + ); + assert_eq!(reused.ranges, fresh.ranges); + for closed in [false, true] { + let mut fresh = FloatFlatContoursBuffer::default(); + for output in [&mut reused, &mut fresh] { + if fixed { + path.stroke_fixed_scale_into(StrokeStyle::new(2.0), closed, 100.0, output) + .unwrap(); + } else { + path.stroke_into(StrokeStyle::new(2.0), closed, output); + } + } + assert_eq!( + reused.points, fresh.points, + "stroke closed={closed}, path={path:?}" + ); + assert_eq!(reused.ranges, fresh.ranges); + } + } + } + } +} + +#[test] +fn collapsed_offsets_do_not_change_surviving_contours_when_reordered() { + let mut hole = rectangle(10.0, 10.0, 20.0, 20.0); + hole.reverse(); + let mut small_hole = rectangle(3.0, 3.0, 5.0, 5.0); + small_hole.reverse(); + let paths = [rectangle(0.0, 0.0, 30.0, 30.0), small_hole, hole]; + for offset in [1.0, 2.0, 5.0, 6.0] { + let style = OutlineStyle::new(offset); + let expected = paths.outline_fixed_scale(&style, 100.0).unwrap(); + for order in [[0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]] { + let source: Vec<_> = order.iter().map(|&i| paths[i].clone()).collect(); + let actual = source.outline_fixed_scale(&style, 100.0).unwrap(); + assert_eq!(actual, expected, "offset={offset}, order={order:?}"); + } + } +} + +#[test] +fn outline_area_filter_keeps_small_offsets_that_merge_into_a_large_shape() { + check_area_filter_after_union(true); +} + +#[test] +fn automatic_scale_outline_area_filter_keeps_merged_shape() { + check_area_filter_after_union(false); +} + +fn check_area_filter_after_union(fixed: bool) { + // Disjoint input squares become overlapping 4x4 squares. Their union is + // a 7x4 rectangle: the final area 28 exceeds the threshold 20. + let source = [rectangle(0.0, 0.0, 2.0, 2.0), rectangle(3.0, 0.0, 5.0, 2.0)]; + let style = OutlineStyle::new(1.0).line_join(LineJoin::Miter(0.1)); + let extract = |options| { + if fixed { + source.outline_custom_fixed_scale(&style, options, 100.0).unwrap() + } else { + source.outline_custom(&style, options) + } + }; + let expected = extract(OverlayOptions::default()); + assert_eq!(expected.len(), 1); + let area: f64 = expected + .iter() + .flatten() + .map(|path| { + path.iter() + .zip(path.iter().cycle().skip(1)) + .map(|(a, b)| 0.5 * (a[0] * b[1] - a[1] * b[0])) + .sum::() + }) + .sum(); + assert!((area - 28.0).abs() < 1e-6, "unfiltered area={area}"); + let mut options = OverlayOptions::default(); + options.min_output_area = 29.0; + assert!( + extract(options).is_empty(), + "the final area filter must still remove the union below threshold" + ); + options.min_output_area = 20.0; + let actual = extract(options); + assert_eq!( + actual, expected, + "minimum output area must be applied to the merged shape of area 28" + ); +} diff --git a/iOverlay/tests/outline_variable_math_tests.rs b/iOverlay/tests/outline_variable_math_tests.rs new file mode 100644 index 00000000..70df34d1 --- /dev/null +++ b/iOverlay/tests/outline_variable_math_tests.rs @@ -0,0 +1,270 @@ +use i_float::int::{angle::Angle, point::IntPoint}; +use i_overlay::mesh::{ + float::{ + outline::offset::OutlineOffset, + style::{LineJoin, OutlineStyle}, + variable_stroke::{StrokeVertex, VariableStrokeStyle, offset::VariableStrokeOffset}, + }, + int::{ + arc::ArcOptions, + outline::offset::IntOutlineOffset, + style::{IntLineJoin, IntOutlineStyle}, + variable_stroke::{IntStrokeVertex, IntVariableStrokeStyle, offset::IntVariableStrokeOffset}, + }, + math::MathMode, +}; +use i_shape::{ + flat::{buffer::FlatContoursBuffer, float::FloatFlatContoursBuffer}, + int::area::Area, +}; + +#[test] +fn default_math_remains_integer() { + assert_eq!(IntOutlineStyle::new(100).math, MathMode::Integer); + assert_eq!(OutlineStyle::new(1.0).math, MathMode::Integer); + assert_eq!(IntVariableStrokeStyle::new().math, MathMode::Integer); + assert_eq!(VariableStrokeStyle::::new().math, MathMode::Integer); +} + +macro_rules! area_checks { + ($name:ident, $int:ty) => { + #[test] + fn $name() { + let square = [ + IntPoint::<$int>::new(-2000, -2000), + IntPoint::new(2000, -2000), + IntPoint::new(2000, 2000), + IntPoint::new(-2000, 2000), + ]; + for math in [MathMode::Integer, MathMode::Float] { + for offset in [-200, 0, 200] { + let style = IntOutlineStyle::new(offset) + .math(math) + .line_join(IntLineJoin::Miter(Angle::from_radians(0.1).unwrap())); + square.validate_outline(&style).unwrap(); + let shapes = square.outline(&style).unwrap(); + let side = 4000.0 + 2.0 * offset as f64; + assert!((shapes.area() as f64 - side * side).abs() <= 2.0 * side); + let mut output = FlatContoursBuffer::default(); + square.outline_into(&style, &mut output).unwrap(); + assert_eq!( + output.points, + shapes + .iter() + .flatten() + .flatten() + .copied() + .collect::>() + ); + } + let path = [ + IntStrokeVertex::new(IntPoint::<$int>::new(0, 0), 400), + IntStrokeVertex::new(IntPoint::new(2400, 3200), 400), + ]; + let style = IntVariableStrokeStyle::new().math(math).arc(ArcOptions { + max_step: Angle::from_radians(0.03).unwrap(), + ..Default::default() + }); + path.validate_variable_stroke().unwrap(); + let shapes = path.variable_stroke(style).unwrap(); + let expected = 4000.0 * 400.0 + core::f64::consts::PI * 200.0 * 200.0; + assert_eq!(shapes.len(), 1); + assert!((shapes.area() as f64 - expected).abs() < 12000.0); + let mut output = FlatContoursBuffer::default(); + path.variable_stroke_into(style, &mut output).unwrap(); + assert_eq!( + output.points, + shapes + .iter() + .flatten() + .flatten() + .copied() + .collect::>() + ); + } + } + }; +} +area_checks!(areas_i16, i16); +area_checks!(areas_i32, i32); +area_checks!(areas_i64, i64); + +#[test] +fn float_math_preserves_local_geometry_at_large_origins() { + let outline = [ + IntPoint::new(0_i64, 0), + IntPoint::new(8000, 0), + IntPoint::new(9000, 6000), + IntPoint::new(1000, 5000), + ]; + let shift = 1_i64 << 60; + let translated = outline.map(|p| IntPoint::new(p.x + shift, p.y - shift)); + for join in [ + IntLineJoin::Bevel, + IntLineJoin::Miter(Angle::from_radians(0.1).unwrap()), + IntLineJoin::Round(ArcOptions::default()), + ] { + let style = IntOutlineStyle::new(400).math(MathMode::Float).line_join(join); + let expected = outline.outline(&style).unwrap(); + let mut actual = translated.outline(&style).unwrap(); + for p in actual.iter_mut().flatten().flatten() { + p.x -= shift; + p.y += shift; + } + assert_eq!(actual, expected); + } + let path = [ + IntStrokeVertex::new(outline[0], 400), + IntStrokeVertex::new(outline[1], 1600), + IntStrokeVertex::new(outline[2], 800), + ]; + let translated = + path.map(|v| IntStrokeVertex::new(IntPoint::new(v.point.x + shift, v.point.y - shift), v.width)); + let style = IntVariableStrokeStyle::new().math(MathMode::Float); + let expected = path.variable_stroke(style).unwrap(); + let mut actual = translated.variable_stroke(style).unwrap(); + for p in actual.iter_mut().flatten().flatten() { + p.x -= shift; + p.y += shift; + } + assert_eq!(actual, expected); +} + +#[test] +fn float_adapters_forward_math_and_replace_flat_output() { + let outline = [[0.0, 0.0], [8.0, 0.0], [9.0, 6.0], [1.0, 5.0]]; + let path = [ + StrokeVertex::new([0.0, 0.0], 0.4), + StrokeVertex::new([8.0, 0.0], 1.6), + StrokeVertex::new([9.0, 6.0], 0.8), + ]; + for math in [MathMode::Integer, MathMode::Float] { + let style = OutlineStyle::new(0.4).math(math).line_join(LineJoin::Round(0.1)); + let shapes = outline.outline_fixed_scale(&style, 1000.0).unwrap(); + let mut output = FloatFlatContoursBuffer::default(); + outline + .outline_fixed_scale_into(&style, 1000.0, &mut output) + .unwrap(); + assert_eq!( + output.points, + shapes.iter().flatten().flatten().copied().collect::>() + ); + let int_outline = outline.map(|p| IntPoint::new((p[0] * 1000.0) as i32, (p[1] * 1000.0) as i32)); + let int_style = IntOutlineStyle::new(400) + .math(math) + .line_join(IntLineJoin::Round(ArcOptions { + max_step: Angle::from_radians(0.1).unwrap(), + ..Default::default() + })); + // The adapter may translate the origin, so compare translation-invariant areas. + let expected = int_outline.outline(&int_style).unwrap().area() as f64 / 1e6; + let area = |shapes: &Vec>>| { + shapes + .iter() + .flatten() + .map(|p| { + (0..p.len()) + .map(|i| p[i][0] * p[(i + 1) % p.len()][1] - p[i][1] * p[(i + 1) % p.len()][0]) + .sum::() + / 2.0 + }) + .sum::() + .abs() + }; + assert!((area(&shapes) - expected).abs() < 0.02); + let style = VariableStrokeStyle::new().math(math); + let shapes = path.variable_stroke_fixed_scale(style, 1000.0).unwrap(); + path.variable_stroke_fixed_scale_into(style, 1000.0, &mut output) + .unwrap(); + assert_eq!( + output.points, + shapes.iter().flatten().flatten().copied().collect::>() + ); + let int_path = path.map(|v| { + IntStrokeVertex::new( + IntPoint::new((v.point[0] * 1000.0) as i32, (v.point[1] * 1000.0) as i32), + (v.width * 1000.0) as i32, + ) + }); + let expected = int_path + .variable_stroke(IntVariableStrokeStyle::new().math(math)) + .unwrap() + .area() as f64 + / 1e6; + assert!((area(&shapes) - expected).abs() < 0.02); + let empty: [StrokeVertex<[f64; 2]>; 0] = []; + empty.variable_stroke_into(style, &mut output); + assert!(output.points.is_empty() && output.ranges.is_empty()); + } +} + +#[cfg(feature = "variable_stroke_debug")] +#[test] +fn debug_geometry_matches_normal_build_for_both_modes() { + let paths = vec![ + vec![], + vec![IntStrokeVertex::new(IntPoint::new(0, 0), 1000)], + vec![ + IntStrokeVertex::new(IntPoint::new(-2000, 0), 400), + IntStrokeVertex::new(IntPoint::new(0, 0), 6000), + IntStrokeVertex::new(IntPoint::new(100, 0), 100), + IntStrokeVertex::new(IntPoint::new(2000, 2000), 4000), + ], + ]; + for math in [MathMode::Integer, MathMode::Float] { + let style = IntVariableStrokeStyle::new().math(math); + let expected = paths.variable_stroke(style).unwrap(); + let debug = paths.variable_stroke_debug(style, Default::default()).unwrap(); + assert_eq!(debug.shapes, expected); + assert!(debug.edges.iter().any(|e| e.path_index == 1)); + assert!(debug.edges.iter().any(|e| e.path_index == 2)); + for (order, edge) in debug.edges.iter().enumerate() { + assert_eq!(edge.order, order); + assert_ne!(edge.a, edge.b); + } + } +} + +#[test] +fn outline_preserves_hole_roles_in_both_modes() { + let paths = vec![ + vec![ + IntPoint::new(-4000, -4000), + IntPoint::new(4000, -4000), + IntPoint::new(4000, 4000), + IntPoint::new(-4000, 4000), + ], + vec![ + IntPoint::new(-1000, -1000), + IntPoint::new(-1000, 1000), + IntPoint::new(1000, 1000), + IntPoint::new(1000, -1000), + ], + ]; + for math in [MathMode::Integer, MathMode::Float] { + for offset in [-200, 0, 200] { + let style = IntOutlineStyle::new(offset) + .math(math) + .line_join(IntLineJoin::Miter(Angle::from_radians(0.1).unwrap())); + let shapes = paths.outline(&style).unwrap(); + assert_eq!(shapes.len(), 1); + assert_eq!(shapes[0].len(), 2); + let outer = 8000_i64 + 2 * offset as i64; + let inner = 2000_i64 - 2 * offset as i64; + assert_eq!(shapes.area(), outer * outer - inner * inner); + } + } +} + +#[test] +fn outline_bounds_use_the_selected_miter_limit() { + let path = [ + IntPoint::new(0_i16, 0), + IntPoint::new(1000, 0), + IntPoint::new(0, 1000), + ]; + // Integer math clamps to 5 degrees; Float uses the requested 1.8 degrees. + let style = IntOutlineStyle::new(300).line_join(IntLineJoin::Miter(Angle::from_radians(0.01).unwrap())); + assert!(path.validate_outline(&style.math(MathMode::Integer)).is_ok()); + assert!(path.validate_outline(&style.math(MathMode::Float)).is_err()); +} diff --git a/iOverlay/tests/output_consistency_tests.rs b/iOverlay/tests/output_consistency_tests.rs index fa27cdd7..d424e453 100644 --- a/iOverlay/tests/output_consistency_tests.rs +++ b/iOverlay/tests/output_consistency_tests.rs @@ -30,9 +30,9 @@ fn flat_and_vector_outputs_match_shapes_with_area_filtering() { let a: Vec<_> = (0..3 + case % 8).map(|_| IntPoint::new(next(), next())).collect(); let b: Vec<_> = (0..3 + case % 9).map(|_| IntPoint::new(next(), next())).collect(); for threshold in [0, 1, 10, 50] { - let mut regular = Overlay::with_contour(&a, &b); + let mut regular = Overlay::from_subj_and_clip(&a, &b); regular.options.min_output_area = threshold; - regular.options.preserve_output_collinear = case % 2 == 0; + regular.options.preserve_output_collinear = true; regular.options.output_direction = if case % 3 == 0 { ContourDirection::Clockwise } else { diff --git a/iOverlay/tests/overlay_tests.rs b/iOverlay/tests/overlay_tests.rs index 4f7f99d0..984b8564 100644 --- a/iOverlay/tests/overlay_tests.rs +++ b/iOverlay/tests/overlay_tests.rs @@ -28,7 +28,7 @@ mod tests { }; fn overlay(test: &BooleanTest, options: IntOverlayOptions, solver: Solver) -> Overlay { - Overlay::with_contours_custom(&test.subj_paths, &test.clip_paths, options, solver) + Overlay::from_subj_and_clip_custom(&test.subj_paths, &test.clip_paths, options, solver) } let mut buffer = Default::default(); @@ -96,12 +96,16 @@ mod tests { #[allow(dead_code)] fn debug_execute(index: usize, overlay_rule: OverlayRule, fill_rule: FillRule, solver: Solver) { let test = BooleanTest::load(index); - let mut overlay = - Overlay::with_contours_custom(&test.subj_paths, &test.clip_paths, Default::default(), solver); + let mut overlay = Overlay::from_subj_and_clip_custom( + &test.subj_paths, + &test.clip_paths, + Default::default(), + solver, + ); let graph = overlay.build_graph_view(fill_rule).unwrap(); let result = graph.extract_shapes(overlay_rule, &mut Default::default()); - println!("{}: {}", &overlay_rule, result.json_print()); + println!("{}: {}", overlay_rule, result.json_print()); match overlay_rule { OverlayRule::Subject => { assert_eq!(true, overlay::is_group_of_shapes_one_of(&result, &test.subject)) @@ -128,7 +132,7 @@ mod tests { #[allow(dead_code)] fn print_json(index: usize, fill_rule: FillRule) { let test = BooleanTest::load(index); - let mut overlay = Overlay::with_contours(&test.subj_paths, &test.clip_paths); + let mut overlay = Overlay::from_subj_and_clip(&test.subj_paths, &test.clip_paths); let mut buffer = overlay.boolean_buffer.take().unwrap_or_default(); let graph = overlay.build_graph_view(fill_rule).unwrap(); diff --git a/iOverlay/tests/point_location_threshold_tests.rs b/iOverlay/tests/point_location_threshold_tests.rs new file mode 100644 index 00000000..269bb188 --- /dev/null +++ b/iOverlay/tests/point_location_threshold_tests.rs @@ -0,0 +1,53 @@ +use i_float::int::point::IntPoint; +use i_overlay::core::point_location::IntPointContainment; + +#[test] +fn point_location_matches_analytic_strip_on_both_sides_of_tree_threshold() { + for intervals in [3998_i32, 3999, 4000] { + // 7998, 8000, 8002 noncollinear vertices; input cleanup preserves all. + let mut contour: Vec<_> = (0..=intervals) + .map(|i| IntPoint::new(4 * i, 100 + 4 * (i % 2))) + .collect(); + contour.extend( + (0..=intervals) + .rev() + .map(|i| IntPoint::new(4 * i, -100 - 4 * (i % 2))), + ); + assert_eq!(contour.len(), 2 * (intervals as usize + 1)); + let mut queries = Vec::new(); + let mut expected = Vec::new(); + for x in -1..=4 * intervals + 1 { + if x == 0 || x == 4 * intervals { + continue; + } + let height = 100 + if (x / 4) % 2 == 0 { x % 4 } else { 4 - x % 4 }; + for y in [-105_i32, -103, -101, -99, 0, 99, 101, 103, 105] { + if y == height || y == -height { + continue; + } + queries.push(IntPoint::new(x, y)); + expected.push(x > 0 && x < 4 * intervals && y.abs() < height); + } + } + // Transposition produces thousands of simultaneously active edges. + for transpose in [false, true] { + if transpose { + for p in contour.iter_mut().chain(queries.iter_mut()) { + std::mem::swap(&mut p.x, &mut p.y); + } + } + for reverse in [false, true] { + if reverse { + contour.reverse(); + } + let actual = contour.contains_points(&queries); + if let Some(index) = actual.iter().zip(&expected).position(|(a, b)| a != b) { + panic!( + "intervals={intervals}, transpose={transpose}, reverse={reverse}, point={:?}, actual={}, expected={}", + queries[index], actual[index], expected[index] + ); + } + } + } + } +} diff --git a/iOverlay/tests/predicate_cell_oracle_tests.rs b/iOverlay/tests/predicate_cell_oracle_tests.rs new file mode 100644 index 00000000..62309825 --- /dev/null +++ b/iOverlay/tests/predicate_cell_oracle_tests.rs @@ -0,0 +1,116 @@ +use i_float::int::point::IntPoint; +use i_overlay::core::fill_rule::FillRule; +use i_overlay::core::overlay::ShapeType; +use i_overlay::core::relate::PredicateOverlay; +use i_overlay::core::solver::Solver; +use std::collections::BTreeSet; + +fn filled(count: i32, rule: FillRule) -> bool { + match rule { + FillRule::EvenOdd => count % 2 != 0, + FillRule::NonZero => count != 0, + FillRule::Positive => count > 0, + FillRule::Negative => count < 0, + } +} + +type Vertices = BTreeSet<(i32, i32)>; +type Edges = BTreeSet<(i32, i32, i32, i32)>; + +fn closure(cells: &BTreeSet<(i32, i32)>) -> (Vertices, Edges) { + let mut vertices = BTreeSet::new(); + let mut edges = BTreeSet::new(); + for &(x, y) in cells { + vertices.extend([(x, y), (x + 1, y), (x, y + 1), (x + 1, y + 1)]); + edges.extend([ + (x, y, x + 1, y), + (x, y + 1, x + 1, y + 1), + (x, y, x, y + 1), + (x + 1, y, x + 1, y + 1), + ]); + } + (vertices, edges) +} + +#[test] +fn predicates_match_filled_cells_after_contour_cancellation() { + let mut seed = 0x3587_89ad_182a_4b12_u64; + let mut next = || { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + (seed >> 32) as usize + }; + for case in 0..1200 { + let mut contours = [Vec::new(), Vec::new()]; + let mut counts = [[0_i32; 64]; 2]; + for side in 0..2 { + for _ in 0..1 + next() % 12 { + let x = next() % 8; + let y = next() % 8; + let x1 = x + 1 + next() % (8 - x); + let y1 = y + 1 + next() % (8 - y); + let sign = if next() % 2 == 0 { 1 } else { -1 }; + let mut path = vec![ + IntPoint::new(x as i32, y as i32), + IntPoint::new(x1 as i32, y as i32), + IntPoint::new(x1 as i32, y1 as i32), + IntPoint::new(x as i32, y1 as i32), + ]; + if sign < 0 { + path.reverse(); + } + let copies = 1 + next() % 3; + for _ in 0..copies { + contours[side].push(path.clone()); + } + for yy in y..y1 { + for xx in x..x1 { + counts[side][8 * yy + xx] += sign * copies as i32; + } + } + } + } + let mut overlay = PredicateOverlay::new(0); + overlay.solver = [Solver::LIST, Solver::TREE, Solver::FRAG][case % 3]; + overlay.add_source(&contours[0], ShapeType::Subject); + overlay.add_source(&contours[1], ShapeType::Clip); + for rule in [ + FillRule::EvenOdd, + FillRule::NonZero, + FillRule::Positive, + FillRule::Negative, + FillRule::EvenOdd, + ] { + overlay.fill_rule = rule; + let cells: Vec> = counts + .iter() + .map(|c| { + c.iter() + .enumerate() + .filter(|(_, n)| filled(**n, rule)) + .map(|(i, _)| ((i % 8) as i32, (i / 8) as i32)) + .collect() + }) + .collect(); + let (av, ae) = closure(&cells[0]); + let (bv, be) = closure(&cells[1]); + let interior = !cells[0].is_disjoint(&cells[1]); + let intersects = !av.is_disjoint(&bv); + let point = intersects && !interior && ae.is_disjoint(&be); + let within = !cells[0].is_empty() && cells[0].is_subset(&cells[1]); + let expected = (intersects, interior, intersects && !interior, point, within); + for repeat in 0..2 { + let actual = ( + overlay.intersects(), + overlay.interiors_intersect(), + overlay.touches(), + overlay.point_intersects(), + overlay.within(), + ); + assert_eq!( + actual, expected, + "case={case}, rule={rule:?}, repeat={repeat}, contours={contours:?}" + ); + } + } + } +} diff --git a/iOverlay/tests/resolved_geometry_tests.rs b/iOverlay/tests/resolved_geometry_tests.rs new file mode 100644 index 00000000..da028134 --- /dev/null +++ b/iOverlay/tests/resolved_geometry_tests.rs @@ -0,0 +1,102 @@ +use i_float::int::point::IntPoint; +use i_overlay::core::fill_rule::FillRule; +use i_overlay::core::overlay::Overlay; +use i_overlay::core::overlay_rule::OverlayRule; +use i_overlay::core::point_location::IntPointContainment; +use i_overlay::core::solver::Solver; + +type Shapes = Vec>>; +fn cross(a: IntPoint, b: IntPoint, c: IntPoint) -> i64 { + (b.x - a.x) as i64 * (c.y - a.y) as i64 - (b.y - a.y) as i64 * (c.x - a.x) as i64 +} +fn oracle(shapes: &Shapes, p: IntPoint) -> Option { + let mut result = false; + for shape in shapes { + let mut inside = false; + for path in shape { + for (&a, &b) in path.iter().zip(path.iter().cycle().skip(1)) { + let c = cross(a, b, p); + if c == 0 + && p.x >= a.x.min(b.x) + && p.x <= a.x.max(b.x) + && p.y >= a.y.min(b.y) + && p.y <= a.y.max(b.y) + { + return None; + } + if (a.y > p.y) != (b.y > p.y) && ((c > 0) == (b.y > a.y)) { + inside = !inside; + } + } + } + result |= inside; + } + Some(result) +} + +#[test] +fn dense_boolean_output_has_resolved_edges_and_correct_point_locations() { + let mut seed = 0x1792_8721_8eda_38fe_u64; + let mut next = || { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + ((seed >> 32) % 101) as i32 - 50 + }; + for case in 0..240 { + let a: Vec<_> = (0..30 + case % 70) + .map(|_| IntPoint::new(next(), next())) + .collect(); + let b: Vec<_> = (0..20 + case % 40) + .map(|_| IntPoint::new(next(), next())) + .collect(); + let mut overlay = Overlay::from_subj_and_clip(&a, &b); + overlay.solver = [Solver::LIST, Solver::TREE, Solver::FRAG][case % 3]; + overlay.options.ogc = true; + let rule = [ + OverlayRule::Union, + OverlayRule::Intersect, + OverlayRule::Difference, + OverlayRule::Xor, + ][case % 4]; + let fill = [ + FillRule::EvenOdd, + FillRule::NonZero, + FillRule::Positive, + FillRule::Negative, + ][(case / 4) % 4]; + let shapes = overlay.overlay(rule, fill); + let edges: Vec<_> = shapes + .iter() + .flatten() + .flat_map(|p| p.iter().copied().zip(p.iter().copied().cycle().skip(1))) + .collect(); + for (i, &(a, b)) in edges.iter().enumerate() { + assert_ne!(a, b, "zero length output edge, case={case}"); + for &(c, d) in &edges[i + 1..] { + let proper = cross(a, b, c).signum() * cross(a, b, d).signum() < 0 + && cross(c, d, a).signum() * cross(c, d, b).signum() < 0; + assert!( + !proper, + "unresolved crossing, case={case}, edges={a:?}..{b:?}, {c:?}..{d:?}" + ); + } + } + let mut queries = Vec::new(); + let mut expected = Vec::new(); + for y in (-52..=52).step_by(3) { + for x in (-52..=52).step_by(3) { + let p = IntPoint::new(x, y); + if let Some(inside) = oracle(&shapes, p) { + queries.push(p); + expected.push(inside); + } + } + } + let actual = shapes.contains_points(&queries); + if let Some(i) = actual.iter().zip(&expected).position(|(a, b)| a != b) { + panic!( + "case={case}, point={:?}, actual={}, expected={}", + queries[i], actual[i], expected[i] + ); + } + } +} diff --git a/iOverlay/tests/simplify_winding_tests.rs b/iOverlay/tests/simplify_winding_tests.rs new file mode 100644 index 00000000..a56b2b8d --- /dev/null +++ b/iOverlay/tests/simplify_winding_tests.rs @@ -0,0 +1,117 @@ +use i_float::int::{number::int::IntNumber, point::IntPoint}; +use i_overlay::core::{ + fill_rule::FillRule, + integer::OverlayInt, + overlay::{ContourDirection, IntOverlayOptions, Overlay}, + overlay_rule::OverlayRule, + simplify::Simplify, +}; +use i_shape::{ + flat::buffer::FlatContoursBuffer, + int::{path::ContourExtension, shape::IntShapes}, +}; + +// Preserve orientation; only ignore which vertex starts a closed contour. +fn canonical(mut shapes: IntShapes) -> IntShapes { + for contour in shapes.iter_mut().flatten() { + let first = contour.iter().enumerate().min_by_key(|(_, p)| **p).unwrap().0; + contour.rotate_left(first); + } + shapes +} + +fn check_fill_and_output_direction + core::fmt::Debug>() { + let ccw = [(0_i16, 0_i16), (100, 0), (100, 100), (0, 100)] + .map(|(x, y)| IntPoint::new(I::from(x), I::from(y))) + .to_vec(); + for input_clockwise in [false, true] { + let mut contour = ccw.clone(); + if input_clockwise { + contour.reverse(); + } + let shape = vec![contour.clone()]; + let shapes = vec![shape.clone()]; + for rule in [ + FillRule::EvenOdd, + FillRule::NonZero, + FillRule::Positive, + FillRule::Negative, + ] { + let filled = match rule { + FillRule::EvenOdd | FillRule::NonZero => true, + FillRule::Positive => !input_clockwise, + FillRule::Negative => input_clockwise, + }; + for direction in [ContourDirection::CounterClockwise, ContourDirection::Clockwise] { + let options = IntOverlayOptions { + output_direction: direction, + ..Default::default() + }; + let expected = canonical( + Overlay::from_subj_custom(&shapes, options, Default::default()) + .overlay(OverlayRule::Subject, rule), + ); + assert_eq!(expected.len(), usize::from(filled)); + if filled { + assert_eq!( + expected[0][0].is_clockwise_ordered(), + direction == ContourDirection::Clockwise + ); + } + let check = |actual, entry| { + assert_eq!( + canonical(actual), + expected, + "entry={entry}, input_clockwise={input_clockwise}, rule={rule:?}, output={direction:?}" + ); + }; + + // The same single-contour fast path is reachable through all resource levels. + check(contour.simplify(rule, options), "contour resource"); + check(shape.simplify(rule, options), "shape resource"); + check(shapes.simplify(rule, options), "shapes resource"); + let mut flat = FlatContoursBuffer::default(); + flat.set_with_contour(&contour); + check(flat.simplify(rule, options), "flat resource"); + + let mut overlay = Overlay::new_custom(4, options, Default::default()); + let result = overlay.simplify_contour(&contour, rule); + assert_eq!( + result.is_none(), + filled && input_clockwise == (direction == ContourDirection::Clockwise), + "None must mean that the input contour needs no changes" + ); + check(result.unwrap_or_else(|| shapes.clone()), "simplify_contour"); + check( + overlay + .simplify_shape(&shape, rule) + .unwrap_or_else(|| shapes.clone()), + "simplify_shape", + ); + check(overlay.simplify_source(&shapes, rule), "simplify_source"); + overlay.simplify_flat_buffer(&mut flat, rule); + let actual = if flat.is_empty() { + vec![] + } else { + vec![flat.to_contours()] + }; + check(actual, "simplify_flat_buffer"); + } + } + } +} + +#[test] +fn simple_contour_winding_i16() { + check_fill_and_output_direction::(); +} + +#[test] +fn simple_contour_winding_i32() { + check_fill_and_output_direction::(); +} + +#[test] +fn simple_contour_winding_i64() { + check_fill_and_output_direction::(); +} diff --git a/iOverlay/tests/slice_area_tests.rs b/iOverlay/tests/slice_area_tests.rs index f4715fea..0cea5566 100644 --- a/iOverlay/tests/slice_area_tests.rs +++ b/iOverlay/tests/slice_area_tests.rs @@ -24,7 +24,7 @@ fn area_two(path: &[IntPoint]) -> i64 { #[test] fn slice_minimum_area_removes_small_piece() { - let mut overlay = StringOverlay::with_shape_contour(&square()); + let mut overlay = StringOverlay::from_shape(&square()); overlay.add_string_line([IntPoint::new(1, -1), IntPoint::new(1, 11)]); let graph = overlay.build_graph_view(FillRule::NonZero).unwrap(); let unfiltered = graph.extract_shapes(StringRule::Slice); @@ -40,8 +40,10 @@ fn slice_minimum_area_removes_small_piece() { (90, vec![180]), (91, vec![]), ] { - let mut options = IntOverlayOptions::default(); - options.min_output_area = threshold; + let options = IntOverlayOptions { + min_output_area: threshold, + ..Default::default() + }; let filtered = graph.extract_shapes_custom(StringRule::Slice, options); let mut areas: Vec<_> = filtered.iter().map(|s| area_two(&s[0])).collect(); areas.sort(); @@ -51,10 +53,12 @@ fn slice_minimum_area_removes_small_piece() { #[test] fn slice_minimum_area_above_subject_area_returns_empty() { - let mut overlay = StringOverlay::with_shape_contour(&square()); + let mut overlay = StringOverlay::from_shape(&square()); let graph = overlay.build_graph_view(FillRule::NonZero).unwrap(); - let mut options = IntOverlayOptions::default(); - options.min_output_area = 1000; + let options = IntOverlayOptions { + min_output_area: 1000, + ..Default::default() + }; let result = graph.extract_shapes_custom(StringRule::Slice, options); assert!( result.is_empty(), @@ -64,7 +68,7 @@ fn slice_minimum_area_above_subject_area_returns_empty() { #[test] fn slice_small_area_threshold_preserves_large_loops_and_holes() { - let mut overlay = StringOverlay::with_shape_contour(&square()); + let mut overlay = StringOverlay::from_shape(&square()); overlay.add_string_path(&[ IntPoint::new(0, 0), IntPoint::new(3, 3), @@ -79,8 +83,10 @@ fn slice_small_area_threshold_preserves_large_loops_and_holes() { let total_area: i64 = expected.iter().flatten().map(|p| area_two(p)).sum(); assert_eq!(total_area, 200); for threshold in [1, 16] { - let mut options = IntOverlayOptions::default(); - options.min_output_area = threshold; + let options = IntOverlayOptions { + min_output_area: threshold, + ..Default::default() + }; let actual = graph.extract_shapes_custom(StringRule::Slice, options); assert_eq!( actual, expected, diff --git a/iOverlay/tests/slice_partition_tests.rs b/iOverlay/tests/slice_partition_tests.rs new file mode 100644 index 00000000..c828ca8a --- /dev/null +++ b/iOverlay/tests/slice_partition_tests.rs @@ -0,0 +1,116 @@ +use i_float::int::point::IntPoint; +use i_overlay::core::fill_rule::FillRule; +use i_overlay::string::overlay::StringOverlay; +use i_overlay::string::rule::StringRule; + +fn area_two(path: &[IntPoint]) -> i64 { + path.iter() + .zip(path.iter().cycle().skip(1)) + .map(|(a, b)| i64::from(a.x) * i64::from(b.y) - i64::from(a.y) * i64::from(b.x)) + .sum() +} + +// Independent ray-crossing oracle. Samples have odd coordinates; cut edges +// lie on even grid lines, so no sample lies on an input boundary. +fn contains(path: &[IntPoint], p: IntPoint) -> bool { + let mut inside = false; + for (a, b) in path.iter().zip(path.iter().cycle().skip(1)) { + if (a.y > p.y) != (b.y > p.y) { + let cross = + i64::from(b.x - a.x) * i64::from(p.y - a.y) - i64::from(b.y - a.y) * i64::from(p.x - a.x); + if (cross > 0) == (b.y > a.y) { + inside = !inside; + } + } + } + inside +} + +#[test] +fn grid_cuts_partition_subject_without_gaps_or_overlaps() { + let subject = [ + IntPoint::new(0, 0), + IntPoint::new(20, 0), + IntPoint::new(20, 20), + IntPoint::new(0, 20), + ]; + let mut seed = 0x7e59_1928_f17a_57b3_u64; + for case in 0..512 { + let mut cuts = Vec::new(); + for x in (0..=20).step_by(2) { + for y in (0..=20).step_by(2) { + for (dx, dy) in [(2, 0), (0, 2)] { + if x + dx > 20 || y + dy > 20 { + continue; + } + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + if (seed >> 32) % 100 < 30 + case % 60 { + cuts.push([IntPoint::new(x, y), IntPoint::new(x + dx, y + dy)]); + } + } + } + } + let mut overlay = StringOverlay::from_shape(&subject); + overlay.add_string_lines(&cuts); + let graph = overlay.build_graph_view(FillRule::NonZero).unwrap(); + let result = graph.extract_shapes(StringRule::Slice); + let total: i64 = result.iter().flatten().map(|path| area_two(path)).sum(); + assert_eq!(total, 800, "case={case}, cuts={cuts:?}, result={result:?}"); + for x in (1..20).step_by(2) { + for y in (1..20).step_by(2) { + let p = IntPoint::new(x, y); + let count = result + .iter() + .filter(|shape| { + contains(&shape[0], p) && !shape[1..].iter().any(|hole| contains(hole, p)) + }) + .count(); + assert_eq!( + count, 1, + "case={case}, sample={p:?}, cuts={cuts:?}, result={result:?}" + ); + } + } + } +} + +#[test] +fn interior_self_crossing_cuts_preserve_subject_area() { + let subject = [ + IntPoint::new(0, 0), + IntPoint::new(100, 0), + IntPoint::new(100, 100), + IntPoint::new(0, 100), + ]; + let mut seed = 0x61f4_2ce8_290b_5b83_u64; + let mut next = || { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + ((seed >> 32) % 91) as i32 + 5 + }; + for case in 0..3000 { + let mut cut: Vec<_> = (0..3 + case % 25) + .map(|_| IntPoint::new(next(), next())) + .collect(); + if case % 2 == 0 { + cut.push(cut[0]); + } + let mut overlay = StringOverlay::from_shape(&subject); + overlay.add_string_path(&cut); + let graph = overlay.build_graph_view(FillRule::NonZero).unwrap(); + let result = graph.extract_shapes(StringRule::Slice); + let total: i64 = result.iter().flatten().map(|path| area_two(path)).sum(); + assert_eq!(total, 20000, "case={case}, cut={cut:?}, result={result:?}"); + for shape in &result { + assert!( + area_two(&shape[0]) > 0, + "invalid outer: case={case}, cut={cut:?}, shape={shape:?}" + ); + for hole in &shape[1..] { + assert!( + area_two(hole) < 0, + "invalid hole: case={case}, cut={cut:?}, hole={hole:?}" + ); + } + } + } +} diff --git a/iOverlay/tests/stroke_bench.rs b/iOverlay/tests/stroke_bench.rs new file mode 100644 index 00000000..cc9b0a62 --- /dev/null +++ b/iOverlay/tests/stroke_bench.rs @@ -0,0 +1,100 @@ +//! Manual comparison; run with release optimizations and an otherwise idle machine. +use core::hint::black_box; +use i_float::float::number::FloatNumber; +use i_float::int::{angle::Angle, point::IntPoint, unit_vector::UnitIntVector}; +use i_overlay::core::integer::OverlayInt; +use i_overlay::mesh::int::{ + arc::ArcOptions, + stroke::offset::IntStrokeOffset, + style::{IntLineCap, IntLineJoin, IntStrokeStyle}, +}; +use i_overlay::mesh::math::MathMode; +use std::time::Instant; + +fn measure(mut run: impl FnMut() -> usize) -> (f64, usize) { + let count = black_box(run()); + let mut samples = [0.0; 7]; + for sample in &mut samples { + let start = Instant::now(); + for _ in 0..10 { + black_box(run()); + } + *sample = start.elapsed().as_secs_f64() * 100_000.0; + } + samples.sort_by(f64::total_cmp); + (samples[3], count) +} + +fn benchmark() { + for jagged in [false, true] { + let paths: Vec>> = (0..16) + .map(|row| { + (0..96) + .map(|j| { + let x = j as f64 * 2048.0; + let y = row as f64 * 32768.0 + + if jagged { + if j % 2 == 0 { 0.0 } else { 4096.0 } + } else { + FloatNumber::sin(j as f64 * 0.18) * 4096.0 + }; + IntPoint::new(I::from_rounded_float(x), I::from_rounded_float(y)) + }) + .collect() + }) + .collect(); + let vectors: Vec<_> = paths + .iter() + .flat_map(|p| p.windows(2).map(|pair| pair[1] - pair[0])) + .collect(); + for math in [MathMode::Integer, MathMode::Float] { + let (us, _) = measure(|| { + for &v in &vectors { + match math { + MathMode::Integer => { + black_box(black_box(v).fast_normalize()); + } + MathMode::Float => { + black_box(UnitIntVector::normalize_with_float(black_box(v))); + } + } + } + vectors.len() + }); + println!("i{} jagged={jagged} normalize {math:?}: {us:.2} us", I::BITS); + } + for join in [ + IntLineJoin::Bevel, + IntLineJoin::Miter(Angle::from_radians(0.1).unwrap()), + IntLineJoin::Round(ArcOptions::default()), + ] { + for math in [MathMode::Integer, MathMode::Float] { + let style = IntStrokeStyle::new(I::from_rounded_float(512.0)) + .math(math) + .line_join(join) + .start_cap(IntLineCap::Round(ArcOptions::default())) + .end_cap(IntLineCap::Square); + let (us, count) = measure(|| { + black_box(&paths) + .stroke(black_box(&style), false) + .unwrap() + .iter() + .flatten() + .map(Vec::len) + .sum() + }); + println!( + "i{} jagged={jagged} join={join:?} stroke {math:?}: {us:.2} us, count={count}", + I::BITS + ); + } + } + } +} + +#[test] +#[ignore = "manual timing: cargo test --release --test stroke_bench benchmark_math_modes -- --ignored --nocapture"] +fn benchmark_math_modes() { + benchmark::(); + benchmark::(); +} diff --git a/iOverlay/tests/stroke_cap_precision_tests.rs b/iOverlay/tests/stroke_cap_precision_tests.rs new file mode 100644 index 00000000..b1e1ec68 --- /dev/null +++ b/iOverlay/tests/stroke_cap_precision_tests.rs @@ -0,0 +1,106 @@ +use i_overlay::float::overlay::OverlayOptions; +use i_overlay::mesh::float::stroke::offset::StrokeOffset; +use i_overlay::mesh::float::style::{LineCap, StrokeStyle}; +use i_overlay::mesh::math::MathMode; + +#[test] +fn round_caps_survive_coarse_integer_grid() { + for math in [MathMode::Integer, MathMode::Float] { + let path = [[0.0, 0.0], [10.0, 0.0]]; + let style = StrokeStyle::new(4.0) + .math(math) + .start_cap(LineCap::Round(0.01)) + .end_cap(LineCap::Round(0.01)); + let output = path.stroke_fixed_scale(style, false, 1.0).unwrap(); + assert_eq!(output.len(), 1); + assert_eq!(output[0].len(), 1); + assert!(output[0][0].len() >= 4); + } +} + +#[test] +fn round_caps_survive_coarse_grid_without_cleanup() { + for math in [MathMode::Integer, MathMode::Float] { + let path = [[0.0, 0.0], [10.0, 0.0]]; + let style = StrokeStyle::new(4.0) + .math(math) + .start_cap(LineCap::Round(0.01)) + .end_cap(LineCap::Round(0.01)); + let mut options = OverlayOptions::default(); + options.clean_result = false; + let output = path + .stroke_custom_fixed_scale(style, false, options, 1.0) + .unwrap(); + assert_eq!(output.len(), 1); + } +} + +#[test] +fn coarse_butt_and_square_caps_and_fine_round_caps_control() { + for math in [MathMode::Integer, MathMode::Float] { + let path = [[0.0, 0.0], [10.0, 0.0]]; + for (cap, scale) in [ + (LineCap::Butt, 1.0), + (LineCap::Square, 1.0), + (LineCap::Round(0.01), 100.0), + ] { + let style = StrokeStyle::new(4.0) + .math(math) + .start_cap(cap.clone()) + .end_cap(cap.clone()); + let output = path.stroke_fixed_scale(style, false, scale).unwrap(); + assert_eq!(output.len(), 1, "cap={cap:?}, scale={scale}"); + } + } +} + +#[test] +fn custom_cap_without_repeated_points_control() { + for math in [MathMode::Integer, MathMode::Float] { + let path = [[0.0, 0.0], [10.0, 0.0]]; + let cap = LineCap::Custom(std::rc::Rc::from([[1.0, -1.0], [1.0, 1.0]])); + let style = StrokeStyle::new(4.0) + .math(math) + .start_cap(cap.clone()) + .end_cap(cap); + let output = path.stroke(style, false); + assert_eq!(output.len(), 1); + } +} + +#[test] +fn repeated_custom_cap_points_preserve_stroke() { + const TEST_NAME: &str = "repeated_custom_cap_points_preserve_stroke"; + const CHILD_ENV: &str = "I_OVERLAY_REPEATED_CAP_TEST_CHILD"; + if std::env::var_os(CHILD_ENV).is_none() { + // A malformed graph may never finish extracting a contour. Keep this + // regression bounded even when the underlying bug is present. + let mut child = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", TEST_NAME, "--nocapture"]) + .env(CHILD_ENV, "1") + .spawn() + .unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3); + loop { + if let Some(status) = child.try_wait().unwrap() { + assert!(status.success(), "custom cap child failed: {status}"); + return; + } + if std::time::Instant::now() >= deadline { + child.kill().unwrap(); + child.wait().unwrap(); + panic!("a two-point stroke with a repeated custom cap point did not finish within 3 seconds"); + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + } + let path = [[0.0, 0.0], [10.0, 0.0]]; + let cap = LineCap::Custom(std::rc::Rc::from([[1.0, -1.0], [1.0, -1.0], [1.0, 1.0]])); + let style = StrokeStyle::new(4.0).start_cap(cap.clone()).end_cap(cap); + let output = path.stroke(style, false); + assert_eq!( + output.len(), + 1, + "duplicating a cap template point must not erase a stroke" + ); +} diff --git a/iOverlay/tests/stroke_coverage_tests.rs b/iOverlay/tests/stroke_coverage_tests.rs new file mode 100644 index 00000000..1df61d37 --- /dev/null +++ b/iOverlay/tests/stroke_coverage_tests.rs @@ -0,0 +1,224 @@ +use i_overlay::mesh::float::outline::offset::OutlineOffset; +use i_overlay::mesh::float::stroke::offset::StrokeOffset; +use i_overlay::mesh::float::style::OutlineStyle; +use i_overlay::mesh::float::style::{LineCap, LineJoin, StrokeStyle}; +use i_overlay::mesh::float::variable_stroke::offset::VariableStrokeOffset; +use i_overlay::mesh::float::variable_stroke::{StrokeVertex, VariableStrokeStyle}; +use i_overlay::mesh::math::MathMode; + +fn contains(shapes: &[Vec>], p: [f64; 2]) -> bool { + shapes.iter().any(|shape| { + let mut inside = false; + for contour in shape { + let mut a = contour[contour.len() - 1]; + for &b in contour { + if (a[1] > p[1]) != (b[1] > p[1]) { + let x = a[0] + (p[1] - a[1]) * (b[0] - a[0]) / (b[1] - a[1]); + if p[0] < x { + inside = !inside; + } + } + a = b; + } + } + inside + }) +} + +fn assert_vertex_disks_covered(shapes: &[Vec>], path: &[StrokeVertex<[f64; 2]>], case: usize) { + for (index, vertex) in path.iter().enumerate() { + for sample in 0..16 { + let angle = sample as f64 * core::f64::consts::TAU / 16.0; + // Stay well inside the disk to exclude tessellation and grid error. + let radius = 0.4 * vertex.width; + let point = [ + vertex.point[0] + radius * angle.cos(), + vertex.point[1] + radius * angle.sin(), + ]; + assert!( + contains(shapes, point), + "uncovered disk: case={case}, vertex={index}, sample={sample}, point={point:?}, path={path:?}" + ); + } + } +} + +#[test] +fn round_strokes_cover_vertex_disks() { + for math in [MathMode::Integer, MathMode::Float] { + let mut seed = 0x37a2_b951_d477_1011_u64; + let mut next = || { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + ((seed >> 32) % 21) as f64 - 10.0 + }; + for case in 0..2000 { + let path: Vec<_> = (0..3 + case % 8).map(|_| [next(), next()]).collect(); + let style = StrokeStyle::new(4.0) + .math(math) + .line_join(LineJoin::Round(0.05)) + .start_cap(LineCap::Round(0.05)) + .end_cap(LineCap::Round(0.05)); + let shapes = path.stroke_fixed_scale(style, false, 10000.0).unwrap(); + let vertices: Vec<_> = path.iter().map(|&p| StrokeVertex::new(p, 4.0)).collect(); + assert_vertex_disks_covered(&shapes, &vertices, case); + } + } +} + +#[test] +fn round_join_covers_a_reversing_vertex() { + for math in [MathMode::Integer, MathMode::Float] { + let path = [[0.0, 0.0], [10.0, 0.0], [0.0, 0.0]]; + let style = StrokeStyle::new(4.0) + .math(math) + .line_join(LineJoin::Round(0.05)) + .start_cap(LineCap::Round(0.05)) + .end_cap(LineCap::Round(0.05)); + let shapes = path.stroke_fixed_scale(style, false, 10000.0).unwrap(); + assert!( + contains(&shapes, [11.5, 0.0]), + "round join must cover the turn at x=10" + ); + } +} + +#[test] +fn round_join_covers_a_diagonal_reversing_vertex() { + for math in [MathMode::Integer, MathMode::Float] { + let path = [[0.0, 0.0], [6.0, -8.0], [0.0, 0.0]]; + let style = StrokeStyle::new(4.0) + .math(math) + .line_join(LineJoin::Round(0.05)) + .start_cap(LineCap::Round(0.05)) + .end_cap(LineCap::Round(0.05)); + let shapes = path.stroke_fixed_scale(style, false, 10000.0).unwrap(); + assert!( + contains(&shapes, [6.9, -9.2]), + "round join must cover the diagonal turn" + ); + } +} + +#[test] +fn round_outline_covers_a_diagonal_spike() { + let path = [ + [0.0, 0.0], + [0.0, 10.0], + [-10.0, 10.0], + [-10.0, 0.0], + [0.0, 0.0], + [6.0, -8.0], + ]; + let style = OutlineStyle::new(2.0).line_join(LineJoin::Round(0.05)); + let shapes = path.outline_fixed_scale(&style, 10000.0).unwrap(); + assert!( + contains(&shapes, [6.9, -9.2]), + "round outline must cover the diagonal tip" + ); +} + +#[test] +fn variable_strokes_cover_vertex_disks() { + let mut seed = 0x37a2_b951_d477_1011_u64; + let mut next = || { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + ((seed >> 32) % 21) as f64 - 10.0 + }; + for case in 0..2000 { + let path: Vec<_> = (0..3 + case % 8) + .map(|_| StrokeVertex::new([next(), next()], next() + 11.0)) + .collect(); + let shapes = path + .variable_stroke_fixed_scale(VariableStrokeStyle::new().round_angle(0.05), 10000.0) + .unwrap(); + assert_vertex_disks_covered(&shapes, &path, case); + } +} + +#[test] +fn variable_strokes_cover_interpolated_disks() { + let mut seed = 0xa153_61b9_911d_7283_u64; + let mut next = || { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + ((seed >> 32) % 41) as f64 - 20.0 + }; + for case in 0..3000 { + let path: Vec<_> = (0..2 + case % 8) + .map(|_| StrokeVertex::new([next(), next()], next() + 20.0)) + .collect(); + let shapes = path + .variable_stroke_fixed_scale(VariableStrokeStyle::new().round_angle(0.05), 10000.0) + .unwrap(); + let mut samples = Vec::new(); + for pair in path.windows(2) { + for step in 1..4 { + let t = step as f64 / 4.0; + let width = pair[0].width * (1.0 - t) + pair[1].width * t; + if width == 0.0 { + continue; + } + samples.push(StrokeVertex::new( + [ + pair[0].point[0] * (1.0 - t) + pair[1].point[0] * t, + pair[0].point[1] * (1.0 - t) + pair[1].point[1] * t, + ], + width, + )); + } + } + assert_vertex_disks_covered(&shapes, &samples, case); + } +} + +// Minimize squared distance minus squared interpolated radius along a +// centerline segment. This oracle does not construct joins or tangent edges. +fn within_variable_stroke(path: &[StrokeVertex<[f64; 2]>], p: [f64; 2]) -> bool { + path.windows(2).any(|pair| { + let a = pair[0]; + let b = pair[1]; + let dx = b.point[0] - a.point[0]; + let dy = b.point[1] - a.point[1]; + let x = p[0] - a.point[0]; + let y = p[1] - a.point[1]; + // Allow a margin for integer snapping and arc approximation. + let r = 0.5 * a.width + 0.03; + let dr = 0.5 * (b.width - a.width); + let qa = dx * dx + dy * dy - dr * dr; + let qb = -2.0 * (x * dx + y * dy + r * dr); + let qc = x * x + y * y - r * r; + let mut minimum = qc.min(qa + qb + qc); + if qa > 0.0 { + let t = (-qb / (2.0 * qa)).clamp(0.0, 1.0); + minimum = minimum.min((qa * t + qb) * t + qc); + } + minimum <= 0.0 + }) +} + +#[test] +fn variable_strokes_do_not_fill_outside_interpolated_disks() { + let mut seed = 0xa153_61b9_911d_7283_u64; + let mut next = || { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + ((seed >> 32) % 41) as f64 - 20.0 + }; + for case in 0..500 { + let path: Vec<_> = (0..2 + case % 8) + .map(|_| StrokeVertex::new([next(), next()], next() + 20.0)) + .collect(); + let shapes = path + .variable_stroke_fixed_scale(VariableStrokeStyle::new().round_angle(0.05), 10000.0) + .unwrap(); + for x in -40..=40 { + for y in -40..=40 { + let p = [x as f64 + 0.37, y as f64 + 0.29]; + if !within_variable_stroke(&path, p) { + assert!( + !contains(&shapes, p), + "excess area: case={case}, p={p:?}, path={path:?}, shapes={shapes:?}" + ); + } + } + } + } +} diff --git a/iOverlay/tests/stroke_direction_tests.rs b/iOverlay/tests/stroke_direction_tests.rs new file mode 100644 index 00000000..defff483 --- /dev/null +++ b/iOverlay/tests/stroke_direction_tests.rs @@ -0,0 +1,55 @@ +use i_overlay::mesh::float::stroke::offset::StrokeOffset; +use i_overlay::mesh::float::style::{LineJoin, StrokeStyle}; +use i_overlay::mesh::math::MathMode; + +fn canonical(shapes: Vec>>) -> Vec>> { + let mut shapes: Vec>> = shapes + .into_iter() + .map(|shape| { + let mut shape: Vec> = shape + .into_iter() + .map(|path| { + let mut path: Vec<_> = path + .into_iter() + .map(|p| [(p[0] * 1e6).round() as i64, (p[1] * 1e6).round() as i64]) + .collect(); + let start = path.iter().enumerate().min_by_key(|(_, p)| **p).unwrap().0; + path.rotate_left(start); + path + }) + .collect(); + shape[1..].sort(); + shape + }) + .collect(); + shapes.sort(); + shapes +} + +#[test] +fn reversing_path_preserves_bevel_and_miter_strokes() { + let mut seed = 0x5493_baa7_9215_7831_u64; + let mut next = || { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + ((seed >> 32) % 21) as f64 - 10.0 + }; + for case in 0..600 { + let path: Vec<_> = (0..3 + case % 7).map(|_| [next(), next()]).collect(); + let mut reversed = path.clone(); + reversed.reverse(); + for closed in [false, true] { + for math in [MathMode::Integer, MathMode::Float] { + for join in [LineJoin::Bevel, LineJoin::Miter(0.2)] { + let style = StrokeStyle::new(1.25).math(math).line_join(join.clone()); + let forward = path.stroke_fixed_scale(style.clone(), closed, 1000.0).unwrap(); + let backward = reversed.stroke_fixed_scale(style, closed, 1000.0).unwrap(); + assert_eq!( + canonical(forward), + canonical(backward), + "case={case}, math={math:?}, closed={closed}, join={join:?}, path={path:?}" + ); + } + } + } + } +} diff --git a/iOverlay/tests/stroke_math_tests.rs b/iOverlay/tests/stroke_math_tests.rs new file mode 100644 index 00000000..6dfc09b6 --- /dev/null +++ b/iOverlay/tests/stroke_math_tests.rs @@ -0,0 +1,133 @@ +use i_float::int::angle::Angle; +use i_float::int::point::IntPoint; +use i_overlay::mesh::float::{ + stroke::offset::StrokeOffset, + style::{LineCap, LineJoin, StrokeStyle}, +}; +use i_overlay::mesh::int::{ + arc::ArcOptions, + stroke::offset::IntStrokeOffset, + style::{IntLineCap, IntLineJoin, IntStrokeStyle}, +}; +use i_overlay::mesh::math::MathMode; +use i_shape::flat::float::FloatFlatContoursBuffer; +use i_shape::int::area::Area; + +#[test] +fn integer_default_is_preserved() { + assert_eq!(IntStrokeStyle::new(1024).math, MathMode::Integer); + assert_eq!(StrokeStyle::<[f64; 2]>::new(1.0).math, MathMode::Integer); +} + +macro_rules! stroke_area { + ($name:ident, $int:ty) => { + #[test] + fn $name() { + let path = [IntPoint::<$int>::new(0, 0), IntPoint::new(2400, 3200)]; + for math in [MathMode::Integer, MathMode::Float] { + for (cap, expected) in [ + (IntLineCap::Butt, 1600000.0), + (IntLineCap::Square, 1760000.0), + ] { + let style = IntStrokeStyle::new(400) + .math(math) + .start_cap(cap.clone()) + .end_cap(cap); + path.validate_stroke(&style).unwrap(); + let shapes = path.stroke(&style, false).unwrap(); + assert_eq!(shapes.len(), 1); + let actual = shapes.area() as f64; + assert!( + (actual - expected).abs() < 12000.0, + "{math:?}: {actual} vs {expected}" + ); + } + } + } + }; +} +stroke_area!(diagonal_area_i16, i16); +stroke_area!(diagonal_area_i32, i32); +stroke_area!(diagonal_area_i64, i64); + +#[test] +fn float_math_preserves_local_geometry_at_large_i64_origins() { + let path = [ + IntPoint::new(0_i64, 0), + IntPoint::new(3000, 4000), + IntPoint::new(9000, 4000), + ]; + let shift = 1_i64 << 60; + let translated = path.map(|p| IntPoint::new(p.x + shift, p.y - shift)); + for join in [ + IntLineJoin::Bevel, + IntLineJoin::Miter(Angle::from_radians(0.1).unwrap()), + IntLineJoin::Round(ArcOptions::default()), + ] { + let style = IntStrokeStyle::new(400) + .math(MathMode::Float) + .line_join(join) + .start_cap(IntLineCap::Round(ArcOptions::default())) + .end_cap(IntLineCap::Square); + let expected = path.stroke(&style, false).unwrap(); + let mut actual = translated.stroke(&style, false).unwrap(); + for p in actual.iter_mut().flatten().flatten() { + p.x -= shift; + p.y += shift; + } + assert_eq!(actual, expected); + } +} + +#[test] +fn both_modes_support_lazy_float_input_and_reused_flat_output() { + let paths = vec![ + vec![], + vec![[0.0, 0.0], [3.0, 4.0], [3.0, 4.0], [7.0, 2.0]], + vec![[1.0, 2.0], [5.0, 0.0]], + ]; + let mut output = FloatFlatContoursBuffer::default(); + for math in [MathMode::Integer, MathMode::Float] { + for closed in [false, true] { + for join in [LineJoin::Bevel, LineJoin::Miter(0.1), LineJoin::Round(0.05)] { + let style = StrokeStyle::new(1.0) + .math(math) + .line_join(join) + .start_cap(LineCap::Round(0.05)) + .end_cap(LineCap::Square); + let expected = paths.stroke_fixed_scale(style.clone(), closed, 10000.0).unwrap(); + paths + .stroke_fixed_scale_into(style.clone(), closed, 10000.0, &mut output) + .unwrap(); + let contours: Vec<_> = expected.iter().flatten().collect(); + assert_eq!(output.ranges.len(), contours.len()); + for (range, contour) in output.ranges.iter().zip(contours) { + assert_eq!(&output.points[range.clone()], contour); + } + let empty: Vec<[f64; 2]> = vec![]; + empty + .stroke_fixed_scale_into(style, closed, 10000.0, &mut output) + .unwrap(); + assert!(output.points.is_empty() && output.ranges.is_empty()); + } + } + } +} + +#[test] +fn asymmetric_custom_end_cap_is_included_in_automatic_bounds() { + let path = [[0.0, 0.0], [1.0, 0.0]]; + for math in [MathMode::Integer, MathMode::Float] { + let style = StrokeStyle::new(2.0).math(math).end_cap(LineCap::Custom( + vec![[0.0, -1.0], [1000.0, 0.0], [0.0, 1.0]].into(), + )); + let shapes = path.stroke(style, false); + let max_x = shapes + .iter() + .flatten() + .flatten() + .map(|p| p[0]) + .fold(f64::NEG_INFINITY, f64::max); + assert!((max_x - 1001.0).abs() < 0.01); + } +} diff --git a/iOverlay/tests/variable_stroke_stress.rs b/iOverlay/tests/variable_stroke_stress.rs index 2784923a..7cf6d19a 100644 --- a/iOverlay/tests/variable_stroke_stress.rs +++ b/iOverlay/tests/variable_stroke_stress.rs @@ -1,5 +1,6 @@ -use i_overlay::mesh::variable_stroke::offset::VariableStrokeOffset; -use i_overlay::mesh::variable_stroke::{StrokeVertex, VariableStrokeStyle}; +use i_overlay::mesh::float::variable_stroke::offset::VariableStrokeOffset; +use i_overlay::mesh::float::variable_stroke::{StrokeVertex, VariableStrokeStyle}; +use i_overlay::mesh::math::MathMode; use std::panic::{AssertUnwindSafe, catch_unwind}; use std::time::{Duration, Instant}; @@ -41,18 +42,21 @@ fn run_variable_stroke_stress_case(seed: u64, iteration: usize) { let style = VariableStrokeStyle::new().round_angle(round_angle); let result = catch_unwind(AssertUnwindSafe(|| { - let shapes = path.variable_stroke(style); - assert_valid_shapes(&shapes, seed); + for math in [MathMode::Integer, MathMode::Float] { + let style = style.math(math); + 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); + } - if seed & 1 == 0 { - let shapes = path.variable_stroke_as::(style); + let mut reversed = path.clone(); + reversed.reverse(); + let shapes = reversed.variable_stroke(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 { @@ -78,7 +82,7 @@ fn random_variable_stroke_path(rng: &mut StressRng) -> Vec = shapes[0][0] + .iter() + .filter(|edge| edge.a.y == 0 && edge.b.y == 0) + .map(|edge| (edge.a.x, edge.b.x, edge.fill)) + .collect(); + assert_eq!( + bottom, + vec![ + (0, 5, SUBJ_LEFT), + (5, 10, SUBJ_LEFT | CLIP_LEFT), + (10, 15, CLIP_LEFT) + ], + "redundant_vertex={redundant_vertex}" + ); + assert_eq!(shapes[0][0].len(), 8); + } + } #[test] fn test_0() {