# Design — Prioritätsbasierte mehrschichtige Wand-Verschneidung (T/X) > Teil der Standalone-Architektur — siehe [../../ARCHITECTURE.md](../../ARCHITECTURE.md). > Aufbauend auf der bestehenden **L-Ecken-Gehrung** in `src/model/joins.ts` > (`computeJoins`, `miterLine`) und `src/model/geometry.ts` (`clippedBand`, > `lineIntersect`). Plan-Verbraucher: `src/plan/generatePlan.ts` (`addWallPoche`). > Bezeichner **englisch**, Prosa deutsch, Einheiten **Meter**. > **Hinweis Referenz:** Die im Auftrag genannte DOSSIER-Datei > `rhino/wand_grips.py` ist in dieser Umgebung **nicht vorhanden** (das einzige > auffindbare `dossier` ist ein Typst-Portfolio, kein Rhino-Plugin). Das Design > stützt sich daher auf (a) den bestehenden Code, (b) die in CONVENTIONS.md/elements.md > festgehaltenen Geometrie-Konventionen und (c) die etablierte Bau-Semantik der > prioritätsbasierten Schichtverschneidung (Vectorworks „Komponenten-Verbindung", > Revit „Layer Priority", ArchiCAD „Composite Priority"). Sobald `wand_grips.py` > verfügbar ist, sollte Abschnitt 7 (Priorität/Backbone) gegen DOSSIERs konkrete > Rangregel abgeglichen werden. --- ## 0. Problem & Leitbild Heute (`computeJoins`) wird **nur die L-Ecke** behandelt: genau zwei Wandenden treffen sich in einem Knoten, eine gemeinsame **Gehrungslinie** (`miterLine`) schneidet beide Wände sauber. T-/X-Stöße (≥ 3 Enden) bleiben rechtwinklig gekappt (`if (ends.length !== 2) continue;`) — die durchgehende Wand wird von der ankommenden Wand nicht durchdrungen, und die Schichten überlappen sich oder klaffen. **Ziel:** An jedem Knoten — L (2 Enden), T (3), X/Kreuz (4+) — soll **jede Schicht jeder Wand** genau bis zu der Fläche reichen, die ihre Verschneidungs- Semantik vorgibt: - Die **gemeinsame, höchstpriorisierte Schicht** (Backbone, z. B. Stahlbeton) läuft **durch** den Stoß. - **Niederpriorisierte Schichten** (z. B. Putz, Dämmung) **stoßen** an die nächst- höhere Schicht des Nachbarn und **enden** dort. - Putz **läuft auf jeder Seite** bis zur Betonfläche und endet dort; Putz **überquert nie** den Beton (kanonisches Beispiel, siehe §7.1). **Architektur-Prinzip (CONVENTIONS.md):** Das semantische Modell ist die einzige Wahrheit. Die Verschneidung ist eine **reine Ableitung** (`Project + Wall[]` → Trimm-Linien je Schicht-Band), nichts wird in die Geometrie eingebacken. Sie wird beim Generieren des Plans angewandt und ist **deterministisch** und **idempotent**. --- ## 1. Geometrie-Konventionen (Wiederholung, verbindlich) Aus CONVENTIONS.md und `geometry.ts`: - Achsrichtung `u = normalize(end - start)`. - Wand-Normale `n = leftNormal(u) = (-u.y, u.x)`. - Schichten werden **außen → innen** gestapelt: Offset entlang `n` läuft von `-T/2` (linke/„außen"-Kante) nach `+T/2` (rechte/„innen"-Kante). Eine Schicht `k` belegt das Intervall `[off_k, off_k + thickness_k]` mit `off_0 = -T/2`. - Eine **unendliche Gerade** ist `Line { point, dir }`; Schnittpunkt zweier Geraden via `lineIntersect(a, da, b, db)` (null bei parallel). - Ein Schicht-**Band** zwischen Offsets `offA, offB` wird über `clippedBand(start, end, offA, offB, startCut, endCut)` gebildet; jede Bandlängskante wird mit einer optionalen Schnittlinie verschnitten statt rechtwinklig gekappt. --- ## 2. Datenmodell — was schon da ist, was neu kommt ### 2.1 Vorhanden (`types.ts`) ```ts interface Component { …; joinPriority: number; } // höher = läuft am Stoß durch interface Layer { componentId: string; thickness: number; } interface WallType { id; name; layers: Layer[]; } // layers: außen → innen interface Wall { start: Vec2; end: Vec2; wallTypeId; height; … } ``` `joinPriority` ist bereits **pro Bauteil (Component)** definiert — genau richtig: Priorität ist eine **Material-Eigenschaft**, nicht eine Schicht-Eigenschaft. Damit verschneidet sich Beton mit Beton unabhängig vom Wandtyp. ### 2.2 Neue, abgeleitete Strukturen (keine neuen persistenten Felder) Die heutige `WallCuts`-Struktur (eine Schnittlinie je **Wandende**) reicht für L (Gehrung) — aber **nicht** für T/X, weil dort verschiedene Schichten **verschiedene** Trimm-Linien brauchen (Beton läuft durch, Putz stoppt früher). Wir erweitern auf **pro Schicht, pro Ende** eine eigene Trimm-Linie: ```ts // src/model/joins.ts (erweitert) /** Eine gerichtete Halbkante eines Wandendes an einem Knoten. */ interface WallEnd { wallId: string; end: "start" | "end"; } /** Trimm-Linie + Klassifikation für GENAU EIN Schicht-Band an EINEM Ende. */ interface LayerCut { /** Schnittgerade in Weltkoordinaten; null = rechtwinklig kappen (Default). */ line: Line | null; /** * "miter" – Gehrung (L): geteilte Diagonale mit dem Nachbarn. * "butt" – Band stößt stumpf an eine Nachbarfläche (niedrigere Priorität). * "through" – Band läuft durch den Knoten (höchste Priorität / Backbone). * "square" – freies Ende, rechtwinklig (line === null). */ kind: "miter" | "butt" | "through" | "square"; } /** Pro Wandende: eine Trimm-Linie je Schicht-Index (parallel zu WallType.layers). */ interface EndCuts { /** layerCuts[k] gilt für layers[k]; Länge === wt.layers.length. */ layerCuts: LayerCut[]; } /** Ersetzt die alte WallCuts: jetzt schichtweise an beiden Enden. */ interface WallJoin { start: EndCuts; end: EndCuts; } export type JoinMap = Map; ``` **Abwärtskompatibilität:** Die alte L-Gehrung ist der Spezialfall „alle `layerCuts[k].line` an einem Ende sind dieselbe `miter`-Linie". `clippedBand` bleibt unverändert; der Plan-Generator ruft es jetzt **je Schicht mit der schichtspezifischen Linie** auf (siehe §8). ### 2.3 Knoten-Repräsentation ```ts /** Ein Verschneidungsknoten: alle Wandenden, die sich (gerundet) berühren. */ interface Junction { key: string; // roundKey(p) p: Vec2; // Knotenposition (Mittel der Enden) arms: Arm[]; // sortiert nach Außenwinkel (CCW) } /** Ein „Arm" = ein Wandende, gesehen als Strahl, der vom Knoten WEGzeigt. */ interface Arm { we: WallEnd; wall: Wall; /** Richtung VOM Knoten weg in die Wand hinein (immer normiert). */ dirOut: Vec2; // = end==="start" ? +u : -u /** Außenwinkel atan2(dirOut.y, dirOut.x) für Sortierung. */ angle: number; total: number; // Gesamtdicke der Wand /** Schicht-Profil, vom Knoten aus gesehen (siehe §3.2). */ layers: ArmLayer[]; } /** Eine Schicht eines Arms, mit ihren beiden Längs-Flächengeraden am Knoten. */ interface ArmLayer { index: number; // Index in wt.layers componentId: string; priority: number; // Component.joinPriority /** Offsets entlang n der Wand: [innerEdge..outerEdge] des Bandes. */ off0: number; off1: number; /** Die zwei Längsflächen als Geraden (point am Knoten, dir = u der Wand). */ faceA: Line; faceB: Line; } ``` --- ## 3. Knotenerkennung (Junction Detection) ### 3.1 Knoten clustern Identisch zur heutigen Logik, nur ohne die `length !== 2`-Abbruchbedingung: ``` function buildJunctions(walls): Junction[] map = Map for w in walls: map.push(roundKey(w.start), {wallId:w.id, end:"start"}) map.push(roundKey(w.end), {wallId:w.id, end:"end"}) out = [] for (key, ends) in map: if ends.length < 2: continue // freies Ende → alle Schichten "square" arms = ends.map(buildArm) arms.sort(by angle) // CCW um den Knoten out.push({ key, p: avgEndPoint(ends), arms }) return out ``` `roundKey` (vorhanden) gruppiert Endpunkte auf ein 0.1-mm-Gitter. **Wichtig für T-Stöße:** Bei einem echten T endet die ankommende Wand **auf der Achse** der durchgehenden Wand, nicht an deren Endpunkt. Solche Knoten werden über die Endpunkt-Gruppierung **nicht** gefunden, wenn die durchgehende Wand dort kein Ende hat. Daher zusätzlich (siehe §3.3) eine **Achs-Auf-Achs-Inzidenz**. ### 3.2 Arm-Schichtprofil (kanonische Orientierung) Damit Schichten zweier Arme vergleichbar sind, muss jeder Arm seine Schichten in **konsistenter Welt-Orientierung** kennen. Wir speichern je Schicht ihre beiden **Längsflächen** als Geraden mit Stützpunkt am Knoten und Richtung `u`: ``` function buildArm(we): Arm wall = byId(we.wallId); u = dirOf(wall); n = leftNormal(u) dirOut = we.end==="start" ? u : negate(u) // vom Knoten in die Wand j = we.end==="start" ? wall.start : wall.end total = wallTypeThickness(wt) off = -total/2 layers = [] for (k, layer) in wt.layers: off0 = off; off1 = off + layer.thickness faceA = { point: j + n*off0, dir: u } faceB = { point: j + n*off1, dir: u } layers.push({ index:k, componentId, priority, off0, off1, faceA, faceB }) off = off1 return { we, wall, dirOut, angle: atan2(dirOut), total, layers } ``` ### 3.3 T-Stoß-Erkennung (Achs-auf-Achs) Zusätzlich zu Endpunkt-Clustern: ein Wandende `e` (Punkt `P`) bildet einen **T-Stoß** mit Wand `B`, wenn `P` (innerhalb Toleranz) auf der **Strecke** `B.start → B.end` liegt, aber **nicht** auf deren Endpunkten: ``` function findTeeIncidences(walls): for endpoint P of each wall A (as WallEnd e): for each wall B != A: if pointOnSegment(P, B.start, B.end, tol) and not nearEndpoint(P, B): // virtueller Knoten: A endet, B läuft durch. registerTee(P, armOf(e), passThroughWall=B) ``` `B` wird hier als **durchgehender Strang** behandelt (kein Ende am Knoten); im Junction-Modell taucht `B` als zwei kollineare „Arme" auf (Richtung `+u` und `−u`), die der Prioritätsalgorithmus (§5) automatisch als „durchlaufend" erkennt (zwei kollineare Arme gleicher Wand → ihre Schichten enden nie gegeneinander). **MVP-Vereinfachung (Phase 1, §10):** T-Stöße zunächst NUR über koinzidente Endpunkte (B hat dort tatsächlich einen Eckpunkt, z. B. weil die Wand dort geteilt wurde). Echte Achs-auf-Achs-T-Stöße (B durchgehend) folgen in Phase 3. --- ## 4. Winkel-Sektoren & Nachbar-Flächen Für die Prioritätsauflösung muss man wissen, **welche Fläche eines Arms welcher Fläche des Nachbarn gegenübersteht**. Die Arme sind CCW nach `angle` sortiert. Zwischen zwei aufeinanderfolgenden Armen `arms[i]` und `arms[i+1]` (zyklisch) liegt ein **Sektor** (Keil). Jeder Sektor wird von **einer Längsfläche jedes der beiden Arme** begrenzt: ``` arm[i+1] \ Sektor S_i \ / faceR(i+1)\ ___ faceL(i) X (Knoten) / / arm[i] ``` Konvention: pro Arm hat die **äußere** Schicht (Index 0, Offset `-T/2`) die Fläche, die in den **CCW-vorausgehenden** Sektor zeigt; die **innere** Schicht den **nachfolgenden**. Konkret bestimmen wir die „dem Sektor zugewandte" Fläche jeder Schicht über das Vorzeichen von `cross(dirOut, sektorrichtung)` — analog zur `lbCloser`-Heuristik in `miterLine`, aber pro Sektor statt global. ``` function sectorFaces(armLeft, armRight): // armRight ist CCW vor armLeft (Sektor liegt zwischen ihnen). // Wähle für jeden Arm die Schicht-Flächen, die in den Sektor zeigen. faceOf(arm, towards): pick faceA or faceB of each layer by sign of cross(arm.dirOut, towards - knoten) ``` Diese Sektor-Sicht verallgemeinert die bestehende `miterLine`: bei genau zwei Armen (L) gibt es zwei Sektoren (innen/außen), und die Mittel-Gehrungslinie ergibt sich wie bisher aus dem Schnitt der gegenüberliegenden Außen- bzw. Innenflächen. --- ## 5. Prioritätsauflösung — das Kernstück ### 5.1 Idee An einem Knoten konkurrieren Schichten verschiedener Arme um denselben Raum. Regel: > Eine Schicht **läuft durch** (`through`), wenn sie zur **höchsten am Knoten > präsenten Priorität** gehört **und** auf der „gegenüberliegenden" Seite eine > Schicht **gleichen Materials** (oder ≥ gleicher Priorität) existiert, an die sie > nahtlos anschließt. Andernfalls **stößt** sie (`butt`) an die nächsthöher- > priorisierte Nachbarfläche und endet dort. Praktisch lösen wir das **fläche-gegen-fläche** je Schicht: Für jede Schicht `L` eines Arms suchen wir die **Trimm-Fläche**, die ihr Band beendet. Das ist die **erste** (vom Knoten aus, entlang `dirOut`) der gegenüberliegenden Flächen mit **echt höherer Priorität**; existiert keine, läuft die Schicht bis zur Knoten-Mittelachse durch. ### 5.2 Pro Schicht: Trimm-Fläche finden ``` function resolveLayerCut(arm, layer, junction): LayerCut // Kandidaten: alle Schichten ALLER ANDEREN Arme, deren Band den // Halbraum dieses Layers überlappt (Offset-Überlapp im gemeinsamen // Sektor) UND deren Priorität die Trimm-Entscheidung bestimmt. candidates = [] for other in junction.arms where other.wall.id-end != arm.id-end: for ol in other.layers: if overlapsInSector(layer, ol, arm, other): candidates.push({ other, ol, face: facingFace(ol, towards arm) }) // Höchste am Knoten präsente Priorität (global, für "through"). maxPrio = max over all candidate.ol.priority and layer.priority if layer.priority == maxPrio and hasCollinearSamePrio(arm, layer, junction): // Backbone: läuft durch bis zur Mittel-/Gehrungslinie. return { line: miterMidline(arm, layer, junction), kind: "through" } // Sonst: stoße an die NÄCHSTE Fläche höherer Priorität entlang dirOut. blockers = candidates.filter(c => c.ol.priority > layer.priority) if blockers.empty: // niemand höher → Gehrung mit dem Nachbarn gleicher Stufe (L-Fall) return { line: miterMidline(arm, layer, junction), kind: "miter" } nearest = argmin over blockers of distanceAlong(dirOut, face) return { line: nearest.face, kind: "butt" } ``` Hilfsbegriffe: - `overlapsInSector(layer, ol, …)` — projiziert beide Bänder auf die **Normale des Sektors** und prüft Intervall-Überlapp `[off0,off1] ∩ [off0',off1'] ≠ ∅`. Nur überlappende Bänder können sich gegenseitig trimmen. - `facingFace(ol, towards arm)` — die der `arm`-Seite zugewandte Längsfläche von `ol` (die `faceA`/`faceB` von §3.2), als `Line`. - `distanceAlong(dirOut, face)` — Abstand des Schnittpunkts `faceLine ∩ layerAxis` vom Knoten, gemessen entlang `dirOut`. Negative bzw. hinter dem Knoten liegende Treffer werden verworfen (eine Trimm-Fläche muss **vor** dem Band liegen). - `miterMidline(...)` — die gemeinsame Diagonale für gleichrangige Begegnung; für zwei Arme exakt die heutige `miterLine`. - `hasCollinearSamePrio(...)` — true, wenn auf der „anderen Seite" des Knotens eine Schicht **gleichen Materials/Priorität** existiert, in die das Band nahtlos übergeht (Backbone-Kontinuität, §7). ### 5.3 Resultat in `LayerCut.line` schreiben `resolveLayerCut` liefert für `layers[k]` eine `Line | null`. Diese wird in `WallJoin[end].layerCuts[k]` abgelegt. `clippedBand` kappt damit **dieses eine Band** an genau dieser Linie — alle anderen Schichten derselben Wand können andere Linien (oder `null`) haben. Das ist die ganze Verkabelung zum Renderer. --- ## 6. Master-Pseudocode `computeJoins` (neu) ``` function computeJoins(project, walls): JoinMap result = init each wall → { start:{layerCuts:[square…]}, end:{layerCuts:[square…]} } junctions = buildJunctions(walls) // §3.1 // (Phase 3: junctions += findTeeIncidences(walls)) // §3.3 for J in junctions: if J.arms.length == 1: continue // freies Ende: alles "square" if J.arms.length == 2: resolveL(J, project, result) // bestehende Gehrung, schichtweise else: resolveTorX(J, project, result) // §5, pro Arm pro Schicht return result function resolveTorX(J, project, result): for arm in J.arms: cuts = [] for layer in arm.layers: cuts[layer.index] = resolveLayerCut(arm, layer, J) // §5.2 writeEnd(result, arm.we, cuts) // start oder end function resolveL(J, project, result): [a, b] = J.arms // Wie heute: eine gemeinsame Gehrungslinie pro Sektor; aber wenn die // Dicken/Schichten ungleich sind, kann pro Schicht über §5.2 verfeinert werden. // MVP: eine miterLine für alle Schichten beider Arme (= heutiges Verhalten, // schichtweise dupliziert). line = miterLine(project, a.wall, a.we.end, b.wall) for arm in [a,b]: cuts = arm.layers.map(_ => ({ line, kind:"miter" })) writeEnd(result, arm.we, cuts) ``` So bleibt die **L-Ecke bit-genau wie heute** (Regressionssicherheit), und die neue Logik greift nur bei ≥ 3 Armen. --- ## 7. Prioritäts-Semantik im Detail (das Putz/Beton-Beispiel) ### 7.1 Kanonisches Beispiel Wandtyp „Aussenwand" (außen → innen): | k | Component | thickness | joinPriority | |---|------------|-----------|--------------| | 0 | Putz aussen| 0.02 | 1 | | 1 | Stahlbeton | 0.18 | 9 | | 2 | Putz innen | 0.015 | 1 | Drei solche Wände treffen in einem T-Knoten (zwei kollinear durchgehend „BB", eine ankommend „A"): 1. **Beton (prio 9)** der durchgehenden Wände `BB` läuft durch — beide Beton-Bänder sind kollinear gleicher Priorität → `hasCollinearSamePrio` = true → `through`. Der Beton von `A` (prio 9) **stößt** an die **Betonfläche** von `BB` (gegenüberliegende Fläche, gleiche höchste Priorität, aber kein kollinearer Partner für `A`) → `butt` an Betonaußenfläche von `BB`. 2. **Putz (prio 1)** jeder Wand stößt an die **erste höherpriorisierte Fläche** entlang `dirOut`. Für die Putzschichten von `A` ist das die **Betonfläche von `BB`**: Putz **läuft auf jeder Seite bis zum Beton** und endet dort (`butt`). 3. Putz **überquert nie** den Beton, weil eine `butt`-Trimm-Linie immer die **nächste** höhere Fläche ist — sie liegt vor dem Beton-Durchlauf. Ergebnis exakt wie gefordert: *Beton durch, Putz schließt beidseitig an, Putz kreuzt Beton nicht.* ### 7.2 Warum Priorität pro Component (nicht pro Layer) Beton trifft Beton → gleiche Priorität → nahtlos. Würde Priorität pro Schicht vergeben, müsste man sie pro Wandtyp neu pflegen. `joinPriority` am Component löst das materialweise — Stahlbeton hat **immer** 9, egal in welchem Wandtyp. ### 7.3 Backbone-Erkennung für „through" `hasCollinearSamePrio(arm, layer, junction)`: ``` for other in junction.arms where other != arm: if collinear(arm.dirOut, other.dirOut) (antiparallel, gleiche Achse) and other has a layer ol with ol.priority == layer.priority and bandsAlign(layer, ol): // gleiche Offsets relativ zur gemeinsamen Achse return true return false ``` Nur wenn das Band auf der **gegenüberliegenden** Achse einen passenden Partner gleicher Priorität und Lage hat, läuft es wirklich **durch**. Sonst (z. B. der ankommende Beton von `A`) **stößt** es an — exakt das gewünschte Verhalten. --- ## 8. Layer-Wrapping (Umschlagen niederpriorisierter Schichten) Ein subtiler, aber wichtiger Fall: An einem T-Stoß endet die **innere** Putzschicht der ankommenden Wand `A` an der Betonfläche von `BB`. Damit der Putz **um die Ecke herum sichtbar bleibt** (auf der Innenseite der durchgehenden Wand zieht der innere Putz von `BB` durch), ist nichts Zusätzliches nötig — `BB`s innerer Putz läuft als eigenes durchgehendes Band weiter. Wo Wrapping aktiv nötig ist: wenn eine **niederpriorisierte Außenschicht** an einer Ecke **um eine höhere Schicht herumgeführt** werden soll (z. B. Dämmung, die außen um die Stütze läuft). Modellierung: - Standard (MVP): kein Wrapping — jede Schicht endet an ihrer Trimm-Fläche (`butt`). Das deckt T/X korrekt ab. - Optional (Phase 4): Ein `wrap`-Flag pro Component (`Component.wrapAtEnds?: boolean`). Ist es gesetzt, erzeugt der Generator an der Trimm-Fläche ein **zusätzliches kurzes Stirnband** quer (Offset-Intervall der Schicht, Länge = Tiefe bis zur nächsten Fläche), sodass die Schicht ihre eigene Stirnseite „umschließt". Geometrisch ein weiteres `clippedBand` mit getauschten Achsen. Wrapping ist bewusst **nachgelagert**; es ändert die Trimm-Logik (§5) nicht, sondern fügt nur Zusatzpolygone hinzu. --- ## 9. Anbindung an den Plan-Generator (`generatePlan.addWallPoche`) Heute ruft `addWallPoche` `clippedBand(p1, p2, off, off+thickness, startCut, endCut)` mit **einer** `WallCuts` je Ende. Neu: ```ts // joins: JoinMap (neu) const join = joins.get(wall.id) ?? emptyJoin(wt.layers.length); … let off = -total / 2; wt.layers.forEach((layer, k) => { const startCut = (s <= 1e-6) ? join.start.layerCuts[k].line : null; const endCut = (e >= axisLen - 1e-6) ? join.end.layerCuts[k].line : null; out.push({ kind:"polygon", pts: clippedBand(p1, p2, off, off + layer.thickness, startCut, endCut), fill: comp.color, hatch: resolveHatch(...), … }); off += layer.thickness; }); ``` - Die **Umrisslinie** über die volle Dicke (`-T/2..+T/2`) nutzt für ihre beiden Kanten die Cuts der **äußersten** bzw. **innersten** Schicht — oder wird, falls Schichten unterschiedlich getrimmt sind, durch eine **abgeleitete Outline** (Vereinigung der Schicht-Polygone, §11) ersetzt. MVP: weiterhin volle-Dicke-Band mit den Cuts von Schicht 0 / letzter Schicht. - `grob`-Detailgrad nutzt nur die **Backbone-Schicht** → ihr `through`/`miter`-Cut für die ganze Sammelfläche (entspricht der bestehenden `backboneColor`-Logik). **Keine Signaturänderung an `clippedBand`** — es bleibt der zentrale Trimmer; wir füttern es nur schichtweise mit verschiedenen Linien. --- ## 10. 2D-analytisch jetzt vs. exakte 3D-Booleans später (OCCT) ### 10.1 Jetzt — 2D-analytisch (dieses Design) - **Plan/Grundriss** ist eine reine 2D-Ableitung (vgl. `generatePlan.ts`-Kopf: „nicht durch Zerschneiden eines 3D-Meshes, sondern direkt aus den Parametern"). - Trimmen = **Geraden-Schnitt** (`lineIntersect`) + Polygon-Kappen (`clippedBand`). Kein Polygon-Boolean nötig, solange Schichten als **konvexe Bänder** mit schrägen Stirnflächen modelliert werden. Das ist exakt, schnell (O(Arme² · Schichten) je Knoten), und deterministisch. - **3D-Wand** im Viewport: Extrusion der getrimmten 2D-Schichtpolygone in `z` (Höhe). Damit stimmen Plan und 3D ohne separaten Kernel überein. Grenzen der 2D-Analytik: nicht-konvexe Stirnprofile, echte Materialdurchdringung in `z` (z. B. wenn Wände unterschiedlicher Höhe / Sturz / Brüstung interagieren), Verschneidung Wand × Decke × Stütze. Das braucht echte Volumen-Booleans. ### 10.2 Später — exakte 3D-Booleans (OCCT / opencascade.js) - **Bibliothek:** `opencascade.js` (WASM-Port von OCCT) — `BRepAlgoAPI_Cut/Common`, `BRepPrimAPI_MakePrism` für Extrusionen. Alternativ `manifold-3d` (schneller, robuster für reine Mesh-Booleans, aber ohne B-Rep/Fillets). - **Modell bleibt gleich:** Priorität (`joinPriority`) bestimmt die **Cut- Reihenfolge**. Algorithmus: baue je Schicht einen über-langen Solid (Band × Höhe, über den Knoten hinaus verlängert); subtrahiere von jeder niederpriori- sierten Schicht die Solids **aller höherpriorisierten** Schichten am Knoten (`result = layerSolid − ⋃ higherPrioritySolids`). Gleiche Priorität: kein Cut (Beton bleibt an Beton). Das ist die **3D-Verallgemeinerung exakt derselben Prioritätsregel** — die 2D-`butt`/`through`-Klassifikation aus §5 ist die 2D-Projektion dieser Boolean-Reihenfolge. - Die Datenstrukturen (§2) bleiben unverändert; nur der **Trimmer** wird ausgetauscht (`clippedBand` → OCCT-Cut). Deshalb ist das 2D-Design bereits „OCCT-ready". --- ## 11. Robuste Outline (optional, Phase 4) Wenn Schichten an einem Knoten unterschiedlich getrimmt sind, ist die „volle Dicke"-Umrisslinie nicht mehr ein einfaches Band. Saubere Lösung: die **Wand-Outline** als **Vereinigung aller Schicht-Polygone** berechnen (Polygon-Boolean in 2D). Empfohlene Library: **`polygon-clipping`** (Martinez- Rueda, robust, klein) oder **`@flatten-js/boolean-op`**. Damit wird der Umriss exakt die Außenkontur der getrimmten Bänder — auch bei X-Knoten. MVP verzichtet darauf und nutzt die Schicht-0/N-Cuts (visuell für die meisten Fälle ausreichend). --- ## 12. Edge Cases 1. **Gleiche Priorität, nicht kollinear** (z. B. zwei Betonwände treffen im rechten Winkel im T): kein „through" (kein kollinearer Partner), aber auch kein `butt`-Blocker höherer Priorität → Fallback **`miter`** (gemeinsame Gehrung wie im L-Fall). Bei drei gleichrangigen Armen: paarweise Gehrung pro Sektor; die resultierenden Stirnflächen sind die Sektor-Halbierenden. 2. **Kollinear (180°)** — zwei Wände in einer Linie: `lineIntersect` liefert null (parallel). Behandlung wie heute: **kein Schnitt**, Bänder laufen gerade durch (bei gleichem Typ nahtlos). Bei ungleichem Typ: die schmalere Wand stößt an die breitere; pro Schicht über §5 auflösbar. 3. **> 2 Schichten / asymmetrische Wandtypen**: Der Algorithmus ist in der Schicht- anzahl generisch (`resolveLayerCut` läuft je Schicht-Index). Treffen Wände verschiedener Typen (verschiedene Schichtzahl/-dicke), entscheidet **nur die Priorität pro Fläche**, nicht der Index — deshalb arbeiten wir mit `ArmLayer.faceA/faceB` (Welt-Flächen), nicht mit Schicht-Indizes über Wände hinweg. 4. **Backbone fehlt** (alle Prioritäten gleich): degeneriert sauber zu reinen Gehrungen (Fall 1). 5. **Sehr spitze Winkel**: Trimm-Schnittpunkte können weit vom Knoten wegwandern. Begrenzung: `distanceAlong` auf `≤ maxReach` (z. B. `3 · total`) clampen; sonst rechtwinklig kappen (`square`), um Artefakte zu vermeiden. 6. **Öffnungen am Wandende** (`addWallPoche`-Segmentierung): Cuts gelten nur für das echte Wandende (`s≤ε` / `e≥len−ε`), wie heute. Türnahe Segmentenden bleiben `square`. 7. **Numerische Knoten-Toleranz**: `roundKey`-Gitter (0.1 mm) und ε in `pointOnSegment` müssen konsistent sein, sonst „flackernde" Knoten. Toleranz zentral als Konstante (`JOIN_EPS = 1e-4` m). 8. **Mehr als 2 Wände gleicher Achse** (degenerierter X, alle kollinear): als ein durchgehender Strang behandeln; Prioritätsregel pro Schicht greift normal. --- ## 13. Staged Build-Plan **Phase 1 — Single-Layer T (Fundament).** - `JoinMap`/`WallJoin`/`EndCuts`/`LayerCut` einführen; `WallCuts` darin als Spezialfall abbilden. `clippedBand` unverändert. - `buildJunctions` ohne `length!==2`-Abbruch; L-Fall ruft bestehende `miterLine` (schichtweise dupliziert) → **Regressionsgleichheit** zu heute. - T-Knoten **nur über koinzidente Endpunkte** (§3.3 MVP). Für **einschichtige** Wände: die durchgehende Wand läuft durch, die ankommende stößt rechtwinklig an deren nächste Fläche. Verifizieren: `npx tsc -b`, `npm run build`, `node scripts/probe.mjs`, Screenshot prüfen. **Phase 2 — Prioritäts-Auflösung mehrschichtig (T).** - `ArmLayer`-Profil (§3.2), Sektor-Flächen (§4), `resolveLayerCut` (§5) inkl. `through`/`butt`/`miter`-Klassifikation und `hasCollinearSamePrio`. - `addWallPoche` schichtweise verkabeln (§9). Putz/Beton-Testszene (§7.1) anlegen und visuell prüfen. **Phase 3 — X-Knoten & echte Achs-T-Stöße.** - `findTeeIncidences` (Achs-auf-Achs, §3.3): durchgehende Wand wird nicht geteilt. - 4+-Arm-Sektorlogik vollständig; Edge Cases 1/5/8 absichern. **Phase 4 — Politur.** - Robuste Outline via Polygon-Boolean (§11); optionales Layer-Wrapping (§8, `Component.wrapAtEnds`). **Phase 5 — Exakte 3D-Booleans (OCCT).** - `opencascade.js` integrieren; Trimmer-Interface so abstrahieren, dass 2D (`clippedBand`) und 3D (OCCT-Cut) dieselbe Prioritäts-Reihenfolge nutzen (§10.2). Plan bleibt 2D-analytisch; nur das 3D-Volumen nutzt Booleans. --- ## 14. Trimmer-Abstraktion (für §10.2-Migration) Damit Phase 5 nicht den Aufrufer ändert, kapseln wir das Trimmen hinter einer Schnittstelle. 2D nutzt `clippedBand`; 3D nutzt OCCT — beide konsumieren dieselbe `JoinMap`. ```ts interface LayerTrimmer { /** 2D: getrimmtes Bandpolygon. 3D-Variante liefert stattdessen einen Solid. */ trimBand(p1: Vec2, p2: Vec2, offA: number, offB: number, startCut: Line | null, endCut: Line | null): Vec2[]; } ``` Die Prioritätslogik (`computeJoins`) bleibt der **gemeinsame, kernel-unabhängige** Kopf; nur der `LayerTrimmer` wird ausgetauscht. Das hält das Design dem Architektur-Prinzip treu: ein semantisches Modell, viele abgeleitete Ansichten.