Skip to content

Commit 208b4a1

Browse files
committed
added feature of transferring points to the positive semi-axis
1 parent b7a399d commit 208b4a1

4 files changed

Lines changed: 229 additions & 0 deletions

File tree

src/app.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ const RIGHT_PANEL_MIN_WIDTH: f32 = 280.0;
109109
const POINTS_PARSE_DEBOUNCE_MS: u64 = 180;
110110
const POINTS_HISTORY_LIMIT: usize = 256;
111111
const POINTS_PARSE_ERROR_PREFIX: &str = "Points parse error: ";
112+
const POINTS_POSITIVE_AXIS_EPS: f64 = 1e-6;
112113
const UI_CORNER_RADIUS: u8 = 6;
113114
const PANEL_INNER_MARGIN_X: i8 = 10;
114115
const PANEL_INNER_MARGIN_Y: i8 = 8;

src/app/points_state.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,52 @@ impl CurveFitApp {
315315
}
316316
}
317317

318+
pub(super) fn can_move_points_to_positive_xy(&mut self) -> bool {
319+
matches!(
320+
&self.points_cache_with_policy(false).parsed_points,
321+
Ok(points) if !points.is_empty()
322+
)
323+
}
324+
325+
pub(super) fn move_points_to_positive_xy(&mut self) {
326+
let points = match &self.points_cache_with_policy(true).parsed_points {
327+
Ok(points) if !points.is_empty() => points,
328+
Ok(_) => return,
329+
Err(error) => {
330+
self.status = Some(StatusMessage::Error(format!(
331+
"{POINTS_PARSE_ERROR_PREFIX}{error}"
332+
)));
333+
return;
334+
}
335+
};
336+
337+
let mut min_x = points[0].x();
338+
let mut min_y = points[0].y();
339+
for point in points.iter().skip(1) {
340+
min_x = min_x.min(point.x());
341+
min_y = min_y.min(point.y());
342+
}
343+
344+
let dx = (POINTS_POSITIVE_AXIS_EPS - min_x).max(0.0);
345+
let dy = (POINTS_POSITIVE_AXIS_EPS - min_y).max(0.0);
346+
347+
let shifted = match points
348+
.iter()
349+
.map(|point| Point::try_new(point.x() + dx, point.y() + dy))
350+
.collect::<Result<Vec<_>, _>>()
351+
{
352+
Ok(points) => points,
353+
Err(error) => {
354+
self.status = Some(StatusMessage::Error(format!(
355+
"Failed to move points to positive x/y: {error}"
356+
)));
357+
return;
358+
}
359+
};
360+
361+
self.write_points_text(&shifted, true);
362+
}
363+
318364
pub(super) fn fill_points_with_residuals(&mut self) {
319365
if self.residual_plot_points.is_empty() {
320366
return;

src/app/tests.rs

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,18 @@ fn points_from_pairs(pairs: &[(f64, f64)]) -> Points {
3232
Points::try_from(points).expect("points must satisfy minimum size")
3333
}
3434

35+
fn parsed_point_pairs(app: &mut CurveFitApp) -> Vec<(f64, f64)> {
36+
let cache = app.points_cache_with_policy(true);
37+
let points = cache
38+
.parsed_points
39+
.as_ref()
40+
.expect("points must parse successfully");
41+
points
42+
.iter()
43+
.map(|point| (point.x(), point.y()))
44+
.collect::<Vec<_>>()
45+
}
46+
3547
fn assert_approx_eq(actual: f64, expected: f64, tolerance: f64) {
3648
assert!(
3749
(actual - expected).abs() <= tolerance,
@@ -1359,3 +1371,158 @@ fn fill_points_with_residuals_is_noop_when_residuals_are_absent() {
13591371
assert!(app.points.undo_stack.is_empty());
13601372
assert!(app.points.redo_stack.is_empty());
13611373
}
1374+
1375+
#[test]
1376+
fn move_points_to_positive_xy_rebases_minimums_and_preserves_offsets() {
1377+
let original = [(-2.0, -1.0), (0.0, 3.0), (5.0, -4.0)];
1378+
let mut app = CurveFitApp {
1379+
points: super::PointsEditorState {
1380+
text: "-2 -1\n0 3\n5 -4\n".to_string(),
1381+
..Default::default()
1382+
},
1383+
..Default::default()
1384+
};
1385+
app.invalidate_points_cache();
1386+
1387+
app.move_points_to_positive_xy();
1388+
1389+
let shifted = parsed_point_pairs(&mut app);
1390+
assert_eq!(shifted.len(), original.len());
1391+
1392+
let min_x = shifted
1393+
.iter()
1394+
.map(|(x, _)| *x)
1395+
.fold(f64::INFINITY, f64::min);
1396+
let min_y = shifted
1397+
.iter()
1398+
.map(|(_, y)| *y)
1399+
.fold(f64::INFINITY, f64::min);
1400+
assert_approx_eq(min_x, super::POINTS_POSITIVE_AXIS_EPS, 1e-12);
1401+
assert_approx_eq(min_y, super::POINTS_POSITIVE_AXIS_EPS, 1e-12);
1402+
1403+
for index in 1..shifted.len() {
1404+
assert_approx_eq(
1405+
shifted[index].0 - shifted[0].0,
1406+
original[index].0 - original[0].0,
1407+
1e-12,
1408+
);
1409+
assert_approx_eq(
1410+
shifted[index].1 - shifted[0].1,
1411+
original[index].1 - original[0].1,
1412+
1e-12,
1413+
);
1414+
}
1415+
}
1416+
1417+
#[test]
1418+
fn move_points_to_positive_xy_keeps_already_positive_points_unchanged() {
1419+
let mut app = CurveFitApp {
1420+
points: super::PointsEditorState {
1421+
text: "2 3\n4 5\n".to_string(),
1422+
..Default::default()
1423+
},
1424+
..Default::default()
1425+
};
1426+
app.invalidate_points_cache();
1427+
1428+
app.move_points_to_positive_xy();
1429+
1430+
let shifted = parsed_point_pairs(&mut app);
1431+
assert_eq!(shifted.len(), 2);
1432+
assert_approx_eq(shifted[0].0, 2.0, 1e-12);
1433+
assert_approx_eq(shifted[0].1, 3.0, 1e-12);
1434+
assert_approx_eq(shifted[1].0, 4.0, 1e-12);
1435+
assert_approx_eq(shifted[1].1, 5.0, 1e-12);
1436+
}
1437+
1438+
#[test]
1439+
fn move_points_to_positive_xy_moves_only_axis_that_needs_it() {
1440+
let mut app = CurveFitApp {
1441+
points: super::PointsEditorState {
1442+
text: "-2 3\n1 5\n".to_string(),
1443+
..Default::default()
1444+
},
1445+
..Default::default()
1446+
};
1447+
app.invalidate_points_cache();
1448+
1449+
app.move_points_to_positive_xy();
1450+
1451+
let shifted = parsed_point_pairs(&mut app);
1452+
assert_eq!(shifted.len(), 2);
1453+
assert_approx_eq(shifted[0].0, super::POINTS_POSITIVE_AXIS_EPS, 1e-12);
1454+
assert_approx_eq(shifted[0].1, 3.0, 1e-12);
1455+
assert_approx_eq(shifted[1].0, 3.000001, 1e-12);
1456+
assert_approx_eq(shifted[1].1, 5.0, 1e-12);
1457+
}
1458+
1459+
#[test]
1460+
fn move_points_to_positive_xy_pushes_undo_and_clears_redo() {
1461+
let previous_text = "-1 0\n1 2\n";
1462+
let mut app = CurveFitApp {
1463+
points: super::PointsEditorState {
1464+
text: previous_text.to_string(),
1465+
redo_stack: vec!["stale redo entry".to_string()],
1466+
..Default::default()
1467+
},
1468+
..Default::default()
1469+
};
1470+
app.invalidate_points_cache();
1471+
1472+
app.move_points_to_positive_xy();
1473+
1474+
assert_eq!(app.points.undo_stack, vec![previous_text.to_string()]);
1475+
assert!(app.points.redo_stack.is_empty());
1476+
assert_ne!(app.points.text, previous_text);
1477+
1478+
let moved_text = app.points.text.clone();
1479+
app.undo_points_edit();
1480+
assert_eq!(app.points.text, previous_text);
1481+
app.redo_points_edit();
1482+
assert_eq!(app.points.text, moved_text);
1483+
1484+
let shifted = parsed_point_pairs(&mut app);
1485+
let min_x = shifted
1486+
.iter()
1487+
.map(|(x, _)| *x)
1488+
.fold(f64::INFINITY, f64::min);
1489+
let min_y = shifted
1490+
.iter()
1491+
.map(|(_, y)| *y)
1492+
.fold(f64::INFINITY, f64::min);
1493+
assert_approx_eq(min_x, super::POINTS_POSITIVE_AXIS_EPS, 1e-12);
1494+
assert_approx_eq(min_y, super::POINTS_POSITIVE_AXIS_EPS, 1e-12);
1495+
}
1496+
1497+
#[test]
1498+
fn can_move_points_to_positive_xy_requires_non_empty_valid_points() {
1499+
let mut empty = CurveFitApp {
1500+
points: super::PointsEditorState {
1501+
text: String::new(),
1502+
..Default::default()
1503+
},
1504+
..Default::default()
1505+
};
1506+
empty.invalidate_points_cache();
1507+
assert!(!empty.can_move_points_to_positive_xy());
1508+
1509+
let mut invalid = CurveFitApp {
1510+
points: super::PointsEditorState {
1511+
text: "1 2 3\n".to_string(),
1512+
..Default::default()
1513+
},
1514+
..Default::default()
1515+
};
1516+
invalid.invalidate_points_cache();
1517+
assert!(!invalid.can_move_points_to_positive_xy());
1518+
1519+
let mut valid = CurveFitApp {
1520+
points: super::PointsEditorState {
1521+
text: "0 0\n1 1\n".to_string(),
1522+
..Default::default()
1523+
},
1524+
..Default::default()
1525+
};
1526+
valid.invalidate_points_cache();
1527+
assert!(valid.can_move_points_to_positive_xy());
1528+
}

src/app/ui/points_editor_panel.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ pub(super) fn ui_points_editor(app: &mut CurveFitApp, ui: &mut egui::Ui) {
113113
);
114114
}
115115
let can_fill_with_residuals = can_edit_points && !app.residual_plot_points.is_empty();
116+
let can_move_points_to_positive_xy = can_edit_points && app.can_move_points_to_positive_xy();
116117
with_toolbar_hover_style(ui, |ui| {
117118
ui.horizontal(|ui| {
118119
ui.spacing_mut().item_spacing.x = TOOLBAR_BUTTON_SPACING_X;
@@ -158,6 +159,20 @@ pub(super) fn ui_points_editor(app: &mut CurveFitApp, ui: &mut egui::Ui) {
158159
app.fill_points_with_residuals();
159160
ui.close();
160161
}
162+
if ui
163+
.add_enabled(
164+
can_move_points_to_positive_xy,
165+
egui::Button::new(tr(
166+
language,
167+
"Move to positive x/y",
168+
"Перенести в +X/+Y",
169+
)),
170+
)
171+
.clicked()
172+
{
173+
app.move_points_to_positive_xy();
174+
ui.close();
175+
}
161176
});
162177
let _ = toolbar_hover_tooltip(actions_response, actions_tooltip(language));
163178
});

0 commit comments

Comments
 (0)