From 23dddf893e643f0b50562dbdec531079c75e6454 Mon Sep 17 00:00:00 2001 From: Karim Date: Sat, 4 Jul 2026 19:22:32 +0200 Subject: [PATCH] Joins Phase 1c: Durchgangswand am T-Stoss echt aufbrechen (spanCutouts) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src-tauri/geometry/src/lib.rs | 137 +++++++++++++++-- src/model/joins.test.ts | 62 +++++++- src/model/joins.ts | 77 ++++++++++ src/plan/generatePlan.tjunction.test.ts | 137 +++++++++++++++-- src/plan/generatePlan.ts | 186 +++++++++++++++++++----- 5 files changed, 541 insertions(+), 58 deletions(-) diff --git a/src-tauri/geometry/src/lib.rs b/src-tauri/geometry/src/lib.rs index 2ab63b3..e629194 100644 --- a/src-tauri/geometry/src/lib.rs +++ b/src-tauri/geometry/src/lib.rs @@ -67,6 +67,20 @@ pub struct LayerCuts { pub end_side: Vec>, } +/// 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, + /// 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, } /// 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 { 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()); } } diff --git a/src/model/joins.test.ts b/src/model/joins.test.ts index 6fa60f3..11f932f 100644 --- a/src/model/joins.test.ts +++ b/src/model/joins.test.ts @@ -311,10 +311,68 @@ describe("computeJoins — materialbewusster T-Stoss (layerCuts)", () => { expect(distToLine(lc.startSide[0]!, { x: 5, y: 0.06 })).toBeCloseTo(0, 6); expect(distToLine(lc.startSide[0]!, { x: 0, y: 0.06 })).toBeCloseTo(0, 6); - // Die kollinearen Hälften der Durchgangswand bleiben unangetastet - // (unverändertes Verhalten, keine layerCuts für sie). + // Die kollinearen Hälften der Durchgangswand bleiben bei den layerCuts + // unangetastet (keine Pro-Schicht-Cuts für sie). expect(joins.get("WA1")!.layerCuts).toBeUndefined(); expect(joins.get("WA2")!.layerCuts).toBeUndefined(); + + // Die als Durchgang gewählte Wandhälfte bekommt eine Durchgangswand-Aussparung + // (Phase 1c): über die Backstein-Kernbreite (0.12) im Nah-Putz-Bereich + // (Offset 0.06…0.075). Welche Hälfte den Durchgang bildet, hängt von der + // Knoten-Reihenfolge ab — wir prüfen die, die den Cutout trägt. + const throughCuts = joins.get("WA1")!.spanCutouts ?? joins.get("WA2")!.spanCutouts; + expect(throughCuts).toBeDefined(); + expect(throughCuts!.length).toBe(1); + const sc = throughCuts![0]; + // Achsen-Intervall: Kernbreite 0.12, zentriert am Knoten (x=5 → [4.94,5.06]). + expect(sc.to - sc.from).toBeCloseTo(0.12, 6); + // offA = Nahfläche (0.075), offB = Rückgrat-Nahfläche (0.06). + expect(sc.offA).toBeCloseTo(0.075, 6); + expect(sc.offB).toBeCloseTo(0.06, 6); + // Der Abzweig selbst trägt keine Aussparung (die gehört der Durchgangswand). + expect(joins.get("WB")!.spanCutouts).toBeUndefined(); + // L-Ecke bzw. Wände ohne T bleiben cutout-frei (die kollineare Nicht-Durchgangs- + // Hälfte hat keinen Cutout). + expect( + joins.get("WA1")!.spanCutouts === undefined || + joins.get("WA2")!.spanCutouts === undefined, + ).toBe(true); + }); + + it("Mittelspannen-T-Stoss: die Durchgangswand bekommt einen spanCutout mit korrekter Kernbreite und Nah-Putz-Offsetzone", () => { + // Realer W9-Fall: Durchgangswand als EINE Wand W (0,0)→(10,0), Abzweig WB + // trifft mit seinem freien Ende (4,0) mittig auf ihre Seite. Beide iw. + const proj = layeredWallProject( + iwWall("W", { x: 0, y: 0 }, { x: 10, y: 0 }), + iwWall("WB", { x: 4, y: 3 }, { x: 4, y: 0 }), + iwWall("Filler", { x: 100, y: 100 }, { x: 100, y: 103 }), // unbeteiligt + ); + const joins = computeJoins(proj, proj.walls); + + const wCuts = joins.get("W")!; + expect(wCuts.spanCutouts).toBeDefined(); + expect(wCuts.spanCutouts!.length).toBe(1); + const sc = wCuts.spanCutouts![0]; + // Intervall um x=4, Breite = Kernbreite 0.12 → [3.94, 4.06]. + expect(sc.from).toBeCloseTo(3.94, 6); + expect(sc.to).toBeCloseTo(4.06, 6); + expect(sc.offA).toBeCloseTo(0.075, 6); + expect(sc.offB).toBeCloseTo(0.06, 6); + // Der Abzweig und die unbeteiligte Wand tragen keine Aussparung. + expect(joins.get("WB")!.spanCutouts).toBeUndefined(); + expect(joins.get("Filler")!.spanCutouts).toBeUndefined(); + }); + + it("L-Ecke und einfacher (nicht materialbewusster) Stoss bekommen KEINEN spanCutout", () => { + // Reine L-Ecke zweier Wände: keine Durchgangswand, kein Durchstoss. + const proj = twoWallProject( + wall("L1", { x: 0, y: 0 }, { x: 5, y: 0 }), + wall("L2", { x: 5, y: 0 }, { x: 5, y: 4 }), + 0.4, + ); + const joins = computeJoins(proj, proj.walls); + expect(joins.get("L1")!.spanCutouts).toBeUndefined(); + expect(joins.get("L2")!.spanCutouts).toBeUndefined(); }); it("setzt layerCuts NICHT, wenn kein Bauteil des Abzweigs mit dem Rückgrat der Durchgangswand übereinstimmt", () => { diff --git a/src/model/joins.ts b/src/model/joins.ts index d69475a..38e90a1 100644 --- a/src/model/joins.ts +++ b/src/model/joins.ts @@ -35,6 +35,29 @@ export interface LayerCuts { endSide: (Line | null)[]; } +/** + * Aussparung der DURCHGANGSWAND an einem materialbewussten T-Stoss: über das + * Achsen-Intervall `[from,to]` (Meter ab `wall.start` entlang der Achse) und den + * Quer-Offsetbereich `[offA,offB]` wird die Nah-Putz-Zone der Durchgangswand + * echt ausgeschnitten, damit der durchstechende Abzweig-Kern (Phase 1b) die + * Durchgangswand nicht nur übermalt, sondern geometrisch aufbricht. + * + * OFFSET-KONVENTION (identisch zu den Schicht-Bändern in `addWallPoche`): + * `offA`/`offB` sind Querabstände von der Wandachse ENTLANG der linken Normalen + * `leftNormal(dir)`, inkl. Referenzversatz — also im selben Bereich wie + * `refOff − total/2` (außen) … `refOff + total/2` (innen). `offA` ist stets die + * NAHFLÄCHE (Wandoberfläche, `refOff + sign·total/2`), `offB` die dem Abzweig + * zugewandte Fläche des Rückgrat-KERNS (`refOff + sign·(total/2 − nearPlaster)`) + * — die Zone dazwischen ist exakt der Nah-Putz, den der Abzweig-Kern durchstösst. + * `from ≤ to` ist die Projektion der Abzweig-Kernbreite auf die Durchgangsachse. + */ +export interface SpanCutout { + from: number; + to: number; + offA: number; + offB: number; +} + /** Schnittlinien einer Wand an ihren beiden Achsenden. */ export interface WallCuts { startCut: Line | null; @@ -46,6 +69,13 @@ export interface WallCuts { * verschmilzt (siehe {@link resolveJoinPriority}). */ layerCuts?: LayerCuts; + /** + * Additive Erweiterung (Durchgangswand-Seite): Aussparungen der Nah-Putz-Zone, + * durch die ein Abzweig-Kern sticht. Fehlt/leer ⇒ die Wand wird unverändert + * gezeichnet. Wird nur an der DURCHGANGSWAND gesetzt (nie am Abzweig), analog + * additiv zu {@link layerCuts}. Siehe {@link SpanCutout}. + */ + spanCutouts?: SpanCutout[]; } /** @@ -397,6 +427,53 @@ function applyLayerCuts( !merged[i] && ((i > 0 && merged[i - 1]) || (i < n - 1 && merged[i + 1])); sideArr[i] = adjMerged ? sideLine : null; } + + // Durchgangswand-Aussparung (Phase 1c): den Nah-Putz der Durchgangswand über + // die Breite des durchstechenden Abzweig-Kerns ECHT ausschneiden. Nur nötig, + // wenn davor überhaupt Nah-Putz liegt (nearPlaster > 0) — sonst schliesst der + // Kern flächenbündig an und es gibt nichts aufzubrechen. + if (nearPlaster > 1e-9) { + // Kernbreite des Abzweigs im Quer-Offset (Offset-Konvention der Bänder: + // gestapelt entlang leftNormal ab refOff − total/2). Die verschmelzenden + // Schichten (`merged`) bilden den Kern; Lo/Hi = ihre Offset-Hülle. + const totalBranch = wallTypeThickness(branchWt); + const refOffBranch = wallReferenceOffset(branchWall, totalBranch); + let bOff = refOffBranch - totalBranch / 2; + let coreLo = Infinity; + let coreHi = -Infinity; + for (let i = 0; i < n; i++) { + const th = branchWt.layers[i].thickness; + if (merged[i]) { + if (bOff < coreLo) coreLo = bOff; + if (bOff + th > coreHi) coreHi = bOff + th; + } + bOff += th; + } + if (coreHi > coreLo) { + // Projektion der Kern-Offsethülle auf die Durchgangsachse: der Achsenpunkt + // liegt bei `axisAt` (Meter ab Durchgangs-Start), ein Abzweig-Offset `o` + // verschiebt sich entlang der Achse um `o · (nBranch · uT)`. + const nBranch = leftNormal(dirOf(branchWall)); + const proj = dot(nBranch, throughDir); + const axisAt = dot(sub(axisPoint, throughWall.start), throughDir); + const e1 = axisAt + coreLo * proj; + const e2 = axisAt + coreHi * proj; + // Quer-Offsetbereich = Nah-Putz-Zone der Durchgangswand: Nahfläche (offA) + // bis Rückgrat-Nahfläche (offB, = mergeCut-Offset). Beide in derselben + // Offset-Konvention wie die Schicht-Bänder der Durchgangswand. + const offA = offT + sign * (tT / 2); + const offB = offT + sign * (tT / 2 - nearPlaster); + const throughCuts = result.get(throughWall.id); + if (throughCuts) { + (throughCuts.spanCutouts ??= []).push({ + from: Math.min(e1, e2), + to: Math.max(e1, e2), + offA, + offB, + }); + } + } + } } /** diff --git a/src/plan/generatePlan.tjunction.test.ts b/src/plan/generatePlan.tjunction.test.ts index abb614f..8664ea6 100644 --- a/src/plan/generatePlan.tjunction.test.ts +++ b/src/plan/generatePlan.tjunction.test.ts @@ -114,17 +114,134 @@ describe("generatePlan — materialbewusster T-Stoss (Poché-Bänder)", () => { } }); - it("lässt die kollineare Durchgangswand unverändert (keine L-Linien, normale Bänder)", () => { + it("bricht die Nah-Putz-Schicht der Durchgangswand am Durchstoss auf (Phase 1c)", () => { + // Am T-Knoten wird eine der kollinearen Hälften als Durchgangswand gewählt + // und bekommt eine Aussparung; ihr Innenputz-Band auf der Durchstoss-Seite + // (y∈[0.06,0.075]) wird über die Backstein-Breite [4.94,5.06] gekürzt (der + // Cutout reicht bis ans Knoten-Ende x=5). Backstein-Kern + Aussenputz bleiben + // voll (kein Überlappungsbereich). const plan = generatePlan(tJunctionProject(), "eg", visible, undefined, "mittel"); - const wa1Bands = plan.primitives.filter( - (p): p is Extract => - p.kind === "polygon" && p.wallId === "WA1" && p.stroke === "none", + // Die Durchgangs-Hälfte ist die mit spanCutouts — finde sie über ihre + // gekürzten Bänder: sammle die Bänder beider Hälften. + const bandsOf = (id: string) => + plan.primitives.filter( + (p): p is Extract => + p.kind === "polygon" && p.wallId === id && p.stroke === "none", + ); + const wa1 = bandsOf("WA1"); + const wa2 = bandsOf("WA2"); + // Beide Hälften haben weiterhin drei Bänder (der Cutout reicht bis ans + // Wandende, splittet also nicht in zwei, sondern kürzt nur). + expect(wa1.length).toBe(3); + expect(wa2.length).toBe(3); + // Genau EINE Hälfte trägt ein gekürztes Nah-Putz-Band: das innere Putzband + // (y-Nahfläche 0.075) endet nicht mehr am Knoten x=5, sondern bei 4.94 bzw. + // (für die andere Orientierung) bei 5.06. + // Nah-Putz-Band = Offsetzone y∈[0.06,0.075] (offA=0.06 → pts[0/1], offB=0.075 + // → pts[2/3]); dessen X-Kanten verraten die Kürzung. + const isNearPlaster = (b: { pts: { y: number }[] }) => { + const ys = b.pts.map((p) => p.y); + return Math.abs(Math.min(...ys) - 0.06) < 1e-6 && Math.abs(Math.max(...ys) - 0.075) < 1e-6; + }; + const nearPlasterEnds = [...wa1, ...wa2] + .filter(isNearPlaster) + .flatMap((b) => b.pts.map((p) => p.x)); + // Mindestens eine Kante liegt bei 4.94 oder 5.06 (Backstein-Breite ab Knoten). + const shortened = nearPlasterEnds.some( + (x) => Math.abs(x - 4.94) < 1e-6 || Math.abs(x - 5.06) < 1e-6, ); - expect(wa1Bands.length).toBe(3); - // Kein Cut an ihrem gemeinsamen Knoten-Ende (endCut bleibt null, s. joins.ts) → - // alle drei Bänder enden exakt am Achsenpunkt (5,0), keine Gehrung nötig. - for (const b of wa1Bands) { - expect(b.pts[1].x).toBeCloseTo(5, 6); - } + expect(shortened).toBe(true); + }); +}); + +/** Mittelspannen-Variante: EINE Durchgangswand W, Abzweig WB trifft mittig. */ +function midSpanProject(): Project { + const base = tJunctionProject(); + const wall = (id: string, start: Wall["start"], end: Wall["end"]): Wall => ({ + id, + type: "wall", + floorId: "eg", + categoryCode: "20", + start, + end, + wallTypeId: "iw", + height: 2.6, + }); + base.walls = [ + wall("W", { x: 0, y: 0 }, { x: 10, y: 0 }), + wall("WB", { x: 4, y: 3 }, { x: 4, y: 0 }), + ]; + return base; +} + +describe("generatePlan — Durchgangswand-Aussparung am Mittelspann-Stoss (Phase 1c)", () => { + it("splittet das Innenputz-Band der Durchgangswand über dem Durchstoss in zwei Teil-Bänder", () => { + const plan = generatePlan(midSpanProject(), "eg", visible, undefined, "mittel"); + const bands = plan.primitives.filter( + (p): p is Extract => + p.kind === "polygon" && p.wallId === "W" && p.stroke === "none", + ); + // Innenputz-Band (Nahfläche y=0.075) wird über [3.94,4.06] entfernt → ZWEI + // Teil-Bänder statt einem. Backstein (2 Bänder? nein 1) + Aussenputz bleiben + // je ein Band → insgesamt 4 Bänder (1 Aussenputz + 1 Backstein + 2 Innenputz). + const innerPlaster = bands.filter((b) => { + const ys = b.pts.map((p) => p.y); + return Math.abs(Math.min(...ys) - 0.06) < 1e-6 && Math.abs(Math.max(...ys) - 0.075) < 1e-6; + }); + expect(innerPlaster.length).toBe(2); + // Das linke Teil-Band endet bei x=3.94, das rechte beginnt bei x=4.06. + const xs = innerPlaster.flatMap((b) => b.pts.map((p) => p.x)).sort((a, b) => a - b); + // Erwartete Kanten: 0, 3.94 (linkes Band) und 4.06, 10 (rechtes Band). + expect(xs.some((x) => Math.abs(x - 3.94) < 1e-6)).toBe(true); + expect(xs.some((x) => Math.abs(x - 4.06) < 1e-6)).toBe(true); + // Insgesamt bleiben Backstein-Kern (y-Nahfläche 0.06) und Aussenputz (−0.06) + // ganze Bänder → 4 Bänder gesamt. + expect(bands.length).toBe(4); + }); + + it("unterbricht die Schichtfuge im Merge-Bereich (keine Trennlinie an der Verschmelzungsfläche)", () => { + const plan = generatePlan(midSpanProject(), "eg", visible, undefined, "mittel"); + // Fugen der Durchgangswand W: layer-joint-Linien parallel zur x-Achse. + const joints = plan.primitives.filter( + (p): p is Extract => + p.kind === "line" && p.cls === "layer-joint", + ); + // Die Fuge an der Rückgrat-Nahfläche y=0.06 (Merge-Kontakt) darf NICHT über + // den Durchstoss [3.94,4.06] laufen: kein Fugen-Segment enthält einen Punkt + // mit y≈0.06 und x im offenen Intervall (3.94, 4.06). + for (const l of joints) { + if (Math.abs(l.a.y - 0.06) < 1e-6 && Math.abs(l.b.y - 0.06) < 1e-6) { + const lo = Math.min(l.a.x, l.b.x); + const hi = Math.max(l.a.x, l.b.x); + // Das Segment darf den Durchstoss nicht überspannen/betreten. + expect(lo >= 4.06 - 1e-9 || hi <= 3.94 + 1e-9).toBe(true); + } + } + // Und es MUSS Fugen-Segmente auf beiden Seiten geben (die Fuge existiert weiter, + // nur unterbrochen). + const at006 = joints.filter( + (l) => Math.abs(l.a.y - 0.06) < 1e-6 && Math.abs(l.b.y - 0.06) < 1e-6, + ); + expect(at006.some((l) => Math.max(l.a.x, l.b.x) <= 3.94 + 1e-6)).toBe(true); + expect(at006.some((l) => Math.min(l.a.x, l.b.x) >= 4.06 - 1e-6)).toBe(true); + }); + + it("unterbricht die Nahflächen-Umrisslinie über dem Durchstoss", () => { + const plan = generatePlan(midSpanProject(), "eg", visible, undefined, "mittel"); + // Die separat gezeichneten Nahkanten-Segmente (cls "wall-outline") liegen bei + // y=0.075 und lassen [3.94,4.06] frei. + const outline = plan.primitives.filter( + (p): p is Extract => + p.kind === "line" && p.cls === "wall-outline", + ); + expect(outline.length).toBe(2); // links + rechts vom Durchstoss + for (const l of outline) { + expect(Math.abs(l.a.y - 0.075) < 1e-6).toBe(true); + expect(Math.abs(l.b.y - 0.075) < 1e-6).toBe(true); + } + // Ein Segment endet bei 3.94, das andere beginnt bei 4.06. + const allX = outline.flatMap((l) => [l.a.x, l.b.x]); + expect(allX.some((x) => Math.abs(x - 3.94) < 1e-6)).toBe(true); + expect(allX.some((x) => Math.abs(x - 4.06) < 1e-6)).toBe(true); }); }); diff --git a/src/plan/generatePlan.ts b/src/plan/generatePlan.ts index 6928789..7262416 100644 --- a/src/plan/generatePlan.ts +++ b/src/plan/generatePlan.ts @@ -1045,6 +1045,38 @@ export function wallGaps( return gaps.sort((a, b) => a.from - b.from); } +/** Toleranz für die Achsen-Intervall-Arithmetik der Durchgangswand-Aussparungen. */ +const CUTOUT_EPS = 1e-9; + +/** + * Zieht die Loch-Intervalle `holes` vom Segment `[s,e]` ab und liefert die + * verbleibenden (gezeichneten) Teil-Intervalle in Achsen-Metern. Löcher werden + * auf `[s,e]` geklemmt, sortiert und vereinigt; leeres Ergebnis heisst „ganz + * ausgespart". Ist `holes` leer, kommt exakt `[[s,e]]` zurück (unverändertes + * Verhalten für Wände ohne Aussparung). + */ +function subtractIntervals( + s: number, + e: number, + holes: Array<[number, number]>, +): Array<[number, number]> { + const clipped = holes + .map(([a, b]): [number, number] => [ + Math.max(s, Math.min(a, b)), + Math.min(e, Math.max(a, b)), + ]) + .filter(([a, b]) => b > a + CUTOUT_EPS) + .sort((p, q) => p[0] - q[0]); + const parts: Array<[number, number]> = []; + let cursor = s; + for (const [a, b] of clipped) { + if (a > cursor + CUTOUT_EPS) parts.push([cursor, a]); + if (b > cursor) cursor = b; + } + if (cursor < e - CUTOUT_EPS) parts.push([cursor, e]); + return parts; +} + function addWallPoche( out: Primitive[], project: Project, @@ -1095,6 +1127,13 @@ function addWallPoche( // (höchste Priorität) — weiß, bzw. schwarz falls dessen Muster "solid" ist. const simpleFill = backbonePocheFill(project, wt); + // Durchgangswand-Aussparungen (Phase 1c): über diese Achsen-Intervalle × Quer- + // Offsetbereiche wird die Nah-Putz-Zone der Durchgangswand echt ausgeschnitten + // (der Abzweig-Kern sticht durch). `offA` ist stets die Nahfläche, `offB` die + // Rückgrat-Nahfläche (siehe SpanCutout in model/joins.ts). Leer ⇒ heutiges + // Verhalten (Wand ohne T-Durchstoss). + const spanCutouts = cuts.spanCutouts ?? []; + // Kanten-Indizes der inneren Gehrungs-Stirnflächen (die Diagonalen am Knoten). // clippedBand liefert [A.start, A.end, B.end, B.start] → Kante 1 (A.end→B.end) // ist die endCut-Stirnfläche, Kante 3 (B.start→A.start) die startCut-Stirnfläche. @@ -1137,6 +1176,15 @@ function addWallPoche( // auf. Die Bänder tragen NUR Füllung + Schraffur (kein eigener Umriss); jede // innere Schichtfuge wird als EIGENSTÄNDIGE, stilisierbare Linie gezeichnet. const bandJoinEdges = joinEdges(startCut, endCut); + // Nur die Aussparungen, die dieses Segment [s,e] entlang der Achse berühren. + const segCutouts = spanCutouts.filter( + (c) => + Math.min(c.from, c.to) < e - CUTOUT_EPS && + Math.max(c.from, c.to) > s + CUTOUT_EPS, + ); + // Punkt entlang der Wandachse bei Achsen-Meter `d` (p1/p2 an den Rändern). + const axisPt = (d: number): Vec2 => + d === s ? p1 : d === e ? p2 : along(wall.start, wall.end, d); // Materialbewusster T-Stoss (siehe resolveJoinPriority in model/joins.ts): // liegt eine Pro-Schicht-Cut-Überschreibung vor, ersetzt sie PRO SCHICHT den // einheitlichen startCut/endCut — nur an den echten Wandenden (dieselbe @@ -1161,20 +1209,38 @@ function addWallPoche( const layerSolid = layerHatch.pattern === "solid"; const layerStartCut = s <= 1e-6 ? (layerCuts ? layerCuts.start[li] : startCut) : null; const layerEndCut = e >= axisLen - 1e-6 ? (layerCuts ? layerCuts.end[li] : endCut) : null; - out.push({ - kind: "polygon", - pts: clippedBand(p1, p2, off, off + layer.thickness, layerStartCut, layerEndCut), - fill: layerSolid ? HATCH_INK : HATCH_PAPER, - // Band ohne Umriss: keine Schichtfuge über die Bandkante, keine 45°-Naht - // am Knoten. Die Fugen kommen als separate `line`-Primitive (siehe unten). - stroke: "none", - strokeWidthMm: 0, - hatch: layerHatch, - greyed, - // Für die Ecktests/Konsistenz beibehalten (bei stroke:"none" wirkungslos). - noStrokeEdges: bandJoinEdges, - wallId, - }); + // Durchgangswand-Aussparung: liegt der Quer-Offsetbereich dieses Bandes + // [off, off+thickness] im Nah-Putz-Bereich einer Aussparung, wird das Band + // entlang der Achse in Teil-Bänder VOR/NACH dem Intervall zerlegt (das + // Durchstoss-Stück entfällt). Ohne Überlappung bleibt genau ein Band + // [s,e] (unverändertes Verhalten). + const bandLo = off; + const bandHi = off + layer.thickness; + const bandHoles: Array<[number, number]> = []; + for (const c of segCutouts) { + const cLo = Math.min(c.offA, c.offB); + const cHi = Math.max(c.offA, c.offB); + if (bandLo < cHi - CUTOUT_EPS && bandHi > cLo + CUTOUT_EPS) + bandHoles.push([c.from, c.to]); + } + for (const [a, b] of subtractIntervals(s, e, bandHoles)) { + const sc = a <= s + CUTOUT_EPS ? layerStartCut : null; + const ec = b >= e - CUTOUT_EPS ? layerEndCut : null; + out.push({ + kind: "polygon", + pts: clippedBand(axisPt(a), axisPt(b), off, off + layer.thickness, sc, ec), + fill: layerSolid ? HATCH_INK : HATCH_PAPER, + // Band ohne Umriss: keine Schichtfuge über die Bandkante, keine 45°-Naht + // am Knoten. Die Fugen kommen als separate `line`-Primitive (siehe unten). + stroke: "none", + strokeWidthMm: 0, + hatch: layerHatch, + greyed, + // Für die Ecktests/Konsistenz beibehalten (bei stroke:"none" wirkungslos). + noStrokeEdges: bandJoinEdges, + wallId, + }); + } // L-Anschluss: eine getrimmte Schicht (z. B. Putz), die an eine // verschmelzende Nachbarschicht (durchlaufender Kern) grenzt, bekommt // zusätzlich eine seitliche Cut-Linie entlang der Durchgangsachse — sie @@ -1222,43 +1288,95 @@ function addWallPoche( // Pro-Schicht-Cuts): die Fuge markiert den Materialwechsel INNERHALB // dieser Wand und bleibt daher an der bisherigen Nahfläche. if (li < wt.layers.length - 1) { - // clippedBand(p1, p2, off, off, …) liefert [edgeStart, edgeEnd, edgeEnd, - // edgeStart] → die zwei distinkten Endpunkte sind Index 0 (Start) und 1 - // (Ende), beide am Cut abgeschnitten wie die Bandlängskante. - const jq = clippedBand(p1, p2, off, off, startCut, endCut); const jls = layer.jointLineStyleId ? project.lineStyles.find((l) => l.id === layer.jointLineStyleId) : undefined; - out.push({ - kind: "line", - a: jq[0], - b: jq[1], - cls: "layer-joint", - weightMm: jls ? jls.weight * LAYER_DETAIL_FACTOR[detail] : layerLineMm, - color: jls ? jls.color : stroke, - dash: jls ? jls.dash : null, - // Zickzack-Fuge (LineStyle kind==="zigzag") ebenfalls durchreichen. - zigzag: jls?.kind === "zigzag" ? jls.zigzag : undefined, - // Custom-Motiv-Fuge (LineStyle kind==="custom") ebenfalls durchreichen. - motif: jls?.kind === "custom" ? jls.motif : undefined, - greyed, - }); + // Durchgangswand-Aussparung: liegt die Fuge im (geschlossenen) Nah-Putz- + // Bereich einer Aussparung — inkl. der Rückgrat-Nahfläche (Merge-Kontakt) + // —, wird sie über das Durchstoss-Intervall unterbrochen. So bleibt an der + // Verschmelzungsfläche keine Trennlinie stehen. + const fugeHoles: Array<[number, number]> = []; + for (const c of segCutouts) { + const cLo = Math.min(c.offA, c.offB); + const cHi = Math.max(c.offA, c.offB); + if (off >= cLo - CUTOUT_EPS && off <= cHi + CUTOUT_EPS) + fugeHoles.push([c.from, c.to]); + } + for (const [a, b] of subtractIntervals(s, e, fugeHoles)) { + const sc = a <= s + CUTOUT_EPS ? startCut : null; + const ec = b >= e - CUTOUT_EPS ? endCut : null; + // clippedBand(pa, pb, off, off, …) liefert [edgeStart, edgeEnd, edgeEnd, + // edgeStart] → die zwei distinkten Endpunkte sind Index 0 (Start) und 1 + // (Ende), beide am Cut abgeschnitten wie die Bandlängskante. + const jq = clippedBand(axisPt(a), axisPt(b), off, off, sc, ec); + out.push({ + kind: "line", + a: jq[0], + b: jq[1], + cls: "layer-joint", + weightMm: jls ? jls.weight * LAYER_DETAIL_FACTOR[detail] : layerLineMm, + color: jls ? jls.color : stroke, + dash: jls ? jls.dash : null, + // Zickzack-Fuge (LineStyle kind==="zigzag") ebenfalls durchreichen. + zigzag: jls?.kind === "zigzag" ? jls.zigzag : undefined, + // Custom-Motiv-Fuge (LineStyle kind==="custom") ebenfalls durchreichen. + motif: jls?.kind === "custom" ? jls.motif : undefined, + greyed, + }); + } } } // Kräftige Wand-Umrisslinie über die volle Dicke (nur Kontur, keine Füllung), // damit die Wand sich klar von den dünnen Schichtfugen abhebt. Die Gehrungs- // Stirnkanten am Knoten entfallen → sauberer Eck-Apex ohne überschießende Barbe. + // Bei einer Durchgangswand-Aussparung wird zusätzlich die NAHFLÄCHEN-Kante + // (die Längskante auf der Durchstoss-Seite) über [from,to] strokelos gemacht + // und als separate Segmente vor/nach dem Durchstoss gezeichnet — sonst liefe + // die Umrisslinie über die Durchstoss-Breite. + const offLo = refOff - total / 2; + const offHi = refOff + total / 2; + const holesHi: Array<[number, number]> = []; + const holesLo: Array<[number, number]> = []; + for (const c of segCutouts) { + // `offA` ist stets die Nahfläche (Wandoberfläche); ordne die Aussparung der + // passenden Längskante (offHi bzw. offLo) zu. + if (Math.abs(c.offA - offHi) < 1e-6) holesHi.push([c.from, c.to]); + else if (Math.abs(c.offA - offLo) < 1e-6) holesLo.push([c.from, c.to]); + } + const outlineNoStroke = [...bandJoinEdges]; + // clippedBand-Kantenreihenfolge [A.start(offLo), A.end(offLo), B.end(offHi), + // B.start(offHi)]: Kante 0 ist die offLo-Längskante, Kante 2 die offHi-Längskante. + if (holesHi.length) outlineNoStroke.push(2); + if (holesLo.length) outlineNoStroke.push(0); out.push({ kind: "polygon", - pts: clippedBand(p1, p2, refOff - total / 2, refOff + total / 2, startCut, endCut), + pts: clippedBand(p1, p2, offLo, offHi, startCut, endCut), fill: "none", stroke, strokeWidthMm: outlineMm, hatch: NO_HATCH, greyed, - noStrokeEdges: bandJoinEdges, + noStrokeEdges: outlineNoStroke, wallId, }); + const drawNearEdge = (edgeOff: number, holes: Array<[number, number]>): void => { + for (const [a, b] of subtractIntervals(s, e, holes)) { + const sc = a <= s + CUTOUT_EPS ? startCut : null; + const ec = b >= e - CUTOUT_EPS ? endCut : null; + const jq = clippedBand(axisPt(a), axisPt(b), edgeOff, edgeOff, sc, ec); + out.push({ + kind: "line", + a: jq[0], + b: jq[1], + cls: "wall-outline", + weightMm: outlineMm, + color: stroke, + greyed, + }); + } + }; + if (holesHi.length) drawNearEdge(offHi, holesHi); + if (holesLo.length) drawNearEdge(offLo, holesLo); } }