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 48bbcaed1e
commit a971cd610b
6 changed files with 582 additions and 114 deletions
+66 -21
View File
@@ -34,6 +34,33 @@ function toScreen(p: Vec2): Vec2 {
return { x: p.x * PX_PER_M, y: -p.y * PX_PER_M };
}
/**
* Zerlegt den Rand eines Polygons in zusammenhängende OFFENE Linienzüge, wobei die
* Kanten in `noStroke` ausgelassen werden (Kante `i` = pts[i]→pts[i+1]). Genutzt,
* um die inneren Gehrungs-Stirnkanten an einer Wandecke NICHT zu stricheln (keine
* 45°-Naht, keine über den Apex schießende Barbe), während die Füllung das volle
* Polygon bleibt. Jeder Lauf beginnt an einer sichtbaren Kante, deren Vorgänger
* ausgelassen ist.
*/
function visibleEdgeRuns(scr: Vec2[], noStroke: number[]): Vec2[][] {
const n = scr.length;
const skip = new Set(noStroke.map((i) => ((i % n) + n) % n));
if (skip.size >= n || n < 2) return [];
const visible = (i: number) => !skip.has(((i % n) + n) % n);
const runs: Vec2[][] = [];
for (let s = 0; s < n; s++) {
if (!visible(s) || visible(s - 1 + n)) continue; // kein Lauf-Anfang
const run: Vec2[] = [scr[s]];
let j = s;
while (visible(j) && j - s < n) {
run.push(scr[(j + 1) % n]);
j++;
}
runs.push(run);
}
return runs;
}
/**
* Papier-Massstab (docs/design/plans-output.md §3): Der Plan IST Papier-Space.
* dpi = 96·devicePixelRatio (CSS definiert 1px = 1/96 inch). Strichstärken sind
@@ -2565,44 +2592,62 @@ function renderPrimitive(
};
switch (p.kind) {
case "polygon": {
const pts = p.pts.map(toScreen).map((s) => `${s.x},${s.y}`).join(" ");
const scr = p.pts.map(toScreen);
const pts = scr.map((s) => `${s.x},${s.y}`).join(" ");
// Umriss-Strichstärke in mm Papier (Display: konstante px / Print: skaliert).
const sw = weight(p.strokeWidthMm);
// Ohne Schraffur: reine Component-Füllung bzw. „none" (nur Umriss).
if (p.hatch.pattern === "none") {
// Innere Gehrungs-Stirnkanten am Wandknoten (noStrokeEdges) NICHT stricheln:
// die Füllung bleibt das volle Polygon, aber die Diagonale am Knoten (Naht)
// und die überschießende Eck-Barbe entfallen. Sichtbare Kanten werden als
// zusammenhängende offene <polyline>-Läufe gezeichnet (keine Gehrung über
// die weggelassene Kante). Leeres/kein noStrokeEdges ⇒ voller Umriss.
const noStroke = p.noStrokeEdges;
const outline = () => {
if (!noStroke || noStroke.length === 0) {
return (
<polygon
points={pts}
fill={p.fill}
<polygon points={pts} fill="none" stroke={p.stroke} strokeWidth={sw} vectorEffect={vfx} />
);
}
const runs = visibleEdgeRuns(scr, noStroke);
return (
<>
{runs.map((run, ri) => (
<polyline
key={`e${ri}`}
points={run.map((s) => `${s.x},${s.y}`).join(" ")}
fill="none"
stroke={p.stroke}
strokeWidth={sw}
vectorEffect={vfx}
/>
))}
</>
);
};
// Ohne Schraffur: reine Component-Füllung bzw. „none" (nur Umriss).
if (p.hatch.pattern === "none") {
return (
<g>
<polygon points={pts} fill={p.fill} stroke="none" />
{outline()}
</g>
);
}
// Vollfüllung: Schraffurfarbe deckt die Component-Füllung (Poché).
if (p.hatch.pattern === "solid") {
return (
<polygon
points={pts}
fill={p.hatch.color}
stroke={p.stroke}
strokeWidth={sw}
vectorEffect={vfx}
/>
<g>
<polygon points={pts} fill={p.hatch.color} stroke="none" />
{outline()}
</g>
);
}
// Linien-/Kreuz-Schraffur: erst Grundfüllung, dann Muster darüber.
// Linien-/Kreuz-Schraffur: erst Grundfüllung, dann Muster, dann Umriss.
return (
<g>
<polygon points={pts} fill={p.fill} stroke="none" />
<polygon
points={pts}
fill={`url(#hatch-${index})`}
stroke={p.stroke}
strokeWidth={sw}
vectorEffect={vfx}
/>
<polygon points={pts} fill={`url(#hatch-${index})`} stroke="none" />
{outline()}
</g>
);
}
+116
View File
@@ -0,0 +1,116 @@
/**
* Unit-Tests für die Wandecken-Gehrung im Grundriss (generatePlan):
* KEINE 45°-Naht zwischen gleichfarbigen Schichten zweier Wände am Knoten
* (die inneren Gehrungs-Stirnkanten tragen `noStrokeEdges`).
* KEIN überschießender Umriss-Barb: die Gehrungs-Diagonale ist der Umriss
* nicht mehr gestrichen; die Cut-Ecken sitzen exakt auf den Fläche×Fläche-
* Apexen (sauberer Miter, kein Overshoot).
* Freie (rechtwinklige) Wandenden bleiben voll umrissen (kein noStrokeEdges).
*/
import { describe, it, expect } from "vitest";
import { generatePlan } from "./generatePlan";
import type { Project, Wall } from "../model/types";
/** Minimalprojekt: zwei Wände, die in (5,0) eine 90°-L-Ecke bilden. */
function cornerProject(): Project {
const wall = (id: string, start: Wall["start"], end: Wall["end"]): Wall => ({
id,
type: "wall",
floorId: "eg",
categoryCode: "20",
start,
end,
wallTypeId: "aw",
height: 2.6,
});
return {
id: "t",
name: "T",
lineStyles: [{ id: "thin", name: "d", weight: 0.13, color: "#111", dash: null }],
hatches: [{ id: "none", name: "Ohne", pattern: "none", scale: 1, angle: 0, color: "#111" }],
components: [
{ id: "a", name: "A", color: "#d8d2c7", hatchId: "none", joinPriority: 10 },
{ id: "b", name: "B", color: "#8c5544", hatchId: "none", joinPriority: 50 },
],
wallTypes: [
{
id: "aw",
name: "AW",
layers: [
{ componentId: "a", thickness: 0.1 },
{ componentId: "b", thickness: 0.245 },
],
},
],
drawingLevels: [
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 },
],
layers: [{ code: "20", name: "Wände", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
walls: [
// W1 endet in (5,0); W2 startet in (5,0) → geteilter Knoten (L-Ecke).
wall("W1", { x: 0, y: 0 }, { x: 5, y: 0 }),
wall("W2", { x: 5, y: 0 }, { x: 5, y: 4 }),
],
doors: [],
openings: [],
ceilings: [],
stairs: [],
rooms: [],
drawings2d: [],
context: [],
};
}
const visible = new Set(["20"]);
describe("generatePlan — Wandecken-Gehrung", () => {
it("markiert die Gehrungs-Stirnkante am Knoten als nicht zu stricheln (keine Naht/Barbe)", () => {
const plan = generatePlan(cornerProject(), "eg", visible, undefined, "mittel");
const wallPolys = plan.primitives.filter(
(p): p is Extract<typeof p, { kind: "polygon" }> =>
p.kind === "polygon" && p.wallId != null,
);
expect(wallPolys.length).toBeGreaterThan(0);
// JEDES Wandpolygon (Schichtbänder + Umriss) trägt an genau EINEM Ende die
// Gehrungs-Stirnkante: W1 am Endcut (Kante 1), W2 am Startcut (Kante 3).
const w1 = wallPolys.filter((p) => p.wallId === "W1");
const w2 = wallPolys.filter((p) => p.wallId === "W2");
expect(w1.length).toBeGreaterThan(0);
expect(w2.length).toBeGreaterThan(0);
for (const p of w1) expect(p.noStrokeEdges).toEqual([1]);
for (const p of w2) expect(p.noStrokeEdges).toEqual([3]);
});
it("legt die Umriss-Cut-Ecken exakt auf die Fläche×Fläche-Apexe (sauberer Miter, kein Overshoot)", () => {
const plan = generatePlan(cornerProject(), "eg", visible, undefined, "mittel");
// Der Wand-Umriss ist das letzte Wandpolygon je Wand (fill:"none").
const outlineW1 = plan.primitives.filter(
(p): p is Extract<typeof p, { kind: "polygon" }> =>
p.kind === "polygon" && p.wallId === "W1" && p.fill === "none",
);
expect(outlineW1.length).toBe(1);
const o = outlineW1[0];
// clippedBand-Reihenfolge [A.start, A.end, B.end, B.start]; A.end/B.end sind
// die getrimmten Enden am Knoten. T=0.345, half=0.1725. Aussen-Apex=(5.1725,
// -0.1725), Innen-Apex=(4.8275, 0.1725) — der EXAKTE 90°-Miter ohne Überstand.
const half = 0.345 / 2;
expect(o.pts[1].x).toBeCloseTo(5 + half, 4); // A.end (Aussenfläche)
expect(o.pts[1].y).toBeCloseTo(-half, 4);
expect(o.pts[2].x).toBeCloseTo(5 - half, 4); // B.end (Innenfläche)
expect(o.pts[2].y).toBeCloseTo(half, 4);
});
it("lässt freie (rechtwinklige) Wandenden voll umrissen (kein noStrokeEdges dort)", () => {
// Nur W1 allein → beide Enden frei, kein Knoten.
const proj = cornerProject();
proj.walls = [proj.walls[0]];
const plan = generatePlan(proj, "eg", visible, undefined, "mittel");
const wallPolys = plan.primitives.filter(
(p): p is Extract<typeof p, { kind: "polygon" }> =>
p.kind === "polygon" && p.wallId === "W1",
);
expect(wallPolys.length).toBeGreaterThan(0);
for (const p of wallPolys) expect(p.noStrokeEdges).toEqual([]);
});
});
+33 -1
View File
@@ -46,6 +46,7 @@ import {
sub,
wallCorners,
} from "../model/geometry";
import type { Line } from "../model/geometry";
/**
* Detailgrad der Plan-Darstellung (Oberleiste grob/mittel/fein"). Steuert hier
@@ -142,6 +143,17 @@ export type Primitive =
hatch: HatchRender;
/** Gedimmt zeichnen (Darstellungsmodus „andere grau"). */
greyed?: boolean;
/**
* Kanten-Indizes, die NICHT umrissen (gestrichen) werden dürfen die Füllung
* bleibt das volle Polygon. Kante `i` läuft von `pts[i]` nach `pts[(i+1)%n]`.
* Damit werden die inneren Gehrungs-/Stoß-Stirnflächen an einer Wandecke
* unterdrückt: die Diagonale, an der GLEICHFARBIGE Schichten zweier Wände
* aneinanderstoßen, würde sonst als sichtbare 45°-Naht erscheinen und der
* Umriss würde als Barbe über den Eck-Apex hinausschießen (Gehrungs-Spitze).
* Längskanten (Schichtfugen zwischen VERSCHIEDENEN Materialien) bleiben
* gestrichen nur die Stoß-Stirnkanten am Knoten entfallen.
*/
noStrokeEdges?: number[];
/**
* ID der Wand, zu der dieses Schicht-/Umriss-Polygon gehört (für die
* Links-Klick-Auswahl: die PlanView markiert ALLE Polygone derselben
@@ -638,6 +650,19 @@ function addWallPoche(
// Vereinfachte Füllung (grob): Farbe des tragenden Bauteils (höchste Priorität).
const simpleFill = backboneColor(project, wt);
// Kanten-Indizes der inneren Gehrungs-Stirnflächen (die Diagonalen am Knoten).
// clippedBand liefert [A.start, A.end, B.end, B.start] → Kante 1 (A.end→B.end)
// ist die endCut-Stirnfläche, Kante 3 (B.start→A.start) die startCut-Stirnfläche.
// Diese werden NICHT umrissen: sonst 45°-Naht zwischen gleichfarbigen Schichten
// + überschießende Gehrungs-Barbe am Eck-Apex. Freie Enden (kein Cut) bleiben
// rechtwinklig gestrichen. Die Längskanten (Schichtfugen) bleiben stets sichtbar.
const joinEdges = (startCut: Line | null, endCut: Line | null): number[] => {
const e: number[] = [];
if (endCut) e.push(1);
if (startCut) e.push(3);
return e;
};
for (const [s, e] of segments) {
const p1 = along(wall.start, wall.end, s);
const p2 = along(wall.start, wall.end, e);
@@ -656,6 +681,7 @@ function addWallPoche(
strokeWidthMm: outlineMm,
hatch: NO_HATCH,
greyed,
noStrokeEdges: joinEdges(startCut, endCut),
wallId,
});
continue;
@@ -664,6 +690,7 @@ function addWallPoche(
// mittel/fein: Schichten von außen (-T/2) nach innen (+T/2) stapeln. Jede
// Schicht löst Bauteil (Füllfarbe) + Schraffur (Muster/Maßstab/Winkel/Farbe)
// auf; die Schichtfugen werden dünn gestrichen.
const bandJoinEdges = joinEdges(startCut, endCut);
let off = refOff - total / 2;
for (const layer of wt.layers) {
const comp = getComponent(project, layer.componentId);
@@ -675,12 +702,16 @@ function addWallPoche(
strokeWidthMm: layerLineMm,
hatch: resolveHatch(project, comp.hatchId),
greyed,
// Die Diagonale am Knoten NICHT stricheln → keine 45°-Naht zwischen
// gleichfarbigen Schichten der beiden Wände.
noStrokeEdges: bandJoinEdges,
wallId,
});
off += layer.thickness;
}
// Kräftige Wand-Umrisslinie über die volle Dicke (nur Kontur, keine Füllung),
// damit die Wand sich klar von den dünnen Schichtfugen abhebt.
// damit die Wand sich klar von den dünnen Schichtfugen abhebt. Die Gehrungs-
// Stirnkanten am Knoten entfallen → sauberer Eck-Apex ohne überschießende Barbe.
out.push({
kind: "polygon",
pts: clippedBand(p1, p2, refOff - total / 2, refOff + total / 2, startCut, endCut),
@@ -689,6 +720,7 @@ function addWallPoche(
strokeWidthMm: outlineMm,
hatch: NO_HATCH,
greyed,
noStrokeEdges: bandJoinEdges,
wallId,
});
}
+47 -7
View File
@@ -11,7 +11,7 @@
import type { Primitive } from '../generatePlan';
import type { Vec2 } from '../../model/types';
import type { GpuGeometry, Rgba } from './glPlanTypes';
import { applyDash, buildHatchSegments } from './glPlanHatch';
import { applyDashRuns, buildHatchRuns } from './glPlanHatch';
const PX_PER_M = 90;
@@ -247,6 +247,44 @@ export function compilePrimitivesToGpu(
const pushLine = (aM: Vec2, bM: Vec2, color: Rgba, strokeMm: number) =>
strokePolyline([aM, bM], false, color, strokeMm);
/**
* Strichelt den Umriss eines Polygons, wobei die Kanten in `noStroke`
* ÜBERSPRUNGEN werden (Kante `i` = pts[i]pts[i+1]). Die Füllung bleibt das
* volle Polygon; nur innere Gehrungs-Stirnkanten am Wandknoten entfallen. Die
* sichtbaren Kanten werden als zusammenhängende OFFENE Züge gestrichelt an
* den weggelassenen Kanten laufen die Enden gerade aus (keine Gehrung über die
* Naht = keine überschießende Barbe). Leeres `noStroke` geschlossener,
* gehrter Umriss wie bisher.
*/
const strokePolygonOutline = (
ptsM: Vec2[],
color: Rgba,
strokeMm: number,
noStroke: number[] | undefined,
) => {
const n = ptsM.length;
if (!noStroke || noStroke.length === 0 || n < 2) {
strokePolyline(ptsM, true, color, strokeMm);
return;
}
const skip = new Set(noStroke.map((i) => ((i % n) + n) % n));
if (skip.size >= n) return; // alle Kanten unterdrückt → nichts zeichnen
// Sichtbare Kanten zu zusammenhängenden Läufen bündeln; ein Lauf beginnt an
// einer sichtbaren Kante, deren Vorgänger übersprungen ist.
const visible = (i: number) => !skip.has(((i % n) + n) % n);
for (let s = 0; s < n; s++) {
if (!visible(s) || visible(s - 1 + n)) continue; // kein Lauf-Anfang
const run: Vec2[] = [ptsM[s]];
let j = s;
while (visible(j)) {
run.push(ptsM[(j + 1) % n]);
j++;
if (j - s >= n) break; // voller Umlauf (kann bei skip.size≥1 nicht sein)
}
strokePolyline(run, false, color, strokeMm);
}
};
for (const prim of primitives) {
if (prim.kind === 'polygon') {
// Füllung (falls vorhanden).
@@ -266,21 +304,23 @@ export function compilePrimitivesToGpu(
}
// Schraffur (nach der Füllung, vor/passend zum Umriss — wie der SVG stapelt):
// das Muster wird in echte Linien-Geometrie (Modell-Meter) tesselliert und
// aufs Polygon geclippt (konkav-fähig, even-odd). Musterlinien in derselben
// Papier-mm-Breite wie der SVG-`<pattern>`; `hatch.dash` wird geometrisch
// (Strich/Lücke) aufgelöst, da die GL-Linien-Pipeline kein Dash kennt.
// aufs Polygon geclippt (konkav-fähig, even-odd). Musterlinien fließen als
// zusammenhängende Läufe in `strokePolyline` → GEHRTE, glatte Übergänge
// (insb. die Dämmungswelle bleibt spitzenfrei). Breite = dieselbe Papier-mm
// wie der SVG-`<pattern>`; `hatch.dash` wird geometrisch (Strich/Lücke)
// aufgelöst, da die GL-Linien-Pipeline kein Dash kennt.
if (prim.hatch && prim.hatch.pattern !== 'none' && prim.hatch.pattern !== 'solid') {
const hatchColor = parseColor(prim.hatch.color) ?? [0.1, 0.1, 0.1, 1];
const hatchMm = prim.hatch.lineWeight > 0 ? prim.hatch.lineWeight : 0.13;
const segs = applyDash(buildHatchSegments(prim.pts, prim.hatch), prim.hatch.dash);
for (const s of segs) pushLine(s.a, s.b, hatchColor, hatchMm);
const runs = applyDashRuns(buildHatchRuns(prim.pts, prim.hatch), prim.hatch.dash);
for (const run of runs) strokePolyline(run, false, hatchColor, hatchMm);
}
// Umriss (crispe Kante) — geschlossener Ring, GEHRT (kein Stufen-Cap an
// Ecken). Breite = ECHTE Papier-mm; der Renderer rechnet massstabs-/
// zoomrichtig in px (wie SVG-printStrokeVb → GL == SVG).
const stroke = parseColor(prim.stroke);
if (stroke && prim.strokeWidthMm > 0 && prim.pts.length >= 2) {
strokePolyline(prim.pts, true, stroke, prim.strokeWidthMm);
strokePolygonOutline(prim.pts, stroke, prim.strokeWidthMm, prim.noStrokeEdges);
}
} else if (prim.kind === 'line') {
const color = parseColor(prim.color) ?? [0.1, 0.1, 0.1, 1];
+170 -32
View File
@@ -6,11 +6,13 @@
import { describe, it, expect } from 'vitest';
import {
applyDash,
buildHatchSegments,
applyDashRuns,
buildHatchRuns,
clipLineToPolygon,
clipPolylineToPolygon,
clipSegmentToPolygon,
hatchLineFamily,
type HatchRun,
type HatchSegment,
} from './glPlanHatch';
import type { Vec2 } from '../../model/types';
@@ -23,6 +25,17 @@ function totalLen(segs: HatchSegment[]): number {
return l;
}
/** Gesamt-Länge einer Lauf-Schar (Polylinien). */
function totalRunLen(runs: HatchRun[]): number {
let l = 0;
for (const run of runs) {
for (let i = 0; i + 1 < run.length; i++) {
l += Math.hypot(run[i + 1].x - run[i].x, run[i + 1].y - run[i].y);
}
}
return l;
}
/** Basis-Hatch mit Überschreibungen. */
function hatch(over: Partial<HatchRender>): HatchRender {
return { pattern: 'diagonal', scale: 1, angle: 0, color: '#000', lineWeight: 0.13, dash: null, ...over };
@@ -136,6 +149,106 @@ describe('clipSegmentToPolygon', () => {
});
});
describe('clipPolylineToPolygon', () => {
const sq: Vec2[] = [
{ x: 0, y: 0 },
{ x: 4, y: 0 },
{ x: 4, y: 4 },
{ x: 0, y: 4 },
];
it('KONVEX: eine durchgehende Polylinie bleibt EIN Lauf (keine Zerreißung an inneren Vertices)', () => {
// Polylinie mit mehreren inneren Stützpunkten, komplett im Quadrat.
const pts: Vec2[] = [
{ x: 1, y: 1 },
{ x: 2, y: 2 },
{ x: 3, y: 1 },
{ x: 3.5, y: 2 },
];
const runs = clipPolylineToPolygon(pts, sq);
expect(runs.length).toBe(1);
// Alle Original-Stützpunkte bleiben erhalten → ein Lauf mit ≥4 Punkten.
expect(runs[0].length).toBe(4);
expect(totalRunLen(runs)).toBeCloseTo(totalRunLen([pts]), 9);
});
it('überstehende Polylinie wird an den Grenzen geklemmt, bleibt aber EIN Lauf', () => {
// Waagerechte Polylinie von x=-3..7 durch die Mitte → innen [0,4].
const pts: Vec2[] = [
{ x: -3, y: 2 },
{ x: 0, y: 2 },
{ x: 7, y: 2 },
];
const runs = clipPolylineToPolygon(pts, sq);
expect(runs.length).toBe(1);
expect(totalRunLen(runs)).toBeCloseTo(4, 9); // x=0..4
});
it('KONKAV (C-Form): eine Linie durch beide Arme → ZWEI getrennte Läufe', () => {
// Gleiche C-Form wie beim Linien-Clipping; eine durchgehende horizontale
// Polylinie auf Höhe y=4 tritt in der Kerbe aus → zwei Läufe.
const c: Vec2[] = [
{ x: 0, y: 0 },
{ x: 6, y: 0 },
{ x: 6, y: 6 },
{ x: 4, y: 6 },
{ x: 4, y: 2 },
{ x: 2, y: 2 },
{ x: 2, y: 6 },
{ x: 0, y: 6 },
];
const pts: Vec2[] = [
{ x: -1, y: 4 },
{ x: 3, y: 4 },
{ x: 7, y: 4 },
];
const runs = clipPolylineToPolygon(pts, c);
expect(runs.length).toBe(2);
const spans = runs
.map((r) => {
const xs = r.map((p) => p.x);
return [Math.min(...xs), Math.max(...xs)];
})
.sort((p, q) => p[0] - q[0]);
expect(spans[0][0]).toBeCloseTo(0, 9);
expect(spans[0][1]).toBeCloseTo(2, 9);
expect(spans[1][0]).toBeCloseTo(4, 9);
expect(spans[1][1]).toBeCloseTo(6, 9);
});
it('Polylinie komplett AUSSERHALB → keine Läufe', () => {
const pts: Vec2[] = [
{ x: -3, y: 10 },
{ x: 2, y: 10 },
{ x: 7, y: 10 },
];
expect(clipPolylineToPolygon(pts, sq)).toEqual([]);
});
it('Ecken-Durchquerung: ein L-förmiger Zug ins und wieder aus dem Polygon bleibt zusammenhängend', () => {
// Zug tritt bei x=0 ein, geht durch eine INNERE Ecke (2,2) und wieder raus:
// (-1,2)-(2,2)-(2,5) → innen: (0,2)-(2,2)-(2,4). Ein Lauf mit der Ecke drin.
const pts: Vec2[] = [
{ x: -1, y: 2 },
{ x: 2, y: 2 },
{ x: 2, y: 5 },
];
const runs = clipPolylineToPolygon(pts, sq);
expect(runs.length).toBe(1);
// Der innere Eckpunkt (2,2) bleibt im Lauf → ≥3 Punkte.
expect(runs[0].length).toBeGreaterThanOrEqual(3);
// Enthält die Ecke (2,2).
const hasCorner = runs[0].some((p) => Math.abs(p.x - 2) < 1e-6 && Math.abs(p.y - 2) < 1e-6);
expect(hasCorner).toBe(true);
expect(totalRunLen(runs)).toBeCloseTo(2 + 2, 9); // (0,2)->(2,2)=2, (2,2)->(2,4)=2
});
it('degeneriert (<2 Punkte / <3 Ecken) → leer', () => {
expect(clipPolylineToPolygon([{ x: 0, y: 0 }], sq)).toEqual([]);
expect(clipPolylineToPolygon([{ x: 0, y: 0 }, { x: 1, y: 1 }], [{ x: 0, y: 0 }])).toEqual([]);
});
});
describe('hatchLineFamily', () => {
const sq: Vec2[] = [
{ x: 0, y: 0 },
@@ -158,7 +271,7 @@ describe('hatchLineFamily', () => {
});
});
describe('buildHatchSegments', () => {
describe('buildHatchRuns', () => {
const sq: Vec2[] = [
{ x: 0, y: 0 },
{ x: 1, y: 0 },
@@ -166,20 +279,22 @@ describe('buildHatchSegments', () => {
{ x: 0, y: 1 },
];
it('solid/none erzeugen KEINE Musterlinien (Füllung/Umriss übernimmt der Aufrufer)', () => {
expect(buildHatchSegments(sq, hatch({ pattern: 'none' }))).toEqual([]);
expect(buildHatchSegments(sq, hatch({ pattern: 'solid' }))).toEqual([]);
it('solid/none erzeugen KEINE Läufe (Füllung/Umriss übernimmt der Aufrufer)', () => {
expect(buildHatchRuns(sq, hatch({ pattern: 'none' }))).toEqual([]);
expect(buildHatchRuns(sq, hatch({ pattern: 'solid' }))).toEqual([]);
});
it('diagonal erzeugt eine einzelne Schar; crosshatch mehr (zwei Scharen)', () => {
const diag = buildHatchSegments(sq, hatch({ pattern: 'diagonal' }));
const cross = buildHatchSegments(sq, hatch({ pattern: 'crosshatch' }));
it('diagonal erzeugt eine einzelne Schar 2-Punkt-Läufe; crosshatch mehr (zwei Scharen)', () => {
const diag = buildHatchRuns(sq, hatch({ pattern: 'diagonal' }));
const cross = buildHatchRuns(sq, hatch({ pattern: 'crosshatch' }));
expect(diag.length).toBeGreaterThan(0);
// Zwei Scharen → mindestens so viele Segmente wie eine Schar, praktisch mehr.
// Gerade Musterlinien sind 2-Punkt-Läufe.
for (const r of diag) expect(r.length).toBe(2);
// Zwei Scharen → mindestens so viele Läufe wie eine Schar, praktisch mehr.
expect(cross.length).toBeGreaterThan(diag.length);
// Alle Segmente liegen im Einheitsquadrat (Clipping wirkt).
for (const s of cross) {
for (const p of [s.a, s.b]) {
// Alle Lauf-Punkte liegen im Einheitsquadrat (Clipping wirkt).
for (const r of cross) {
for (const p of r) {
expect(p.x).toBeGreaterThanOrEqual(-1e-6);
expect(p.x).toBeLessThanOrEqual(1 + 1e-6);
expect(p.y).toBeGreaterThanOrEqual(-1e-6);
@@ -188,11 +303,14 @@ describe('buildHatchSegments', () => {
}
});
it('insulation erzeugt tessellierte Wellen-Segmente innerhalb des Polygons', () => {
const segs = buildHatchSegments(sq, hatch({ pattern: 'insulation' }));
expect(segs.length).toBeGreaterThan(0);
for (const s of segs) {
for (const p of [s.a, s.b]) {
it('insulation erzeugt DURCHGEHENDE Wellen-Läufe (≥3 Punkte) innerhalb des Polygons', () => {
const runs = buildHatchRuns(sq, hatch({ pattern: 'insulation' }));
expect(runs.length).toBeGreaterThan(0);
// Mindestens ein Lauf ist eine echte Polylinie (nicht zerrissen in 2-Punkt-Stücke).
expect(runs.some((r) => r.length >= 3)).toBe(true);
for (const r of runs) {
expect(r.length).toBeGreaterThanOrEqual(2);
for (const p of r) {
expect(p.x).toBeGreaterThanOrEqual(-1e-6);
expect(p.x).toBeLessThanOrEqual(1 + 1e-6);
}
@@ -200,30 +318,50 @@ describe('buildHatchSegments', () => {
});
it('größerer scale → weniger Musterlinien (weitere Teilung)', () => {
const fine = buildHatchSegments(sq, hatch({ pattern: 'diagonal', scale: 1 }));
const coarse = buildHatchSegments(sq, hatch({ pattern: 'diagonal', scale: 4 }));
const fine = buildHatchRuns(sq, hatch({ pattern: 'diagonal', scale: 1 }));
const coarse = buildHatchRuns(sq, hatch({ pattern: 'diagonal', scale: 4 }));
expect(coarse.length).toBeLessThan(fine.length);
});
it('scale ≤ 0 wird auf 1 geklemmt (keine leere/entartete Kachel)', () => {
const segs = buildHatchSegments(sq, hatch({ pattern: 'diagonal', scale: 0 }));
expect(segs.length).toBeGreaterThan(0);
const runs = buildHatchRuns(sq, hatch({ pattern: 'diagonal', scale: 0 }));
expect(runs.length).toBeGreaterThan(0);
});
});
describe('applyDash', () => {
it('ohne Muster (null/leer) bleiben Segmente unverändert', () => {
const segs: HatchSegment[] = [{ a: { x: 0, y: 0 }, b: { x: 10, y: 0 } }];
expect(applyDash(segs, null)).toBe(segs);
expect(applyDash(segs, [])).toBe(segs);
describe('applyDashRuns', () => {
it('ohne Muster (null/leer) bleiben Läufe unverändert', () => {
const runs: HatchRun[] = [[{ x: 0, y: 0 }, { x: 10, y: 0 }]];
expect(applyDashRuns(runs, null)).toBe(runs);
expect(applyDashRuns(runs, [])).toBe(runs);
});
it('zerlegt ein Segment in Strich/Lücke (Summe der Striche < Gesamtlänge)', () => {
const segs: HatchSegment[] = [{ a: { x: 0, y: 0 }, b: { x: 10, y: 0 } }];
const dashed = applyDash(segs, [0.13, 0.13]); // gleich lang an/aus
it('zerlegt einen Lauf in Strich/Lücke (Summe der Striche < Gesamtlänge)', () => {
const runs: HatchRun[] = [[{ x: 0, y: 0 }, { x: 10, y: 0 }]];
const dashed = applyDashRuns(runs, [0.13, 0.13]); // gleich lang an/aus
expect(dashed.length).toBeGreaterThan(1);
// Nur die „an"-Stücke bleiben → Gesamtlänge kleiner als das Original (10).
expect(totalLen(dashed)).toBeGreaterThan(0);
expect(totalLen(dashed)).toBeLessThan(totalLen(segs));
expect(totalRunLen(dashed)).toBeGreaterThan(0);
expect(totalRunLen(dashed)).toBeLessThan(totalRunLen(runs));
});
it('läuft die Dash-Phase über Stützpunkt-Ecken durch (gekrümmter Lauf)', () => {
// Ein rechtwinkliger Lauf (zwei Segmente à 10 m). Die Phase darf nicht je
// Segment zurückgesetzt werden → einzelne „an"-Stücke dürfen die Ecke queren.
const runs: HatchRun[] = [
[
{ x: 0, y: 0 },
{ x: 10, y: 0 },
{ x: 10, y: 10 },
],
];
const dashed = applyDashRuns(runs, [50, 5]); // langer Strich (>1 Segment), kurze Lücke
expect(dashed.length).toBeGreaterThan(0);
// Mindestens ein „an"-Stück überspannt die Ecke (enthält einen 90°-Knick →
// ≥3 Punkte, wobei der mittlere die Ecke (10,0) ist).
const spansCorner = dashed.some(
(r) => r.length >= 3 && r.some((p) => Math.abs(p.x - 10) < 1e-6 && Math.abs(p.y) < 1e-6),
);
expect(spansCorner).toBe(true);
});
});
+147 -50
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,44 +278,69 @@ 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);
}
pos = end;
// 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;
}
@@ -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);
wavePts.push(toWorld(u, vBase + wave));
}
prev = pt;
for (const run of clipPolylineToPolygon(wavePts, poly)) runs.push(run);
}
}
return out;
return runs;
}