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:
+66
-21
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user