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:
@@ -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.
|
||||
Reference in New Issue
Block a user