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.
28 KiB
Design — Prioritätsbasierte mehrschichtige Wand-Verschneidung (T/X)
Teil der Standalone-Architektur — siehe ../../ARCHITECTURE.md. Aufbauend auf der bestehenden L-Ecken-Gehrung in
src/model/joins.ts(computeJoins,miterLine) undsrc/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.pyist in dieser Umgebung nicht vorhanden (das einzige auffindbaredossierist 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"). Sobaldwand_grips.pyverfü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
nläuft von-T/2(linke/„außen"-Kante) nach+T/2(rechte/„innen"-Kante). Eine Schichtkbelegt das Intervall[off_k, off_k + thickness_k]mitoff_0 = -T/2. - Eine unendliche Gerade ist
Line { point, dir }; Schnittpunkt zweier Geraden vialineIntersect(a, da, b, db)(null bei parallel). - Ein Schicht-Band zwischen Offsets
offA, offBwird überclippedBand(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)
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:
// 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
/** 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 derarm-Seite zugewandte Längsfläche vonol(diefaceA/faceBvon §3.2), alsLine.distanceAlong(dirOut, face)— Abstand des SchnittpunktsfaceLine ∩ layerAxisvom Knoten, gemessen entlangdirOut. 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 heutigemiterLine.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"):
- Beton (prio 9) der durchgehenden Wände
BBläuft durch — beide Beton-Bänder sind kollinear gleicher Priorität →hasCollinearSamePrio= true →through. Der Beton vonA(prio 9) stößt an die Betonfläche vonBB(gegenüberliegende Fläche, gleiche höchste Priorität, aber kein kollinearer Partner fürA) →buttan Betonaußenfläche vonBB. - Putz (prio 1) jeder Wand stößt an die erste höherpriorisierte Fläche
entlang
dirOut. Für die Putzschichten vonAist das die Betonfläche vonBB: Putz läuft auf jeder Seite bis zum Beton und endet dort (butt). - 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 — BBs 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 weiteresclippedBandmit 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:
// 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 → ihrthrough/miter-Cut für die ganze Sammelfläche (entspricht der bestehendenbackboneColor-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_MakePrismfür Extrusionen. Alternativmanifold-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
- 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 → Fallbackmiter(gemeinsame Gehrung wie im L-Fall). Bei drei gleichrangigen Armen: paarweise Gehrung pro Sektor; die resultierenden Stirnflächen sind die Sektor-Halbierenden. - Kollinear (180°) — zwei Wände in einer Linie:
lineIntersectliefert 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. - > 2 Schichten / asymmetrische Wandtypen: Der Algorithmus ist in der Schicht-
anzahl generisch (
resolveLayerCutlä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 mitArmLayer.faceA/faceB(Welt-Flächen), nicht mit Schicht-Indizes über Wände hinweg. - Backbone fehlt (alle Prioritäten gleich): degeneriert sauber zu reinen Gehrungen (Fall 1).
- Sehr spitze Winkel: Trimm-Schnittpunkte können weit vom Knoten wegwandern.
Begrenzung:
distanceAlongauf≤ maxReach(z. B.3 · total) clampen; sonst rechtwinklig kappen (square), um Artefakte zu vermeiden. - Öffnungen am Wandende (
addWallPoche-Segmentierung): Cuts gelten nur für das echte Wandende (s≤ε/e≥len−ε), wie heute. Türnahe Segmentenden bleibensquare. - Numerische Knoten-Toleranz:
roundKey-Gitter (0.1 mm) und ε inpointOnSegmentmüssen konsistent sein, sonst „flackernde" Knoten. Toleranz zentral als Konstante (JOIN_EPS = 1e-4m). - 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/LayerCuteinführen;WallCutsdarin als Spezialfall abbilden.clippedBandunverändert.buildJunctionsohnelength!==2-Abbruch; L-Fall ruft bestehendemiterLine(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 undhasCollinearSamePrio.addWallPocheschichtweise 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.jsintegrieren; 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.
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.