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
+127 -10
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 wa1Bands = plan.primitives.filter(
(p): p is Extract<typeof p, { kind: "polygon" }> =>
p.kind === "polygon" && p.wallId === "WA1" && p.stroke === "none",
// Die Durchgangs-Hälfte ist die mit spanCutouts — finde sie über ihre
// gekürzten Bänder: sammle die Bänder beider Hälften.
const bandsOf = (id: string) =>
plan.primitives.filter(
(p): p is Extract<typeof p, { kind: "polygon" }> =>
p.kind === "polygon" && p.wallId === id && p.stroke === "none",
);
const wa1 = bandsOf("WA1");
const wa2 = bandsOf("WA2");
// Beide Hälften haben weiterhin drei Bänder (der Cutout reicht bis ans
// Wandende, splittet also nicht in zwei, sondern kürzt nur).
expect(wa1.length).toBe(3);
expect(wa2.length).toBe(3);
// Genau EINE Hälfte trägt ein gekürztes Nah-Putz-Band: das innere Putzband
// (y-Nahfläche 0.075) endet nicht mehr am Knoten x=5, sondern bei 4.94 bzw.
// (für die andere Orientierung) bei 5.06.
// Nah-Putz-Band = Offsetzone y∈[0.06,0.075] (offA=0.06 → pts[0/1], offB=0.075
// → pts[2/3]); dessen X-Kanten verraten die Kürzung.
const isNearPlaster = (b: { pts: { y: number }[] }) => {
const ys = b.pts.map((p) => p.y);
return Math.abs(Math.min(...ys) - 0.06) < 1e-6 && Math.abs(Math.max(...ys) - 0.075) < 1e-6;
};
const nearPlasterEnds = [...wa1, ...wa2]
.filter(isNearPlaster)
.flatMap((b) => b.pts.map((p) => p.x));
// Mindestens eine Kante liegt bei 4.94 oder 5.06 (Backstein-Breite ab Knoten).
const shortened = nearPlasterEnds.some(
(x) => Math.abs(x - 4.94) < 1e-6 || Math.abs(x - 5.06) < 1e-6,
);
expect(wa1Bands.length).toBe(3);
// Kein Cut an ihrem gemeinsamen Knoten-Ende (endCut bleibt null, s. joins.ts) →
// alle drei Bänder enden exakt am Achsenpunkt (5,0), keine Gehrung nötig.
for (const b of wa1Bands) {
expect(b.pts[1].x).toBeCloseTo(5, 6);
}
expect(shortened).toBe(true);
});
});
/** Mittelspannen-Variante: EINE Durchgangswand W, Abzweig WB trifft mittig. */
function midSpanProject(): Project {
const base = tJunctionProject();
const wall = (id: string, start: Wall["start"], end: Wall["end"]): Wall => ({
id,
type: "wall",
floorId: "eg",
categoryCode: "20",
start,
end,
wallTypeId: "iw",
height: 2.6,
});
base.walls = [
wall("W", { x: 0, y: 0 }, { x: 10, y: 0 }),
wall("WB", { x: 4, y: 3 }, { x: 4, y: 0 }),
];
return base;
}
describe("generatePlan — Durchgangswand-Aussparung am Mittelspann-Stoss (Phase 1c)", () => {
it("splittet das Innenputz-Band der Durchgangswand über dem Durchstoss in zwei Teil-Bänder", () => {
const plan = generatePlan(midSpanProject(), "eg", visible, undefined, "mittel");
const bands = plan.primitives.filter(
(p): p is Extract<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);
});
});