diff --git a/src-tauri/geometry/src/lib.rs b/src-tauri/geometry/src/lib.rs index 23dc0b9..fee2cc2 100644 --- a/src-tauri/geometry/src/lib.rs +++ b/src-tauri/geometry/src/lib.rs @@ -124,8 +124,10 @@ fn dir_of(w: &WallInput) -> Vec2 { /** * Berechnet fuer jede Wand die Gehrungs-Schnittlinien. - * Nur L-Ecken (genau zwei Wandenden treffen sich) werden behandelt; freie - * Enden und T-/X-Stoesse bleiben rechtwinklig. + * Behandelt werden: L-Ecken (genau zwei Wandenden treffen sich), T-Knoten + * (drei Wandenden treffen sich, zwei davon kollinear) und Mittelspannen- + * T-Stoesse (ein freies Wandende trifft auf die Seite einer anderen Wand). + * X-Stoesse (vier und mehr Enden) bleiben vorerst rechtwinklig. */ pub fn compute_joins(input: JoinInput) -> Vec { let walls = input.walls; @@ -167,33 +169,51 @@ pub fn compute_joins(input: JoinInput) -> Vec { } for (_key, ends) in &junctions { - // Freies Ende -> kein Schnitt. + // Freies Ende -> kein Schnitt hier (wird unten separat auf Mittelspannen-T + // geprueft, Fall 2). if ends.len() == 1 { continue; } - // T-/X-Stoesse (>2 Enden): vorerst rechtwinklig lassen. - if ends.len() != 2 { + + if ends.len() == 2 { + let a_idx = match index.get(ends[0].wall_id.as_str()) { + Some(i) => *i, + None => continue, + }; + let b_idx = match index.get(ends[1].wall_id.as_str()) { + Some(i) => *i, + None => continue, + }; + let a = &walls[a_idx]; + let b = &walls[b_idx]; + + let cut = match miter_line(a, ends[0].end, b) { + Some(c) => c, + None => continue, // kollinear -> kein Schnitt + }; + + set_cut(&mut result[a_idx], ends[0].end, cut); + set_cut(&mut result[b_idx], ends[1].end, cut); continue; } - let a_idx = match index.get(ends[0].wall_id.as_str()) { - Some(i) => *i, - None => continue, - }; - let b_idx = match index.get(ends[1].wall_id.as_str()) { - Some(i) => *i, - None => continue, - }; - let a = &walls[a_idx]; - let b = &walls[b_idx]; + if ends.len() == 3 { + apply_t_junction(&walls, &index, ends, &mut result); + continue; + } - let cut = match miter_line(a, ends[0].end, b) { - Some(c) => c, - None => continue, // kollinear -> kein Schnitt - }; + // X-Stoesse u. ae. (>3 Enden): vorerst rechtwinklig lassen (Folge-Arbeit). + } - set_cut(&mut result[a_idx], ends[0].end, cut); - set_cut(&mut result[b_idx], ends[1].end, cut); + // Fall 2: Mittelspannen-T-Stoss. Ein freies Wandende (kein anderes Wandende + // teilt sich seinen Knoten) kann trotzdem auf die Seite einer anderen Wand + // treffen (nicht an deren Endpunkten). In dem Fall bekommt das freie Ende + // einen Schnitt entlang der zugewandten Flaeche dieser Wand. + for (_key, ends) in &junctions { + if ends.len() != 1 { + continue; + } + apply_mid_span_tee(&walls, &index, &ends[0], &mut result); } result @@ -207,6 +227,168 @@ fn set_cut(cuts: &mut WallCuts, end: WallEndKind, cut: Line) { } } +/** + * Fall 1 -- T-Knoten: drei Wandenden treffen sich im selben (gerundeten) + * Knoten. Typisch sind zwei davon kollinear (die zwei Haelften der + * Durchgangswand, die geometrisch als eine Achse durchlaeuft) und das dritte + * zweigt ab. Die kollinearen zwei bleiben ungeschnitten; der Abzweig bekommt + * einen Schnitt entlang der ihm zugewandten Flaeche der Durchgangswand. + * Findet sich kein eindeutig kollineares Paar (z. B. echter Y-/X-Knoten), + * bleibt der Knoten unangetastet (rechtwinklig). + */ +fn apply_t_junction( + walls: &[WallInput], + index: &std::collections::HashMap<&str, usize>, + ends: &[WallEnd], + result: &mut [WallCuts], +) { + let idx_of = |id: &str| index.get(id).copied(); + + let dirs: Vec = match ends + .iter() + .map(|e| idx_of(e.wall_id.as_str()).map(|i| dir_of(&walls[i]))) + .collect::>>() + { + Some(d) => d, + None => return, + }; + + // Suche das (naeherungsweise) kollineare Paar: |cos(Winkel der Achsen)| ~ 1. + let mut through_pair: Option<(usize, usize)> = None; + 'outer: for i in 0..3 { + for k in (i + 1)..3 { + if 1.0 - dot(dirs[i], dirs[k]).abs() < 1e-6 { + through_pair = Some((i, k)); + break 'outer; + } + } + } + let (ti, tk) = match through_pair { + Some(p) => p, + None => return, // kein eindeutiger Durchgang erkennbar + }; + + let branch_idx = match (0..3).find(|&i| i != ti && i != tk) { + Some(i) => i, + None => return, + }; + let branch_end = &ends[branch_idx]; + let through_end = &ends[ti]; + let branch_wall_idx = match idx_of(branch_end.wall_id.as_str()) { + Some(i) => i, + None => return, + }; + let through_wall_idx = match idx_of(through_end.wall_id.as_str()) { + Some(i) => i, + None => return, + }; + let branch_wall = &walls[branch_wall_idx]; + let through_wall = &walls[through_wall_idx]; + + let j = match through_end.end { + WallEndKind::Start => through_wall.start, + WallEndKind::End => through_wall.end, + }; + let t_t = through_wall.thickness; + let u_t = dir_of(through_wall); + let n_t = left_normal(u_t); + let off_t = through_wall.reference_offset; + + // Auslaufrichtung des Abzweigs vom Knoten weg, in seinen eigenen Koerper + // (gleiche Konvention wie d_a/d_b in miter_line). + let d_branch = match branch_end.end { + WallEndKind::Start => dir_of(branch_wall), + WallEndKind::End => scale(dir_of(branch_wall), -1.0), + }; + + // Zugewandte Flaeche: die Seite der Durchgangsachse, zu der der Abzweig + // zeigt (positive n_t-Seite, wenn der Abzweig dorthin auslaeuft). + let sign = if dot(n_t, d_branch) >= 0.0 { 1.0 } else { -1.0 }; + let face_point = add(j, scale(n_t, off_t + sign * (t_t / 2.0))); + + set_cut( + &mut result[branch_wall_idx], + branch_end.end, + Line { point: face_point, dir: u_t }, + ); +} + +/** + * Fall 2 -- Mittelspannen-T-Stoss: prueft, ob das freie Wandende `free_end` + * auf die innere Spanne einer anderen Wand trifft (projizierter Parameter im + * Inneren, nicht an deren Endpunkten) und dabei senkrecht nah genug an deren + * Achse liegt (<= halbe Dicke + Toleranz). Bei Treffer bekommt das freie Ende + * einen Schnitt entlang der zugewandten Flaeche der getroffenen Wand; bei + * mehreren Treffern gewinnt die naechstliegende Wand. + */ +fn apply_mid_span_tee( + walls: &[WallInput], + index: &std::collections::HashMap<&str, usize>, + free_end: &WallEnd, + result: &mut [WallCuts], +) { + let free_wall_idx = match index.get(free_end.wall_id.as_str()) { + Some(i) => *i, + None => return, + }; + let free_wall = &walls[free_wall_idx]; + let p = match free_end.end { + WallEndKind::Start => free_wall.start, + WallEndKind::End => free_wall.end, + }; + + let end_gap = 1e-4; // Mindestabstand zu den Wandenden fuer "innere Spanne" + let tol = 1e-3; // Toleranz fuer den senkrechten Abstand zur Wandflaeche + + // (Index in `walls`, perp-Abstand vorzeichenbehaftet, absoluter Abstand). + let mut best: Option<(usize, f64, f64)> = None; + for (i, w) in walls.iter().enumerate() { + if w.id == free_wall.id { + continue; + } + let u = dir_of(w); + let w_len = len(sub(w.end, w.start)); + if w_len < 1e-9 { + continue; + } + + let rel = sub(p, w.start); + let dist_along = dot(rel, u); + if dist_along <= end_gap || dist_along >= w_len - end_gap { + continue; // an Endpunkt, kein Mittelspann + } + + let perp = dot(rel, left_normal(u)); + let tw = w.thickness; + let dist = perp.abs(); + if dist > tw / 2.0 + tol { + continue; + } + + if best.map_or(true, |(_, _, best_dist)| dist < best_dist) { + best = Some((i, perp, dist)); + } + } + let (w_idx, perp, _dist) = match best { + Some(b) => b, + None => return, // kein Treffer -> rechtwinklig + }; + + let w = &walls[w_idx]; + let tw = w.thickness; + let u_w = dir_of(w); + let n_w = left_normal(u_w); + let off_w = w.reference_offset; + let sign = if perp >= 0.0 { 1.0 } else { -1.0 }; + let face_point = add(w.start, scale(n_w, off_w + sign * (tw / 2.0))); + + set_cut( + &mut result[free_wall_idx], + free_end.end, + Line { point: face_point, dir: u_w }, + ); +} + /** * Gemeinsame Gehrungslinie zweier Waende A, B, die sich im Knoten J treffen. * Robust gegen beliebige Wicklung, ungleiche Dicken und JEDEN Oeffnungswinkel @@ -378,8 +560,12 @@ mod tests { } #[test] - fn t_junction_no_cut() { - // Drei Enden treffen sich in (5,0): rechtwinklig lassen. + fn t_junction_branch_gets_face_cut() { + // Drei Enden treffen sich in (5,0): A/C sind kollinear (Durchgangswand), + // B zweigt ab. A/C bleiben ungeschnitten; B bekommt einen Schnitt entlang + // der ihm zugewandten Flaeche der Durchgangswand (hier: y = +0.1, da B + // nach +y auslaeuft und die Durchgangswand entlang +x verlaeuft, also + // liegt deren linke Flaeche bei +0.1). let input = JoinInput { walls: vec![ w("A", 0.0, 0.0, 5.0, 0.0, 0.2), @@ -389,11 +575,21 @@ mod tests { }; let out = compute_joins(input); assert_eq!(out.len(), 3); - for id in ["A", "B", "C"] { - let c = find(&out, id); - assert!(c.start_cut.is_none(), "{id} startCut none"); - assert!(c.end_cut.is_none(), "{id} endCut none"); - } + + let ca = find(&out, "A"); + let cc = find(&out, "C"); + assert!(ca.start_cut.is_none(), "A startCut ist freies Ende"); + assert!(ca.end_cut.is_none(), "A durchgehend -> kein Schnitt"); + assert!(cc.start_cut.is_none(), "C durchgehend -> kein Schnitt"); + assert!(cc.end_cut.is_none(), "C endCut ist freies Ende"); + + let cb = find(&out, "B"); + let cut = cb.start_cut.expect("B startCut gesetzt"); + assert!(cb.end_cut.is_none(), "B endCut ist freies Ende"); + assert!((cut.point.x - 5.0).abs() < 1e-9); + assert!((cut.point.y - 0.1).abs() < 1e-9, "Flaeche bei y=+0.1 erwartet"); + // Schnittlinie verlaeuft entlang der Durchgangsachse (parallel zu +x). + assert!(cut.dir.y.abs() / len(cut.dir) < 1e-9); } #[test] @@ -413,4 +609,41 @@ mod tests { assert!(c.end_cut.is_none(), "{id} endCut none"); } } + + #[test] + fn mid_span_tee_free_end_hits_wall_side() { + // Freie Wand B endet bei (5,0) exakt auf der Achse der durchgehenden + // Wand A (0,0)-(10,0), mittig auf deren Spanne (nicht an deren Enden). + // B's anderes Ende (5,2) bleibt frei (zu weit von A entfernt). Der + // Treffer liegt exakt auf A's Achse (perp=0) -> die zugewandte Flaeche + // ist A's linke Flaeche bei y=+tw/2=+0.1 (sign=+1 per Konvention bei + // perp>=0). Die Schnittlinie ist als Punkt+Richtung auf A's Anker + // (A.start) verankert, verlaeuft aber unabhaengig davon entlang y=0.1. + let input = JoinInput { + walls: vec![ + w("A", 0.0, 0.0, 10.0, 0.0, 0.2), + w("B", 5.0, 2.0, 5.0, 0.0, 0.2), + ], + }; + let out = compute_joins(input); + assert_eq!(out.len(), 2); + + let ca = find(&out, "A"); + assert!(ca.start_cut.is_none(), "A durchgehend -> kein Schnitt"); + assert!(ca.end_cut.is_none(), "A durchgehend -> kein Schnitt"); + + let cb = find(&out, "B"); + assert!(cb.start_cut.is_none(), "B startCut ist freies Ende bei (5,2)"); + let cut = cb.end_cut.expect("B endCut (Mittelspann-Treffer) gesetzt"); + + // Verankert an A.start = (0,0), verschoben um A's halbe Dicke entlang + // ihrer linken Normale (0,1) -> Punkt (0, 0.1), Richtung parallel zu A. + assert!((cut.point.x - 0.0).abs() < 1e-9); + assert!((cut.point.y - 0.1).abs() < 1e-9); + assert!(cut.dir.y.abs() / len(cut.dir) < 1e-9, "Schnittlinie parallel zu A"); + + // Robuster: die Gerade verlaeuft exakt bei y=0.1, unabhaengig vom x. + let dist = |q: Vec2| cross(cut.dir, sub(q, cut.point)).abs() / len(cut.dir); + assert!(dist(Vec2 { x: 5.0, y: 0.1 }) < 1e-9, "Treffpunkt (5,0.1) auf Gehrung"); + } }