Joins Phase 1c: Durchgangswand am T-Stoss echt aufbrechen (spanCutouts)

Am materialbewussten T-Stoss wurde die Durchgangswand bisher nur uebermalt
(Zeichenreihenfolge), nicht geometrisch ausgeschnitten: ihr Nah-Putz-Band, die
Schichtfugen- und die Nahflaechenlinie liefen weiter ueber die Durchstoss-Breite
des Abzweig-Backsteins. Neu liefert computeJoins erstmals auch Cuts fuer die
DURCHGANGSWAND: WallCuts.spanCutouts (Achsen-Intervall = Projektion der Abzweig-
Kernbreite, Quer-Offsetzone = Nahflaeche bis Rueckgrat-Nahflaeche = Nah-Putz-
Tiefe aus Phase 1b). addWallPoche splittet betroffene Schicht-Baender entlang der
Achse in Teil-Baender vor/nach dem Intervall, unterbricht die Schichtfugen im
Merge-Bereich und macht die Nahflaechen-Umrisskante ueber dem Durchstoss
strokelos (separate Segmente davor/danach) — keine Trennlinie an der
Verschmelzungsflaeche, der T-Stoss liest als EIN Join. Rust-Paritaet additiv
(span_cutouts), Aggregat-startCut/endCut unveraendert -> parity gruen.

vitest 227 (+5), cargo 8 (+1), tsc sauber.
This commit is contained in:
2026-07-04 19:22:32 +02:00
parent cb8fae5cb4
commit c5a344d3f2
5 changed files with 541 additions and 58 deletions
+125 -12
View File
@@ -67,6 +67,20 @@ pub struct LayerCuts {
pub end_side: Vec<Option<Line>>,
}
/// Aussparung der DURCHGANGSWAND am materialbewussten T-Stoss -- Rust-Spiegel
/// von `SpanCutout` aus `model/joins.ts`. `from`/`to` = Achsen-Intervall (Meter
/// ab `wall.start`), `offA`/`offB` = Quer-Offsetbereich (Nah-Putz-Zone) in der
/// Offset-Konvention der Schicht-Baender; `offA` ist stets die Nahflaeche.
#[derive(Serialize, Deserialize, Clone, Copy)]
pub struct SpanCutout {
pub from: f64,
pub to: f64,
#[serde(rename = "offA")]
pub off_a: f64,
#[serde(rename = "offB")]
pub off_b: f64,
}
/// Schnittlinien einer Wand an ihren beiden Achsenden.
#[derive(Serialize, Deserialize)]
pub struct WallCuts {
@@ -82,6 +96,11 @@ pub struct WallCuts {
/// weiterhin `start_cut`/`end_cut` fuer alle Schichten.
#[serde(rename = "layerCuts", skip_serializing_if = "Option::is_none")]
pub layer_cuts: Option<LayerCuts>,
/// ADDITIV (Durchgangswand-Seite): Nah-Putz-Aussparungen, durch die ein
/// Abzweig-Kern sticht. Leer -> von serde bei Serialisierung weggelassen
/// (`skip_serializing_if`), Wand wird unveraendert gezeichnet.
#[serde(rename = "spanCutouts", skip_serializing_if = "Vec::is_empty")]
pub span_cutouts: Vec<SpanCutout>,
}
/// Verschneidungs-Entscheidung zweier Bauteile am Stoss -- Rust-Spiegel von
@@ -135,6 +154,13 @@ fn apply_layer_cuts(
through_dir: Vec2,
sign: f64,
off_t: f64,
// Durchgangswand-Aussparung (Phase 1c): Index/Start der Durchgangswand,
// Achsrichtung + Referenzversatz des Abzweigs -- fuer die Projektion der
// Kernbreite auf die Durchgangsachse.
through_wall_idx: usize,
through_start: Vec2,
branch_dir: Vec2,
branch_ref_off: f64,
) {
if branch_layers.is_empty() || through_layers.is_empty() {
return;
@@ -199,17 +225,58 @@ fn apply_layer_cuts(
let merge_cut = Line { point: merge_point, dir: through_dir };
// Getrimmte Nachbarschichten schliessen mit ihrer L-Seitenlinie an dieser
// Rueckgrat-Nahflaeche ab (nicht an der Wandachse).
let side_line = merge_cut;
let (face_arr, side_arr) = match branch_end {
WallEndKind::Start => (&mut lc.start, &mut lc.start_side),
WallEndKind::End => (&mut lc.end, &mut lc.end_side),
};
for i in 0..n {
face_arr[i] = if merged[i] { Some(merge_cut) } else { Some(face_cut) };
let adj_merged =
!merged[i] && ((i > 0 && merged[i - 1]) || (i + 1 < n && merged[i + 1]));
side_arr[i] = if adj_merged { Some(side_line) } else { None };
// Rueckgrat-Nahflaeche ab (nicht an der Wandachse). In einem Block, damit die
// mutable Ausleihe von `result[branch_wall_idx]` vor dem Zugriff auf die
// Durchgangswand unten endet.
{
let side_line = merge_cut;
let (face_arr, side_arr) = match branch_end {
WallEndKind::Start => (&mut lc.start, &mut lc.start_side),
WallEndKind::End => (&mut lc.end, &mut lc.end_side),
};
for i in 0..n {
face_arr[i] = if merged[i] { Some(merge_cut) } else { Some(face_cut) };
let adj_merged =
!merged[i] && ((i > 0 && merged[i - 1]) || (i + 1 < n && merged[i + 1]));
side_arr[i] = if adj_merged { Some(side_line) } else { None };
}
}
// Durchgangswand-Aussparung (Phase 1c): den Nah-Putz der Durchgangswand ueber
// die Breite des durchstechenden Abzweig-Kerns ECHT ausschneiden. Nur noetig,
// wenn davor Nah-Putz liegt (near_plaster > 0).
if near_plaster > 1e-9 {
let total_branch: f64 = branch_layers.iter().map(|l| l.thickness).sum();
let mut b_off = branch_ref_off - total_branch / 2.0;
let mut core_lo = f64::INFINITY;
let mut core_hi = f64::NEG_INFINITY;
for (i, l) in branch_layers.iter().enumerate() {
if merged[i] {
if b_off < core_lo {
core_lo = b_off;
}
if b_off + l.thickness > core_hi {
core_hi = b_off + l.thickness;
}
}
b_off += l.thickness;
}
if core_hi > core_lo {
// Projektion der Kern-Offsethuelle auf die Durchgangsachse.
let n_branch = left_normal(branch_dir);
let proj = dot(n_branch, through_dir);
let axis_at = dot(sub(axis_point, through_start), through_dir);
let e1 = axis_at + core_lo * proj;
let e2 = axis_at + core_hi * proj;
let off_a = off_t + sign * (t_t / 2.0);
let off_b = off_t + sign * (t_t / 2.0 - near_plaster);
result[through_wall_idx].span_cutouts.push(SpanCutout {
from: e1.min(e2),
to: e1.max(e2),
off_a,
off_b,
});
}
}
}
@@ -305,6 +372,7 @@ pub fn compute_joins(input: JoinInput) -> Vec<WallCuts> {
start_cut: None,
end_cut: None,
layer_cuts: None,
span_cutouts: Vec::new(),
})
.collect();
@@ -485,6 +553,10 @@ fn apply_t_junction(
u_t,
sign,
off_t,
through_wall_idx,
through_wall.start,
dir_of(branch_wall),
branch_wall.reference_offset,
);
}
@@ -575,6 +647,10 @@ fn apply_mid_span_tee(
u_w,
sign,
off_w,
w_idx,
w.start,
dir_of(free_wall),
free_wall.reference_offset,
);
}
@@ -904,10 +980,47 @@ mod tests {
assert!(dist(s0, Vec2 { x: 0.0, y: 0.06 }) < 1e-9);
assert!(dist(s2, Vec2 { x: 5.0, y: 0.06 }) < 1e-9);
// Die kollineare Durchgangswand bleibt unangetastet (kein layerCuts).
// Die kollineare Durchgangswand bleibt bei den layerCuts unangetastet.
let ca = find(&out, "A");
let cc = find(&out, "C");
assert!(ca.layer_cuts.is_none());
assert!(cc.layer_cuts.is_none());
// ... bekommt aber die Durchgangswand-Aussparung (Phase 1c): die als
// Durchgang gewaehlte Haelfte A wird ueber die Backstein-Kernbreite (0.12)
// im Nah-Putz-Bereich (y in [0.06,0.075]) ausgeschnitten. Das Intervall
// ist die Projektion der Kernbreite auf die Achse, zentriert am Knoten x=5.
assert_eq!(ca.span_cutouts.len(), 1, "A (Durchgang) hat genau eine Aussparung");
let sc = ca.span_cutouts[0];
assert!((sc.from - 4.94).abs() < 1e-9, "from = 5 - 0.06");
assert!((sc.to - 5.06).abs() < 1e-9, "to = 5 + 0.06");
assert!(((sc.to - sc.from) - 0.12).abs() < 1e-9, "Intervallbreite = Kernbreite");
assert!((sc.off_a - 0.075).abs() < 1e-9, "offA = Nahflaeche");
assert!((sc.off_b - 0.06).abs() < 1e-9, "offB = Rueckgrat-Nahflaeche");
// Der Abzweig B traegt keine Aussparung (die gehoert der Durchgangswand).
assert!(cb.span_cutouts.is_empty());
}
#[test]
fn mid_span_tee_through_wall_gets_span_cutout() {
// Mittelspannen-T-Stoss (wie der reale W9-Fall): Durchgangswand W als EINE
// Wand (0,0)->(10,0), Abzweig B trifft mit seinem freien Ende (4,0) mittig
// auf ihre Seite. W bekommt EINE Aussparung um x=4, Breite = Kernbreite.
let input = JoinInput {
walls: vec![
w_iw("W", 0.0, 0.0, 10.0, 0.0),
w_iw("B", 4.0, 3.0, 4.0, 0.0),
],
};
let out = compute_joins(input);
let cw = find(&out, "W");
assert_eq!(cw.span_cutouts.len(), 1, "W hat genau eine Aussparung");
let sc = cw.span_cutouts[0];
assert!((sc.from - 3.94).abs() < 1e-9);
assert!((sc.to - 4.06).abs() < 1e-9);
assert!((sc.off_a - 0.075).abs() < 1e-9);
assert!((sc.off_b - 0.06).abs() < 1e-9);
// Der Abzweig selbst hat keine Aussparung.
assert!(find(&out, "B").span_cutouts.is_empty());
}
}