ca859c4aa4
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.
637 lines
28 KiB
Markdown
637 lines
28 KiB
Markdown
# 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.
|