23410f9b9e
Sechs Lücken geschlossen: Text-Massstab vom Geometrie-Massstab entkoppelt (set_text_scale, spiegelt SVG-Referenzskala); glyphon auf ColorMode::Web (Text war linear-konvertiert zu dunkel); z-basierte Maler-Reihenfolge (interleavte draw_sequence statt fills-vor-lines, Alt-Szenen unverändert); CSS-Klassenfarben/-Opacities in toRenderScene gespiegelt (Türschwenk etc.); greyed-Dimmung 0.3 auf allen Primitiven; Dämmschraffur am Modell-Ursprung verankert (userSpaceOnUse), exakte Bézier-Wellenform statt Sinus und kachelgekoppelte Strichbreite (widthScreen-Modus). Probe scripts/probe-engine-parity.mjs vergleicht ?gl=0 gegen ?engine=wasm; Rest-Diff nur AA/Glyphen-Rasterung. cargo 17/17, vitest 94/94, Builds grün.
489 lines
21 KiB
TypeScript
489 lines
21 KiB
TypeScript
/**
|
||
* Schraffur-Tessellierung für den GPU-Grundriss: wandelt ein Polygon mit
|
||
* aufgelöster {@link HatchRender} in eine Schar echter Linien-Segmente (Modell-
|
||
* Meter), die anschließend über `strokePolyline`/`pushLine` in die Linien-Puffer
|
||
* fließen. Damit rendert die GPU-Ebene dieselben Muster wie der SVG-`<pattern>`-
|
||
* Renderer, nur als tessellierte Geometrie statt als Kachel-Füllung.
|
||
*
|
||
* KOORDINATEN & MASSSTAB (muss deckungsgleich zum SVG sein):
|
||
* • Der SVG-`<pattern>` ist in BILDSCHIRM-Raum (viewBox-px, `sx = mx·PX_PER_M`)
|
||
* definiert; Kachelgröße = `8·scale` px (diagonal/cross) bzw. `14×10·scale` px
|
||
* (insulation), gedreht per `rotate(angle)` (im Uhrzeigersinn, Bildschirm-Y
|
||
* nach unten). Die Muster-GEOMETRIE skaliert also mit dem Zoom (Papier).
|
||
* • Wir erzeugen die Muster direkt in MODELL-Metern: ein px in Bildschirm-Raum
|
||
* entspricht `1/PX_PER_M` m. So passen Abstand/Winkel exakt zum SVG, und die
|
||
* Segmente lassen sich unverändert an `strokePolyline` (Papier-mm-Breite)
|
||
* übergeben.
|
||
* • Die STRICHBREITE bleibt in Papier-mm (`hatch.lineWeight`) — genau wie beim
|
||
* SVG-Muster, das `hatchStrokePx(lineWeight)` als non-scaling-artige Breite
|
||
* zeichnet.
|
||
*/
|
||
|
||
import type { Vec2 } from '../../model/types';
|
||
import type { HatchRender } from '../generatePlan';
|
||
|
||
/** Bildschirm-Einheiten (viewBox-px) je Meter — identisch zu PlanView.toScreen. */
|
||
const PX_PER_M = 90;
|
||
|
||
/** px (Bildschirm-Raum) → Meter (Modell-Raum). */
|
||
const PX_TO_M = 1 / PX_PER_M;
|
||
|
||
/** Ein Linien-Segment in Modell-Metern. */
|
||
export interface HatchSegment {
|
||
a: Vec2;
|
||
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.
|
||
*/
|
||
function bbox(poly: Vec2[]): { minX: number; minY: number; maxX: number; maxY: number } {
|
||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||
for (const p of poly) {
|
||
if (p.x < minX) minX = p.x;
|
||
if (p.y < minY) minY = p.y;
|
||
if (p.x > maxX) maxX = p.x;
|
||
if (p.y > maxY) maxY = p.y;
|
||
}
|
||
return { minX, minY, maxX, maxY };
|
||
}
|
||
|
||
/**
|
||
* Schneidet eine UNENDLICHE Linie (Aufpunkt `p0`, Einheits-Richtung `dir`) mit
|
||
* einem einfachen (auch KONKAVEN) Polygon und liefert die INNERHALB liegenden
|
||
* Segmente. Vorgehen (robustes Even-Odd-Scanline-Prinzip):
|
||
* 1. Für jede Polygon-Kante den Schnitt-Parameter `t` (entlang `dir`) berechnen,
|
||
* bei dem die Linie die Kante quert.
|
||
* 2. Alle `t` sortieren; aufeinanderfolgende Paare (t0,t1),(t2,t3),… liegen im
|
||
* Inneren (even-odd) → als Segmente zurückgeben.
|
||
* Das ist für beliebige konkave Polygone korrekt, weil eine Gerade ein einfaches
|
||
* Polygon stets in eine gerade Anzahl Kanten schneidet und die Innen-Intervalle
|
||
* sich mit jeder Kreuzung ein-/ausschalten.
|
||
*
|
||
* Robustheit an Ecken: Ein Schnitt wird nur gezählt, wenn die Linie die Kante
|
||
* ECHT quert (die beiden Endpunkte liegen auf verschiedenen Seiten). Berührungen
|
||
* genau am Eckpunkt werden über eine halb-offene Kanten-Konvention (das untere
|
||
* Kanten-Ende zählt, das obere nicht) vermieden, sodass keine doppelten oder
|
||
* fehlenden Kreuzungen entstehen.
|
||
*/
|
||
export function clipLineToPolygon(p0: Vec2, dir: Vec2, poly: Vec2[]): HatchSegment[] {
|
||
const n = poly.length;
|
||
if (n < 3) return [];
|
||
|
||
// Senkrechte zur Richtung: side(p) = vorzeichenbehaftete Distanz zur Linie.
|
||
const nx = -dir.y;
|
||
const ny = dir.x;
|
||
const side = (p: Vec2) => (p.x - p0.x) * nx + (p.y - p0.y) * ny;
|
||
|
||
const ts: number[] = [];
|
||
for (let i = 0; i < n; i++) {
|
||
const a = poly[i];
|
||
const b = poly[(i + 1) % n];
|
||
const sa = side(a);
|
||
const sb = side(b);
|
||
|
||
// Beide echt auf derselben Seite → keine Kreuzung.
|
||
if (sa > 0 && sb > 0) continue;
|
||
if (sa < 0 && sb < 0) continue;
|
||
|
||
// Halb-offene Konvention gegen Doppel-/Fehlzählung an Eckpunkten:
|
||
// • Kante liegt exakt AUF der Linie → ignorieren.
|
||
// • Berührung genau an einem Endpunkt (side==0) zählt nur, wenn der ANDERE
|
||
// Endpunkt echt oberhalb (>0) liegt. So gehört ein Eckpunkt eindeutig zur
|
||
// Kante darunter und wird genau EINMAL gezählt.
|
||
if (sa === 0 && sb === 0) continue;
|
||
if (sa === 0 && sb < 0) continue;
|
||
if (sb === 0 && sa < 0) continue;
|
||
|
||
// Parameter `t` entlang `dir` am Schnittpunkt: Nullstelle von side auf der
|
||
// Kante interpolieren, dann auf `dir` projizieren.
|
||
const denom = sa - sb;
|
||
const u = denom !== 0 ? sa / denom : 0; // Anteil a→b bis side==0
|
||
const ix = a.x + (b.x - a.x) * u;
|
||
const iy = a.y + (b.y - a.y) * u;
|
||
const t = (ix - p0.x) * dir.x + (iy - p0.y) * dir.y;
|
||
ts.push(t);
|
||
}
|
||
|
||
if (ts.length < 2) return [];
|
||
ts.sort((x, y) => x - y);
|
||
|
||
const segs: HatchSegment[] = [];
|
||
for (let i = 0; i + 1 < ts.length; i += 2) {
|
||
const t0 = ts[i];
|
||
const t1 = ts[i + 1];
|
||
if (t1 - t0 < 1e-9) continue; // entartetes Intervall (Tangente an Ecke)
|
||
segs.push({
|
||
a: { x: p0.x + dir.x * t0, y: p0.y + dir.y * t0 },
|
||
b: { x: p0.x + dir.x * t1, y: p0.y + dir.y * t1 },
|
||
});
|
||
}
|
||
return segs;
|
||
}
|
||
|
||
/**
|
||
* Schneidet ein ENDLICHES Segment [a,b] gegen ein (konkaves) Polygon und liefert
|
||
* die innenliegenden Teilstücke. Baut auf {@link clipLineToPolygon} auf: erst die
|
||
* Innen-Intervalle der Trägergeraden bestimmen, dann mit [a,b] (Parameterbereich
|
||
* [0,len]) verschneiden.
|
||
*/
|
||
export function clipSegmentToPolygon(a: Vec2, b: Vec2, poly: Vec2[]): HatchSegment[] {
|
||
const dx = b.x - a.x;
|
||
const dy = b.y - a.y;
|
||
const len = Math.hypot(dx, dy);
|
||
if (len < 1e-12) return [];
|
||
const dir: Vec2 = { x: dx / len, y: dy / len };
|
||
const full = clipLineToPolygon(a, dir, poly);
|
||
const out: HatchSegment[] = [];
|
||
for (const seg of full) {
|
||
// Parameter der Innen-Intervall-Enden entlang `dir` (ab Aufpunkt a).
|
||
const t0 = (seg.a.x - a.x) * dir.x + (seg.a.y - a.y) * dir.y;
|
||
const t1 = (seg.b.x - a.x) * dir.x + (seg.b.y - a.y) * dir.y;
|
||
const lo = Math.max(0, Math.min(t0, t1));
|
||
const hi = Math.min(len, Math.max(t0, t1));
|
||
if (hi - lo < 1e-9) continue;
|
||
out.push({
|
||
a: { x: a.x + dir.x * lo, y: a.y + dir.y * lo },
|
||
b: { x: a.x + dir.x * hi, y: a.y + dir.y * hi },
|
||
});
|
||
}
|
||
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`
|
||
* (Meter) senkrecht zur Richtung, Phasen-Versatz `offsetM` (Meter, entlang der
|
||
* Normalen). Deckt das Polygon vollständig ab (über dessen projizierte Ausdehnung).
|
||
*/
|
||
export function hatchLineFamily(
|
||
poly: Vec2[],
|
||
angleRad: number,
|
||
spacingM: number,
|
||
offsetM = 0,
|
||
): HatchSegment[] {
|
||
if (poly.length < 3 || spacingM <= 1e-9) return [];
|
||
const bb = bbox(poly);
|
||
if (!(bb.maxX > bb.minX) && !(bb.maxY > bb.minY)) return [];
|
||
|
||
const dir: Vec2 = { x: Math.cos(angleRad), y: Math.sin(angleRad) };
|
||
const nrm: Vec2 = { x: -dir.y, y: dir.x }; // Linien-Normale (Scan-Richtung)
|
||
|
||
// Rechtwinklige Projektion aller Ecken auf die Normale → Abdeckungsbereich.
|
||
// ANKER = MODELL-URSPRUNG (nicht die Polygon-Mitte): der SVG-`<pattern>` ist
|
||
// `userSpaceOnUse`, sein Kachelgitter liegt global im viewBox-Raum fest
|
||
// (Ursprung (0,0) = Modell-Ursprung). Nur mit demselben Anker liegen die
|
||
// Musterlinien deckungsgleich zum SVG-Referenzpfad — und benachbarte Polygone
|
||
// teilen dieselbe Muster-Phase statt je eigener (bbox-abhängiger) Versätze.
|
||
const center: Vec2 = { x: 0, y: 0 };
|
||
let dMin = Infinity, dMax = -Infinity;
|
||
for (const p of poly) {
|
||
const d = (p.x - center.x) * nrm.x + (p.y - center.y) * nrm.y;
|
||
if (d < dMin) dMin = d;
|
||
if (d > dMax) dMax = d;
|
||
}
|
||
|
||
const segs: HatchSegment[] = [];
|
||
// Raster so wählen, dass es den Bereich (inkl. Versatz) sicher deckt.
|
||
const first = Math.floor((dMin - offsetM) / spacingM) - 1;
|
||
const last = Math.ceil((dMax - offsetM) / spacingM) + 1;
|
||
for (let k = first; k <= last; k++) {
|
||
const d = offsetM + k * spacingM;
|
||
const p0: Vec2 = { x: center.x + nrm.x * d, y: center.y + nrm.y * d };
|
||
for (const s of clipLineToPolygon(p0, dir, poly)) segs.push(s);
|
||
}
|
||
return segs;
|
||
}
|
||
|
||
/** 2D-Vektor um `rad` drehen (gegen den Uhrzeigersinn, Modell-Konvention). */
|
||
function rotate(v: Vec2, rad: number): Vec2 {
|
||
const c = Math.cos(rad), s = Math.sin(rad);
|
||
return { x: v.x * c - v.y * s, y: v.x * s + v.y * c };
|
||
}
|
||
|
||
/**
|
||
* Faktor mm-Papier → px-Musterraum, identisch zum SVG-`dashArray` (dort
|
||
* `dash·(1/0.13)`). Ein 0.13-mm-Strich entspricht ~1 px Kachelmaß; dividiert man
|
||
* mit `PX_TO_M`, ergeben sich die Strichlängen in Modell-Metern (skalieren mit
|
||
* dem Zoom wie die Kachel selbst — genau wie die SVG-Schraffur).
|
||
*/
|
||
const DASH_MM_TO_M = (1 / 0.13) * PX_TO_M;
|
||
|
||
/**
|
||
* 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 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 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 runs;
|
||
|
||
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"
|
||
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
|
||
}
|
||
}
|
||
}
|
||
flush();
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* SVG-`rotate(angleDeg)` (im Uhrzeigersinn, Bildschirm-Y nach unten) → Winkel im
|
||
* MODELL-Raum (gegen den Uhrzeigersinn, Modell-Y nach oben). Da die Bildschirm-
|
||
* Abbildung Y spiegelt (`sy=-my`), kehrt sich der Drehsinn um: eine im Bildschirm
|
||
* um +angle (CW) gedrehte Musterrichtung entspricht im Modell −angle relativ zur
|
||
* gewählten Basisrichtung.
|
||
*/
|
||
function toModelAngleRad(screenDeg: number): number {
|
||
return (-screenDeg * Math.PI) / 180;
|
||
}
|
||
|
||
/**
|
||
* 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. 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 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 buildHatchRuns(poly: Vec2[], hatch: HatchRender): HatchRun[] {
|
||
if (poly.length < 3) return [];
|
||
const pattern = hatch.pattern;
|
||
if (pattern === 'none' || pattern === 'solid') return [];
|
||
|
||
const scale = hatch.scale > 1e-6 ? hatch.scale : 1;
|
||
|
||
if (pattern === 'insulation') {
|
||
return insulationRuns(poly, hatch, scale);
|
||
}
|
||
|
||
// diagonal / crosshatch: parallele Geradenscharen.
|
||
// Die SVG-Kachel ist 8·scale px; die Musterlinie läuft senkrecht (y-Achse der
|
||
// Kachel), Abstand also 8·scale px in x. In Metern: (8·scale)/PX_PER_M.
|
||
const spacingM = 8 * scale * PX_TO_M;
|
||
|
||
// Kachel-Basisrichtung: die Linie läuft in der SVG-Kachel entlang +Y
|
||
// (Bildschirm, nach unten). Im Modell (Y gespiegelt) entspricht das (0,-1).
|
||
// Zusätzlich um `angle` (SVG-CW) gedreht → Modell-CCW um −angle.
|
||
const rot = toModelAngleRad(hatch.angle);
|
||
const dir1 = rotate({ x: 0, y: -1 }, rot);
|
||
const a1 = Math.atan2(dir1.y, dir1.x);
|
||
// 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)) runs.push([s.a, s.b]);
|
||
}
|
||
return runs;
|
||
}
|
||
|
||
/**
|
||
* Dämmungs-Schraffur (Wellenlinie): die SVG-Kachel ist 14×10·scale px und trägt
|
||
* 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 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 insulationRuns(poly: Vec2[], hatch: HatchRender, scale: number): HatchRun[] {
|
||
const rot = toModelAngleRad(hatch.angle);
|
||
|
||
// Muster-Parameter in Metern (aus den SVG-px-Werten der Kachel).
|
||
const wavelength = 14 * scale * PX_TO_M; // Kachelbreite
|
||
const rowGap = 10 * scale * PX_TO_M; // Kachelhöhe → Zeilenabstand
|
||
|
||
// Lokales Muster-Koordinatensystem: u entlang der Welle, v quer (Zeilen).
|
||
// ANKER = MODELL-URSPRUNG, wie beim SVG-`userSpaceOnUse`-Pattern (siehe
|
||
// hatchLineFamily): so liegen Wellen-Phase UND Zeilenraster deckungsgleich
|
||
// zum SVG-Referenzpfad und über alle Polygone hinweg in EINEM Gitter.
|
||
const uDir = rotate({ x: 1, y: 0 }, rot);
|
||
const vDir = rotate({ x: 0, y: 1 }, rot);
|
||
|
||
// Abdeckungsbereich in (u,v) über die Polygon-Ecken bestimmen.
|
||
let uMin = Infinity, uMax = -Infinity, vMin = Infinity, vMax = -Infinity;
|
||
for (const p of poly) {
|
||
const u = p.x * uDir.x + p.y * uDir.y;
|
||
const v = p.x * vDir.x + p.y * vDir.y;
|
||
if (u < uMin) uMin = u;
|
||
if (u > uMax) uMax = u;
|
||
if (v < vMin) vMin = v;
|
||
if (v > vMax) vMax = v;
|
||
}
|
||
if (!(uMax > uMin) || !(vMax > vMin)) return [];
|
||
|
||
// 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 runs: HatchRun[] = [];
|
||
const toWorld = (u: number, v: number): Vec2 => ({
|
||
x: uDir.x * u + vDir.x * v,
|
||
y: uDir.y * u + vDir.y * v,
|
||
});
|
||
|
||
// Die Wellen-Mittellinie liegt in der SVG-Kachel bei y = h/2 (my = 5·scale),
|
||
// die Zeilen also auf HALBEN Rasterlinien: v = (r + 0.5)·rowGap.
|
||
const firstRow = Math.floor(vMin / rowGap - 0.5) - 1;
|
||
const lastRow = Math.ceil(vMax / rowGap - 0.5) + 1;
|
||
const uStart = Math.floor((uMin - wavelength) / du) * du;
|
||
const uEnd = uMax + wavelength;
|
||
|
||
// EXAKT die SVG-Wellenform: die Kachel zeichnet zwei quadratische Béziers
|
||
// `M0 5s Q 3.5s -1s 7s 5s Q 10.5s 11s 14s 5s`. Deren x(t) ist LINEAR (7s·t je
|
||
// Halbwelle), die Auslenkung eine Parabel ±12s·t(1−t) (Scheitel ±3s) — also
|
||
// Halbwellen-Parabeln statt einer Sinuskurve (flachere Kämme, steilere
|
||
// Nulldurchgänge). Erste Halbwelle: Kamm nach OBEN (Bildschirm) = +v (Modell).
|
||
const half = wavelength / 2;
|
||
const waveAt = (u: number): number => {
|
||
const phase = ((u % wavelength) + wavelength) % wavelength;
|
||
const t = (phase % half) / half;
|
||
const mag = 12 * scale * PX_TO_M * t * (1 - t);
|
||
return phase < half ? mag : -mag;
|
||
};
|
||
|
||
for (let r = firstRow; r <= lastRow; r++) {
|
||
const vBase = (r + 0.5) * rowGap;
|
||
// Ganze Welle als EINE Polylinie tessellieren, dann DURCHGEHEND clippen.
|
||
// Das u-Raster ist an Vielfachen von du = λ/22 ausgerichtet → die Null-
|
||
// durchgänge (Vielfache von λ/2 = 11·du) liegen exakt auf Stützpunkten.
|
||
const wavePts: Vec2[] = [];
|
||
for (let u = uStart; u <= uEnd + 1e-9; u += du) {
|
||
wavePts.push(toWorld(u, vBase + waveAt(u)));
|
||
}
|
||
for (const run of clipPolylineToPolygon(wavePts, poly)) runs.push(run);
|
||
}
|
||
return runs;
|
||
}
|