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.
This commit is contained in:
@@ -22,6 +22,21 @@ pub struct Line {
|
|||||||
pub dir: Vec2,
|
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)]
|
#[derive(Serialize, Deserialize)]
|
||||||
pub struct WallInput {
|
pub struct WallInput {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -30,6 +45,8 @@ pub struct WallInput {
|
|||||||
pub thickness: f64,
|
pub thickness: f64,
|
||||||
#[serde(rename = "referenceOffset")]
|
#[serde(rename = "referenceOffset")]
|
||||||
pub reference_offset: f64,
|
pub reference_offset: f64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub layers: Vec<LayerInput>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
@@ -37,6 +54,19 @@ pub struct JoinInput {
|
|||||||
pub walls: Vec<WallInput>,
|
pub walls: Vec<WallInput>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<Option<Line>>,
|
||||||
|
pub end: Vec<Option<Line>>,
|
||||||
|
#[serde(rename = "startSide")]
|
||||||
|
pub start_side: Vec<Option<Line>>,
|
||||||
|
#[serde(rename = "endSide")]
|
||||||
|
pub end_side: Vec<Option<Line>>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Schnittlinien einer Wand an ihren beiden Achsenden.
|
/// Schnittlinien einer Wand an ihren beiden Achsenden.
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
pub struct WallCuts {
|
pub struct WallCuts {
|
||||||
@@ -46,6 +76,112 @@ pub struct WallCuts {
|
|||||||
pub start_cut: Option<Line>,
|
pub start_cut: Option<Line>,
|
||||||
#[serde(rename = "endCut")]
|
#[serde(rename = "endCut")]
|
||||||
pub end_cut: Option<Line>,
|
pub end_cut: Option<Line>,
|
||||||
|
/// 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<LayerCuts>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 -----------------------------------------------------------
|
// --- Vektor-Helfer -----------------------------------------------------------
|
||||||
@@ -139,6 +275,7 @@ pub fn compute_joins(input: JoinInput) -> Vec<WallCuts> {
|
|||||||
wall_id: w.id.clone(),
|
wall_id: w.id.clone(),
|
||||||
start_cut: None,
|
start_cut: None,
|
||||||
end_cut: None,
|
end_cut: None,
|
||||||
|
layer_cuts: None,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -305,11 +442,18 @@ fn apply_t_junction(
|
|||||||
// zeigt (positive n_t-Seite, wenn der Abzweig dorthin auslaeuft).
|
// 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 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_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(
|
set_cut(&mut result[branch_wall_idx], branch_end.end, face_cut);
|
||||||
&mut result[branch_wall_idx],
|
apply_layer_cuts(
|
||||||
|
result,
|
||||||
|
branch_wall_idx,
|
||||||
branch_end.end,
|
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 off_w = w.reference_offset;
|
||||||
let sign = if perp >= 0.0 { 1.0 } else { -1.0 };
|
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_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(
|
set_cut(&mut result[free_wall_idx], free_end.end, face_cut);
|
||||||
&mut result[free_wall_idx],
|
// 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,
|
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 },
|
end: Vec2 { x: ex, y: ey },
|
||||||
thickness,
|
thickness,
|
||||||
reference_offset: 0.0,
|
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);
|
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");
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+146
-2
@@ -15,10 +15,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { computeJoins } from "./joins";
|
import { computeJoins, resolveJoinPriority } from "./joins";
|
||||||
import { cross, len, sub } from "./geometry";
|
import { cross, len, sub } from "./geometry";
|
||||||
import type { Line } 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`. */
|
/** Abstand eines Punktes P von der unendlichen Geraden `line`. */
|
||||||
function distToLine(line: Line, p: Vec2): number {
|
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();
|
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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+136
-6
@@ -3,8 +3,8 @@
|
|||||||
// gemeinsamen Gehrungslinie sauber stoßen. Dieses Modul berechnet pro Wand
|
// gemeinsamen Gehrungslinie sauber stoßen. Dieses Modul berechnet pro Wand
|
||||||
// die optionalen Schnittlinien an Start- und Endpunkt.
|
// die optionalen Schnittlinien an Start- und Endpunkt.
|
||||||
|
|
||||||
import type { Project, Vec2, Wall } from "./types";
|
import type { Component, Project, Vec2, Wall } from "./types";
|
||||||
import { getWallType, wallTypeThickness } from "./types";
|
import { getComponent, getWallType, wallTypeThickness } from "./types";
|
||||||
import { wallReferenceOffset } from "./wall";
|
import { wallReferenceOffset } from "./wall";
|
||||||
import {
|
import {
|
||||||
add,
|
add,
|
||||||
@@ -18,10 +18,55 @@ import {
|
|||||||
} from "./geometry";
|
} from "./geometry";
|
||||||
import type { Line } 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. */
|
/** Schnittlinien einer Wand an ihren beiden Achsenden. */
|
||||||
export interface WallCuts {
|
export interface WallCuts {
|
||||||
startCut: Line | null;
|
startCut: Line | null;
|
||||||
endCut: 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. */
|
/** 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).
|
// zeigt (positive nT-Seite, wenn der Abzweig dorthin ausläuft).
|
||||||
const sign = dot(nT, dBranch) >= 0 ? 1 : -1;
|
const sign = dot(nT, dBranch) >= 0 ? 1 : -1;
|
||||||
const facePoint = add(j, scale(nT, offT + sign * (tT / 2)));
|
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<string, WallCuts>,
|
||||||
|
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 endGap = 1e-4; // Mindestabstand zu den Wandenden für „innere Spanne"
|
||||||
const tol = 1e-3; // Toleranz für den senkrechten Abstand zur Wandfläche
|
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) {
|
for (const w of walls) {
|
||||||
if (w.id === freeWall.id) continue;
|
if (w.id === freeWall.id) continue;
|
||||||
const u = dirOf(w);
|
const u = dirOf(w);
|
||||||
@@ -278,7 +402,7 @@ function applyMidSpanTee(
|
|||||||
const dist = Math.abs(perp);
|
const dist = Math.abs(perp);
|
||||||
if (dist > tw / 2 + tol) continue;
|
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
|
if (!best) return; // kein Treffer → rechtwinklig
|
||||||
|
|
||||||
@@ -289,6 +413,12 @@ function applyMidSpanTee(
|
|||||||
const offW = wallReferenceOffset(w, tw);
|
const offW = wallReferenceOffset(w, tw);
|
||||||
const sign = best.perp >= 0 ? 1 : -1;
|
const sign = best.perp >= 0 ? 1 : -1;
|
||||||
const facePoint = add(w.start, scale(nW, offW + sign * (tw / 2)));
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<typeof p, { kind: "polygon" }> =>
|
||||||
|
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<typeof p, { kind: "line" }> => 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<typeof p, { kind: "polygon" }> =>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -45,6 +45,7 @@ import {
|
|||||||
along,
|
along,
|
||||||
clippedBand,
|
clippedBand,
|
||||||
leftNormal,
|
leftNormal,
|
||||||
|
lineIntersect,
|
||||||
normalize,
|
normalize,
|
||||||
scale,
|
scale,
|
||||||
sub,
|
sub,
|
||||||
@@ -1127,6 +1128,12 @@ function addWallPoche(
|
|||||||
// auf. Die Bänder tragen NUR Füllung + Schraffur (kein eigener Umriss); jede
|
// auf. Die Bänder tragen NUR Füllung + Schraffur (kein eigener Umriss); jede
|
||||||
// innere Schichtfuge wird als EIGENSTÄNDIGE, stilisierbare Linie gezeichnet.
|
// innere Schichtfuge wird als EIGENSTÄNDIGE, stilisierbare Linie gezeichnet.
|
||||||
const bandJoinEdges = joinEdges(startCut, endCut);
|
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;
|
let off = refOff - total / 2;
|
||||||
for (let li = 0; li < wt.layers.length; li++) {
|
for (let li = 0; li < wt.layers.length; li++) {
|
||||||
const layer = wt.layers[li];
|
const layer = wt.layers[li];
|
||||||
@@ -1142,9 +1149,11 @@ function addWallPoche(
|
|||||||
// SIA-Poché: neutraler Hintergrund (weiß) statt Bauteil-Albedo, die
|
// SIA-Poché: neutraler Hintergrund (weiß) statt Bauteil-Albedo, die
|
||||||
// Schichtschraffur liegt darüber; Vollmuster ("solid") = schwarze Poché.
|
// Schichtschraffur liegt darüber; Vollmuster ("solid") = schwarze Poché.
|
||||||
const layerSolid = layerHatch.pattern === "solid";
|
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({
|
out.push({
|
||||||
kind: "polygon",
|
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,
|
fill: layerSolid ? POCHE_FILL_BLACK : POCHE_FILL_WHITE,
|
||||||
// Band ohne Umriss: keine Schichtfuge über die Bandkante, keine 45°-Naht
|
// Band ohne Umriss: keine Schichtfuge über die Bandkante, keine 45°-Naht
|
||||||
// am Knoten. Die Fugen kommen als separate `line`-Primitive (siehe unten).
|
// am Knoten. Die Fugen kommen als separate `line`-Primitive (siehe unten).
|
||||||
@@ -1156,6 +1165,42 @@ function addWallPoche(
|
|||||||
noStrokeEdges: bandJoinEdges,
|
noStrokeEdges: bandJoinEdges,
|
||||||
wallId,
|
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;
|
off += layer.thickness;
|
||||||
// Innere Schichtfuge (zwischen dieser und der nächst-inneren Schicht) als
|
// Innere Schichtfuge (zwischen dieser und der nächst-inneren Schicht) als
|
||||||
// eigene Linie am kumulierten Offset `off`, mit dem Cut der Bänder geclippt.
|
// 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-
|
// linie (LAYER_LINE_MM) in Wandfarbe. Das Gewicht folgt demselben Detail-
|
||||||
// Faktor wie die frühere Schichtfuge (`layerLineMm`), damit die Detailgrade
|
// Faktor wie die frühere Schichtfuge (`layerLineMm`), damit die Detailgrade
|
||||||
// sich unverändert verhalten. Die innerste Schicht hat keine innere Fuge.
|
// 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) {
|
if (li < wt.layers.length - 1) {
|
||||||
// clippedBand(p1, p2, off, off, …) liefert [edgeStart, edgeEnd, edgeEnd,
|
// clippedBand(p1, p2, off, off, …) liefert [edgeStart, edgeEnd, edgeEnd,
|
||||||
// edgeStart] → die zwei distinkten Endpunkte sind Index 0 (Start) und 1
|
// edgeStart] → die zwei distinkten Endpunkte sind Index 0 (Start) und 1
|
||||||
|
|||||||
Reference in New Issue
Block a user