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 9b33a89318
commit 1d939a939a
129 changed files with 26324 additions and 842 deletions
+513 -13
View File
@@ -21,19 +21,68 @@
//!
//! ## Ausgabeformat
//!
//! Interleaved `[world_x, world_y, world_z, u, v]` je Vertex (5 f32,
//! `CAP_FLOATS_PER_VERTEX`), Dreiecksliste. `(u,v)` in Modell-Metern wandern als
//! Attribut mit, damit die Schraffur im Fragment-Shader ein durchgaengiges,
//! blickwinkel-unabhaengiges Muster IN der Schnittebene zeichnet (nicht im
//! Bildschirmraum). Ein Cut-Polygon ist stets ein konvexes Rechteck (4 Ecken,
//! siehe `section.rs`) — Fan-Triangulierung genuegt.
//! Interleaved `[world_x, world_y, world_z, u, v, pattern, angle_rad, scale]` je
//! Vertex (8 f32, `CAP_FLOATS_PER_VERTEX`), Dreiecksliste. `(u,v)` in Modell-Metern
//! wandern als Attribut mit, damit die Schraffur im Fragment-Shader ein
//! durchgaengiges, blickwinkel-unabhaengiges Muster IN der Schnittebene zeichnet
//! (nicht im Bildschirmraum). `pattern`/`angle_rad`/`scale` kommen aus der
//! Bauteil-Schraffur (`CutPolygon::hatch` -> `types::Hatch`) und waehlen im Shader
//! das MUSTER je geschnittener Schicht (statt eines fixen 45°-Musters). Sie sind je
//! Cut-Polygon konstant, wandern aber als (redundantes) Vertex-Attribut mit — das
//! haelt die Cap-Pipeline ohne zusaetzliche per-Draw-Uniform-Verwaltung (die Caps
//! werden in EINEM `draw` gezeichnet). Ein Cut-Polygon ist stets ein konvexes
//! Rechteck (4 Ecken, siehe `section.rs`) — Fan-Triangulierung genuegt.
use crate::section::{cut_section, SectionPlane};
use crate::section::{cut_section, CutPolygon, SectionPlane};
use crate::section_boolean::{rect_of_poly, subtract_dominant_bands};
use crate::types::{SlabInput, WallInput};
/// f32 pro Cap-Vertex: [pos.x, pos.y, pos.z, u, v]. Spiegel des Vertex-Layouts
/// der `cap_pipeline` (gpu.rs) und des `VsIn` in `CAP_WGSL` (shaders.rs).
pub const CAP_FLOATS_PER_VERTEX: usize = 5;
/// f32 pro Cap-Vertex: [pos.x, pos.y, pos.z, u, v, pattern, angle_rad, scale,
/// line_weight_mm]. Spiegel des Vertex-Layouts der `cap_pipeline` (gpu.rs) und des
/// `VsIn` in `CAP_WGSL` (shaders.rs). `line_weight_mm` steuert die Musterlinien-
/// Breite (mm-Papier -> Welt) je Schraffur.
pub const CAP_FLOATS_PER_VERTEX: usize = 9;
/// Default-Musterlinien-Staerke (mm-Papier), wenn ein Cut-Polygon keine `hatch`
/// traegt (Fallback-Diagonale) — Haarlinie, wie `HatchRender.lineWeight`-Default.
const FALLBACK_HATCH_LINE_WEIGHT_MM: f32 = 0.13;
/// f32 pro Cut-Linien-Vertex: [pos.x, pos.y, pos.z, r, g, b] — dasselbe Layout wie
/// die Grid-/Highlight-LineList (`GRID_WGSL`), damit die Schnitt-Umrisslinien ohne
/// eigenes Vertex-Format ueber eine (Dreiecks-)Ribbon-Pipeline laufen.
pub const CUT_LINE_FLOATS_PER_VERTEX: usize = 6;
/// Default-Schnittkanten-Farbe (dunkle Tinte), wenn ein Band keinen eigenen
/// `line_color` traegt — passend zur 2D-Schnittkonvention (dunkle Fugenlinie).
const DEFAULT_CUT_LINE_COLOR: [f32; 3] = [0.10, 0.10, 0.10];
/// Default-Strichstaerke der Schnitt-Umrisslinie in mm-Papier (Haarlinie), wenn
/// ein Band keinen eigenen `line_weight` traegt.
const DEFAULT_CUT_LINE_WEIGHT_MM: f32 = 0.35;
/// mm-Papier -> Welt-Meter fuer die Ribbon-BREITE der Schnitt-Umrisslinien.
///
/// EHRLICHE EINSCHRAENKUNG: die 2D-Strichstaerke ist in mm-Papier (masstabsabhaengig).
/// Der 3D-Schnitt kennt keinen Papiermasstab; die Linie wird hier als flaches Ribbon
/// mit KONSTANTER Welt-Breite in die Schnittebene gelegt (blickwinkel-unabhaengig,
/// skaliert also mit der Kamera-Distanz wie gemalte Geometrie — NICHT bildschirmfest).
/// Der Faktor ist bewusst grob gewaehlt (0.35 mm -> ~3.5 mm Welt), damit die Fuge
/// ueberhaupt lesbar bleibt; eine bildschirmfeste Strichstaerke braeuchte eine
/// Screen-Space-Expansion in einer eigenen Pipeline (bekannte Luecke, siehe
/// Schlussbericht/Moduldoc).
const CUT_LINE_MM_TO_WORLD: f32 = 0.01;
/// Kleiner Versatz (Meter) der Umrisslinien-Ribbons ZUR KAMERA (entgegen der
/// Ebenennormale), damit sie (a) nicht durch die Schnittebenen-Kappung von
/// `GRID_WGSL` (`depth > 0` = hinter der Ebene) weggeschnitten werden und (b)
/// sichtbar VOR den Cut-Caps liegen (die exakt auf der Ebene sitzen).
const CUT_LINE_LIFT: f32 = 0.002;
/// Fallback-Muster-Id, wenn ein Cut-Polygon keine `hatch` traegt: 2 = diagonal
/// (45°) — reproduziert mit `angle_rad = 0`, `scale = 1` exakt das fruehere fest
/// verdrahtete 45°-Muster (Rueckwaertskompatibilitaet fuer Waende/Decken ohne
/// aufgeloeste Bauteil-Schraffur).
const FALLBACK_PATTERN: f32 = 2.0;
/// Erzeugt die Schnittflaechen-Geometrie (Cut-Caps) fuer eine vertikale
/// Schnittebene: ruft `cut_section` und trianguliert jedes (u,v)-Rechteck in die
@@ -44,12 +93,23 @@ pub const CAP_FLOATS_PER_VERTEX: usize = 5;
/// zeichnet ohne Backface-Culling (`CullMode::None`, siehe gpu.rs), sodass die
/// flache Schnittflaeche aus beiden Richtungen sichtbar ist. Die Schraffur haengt
/// nicht von der Normale ab.
/// Berechnet die (u,v)-Cut-Polygone der Schnittebene MIT angewandter Boolean-
/// Dominanz (`section_boolean::subtract_dominant_bands`): die staerkere Schicht
/// schneidet die schwaechere weg — elementuebergreifend, exakt wie der 2D-Schnitt
/// (`toSection.ts::attachCutStyles` Phase 2). Gemeinsame Basis der Cut-Cap-Flaechen
/// (`build_cut_caps`) UND der Cut-Umrisslinien (`build_cut_cap_lines`), damit beide
/// auf DERSELBEN, ueberlappungsfreien Rechteck-Menge arbeiten.
fn cut_cap_polys(plane: &SectionPlane, walls: &[WallInput], slabs: &[SlabInput]) -> Vec<CutPolygon> {
let out = cut_section(plane, walls, slabs);
subtract_dominant_bands(out.cut_polygons)
}
pub fn build_cut_caps(
plane: &SectionPlane,
walls: &[WallInput],
slabs: &[SlabInput],
) -> Vec<f32> {
let out = cut_section(plane, walls, slabs);
let polys = cut_cap_polys(plane, walls, slabs);
let point = plane.point;
let u_axis = plane.u_axis();
// (u, v) -> Welt: horizontale Lage aus point + u*u_axis, Hoehe = v.
@@ -62,14 +122,28 @@ pub fn build_cut_caps(
};
let mut verts: Vec<f32> = Vec::new();
for poly in &out.cut_polygons {
for poly in &polys {
let pts = &poly.pts;
if pts.len() < 3 {
continue;
}
// Muster/Winkel/Skalierung aus der Bauteil-Schraffur (oder Fallback-
// Diagonale). Winkel von Grad in Radiant, damit der Shader direkt
// cos/sin rechnen kann.
let (pattern, angle_rad, scale, line_weight) = match poly.hatch {
Some(h) => (
h.pattern as f32,
h.angle.to_radians(),
h.scale.max(0.05),
h.line_weight.max(0.0),
),
None => (FALLBACK_PATTERN, 0.0, 1.0, FALLBACK_HATCH_LINE_WEIGHT_MM),
};
let mut push_vertex = |uv: [f32; 2]| {
let w = to_world(uv);
verts.extend_from_slice(&[w[0], w[1], w[2], uv[0], uv[1]]);
verts.extend_from_slice(&[
w[0], w[1], w[2], uv[0], uv[1], pattern, angle_rad, scale, line_weight,
]);
};
// Fan-Triangulierung (konvexe Rechtecke): (0, i, i+1).
for i in 1..pts.len() - 1 {
@@ -80,3 +154,429 @@ pub fn build_cut_caps(
}
verts
}
/// Erzeugt die Schnitt-UMRISSLINIEN (P2): fuer JEDES ueberlebende Cut-Cap-Rechteck
/// seine vier Kanten als flache Ribbon-Dreiecke auf der Schnittebene, in Welt-
/// Koordinaten (dieselbe (u,v)->Welt-Ruecktransformation wie `build_cut_caps`).
/// Rueckgabe = interleaved `[px,py,pz, r,g,b]` (`CUT_LINE_FLOATS_PER_VERTEX`,
/// TriangleList), leer wenn die Ebene das Modell nicht schneidet.
///
/// STIL je Kante: Farbe/Strichstaerke aus dem `line_color`/`line_weight` des Bandes
/// (aus dem `jointLineStyleId`-LineStyle, TS-seitig aufgeloest), sonst der Default-
/// Schnittkanten-Stil. Die Strichstaerke (mm-Papier) wird ueber `CUT_LINE_MM_TO_WORLD`
/// in eine konstante Welt-Breite umgesetzt (siehe dortige Doku — bewusste
/// Vereinfachung, keine bildschirmfeste Strichstaerke). Jede Kante wird als Quad
/// (2 Dreiecke) um ihre Mittellinie gelegt, mit der Ribbon-Breite senkrecht zur
/// Kante IN der Ebene; alle Ribbons um `CUT_LINE_LIFT` zur Kamera versetzt, damit
/// sie nicht an der Ebene weggeclippt werden und vor den Caps liegen.
pub fn build_cut_cap_lines(
plane: &SectionPlane,
walls: &[WallInput],
slabs: &[SlabInput],
) -> Vec<f32> {
let polys = cut_cap_polys(plane, walls, slabs);
let point = plane.point;
let u_axis = plane.u_axis();
let normal = plane.normal;
// Kamera-waertiger Lift entlang -normal (normal zeigt vom Betrachter ins Modell).
let lift = [
-normal[0] * CUT_LINE_LIFT,
-normal[1] * CUT_LINE_LIFT,
-normal[2] * CUT_LINE_LIFT,
];
let to_world = |uv: [f32; 2]| -> [f32; 3] {
[
point[0] + uv[0] * u_axis[0] + lift[0],
uv[1] + lift[1],
point[2] + uv[0] * u_axis[2] + lift[2],
]
};
let mut verts: Vec<f32> = Vec::new();
for poly in &polys {
let Some(r) = rect_of_poly(poly) else {
continue;
};
// Stil aus den Band-Metadaten (oder Default).
let (color, weight_mm) = match &poly.cut {
Some(meta) => (
meta.line_color.unwrap_or(DEFAULT_CUT_LINE_COLOR),
meta.line_weight.unwrap_or(DEFAULT_CUT_LINE_WEIGHT_MM),
),
None => (DEFAULT_CUT_LINE_COLOR, DEFAULT_CUT_LINE_WEIGHT_MM),
};
let half_w = (weight_mm.max(0.0) * CUT_LINE_MM_TO_WORLD * 0.5).max(1e-4);
// Die vier Rechteck-Ecken (u,v) im Umlauf; je Kante ein Ribbon.
let corners = [
[r.u_min, r.v_min],
[r.u_max, r.v_min],
[r.u_max, r.v_max],
[r.u_min, r.v_max],
];
for k in 0..4 {
let a = to_world(corners[k]);
let b = to_world(corners[(k + 1) % 4]);
push_line_ribbon(&mut verts, a, b, normal, half_w, color);
}
}
verts
}
/// Legt ein flaches Ribbon-Quad (2 Dreiecke) der halben Breite `half_w` um die
/// Kante `a`->`b`, verbreitert IN der Schnittebene (senkrecht zur Kante ueber
/// `cross(normal, dir)`), und schreibt es als 6 Vertices `[pos, color]` in `out`.
fn push_line_ribbon(
out: &mut Vec<f32>,
a: [f32; 3],
b: [f32; 3],
normal: [f32; 3],
half_w: f32,
color: [f32; 3],
) {
let dir = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
let len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
if len < 1e-9 {
return;
}
let d = [dir[0] / len, dir[1] / len, dir[2] / len];
// In-Ebene-Senkrechte zur Kante: cross(normal, dir) liegt in der Ebene und
// steht senkrecht auf der Kante.
let mut perp = [
normal[1] * d[2] - normal[2] * d[1],
normal[2] * d[0] - normal[0] * d[2],
normal[0] * d[1] - normal[1] * d[0],
];
let plen = (perp[0] * perp[0] + perp[1] * perp[1] + perp[2] * perp[2]).sqrt();
if plen < 1e-9 {
return;
}
perp = [perp[0] / plen * half_w, perp[1] / plen * half_w, perp[2] / plen * half_w];
let p0 = [a[0] - perp[0], a[1] - perp[1], a[2] - perp[2]];
let p1 = [a[0] + perp[0], a[1] + perp[1], a[2] + perp[2]];
let p2 = [b[0] + perp[0], b[1] + perp[1], b[2] + perp[2]];
let p3 = [b[0] - perp[0], b[1] - perp[1], b[2] - perp[2]];
let mut push = |p: [f32; 3]| {
out.extend_from_slice(&[p[0], p[1], p[2], color[0], color[1], color[2]]);
};
// Zwei Dreiecke: p0,p1,p2 und p0,p2,p3.
push(p0);
push(p1);
push(p2);
push(p0);
push(p2);
push(p3);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::Hatch;
/// Eine Wand entlang der X-Achse, senkrecht von einer +X-Ebene geschnitten:
/// die Muster-Id/Winkel/Skalierung der Bauteil-Schraffur muessen in JEDEM
/// Cap-Vertex an den Positionen 5/6/7 landen (Vertex-Stride = 8).
#[test]
fn hatch_muster_id_landet_in_den_cap_vertices() {
let wall = WallInput {
start: [0.0, 0.0],
end: [3.0, 0.0],
thickness: 0.3,
height: 2.5,
base_elevation: 0.0,
color: [0.8, 0.8, 0.8],
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: Some(Hatch {
pattern: 3, // crosshatch
angle: 90.0,
scale: 2.0,
line_weight: 0.5,
}),
cut: None,
};
let plane = SectionPlane::looking_plus_x(1.5);
let verts = build_cut_caps(&plane, &[wall], &[]);
assert!(!verts.is_empty(), "Schnitt liefert Cap-Geometrie");
assert_eq!(
verts.len() % CAP_FLOATS_PER_VERTEX,
0,
"Vertexpuffer ist ein Vielfaches des Strides ({CAP_FLOATS_PER_VERTEX})"
);
let n = verts.len() / CAP_FLOATS_PER_VERTEX;
for i in 0..n {
let base = i * CAP_FLOATS_PER_VERTEX;
assert_eq!(verts[base + 5], 3.0, "pattern-Id je Vertex");
assert!(
(verts[base + 6] - 90.0_f32.to_radians()).abs() < 1e-5,
"angle in Radiant"
);
assert_eq!(verts[base + 7], 2.0, "scale je Vertex");
assert_eq!(verts[base + 8], 0.5, "line_weight (mm) je Vertex");
}
}
/// Ohne `hatch` faellt jedes Cap-Polygon auf das Diagonalmuster (Id 2,
/// angle 0, scale 1) zurueck — das reproduziert das fruehere fixe 45°-Muster.
#[test]
fn ohne_hatch_fallback_auf_diagonale() {
let wall = WallInput {
start: [0.0, 0.0],
end: [3.0, 0.0],
thickness: 0.3,
height: 2.5,
base_elevation: 0.0,
color: [0.8, 0.8, 0.8],
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
};
let plane = SectionPlane::looking_plus_x(1.5);
let verts = build_cut_caps(&plane, &[wall], &[]);
assert!(!verts.is_empty());
assert_eq!(verts[5], FALLBACK_PATTERN, "Fallback = Diagonale (2)");
assert_eq!(verts[6], 0.0, "Fallback-Winkel 0");
assert_eq!(verts[7], 1.0, "Fallback-Skalierung 1");
assert_eq!(verts[8], FALLBACK_HATCH_LINE_WEIGHT_MM, "Fallback-Musterlinienstaerke 0.13 mm");
}
use crate::types::CutBandMeta;
/// Ein Wand-Band (Prio 50, volle Hoehe) und ein u-deckungsgleiches Decken-Band
/// (Prio 100) ueberlappen in einem z-Intervall. Nach `build_cut_caps` (das die
/// Boolean-Dominanz anwendet) darf im Ueberlappungs-z-Intervall KEINE Wand-
/// Cap-Geometrie mehr liegen — die geometrische Kernaussage von P1, direkt auf
/// den erzeugten Welt-Vertices geprueft (nicht bloss „laeuft").
#[test]
fn build_cut_caps_wendet_dominanz_an_wand_verliert_ueberlappung() {
// Wand entlang +X bei x in [0,3], Dicke 0.4, volle Hoehe [0,2.5]; Prio 50.
let wall = WallInput {
start: [0.0, 0.0],
end: [3.0, 0.0],
thickness: 0.4,
height: 2.5,
base_elevation: 0.0,
color: [0.8, 0.8, 0.8],
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: Some(Hatch { pattern: 2, angle: 0.0, scale: 1.0, line_weight: 0.13 }),
cut: Some(CutBandMeta {
join_priority: Some(50),
component_id: Some("brick".into()),
line_color: None,
line_weight: None,
}),
};
// Decken-Slab, deren Umriss die Wand an der Schnittstelle (x=1.5) ueberdeckt,
// z in [2.0, 2.4]; Prio 100 (Beton laeuft durch, schneidet die Wand).
let slab = SlabInput {
outline: vec![[-1.0, -1.0], [4.0, -1.0], [4.0, 1.0], [-1.0, 1.0]],
z_bottom: 2.0,
z_top: 2.4,
color: [0.8, 0.8, 0.8],
hatch: Some(Hatch { pattern: 1, angle: 0.0, scale: 1.0, line_weight: 0.13 }),
cut: Some(CutBandMeta {
join_priority: Some(100),
component_id: Some("concrete".into()),
line_color: None,
line_weight: None,
}),
};
let plane = SectionPlane::looking_plus_x(1.5);
let verts = build_cut_caps(&plane, &[wall], &[slab]);
assert!(!verts.is_empty());
// Die Wand-Caps tragen pattern==2 (diagonal), die Beton-Caps pattern==1
// (solid). Fuer JEDES Wand-Vertex (pattern 2) muss die Hoehe (v = pos.y,
// Index 1) AUSSERHALB des Beton-Intervalls [2.0,2.4] liegen.
let n = verts.len() / CAP_FLOATS_PER_VERTEX;
let mut wall_verts = 0;
let mut concrete_verts = 0;
for i in 0..n {
let base = i * CAP_FLOATS_PER_VERTEX;
let y = verts[base + 1];
let pattern = verts[base + 5];
if (pattern - 2.0).abs() < 0.5 {
wall_verts += 1;
assert!(
y <= 2.0 + 1e-3 || y >= 2.4 - 1e-3,
"Wand-Cap-Vertex bei v={y} liegt im Beton-z-Intervall (muesste weggeschnitten sein)"
);
} else if (pattern - 1.0).abs() < 0.5 {
concrete_verts += 1;
}
}
assert!(wall_verts > 0, "Wand-Caps vorhanden (ober-/unterhalb des Betons)");
assert!(concrete_verts > 0, "Beton-Caps vorhanden (voll durchlaufend)");
}
/// Reziprok zum vorigen Test (Nutzer-Report 2026-07-08): eine Backstein-WAND
/// (Prio 50) muss die SCHWAECHEREN Decken-Schichten Estrich (30) und Daemmung
/// (20) in der u/v-Ueberlappung wegschneiden — wie im 2D-Schnitt. Wir bauen die
/// Wand als EIN Backstein-Band (Prio 50) und den Boden als zwei duenne Slabs
/// (Estrich oben, Daemmung darunter), deren z-Intervall die Wand ueberlappt.
/// Nach der Dominanz darf im Wand-u-Intervall KEINE Estrich-/Daemmung-Cap mehr
/// liegen (die Wand-Cap dagegen bleibt — Backstein ist dort dominant).
#[test]
fn wand_schneidet_schwaechere_decken_schichten() {
// Backstein-Wand entlang +X, x in [0,3], Dicke 0.4 → u (=y) in [-0.2,0.2],
// volle Hoehe [0,2.5]; Prio 50, Muster 2 (diagonal).
let wall = WallInput {
start: [0.0, 0.0],
end: [3.0, 0.0],
thickness: 0.4,
height: 2.5,
base_elevation: 0.0,
color: [0.8, 0.8, 0.8],
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: Some(Hatch { pattern: 2, angle: 0.0, scale: 1.0, line_weight: 0.13 }),
cut: Some(CutBandMeta {
join_priority: Some(50),
component_id: Some("brick".into()),
line_color: None,
line_weight: None,
}),
};
// Estrich-Slab (Prio 30, Muster 3 crosshatch), z in [1.0,1.06], deckt die
// Wand in u ab (Umriss ueber y in [-1,1]).
let screed = SlabInput {
outline: vec![[-1.0, -1.0], [4.0, -1.0], [4.0, 1.0], [-1.0, 1.0]],
z_bottom: 1.0,
z_top: 1.06,
color: [0.8, 0.8, 0.8],
hatch: Some(Hatch { pattern: 3, angle: 0.0, scale: 1.0, line_weight: 0.13 }),
cut: Some(CutBandMeta {
join_priority: Some(30),
component_id: Some("screed".into()),
line_color: None,
line_weight: None,
}),
};
// Daemmung-Slab (Prio 20, Muster 4), z in [0.96,1.0], gleiche u-Deckung.
let insulation = SlabInput {
outline: vec![[-1.0, -1.0], [4.0, -1.0], [4.0, 1.0], [-1.0, 1.0]],
z_bottom: 0.96,
z_top: 1.0,
color: [0.8, 0.8, 0.8],
hatch: Some(Hatch { pattern: 4, angle: 0.0, scale: 1.0, line_weight: 0.13 }),
cut: Some(CutBandMeta {
join_priority: Some(20),
component_id: Some("insulation".into()),
line_color: None,
line_weight: None,
}),
};
let plane = SectionPlane::looking_plus_x(1.5);
let verts = build_cut_caps(&plane, &[wall], &[screed, insulation]);
assert!(!verts.is_empty());
// Wand-u-Intervall (=y) ist [-0.2,0.2]. Fuer JEDES Estrich- (Muster 3) oder
// Daemmung-Vertex (Muster 4) muss u AUSSERHALB [-0.2,0.2] liegen — sonst hat
// der Backstein die schwaechere Schicht dort NICHT weggeschnitten.
let n = verts.len() / CAP_FLOATS_PER_VERTEX;
let mut weak_verts = 0;
for i in 0..n {
let base = i * CAP_FLOATS_PER_VERTEX;
let u = verts[base]; // pos.x = y-Welt (Schnitt entlang +X)
let pattern = verts[base + 5];
if (pattern - 3.0).abs() < 0.5 || (pattern - 4.0).abs() < 0.5 {
weak_verts += 1;
assert!(
u <= -0.2 + 1e-3 || u >= 0.2 - 1e-3,
"Estrich/Daemmung-Vertex bei u={u} liegt im Backstein-u-Intervall \
[-0.2,0.2] (muesste weggeschnitten sein)"
);
}
}
assert!(weak_verts > 0, "Estrich/Daemmung-Caps existieren (ausserhalb der Wand)");
}
/// P2: je ueberlebendem Cut-Cap-Rechteck entstehen 4 Umriss-Kanten als Ribbon-
/// Dreiecke (4 Kanten * 6 Vertices = 24 Vertices je Rechteck), und Farbe +
/// Strichstaerke kommen aus dem Band-Stil durch (Farbe je Vertex, Staerke ueber
/// die Ribbon-Breite messbar).
#[test]
fn build_cut_cap_lines_kantenzahl_und_stil() {
let color = [0.2, 0.4, 0.9];
let wall = WallInput {
start: [0.0, 0.0],
end: [3.0, 0.0],
thickness: 0.4,
height: 2.5,
base_elevation: 0.0,
color: [0.8, 0.8, 0.8],
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: Some(CutBandMeta {
join_priority: Some(50),
component_id: Some("brick".into()),
line_color: Some(color),
line_weight: Some(2.0),
}),
};
let plane = SectionPlane::looking_plus_x(1.5);
let verts = build_cut_cap_lines(&plane, &[wall.clone()], &[]);
assert!(!verts.is_empty());
assert_eq!(
verts.len() % CUT_LINE_FLOATS_PER_VERTEX,
0,
"Vielfaches des Cut-Linien-Strides"
);
// EIN Rechteck (keine Dominanz-Subtraktion, lone band) -> 4 Kanten * 6 = 24 Vertices.
assert_eq!(
verts.len() / CUT_LINE_FLOATS_PER_VERTEX,
24,
"4 Kanten * 6 Vertices je Ribbon"
);
// Farbe je Vertex aus dem Stil (Index 3/4/5).
let n = verts.len() / CUT_LINE_FLOATS_PER_VERTEX;
for i in 0..n {
let base = i * CUT_LINE_FLOATS_PER_VERTEX;
assert!((verts[base + 3] - color[0]).abs() < 1e-6);
assert!((verts[base + 4] - color[1]).abs() < 1e-6);
assert!((verts[base + 5] - color[2]).abs() < 1e-6);
}
// Strichstaerke kommt durch: eine DICKERE Linie erzeugt ein breiteres Ribbon.
// Messbar an der (u,v)-Ausdehnung senkrecht zur Kante — hier ueber die
// world-Bounding-Box-Hoehe der beiden horizontalen Kanten (v-Ausdehnung der
// gesamten Geometrie) im Vergleich duenn vs. dick.
let bbox_y = |vs: &[f32]| -> f32 {
let n = vs.len() / CUT_LINE_FLOATS_PER_VERTEX;
let mut lo = f32::INFINITY;
let mut hi = f32::NEG_INFINITY;
for i in 0..n {
let y = vs[i * CUT_LINE_FLOATS_PER_VERTEX + 1];
lo = lo.min(y);
hi = hi.max(y);
}
hi - lo
};
let mut thin = wall.clone();
thin.cut = Some(CutBandMeta {
join_priority: Some(50),
component_id: Some("brick".into()),
line_color: Some(color),
line_weight: Some(0.2),
});
let thin_verts = build_cut_cap_lines(&plane, &[thin], &[]);
assert!(
bbox_y(&verts) > bbox_y(&thin_verts) + 1e-4,
"dickere Strichstaerke -> groessere v-Ausdehnung der Ribbons"
);
}
}