2D-Plan-Qualitaet: saubere Wandecken + glatte Dämmschraffur

Wandecken (generatePlan/glPlanCompile/PlanView): die diagonale Miter-Stirnkante
am L-Stoss wurde als Umriss gestrokt -> Barb-Ueberstand am Aussenapex + 45deg-
Naht zwischen gleichen Schichten. Fix: interne Join-Stirnkanten per neuem
noStrokeEdges vom Umriss ausnehmen (Fuellung bleibt volle Flaeche, laengs
laufende Materialfugen bleiben). Ecke = sauberer Miter ohne Ueberstand, gleiche
Schichten verschmelzen nahtlos ueber die Ecke. GPU- und SVG-Pfad teilen sich
noStrokeEdges.

Dämmschraffur (glPlanHatch): die Sinuswelle wurde pro Segment einzeln aufs
Polygon geclippt und mit Butt-Caps gestrokt -> fransige Fragmente an jeder
Wellenbiegung. Fix: kontinuierliche Punkt-Laeufe (clipPolylineToPolygon) als EIN
gehrter Streifen; Dash laeuft ueber die Laeufe. STEPS 12->22. Gerade Scharen
(diagonal/crosshatch) unveraendert.
This commit is contained in:
2026-07-02 00:54:41 +02:00
parent c96239a794
commit e7ea1eeddd
6 changed files with 582 additions and 114 deletions
+148 -51
View File
@@ -34,6 +34,14 @@ export interface HatchSegment {
b: Vec2;
}
/**
* Ein zusammenhängender Linienzug in Modell-Metern (≥2 Punkte). Gerade
* Musterlinien (diagonal/crosshatch) sind 2-Punkt-Läufe; die Dämmungs-Welle ist
* ein langer, an den Polygonkanten geclippter Lauf. Läufe fließen als EIN gehrter
* Streifen in `strokePolyline` → glatte Übergänge statt Butt-Cap-Stufen.
*/
export type HatchRun = Vec2[];
/**
* Achsen-ausgerichtetes Bounding-Rechteck eines Polygons (Modell-Meter).
* Leeres Polygon → degeneriert (min>max), der Aufrufer erzeugt dann keine Linien.
@@ -151,6 +159,67 @@ export function clipSegmentToPolygon(a: Vec2, b: Vec2, poly: Vec2[]): HatchSegme
return out;
}
/**
* Schneidet eine ZUSAMMENHÄNGENDE Polylinie `pts` gegen ein (auch KONKAVES)
* einfaches Polygon und liefert die innenliegenden Teile als CONTINUOUS runs
* (jeder Lauf = Vec2[] mit ≥2 Punkten). Anders als segmentweises Clippen bleiben
* aufeinanderfolgende Innen-Segmente in EINEM Lauf verbunden — die interne
* Tessellierung (z. B. Sinus-Stützpunkte) wird NICHT an jedem Vertex zerrissen.
* Nur ein echtes Austreten aus dem Polygon (bzw. eine Lücke) beginnt einen neuen
* Lauf. Nutzt dieselbe Even-Odd-/halb-offene-Kanten-Konvention wie
* {@link clipLineToPolygon} via {@link clipSegmentToPolygon}.
*
* Verfahren: jede Polylinien-Kante wird geclippt; die (in Laufrichtung
* sortierten) Innenstücke werden an den laufenden Lauf angehängt, solange ihr
* Anfang bündig ans bisherige Lauf-Ende anschließt. Sobald ein Innenstück nicht
* am Lauf-Ende beginnt (Wiedereintritt nach einem Austritt) oder die Kante gar
* kein Innenstück liefert (komplett außen), wird der Lauf abgeschlossen.
*/
export function clipPolylineToPolygon(pts: Vec2[], poly: Vec2[]): HatchRun[] {
if (pts.length < 2 || poly.length < 3) return [];
const EPS = 1e-9;
const near = (p: Vec2, q: Vec2) => Math.abs(p.x - q.x) < EPS && Math.abs(p.y - q.y) < EPS;
const runs: HatchRun[] = [];
let cur: HatchRun | null = null;
const closeRun = () => {
if (cur && cur.length >= 2) runs.push(cur);
cur = null;
};
for (let i = 0; i + 1 < pts.length; i++) {
const a = pts[i];
const b = pts[i + 1];
// Innenstücke dieser Kante, in Laufrichtung a→b sortiert (clipSegment liefert
// sie bereits entlang der Trägergeraden geordnet; a→b ist die positive Richtung).
const pieces = clipSegmentToPolygon(a, b, poly);
for (const seg of pieces) {
// Kante in Laufrichtung orientieren (clipSegment kann a/b vertauschen).
const dx = b.x - a.x, dy = b.y - a.y;
const ta = (seg.a.x - a.x) * dx + (seg.a.y - a.y) * dy;
const tb = (seg.b.x - a.x) * dx + (seg.b.y - a.y) * dy;
const s = ta <= tb ? seg.a : seg.b;
const e = ta <= tb ? seg.b : seg.a;
if (cur && near(cur[cur.length - 1], s)) {
// Nahtloser Anschluss ans Lauf-Ende → nur den neuen Endpunkt anhängen.
cur.push(e);
} else {
// Neuer Lauf (erster Innenteil oder Wiedereintritt nach Austritt).
closeRun();
cur = [s, e];
}
}
// Endete diese Kante NICHT genau am Polygonrand-Innenstück bis b (d. h. das
// letzte Innenstück reicht nicht bis zum Kantenende b), so tritt der Lauf hier
// aus dem Polygon → beim nächsten Innenteil beginnt ein neuer Lauf.
if (cur && !near(cur[cur.length - 1], b)) closeRun();
}
closeRun();
return runs;
}
/**
* Erzeugt eine Schar paralleler, aufs Polygon geclippter Linien-Segmente:
* Richtung `angleRad` (Modell-Raum, gegen den Uhrzeigersinn), Abstand `spacingM`
@@ -209,43 +278,68 @@ function rotate(v: Vec2, rad: number): Vec2 {
const DASH_MM_TO_M = (1 / 0.13) * PX_TO_M;
/**
* Zerlegt eine Segment-Schar entlang eines Strichmusters (`dash`, in mm-Papier)
* in die durchgezogenen Teilstücke. `dash` ist eine Sequenz [an, aus, an, …];
* ungerade Längen werden (wie SVG/Canvas) verdoppelt. Leeres/ungültiges Muster
* → Segmente unverändert (durchgezogen). Die Phase läuft je Segment neu bei 0
* (Segmente sind bereits kurze Musterlinien-Stücke → sichtbar konsistent).
* Zerlegt zusammenhängende Läufe entlang eines Strichmusters (`dash`, in mm-
* Papier) in die durchgezogenen Teil-Läufe. `dash` ist eine Sequenz
* [an, aus, an, …]; ungerade Längen werden (wie SVG/Canvas) verdoppelt. Leeres/
* ungültiges Muster → Läufe unverändert (durchgezogen). Die Phase läuft ENTLANG
* DES GESAMTEN LAUFS durch (über Stützpunkt-Ecken hinweg), sodass die Striche
* einer gekrümmten Musterlinie (Dämmungswelle) gleichmäßig und ecken-übergreifend
* verteilt sind. Jedes „an"-Stück wird als eigener (kurzer) Lauf ausgegeben und
* kann selbst über mehrere Stützpunkt-Segmente reichen.
*/
export function applyDash(segs: HatchSegment[], dash: number[] | null): HatchSegment[] {
if (!dash || dash.length === 0) return segs;
export function applyDashRuns(runs: HatchRun[], dash: number[] | null): HatchRun[] {
if (!dash || dash.length === 0) return runs;
// Musterlängen in Meter; nicht-positive/ungültige Werte verwerfen.
const pat = dash.map((d) => Math.max(0, d) * DASH_MM_TO_M).filter((d) => d > 0);
if (pat.length === 0) return segs;
if (pat.length === 0) return runs;
// Ungerade Länge → verdoppeln (Standard-Dash-Semantik).
const cycle = pat.length % 2 === 0 ? pat : pat.concat(pat);
const total = cycle.reduce((s, d) => s + d, 0);
if (total <= 1e-9) return segs;
if (total <= 1e-9) return runs;
const out: HatchSegment[] = [];
for (const seg of segs) {
const dx = seg.b.x - seg.a.x;
const dy = seg.b.y - seg.a.y;
const len = Math.hypot(dx, dy);
if (len < 1e-12) continue;
const ux = dx / len, uy = dy / len;
let pos = 0;
const out: HatchRun[] = [];
for (const run of runs) {
if (run.length < 2) continue;
// Bogenlänge je Stützpunkt vorab, um Dash-Positionen auf Segmente abzubilden.
let idx = 0; // gerade Index = „an", ungerade = „aus"
while (pos < len - 1e-9) {
const remain = cycle[idx % cycle.length];
const end = Math.min(len, pos + remain);
if (idx % 2 === 0 && end - pos > 1e-9) {
out.push({
a: { x: seg.a.x + ux * pos, y: seg.a.y + uy * pos },
b: { x: seg.a.x + ux * end, y: seg.a.y + uy * end },
});
let dashLeft = cycle[0]; // verbleibende Länge im aktuellen Dash-Zustand
let on = true;
let piece: Vec2[] | null = [run[0]]; // aktuell offenes „an"-Stück
const flush = () => {
if (piece && piece.length >= 2) out.push(piece);
piece = null;
};
for (let i = 0; i + 1 < run.length; i++) {
let a = run[i];
const b = run[i + 1];
let segLen = Math.hypot(b.x - a.x, b.y - a.y);
if (segLen < 1e-12) continue;
let ux = (b.x - a.x) / segLen, uy = (b.y - a.y) / segLen;
// Segment ggf. an Dash-Grenzen zerschneiden.
while (segLen > 1e-9) {
const step = Math.min(segLen, dashLeft);
const mid: Vec2 = { x: a.x + ux * step, y: a.y + uy * step };
if (on) {
if (!piece) piece = [a];
piece.push(mid);
}
// Vorrücken.
a = mid;
segLen -= step;
dashLeft -= step;
if (dashLeft <= 1e-9) {
// Dash-Zustand wechseln.
if (on) flush();
on = !on;
idx++;
dashLeft = cycle[idx % cycle.length];
if (on) piece = [a]; // neues „an"-Stück beginnt hier
}
}
pos = end;
idx++;
}
flush();
}
return out;
}
@@ -262,19 +356,21 @@ function toModelAngleRad(screenDeg: number): number {
}
/**
* Baut die Schraffur eines Polygons als Linien-Segmente (Modell-Meter). Bildet
* die SVG-`<pattern>`-Geometrie 1:1 nach:
* Baut die Schraffur eines Polygons als zusammenhängende Läufe ({@link HatchRun},
* Modell-Meter). Bildet die SVG-`<pattern>`-Geometrie 1:1 nach:
* • diagonal — eine Schar; SVG-Kachel 8·scale px mit senkrechter Musterlinie
* (x-konstant), gedreht um `angle`. Abstand = 8·scale px.
* (x-konstant), gedreht um `angle`. Abstand = 8·scale px. Jede
* Musterlinie ist ein gerader 2-Punkt-Lauf.
* • crosshatch — zwei Scharen (90° versetzt), gleicher Abstand.
* • insulation — weiche Wellenlinie, Kachel 14×10·scale px, gedreht um `angle`;
* als segmentierte Polylinie tesselliert und geclippt.
* als DURCHGEHENDE Polylinie tesselliert und in kontinuierliche
* Läufe geclippt (glatte gehrte Übergänge statt Butt-Cap-Stufen).
* • solid/none — keine Linien (Vollfüllung/Umriss übernimmt der Aufrufer).
*
* Skala 0/negativ wird auf 1 geklemmt (kein Division-durch-Null, keine leere
* Kachel), damit degeneriert konfigurierte Hatches trotzdem sichtbar bleiben.
*/
export function buildHatchSegments(poly: Vec2[], hatch: HatchRender): HatchSegment[] {
export function buildHatchRuns(poly: Vec2[], hatch: HatchRender): HatchRun[] {
if (poly.length < 3) return [];
const pattern = hatch.pattern;
if (pattern === 'none' || pattern === 'solid') return [];
@@ -282,7 +378,7 @@ export function buildHatchSegments(poly: Vec2[], hatch: HatchRender): HatchSegme
const scale = hatch.scale > 1e-6 ? hatch.scale : 1;
if (pattern === 'insulation') {
return insulationSegments(poly, hatch, scale);
return insulationRuns(poly, hatch, scale);
}
// diagonal / crosshatch: parallele Geradenscharen.
@@ -296,14 +392,15 @@ export function buildHatchSegments(poly: Vec2[], hatch: HatchRender): HatchSegme
const rot = toModelAngleRad(hatch.angle);
const dir1 = rotate({ x: 0, y: -1 }, rot);
const a1 = Math.atan2(dir1.y, dir1.x);
const segs = hatchLineFamily(poly, a1, spacingM, 0);
// Gerade Musterlinien → 2-Punkt-Läufe (visuell identisch zu Butt-Cap-Segmenten).
const runs: HatchRun[] = hatchLineFamily(poly, a1, spacingM, 0).map((s) => [s.a, s.b]);
if (pattern === 'crosshatch') {
// Zweite Schar: die SVG-Kachel trägt zusätzlich eine horizontale Linie
// (x-Achse der Kachel) → 90° zur ersten, gleicher Abstand.
for (const s of hatchLineFamily(poly, a1 + Math.PI / 2, spacingM, 0)) segs.push(s);
for (const s of hatchLineFamily(poly, a1 + Math.PI / 2, spacingM, 0)) runs.push([s.a, s.b]);
}
return segs;
return runs;
}
/**
@@ -311,11 +408,13 @@ export function buildHatchSegments(poly: Vec2[], hatch: HatchRender): HatchSegme
* pro Kachel eine weiche Bézier-Welle. Wir bilden sie als Scharwellen nach: eine
* Schar paralleler „Musterachsen" im Abstand der Kachelhöhe (10·scale px), auf
* jeder Achse eine tessellierte Sinuswelle mit Wellenlänge = Kachelbreite
* (14·scale px) und weicher Amplitude. Jede Wellen-Polylinie wird Segment für
* Segment aufs Polygon geclippt (even-odd), sodass die Welle exakt an den
* Polygonkanten endet.
* (14·scale px) und weicher Amplitude. Jede Wellen-Polylinie wird als
* DURCHGEHENDER Zug aufs Polygon geclippt ({@link clipPolylineToPolygon}), sodass
* benachbarte Wellen-Stützpunkte in EINEM Lauf verbunden bleiben. Der Compiler
* strokt den Lauf als gehrten Streifen → glatte, spitzenfreie Welle. Die Welle
* endet exakt an den Polygonkanten (even-odd-Clipping, konkav-fähig).
*/
function insulationSegments(poly: Vec2[], hatch: HatchRender, scale: number): HatchSegment[] {
function insulationRuns(poly: Vec2[], hatch: HatchRender, scale: number): HatchRun[] {
const bb = bbox(poly);
const rot = toModelAngleRad(hatch.angle);
@@ -341,11 +440,12 @@ function insulationSegments(poly: Vec2[], hatch: HatchRender, scale: number): Ha
}
if (!(uMax > uMin) || !(vMax > vMin)) return [];
// Feinheit der Wellen-Tessellierung (Segmente je Wellenlänge).
const STEPS = 12;
// Feinheit der Wellen-Tessellierung (Segmente je Wellenlänge). Höher als beim
// segmentweisen Ansatz, da die Läufe jetzt glatt gehrt werden → weichere Welle.
const STEPS = 22;
const du = wavelength / STEPS;
const out: HatchSegment[] = [];
const runs: HatchRun[] = [];
const toWorld = (u: number, v: number): Vec2 => ({
x: center.x + uDir.x * u + vDir.x * v,
y: center.y + uDir.y * u + vDir.y * v,
@@ -358,16 +458,13 @@ function insulationSegments(poly: Vec2[], hatch: HatchRender, scale: number): Ha
for (let r = firstRow; r <= lastRow; r++) {
const vBase = r * rowGap;
// Eine Welle als Polylinie tessellieren, dann JEDES Segment clippen.
let prev: Vec2 | null = null;
// Ganze Welle als EINE Polylinie tessellieren, dann DURCHGEHEND clippen.
const wavePts: Vec2[] = [];
for (let u = uStart; u <= uEnd + 1e-9; u += du) {
const wave = amplitude * Math.sin((2 * Math.PI * u) / wavelength);
const pt = toWorld(u, vBase + wave);
if (prev) {
for (const s of clipSegmentToPolygon(prev, pt, poly)) out.push(s);
}
prev = pt;
wavePts.push(toWorld(u, vBase + wave));
}
for (const run of clipPolylineToPolygon(wavePts, poly)) runs.push(run);
}
return out;
return runs;
}