Files
karim ca859c4aa4 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.
2026-06-30 20:52:27 +02:00

28 KiB
Raw Permalink Blame History

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) 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)

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 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 — 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 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:

// 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.

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.