From fc0a76feea97096d63cdf19a11db936200fefe77 Mon Sep 17 00:00:00 2001 From: Karim Date: Sat, 4 Jul 2026 14:13:55 +0200 Subject: [PATCH] Joins Phase 1: materialbewusster T-Stoss (Backstein verschmilzt, Putz-L) Am T-Stoss wird der Abzweig nicht mehr naiv ueber die volle Dicke an der Durchgangswand-Flaeche gekappt. Neu (joinPriority-basiert): - resolveJoinPriority(a,b): merge/coexist/trim je nach Prioritaet+Komponente. - WallCuts.layerCuts (additiv): pro Schicht eine Cut-Linie + optionale L-Seiten- linie. Gleiche/hoehere Prioritaet wie der Durchgangs-Backbone -> kein Cut (Schicht laeuft durch = Merge); schwaechere Schicht -> Nahflaechen-Cut + L. - addWallPoche nutzt layerCuts pro Schicht (Fallback: alte Aggregat-Cuts); neue 'layer-joint-l'-Linie als L-Rueckschnitt. - Rust-Paritaet in geometry/lib.rs additiv gespiegelt (LayerInput serde default, Aggregat-Cuts bit-identisch -> parity.test gruen). Einschichtige/nicht-passende T-Stoesse pixel-identisch. +8 Tests (216 gesamt), cargo 7/7. --- src-tauri/geometry/src/lib.rs | 236 +++++++++++++++++++++++- src/model/joins.test.ts | 148 ++++++++++++++- src/model/joins.ts | 142 +++++++++++++- src/plan/generatePlan.tjunction.test.ts | 128 +++++++++++++ src/plan/generatePlan.ts | 50 ++++- 5 files changed, 689 insertions(+), 15 deletions(-) create mode 100644 src/plan/generatePlan.tjunction.test.ts diff --git a/src-tauri/geometry/src/lib.rs b/src-tauri/geometry/src/lib.rs index fee2cc2..063a414 100644 --- a/src-tauri/geometry/src/lib.rs +++ b/src-tauri/geometry/src/lib.rs @@ -22,6 +22,21 @@ pub struct Line { pub dir: Vec2, } +/// Eine Schicht einer Wand, bereits aus dem WallType aufgeloest (Komponenten-Id +/// + Prioritaet + Dicke) -- fuer die materialbewusste T-Stoss-Erweiterung +/// (siehe `resolve_join_priority`/`apply_layer_cuts`). ADDITIV: fehlt der Key +/// `layers` in der JSON-Eingabe, liefert `#[serde(default)]` eine leere Liste, +/// sodass die bestehende Paritaets-Eingabe (ohne Schichten) unveraendert +/// funktioniert. +#[derive(Serialize, Deserialize, Clone)] +pub struct LayerInput { + #[serde(rename = "componentId")] + pub component_id: String, + #[serde(rename = "joinPriority")] + pub join_priority: f64, + pub thickness: f64, +} + #[derive(Serialize, Deserialize)] pub struct WallInput { pub id: String, @@ -30,6 +45,8 @@ pub struct WallInput { pub thickness: f64, #[serde(rename = "referenceOffset")] pub reference_offset: f64, + #[serde(default)] + pub layers: Vec, } #[derive(Serialize, Deserialize)] @@ -37,6 +54,19 @@ pub struct JoinInput { pub walls: Vec, } +/// Pro-Schicht-Override der Nahflaechen-Cuts (materialbewusster T-Stoss). +/// Index = Schicht-Index in `WallInput.layers` der Abzweig-Wand. Spiegelt +/// `LayerCuts` aus `model/joins.ts` 1:1. +#[derive(Serialize, Deserialize, Clone)] +pub struct LayerCuts { + pub start: Vec>, + pub end: Vec>, + #[serde(rename = "startSide")] + pub start_side: Vec>, + #[serde(rename = "endSide")] + pub end_side: Vec>, +} + /// Schnittlinien einer Wand an ihren beiden Achsenden. #[derive(Serialize, Deserialize)] pub struct WallCuts { @@ -46,6 +76,112 @@ pub struct WallCuts { pub start_cut: Option, #[serde(rename = "endCut")] pub end_cut: Option, + /// ADDITIV: nur gesetzt, wenn an einem T-Stoss mindestens eine Schicht + /// materialbewusst verschmilzt (siehe `apply_layer_cuts`). Fehlt sie in der + /// Ausgabe (`None` -> von serde bei Serialisierung weggelassen), gilt + /// weiterhin `start_cut`/`end_cut` fuer alle Schichten. + #[serde(rename = "layerCuts", skip_serializing_if = "Option::is_none")] + pub layer_cuts: Option, +} + +/// Verschneidungs-Entscheidung zweier Bauteile am Stoss -- Rust-Spiegel von +/// `JoinPriorityResolution`/`resolveJoinPriority` aus `model/joins.ts`. +#[derive(PartialEq)] +enum JoinPriorityResolution { + Merge, + TrimA, + TrimB, + Coexist, +} + +fn resolve_join_priority( + a_id: &str, + a_prio: f64, + b_id: &str, + b_prio: f64, +) -> JoinPriorityResolution { + if a_prio == b_prio { + if a_id == b_id { + JoinPriorityResolution::Merge + } else { + JoinPriorityResolution::Coexist + } + } else if a_prio > b_prio { + JoinPriorityResolution::TrimB + } else { + JoinPriorityResolution::TrimA + } +} + +/// Materialbewusste Pro-Schicht-Erweiterung eines T-Stoss-Cuts -- Rust-Spiegel +/// von `applyLayerCuts` aus `model/joins.ts`. Vergleicht jede Schicht der +/// Abzweig-Wand mit dem Rueckgrat (hoechste `joinPriority`) der Durchgangswand; +/// verschmilzt sie (gleiches Rueckgrat-Material, oder die Abzweig-Schicht ist +/// sogar prioritaerer), bekommt sie keinen Cut. Getrimmte Schichten neben einer +/// verschmelzenden Nachbarschicht bekommen zusaetzlich die seitliche L-Linie. +#[allow(clippy::too_many_arguments)] +fn apply_layer_cuts( + result: &mut [WallCuts], + branch_wall_idx: usize, + branch_end: WallEndKind, + branch_layers: &[LayerInput], + through_layers: &[LayerInput], + face_cut: Line, + axis_point: Vec2, + through_dir: Vec2, +) { + if branch_layers.is_empty() || through_layers.is_empty() { + return; + } + + // Rueckgrat der Durchgangswand: die Schicht mit der hoechsten joinPriority. + let mut backbone = &through_layers[0]; + for l in through_layers { + if l.join_priority > backbone.join_priority { + backbone = l; + } + } + + let n = branch_layers.len(); + let mut merged = vec![false; n]; + for (i, layer) in branch_layers.iter().enumerate() { + let res = resolve_join_priority( + &layer.component_id, + layer.join_priority, + &backbone.component_id, + backbone.join_priority, + ); + merged[i] = matches!(res, JoinPriorityResolution::Merge | JoinPriorityResolution::TrimB); + } + if !merged.iter().any(|&m| m) { + return; // keine Materialuebereinstimmung -> Default (kein layer_cuts) + } + + let cuts = &mut result[branch_wall_idx]; + if cuts.layer_cuts.is_none() { + cuts.layer_cuts = Some(LayerCuts { + start: vec![None; n], + end: vec![None; n], + start_side: vec![None; n], + end_side: vec![None; n], + }); + } + let lc = cuts.layer_cuts.as_mut().unwrap(); + if lc.start.len() != n { + return; // Schicht-Anzahl passt nicht zusammen (sollte nicht vorkommen) + } + + let side_line = Line { point: axis_point, dir: through_dir }; + 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] { None } 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 }; + } } // --- Vektor-Helfer ----------------------------------------------------------- @@ -139,6 +275,7 @@ pub fn compute_joins(input: JoinInput) -> Vec { wall_id: w.id.clone(), start_cut: None, end_cut: None, + layer_cuts: None, }) .collect(); @@ -305,11 +442,18 @@ fn apply_t_junction( // 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))); + let face_cut = Line { point: face_point, dir: u_t }; - set_cut( - &mut result[branch_wall_idx], + set_cut(&mut result[branch_wall_idx], branch_end.end, face_cut); + apply_layer_cuts( + result, + branch_wall_idx, branch_end.end, - Line { point: face_point, dir: u_t }, + &branch_wall.layers, + &through_wall.layers, + face_cut, + j, + u_t, ); } @@ -381,11 +525,23 @@ fn apply_mid_span_tee( 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))); + let face_cut = Line { point: face_point, dir: u_w }; - set_cut( - &mut result[free_wall_idx], + set_cut(&mut result[free_wall_idx], free_end.end, face_cut); + // Achsenpunkt auf der getroffenen Wand, auf Hoehe der Projektion des + // freien Endes (nicht zwingend w.start) -- Anker fuer die + // materialbewusste Pro-Schicht-Erweiterung (analog zum T-Knoten-Fall). + let dist_along = dot(sub(p, w.start), u_w); + let axis_point = add(w.start, scale(u_w, dist_along)); + apply_layer_cuts( + result, + free_wall_idx, free_end.end, - Line { point: face_point, dir: u_w }, + &free_wall.layers, + &w.layers, + face_cut, + axis_point, + u_w, ); } @@ -476,6 +632,7 @@ mod tests { end: Vec2 { x: ex, y: ey }, thickness, reference_offset: 0.0, + layers: Vec::new(), } } @@ -646,4 +803,71 @@ mod tests { 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"); } + + /// W9-artiger Wandtyp: Innenputz (0.015) / Backstein-Kern (0.12) / Innenputz + /// (0.015), joinPriority 10 bzw. 50 -- Spiegel von `layeredWallProject` aus + /// `model/joins.test.ts`. + fn w_iw(id: &str, sx: f64, sy: f64, ex: f64, ey: f64) -> WallInput { + WallInput { + id: id.to_string(), + start: Vec2 { x: sx, y: sy }, + end: Vec2 { x: ex, y: ey }, + thickness: 0.15, + reference_offset: 0.0, + layers: vec![ + LayerInput { component_id: "render-int".into(), join_priority: 10.0, thickness: 0.015 }, + LayerInput { component_id: "brick".into(), join_priority: 50.0, thickness: 0.12 }, + LayerInput { component_id: "render-int".into(), join_priority: 10.0, thickness: 0.015 }, + ], + } + } + + #[test] + fn t_junction_layer_cuts_merge_and_trim() { + // Wie t_junction_branch_gets_face_cut, aber alle drei Waende vom + // W9-artigen Wandtyp: der Backstein-Kern (Index 1) verschmilzt (kein + // Cut), die beiden Innenputz-Schichten (Index 0/2) werden getrimmt und + // bekommen zusaetzlich die seitliche L-Linie. + let input = JoinInput { + walls: vec![ + w_iw("A", 0.0, 0.0, 5.0, 0.0), + w_iw("B", 5.0, 0.0, 5.0, 3.0), + w_iw("C", 5.0, 0.0, 10.0, 0.0), + ], + }; + let out = compute_joins(input); + let cb = find(&out, "B"); + + // Aggregat-Cut bleibt unveraendert (bit-identisch zum bisherigen Verhalten). + let agg = cb.start_cut.expect("B startCut gesetzt"); + assert!((agg.point.x - 5.0).abs() < 1e-9); + assert!((agg.point.y - 0.075).abs() < 1e-9); + + let lc = cb.layer_cuts.as_ref().expect("layerCuts gesetzt (Backstein verschmilzt)"); + assert_eq!(lc.start.len(), 3); + + // Schicht 1 (Backstein-Kern): kein Cut, keine Seitenlinie. + assert!(lc.start[1].is_none()); + assert!(lc.start_side[1].is_none()); + + // Schichten 0/2 (Innenputz): derselbe Nahflaechen-Cut wie das Aggregat... + let l0 = lc.start[0].expect("Schicht 0 getrimmt"); + let l2 = lc.start[2].expect("Schicht 2 getrimmt"); + assert!((l0.point.y - 0.075).abs() < 1e-9); + assert!((l2.point.y - 0.075).abs() < 1e-9); + // ... UND die seitliche L-Linie entlang der Durchgangsachse (durch den + // Knoten (5,0), parallel zur x-Achse). + let s0 = lc.start_side[0].expect("Schicht 0 hat L-Seitenlinie"); + let s2 = lc.start_side[2].expect("Schicht 2 hat L-Seitenlinie"); + let dist = |cut: Line, q: Vec2| cross(cut.dir, sub(q, cut.point)).abs() / len(cut.dir); + assert!(dist(s0, Vec2 { x: 5.0, y: 0.0 }) < 1e-9); + assert!(dist(s0, Vec2 { x: 0.0, y: 0.0 }) < 1e-9); + assert!(dist(s2, Vec2 { x: 5.0, y: 0.0 }) < 1e-9); + + // Die kollineare Durchgangswand bleibt unangetastet (kein layerCuts). + let ca = find(&out, "A"); + let cc = find(&out, "C"); + assert!(ca.layer_cuts.is_none()); + assert!(cc.layer_cuts.is_none()); + } } diff --git a/src/model/joins.test.ts b/src/model/joins.test.ts index f6a6d21..90242a4 100644 --- a/src/model/joins.test.ts +++ b/src/model/joins.test.ts @@ -15,10 +15,10 @@ */ import { describe, it, expect } from "vitest"; -import { computeJoins } from "./joins"; +import { computeJoins, resolveJoinPriority } from "./joins"; import { cross, len, sub } from "./geometry"; import type { Line } from "./geometry"; -import type { Project, Vec2, Wall } from "./types"; +import type { Component, Project, Vec2, Wall } from "./types"; /** Abstand eines Punktes P von der unendlichen Geraden `line`. */ function distToLine(line: Line, p: Vec2): number { @@ -185,3 +185,147 @@ describe("computeJoins — Mittelspannen-T-Stoß (Ende trifft Wandseite)", () => expect(joins.get("WB3")!.endCut).toBeNull(); }); }); + +describe("resolveJoinPriority — Ordnungslogik zweier Bauteile am Stoss", () => { + const brick: Component = { id: "brick", name: "Backstein", color: "#8c5544", hatchId: "none", joinPriority: 50 }; + const brick2: Component = { id: "brick", name: "Backstein (Kopie)", color: "#000", hatchId: "none", joinPriority: 50 }; + const render: Component = { id: "render", name: "Putz", color: "#efece6", hatchId: "none", joinPriority: 10 }; + const insulation: Component = { id: "insulation", name: "Dämmung", color: "#fff", hatchId: "none", joinPriority: 20 }; + const concrete: Component = { id: "concrete", name: "Beton", color: "#9aa0a6", hatchId: "none", joinPriority: 100 }; + + it("verschmilzt bei gleicher Priorität UND gleicher Komponente", () => { + expect(resolveJoinPriority(brick, brick2)).toBe("merge"); + }); + + it("koexistiert bei gleicher Priorität, aber verschiedener Komponente", () => { + // Zwei fiktive Komponenten mit zufällig gleicher Priorität, aber + // unterschiedlicher Identität — keine eindeutige Rangfolge. + const other: Component = { id: "other-30", name: "X", color: "#123", hatchId: "none", joinPriority: 20 }; + expect(resolveJoinPriority(insulation, other)).toBe("coexist"); + }); + + it("lässt die höhere Priorität gewinnen (die niedrigere wird getrimmt)", () => { + expect(resolveJoinPriority(render, brick)).toBe("trim-a"); // a=Putz verliert + expect(resolveJoinPriority(brick, render)).toBe("trim-b"); // b=Putz verliert + expect(resolveJoinPriority(concrete, brick)).toBe("trim-b"); + expect(resolveJoinPriority(brick, concrete)).toBe("trim-a"); + }); +}); + +/** + * Minimalprojekt mit einem mehrschichtigen, W9-artigen Wandtyp + * (Innenputz/Backstein-Kern/Innenputz, wie `sampleProject`s "iw") — für die + * materialbewusste T-Stoss-Erweiterung (layerCuts). + */ +function layeredWallProject(w1: Wall, w2: Wall, w3: Wall): Project { + return { + id: "t", + name: "T", + lineStyles: [], + hatches: [], + components: [ + { id: "render-int", name: "Innenputz", color: "#efece6", hatchId: "none", joinPriority: 10 }, + { id: "brick", name: "Backstein", color: "#8c5544", hatchId: "none", joinPriority: 50 }, + ], + wallTypes: [ + { + id: "iw", + name: "Innenwand", + layers: [ + { componentId: "render-int", thickness: 0.015 }, + { componentId: "brick", thickness: 0.12 }, + { componentId: "render-int", thickness: 0.015 }, + ], + }, + ], + drawingLevels: [ + { id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 }, + ], + layers: [{ code: "20", name: "Wände", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }], + walls: [w1, w2, w3], + doors: [], + openings: [], + ceilings: [], + stairs: [], + rooms: [], + drawings2d: [], + context: [], + }; +} + +function iwWall(id: string, start: Vec2, end: Vec2): Wall { + return { + id, + type: "wall", + floorId: "eg", + categoryCode: "20", + start, + end, + wallTypeId: "iw", + height: 2.6, + }; +} + +describe("computeJoins — materialbewusster T-Stoss (layerCuts)", () => { + it("W9-artiger T-Knoten: Backstein-Kern des Abzweigs verschmilzt (kein Cut), Putz wird getrimmt + L-Seitenlinie", () => { + // Durchgangswand WA1 (0,0)→(5,0) / WA2 (5,0)→(10,0), Abzweig WB (5,0)→(5,3), + // alle vom selben Wandtyp "iw" (Innenputz/Backstein/Innenputz, T=0.15). + const proj = layeredWallProject( + iwWall("WA1", { x: 0, y: 0 }, { x: 5, y: 0 }), + iwWall("WA2", { x: 5, y: 0 }, { x: 10, y: 0 }), + iwWall("WB", { x: 5, y: 0 }, { x: 5, y: 3 }), + ); + const joins = computeJoins(proj, proj.walls); + const cuts = joins.get("WB")!; + + // Aggregat-Cut bleibt unverändert (bit-identisch zum bisherigen Verhalten). + expect(cuts.startCut).not.toBeNull(); + expect(distToLine(cuts.startCut!, { x: 5, y: 0.075 })).toBeCloseTo(0, 6); + + // layerCuts ist gesetzt (mindestens eine Schicht verschmilzt). + expect(cuts.layerCuts).toBeDefined(); + const lc = cuts.layerCuts!; + expect(lc.start.length).toBe(3); + + // Schicht 1 (Backstein-Kern, Index 1) verschmilzt: KEIN Cut. + expect(lc.start[1]).toBeNull(); + expect(lc.startSide[1]).toBeNull(); + + // Schichten 0 und 2 (Innenputz) werden getrimmt: derselbe Nahflächen-Cut + // wie das Aggregat... + expect(lc.start[0]).not.toBeNull(); + expect(lc.start[2]).not.toBeNull(); + expect(distToLine(lc.start[0]!, { x: 5, y: 0.075 })).toBeCloseTo(0, 6); + expect(distToLine(lc.start[2]!, { x: 5, y: 0.075 })).toBeCloseTo(0, 6); + // ... UND bekommen die zusätzliche L-Seitenlinie entlang der + // Durchgangsachse (durch den Knoten (5,0), Richtung parallel zur x-Achse). + expect(lc.startSide[0]).not.toBeNull(); + expect(lc.startSide[2]).not.toBeNull(); + expect(distToLine(lc.startSide[0]!, { x: 5, y: 0 })).toBeCloseTo(0, 6); + expect(distToLine(lc.startSide[0]!, { x: 0, y: 0 })).toBeCloseTo(0, 6); + + // Die kollinearen Hälften der Durchgangswand bleiben unangetastet + // (unverändertes Verhalten, keine layerCuts für sie). + expect(joins.get("WA1")!.layerCuts).toBeUndefined(); + expect(joins.get("WA2")!.layerCuts).toBeUndefined(); + }); + + it("setzt layerCuts NICHT, wenn kein Bauteil des Abzweigs mit dem Rückgrat der Durchgangswand übereinstimmt", () => { + // Gleiche Anordnung, aber der Abzweig ist eine reine Einschicht-Wand ohne + // Materialübereinstimmung zum Backstein-Rückgrat → Default-Verhalten. + const proj = layeredWallProject( + iwWall("WA1", { x: 0, y: 0 }, { x: 5, y: 0 }), + iwWall("WA2", { x: 5, y: 0 }, { x: 10, y: 0 }), + iwWall("WB", { x: 5, y: 0 }, { x: 5, y: 3 }), + ); + proj.wallTypes.push({ + id: "solo", + name: "Solo", + layers: [{ componentId: "render-int", thickness: 0.1 }], + }); + proj.walls[2] = { ...proj.walls[2], wallTypeId: "solo" }; + const joins = computeJoins(proj, proj.walls); + expect(joins.get("WB")!.layerCuts).toBeUndefined(); + expect(joins.get("WB")!.startCut).not.toBeNull(); // Aggregat-Cut bleibt normal + }); +}); diff --git a/src/model/joins.ts b/src/model/joins.ts index 3b74ed1..db39551 100644 --- a/src/model/joins.ts +++ b/src/model/joins.ts @@ -3,8 +3,8 @@ // gemeinsamen Gehrungslinie sauber stoßen. Dieses Modul berechnet pro Wand // die optionalen Schnittlinien an Start- und Endpunkt. -import type { Project, Vec2, Wall } from "./types"; -import { getWallType, wallTypeThickness } from "./types"; +import type { Component, Project, Vec2, Wall } from "./types"; +import { getComponent, getWallType, wallTypeThickness } from "./types"; import { wallReferenceOffset } from "./wall"; import { add, @@ -18,10 +18,55 @@ import { } from "./geometry"; import type { Line } from "./geometry"; +/** + * Pro-Schicht-Override der Nahflächen-Cuts (materialbewusster T-Stoss, siehe + * {@link resolveJoinPriority}). Index = Schicht-Index im Wandtyp DIESER Wand + * (der Abzweig-Wand). `start`/`end` ersetzen — wo gesetzt — den einheitlichen + * `startCut`/`endCut` NUR für die jeweilige Schicht; `null` an einem Index + * heisst: diese Schicht bekommt KEINEN Cut (verschmilzt, läuft durch). + * `startSide`/`endSide` tragen die zusätzliche seitliche Cut-Linie einer + * getrimmten Schicht, die an eine verschmelzende Nachbarschicht angrenzt + * (L-Anschluss, z. B. Putz neben durchlaufendem Backstein-Kern). + */ +export interface LayerCuts { + start: (Line | null)[]; + end: (Line | null)[]; + startSide: (Line | null)[]; + endSide: (Line | null)[]; +} + /** Schnittlinien einer Wand an ihren beiden Achsenden. */ export interface WallCuts { startCut: Line | null; endCut: Line | null; + /** + * Additive Erweiterung: fehlt dieses Feld, gilt weiterhin `startCut`/ + * `endCut` für ALLE Schichten (bisheriges Verhalten, bit-identisch). Wird + * nur gesetzt, wenn an einem T-Stoss mindestens eine Schicht materialbewusst + * verschmilzt (siehe {@link resolveJoinPriority}). + */ + layerCuts?: LayerCuts; +} + +/** + * Verschneidungs-Entscheidung zweier Bauteile (Komponenten) am Stoss: + * • "merge" — gleiche Priorität UND gleiche Komponente (z. B. Backstein- + * Kern trifft Backstein-Kern): die Schichten verschmelzen, kein Cut. + * • "coexist" — gleiche Priorität, aber VERSCHIEDENE Komponente: keine + * eindeutige Rangfolge, bleibt beim heutigen (getrimmten) Verhalten. + * • "trim-a" — `b` hat die höhere Priorität und gewinnt; `a` wird getrimmt. + * • "trim-b" — `a` hat die höhere Priorität und gewinnt; `b` wird getrimmt. + */ +export type JoinPriorityResolution = "merge" | "trim-a" | "trim-b" | "coexist"; + +export function resolveJoinPriority( + a: Component, + b: Component, +): JoinPriorityResolution { + if (a.joinPriority === b.joinPriority) { + return a.id === b.id ? "merge" : "coexist"; + } + return a.joinPriority > b.joinPriority ? "trim-b" : "trim-a"; } /** Rundet eine Koordinate auf ein Gitter, um Endpunkte robust zu gruppieren. */ @@ -237,8 +282,87 @@ function applyTJunction( // zeigt (positive nT-Seite, wenn der Abzweig dorthin ausläuft). const sign = dot(nT, dBranch) >= 0 ? 1 : -1; const facePoint = add(j, scale(nT, offT + sign * (tT / 2))); + const faceCut: Line = { point: facePoint, dir: uT }; - setCut(result, branchEnd, { point: facePoint, dir: uT }); + setCut(result, branchEnd, faceCut); + applyLayerCuts(project, result, branchEnd, branchWall, throughWall, faceCut, j, uT); +} + +/** + * Materialbewusste Pro-Schicht-Erweiterung eines T-Stoss-Cuts (siehe + * {@link resolveJoinPriority}): vergleicht jede Schicht der ABZWEIG-Wand mit + * dem Rückgrat (höchste `joinPriority`) der DURCHGANGSWAND. Verschmilzt eine + * Schicht (gleiche Komponente + Priorität wie das Rückgrat, oder die + * Abzweig-Schicht ist sogar prioritärer), bekommt sie KEINEN Cut — das Band + * läuft bis zum Wandachsenpunkt durch (die Durchgangswand selbst ist an einem + * T-Stoss unzerschnitten und deckt den Rest ab, s. `applyTJunction`-Doc). + * Getrimmte Schichten, die direkt an eine verschmelzende Nachbarschicht + * grenzen, bekommen zusätzlich eine seitliche Cut-Linie entlang der + * Durchgangsachse (L-Anschluss, z. B. Putz neben durchlaufendem Kern). + * + * Setzt `WallCuts.layerCuts` NUR, wenn mindestens eine Schicht verschmilzt — + * sonst bleibt das Feld `undefined` (additiv, bit-identisches Default- + * Verhalten für den ganz überwiegenden Fall ohne Materialübereinstimmung). + */ +function applyLayerCuts( + project: Project, + result: Map, + branchEnd: WallEnd, + branchWall: Wall, + throughWall: Wall, + faceCut: Line, + axisPoint: Vec2, + throughDir: Vec2, +): void { + const branchWt = getWallType(project, branchWall); + const throughWt = getWallType(project, throughWall); + if (branchWt.layers.length === 0 || throughWt.layers.length === 0) return; + + // Rückgrat der Durchgangswand: die Schicht mit der höchsten joinPriority. + let backbone = getComponent(project, throughWt.layers[0].componentId); + for (const l of throughWt.layers) { + const c = getComponent(project, l.componentId); + if (c.joinPriority > backbone.joinPriority) backbone = c; + } + + const n = branchWt.layers.length; + const merged: boolean[] = new Array(n); + for (let i = 0; i < n; i++) { + const comp = getComponent(project, branchWt.layers[i].componentId); + const res = resolveJoinPriority(comp, backbone); + // "merge": identisches Rückgrat-Material -> durchgehender Kern. + // "trim-b": die Abzweig-Schicht ist SELBST prioritärer als das Rückgrat + // der Durchgangswand -> sie gewinnt ebenfalls und läuft durch (die + // Durchgangswand könnten wir hier nicht schneiden, ausserhalb des Scopes). + merged[i] = res === "merge" || res === "trim-b"; + } + if (!merged.some(Boolean)) return; // keine Materialübereinstimmung -> Default + + const cuts = result.get(branchWall.id)!; + if (!cuts.layerCuts) { + cuts.layerCuts = { + start: new Array(n).fill(null), + end: new Array(n).fill(null), + startSide: new Array(n).fill(null), + endSide: new Array(n).fill(null), + }; + } + const lc = cuts.layerCuts; + if (lc.start.length !== n) return; // Schicht-Anzahl passt nicht zusammen (sollte nicht vorkommen) + + const faceArr = branchEnd.end === "start" ? lc.start : lc.end; + const sideArr = branchEnd.end === "start" ? lc.startSide : lc.endSide; + // Seitliche Cut-Linie entlang der Durchgangsachse, auf Höhe des Wand- + // Achsenpunkts (= die Tiefe, bis zu der eine verschmelzende Nachbarschicht + // ungekappt durchläuft). + const sideLine: Line = { point: axisPoint, dir: throughDir }; + + for (let i = 0; i < n; i++) { + faceArr[i] = merged[i] ? null : faceCut; + const adjMerged = + !merged[i] && ((i > 0 && merged[i - 1]) || (i < n - 1 && merged[i + 1])); + sideArr[i] = adjMerged ? sideLine : null; + } } /** @@ -262,7 +386,7 @@ function applyMidSpanTee( const endGap = 1e-4; // Mindestabstand zu den Wandenden für „innere Spanne" const tol = 1e-3; // Toleranz für den senkrechten Abstand zur Wandfläche - let best: { wall: Wall; perp: number; dist: number } | null = null; + let best: { wall: Wall; perp: number; dist: number; distAlong: number } | null = null; for (const w of walls) { if (w.id === freeWall.id) continue; const u = dirOf(w); @@ -278,7 +402,7 @@ function applyMidSpanTee( const dist = Math.abs(perp); if (dist > tw / 2 + tol) continue; - if (!best || dist < best.dist) best = { wall: w, perp, dist }; + if (!best || dist < best.dist) best = { wall: w, perp, dist, distAlong }; } if (!best) return; // kein Treffer → rechtwinklig @@ -289,6 +413,12 @@ function applyMidSpanTee( const offW = wallReferenceOffset(w, tw); const sign = best.perp >= 0 ? 1 : -1; const facePoint = add(w.start, scale(nW, offW + sign * (tw / 2))); + const faceCut: Line = { point: facePoint, dir: uW }; - setCut(result, freeEnd, { point: facePoint, dir: uW }); + setCut(result, freeEnd, faceCut); + // Achsenpunkt auf der getroffenen Wand, auf Höhe der Projektion des freien + // Endes (nicht zwingend w.start) — Anker für die materialbewusste + // Pro-Schicht-Erweiterung (analog zum T-Knoten-Fall). + const axisPoint = add(w.start, scale(uW, best.distAlong)); + applyLayerCuts(project, result, freeEnd, freeWall, w, faceCut, axisPoint, uW); } diff --git a/src/plan/generatePlan.tjunction.test.ts b/src/plan/generatePlan.tjunction.test.ts new file mode 100644 index 0000000..5f2a13b --- /dev/null +++ b/src/plan/generatePlan.tjunction.test.ts @@ -0,0 +1,128 @@ +/** + * Unit-Tests für den materialbewussten T-Stoss im 2D-Plan (generatePlan): + * an einem T-Stoss zweier verputzter Mauerwerkswände (W9-artig: Innenputz/ + * Backstein-Kern/Innenputz) darf der Abzweig nicht mehr naiv über die volle + * Dicke an der Durchgangswand-Fläche gekappt werden. + * • Der Backstein-Kern des Abzweigs läuft UNGEKAPPT bis zur Durchgangsachse + * durch (kein Cut an der Nahfläche) — "durchgehender Kern". + * • Die Innenputz-Schichten des Abzweigs enden weiterhin an der Nahfläche + * UND bekommen eine zusätzliche seitliche L-Linie (cls "layer-joint-l"). + */ + +import { describe, it, expect } from "vitest"; +import { generatePlan } from "./generatePlan"; +import type { Project, Wall } from "../model/types"; + +/** W9-artiger Wandtyp: Innenputz (0.015) / Backstein-Kern (0.12) / Innenputz (0.015). */ +function tJunctionProject(): Project { + 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, + }); + return { + id: "t", + name: "T", + lineStyles: [], + hatches: [{ id: "none", name: "Ohne", pattern: "none", scale: 1, angle: 0, color: "#111" }], + components: [ + { id: "render-int", name: "Innenputz", color: "#efece6", hatchId: "none", joinPriority: 10 }, + { id: "brick", name: "Backstein", color: "#8c5544", hatchId: "none", joinPriority: 50 }, + ], + wallTypes: [ + { + id: "iw", + name: "Innenwand", + layers: [ + { componentId: "render-int", thickness: 0.015 }, + { componentId: "brick", thickness: 0.12 }, + { componentId: "render-int", thickness: 0.015 }, + ], + }, + ], + drawingLevels: [ + { id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 }, + ], + layers: [{ code: "20", name: "Wände", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }], + walls: [ + // Durchgangswand WA1/WA2 in zwei Hälften, Abzweig WB nach oben (5,0)→(5,3). + wall("WA1", { x: 0, y: 0 }, { x: 5, y: 0 }), + wall("WA2", { x: 5, y: 0 }, { x: 10, y: 0 }), + wall("WB", { x: 5, y: 0 }, { x: 5, y: 3 }), + ], + doors: [], + openings: [], + ceilings: [], + stairs: [], + rooms: [], + drawings2d: [], + context: [], + }; +} + +const visible = new Set(["20"]); + +describe("generatePlan — materialbewusster T-Stoss (Poché-Bänder)", () => { + it("lässt den Backstein-Kern des Abzweigs ungekappt durchlaufen, während der Innenputz an der Nahfläche endet", () => { + const plan = generatePlan(tJunctionProject(), "eg", visible, undefined, "mittel"); + // Die Schicht-Bänder der Wand WB: gefüllte Polygone ohne eigenen Umriss + // (stroke:"none"), in Schicht-Reihenfolge gepusht (0=Putz,1=Backstein,2=Putz). + const bands = plan.primitives.filter( + (p): p is Extract => + p.kind === "polygon" && p.wallId === "WB" && p.stroke === "none", + ); + expect(bands.length).toBe(3); + const [render0, brick, render2] = bands; + + // clippedBand liefert [edgeStart(offA), edgeEnd(offA), edgeEnd(offB), edgeStart(offB)]; + // edgeStart liegt am Wandanfang (5,0) = der T-Stoss-Seite. Für die beiden + // Innenputz-Bänder MUSS die Y-Koordinate dort exakt auf der Nahfläche + // (0.075, halbe Wanddicke 0.15/2) liegen — unverändertes Trim-Verhalten. + expect(render0.pts[0].y).toBeCloseTo(0.075, 6); + expect(render0.pts[3].y).toBeCloseTo(0.075, 6); + expect(render2.pts[0].y).toBeCloseTo(0.075, 6); + expect(render2.pts[3].y).toBeCloseTo(0.075, 6); + + // Der Backstein-Kern verschmilzt: KEIN Cut an der Nahfläche → die + // Randpunkte liegen auf der Durchgangsachse (y=0), nicht auf y=0.075. + expect(brick.pts[0].y).toBeCloseTo(0, 6); + expect(brick.pts[3].y).toBeCloseTo(0, 6); + }); + + it("zeichnet die L-Seitenlinie nur für die getrimmten Putzschichten, nicht für den durchlaufenden Kern", () => { + const plan = generatePlan(tJunctionProject(), "eg", visible, undefined, "mittel"); + const lLines = plan.primitives.filter( + (p): p is Extract => p.kind === "line" && p.cls === "layer-joint-l", + ); + // Genau zwei L-Linien (eine je Innenputz-Schicht); der Backstein-Kern + // bekommt keine (er verschmilzt, kein Sprung im Material). + expect(lLines.length).toBe(2); + + // Jede L-Linie verläuft entlang der Durchgangsachse (y=0, parallel zur + // x-Achse) und spannt genau die Breite EINER Putzschicht (0.015 m) auf. + for (const l of lLines) { + expect(l.a.y).toBeCloseTo(0, 6); + expect(l.b.y).toBeCloseTo(0, 6); + expect(Math.abs(l.a.x - l.b.x)).toBeCloseTo(0.015, 6); + } + }); + + it("lässt die kollineare Durchgangswand unverändert (keine L-Linien, normale Bänder)", () => { + 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", + ); + 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); + } + }); +}); diff --git a/src/plan/generatePlan.ts b/src/plan/generatePlan.ts index b95fa2d..0a02b87 100644 --- a/src/plan/generatePlan.ts +++ b/src/plan/generatePlan.ts @@ -45,6 +45,7 @@ import { along, clippedBand, leftNormal, + lineIntersect, normalize, scale, sub, @@ -1127,6 +1128,12 @@ 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); + // 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 + // Maskierung wie oben für startCut/endCut, s <= 1e-6 / e >= axisLen-1e-6). + // Fehlt sie, bleibt alles wie zuvor (startCut/endCut für jede Schicht). + const layerCuts = cuts.layerCuts; let off = refOff - total / 2; for (let li = 0; li < wt.layers.length; li++) { const layer = wt.layers[li]; @@ -1142,9 +1149,11 @@ function addWallPoche( // SIA-Poché: neutraler Hintergrund (weiß) statt Bauteil-Albedo, die // Schichtschraffur liegt darüber; Vollmuster ("solid") = schwarze Poché. 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, startCut, endCut), + pts: clippedBand(p1, p2, off, off + layer.thickness, layerStartCut, layerEndCut), fill: layerSolid ? POCHE_FILL_BLACK : POCHE_FILL_WHITE, // Band ohne Umriss: keine Schichtfuge über die Bandkante, keine 45°-Naht // am Knoten. Die Fugen kommen als separate `line`-Primitive (siehe unten). @@ -1156,6 +1165,42 @@ function addWallPoche( 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 + // begrenzt die Schichtbreite dort, wo der Kern sie unterbricht (L-Form). + const sideStart = s <= 1e-6 ? layerCuts?.startSide[li] : null; + const sideEnd = e >= axisLen - 1e-6 ? layerCuts?.endSide[li] : null; + if (sideStart || sideEnd) { + const uSeg = normalize(sub(p2, p1)); + const nSeg = leftNormal(uSeg); + const sidePoint = (base: Vec2, layerOff: number, cut: Line): Vec2 => { + const pt = add(base, scale(nSeg, layerOff)); + return lineIntersect(pt, uSeg, cut.point, cut.dir) ?? pt; + }; + if (sideStart) { + out.push({ + kind: "line", + a: sidePoint(p1, off, sideStart), + b: sidePoint(p1, off + layer.thickness, sideStart), + cls: "layer-joint-l", + weightMm: layerLineMm, + color: stroke, + greyed, + }); + } + if (sideEnd) { + out.push({ + kind: "line", + a: sidePoint(p2, off, sideEnd), + b: sidePoint(p2, off + layer.thickness, sideEnd), + cls: "layer-joint-l", + weightMm: layerLineMm, + color: stroke, + greyed, + }); + } + } off += layer.thickness; // Innere Schichtfuge (zwischen dieser und der nächst-inneren Schicht) als // eigene Linie am kumulierten Offset `off`, mit dem Cut der Bänder geclippt. @@ -1163,6 +1208,9 @@ function addWallPoche( // linie (LAYER_LINE_MM) in Wandfarbe. Das Gewicht folgt demselben Detail- // Faktor wie die frühere Schichtfuge (`layerLineMm`), damit die Detailgrade // sich unverändert verhalten. Die innerste Schicht hat keine innere Fuge. + // Nutzt bewusst weiterhin den einheitlichen startCut/endCut (nicht die + // 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