Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion crates/ink/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ impl Stroke {
self.dabs.is_empty()
}

/// The box the ink covers, with the width of the stroke taken into account.
/// The box the ink covers, including smoothing and the selected full-width padding.
/// `None` for a stroke with no readings.
pub fn bounds(&self) -> Option<(f32, f32, f32, f32)> {
let first = self.dabs.first()?;
Expand All @@ -257,6 +257,17 @@ impl Stroke {
x1 = x1.max(d.x);
y1 = y1.max(d.y);
}
// Catmull-Rom can bow beyond the readings. Keep the original padding and
// extrema, but also enclose the same sampled curve that the painter and
// saved outline use. Dots and two-reading lines need no smoothing.
if self.dabs.len() >= 3 {
for d in shape::centre(self) {
x0 = x0.min(d.x);
y0 = y0.min(d.y);
x1 = x1.max(d.x);
y1 = y1.max(d.y);
}
}
let edge = self.width / 2.0;
Some((x0 - edge, y0 - edge, x1 + edge, y1 + edge))
}
Expand Down
81 changes: 81 additions & 0 deletions crates/ink/tests/smoothed_bounds.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
use notecraft_ink::{Dab, Drawing, Rgb, Stroke, Tool, pick, shape, svg};

fn curved(tool: Tool, turn: usize, reverse: bool) -> Stroke {
let mut stroke = Stroke::new(tool, Rgb::BLACK, 4.0);
for (x, y, pressure) in [(10.0, 0.0, 0.2), (110.0, 0.0, 1.0), (111.0, 0.0, 0.4), (210.0, 200.0, 0.8)] {
let (x, y) = match turn {
0 => (x, y),
1 => (-y, x),
2 => (-x, -y),
_ => (y, -x),
};
stroke.add(Dab::new(x, y, pressure));
}
if reverse {
stroke.dabs.reverse();
}
stroke
}

fn contains(bounds: (f32, f32, f32, f32), point: (f32, f32)) -> bool {
point.0 >= bounds.0 - 0.0001 && point.1 >= bounds.1 - 0.0001 && point.0 <= bounds.2 + 0.0001 && point.1 <= bounds.3 + 0.0001
}

#[test]
fn smoothed_bounds_include_visible_ink_and_selection_in_every_direction() {
for tool in Tool::ALL {
for turn in 0..4 {
for reverse in [false, true] {
let stroke = curved(tool, turn, reverse);
let bounds = stroke.bounds().expect("stroke bounds");
let ring = shape::outline(&stroke);
let mut drawing = Drawing::new(240.0, 240.0);
drawing.add(stroke);
let selected = pick::bounds(&drawing, &[0]).expect("selection bounds");
assert_eq!(drawing.bounds(), Some(bounds));
assert_eq!(selected, bounds);
let viewport = (drawing.view_x, drawing.view_y, drawing.view_x + drawing.width, drawing.view_y + drawing.height);
for point in ring {
assert!(contains(bounds, point), "{tool:?} turn={turn} reverse={reverse}: {point:?} outside stroke/selection bounds {bounds:?}");
assert!(contains(viewport, point), "{point:?} outside saved viewport {viewport:?}");
}
}
}
}
}

#[test]
fn smoothed_bounds_preserve_full_width_padding_and_existing_sheet_area() {
for count in [1, 2, 3] {
let mut stroke = Stroke::new(Tool::Pen, Rgb::BLACK, 40.0);
for x in [50.0, 100.0, 150.0].into_iter().take(count) {
stroke.add(Dab::new(x, 60.0, 0.0));
}
assert_eq!(
stroke.bounds(),
Some((30.0, 40.0, 50.0 * count as f32 + 20.0, 80.0)),
"keep selected maximum-width padding even with light pressure"
);
}
let mut drawing = Drawing::new(600.0, 800.0);
drawing.set_view_box(-30.0, -40.0, 600.0, 800.0);
drawing.add(curved(Tool::Pen, 0, false));
assert_eq!(
(drawing.view_x, drawing.view_y, drawing.width, drawing.height),
(-30.0, -40.0, 600.0, 800.0),
"bounds must not shrink an existing sheet"
);
}

#[test]
fn smoothed_bounds_svg_round_trip_keeps_readings_and_a_stable_viewport() {
let stroke = curved(Tool::Pen, 0, false);
let mut drawing = Drawing::new(240.0, 240.0);
drawing.add(stroke.clone());
assert!(drawing.view_y < -10.0, "the viewport includes the smoothed lobe above the raw readings");
let text = svg::write(&drawing, svg::Flavour::Editable);
let (back, flat) = svg::read(&text).expect("saved drawing");
assert_eq!(flat, 0);
assert_eq!(back.strokes, vec![stroke]);
assert_eq!(svg::write(&back, svg::Flavour::Editable), text, "rereading must not keep growing or move the viewport");
}
39 changes: 39 additions & 0 deletions crates/ui-egui/tests/ink_svg_bounds.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
//! Real Stroke -> SVG -> existing SVG-loader regressions. No window or test doubles.
use notecraft_ink::{Dab, Drawing, Rgb, Stroke, Tool, svg};

#[test]
fn saved_svg_keeps_smoothed_ink_outside_the_raw_reading_bounds() {
let mut stroke = Stroke::new(Tool::Pen, Rgb::BLACK, 4.0);
for (x, y) in [(10.0, 0.0), (110.0, 0.0), (111.0, 0.0), (210.0, 200.0)] {
stroke.add(Dab::new(x, y, 1.0));
}
let mut drawing = Drawing::new(240.0, 240.0);
drawing.add(stroke);
let mut expanded = drawing.clone();
expanded.set_view_box(-10.0, -30.0, 260.0, 280.0);
for flavour in [svg::Flavour::Plain, svg::Flavour::Editable] {
let text = svg::write(&drawing, flavour);
let control_text = svg::write(&expanded, flavour);
assert_eq!(text.split_once("<title>").map(|p| p.1), control_text.split_once("<title>").map(|p| p.1), "the control changes only the viewport");
let control = egui_extras::image::load_svg_bytes(control_text.as_bytes(), &Default::default()).expect("control raster");
let image = egui_extras::image::load_svg_bytes(text.as_bytes(), &Default::default()).expect("saved raster");
let (i, witness) = control
.pixels
.iter()
.enumerate()
.filter(|(i, _)| expanded.view_y + (*i / control.size[0]) as f32 + 0.5 < -3.0)
.max_by_key(|(_, p)| p.a())
.expect("overshoot raster pixels");
assert!(witness.a() > 128, "the expanded control must contain clearly painted overshoot");
let x = expanded.view_x + (i % control.size[0]) as f32 + 0.5;
let y = expanded.view_y + (i / control.size[0]) as f32 + 0.5;
let px = ((x - drawing.view_x) * image.size[0] as f32 / drawing.width).floor() as isize;
let py = ((y - drawing.view_y) * image.size[1] as f32 / drawing.height).floor() as isize;
let alpha = if px >= 0 && py >= 0 && px < image.size[0] as isize && py < image.size[1] as isize {
image.pixels[py as usize * image.size[0] + px as usize].a()
} else {
0
};
assert!(alpha > 128, "{flavour:?}: saved viewport clips visible curve at ({x}, {y}), alpha={alpha}");
}
}
Loading