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:
+146
-2
@@ -15,10 +15,10 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { computeJoins } from "./joins";
|
||||
import { computeJoins, resolveJoinPriority } from "./joins";
|
||||
import { cross, len, sub } from "./geometry";
|
||||
import type { Line } from "./geometry";
|
||||
import type { Project, Vec2, Wall } from "./types";
|
||||
import type { Component, Project, Vec2, Wall } from "./types";
|
||||
|
||||
/** Abstand eines Punktes P von der unendlichen Geraden `line`. */
|
||||
function distToLine(line: Line, p: Vec2): number {
|
||||
@@ -185,3 +185,147 @@ describe("computeJoins — Mittelspannen-T-Stoß (Ende trifft Wandseite)", () =>
|
||||
expect(joins.get("WB3")!.endCut).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveJoinPriority — Ordnungslogik zweier Bauteile am Stoss", () => {
|
||||
const brick: Component = { id: "brick", name: "Backstein", color: "#8c5544", hatchId: "none", joinPriority: 50 };
|
||||
const brick2: Component = { id: "brick", name: "Backstein (Kopie)", color: "#000", hatchId: "none", joinPriority: 50 };
|
||||
const render: Component = { id: "render", name: "Putz", color: "#efece6", hatchId: "none", joinPriority: 10 };
|
||||
const insulation: Component = { id: "insulation", name: "Dämmung", color: "#fff", hatchId: "none", joinPriority: 20 };
|
||||
const concrete: Component = { id: "concrete", name: "Beton", color: "#9aa0a6", hatchId: "none", joinPriority: 100 };
|
||||
|
||||
it("verschmilzt bei gleicher Priorität UND gleicher Komponente", () => {
|
||||
expect(resolveJoinPriority(brick, brick2)).toBe("merge");
|
||||
});
|
||||
|
||||
it("koexistiert bei gleicher Priorität, aber verschiedener Komponente", () => {
|
||||
// Zwei fiktive Komponenten mit zufällig gleicher Priorität, aber
|
||||
// unterschiedlicher Identität — keine eindeutige Rangfolge.
|
||||
const other: Component = { id: "other-30", name: "X", color: "#123", hatchId: "none", joinPriority: 20 };
|
||||
expect(resolveJoinPriority(insulation, other)).toBe("coexist");
|
||||
});
|
||||
|
||||
it("lässt die höhere Priorität gewinnen (die niedrigere wird getrimmt)", () => {
|
||||
expect(resolveJoinPriority(render, brick)).toBe("trim-a"); // a=Putz verliert
|
||||
expect(resolveJoinPriority(brick, render)).toBe("trim-b"); // b=Putz verliert
|
||||
expect(resolveJoinPriority(concrete, brick)).toBe("trim-b");
|
||||
expect(resolveJoinPriority(brick, concrete)).toBe("trim-a");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Minimalprojekt mit einem mehrschichtigen, W9-artigen Wandtyp
|
||||
* (Innenputz/Backstein-Kern/Innenputz, wie `sampleProject`s "iw") — für die
|
||||
* materialbewusste T-Stoss-Erweiterung (layerCuts).
|
||||
*/
|
||||
function layeredWallProject(w1: Wall, w2: Wall, w3: Wall): Project {
|
||||
return {
|
||||
id: "t",
|
||||
name: "T",
|
||||
lineStyles: [],
|
||||
hatches: [],
|
||||
components: [
|
||||
{ id: "render-int", name: "Innenputz", color: "#efece6", hatchId: "none", joinPriority: 10 },
|
||||
{ id: "brick", name: "Backstein", color: "#8c5544", hatchId: "none", joinPriority: 50 },
|
||||
],
|
||||
wallTypes: [
|
||||
{
|
||||
id: "iw",
|
||||
name: "Innenwand",
|
||||
layers: [
|
||||
{ componentId: "render-int", thickness: 0.015 },
|
||||
{ componentId: "brick", thickness: 0.12 },
|
||||
{ componentId: "render-int", thickness: 0.015 },
|
||||
],
|
||||
},
|
||||
],
|
||||
drawingLevels: [
|
||||
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 },
|
||||
],
|
||||
layers: [{ code: "20", name: "Wände", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
|
||||
walls: [w1, w2, w3],
|
||||
doors: [],
|
||||
openings: [],
|
||||
ceilings: [],
|
||||
stairs: [],
|
||||
rooms: [],
|
||||
drawings2d: [],
|
||||
context: [],
|
||||
};
|
||||
}
|
||||
|
||||
function iwWall(id: string, start: Vec2, end: Vec2): Wall {
|
||||
return {
|
||||
id,
|
||||
type: "wall",
|
||||
floorId: "eg",
|
||||
categoryCode: "20",
|
||||
start,
|
||||
end,
|
||||
wallTypeId: "iw",
|
||||
height: 2.6,
|
||||
};
|
||||
}
|
||||
|
||||
describe("computeJoins — materialbewusster T-Stoss (layerCuts)", () => {
|
||||
it("W9-artiger T-Knoten: Backstein-Kern des Abzweigs verschmilzt (kein Cut), Putz wird getrimmt + L-Seitenlinie", () => {
|
||||
// Durchgangswand WA1 (0,0)→(5,0) / WA2 (5,0)→(10,0), Abzweig WB (5,0)→(5,3),
|
||||
// alle vom selben Wandtyp "iw" (Innenputz/Backstein/Innenputz, T=0.15).
|
||||
const proj = layeredWallProject(
|
||||
iwWall("WA1", { x: 0, y: 0 }, { x: 5, y: 0 }),
|
||||
iwWall("WA2", { x: 5, y: 0 }, { x: 10, y: 0 }),
|
||||
iwWall("WB", { x: 5, y: 0 }, { x: 5, y: 3 }),
|
||||
);
|
||||
const joins = computeJoins(proj, proj.walls);
|
||||
const cuts = joins.get("WB")!;
|
||||
|
||||
// Aggregat-Cut bleibt unverändert (bit-identisch zum bisherigen Verhalten).
|
||||
expect(cuts.startCut).not.toBeNull();
|
||||
expect(distToLine(cuts.startCut!, { x: 5, y: 0.075 })).toBeCloseTo(0, 6);
|
||||
|
||||
// layerCuts ist gesetzt (mindestens eine Schicht verschmilzt).
|
||||
expect(cuts.layerCuts).toBeDefined();
|
||||
const lc = cuts.layerCuts!;
|
||||
expect(lc.start.length).toBe(3);
|
||||
|
||||
// Schicht 1 (Backstein-Kern, Index 1) verschmilzt: KEIN Cut.
|
||||
expect(lc.start[1]).toBeNull();
|
||||
expect(lc.startSide[1]).toBeNull();
|
||||
|
||||
// Schichten 0 und 2 (Innenputz) werden getrimmt: derselbe Nahflächen-Cut
|
||||
// wie das Aggregat...
|
||||
expect(lc.start[0]).not.toBeNull();
|
||||
expect(lc.start[2]).not.toBeNull();
|
||||
expect(distToLine(lc.start[0]!, { x: 5, y: 0.075 })).toBeCloseTo(0, 6);
|
||||
expect(distToLine(lc.start[2]!, { x: 5, y: 0.075 })).toBeCloseTo(0, 6);
|
||||
// ... UND bekommen die zusätzliche L-Seitenlinie entlang der
|
||||
// Durchgangsachse (durch den Knoten (5,0), Richtung parallel zur x-Achse).
|
||||
expect(lc.startSide[0]).not.toBeNull();
|
||||
expect(lc.startSide[2]).not.toBeNull();
|
||||
expect(distToLine(lc.startSide[0]!, { x: 5, y: 0 })).toBeCloseTo(0, 6);
|
||||
expect(distToLine(lc.startSide[0]!, { x: 0, y: 0 })).toBeCloseTo(0, 6);
|
||||
|
||||
// Die kollinearen Hälften der Durchgangswand bleiben unangetastet
|
||||
// (unverändertes Verhalten, keine layerCuts für sie).
|
||||
expect(joins.get("WA1")!.layerCuts).toBeUndefined();
|
||||
expect(joins.get("WA2")!.layerCuts).toBeUndefined();
|
||||
});
|
||||
|
||||
it("setzt layerCuts NICHT, wenn kein Bauteil des Abzweigs mit dem Rückgrat der Durchgangswand übereinstimmt", () => {
|
||||
// Gleiche Anordnung, aber der Abzweig ist eine reine Einschicht-Wand ohne
|
||||
// Materialübereinstimmung zum Backstein-Rückgrat → Default-Verhalten.
|
||||
const proj = layeredWallProject(
|
||||
iwWall("WA1", { x: 0, y: 0 }, { x: 5, y: 0 }),
|
||||
iwWall("WA2", { x: 5, y: 0 }, { x: 10, y: 0 }),
|
||||
iwWall("WB", { x: 5, y: 0 }, { x: 5, y: 3 }),
|
||||
);
|
||||
proj.wallTypes.push({
|
||||
id: "solo",
|
||||
name: "Solo",
|
||||
layers: [{ componentId: "render-int", thickness: 0.1 }],
|
||||
});
|
||||
proj.walls[2] = { ...proj.walls[2], wallTypeId: "solo" };
|
||||
const joins = computeJoins(proj, proj.walls);
|
||||
expect(joins.get("WB")!.layerCuts).toBeUndefined();
|
||||
expect(joins.get("WB")!.startCut).not.toBeNull(); // Aggregat-Cut bleibt normal
|
||||
});
|
||||
});
|
||||
|
||||
+136
-6
@@ -3,8 +3,8 @@
|
||||
// gemeinsamen Gehrungslinie sauber stoßen. Dieses Modul berechnet pro Wand
|
||||
// die optionalen Schnittlinien an Start- und Endpunkt.
|
||||
|
||||
import type { Project, Vec2, Wall } from "./types";
|
||||
import { getWallType, wallTypeThickness } from "./types";
|
||||
import type { Component, Project, Vec2, Wall } from "./types";
|
||||
import { getComponent, getWallType, wallTypeThickness } from "./types";
|
||||
import { wallReferenceOffset } from "./wall";
|
||||
import {
|
||||
add,
|
||||
@@ -18,10 +18,55 @@ import {
|
||||
} from "./geometry";
|
||||
import type { Line } from "./geometry";
|
||||
|
||||
/**
|
||||
* Pro-Schicht-Override der Nahflächen-Cuts (materialbewusster T-Stoss, siehe
|
||||
* {@link resolveJoinPriority}). Index = Schicht-Index im Wandtyp DIESER Wand
|
||||
* (der Abzweig-Wand). `start`/`end` ersetzen — wo gesetzt — den einheitlichen
|
||||
* `startCut`/`endCut` NUR für die jeweilige Schicht; `null` an einem Index
|
||||
* heisst: diese Schicht bekommt KEINEN Cut (verschmilzt, läuft durch).
|
||||
* `startSide`/`endSide` tragen die zusätzliche seitliche Cut-Linie einer
|
||||
* getrimmten Schicht, die an eine verschmelzende Nachbarschicht angrenzt
|
||||
* (L-Anschluss, z. B. Putz neben durchlaufendem Backstein-Kern).
|
||||
*/
|
||||
export interface LayerCuts {
|
||||
start: (Line | null)[];
|
||||
end: (Line | null)[];
|
||||
startSide: (Line | null)[];
|
||||
endSide: (Line | null)[];
|
||||
}
|
||||
|
||||
/** Schnittlinien einer Wand an ihren beiden Achsenden. */
|
||||
export interface WallCuts {
|
||||
startCut: Line | null;
|
||||
endCut: Line | null;
|
||||
/**
|
||||
* Additive Erweiterung: fehlt dieses Feld, gilt weiterhin `startCut`/
|
||||
* `endCut` für ALLE Schichten (bisheriges Verhalten, bit-identisch). Wird
|
||||
* nur gesetzt, wenn an einem T-Stoss mindestens eine Schicht materialbewusst
|
||||
* verschmilzt (siehe {@link resolveJoinPriority}).
|
||||
*/
|
||||
layerCuts?: LayerCuts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verschneidungs-Entscheidung zweier Bauteile (Komponenten) am Stoss:
|
||||
* • "merge" — gleiche Priorität UND gleiche Komponente (z. B. Backstein-
|
||||
* Kern trifft Backstein-Kern): die Schichten verschmelzen, kein Cut.
|
||||
* • "coexist" — gleiche Priorität, aber VERSCHIEDENE Komponente: keine
|
||||
* eindeutige Rangfolge, bleibt beim heutigen (getrimmten) Verhalten.
|
||||
* • "trim-a" — `b` hat die höhere Priorität und gewinnt; `a` wird getrimmt.
|
||||
* • "trim-b" — `a` hat die höhere Priorität und gewinnt; `b` wird getrimmt.
|
||||
*/
|
||||
export type JoinPriorityResolution = "merge" | "trim-a" | "trim-b" | "coexist";
|
||||
|
||||
export function resolveJoinPriority(
|
||||
a: Component,
|
||||
b: Component,
|
||||
): JoinPriorityResolution {
|
||||
if (a.joinPriority === b.joinPriority) {
|
||||
return a.id === b.id ? "merge" : "coexist";
|
||||
}
|
||||
return a.joinPriority > b.joinPriority ? "trim-b" : "trim-a";
|
||||
}
|
||||
|
||||
/** Rundet eine Koordinate auf ein Gitter, um Endpunkte robust zu gruppieren. */
|
||||
@@ -237,8 +282,87 @@ function applyTJunction(
|
||||
// zeigt (positive nT-Seite, wenn der Abzweig dorthin ausläuft).
|
||||
const sign = dot(nT, dBranch) >= 0 ? 1 : -1;
|
||||
const facePoint = add(j, scale(nT, offT + sign * (tT / 2)));
|
||||
const faceCut: Line = { point: facePoint, dir: uT };
|
||||
|
||||
setCut(result, branchEnd, { point: facePoint, dir: uT });
|
||||
setCut(result, branchEnd, faceCut);
|
||||
applyLayerCuts(project, result, branchEnd, branchWall, throughWall, faceCut, j, uT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialbewusste Pro-Schicht-Erweiterung eines T-Stoss-Cuts (siehe
|
||||
* {@link resolveJoinPriority}): vergleicht jede Schicht der ABZWEIG-Wand mit
|
||||
* dem Rückgrat (höchste `joinPriority`) der DURCHGANGSWAND. Verschmilzt eine
|
||||
* Schicht (gleiche Komponente + Priorität wie das Rückgrat, oder die
|
||||
* Abzweig-Schicht ist sogar prioritärer), bekommt sie KEINEN Cut — das Band
|
||||
* läuft bis zum Wandachsenpunkt durch (die Durchgangswand selbst ist an einem
|
||||
* T-Stoss unzerschnitten und deckt den Rest ab, s. `applyTJunction`-Doc).
|
||||
* Getrimmte Schichten, die direkt an eine verschmelzende Nachbarschicht
|
||||
* grenzen, bekommen zusätzlich eine seitliche Cut-Linie entlang der
|
||||
* Durchgangsachse (L-Anschluss, z. B. Putz neben durchlaufendem Kern).
|
||||
*
|
||||
* Setzt `WallCuts.layerCuts` NUR, wenn mindestens eine Schicht verschmilzt —
|
||||
* sonst bleibt das Feld `undefined` (additiv, bit-identisches Default-
|
||||
* Verhalten für den ganz überwiegenden Fall ohne Materialübereinstimmung).
|
||||
*/
|
||||
function applyLayerCuts(
|
||||
project: Project,
|
||||
result: Map<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 tol = 1e-3; // Toleranz für den senkrechten Abstand zur Wandfläche
|
||||
|
||||
let best: { wall: Wall; perp: number; dist: number } | null = null;
|
||||
let best: { wall: Wall; perp: number; dist: number; distAlong: number } | null = null;
|
||||
for (const w of walls) {
|
||||
if (w.id === freeWall.id) continue;
|
||||
const u = dirOf(w);
|
||||
@@ -278,7 +402,7 @@ function applyMidSpanTee(
|
||||
const dist = Math.abs(perp);
|
||||
if (dist > tw / 2 + tol) continue;
|
||||
|
||||
if (!best || dist < best.dist) best = { wall: w, perp, dist };
|
||||
if (!best || dist < best.dist) best = { wall: w, perp, dist, distAlong };
|
||||
}
|
||||
if (!best) return; // kein Treffer → rechtwinklig
|
||||
|
||||
@@ -289,6 +413,12 @@ function applyMidSpanTee(
|
||||
const offW = wallReferenceOffset(w, tw);
|
||||
const sign = best.perp >= 0 ? 1 : -1;
|
||||
const facePoint = add(w.start, scale(nW, offW + sign * (tw / 2)));
|
||||
const faceCut: Line = { point: facePoint, dir: uW };
|
||||
|
||||
setCut(result, freeEnd, { point: facePoint, dir: uW });
|
||||
setCut(result, freeEnd, faceCut);
|
||||
// Achsenpunkt auf der getroffenen Wand, auf Höhe der Projektion des freien
|
||||
// Endes (nicht zwingend w.start) — Anker für die materialbewusste
|
||||
// Pro-Schicht-Erweiterung (analog zum T-Knoten-Fall).
|
||||
const axisPoint = add(w.start, scale(uW, best.distAlong));
|
||||
applyLayerCuts(project, result, freeEnd, freeWall, w, faceCut, axisPoint, uW);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user