feat: Achsenkreuz aus zwei Strichen (Issue #20)
Kreuzt eine eingerastete Gerade (Halten oder Formen-Werkzeug) den vorigen Strich, und ist dieser ebenfalls eine Gerade (eingerastet oder freihändig gerade gezogen), werden beide zum Achsenkreuz mit Pfeilspitzen an allen vier Enden. Eine Gerade muss waagerecht, die andere senkrecht sein (je höchstens 25° schief), und jeder Arm reicht mindestens ein Fünftel seiner Achse über den Schnittpunkt hinaus. Beim Halten zeigt die Vorschau schon das Achsenkreuz; Rückgängig stellt die erste Gerade wieder her. Ein T oder L bleibt zwei Geraden. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+110
@@ -1499,6 +1499,77 @@ pub fn detect_axis_cross(points: &[Vec2]) -> Option<RecognizedShape> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Achsenkreuz aus zwei Strichen: eine waagerechte und eine senkrechte Gerade (je höchstens
|
||||||
|
/// 25° schief, Reihenfolge egal), die sich kreuzen und auf beiden Seiten des Schnittpunkts
|
||||||
|
/// deutlich weiterlaufen. Ergebnis wie bei `detect_axis_cross`.
|
||||||
|
pub fn axis_cross_from_lines(a: (Vec2, Vec2), b: (Vec2, Vec2)) -> Option<RecognizedShape> {
|
||||||
|
let max_slope = 25.0_f32.to_radians().tan();
|
||||||
|
let is_horizontal = |(p, q): (Vec2, Vec2)| (q.y - p.y).abs() <= (q.x - p.x).abs() * max_slope;
|
||||||
|
let is_vertical = |(p, q): (Vec2, Vec2)| (q.x - p.x).abs() <= (q.y - p.y).abs() * max_slope;
|
||||||
|
let (horizontal, vertical) = if is_horizontal(a) && is_vertical(b) {
|
||||||
|
(a, b)
|
||||||
|
} else if is_vertical(a) && is_horizontal(b) {
|
||||||
|
(b, a)
|
||||||
|
} else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
|
||||||
|
let (x_min, x_max) = (
|
||||||
|
horizontal.0.x.min(horizontal.1.x),
|
||||||
|
horizontal.0.x.max(horizontal.1.x),
|
||||||
|
);
|
||||||
|
let (y_min, y_max) = (
|
||||||
|
vertical.0.y.min(vertical.1.y),
|
||||||
|
vertical.0.y.max(vertical.1.y),
|
||||||
|
);
|
||||||
|
let origin = Vec2::new(
|
||||||
|
(vertical.0.x + vertical.1.x) * 0.5,
|
||||||
|
(horizontal.0.y + horizontal.1.y) * 0.5,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Vier Arme: jeder mindestens ein Fünftel seiner Achse, die Achsen nicht winzig
|
||||||
|
let (width, height) = (x_max - x_min, y_max - y_min);
|
||||||
|
let arm_ok = |arm: f32, axis: f32| arm >= axis * 0.2;
|
||||||
|
if width < 30.0
|
||||||
|
|| height < 30.0
|
||||||
|
|| !(arm_ok(origin.x - x_min, width)
|
||||||
|
&& arm_ok(x_max - origin.x, width)
|
||||||
|
&& arm_ok(origin.y - y_min, height)
|
||||||
|
&& arm_ok(y_max - origin.y, height))
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// x nach rechts, y nach oben (auf dem Blatt: kleineres y)
|
||||||
|
Some(RecognizedShape::CoordinateSystem {
|
||||||
|
origin,
|
||||||
|
x_axis_end: Vec2::new(x_max, origin.y),
|
||||||
|
y_axis_end: Vec2::new(origin.x, y_min),
|
||||||
|
with_arrows: true,
|
||||||
|
negative_ends: Some([Vec2::new(x_min, origin.y), Vec2::new(origin.x, y_max)]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gerade, die ein Strich darstellt: eine als Gerade eingerastete Form oder ein freihändiger
|
||||||
|
/// Strich, den die Formerkennung als Gerade erkennt. Bilder, Textfelder und Textmarker zählen
|
||||||
|
/// nicht.
|
||||||
|
pub fn stroke_as_line(stroke: &Stroke) -> Option<(Vec2, Vec2)> {
|
||||||
|
if stroke.image.is_some()
|
||||||
|
|| stroke.text.is_some()
|
||||||
|
|| stroke.tool == crate::document::StrokeTool::Highlighter
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let shape = match &stroke.shape {
|
||||||
|
Some(shape) => shape.clone(),
|
||||||
|
None => detect_shape(&stroke.points)?,
|
||||||
|
};
|
||||||
|
match shape {
|
||||||
|
RecognizedShape::Line { start, end } => Some((start, end)),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Führt die automatische Erkennung für einen Strich aus.
|
/// Führt die automatische Erkennung für einen Strich aus.
|
||||||
pub fn detect_shape(points: &[StylusSample]) -> Option<RecognizedShape> {
|
pub fn detect_shape(points: &[StylusSample]) -> Option<RecognizedShape> {
|
||||||
if points.len() < 2 {
|
if points.len() < 2 {
|
||||||
@@ -2674,6 +2745,45 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_axis_cross_from_two_lines() {
|
||||||
|
let horizontal = (Vec2::new(100.0, 305.0), Vec2::new(400.0, 300.0));
|
||||||
|
let vertical = (Vec2::new(252.0, 450.0), Vec2::new(248.0, 150.0));
|
||||||
|
for (a, b) in [(horizontal, vertical), (vertical, horizontal)] {
|
||||||
|
let Some(RecognizedShape::CoordinateSystem {
|
||||||
|
origin,
|
||||||
|
x_axis_end,
|
||||||
|
y_axis_end,
|
||||||
|
with_arrows: true,
|
||||||
|
negative_ends: Some([x_start, y_start]),
|
||||||
|
}) = axis_cross_from_lines(a, b)
|
||||||
|
else {
|
||||||
|
panic!("kein Achsenkreuz");
|
||||||
|
};
|
||||||
|
assert_eq!(origin, Vec2::new(250.0, 302.5));
|
||||||
|
assert_eq!(x_axis_end, Vec2::new(400.0, 302.5));
|
||||||
|
assert_eq!(x_start, Vec2::new(100.0, 302.5));
|
||||||
|
assert_eq!(y_axis_end, Vec2::new(250.0, 150.0));
|
||||||
|
assert_eq!(y_start, Vec2::new(250.0, 450.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_no_axis_cross_from_lines_that_do_not_cross() {
|
||||||
|
let horizontal = (Vec2::new(100.0, 300.0), Vec2::new(400.0, 300.0));
|
||||||
|
// Senkrechte endet über der Waagerechten (L oder T statt Kreuz)
|
||||||
|
let above = (Vec2::new(250.0, 100.0), Vec2::new(250.0, 290.0));
|
||||||
|
assert!(axis_cross_from_lines(horizontal, above).is_none());
|
||||||
|
// Senkrechte schneidet ganz am Rand
|
||||||
|
let at_edge = (Vec2::new(110.0, 150.0), Vec2::new(110.0, 450.0));
|
||||||
|
assert!(axis_cross_from_lines(horizontal, at_edge).is_none());
|
||||||
|
// Zwei Waagerechte oder eine schräge Linie
|
||||||
|
let parallel = (Vec2::new(100.0, 350.0), Vec2::new(400.0, 350.0));
|
||||||
|
assert!(axis_cross_from_lines(horizontal, parallel).is_none());
|
||||||
|
let diagonal = (Vec2::new(150.0, 150.0), Vec2::new(350.0, 450.0));
|
||||||
|
assert!(axis_cross_from_lines(horizontal, diagonal).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_detect_axis_cross_in_one_stroke() {
|
fn test_detect_axis_cross_in_one_stroke() {
|
||||||
let c = Vec2::new(800.0, 900.0);
|
let c = Vec2::new(800.0, 900.0);
|
||||||
|
|||||||
+42
@@ -287,6 +287,23 @@ fn start_pdf_export(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Achsenkreuz aus der eingerasteten Geraden `shape` und dem vorigen Strich der aktuellen
|
||||||
|
/// Seite, falls sich beide kreuzen (Issue #20); mit der Id des vorigen Strichs
|
||||||
|
fn axis_cross_with_previous(
|
||||||
|
notebook: &Notebook,
|
||||||
|
shape: &RecognizedShape,
|
||||||
|
) -> Option<(u64, RecognizedShape)> {
|
||||||
|
let RecognizedShape::Line { start, end } = shape else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let previous = notebook.current_page().strokes.last()?;
|
||||||
|
let line = geometry::stroke_as_line(previous)?;
|
||||||
|
Some((
|
||||||
|
previous.id,
|
||||||
|
geometry::axis_cross_from_lines(line, (*start, *end))?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
/// Finalisiert einen Strich (mit Spline-Tessellierung, Ecken-Erkennung & RDP oder Auto-Snap)
|
/// Finalisiert einen Strich (mit Spline-Tessellierung, Ecken-Erkennung & RDP oder Auto-Snap)
|
||||||
/// und fügt ihn in die aktuelle Seite des Notizbuchs ein.
|
/// und fügt ihn in die aktuelle Seite des Notizbuchs ein.
|
||||||
fn commit_stroke_to_notebook(
|
fn commit_stroke_to_notebook(
|
||||||
@@ -298,6 +315,27 @@ fn commit_stroke_to_notebook(
|
|||||||
window: &Window,
|
window: &Window,
|
||||||
snapped_shape: Option<RecognizedShape>,
|
snapped_shape: Option<RecognizedShape>,
|
||||||
) {
|
) {
|
||||||
|
// Zweite Gerade über Kreuz mit dem vorigen Strich: beide werden zum Achsenkreuz; der
|
||||||
|
// vorige Strich wird ersetzt, ein Rückgängig stellt ihn wieder her
|
||||||
|
if let Some(shape) = &snapped_shape {
|
||||||
|
if let Some((previous_id, cross)) = axis_cross_with_previous(notebook, shape) {
|
||||||
|
let stroke_width = ui_state.pen_stroke_width_mm * ink_notes::document::UNITS_PER_MM;
|
||||||
|
canvas.cancel_stroke();
|
||||||
|
let stroke = geometry::shape_to_stroke(
|
||||||
|
cross,
|
||||||
|
previous_id,
|
||||||
|
canvas.current_ink_color,
|
||||||
|
stroke_width,
|
||||||
|
);
|
||||||
|
let page = notebook.current_page_mut();
|
||||||
|
page.replace_stroke(previous_id, stroke);
|
||||||
|
canvas.sync_page(page);
|
||||||
|
autosave.mark_dirty(notebook, manifest);
|
||||||
|
window.request_redraw();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let new_id = notebook.next_stroke_id();
|
let new_id = notebook.next_stroke_id();
|
||||||
let stroke_opt = if let Some(shape) = snapped_shape {
|
let stroke_opt = if let Some(shape) = snapped_shape {
|
||||||
let stroke_width = ui_state.pen_stroke_width_mm * ink_notes::document::UNITS_PER_MM;
|
let stroke_width = ui_state.pen_stroke_width_mm * ink_notes::document::UNITS_PER_MM;
|
||||||
@@ -1824,6 +1862,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
if let Some(shape) = geometry::detect_shape(&canvas.live_samples) {
|
if let Some(shape) = geometry::detect_shape(&canvas.live_samples) {
|
||||||
stroke_already_snapped = true;
|
stroke_already_snapped = true;
|
||||||
stroke_snapped_shape = Some(shape.clone());
|
stroke_snapped_shape = Some(shape.clone());
|
||||||
|
// Kreuzt die Gerade den vorigen Strich, zeigt die Vorschau schon das
|
||||||
|
// Achsenkreuz (übernommen wird es beim Absetzen)
|
||||||
|
let shape = axis_cross_with_previous(¬ebook, &shape)
|
||||||
|
.map_or(shape, |(_, cross)| cross);
|
||||||
let width_units = ui_state.pen_stroke_width_mm * ink_notes::document::UNITS_PER_MM;
|
let width_units = ui_state.pen_stroke_width_mm * ink_notes::document::UNITS_PER_MM;
|
||||||
let (samples, vertices) = geometry::shape_to_mesh(&shape, width_units, canvas.current_ink_color);
|
let (samples, vertices) = geometry::shape_to_mesh(&shape, width_units, canvas.current_ink_color);
|
||||||
canvas.set_live_shape_mesh(samples, vertices);
|
canvas.set_live_shape_mesh(samples, vertices);
|
||||||
|
|||||||
Reference in New Issue
Block a user