Joins Phase 1c: Durchgangswand am T-Stoss echt aufbrechen (spanCutouts)

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.
This commit is contained in:
2026-07-04 19:22:32 +02:00
parent cb8fae5cb4
commit c5a344d3f2
5 changed files with 541 additions and 58 deletions
+115 -2
View File
@@ -67,6 +67,20 @@ pub struct LayerCuts {
pub end_side: Vec<Option<Line>>, pub end_side: Vec<Option<Line>>,
} }
/// 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. /// Schnittlinien einer Wand an ihren beiden Achsenden.
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
pub struct WallCuts { pub struct WallCuts {
@@ -82,6 +96,11 @@ pub struct WallCuts {
/// weiterhin `start_cut`/`end_cut` fuer alle Schichten. /// weiterhin `start_cut`/`end_cut` fuer alle Schichten.
#[serde(rename = "layerCuts", skip_serializing_if = "Option::is_none")] #[serde(rename = "layerCuts", skip_serializing_if = "Option::is_none")]
pub layer_cuts: Option<LayerCuts>, pub layer_cuts: Option<LayerCuts>,
/// 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<SpanCutout>,
} }
/// Verschneidungs-Entscheidung zweier Bauteile am Stoss -- Rust-Spiegel von /// Verschneidungs-Entscheidung zweier Bauteile am Stoss -- Rust-Spiegel von
@@ -135,6 +154,13 @@ fn apply_layer_cuts(
through_dir: Vec2, through_dir: Vec2,
sign: f64, sign: f64,
off_t: 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() { if branch_layers.is_empty() || through_layers.is_empty() {
return; return;
@@ -199,7 +225,10 @@ fn apply_layer_cuts(
let merge_cut = Line { point: merge_point, dir: through_dir }; let merge_cut = Line { point: merge_point, dir: through_dir };
// Getrimmte Nachbarschichten schliessen mit ihrer L-Seitenlinie an dieser // Getrimmte Nachbarschichten schliessen mit ihrer L-Seitenlinie an dieser
// Rueckgrat-Nahflaeche ab (nicht an der Wandachse). // 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 side_line = merge_cut;
let (face_arr, side_arr) = match branch_end { let (face_arr, side_arr) = match branch_end {
WallEndKind::Start => (&mut lc.start, &mut lc.start_side), WallEndKind::Start => (&mut lc.start, &mut lc.start_side),
@@ -213,6 +242,44 @@ fn apply_layer_cuts(
} }
} }
// 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,
});
}
}
}
// --- Vektor-Helfer ----------------------------------------------------------- // --- Vektor-Helfer -----------------------------------------------------------
fn sub(a: Vec2, b: Vec2) -> Vec2 { fn sub(a: Vec2, b: Vec2) -> Vec2 {
@@ -305,6 +372,7 @@ pub fn compute_joins(input: JoinInput) -> Vec<WallCuts> {
start_cut: None, start_cut: None,
end_cut: None, end_cut: None,
layer_cuts: None, layer_cuts: None,
span_cutouts: Vec::new(),
}) })
.collect(); .collect();
@@ -485,6 +553,10 @@ fn apply_t_junction(
u_t, u_t,
sign, sign,
off_t, 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, u_w,
sign, sign,
off_w, 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(s0, Vec2 { x: 0.0, y: 0.06 }) < 1e-9);
assert!(dist(s2, Vec2 { x: 5.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 ca = find(&out, "A");
let cc = find(&out, "C"); let cc = find(&out, "C");
assert!(ca.layer_cuts.is_none()); assert!(ca.layer_cuts.is_none());
assert!(cc.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());
} }
} }
+60 -2
View File
@@ -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: 5, y: 0.06 })).toBeCloseTo(0, 6);
expect(distToLine(lc.startSide[0]!, { x: 0, 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 // Die kollinearen Hälften der Durchgangswand bleiben bei den layerCuts
// (unverändertes Verhalten, keine layerCuts für sie). // unangetastet (keine Pro-Schicht-Cuts für sie).
expect(joins.get("WA1")!.layerCuts).toBeUndefined(); expect(joins.get("WA1")!.layerCuts).toBeUndefined();
expect(joins.get("WA2")!.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", () => { it("setzt layerCuts NICHT, wenn kein Bauteil des Abzweigs mit dem Rückgrat der Durchgangswand übereinstimmt", () => {
+77
View File
@@ -35,6 +35,29 @@ export interface LayerCuts {
endSide: (Line | null)[]; 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. */ /** Schnittlinien einer Wand an ihren beiden Achsenden. */
export interface WallCuts { export interface WallCuts {
startCut: Line | null; startCut: Line | null;
@@ -46,6 +69,13 @@ export interface WallCuts {
* verschmilzt (siehe {@link resolveJoinPriority}). * verschmilzt (siehe {@link resolveJoinPriority}).
*/ */
layerCuts?: LayerCuts; 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])); !merged[i] && ((i > 0 && merged[i - 1]) || (i < n - 1 && merged[i + 1]));
sideArr[i] = adjMerged ? sideLine : null; 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,
});
}
}
}
} }
/** /**
+125 -8
View File
@@ -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 plan = generatePlan(tJunctionProject(), "eg", visible, undefined, "mittel");
const wa1Bands = plan.primitives.filter( // 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<typeof p, { kind: "polygon" }> => (p): p is Extract<typeof p, { kind: "polygon" }> =>
p.kind === "polygon" && p.wallId === "WA1" && p.stroke === "none", p.kind === "polygon" && p.wallId === id && p.stroke === "none",
); );
expect(wa1Bands.length).toBe(3); const wa1 = bandsOf("WA1");
// Kein Cut an ihrem gemeinsamen Knoten-Ende (endCut bleibt null, s. joins.ts) → const wa2 = bandsOf("WA2");
// alle drei Bänder enden exakt am Achsenpunkt (5,0), keine Gehrung nötig. // Beide Hälften haben weiterhin drei Bänder (der Cutout reicht bis ans
for (const b of wa1Bands) { // Wandende, splittet also nicht in zwei, sondern kürzt nur).
expect(b.pts[1].x).toBeCloseTo(5, 6); 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(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<typeof p, { kind: "polygon" }> =>
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<typeof p, { kind: "line" }> =>
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<typeof p, { kind: "line" }> =>
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);
}); });
}); });
+125 -7
View File
@@ -1045,6 +1045,38 @@ export function wallGaps(
return gaps.sort((a, b) => a.from - b.from); 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( function addWallPoche(
out: Primitive[], out: Primitive[],
project: Project, project: Project,
@@ -1095,6 +1127,13 @@ function addWallPoche(
// (höchste Priorität) — weiß, bzw. schwarz falls dessen Muster "solid" ist. // (höchste Priorität) — weiß, bzw. schwarz falls dessen Muster "solid" ist.
const simpleFill = backbonePocheFill(project, wt); 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). // 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) // 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. // 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 // 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);
// 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): // Materialbewusster T-Stoss (siehe resolveJoinPriority in model/joins.ts):
// liegt eine Pro-Schicht-Cut-Überschreibung vor, ersetzt sie PRO SCHICHT den // liegt eine Pro-Schicht-Cut-Überschreibung vor, ersetzt sie PRO SCHICHT den
// einheitlichen startCut/endCut — nur an den echten Wandenden (dieselbe // einheitlichen startCut/endCut — nur an den echten Wandenden (dieselbe
@@ -1161,9 +1209,26 @@ function addWallPoche(
const layerSolid = layerHatch.pattern === "solid"; const layerSolid = layerHatch.pattern === "solid";
const layerStartCut = s <= 1e-6 ? (layerCuts ? layerCuts.start[li] : startCut) : null; const layerStartCut = s <= 1e-6 ? (layerCuts ? layerCuts.start[li] : startCut) : null;
const layerEndCut = e >= axisLen - 1e-6 ? (layerCuts ? layerCuts.end[li] : endCut) : null; const layerEndCut = e >= axisLen - 1e-6 ? (layerCuts ? layerCuts.end[li] : endCut) : null;
// 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({ out.push({
kind: "polygon", kind: "polygon",
pts: clippedBand(p1, p2, off, off + layer.thickness, layerStartCut, layerEndCut), pts: clippedBand(axisPt(a), axisPt(b), off, off + layer.thickness, sc, ec),
fill: layerSolid ? HATCH_INK : HATCH_PAPER, fill: layerSolid ? HATCH_INK : HATCH_PAPER,
// 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).
@@ -1175,6 +1240,7 @@ function addWallPoche(
noStrokeEdges: bandJoinEdges, noStrokeEdges: bandJoinEdges,
wallId, wallId,
}); });
}
// L-Anschluss: eine getrimmte Schicht (z. B. Putz), die an eine // L-Anschluss: eine getrimmte Schicht (z. B. Putz), die an eine
// verschmelzende Nachbarschicht (durchlaufender Kern) grenzt, bekommt // verschmelzende Nachbarschicht (durchlaufender Kern) grenzt, bekommt
// zusätzlich eine seitliche Cut-Linie entlang der Durchgangsachse — sie // zusätzlich eine seitliche Cut-Linie entlang der Durchgangsachse — sie
@@ -1222,13 +1288,27 @@ function addWallPoche(
// Pro-Schicht-Cuts): die Fuge markiert den Materialwechsel INNERHALB // Pro-Schicht-Cuts): die Fuge markiert den Materialwechsel INNERHALB
// dieser Wand und bleibt daher an der bisherigen Nahfläche. // 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,
// 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 const jls = layer.jointLineStyleId
? project.lineStyles.find((l) => l.id === layer.jointLineStyleId) ? project.lineStyles.find((l) => l.id === layer.jointLineStyleId)
: undefined; : undefined;
// 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({ out.push({
kind: "line", kind: "line",
a: jq[0], a: jq[0],
@@ -1245,20 +1325,58 @@ function addWallPoche(
}); });
} }
} }
}
// Kräftige Wand-Umrisslinie über die volle Dicke (nur Kontur, keine Füllung), // 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- // damit die Wand sich klar von den dünnen Schichtfugen abhebt. Die Gehrungs-
// Stirnkanten am Knoten entfallen → sauberer Eck-Apex ohne überschießende Barbe. // 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({ out.push({
kind: "polygon", kind: "polygon",
pts: clippedBand(p1, p2, refOff - total / 2, refOff + total / 2, startCut, endCut), pts: clippedBand(p1, p2, offLo, offHi, startCut, endCut),
fill: "none", fill: "none",
stroke, stroke,
strokeWidthMm: outlineMm, strokeWidthMm: outlineMm,
hatch: NO_HATCH, hatch: NO_HATCH,
greyed, greyed,
noStrokeEdges: bandJoinEdges, noStrokeEdges: outlineNoStroke,
wallId, 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);
} }
} }