Skip to content

Commit 39fdde2

Browse files
authored
Merge pull request #62 from devmobasa/erase-by-stroke-fix
eraser: densify stroke-erase path sampling
2 parents 94fb805 + 279f0de commit 39fdde2

File tree

3 files changed

+224
-2
lines changed

3 files changed

+224
-2
lines changed

src/input/state/core/selection_actions.rs

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,54 @@ impl InputState {
6060
}
6161

6262
pub(crate) fn erase_strokes_by_points(&mut self, points: &[(i32, i32)]) -> bool {
63-
let ids = self.hit_test_all_for_points(points, self.eraser_hit_radius());
63+
let sampled = self.sample_eraser_path_points(points);
64+
let ids = self.hit_test_all_for_points(&sampled, self.eraser_hit_radius());
6465
self.delete_shapes_by_ids(&ids)
6566
}
6667

68+
fn sample_eraser_path_points(&self, points: &[(i32, i32)]) -> Vec<(i32, i32)> {
69+
if points.len() < 2 {
70+
return points.to_vec();
71+
}
72+
73+
let step = (self.eraser_hit_radius() * 0.9).max(1.0);
74+
let mut needs_sampling = false;
75+
for window in points.windows(2) {
76+
let dx = (window[1].0 - window[0].0) as f64;
77+
let dy = (window[1].1 - window[0].1) as f64;
78+
if (dx * dx + dy * dy).sqrt() > step {
79+
needs_sampling = true;
80+
break;
81+
}
82+
}
83+
84+
if !needs_sampling {
85+
return points.to_vec();
86+
}
87+
88+
let mut sampled = Vec::with_capacity(points.len());
89+
sampled.push(points[0]);
90+
for window in points.windows(2) {
91+
let (x0, y0) = window[0];
92+
let (x1, y1) = window[1];
93+
let dx = (x1 - x0) as f64;
94+
let dy = (y1 - y0) as f64;
95+
let dist = (dx * dx + dy * dy).sqrt();
96+
let steps = ((dist / step).ceil() as i32).max(1);
97+
for i in 1..=steps {
98+
let t = i as f64 / steps as f64;
99+
let point = (
100+
(x0 as f64 + dx * t).round() as i32,
101+
(y0 as f64 + dy * t).round() as i32,
102+
);
103+
if sampled.last().copied() != Some(point) {
104+
sampled.push(point);
105+
}
106+
}
107+
}
108+
sampled
109+
}
110+
67111
pub(crate) fn duplicate_selection(&mut self) -> bool {
68112
let ids: Vec<ShapeId> = self.selected_shape_ids().to_vec();
69113
if ids.is_empty() {

src/input/state/mouse.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,11 @@ impl InputState {
309309
Tool::Eraser => {
310310
if self.eraser_mode == EraserMode::Stroke {
311311
self.clear_provisional_dirty();
312-
self.erase_strokes_by_points(&points);
312+
let mut path = points;
313+
if path.last().copied() != Some((x, y)) {
314+
path.push((x, y));
315+
}
316+
self.erase_strokes_by_points(&path);
313317
return;
314318
}
315319
Shape::EraserStroke {

src/input/state/tests.rs

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -941,3 +941,177 @@ fn keyboard_context_menu_sets_initial_focus() {
941941
ContextMenuState::Hidden => panic!("Context menu should be open"),
942942
}
943943
}
944+
945+
#[test]
946+
fn erase_stroke_samples_sparse_path() {
947+
let mut state = create_test_input_state();
948+
state.eraser_size = 4.0;
949+
state.eraser_mode = EraserMode::Stroke;
950+
951+
let line_id = state.canvas_set.active_frame_mut().add_shape(Shape::Line {
952+
x1: 0,
953+
y1: 0,
954+
x2: 100,
955+
y2: 0,
956+
color: Color {
957+
r: 0.0,
958+
g: 0.0,
959+
b: 0.0,
960+
a: 1.0,
961+
},
962+
thick: 1.0,
963+
});
964+
965+
let erased = state.erase_strokes_by_points(&[(0, -10), (100, 10)]);
966+
assert!(erased, "stroke eraser should remove intersected line");
967+
assert!(state.canvas_set.active_frame().shape(line_id).is_none());
968+
}
969+
970+
#[test]
971+
fn erase_stroke_includes_release_segment() {
972+
let mut state = create_test_input_state();
973+
state.eraser_size = 4.0;
974+
state.eraser_mode = EraserMode::Stroke;
975+
state.set_tool_override(Some(Tool::Eraser));
976+
977+
let line_id = state.canvas_set.active_frame_mut().add_shape(Shape::Line {
978+
x1: 0,
979+
y1: 0,
980+
x2: 100,
981+
y2: 0,
982+
color: Color {
983+
r: 0.0,
984+
g: 0.0,
985+
b: 0.0,
986+
a: 1.0,
987+
},
988+
thick: 1.0,
989+
});
990+
991+
state.on_mouse_press(MouseButton::Left, 0, -10);
992+
state.on_mouse_release(MouseButton::Left, 100, 10);
993+
994+
assert!(state.canvas_set.active_frame().shape(line_id).is_none());
995+
}
996+
997+
#[test]
998+
fn erase_stroke_samples_randomized_crossings() {
999+
fn next_unit(seed: &mut u64) -> f64 {
1000+
*seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
1001+
let value = ((*seed >> 33) as u32) as f64;
1002+
value / (u32::MAX as f64)
1003+
}
1004+
1005+
let mut seed = 0x1234_5678_9abc_def0u64;
1006+
for _ in 0..16 {
1007+
let mut state = create_test_input_state();
1008+
state.eraser_size = 4.0;
1009+
state.eraser_mode = EraserMode::Stroke;
1010+
1011+
let line_id = state.canvas_set.active_frame_mut().add_shape(Shape::Line {
1012+
x1: 0,
1013+
y1: 0,
1014+
x2: 100,
1015+
y2: 0,
1016+
color: Color {
1017+
r: 0.0,
1018+
g: 0.0,
1019+
b: 0.0,
1020+
a: 1.0,
1021+
},
1022+
thick: 1.0,
1023+
});
1024+
1025+
let unit = next_unit(&mut seed);
1026+
let angle = std::f64::consts::PI * (0.35 + unit * 0.3);
1027+
let dx = angle.cos();
1028+
let dy = angle.sin();
1029+
let length = 80.0;
1030+
let x0 = 50.0 - dx * length;
1031+
let y0 = 0.0 - dy * length;
1032+
let x1 = 50.0 + dx * length;
1033+
let y1 = 0.0 + dy * length;
1034+
1035+
let erased = state.erase_strokes_by_points(&[
1036+
(x0.round() as i32, y0.round() as i32),
1037+
(x1.round() as i32, y1.round() as i32),
1038+
]);
1039+
1040+
assert!(
1041+
erased,
1042+
"stroke eraser should remove line at angle {}",
1043+
angle
1044+
);
1045+
assert!(state.canvas_set.active_frame().shape(line_id).is_none());
1046+
}
1047+
}
1048+
1049+
#[test]
1050+
fn erase_stroke_hits_various_shapes() {
1051+
let cases = vec![
1052+
(
1053+
Shape::Rect {
1054+
x: 10,
1055+
y: 10,
1056+
w: 40,
1057+
h: 20,
1058+
fill: false,
1059+
color: Color {
1060+
r: 0.0,
1061+
g: 0.0,
1062+
b: 0.0,
1063+
a: 1.0,
1064+
},
1065+
thick: 1.0,
1066+
},
1067+
vec![(0, 10), (100, 10)],
1068+
),
1069+
(
1070+
Shape::Ellipse {
1071+
cx: 50,
1072+
cy: 50,
1073+
rx: 20,
1074+
ry: 10,
1075+
fill: false,
1076+
color: Color {
1077+
r: 0.0,
1078+
g: 0.0,
1079+
b: 0.0,
1080+
a: 1.0,
1081+
},
1082+
thick: 1.0,
1083+
},
1084+
vec![(0, 40), (100, 40)],
1085+
),
1086+
(
1087+
Shape::Arrow {
1088+
x1: 10,
1089+
y1: 90,
1090+
x2: 90,
1091+
y2: 90,
1092+
color: Color {
1093+
r: 0.0,
1094+
g: 0.0,
1095+
b: 0.0,
1096+
a: 1.0,
1097+
},
1098+
thick: 1.0,
1099+
arrow_length: 20.0,
1100+
arrow_angle: 30.0,
1101+
head_at_end: true,
1102+
},
1103+
vec![(0, 90), (100, 90)],
1104+
),
1105+
];
1106+
1107+
for (shape, path) in cases {
1108+
let mut state = create_test_input_state();
1109+
state.eraser_size = 4.0;
1110+
state.eraser_mode = EraserMode::Stroke;
1111+
let shape_id = state.canvas_set.active_frame_mut().add_shape(shape);
1112+
1113+
let erased = state.erase_strokes_by_points(&path);
1114+
assert!(erased, "stroke eraser should remove intersected shape");
1115+
assert!(state.canvas_set.active_frame().shape(shape_id).is_none());
1116+
}
1117+
}

0 commit comments

Comments
 (0)