Browser-BIM (cad): semantisches Modell, abgeleitete 2D/3D-Sichten, Zeichenwerkzeuge

Standalone-Browser-Port von DOSSIER. Enthaelt das semantische Modell mit
Plan-/3D-Ableitung, Zeichen- und Editierwerkzeuge, Rhino-artiges Befehlssystem,
dockbares Panel-System, Resource-Manager, DXF/.lin/.pat-Import, i18n (de/en)
sowie Projektdokumentation und Probe-Harness.
This commit is contained in:
2026-06-30 20:52:27 +02:00
commit ca859c4aa4
157 changed files with 37921 additions and 0 deletions
+636
View File
@@ -0,0 +1,636 @@
# 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<string /*wallId*/, WallJoin>;
```
**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<key, WallEnd[]>
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.