Compare commits

...
Author SHA1 Message Date
haraldandClaude Opus 5.5 c92bf16cbf 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>
2026-10-09 12:45:58 +02:00
2 changed files with 152 additions and 0 deletions
+110
View File
@@ -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.
pub fn detect_shape(points: &[StylusSample]) -> Option<RecognizedShape> {
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]
fn test_detect_axis_cross_in_one_stroke() {
let c = Vec2::new(800.0, 900.0);
+42
View File
@@ -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)
/// und fügt ihn in die aktuelle Seite des Notizbuchs ein.
fn commit_stroke_to_notebook(
@@ -298,6 +315,27 @@ fn commit_stroke_to_notebook(
window: &Window,
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 stroke_opt = if let Some(shape) = snapped_shape {
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) {
stroke_already_snapped = true;
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(&notebook, &shape)
.map_or(shape, |(_, cross)| cross);
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);
canvas.set_live_shape_mesh(samples, vertices);