Geometry-Crate: T-Stoss-Verschneidung nach Rust portiert (TS<->Rust-Paritaet)

compute_joins behandelt jetzt wie die TS-Referenz auch T-Knoten (3 Enden,
kollineares Paar = Durchgang, Abzweig an dessen Flaeche geschnitten) und
Mittelspannen-Stoesse (freies Ende trifft Wandseite). miter_line/L-Ecke
bit-identisch. Rust-Tests aktualisiert (t_junction_branch_gets_face_cut,
mid_span_tee_free_end_hits_wall_side). cargo test + parity.test.ts gruen.
This commit is contained in:
2026-07-04 13:33:00 +02:00
parent 0d3a0a081f
commit 41d8fafa4e
+247 -14
View File
@@ -124,8 +124,10 @@ fn dir_of(w: &WallInput) -> Vec2 {
/** /**
* Berechnet fuer jede Wand die Gehrungs-Schnittlinien. * Berechnet fuer jede Wand die Gehrungs-Schnittlinien.
* Nur L-Ecken (genau zwei Wandenden treffen sich) werden behandelt; freie * Behandelt werden: L-Ecken (genau zwei Wandenden treffen sich), T-Knoten
* Enden und T-/X-Stoesse bleiben rechtwinklig. * (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<WallCuts> { pub fn compute_joins(input: JoinInput) -> Vec<WallCuts> {
let walls = input.walls; let walls = input.walls;
@@ -167,15 +169,13 @@ pub fn compute_joins(input: JoinInput) -> Vec<WallCuts> {
} }
for (_key, ends) in &junctions { 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 { if ends.len() == 1 {
continue; continue;
} }
// T-/X-Stoesse (>2 Enden): vorerst rechtwinklig lassen.
if ends.len() != 2 {
continue;
}
if ends.len() == 2 {
let a_idx = match index.get(ends[0].wall_id.as_str()) { let a_idx = match index.get(ends[0].wall_id.as_str()) {
Some(i) => *i, Some(i) => *i,
None => continue, None => continue,
@@ -194,6 +194,26 @@ pub fn compute_joins(input: JoinInput) -> Vec<WallCuts> {
set_cut(&mut result[a_idx], ends[0].end, cut); set_cut(&mut result[a_idx], ends[0].end, cut);
set_cut(&mut result[b_idx], ends[1].end, cut); set_cut(&mut result[b_idx], ends[1].end, cut);
continue;
}
if ends.len() == 3 {
apply_t_junction(&walls, &index, ends, &mut result);
continue;
}
// X-Stoesse u. ae. (>3 Enden): vorerst rechtwinklig lassen (Folge-Arbeit).
}
// 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 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<Vec2> = match ends
.iter()
.map(|e| idx_of(e.wall_id.as_str()).map(|i| dir_of(&walls[i])))
.collect::<Option<Vec<Vec2>>>()
{
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. * Gemeinsame Gehrungslinie zweier Waende A, B, die sich im Knoten J treffen.
* Robust gegen beliebige Wicklung, ungleiche Dicken und JEDEN Oeffnungswinkel * Robust gegen beliebige Wicklung, ungleiche Dicken und JEDEN Oeffnungswinkel
@@ -378,8 +560,12 @@ mod tests {
} }
#[test] #[test]
fn t_junction_no_cut() { fn t_junction_branch_gets_face_cut() {
// Drei Enden treffen sich in (5,0): rechtwinklig lassen. // 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 { let input = JoinInput {
walls: vec![ walls: vec![
w("A", 0.0, 0.0, 5.0, 0.0, 0.2), w("A", 0.0, 0.0, 5.0, 0.0, 0.2),
@@ -389,11 +575,21 @@ mod tests {
}; };
let out = compute_joins(input); let out = compute_joins(input);
assert_eq!(out.len(), 3); assert_eq!(out.len(), 3);
for id in ["A", "B", "C"] {
let c = find(&out, id); let ca = find(&out, "A");
assert!(c.start_cut.is_none(), "{id} startCut none"); let cc = find(&out, "C");
assert!(c.end_cut.is_none(), "{id} endCut none"); 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] #[test]
@@ -413,4 +609,41 @@ mod tests {
assert!(c.end_cut.is_none(), "{id} endCut none"); 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");
}
} }