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:
@@ -67,6 +67,20 @@ pub struct LayerCuts {
|
||||
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.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct WallCuts {
|
||||
@@ -82,6 +96,11 @@ pub struct WallCuts {
|
||||
/// weiterhin `start_cut`/`end_cut` fuer alle Schichten.
|
||||
#[serde(rename = "layerCuts", skip_serializing_if = "Option::is_none")]
|
||||
pub layer_cuts: Option<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
|
||||
@@ -135,6 +154,13 @@ fn apply_layer_cuts(
|
||||
through_dir: Vec2,
|
||||
sign: f64,
|
||||
off_t: f64,
|
||||
// Durchgangswand-Aussparung (Phase 1c): Index/Start der Durchgangswand,
|
||||
// Achsrichtung + Referenzversatz des Abzweigs -- fuer die Projektion der
|
||||
// Kernbreite auf die Durchgangsachse.
|
||||
through_wall_idx: usize,
|
||||
through_start: Vec2,
|
||||
branch_dir: Vec2,
|
||||
branch_ref_off: f64,
|
||||
) {
|
||||
if branch_layers.is_empty() || through_layers.is_empty() {
|
||||
return;
|
||||
@@ -199,7 +225,10 @@ fn apply_layer_cuts(
|
||||
let merge_cut = Line { point: merge_point, dir: through_dir };
|
||||
|
||||
// Getrimmte Nachbarschichten schliessen mit ihrer L-Seitenlinie an dieser
|
||||
// Rueckgrat-Nahflaeche ab (nicht an der Wandachse).
|
||||
// Rueckgrat-Nahflaeche ab (nicht an der Wandachse). In einem Block, damit die
|
||||
// mutable Ausleihe von `result[branch_wall_idx]` vor dem Zugriff auf die
|
||||
// Durchgangswand unten endet.
|
||||
{
|
||||
let side_line = merge_cut;
|
||||
let (face_arr, side_arr) = match branch_end {
|
||||
WallEndKind::Start => (&mut lc.start, &mut lc.start_side),
|
||||
@@ -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 -----------------------------------------------------------
|
||||
|
||||
fn sub(a: Vec2, b: Vec2) -> Vec2 {
|
||||
@@ -305,6 +372,7 @@ pub fn compute_joins(input: JoinInput) -> Vec<WallCuts> {
|
||||
start_cut: None,
|
||||
end_cut: None,
|
||||
layer_cuts: None,
|
||||
span_cutouts: Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -485,6 +553,10 @@ fn apply_t_junction(
|
||||
u_t,
|
||||
sign,
|
||||
off_t,
|
||||
through_wall_idx,
|
||||
through_wall.start,
|
||||
dir_of(branch_wall),
|
||||
branch_wall.reference_offset,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -575,6 +647,10 @@ fn apply_mid_span_tee(
|
||||
u_w,
|
||||
sign,
|
||||
off_w,
|
||||
w_idx,
|
||||
w.start,
|
||||
dir_of(free_wall),
|
||||
free_wall.reference_offset,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -904,10 +980,47 @@ mod tests {
|
||||
assert!(dist(s0, Vec2 { x: 0.0, y: 0.06 }) < 1e-9);
|
||||
assert!(dist(s2, Vec2 { x: 5.0, y: 0.06 }) < 1e-9);
|
||||
|
||||
// Die kollineare Durchgangswand bleibt unangetastet (kein layerCuts).
|
||||
// Die kollineare Durchgangswand bleibt bei den layerCuts unangetastet.
|
||||
let ca = find(&out, "A");
|
||||
let cc = find(&out, "C");
|
||||
assert!(ca.layer_cuts.is_none());
|
||||
assert!(cc.layer_cuts.is_none());
|
||||
|
||||
// ... bekommt aber die Durchgangswand-Aussparung (Phase 1c): die als
|
||||
// Durchgang gewaehlte Haelfte A wird ueber die Backstein-Kernbreite (0.12)
|
||||
// im Nah-Putz-Bereich (y in [0.06,0.075]) ausgeschnitten. Das Intervall
|
||||
// ist die Projektion der Kernbreite auf die Achse, zentriert am Knoten x=5.
|
||||
assert_eq!(ca.span_cutouts.len(), 1, "A (Durchgang) hat genau eine Aussparung");
|
||||
let sc = ca.span_cutouts[0];
|
||||
assert!((sc.from - 4.94).abs() < 1e-9, "from = 5 - 0.06");
|
||||
assert!((sc.to - 5.06).abs() < 1e-9, "to = 5 + 0.06");
|
||||
assert!(((sc.to - sc.from) - 0.12).abs() < 1e-9, "Intervallbreite = Kernbreite");
|
||||
assert!((sc.off_a - 0.075).abs() < 1e-9, "offA = Nahflaeche");
|
||||
assert!((sc.off_b - 0.06).abs() < 1e-9, "offB = Rueckgrat-Nahflaeche");
|
||||
// Der Abzweig B traegt keine Aussparung (die gehoert der Durchgangswand).
|
||||
assert!(cb.span_cutouts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mid_span_tee_through_wall_gets_span_cutout() {
|
||||
// Mittelspannen-T-Stoss (wie der reale W9-Fall): Durchgangswand W als EINE
|
||||
// Wand (0,0)->(10,0), Abzweig B trifft mit seinem freien Ende (4,0) mittig
|
||||
// auf ihre Seite. W bekommt EINE Aussparung um x=4, Breite = Kernbreite.
|
||||
let input = JoinInput {
|
||||
walls: vec![
|
||||
w_iw("W", 0.0, 0.0, 10.0, 0.0),
|
||||
w_iw("B", 4.0, 3.0, 4.0, 0.0),
|
||||
],
|
||||
};
|
||||
let out = compute_joins(input);
|
||||
let cw = find(&out, "W");
|
||||
assert_eq!(cw.span_cutouts.len(), 1, "W hat genau eine Aussparung");
|
||||
let sc = cw.span_cutouts[0];
|
||||
assert!((sc.from - 3.94).abs() < 1e-9);
|
||||
assert!((sc.to - 4.06).abs() < 1e-9);
|
||||
assert!((sc.off_a - 0.075).abs() < 1e-9);
|
||||
assert!((sc.off_b - 0.06).abs() < 1e-9);
|
||||
// Der Abzweig selbst hat keine Aussparung.
|
||||
assert!(find(&out, "B").span_cutouts.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
+60
-2
@@ -311,10 +311,68 @@ describe("computeJoins — materialbewusster T-Stoss (layerCuts)", () => {
|
||||
expect(distToLine(lc.startSide[0]!, { x: 5, y: 0.06 })).toBeCloseTo(0, 6);
|
||||
expect(distToLine(lc.startSide[0]!, { x: 0, y: 0.06 })).toBeCloseTo(0, 6);
|
||||
|
||||
// Die kollinearen Hälften der Durchgangswand bleiben unangetastet
|
||||
// (unverändertes Verhalten, keine layerCuts für sie).
|
||||
// Die kollinearen Hälften der Durchgangswand bleiben bei den layerCuts
|
||||
// unangetastet (keine Pro-Schicht-Cuts für sie).
|
||||
expect(joins.get("WA1")!.layerCuts).toBeUndefined();
|
||||
expect(joins.get("WA2")!.layerCuts).toBeUndefined();
|
||||
|
||||
// Die als Durchgang gewählte Wandhälfte bekommt eine Durchgangswand-Aussparung
|
||||
// (Phase 1c): über die Backstein-Kernbreite (0.12) im Nah-Putz-Bereich
|
||||
// (Offset 0.06…0.075). Welche Hälfte den Durchgang bildet, hängt von der
|
||||
// Knoten-Reihenfolge ab — wir prüfen die, die den Cutout trägt.
|
||||
const throughCuts = joins.get("WA1")!.spanCutouts ?? joins.get("WA2")!.spanCutouts;
|
||||
expect(throughCuts).toBeDefined();
|
||||
expect(throughCuts!.length).toBe(1);
|
||||
const sc = throughCuts![0];
|
||||
// Achsen-Intervall: Kernbreite 0.12, zentriert am Knoten (x=5 → [4.94,5.06]).
|
||||
expect(sc.to - sc.from).toBeCloseTo(0.12, 6);
|
||||
// offA = Nahfläche (0.075), offB = Rückgrat-Nahfläche (0.06).
|
||||
expect(sc.offA).toBeCloseTo(0.075, 6);
|
||||
expect(sc.offB).toBeCloseTo(0.06, 6);
|
||||
// Der Abzweig selbst trägt keine Aussparung (die gehört der Durchgangswand).
|
||||
expect(joins.get("WB")!.spanCutouts).toBeUndefined();
|
||||
// L-Ecke bzw. Wände ohne T bleiben cutout-frei (die kollineare Nicht-Durchgangs-
|
||||
// Hälfte hat keinen Cutout).
|
||||
expect(
|
||||
joins.get("WA1")!.spanCutouts === undefined ||
|
||||
joins.get("WA2")!.spanCutouts === undefined,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("Mittelspannen-T-Stoss: die Durchgangswand bekommt einen spanCutout mit korrekter Kernbreite und Nah-Putz-Offsetzone", () => {
|
||||
// Realer W9-Fall: Durchgangswand als EINE Wand W (0,0)→(10,0), Abzweig WB
|
||||
// trifft mit seinem freien Ende (4,0) mittig auf ihre Seite. Beide iw.
|
||||
const proj = layeredWallProject(
|
||||
iwWall("W", { x: 0, y: 0 }, { x: 10, y: 0 }),
|
||||
iwWall("WB", { x: 4, y: 3 }, { x: 4, y: 0 }),
|
||||
iwWall("Filler", { x: 100, y: 100 }, { x: 100, y: 103 }), // unbeteiligt
|
||||
);
|
||||
const joins = computeJoins(proj, proj.walls);
|
||||
|
||||
const wCuts = joins.get("W")!;
|
||||
expect(wCuts.spanCutouts).toBeDefined();
|
||||
expect(wCuts.spanCutouts!.length).toBe(1);
|
||||
const sc = wCuts.spanCutouts![0];
|
||||
// Intervall um x=4, Breite = Kernbreite 0.12 → [3.94, 4.06].
|
||||
expect(sc.from).toBeCloseTo(3.94, 6);
|
||||
expect(sc.to).toBeCloseTo(4.06, 6);
|
||||
expect(sc.offA).toBeCloseTo(0.075, 6);
|
||||
expect(sc.offB).toBeCloseTo(0.06, 6);
|
||||
// Der Abzweig und die unbeteiligte Wand tragen keine Aussparung.
|
||||
expect(joins.get("WB")!.spanCutouts).toBeUndefined();
|
||||
expect(joins.get("Filler")!.spanCutouts).toBeUndefined();
|
||||
});
|
||||
|
||||
it("L-Ecke und einfacher (nicht materialbewusster) Stoss bekommen KEINEN spanCutout", () => {
|
||||
// Reine L-Ecke zweier Wände: keine Durchgangswand, kein Durchstoss.
|
||||
const proj = twoWallProject(
|
||||
wall("L1", { x: 0, y: 0 }, { x: 5, y: 0 }),
|
||||
wall("L2", { x: 5, y: 0 }, { x: 5, y: 4 }),
|
||||
0.4,
|
||||
);
|
||||
const joins = computeJoins(proj, proj.walls);
|
||||
expect(joins.get("L1")!.spanCutouts).toBeUndefined();
|
||||
expect(joins.get("L2")!.spanCutouts).toBeUndefined();
|
||||
});
|
||||
|
||||
it("setzt layerCuts NICHT, wenn kein Bauteil des Abzweigs mit dem Rückgrat der Durchgangswand übereinstimmt", () => {
|
||||
|
||||
@@ -35,6 +35,29 @@ export interface LayerCuts {
|
||||
endSide: (Line | null)[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Aussparung der DURCHGANGSWAND an einem materialbewussten T-Stoss: über das
|
||||
* Achsen-Intervall `[from,to]` (Meter ab `wall.start` entlang der Achse) und den
|
||||
* Quer-Offsetbereich `[offA,offB]` wird die Nah-Putz-Zone der Durchgangswand
|
||||
* echt ausgeschnitten, damit der durchstechende Abzweig-Kern (Phase 1b) die
|
||||
* Durchgangswand nicht nur übermalt, sondern geometrisch aufbricht.
|
||||
*
|
||||
* OFFSET-KONVENTION (identisch zu den Schicht-Bändern in `addWallPoche`):
|
||||
* `offA`/`offB` sind Querabstände von der Wandachse ENTLANG der linken Normalen
|
||||
* `leftNormal(dir)`, inkl. Referenzversatz — also im selben Bereich wie
|
||||
* `refOff − total/2` (außen) … `refOff + total/2` (innen). `offA` ist stets die
|
||||
* NAHFLÄCHE (Wandoberfläche, `refOff + sign·total/2`), `offB` die dem Abzweig
|
||||
* zugewandte Fläche des Rückgrat-KERNS (`refOff + sign·(total/2 − nearPlaster)`)
|
||||
* — die Zone dazwischen ist exakt der Nah-Putz, den der Abzweig-Kern durchstösst.
|
||||
* `from ≤ to` ist die Projektion der Abzweig-Kernbreite auf die Durchgangsachse.
|
||||
*/
|
||||
export interface SpanCutout {
|
||||
from: number;
|
||||
to: number;
|
||||
offA: number;
|
||||
offB: number;
|
||||
}
|
||||
|
||||
/** Schnittlinien einer Wand an ihren beiden Achsenden. */
|
||||
export interface WallCuts {
|
||||
startCut: Line | null;
|
||||
@@ -46,6 +69,13 @@ export interface WallCuts {
|
||||
* verschmilzt (siehe {@link resolveJoinPriority}).
|
||||
*/
|
||||
layerCuts?: LayerCuts;
|
||||
/**
|
||||
* Additive Erweiterung (Durchgangswand-Seite): Aussparungen der Nah-Putz-Zone,
|
||||
* durch die ein Abzweig-Kern sticht. Fehlt/leer ⇒ die Wand wird unverändert
|
||||
* gezeichnet. Wird nur an der DURCHGANGSWAND gesetzt (nie am Abzweig), analog
|
||||
* additiv zu {@link layerCuts}. Siehe {@link SpanCutout}.
|
||||
*/
|
||||
spanCutouts?: SpanCutout[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -397,6 +427,53 @@ function applyLayerCuts(
|
||||
!merged[i] && ((i > 0 && merged[i - 1]) || (i < n - 1 && merged[i + 1]));
|
||||
sideArr[i] = adjMerged ? sideLine : null;
|
||||
}
|
||||
|
||||
// Durchgangswand-Aussparung (Phase 1c): den Nah-Putz der Durchgangswand über
|
||||
// die Breite des durchstechenden Abzweig-Kerns ECHT ausschneiden. Nur nötig,
|
||||
// wenn davor überhaupt Nah-Putz liegt (nearPlaster > 0) — sonst schliesst der
|
||||
// Kern flächenbündig an und es gibt nichts aufzubrechen.
|
||||
if (nearPlaster > 1e-9) {
|
||||
// Kernbreite des Abzweigs im Quer-Offset (Offset-Konvention der Bänder:
|
||||
// gestapelt entlang leftNormal ab refOff − total/2). Die verschmelzenden
|
||||
// Schichten (`merged`) bilden den Kern; Lo/Hi = ihre Offset-Hülle.
|
||||
const totalBranch = wallTypeThickness(branchWt);
|
||||
const refOffBranch = wallReferenceOffset(branchWall, totalBranch);
|
||||
let bOff = refOffBranch - totalBranch / 2;
|
||||
let coreLo = Infinity;
|
||||
let coreHi = -Infinity;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const th = branchWt.layers[i].thickness;
|
||||
if (merged[i]) {
|
||||
if (bOff < coreLo) coreLo = bOff;
|
||||
if (bOff + th > coreHi) coreHi = bOff + th;
|
||||
}
|
||||
bOff += th;
|
||||
}
|
||||
if (coreHi > coreLo) {
|
||||
// Projektion der Kern-Offsethülle auf die Durchgangsachse: der Achsenpunkt
|
||||
// liegt bei `axisAt` (Meter ab Durchgangs-Start), ein Abzweig-Offset `o`
|
||||
// verschiebt sich entlang der Achse um `o · (nBranch · uT)`.
|
||||
const nBranch = leftNormal(dirOf(branchWall));
|
||||
const proj = dot(nBranch, throughDir);
|
||||
const axisAt = dot(sub(axisPoint, throughWall.start), throughDir);
|
||||
const e1 = axisAt + coreLo * proj;
|
||||
const e2 = axisAt + coreHi * proj;
|
||||
// Quer-Offsetbereich = Nah-Putz-Zone der Durchgangswand: Nahfläche (offA)
|
||||
// bis Rückgrat-Nahfläche (offB, = mergeCut-Offset). Beide in derselben
|
||||
// Offset-Konvention wie die Schicht-Bänder der Durchgangswand.
|
||||
const offA = offT + sign * (tT / 2);
|
||||
const offB = offT + sign * (tT / 2 - nearPlaster);
|
||||
const throughCuts = result.get(throughWall.id);
|
||||
if (throughCuts) {
|
||||
(throughCuts.spanCutouts ??= []).push({
|
||||
from: Math.min(e1, e2),
|
||||
to: Math.max(e1, e2),
|
||||
offA,
|
||||
offB,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -114,17 +114,134 @@ describe("generatePlan — materialbewusster T-Stoss (Poché-Bänder)", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("lässt die kollineare Durchgangswand unverändert (keine L-Linien, normale Bänder)", () => {
|
||||
it("bricht die Nah-Putz-Schicht der Durchgangswand am Durchstoss auf (Phase 1c)", () => {
|
||||
// Am T-Knoten wird eine der kollinearen Hälften als Durchgangswand gewählt
|
||||
// und bekommt eine Aussparung; ihr Innenputz-Band auf der Durchstoss-Seite
|
||||
// (y∈[0.06,0.075]) wird über die Backstein-Breite [4.94,5.06] gekürzt (der
|
||||
// Cutout reicht bis ans Knoten-Ende x=5). Backstein-Kern + Aussenputz bleiben
|
||||
// voll (kein Überlappungsbereich).
|
||||
const plan = generatePlan(tJunctionProject(), "eg", visible, undefined, "mittel");
|
||||
const wa1Bands = plan.primitives.filter(
|
||||
// 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.kind === "polygon" && p.wallId === "WA1" && p.stroke === "none",
|
||||
p.kind === "polygon" && p.wallId === id && 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);
|
||||
const wa1 = bandsOf("WA1");
|
||||
const wa2 = bandsOf("WA2");
|
||||
// Beide Hälften haben weiterhin drei Bänder (der Cutout reicht bis ans
|
||||
// Wandende, splittet also nicht in zwei, sondern kürzt nur).
|
||||
expect(wa1.length).toBe(3);
|
||||
expect(wa2.length).toBe(3);
|
||||
// Genau EINE Hälfte trägt ein gekürztes Nah-Putz-Band: das innere Putzband
|
||||
// (y-Nahfläche 0.075) endet nicht mehr am Knoten x=5, sondern bei 4.94 bzw.
|
||||
// (für die andere Orientierung) bei 5.06.
|
||||
// Nah-Putz-Band = Offsetzone y∈[0.06,0.075] (offA=0.06 → pts[0/1], offB=0.075
|
||||
// → pts[2/3]); dessen X-Kanten verraten die Kürzung.
|
||||
const isNearPlaster = (b: { pts: { y: number }[] }) => {
|
||||
const ys = b.pts.map((p) => p.y);
|
||||
return Math.abs(Math.min(...ys) - 0.06) < 1e-6 && Math.abs(Math.max(...ys) - 0.075) < 1e-6;
|
||||
};
|
||||
const nearPlasterEnds = [...wa1, ...wa2]
|
||||
.filter(isNearPlaster)
|
||||
.flatMap((b) => b.pts.map((p) => p.x));
|
||||
// Mindestens eine Kante liegt bei 4.94 oder 5.06 (Backstein-Breite ab Knoten).
|
||||
const shortened = nearPlasterEnds.some(
|
||||
(x) => Math.abs(x - 4.94) < 1e-6 || Math.abs(x - 5.06) < 1e-6,
|
||||
);
|
||||
expect(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
@@ -1045,6 +1045,38 @@ export function wallGaps(
|
||||
return gaps.sort((a, b) => a.from - b.from);
|
||||
}
|
||||
|
||||
/** Toleranz für die Achsen-Intervall-Arithmetik der Durchgangswand-Aussparungen. */
|
||||
const CUTOUT_EPS = 1e-9;
|
||||
|
||||
/**
|
||||
* Zieht die Loch-Intervalle `holes` vom Segment `[s,e]` ab und liefert die
|
||||
* verbleibenden (gezeichneten) Teil-Intervalle in Achsen-Metern. Löcher werden
|
||||
* auf `[s,e]` geklemmt, sortiert und vereinigt; leeres Ergebnis heisst „ganz
|
||||
* ausgespart". Ist `holes` leer, kommt exakt `[[s,e]]` zurück (unverändertes
|
||||
* Verhalten für Wände ohne Aussparung).
|
||||
*/
|
||||
function subtractIntervals(
|
||||
s: number,
|
||||
e: number,
|
||||
holes: Array<[number, number]>,
|
||||
): Array<[number, number]> {
|
||||
const clipped = holes
|
||||
.map(([a, b]): [number, number] => [
|
||||
Math.max(s, Math.min(a, b)),
|
||||
Math.min(e, Math.max(a, b)),
|
||||
])
|
||||
.filter(([a, b]) => b > a + CUTOUT_EPS)
|
||||
.sort((p, q) => p[0] - q[0]);
|
||||
const parts: Array<[number, number]> = [];
|
||||
let cursor = s;
|
||||
for (const [a, b] of clipped) {
|
||||
if (a > cursor + CUTOUT_EPS) parts.push([cursor, a]);
|
||||
if (b > cursor) cursor = b;
|
||||
}
|
||||
if (cursor < e - CUTOUT_EPS) parts.push([cursor, e]);
|
||||
return parts;
|
||||
}
|
||||
|
||||
function addWallPoche(
|
||||
out: Primitive[],
|
||||
project: Project,
|
||||
@@ -1095,6 +1127,13 @@ function addWallPoche(
|
||||
// (höchste Priorität) — weiß, bzw. schwarz falls dessen Muster "solid" ist.
|
||||
const simpleFill = backbonePocheFill(project, wt);
|
||||
|
||||
// Durchgangswand-Aussparungen (Phase 1c): über diese Achsen-Intervalle × Quer-
|
||||
// Offsetbereiche wird die Nah-Putz-Zone der Durchgangswand echt ausgeschnitten
|
||||
// (der Abzweig-Kern sticht durch). `offA` ist stets die Nahfläche, `offB` die
|
||||
// Rückgrat-Nahfläche (siehe SpanCutout in model/joins.ts). Leer ⇒ heutiges
|
||||
// Verhalten (Wand ohne T-Durchstoss).
|
||||
const spanCutouts = cuts.spanCutouts ?? [];
|
||||
|
||||
// Kanten-Indizes der inneren Gehrungs-Stirnflächen (die Diagonalen am Knoten).
|
||||
// clippedBand liefert [A.start, A.end, B.end, B.start] → Kante 1 (A.end→B.end)
|
||||
// ist die endCut-Stirnfläche, Kante 3 (B.start→A.start) die startCut-Stirnfläche.
|
||||
@@ -1137,6 +1176,15 @@ function addWallPoche(
|
||||
// auf. Die Bänder tragen NUR Füllung + Schraffur (kein eigener Umriss); jede
|
||||
// innere Schichtfuge wird als EIGENSTÄNDIGE, stilisierbare Linie gezeichnet.
|
||||
const bandJoinEdges = joinEdges(startCut, endCut);
|
||||
// Nur die Aussparungen, die dieses Segment [s,e] entlang der Achse berühren.
|
||||
const segCutouts = spanCutouts.filter(
|
||||
(c) =>
|
||||
Math.min(c.from, c.to) < e - CUTOUT_EPS &&
|
||||
Math.max(c.from, c.to) > s + CUTOUT_EPS,
|
||||
);
|
||||
// Punkt entlang der Wandachse bei Achsen-Meter `d` (p1/p2 an den Rändern).
|
||||
const axisPt = (d: number): Vec2 =>
|
||||
d === s ? p1 : d === e ? p2 : along(wall.start, wall.end, d);
|
||||
// Materialbewusster T-Stoss (siehe resolveJoinPriority in model/joins.ts):
|
||||
// liegt eine Pro-Schicht-Cut-Überschreibung vor, ersetzt sie PRO SCHICHT den
|
||||
// einheitlichen startCut/endCut — nur an den echten Wandenden (dieselbe
|
||||
@@ -1161,9 +1209,26 @@ function addWallPoche(
|
||||
const layerSolid = layerHatch.pattern === "solid";
|
||||
const layerStartCut = s <= 1e-6 ? (layerCuts ? layerCuts.start[li] : startCut) : null;
|
||||
const layerEndCut = e >= axisLen - 1e-6 ? (layerCuts ? layerCuts.end[li] : endCut) : null;
|
||||
// Durchgangswand-Aussparung: liegt der Quer-Offsetbereich dieses Bandes
|
||||
// [off, off+thickness] im Nah-Putz-Bereich einer Aussparung, wird das Band
|
||||
// entlang der Achse in Teil-Bänder VOR/NACH dem Intervall zerlegt (das
|
||||
// Durchstoss-Stück entfällt). Ohne Überlappung bleibt genau ein Band
|
||||
// [s,e] (unverändertes Verhalten).
|
||||
const bandLo = off;
|
||||
const bandHi = off + layer.thickness;
|
||||
const bandHoles: Array<[number, number]> = [];
|
||||
for (const c of segCutouts) {
|
||||
const cLo = Math.min(c.offA, c.offB);
|
||||
const cHi = Math.max(c.offA, c.offB);
|
||||
if (bandLo < cHi - CUTOUT_EPS && bandHi > cLo + CUTOUT_EPS)
|
||||
bandHoles.push([c.from, c.to]);
|
||||
}
|
||||
for (const [a, b] of subtractIntervals(s, e, bandHoles)) {
|
||||
const sc = a <= s + CUTOUT_EPS ? layerStartCut : null;
|
||||
const ec = b >= e - CUTOUT_EPS ? layerEndCut : null;
|
||||
out.push({
|
||||
kind: "polygon",
|
||||
pts: clippedBand(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,
|
||||
// Band ohne Umriss: keine Schichtfuge über die Bandkante, keine 45°-Naht
|
||||
// am Knoten. Die Fugen kommen als separate `line`-Primitive (siehe unten).
|
||||
@@ -1175,6 +1240,7 @@ 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
|
||||
@@ -1222,13 +1288,27 @@ function addWallPoche(
|
||||
// Pro-Schicht-Cuts): die Fuge markiert den Materialwechsel INNERHALB
|
||||
// dieser Wand und bleibt daher an der bisherigen Nahfläche.
|
||||
if (li < wt.layers.length - 1) {
|
||||
// clippedBand(p1, p2, off, off, …) liefert [edgeStart, edgeEnd, edgeEnd,
|
||||
// edgeStart] → die zwei distinkten Endpunkte sind Index 0 (Start) und 1
|
||||
// (Ende), beide am Cut abgeschnitten wie die Bandlängskante.
|
||||
const jq = clippedBand(p1, p2, off, off, startCut, endCut);
|
||||
const jls = layer.jointLineStyleId
|
||||
? project.lineStyles.find((l) => l.id === layer.jointLineStyleId)
|
||||
: undefined;
|
||||
// Durchgangswand-Aussparung: liegt die Fuge im (geschlossenen) Nah-Putz-
|
||||
// Bereich einer Aussparung — inkl. der Rückgrat-Nahfläche (Merge-Kontakt)
|
||||
// —, wird sie über das Durchstoss-Intervall unterbrochen. So bleibt an der
|
||||
// Verschmelzungsfläche keine Trennlinie stehen.
|
||||
const fugeHoles: Array<[number, number]> = [];
|
||||
for (const c of segCutouts) {
|
||||
const cLo = Math.min(c.offA, c.offB);
|
||||
const cHi = Math.max(c.offA, c.offB);
|
||||
if (off >= cLo - CUTOUT_EPS && off <= cHi + CUTOUT_EPS)
|
||||
fugeHoles.push([c.from, c.to]);
|
||||
}
|
||||
for (const [a, b] of subtractIntervals(s, e, fugeHoles)) {
|
||||
const sc = a <= s + CUTOUT_EPS ? startCut : null;
|
||||
const ec = b >= e - CUTOUT_EPS ? endCut : null;
|
||||
// clippedBand(pa, pb, off, off, …) liefert [edgeStart, edgeEnd, edgeEnd,
|
||||
// edgeStart] → die zwei distinkten Endpunkte sind Index 0 (Start) und 1
|
||||
// (Ende), beide am Cut abgeschnitten wie die Bandlängskante.
|
||||
const jq = clippedBand(axisPt(a), axisPt(b), off, off, sc, ec);
|
||||
out.push({
|
||||
kind: "line",
|
||||
a: jq[0],
|
||||
@@ -1245,20 +1325,58 @@ function addWallPoche(
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// Kräftige Wand-Umrisslinie über die volle Dicke (nur Kontur, keine Füllung),
|
||||
// damit die Wand sich klar von den dünnen Schichtfugen abhebt. Die Gehrungs-
|
||||
// Stirnkanten am Knoten entfallen → sauberer Eck-Apex ohne überschießende Barbe.
|
||||
// Bei einer Durchgangswand-Aussparung wird zusätzlich die NAHFLÄCHEN-Kante
|
||||
// (die Längskante auf der Durchstoss-Seite) über [from,to] strokelos gemacht
|
||||
// und als separate Segmente vor/nach dem Durchstoss gezeichnet — sonst liefe
|
||||
// die Umrisslinie über die Durchstoss-Breite.
|
||||
const offLo = refOff - total / 2;
|
||||
const offHi = refOff + total / 2;
|
||||
const holesHi: Array<[number, number]> = [];
|
||||
const holesLo: Array<[number, number]> = [];
|
||||
for (const c of segCutouts) {
|
||||
// `offA` ist stets die Nahfläche (Wandoberfläche); ordne die Aussparung der
|
||||
// passenden Längskante (offHi bzw. offLo) zu.
|
||||
if (Math.abs(c.offA - offHi) < 1e-6) holesHi.push([c.from, c.to]);
|
||||
else if (Math.abs(c.offA - offLo) < 1e-6) holesLo.push([c.from, c.to]);
|
||||
}
|
||||
const outlineNoStroke = [...bandJoinEdges];
|
||||
// clippedBand-Kantenreihenfolge [A.start(offLo), A.end(offLo), B.end(offHi),
|
||||
// B.start(offHi)]: Kante 0 ist die offLo-Längskante, Kante 2 die offHi-Längskante.
|
||||
if (holesHi.length) outlineNoStroke.push(2);
|
||||
if (holesLo.length) outlineNoStroke.push(0);
|
||||
out.push({
|
||||
kind: "polygon",
|
||||
pts: clippedBand(p1, p2, refOff - total / 2, refOff + total / 2, startCut, endCut),
|
||||
pts: clippedBand(p1, p2, offLo, offHi, startCut, endCut),
|
||||
fill: "none",
|
||||
stroke,
|
||||
strokeWidthMm: outlineMm,
|
||||
hatch: NO_HATCH,
|
||||
greyed,
|
||||
noStrokeEdges: bandJoinEdges,
|
||||
noStrokeEdges: outlineNoStroke,
|
||||
wallId,
|
||||
});
|
||||
const drawNearEdge = (edgeOff: number, holes: Array<[number, number]>): void => {
|
||||
for (const [a, b] of subtractIntervals(s, e, holes)) {
|
||||
const sc = a <= s + CUTOUT_EPS ? startCut : null;
|
||||
const ec = b >= e - CUTOUT_EPS ? endCut : null;
|
||||
const jq = clippedBand(axisPt(a), axisPt(b), edgeOff, edgeOff, sc, ec);
|
||||
out.push({
|
||||
kind: "line",
|
||||
a: jq[0],
|
||||
b: jq[1],
|
||||
cls: "wall-outline",
|
||||
weightMm: outlineMm,
|
||||
color: stroke,
|
||||
greyed,
|
||||
});
|
||||
}
|
||||
};
|
||||
if (holesHi.length) drawNearEdge(offHi, holesHi);
|
||||
if (holesLo.length) drawNearEdge(offLo, holesLo);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user