Akkumulierten grünen Arbeitsstand landen (Basis für Weiterarbeit)

Bündelt den über mehrere Sessions gewachsenen, uncommitteten Stand in
einem Basis-Commit, damit Folge-Features isoliert darauf aufsetzen.
Verifikation: tsc --noEmit sauber, vitest 600/600 grün.

Enthalten (Details in PENDENZEN.md -Liste / HANDOVER.md):
- truck-Integration: Profil-Extrusion + Verjüngung + Boolean-CSG (csgrs),
  Crate src-tauri/trucksolid, Werkzeug `extrude`, ExtrudedSolid-Modell.
- kernel2d-Port nach Rust/WASM (Phasen 1–5, Diff-Harness).
- render3d 3D-Live-Schnitt = 2D-Schnitt: geschichteter Bodenaufbau,
  Prioritäts-Verschneidung (section_boolean.rs), einstellbare
  Schichttrennlinien, per-Hatch-Strichstärke, relativeToWall-Orientierung.
- Interop-Export IFC4/STL/OBJ (Loch-Ausschnitt wallMeshCut), Schnellexport.
- Projektdatei .obp + OS-Lock (lock.rs, LockConflictDialog).
- Layout-Blätter (Modell/Editor/Panel/PDF), Ausschnitte, Override-Engine,
  Tragwerk-Stützen (Column), BIM-Tree-Panel.
- Bauteil-Typsystem (Tür/Fenster/Treppe-Typen), Betontreppe mit schräger
  Laufplatte, Text-/Textbox-Werkzeug, Mess-Werkzeug, 2D/3D-Griffe für
  Öffnungen/Treppen, Snap-Symbol-Restyle.
This commit is contained in:
2026-07-09 00:57:29 +02:00
parent 889cbb2c12
commit 35299307d6
131 changed files with 26501 additions and 852 deletions
+516
View File
@@ -0,0 +1,516 @@
//! Schnitt-Boolean-Dominanz fuer den 3D-Live-Schnitt — die 1:1-Portierung von
//! `src/plan/toSection.ts::subtractDominantBands`/`subtractRect` nach Rust.
//!
//! GRUND: der 2D-Schnitt (`toSection.ts`) ist die Referenz-Implementierung. Er
//! sammelt alle Cut-Baender (Wand- und Deckenschichten) als achsparallele
//! (u,v)-Rechtecke, taggt jedes mit seiner `Component.joinPriority` und laesst
//! dann die STAERKERE Schicht die schwaechere per Rechteck-Subtraktion wegschneiden
//! (elementuebergreifend). Der 3D-Rust-Cut (`section.rs`) produziert bereits genau
//! dasselbe Format — achsparallele (u,v)-Rechteck-`CutPolygon`s — also laesst sich
//! die Dominanz-Zerlegung hier identisch nachbauen, damit der 3D-Live-Schnitt fuer
//! Wand+Decken-Schichten EXAKT wie der 2D-Schnitt aussieht.
//!
//! Die Prioritaet/Identitaet je Band kommt aus `CutPolygon::cut` (`CutBandMeta`,
//! aus der TS-Emission `toWalls3d`). Baender ohne `join_priority` (unaufloesbares
//! Bauteil) nehmen weder als Basis noch als Cutter teil und bleiben unveraendert.
use crate::section::CutPolygon;
/// Toleranz fuer Ueberlappungs-/Rest-Rechtecke (Meter): Null-/Negativflaechen und
/// Rest-Streifen duenner als EPS werden verworfen (Float-Rauschen der (u,v)-Meter).
/// Identisch zu `RECT_EPS` in `toSection.ts`.
const RECT_EPS: f32 = 1e-6;
/// Achsparalleles Rechteck in (u, v)-Metern — das Rust-Gegenstueck zu `Rect` in
/// `toSection.ts`.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Rect {
pub u_min: f32,
pub u_max: f32,
pub v_min: f32,
pub v_max: f32,
}
/// Bounding-Box eines (achsparallelen) Cut-Bands als `Rect`, oder `None` bei <3
/// Ecken (analog `rectOfBand`).
pub fn rect_of_poly(poly: &CutPolygon) -> Option<Rect> {
if poly.pts.len() < 3 {
return None;
}
let mut u_min = f32::INFINITY;
let mut u_max = f32::NEG_INFINITY;
let mut v_min = f32::INFINITY;
let mut v_max = f32::NEG_INFINITY;
for p in &poly.pts {
u_min = u_min.min(p[0]);
u_max = u_max.max(p[0]);
v_min = v_min.min(p[1]);
v_max = v_max.max(p[1]);
}
Some(Rect {
u_min,
u_max,
v_min,
v_max,
})
}
/// Rechteck-minus-Rechteck (beide achsparallel): `base` ohne die Ueberlappung mit
/// `cutter`, zerlegt in 04 disjunkte Rest-Rechtecke (links/rechts ueber die volle
/// Hoehe, dann oben/unten im mittleren u-Streifen — ueberlappungsfrei). Kein echter
/// Ueberlapp (Flaeche ≤ EPS in u oder v) ⇒ `base` bleibt ganz. Vollstaendige
/// Ueberdeckung ⇒ leere Liste. Rest-Streifen duenner als EPS werden verworfen.
/// 1:1 aus `toSection.ts::subtractRect`.
pub fn subtract_rect(base: Rect, cutter: Rect) -> Vec<Rect> {
let o_min_u = base.u_min.max(cutter.u_min);
let o_max_u = base.u_max.min(cutter.u_max);
let o_min_v = base.v_min.max(cutter.v_min);
let o_max_v = base.v_max.min(cutter.v_max);
// Kein (nennenswerter) Ueberlapp — `base` unveraendert.
if o_max_u - o_min_u <= RECT_EPS || o_max_v - o_min_v <= RECT_EPS {
return vec![base];
}
let mut out = Vec::new();
let mut push = |r: Rect| {
if r.u_max - r.u_min > RECT_EPS && r.v_max - r.v_min > RECT_EPS {
out.push(r);
}
};
// Links / rechts der Ueberlappung, jeweils ueber die volle Basis-Hoehe.
push(Rect {
u_min: base.u_min,
u_max: o_min_u,
v_min: base.v_min,
v_max: base.v_max,
});
push(Rect {
u_min: o_max_u,
u_max: base.u_max,
v_min: base.v_min,
v_max: base.v_max,
});
// Unten / oben im mittleren u-Streifen (nur der von der Ueberlappung befreite Rest).
push(Rect {
u_min: o_min_u,
u_max: o_max_u,
v_min: base.v_min,
v_max: o_min_v,
});
push(Rect {
u_min: o_min_u,
u_max: o_max_u,
v_min: o_max_v,
v_max: base.v_max,
});
out
}
/// `join_priority` eines Bands (aus `CutBandMeta`), oder `None`.
fn priority_of(poly: &CutPolygon) -> Option<i32> {
poly.cut.as_ref().and_then(|c| c.join_priority)
}
/// `component_id` eines Bands (aus `CutBandMeta`), oder `None`.
fn component_of(poly: &CutPolygon) -> Option<&str> {
poly.cut.as_ref().and_then(|c| c.component_id.as_deref())
}
/// Erzeugt aus einem Rest-`Rect` ein Cut-Band, das Stil/Referenz/Metadaten von
/// `src` erbt (analog `bandFromRect`). Ecken-Reihenfolge wie in `section.rs`
/// (CCW-analog, hier v_max oben zuerst — fuer die Fan-Triangulierung irrelevant).
fn poly_from_rect(src: &CutPolygon, r: Rect) -> CutPolygon {
CutPolygon {
component: src.component,
color: src.color,
hatch: src.hatch,
cut: src.cut.clone(),
pts: vec![
[r.u_min, r.v_max],
[r.u_max, r.v_max],
[r.u_max, r.v_min],
[r.u_min, r.v_min],
],
}
}
/// Globale Schnitt-Dominanz: fuer jedes Band wird die Rechteck-Ueberlappung ALLER
/// Baender mit STRIKT hoeherer `join_priority` subtrahiert (elementuebergreifend —
/// z. B. schneidet ein Decken-Beton-Band 100 ein ueberlappendes Wand-Putz-Band 10
/// weg). Gleiche Prioritaet schneidet NICHT (koexistiert → durchgehende Flaeche).
/// Baender ohne `join_priority` nehmen weder als Basis noch als Cutter teil und
/// bleiben unveraendert erhalten. 1:1 aus `toSection.ts::subtractDominantBands`
/// (inklusive der abschliessenden MERGE-Phase).
pub fn subtract_dominant_bands(bands: Vec<CutPolygon>) -> Vec<CutPolygon> {
let rects: Vec<Option<Rect>> = bands.iter().map(rect_of_poly).collect();
let prios: Vec<Option<i32>> = bands.iter().map(priority_of).collect();
let mut out: Vec<CutPolygon> = Vec::new();
for i in 0..bands.len() {
// Nicht-rechteckig oder ohne Prioritaet: unveraendert durchreichen.
let (base, prio) = match (rects[i], prios[i]) {
(Some(b), Some(p)) => (b, p),
_ => {
out.push(bands[i].clone());
continue;
}
};
let mut pieces = vec![base];
for j in 0..bands.len() {
if pieces.is_empty() {
break;
}
if j == i {
continue;
}
let cutter = match (rects[j], prios[j]) {
(Some(c), Some(op)) if op > prio => c,
_ => continue,
};
let mut next = Vec::new();
for p in &pieces {
next.extend(subtract_rect(*p, cutter));
}
pieces = next;
}
for p in pieces {
out.push(poly_from_rect(&bands[i], p));
}
}
merge_same_component_bands(out)
}
/// Union zweier achsparalleler Rechtecke, ABER NUR wenn das Ergebnis EXAKT wieder
/// ein Rechteck ist — sonst `None`. Enthaltensein oder Achs-Deckung + Beruehrung
/// liefern eine rechteckige Union; Teil-/Eck-Ueberlappungen (L-Form) und Baender
/// mit Spalt liefern `None`. 1:1 aus `toSection.ts::rectUnionIfRect`.
fn rect_union_if_rect(a: Rect, b: Rect) -> Option<Rect> {
const E: f32 = RECT_EPS;
// Enthaltensein (inkl. Deckungsgleichheit): Union = umschliessendes Rechteck.
let a_in_b = b.u_min - a.u_min <= E
&& a.u_max - b.u_max <= E
&& b.v_min - a.v_min <= E
&& a.v_max - b.v_max <= E;
if a_in_b {
return Some(b);
}
let b_in_a = a.u_min - b.u_min <= E
&& b.u_max - a.u_max <= E
&& a.v_min - b.v_min <= E
&& b.v_max - a.v_max <= E;
if b_in_a {
return Some(a);
}
let u_aligned = (a.u_min - b.u_min).abs() <= E && (a.u_max - b.u_max).abs() <= E;
let v_aligned = (a.v_min - b.v_min).abs() <= E && (a.v_max - b.v_max).abs() <= E;
// Beruehrung/Ueberlappung in der jeweils ANDEREN Achse (kein Spalt groesser EPS).
let v_touch = a.v_max >= b.v_min - E && b.v_max >= a.v_min - E;
let u_touch = a.u_max >= b.u_min - E && b.u_max >= a.u_min - E;
if (u_aligned && v_touch) || (v_aligned && u_touch) {
return Some(Rect {
u_min: a.u_min.min(b.u_min),
u_max: a.u_max.max(b.u_max),
v_min: a.v_min.min(b.v_min),
v_max: a.v_max.max(b.v_max),
});
}
None
}
/// MERGE-Phase der Schnitt-Dominanz: Baender mit GLEICHER `component_id` UND
/// gleicher `join_priority`, deren achsparallele Rechtecke sich beruehren/ueberlappen
/// und eine rechteckige Union bilden (`rect_union_if_rect`), werden zu EINEM Band
/// zusammengefasst — so entsteht an der Beruehrkante keine innere Trennlinie durch
/// gleichartiges Material. Baender ohne `component_id`/`join_priority` oder ohne
/// Rechteck-Form nehmen nicht teil. Iterativ bis zum Fixpunkt. 1:1 aus
/// `toSection.ts::mergeSameComponentBands`.
fn merge_same_component_bands(bands: Vec<CutPolygon>) -> Vec<CutPolygon> {
let mut out = bands;
let mut merged = true;
while merged {
merged = false;
'outer: for i in 0..out.len() {
let (ci, pi) = match (component_of(&out[i]), priority_of(&out[i])) {
(Some(c), Some(p)) => (c.to_string(), p),
_ => continue,
};
let ri = match rect_of_poly(&out[i]) {
Some(r) => r,
None => continue,
};
for j in (i + 1)..out.len() {
if component_of(&out[j]) != Some(ci.as_str()) || priority_of(&out[j]) != Some(pi) {
continue;
}
let rj = match rect_of_poly(&out[j]) {
Some(r) => r,
None => continue,
};
if let Some(u) = rect_union_if_rect(ri, rj) {
out[i] = poly_from_rect(&out[i], u);
out.remove(j);
merged = true; // Fixpunkt-Neustart
break 'outer;
}
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::section::{ComponentKind, ComponentRef, CutPolygon};
use crate::types::CutBandMeta;
/// Achsparalleles Cut-Band mit Prioritaet (und optionaler Komponenten-Id) —
/// Ecken wie `section.rs` (Rechteck).
fn band(
u_min: f32,
u_max: f32,
v_min: f32,
v_max: f32,
join_priority: i32,
component_id: Option<&str>,
) -> CutPolygon {
CutPolygon {
component: ComponentRef {
kind: ComponentKind::Wall,
index: 0,
},
color: [0.5, 0.5, 0.5],
hatch: None,
pts: vec![
[u_min, v_max],
[u_max, v_max],
[u_max, v_min],
[u_min, v_min],
],
cut: Some(CutBandMeta {
join_priority: Some(join_priority),
component_id: component_id.map(|s| s.to_string()),
line_color: None,
line_weight: None,
}),
}
}
fn rect_of(p: &CutPolygon) -> Rect {
rect_of_poly(p).unwrap()
}
fn area(bands: &[CutPolygon]) -> f32 {
bands
.iter()
.map(|b| {
let r = rect_of(b);
(r.u_max - r.u_min) * (r.v_max - r.v_min)
})
.sum()
}
fn approx(a: f32, b: f32) -> bool {
(a - b).abs() < 1e-5
}
// --- subtractRect (Referenz: toSection.boolean.test.ts) ------------------
#[test]
fn subtract_rect_kein_overlap_base_unveraendert() {
let base = Rect { u_min: 0.0, u_max: 1.0, v_min: 0.0, v_max: 1.0 };
let cut = Rect { u_min: 2.0, u_max: 3.0, v_min: 2.0, v_max: 3.0 };
assert_eq!(subtract_rect(base, cut), vec![base]);
}
#[test]
fn subtract_rect_kantenberuehrung_base_unveraendert() {
let base = Rect { u_min: 0.0, u_max: 1.0, v_min: 0.0, v_max: 1.0 };
let cut = Rect { u_min: 1.0, u_max: 2.0, v_min: 0.0, v_max: 1.0 };
assert_eq!(subtract_rect(base, cut), vec![base]);
}
#[test]
fn subtract_rect_cutter_enthaelt_base_leer() {
let base = Rect { u_min: 1.0, u_max: 2.0, v_min: 1.0, v_max: 2.0 };
let cut = Rect { u_min: 0.0, u_max: 3.0, v_min: 0.0, v_max: 3.0 };
assert!(subtract_rect(base, cut).is_empty());
}
#[test]
fn subtract_rect_cutter_mittig_vier_reste() {
let base = Rect { u_min: 0.0, u_max: 3.0, v_min: 0.0, v_max: 3.0 };
let cut = Rect { u_min: 1.0, u_max: 2.0, v_min: 1.0, v_max: 2.0 };
let rest = subtract_rect(base, cut);
assert_eq!(rest.len(), 4);
let a: f32 = rest
.iter()
.map(|r| (r.u_max - r.u_min) * (r.v_max - r.v_min))
.sum();
assert!(approx(a, 8.0), "9 (base) - 1 (overlap) = 8, ist {a}");
}
#[test]
fn subtract_rect_eck_ueberlappung_zwei_reste() {
// cutter deckt die obere-rechte Ecke von base: overlap [1,2]x[1,2].
let base = Rect { u_min: 0.0, u_max: 2.0, v_min: 0.0, v_max: 2.0 };
let cut = Rect { u_min: 1.0, u_max: 3.0, v_min: 1.0, v_max: 3.0 };
let rest = subtract_rect(base, cut);
let a: f32 = rest
.iter()
.map(|r| (r.u_max - r.u_min) * (r.v_max - r.v_min))
.sum();
assert!(approx(a, 3.0), "4 - 1 = 3, ist {a}");
}
// --- subtractDominantBands: die geometrische Kernaussage ------------------
#[test]
fn decke_100_schneidet_wand_50_im_ueberlappungs_z_intervall_weg() {
// Nutzer-Sample-analog: eine Wandschicht (Backstein, Prio 50, u in [0,0.2],
// volle Hoehe v in [0,3]) und ein Decken-Beton-Band (Prio 100, u in [0,0.2],
// v in [2.6,2.8]) ueberlappen in (u,v). Nach der Subtraktion darf die Wand
// im Decken-z-Intervall KEINE Flaeche mehr haben; Beton bleibt voll.
let brick = band(0.0, 0.2, 0.0, 3.0, 50, Some("brick"));
let concrete = band(0.0, 0.2, 2.6, 2.8, 100, Some("concrete"));
let out = subtract_dominant_bands(vec![brick, concrete]);
// Beton unveraendert (kein staerkeres Band).
let concrete_out: Vec<&CutPolygon> = out
.iter()
.filter(|b| component_of(b) == Some("concrete"))
.collect();
assert_eq!(concrete_out.len(), 1);
assert!(approx(
(rect_of(concrete_out[0]).v_max - rect_of(concrete_out[0]).v_min) as f32,
0.2
));
// Backstein: in v in [2.6,2.8] KEINE Flaeche mehr -> zwei Reste [0,2.6] und
// [2.8,3.0]. Kein Rest-Rechteck ragt ins Beton-Intervall.
let brick_out: Vec<&CutPolygon> = out
.iter()
.filter(|b| component_of(b) == Some("brick"))
.collect();
assert!(!brick_out.is_empty());
for b in &brick_out {
let r = rect_of(b);
// Kein Backstein-Rest ueberlappt (2.6, 2.8) echt.
let overlaps = r.v_min < 2.8 - 1e-4 && r.v_max > 2.6 + 1e-4;
assert!(!overlaps, "Backstein-Rest {r:?} ragt ins Beton-z-Intervall");
}
// Flaechenbilanz: Backstein 0.2*3 = 0.6, minus Overlap 0.2*0.2 = 0.04 -> 0.56.
assert!(
approx(area(&out.iter().filter(|b| component_of(b) == Some("brick")).cloned().collect::<Vec<_>>()), 0.56),
"Backstein-Restflaeche"
);
}
#[test]
fn wand_50_schneidet_estrich_30() {
// Estrich (Decke, Prio 30) verliert die Ueberlappung mit der Wand
// (Backstein, Prio 50). Referenz-Zahlen wie im 2D-Test.
let screed = band(0.0, 2.0, 0.0, 3.0, 30, Some("screed"));
let brick = band(0.0, 2.0, 2.0, 4.0, 50, Some("brick")); // Overlap v in [2,3]
let out = subtract_dominant_bands(vec![screed, brick]);
// Brick voll.
assert!(approx(
area(&out.iter().filter(|b| component_of(b) == Some("brick")).cloned().collect::<Vec<_>>()),
4.0
));
// Estrich: [0,3] minus Overlap [2,3] = [0,2] -> Rest genau v in [0,2].
let screed_out: Vec<&CutPolygon> = out
.iter()
.filter(|b| component_of(b) == Some("screed"))
.collect();
assert_eq!(screed_out.len(), 1);
let r = rect_of(screed_out[0]);
assert!(approx(r.v_min, 0.0) && approx(r.v_max, 2.0), "Estrich-Rest {r:?}");
}
/// SAMPLE-ANALOG (Nutzer-Bug): der Schnitt durch Aussenwand + Geschossdecke.
/// Deckenschichten (u-breit, gestapelt in v nahe OK): Estrich (30, [2.54,2.6]),
/// Dämmung (20, [2.50,2.54]), Beton (100, [2.30,2.50]). Wandschichten (schmal in
/// u, an ihrer Dicken-Position): Backstein (50) läuft als ZWEI Boxen [0,2.3] +
/// [2.5,2.6] durch (Beton kappt das Mittelstück — genau die per-Schicht-Decken-
/// Dominanz aus emitWall). ERWARTUNG: nach der Verschneidung haben Estrich UND
/// Dämmung im u-Bereich des Backsteins KEINE Fläche mehr (Backstein 50 > 30, 20);
/// der Beton bleibt voll und läuft durch. Das ist die Richtung, die der Nutzer als
/// kaputt meldete ("dämmung und zementboden laufen raus").
#[test]
fn sample_analog_backstein_schneidet_estrich_und_daemmung() {
let brick_u = (0.0075_f32, 0.1575_f32); // Backstein-Dickenposition (W4-analog)
let ceil_u = (0.0_f32, 5.0_f32); // Deckenschichten über die volle Raumbreite
let bands = vec![
band(ceil_u.0, ceil_u.1, 2.54, 2.6, 30, Some("screed")),
band(ceil_u.0, ceil_u.1, 2.50, 2.54, 20, Some("insulation-ceiling")),
band(ceil_u.0, ceil_u.1, 2.30, 2.50, 100, Some("concrete")),
band(brick_u.0, brick_u.1, 0.0, 2.3, 50, Some("brick")),
band(brick_u.0, brick_u.1, 2.5, 2.6, 50, Some("brick")),
];
let out = subtract_dominant_bands(bands);
// Kein Estrich-/Dämmung-Rest überlappt den Backstein-u-Bereich ECHT.
for name in ["screed", "insulation-ceiling"] {
for b in out.iter().filter(|b| component_of(b) == Some(name)) {
let r = rect_of(b);
let u_overlap = r.u_min < brick_u.1 - 1e-4 && r.u_max > brick_u.0 + 1e-4;
assert!(
!u_overlap,
"{name}-Rest {r:?} ragt noch in den Backstein-u-Bereich (müsste weg sein)"
);
}
}
// Beton bleibt voll (kein stärkeres Band): Fläche 5*0.2 = 1.0.
assert!(approx(
area(&out.iter().filter(|b| component_of(b) == Some("concrete")).cloned().collect::<Vec<_>>()),
1.0
));
// Backstein bleibt in beiden Boxen erhalten (nichts Stärkeres überlappt).
assert!(approx(
area(&out.iter().filter(|b| component_of(b) == Some("brick")).cloned().collect::<Vec<_>>()),
0.15 * 2.3 + 0.15 * 0.1
));
}
#[test]
fn gleiche_prioritaet_koexistiert() {
// Beton-Wand + Beton-Decke, gleiche Prio 100, VERSCHIEDENE Komponenten:
// schneiden sich NICHT und verschmelzen NICHT (bleiben beide voll).
let wand = band(0.0, 1.0, 0.0, 3.0, 100, Some("beton-wand"));
let decke = band(0.0, 1.0, 2.0, 4.0, 100, Some("beton-decke")); // Overlap v[2,3]
let out = subtract_dominant_bands(vec![wand, decke]);
assert_eq!(out.len(), 2);
assert!(approx(
area(&out.iter().filter(|b| component_of(b) == Some("beton-wand")).cloned().collect::<Vec<_>>()),
3.0
));
assert!(approx(
area(&out.iter().filter(|b| component_of(b) == Some("beton-decke")).cloned().collect::<Vec<_>>()),
2.0
));
}
#[test]
fn ohne_prioritaet_keine_teilnahme() {
let stark = band(0.0, 2.0, 0.0, 2.0, 100, None);
let mut ohne = band(0.0, 2.0, 0.0, 2.0, 0, None);
ohne.cut = None; // keine Prioritaet
let out = subtract_dominant_bands(vec![stark, ohne]);
// Beide unveraendert (4 + 4).
assert_eq!(out.len(), 2);
assert!(approx(area(&out), 8.0));
}
#[test]
fn merge_gleiche_komponente_und_prioritaet() {
// Beton-Wand trifft Beton-Decke (u-deckungsgleich), gleiche componentId +
// Prio -> verschmelzen zu EINEM Rechteck v in [0,4] (keine innere Kante).
let wand = band(0.0, 1.0, 0.0, 3.0, 100, Some("beton"));
let decke = band(0.0, 1.0, 2.0, 4.0, 100, Some("beton"));
let out = subtract_dominant_bands(vec![wand, decke]);
assert_eq!(out.len(), 1);
let r = rect_of(&out[0]);
assert!(approx(r.v_min, 0.0) && approx(r.v_max, 4.0), "verschmolzen {r:?}");
}
}