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 };
|
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.
|
* 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
|
* dpi = 96·devicePixelRatio (CSS definiert 1px = 1/96 inch). Strichstärken sind
|
||||||
@@ -2565,44 +2592,62 @@ function renderPrimitive(
|
|||||||
};
|
};
|
||||||
switch (p.kind) {
|
switch (p.kind) {
|
||||||
case "polygon": {
|
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).
|
// Umriss-Strichstärke in mm Papier (Display: konstante px / Print: skaliert).
|
||||||
const sw = weight(p.strokeWidthMm);
|
const sw = weight(p.strokeWidthMm);
|
||||||
// Ohne Schraffur: reine Component-Füllung bzw. „none" (nur Umriss).
|
// Innere Gehrungs-Stirnkanten am Wandknoten (noStrokeEdges) NICHT stricheln:
|
||||||
if (p.hatch.pattern === "none") {
|
// 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 (
|
return (
|
||||||
<polygon
|
<polygon points={pts} fill="none" stroke={p.stroke} strokeWidth={sw} vectorEffect={vfx} />
|
||||||
points={pts}
|
);
|
||||||
fill={p.fill}
|
}
|
||||||
|
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}
|
stroke={p.stroke}
|
||||||
strokeWidth={sw}
|
strokeWidth={sw}
|
||||||
vectorEffect={vfx}
|
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é).
|
// Vollfüllung: Schraffurfarbe deckt die Component-Füllung (Poché).
|
||||||
if (p.hatch.pattern === "solid") {
|
if (p.hatch.pattern === "solid") {
|
||||||
return (
|
return (
|
||||||
<polygon
|
<g>
|
||||||
points={pts}
|
<polygon points={pts} fill={p.hatch.color} stroke="none" />
|
||||||
fill={p.hatch.color}
|
{outline()}
|
||||||
stroke={p.stroke}
|
</g>
|
||||||
strokeWidth={sw}
|
|
||||||
vectorEffect={vfx}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Linien-/Kreuz-Schraffur: erst Grundfüllung, dann Muster darüber.
|
// Linien-/Kreuz-Schraffur: erst Grundfüllung, dann Muster, dann Umriss.
|
||||||
return (
|
return (
|
||||||
<g>
|
<g>
|
||||||
<polygon points={pts} fill={p.fill} stroke="none" />
|
<polygon points={pts} fill={p.fill} stroke="none" />
|
||||||
<polygon
|
<polygon points={pts} fill={`url(#hatch-${index})`} stroke="none" />
|
||||||
points={pts}
|
{outline()}
|
||||||
fill={`url(#hatch-${index})`}
|
|
||||||
stroke={p.stroke}
|
|
||||||
strokeWidth={sw}
|
|
||||||
vectorEffect={vfx}
|
|
||||||
/>
|
|
||||||
</g>
|
</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,
|
sub,
|
||||||
wallCorners,
|
wallCorners,
|
||||||
} from "../model/geometry";
|
} from "../model/geometry";
|
||||||
|
import type { Line } from "../model/geometry";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detailgrad der Plan-Darstellung (Oberleiste „grob/mittel/fein"). Steuert hier
|
* Detailgrad der Plan-Darstellung (Oberleiste „grob/mittel/fein"). Steuert hier
|
||||||
@@ -142,6 +143,17 @@ export type Primitive =
|
|||||||
hatch: HatchRender;
|
hatch: HatchRender;
|
||||||
/** Gedimmt zeichnen (Darstellungsmodus „andere grau"). */
|
/** Gedimmt zeichnen (Darstellungsmodus „andere grau"). */
|
||||||
greyed?: boolean;
|
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
|
* ID der Wand, zu der dieses Schicht-/Umriss-Polygon gehört (für die
|
||||||
* Links-Klick-Auswahl: die PlanView markiert ALLE Polygone derselben
|
* 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).
|
// Vereinfachte Füllung (grob): Farbe des tragenden Bauteils (höchste Priorität).
|
||||||
const simpleFill = backboneColor(project, wt);
|
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) {
|
for (const [s, e] of segments) {
|
||||||
const p1 = along(wall.start, wall.end, s);
|
const p1 = along(wall.start, wall.end, s);
|
||||||
const p2 = along(wall.start, wall.end, e);
|
const p2 = along(wall.start, wall.end, e);
|
||||||
@@ -656,6 +681,7 @@ function addWallPoche(
|
|||||||
strokeWidthMm: outlineMm,
|
strokeWidthMm: outlineMm,
|
||||||
hatch: NO_HATCH,
|
hatch: NO_HATCH,
|
||||||
greyed,
|
greyed,
|
||||||
|
noStrokeEdges: joinEdges(startCut, endCut),
|
||||||
wallId,
|
wallId,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
@@ -664,6 +690,7 @@ function addWallPoche(
|
|||||||
// mittel/fein: Schichten von außen (-T/2) nach innen (+T/2) stapeln. Jede
|
// 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)
|
// Schicht löst Bauteil (Füllfarbe) + Schraffur (Muster/Maßstab/Winkel/Farbe)
|
||||||
// auf; die Schichtfugen werden dünn gestrichen.
|
// auf; die Schichtfugen werden dünn gestrichen.
|
||||||
|
const bandJoinEdges = joinEdges(startCut, endCut);
|
||||||
let off = refOff - total / 2;
|
let off = refOff - total / 2;
|
||||||
for (const layer of wt.layers) {
|
for (const layer of wt.layers) {
|
||||||
const comp = getComponent(project, layer.componentId);
|
const comp = getComponent(project, layer.componentId);
|
||||||
@@ -675,12 +702,16 @@ function addWallPoche(
|
|||||||
strokeWidthMm: layerLineMm,
|
strokeWidthMm: layerLineMm,
|
||||||
hatch: resolveHatch(project, comp.hatchId),
|
hatch: resolveHatch(project, comp.hatchId),
|
||||||
greyed,
|
greyed,
|
||||||
|
// Die Diagonale am Knoten NICHT stricheln → keine 45°-Naht zwischen
|
||||||
|
// gleichfarbigen Schichten der beiden Wände.
|
||||||
|
noStrokeEdges: bandJoinEdges,
|
||||||
wallId,
|
wallId,
|
||||||
});
|
});
|
||||||
off += layer.thickness;
|
off += layer.thickness;
|
||||||
}
|
}
|
||||||
// Kräftige Wand-Umrisslinie über die volle Dicke (nur Kontur, keine Füllung),
|
// 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({
|
out.push({
|
||||||
kind: "polygon",
|
kind: "polygon",
|
||||||
pts: clippedBand(p1, p2, refOff - total / 2, refOff + total / 2, startCut, endCut),
|
pts: clippedBand(p1, p2, refOff - total / 2, refOff + total / 2, startCut, endCut),
|
||||||
@@ -689,6 +720,7 @@ function addWallPoche(
|
|||||||
strokeWidthMm: outlineMm,
|
strokeWidthMm: outlineMm,
|
||||||
hatch: NO_HATCH,
|
hatch: NO_HATCH,
|
||||||
greyed,
|
greyed,
|
||||||
|
noStrokeEdges: bandJoinEdges,
|
||||||
wallId,
|
wallId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
import type { Primitive } from '../generatePlan';
|
import type { Primitive } from '../generatePlan';
|
||||||
import type { Vec2 } from '../../model/types';
|
import type { Vec2 } from '../../model/types';
|
||||||
import type { GpuGeometry, Rgba } from './glPlanTypes';
|
import type { GpuGeometry, Rgba } from './glPlanTypes';
|
||||||
import { applyDash, buildHatchSegments } from './glPlanHatch';
|
import { applyDashRuns, buildHatchRuns } from './glPlanHatch';
|
||||||
|
|
||||||
const PX_PER_M = 90;
|
const PX_PER_M = 90;
|
||||||
|
|
||||||
@@ -247,6 +247,44 @@ export function compilePrimitivesToGpu(
|
|||||||
const pushLine = (aM: Vec2, bM: Vec2, color: Rgba, strokeMm: number) =>
|
const pushLine = (aM: Vec2, bM: Vec2, color: Rgba, strokeMm: number) =>
|
||||||
strokePolyline([aM, bM], false, color, strokeMm);
|
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) {
|
for (const prim of primitives) {
|
||||||
if (prim.kind === 'polygon') {
|
if (prim.kind === 'polygon') {
|
||||||
// Füllung (falls vorhanden).
|
// Füllung (falls vorhanden).
|
||||||
@@ -266,21 +304,23 @@ export function compilePrimitivesToGpu(
|
|||||||
}
|
}
|
||||||
// Schraffur (nach der Füllung, vor/passend zum Umriss — wie der SVG stapelt):
|
// Schraffur (nach der Füllung, vor/passend zum Umriss — wie der SVG stapelt):
|
||||||
// das Muster wird in echte Linien-Geometrie (Modell-Meter) tesselliert und
|
// das Muster wird in echte Linien-Geometrie (Modell-Meter) tesselliert und
|
||||||
// aufs Polygon geclippt (konkav-fähig, even-odd). Musterlinien in derselben
|
// aufs Polygon geclippt (konkav-fähig, even-odd). Musterlinien fließen als
|
||||||
// Papier-mm-Breite wie der SVG-`<pattern>`; `hatch.dash` wird geometrisch
|
// zusammenhängende Läufe in `strokePolyline` → GEHRTE, glatte Übergänge
|
||||||
// (Strich/Lücke) aufgelöst, da die GL-Linien-Pipeline kein Dash kennt.
|
// (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') {
|
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 hatchColor = parseColor(prim.hatch.color) ?? [0.1, 0.1, 0.1, 1];
|
||||||
const hatchMm = prim.hatch.lineWeight > 0 ? prim.hatch.lineWeight : 0.13;
|
const hatchMm = prim.hatch.lineWeight > 0 ? prim.hatch.lineWeight : 0.13;
|
||||||
const segs = applyDash(buildHatchSegments(prim.pts, prim.hatch), prim.hatch.dash);
|
const runs = applyDashRuns(buildHatchRuns(prim.pts, prim.hatch), prim.hatch.dash);
|
||||||
for (const s of segs) pushLine(s.a, s.b, hatchColor, hatchMm);
|
for (const run of runs) strokePolyline(run, false, hatchColor, hatchMm);
|
||||||
}
|
}
|
||||||
// Umriss (crispe Kante) — geschlossener Ring, GEHRT (kein Stufen-Cap an
|
// Umriss (crispe Kante) — geschlossener Ring, GEHRT (kein Stufen-Cap an
|
||||||
// Ecken). Breite = ECHTE Papier-mm; der Renderer rechnet massstabs-/
|
// Ecken). Breite = ECHTE Papier-mm; der Renderer rechnet massstabs-/
|
||||||
// zoomrichtig in px (wie SVG-printStrokeVb → GL == SVG).
|
// zoomrichtig in px (wie SVG-printStrokeVb → GL == SVG).
|
||||||
const stroke = parseColor(prim.stroke);
|
const stroke = parseColor(prim.stroke);
|
||||||
if (stroke && prim.strokeWidthMm > 0 && prim.pts.length >= 2) {
|
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') {
|
} else if (prim.kind === 'line') {
|
||||||
const color = parseColor(prim.color) ?? [0.1, 0.1, 0.1, 1];
|
const color = parseColor(prim.color) ?? [0.1, 0.1, 0.1, 1];
|
||||||
|
|||||||
@@ -6,11 +6,13 @@
|
|||||||
|
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import {
|
import {
|
||||||
applyDash,
|
applyDashRuns,
|
||||||
buildHatchSegments,
|
buildHatchRuns,
|
||||||
clipLineToPolygon,
|
clipLineToPolygon,
|
||||||
|
clipPolylineToPolygon,
|
||||||
clipSegmentToPolygon,
|
clipSegmentToPolygon,
|
||||||
hatchLineFamily,
|
hatchLineFamily,
|
||||||
|
type HatchRun,
|
||||||
type HatchSegment,
|
type HatchSegment,
|
||||||
} from './glPlanHatch';
|
} from './glPlanHatch';
|
||||||
import type { Vec2 } from '../../model/types';
|
import type { Vec2 } from '../../model/types';
|
||||||
@@ -23,6 +25,17 @@ function totalLen(segs: HatchSegment[]): number {
|
|||||||
return l;
|
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. */
|
/** Basis-Hatch mit Überschreibungen. */
|
||||||
function hatch(over: Partial<HatchRender>): HatchRender {
|
function hatch(over: Partial<HatchRender>): HatchRender {
|
||||||
return { pattern: 'diagonal', scale: 1, angle: 0, color: '#000', lineWeight: 0.13, dash: null, ...over };
|
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', () => {
|
describe('hatchLineFamily', () => {
|
||||||
const sq: Vec2[] = [
|
const sq: Vec2[] = [
|
||||||
{ x: 0, y: 0 },
|
{ x: 0, y: 0 },
|
||||||
@@ -158,7 +271,7 @@ describe('hatchLineFamily', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('buildHatchSegments', () => {
|
describe('buildHatchRuns', () => {
|
||||||
const sq: Vec2[] = [
|
const sq: Vec2[] = [
|
||||||
{ x: 0, y: 0 },
|
{ x: 0, y: 0 },
|
||||||
{ x: 1, y: 0 },
|
{ x: 1, y: 0 },
|
||||||
@@ -166,20 +279,22 @@ describe('buildHatchSegments', () => {
|
|||||||
{ x: 0, y: 1 },
|
{ x: 0, y: 1 },
|
||||||
];
|
];
|
||||||
|
|
||||||
it('solid/none erzeugen KEINE Musterlinien (Füllung/Umriss übernimmt der Aufrufer)', () => {
|
it('solid/none erzeugen KEINE Läufe (Füllung/Umriss übernimmt der Aufrufer)', () => {
|
||||||
expect(buildHatchSegments(sq, hatch({ pattern: 'none' }))).toEqual([]);
|
expect(buildHatchRuns(sq, hatch({ pattern: 'none' }))).toEqual([]);
|
||||||
expect(buildHatchSegments(sq, hatch({ pattern: 'solid' }))).toEqual([]);
|
expect(buildHatchRuns(sq, hatch({ pattern: 'solid' }))).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('diagonal erzeugt eine einzelne Schar; crosshatch mehr (zwei Scharen)', () => {
|
it('diagonal erzeugt eine einzelne Schar 2-Punkt-Läufe; crosshatch mehr (zwei Scharen)', () => {
|
||||||
const diag = buildHatchSegments(sq, hatch({ pattern: 'diagonal' }));
|
const diag = buildHatchRuns(sq, hatch({ pattern: 'diagonal' }));
|
||||||
const cross = buildHatchSegments(sq, hatch({ pattern: 'crosshatch' }));
|
const cross = buildHatchRuns(sq, hatch({ pattern: 'crosshatch' }));
|
||||||
expect(diag.length).toBeGreaterThan(0);
|
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);
|
expect(cross.length).toBeGreaterThan(diag.length);
|
||||||
// Alle Segmente liegen im Einheitsquadrat (Clipping wirkt).
|
// Alle Lauf-Punkte liegen im Einheitsquadrat (Clipping wirkt).
|
||||||
for (const s of cross) {
|
for (const r of cross) {
|
||||||
for (const p of [s.a, s.b]) {
|
for (const p of r) {
|
||||||
expect(p.x).toBeGreaterThanOrEqual(-1e-6);
|
expect(p.x).toBeGreaterThanOrEqual(-1e-6);
|
||||||
expect(p.x).toBeLessThanOrEqual(1 + 1e-6);
|
expect(p.x).toBeLessThanOrEqual(1 + 1e-6);
|
||||||
expect(p.y).toBeGreaterThanOrEqual(-1e-6);
|
expect(p.y).toBeGreaterThanOrEqual(-1e-6);
|
||||||
@@ -188,11 +303,14 @@ describe('buildHatchSegments', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('insulation erzeugt tessellierte Wellen-Segmente innerhalb des Polygons', () => {
|
it('insulation erzeugt DURCHGEHENDE Wellen-Läufe (≥3 Punkte) innerhalb des Polygons', () => {
|
||||||
const segs = buildHatchSegments(sq, hatch({ pattern: 'insulation' }));
|
const runs = buildHatchRuns(sq, hatch({ pattern: 'insulation' }));
|
||||||
expect(segs.length).toBeGreaterThan(0);
|
expect(runs.length).toBeGreaterThan(0);
|
||||||
for (const s of segs) {
|
// Mindestens ein Lauf ist eine echte Polylinie (nicht zerrissen in 2-Punkt-Stücke).
|
||||||
for (const p of [s.a, s.b]) {
|
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).toBeGreaterThanOrEqual(-1e-6);
|
||||||
expect(p.x).toBeLessThanOrEqual(1 + 1e-6);
|
expect(p.x).toBeLessThanOrEqual(1 + 1e-6);
|
||||||
}
|
}
|
||||||
@@ -200,30 +318,50 @@ describe('buildHatchSegments', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('größerer scale → weniger Musterlinien (weitere Teilung)', () => {
|
it('größerer scale → weniger Musterlinien (weitere Teilung)', () => {
|
||||||
const fine = buildHatchSegments(sq, hatch({ pattern: 'diagonal', scale: 1 }));
|
const fine = buildHatchRuns(sq, hatch({ pattern: 'diagonal', scale: 1 }));
|
||||||
const coarse = buildHatchSegments(sq, hatch({ pattern: 'diagonal', scale: 4 }));
|
const coarse = buildHatchRuns(sq, hatch({ pattern: 'diagonal', scale: 4 }));
|
||||||
expect(coarse.length).toBeLessThan(fine.length);
|
expect(coarse.length).toBeLessThan(fine.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('scale ≤ 0 wird auf 1 geklemmt (keine leere/entartete Kachel)', () => {
|
it('scale ≤ 0 wird auf 1 geklemmt (keine leere/entartete Kachel)', () => {
|
||||||
const segs = buildHatchSegments(sq, hatch({ pattern: 'diagonal', scale: 0 }));
|
const runs = buildHatchRuns(sq, hatch({ pattern: 'diagonal', scale: 0 }));
|
||||||
expect(segs.length).toBeGreaterThan(0);
|
expect(runs.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('applyDash', () => {
|
describe('applyDashRuns', () => {
|
||||||
it('ohne Muster (null/leer) bleiben Segmente unverändert', () => {
|
it('ohne Muster (null/leer) bleiben Läufe unverändert', () => {
|
||||||
const segs: HatchSegment[] = [{ a: { x: 0, y: 0 }, b: { x: 10, y: 0 } }];
|
const runs: HatchRun[] = [[{ x: 0, y: 0 }, { x: 10, y: 0 }]];
|
||||||
expect(applyDash(segs, null)).toBe(segs);
|
expect(applyDashRuns(runs, null)).toBe(runs);
|
||||||
expect(applyDash(segs, [])).toBe(segs);
|
expect(applyDashRuns(runs, [])).toBe(runs);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('zerlegt ein Segment in Strich/Lücke (Summe der Striche < Gesamtlänge)', () => {
|
it('zerlegt einen Lauf in Strich/Lücke (Summe der Striche < Gesamtlänge)', () => {
|
||||||
const segs: HatchSegment[] = [{ a: { x: 0, y: 0 }, b: { x: 10, y: 0 } }];
|
const runs: HatchRun[] = [[{ x: 0, y: 0 }, { x: 10, y: 0 }]];
|
||||||
const dashed = applyDash(segs, [0.13, 0.13]); // gleich lang an/aus
|
const dashed = applyDashRuns(runs, [0.13, 0.13]); // gleich lang an/aus
|
||||||
expect(dashed.length).toBeGreaterThan(1);
|
expect(dashed.length).toBeGreaterThan(1);
|
||||||
// Nur die „an"-Stücke bleiben → Gesamtlänge kleiner als das Original (10).
|
// Nur die „an"-Stücke bleiben → Gesamtlänge kleiner als das Original (10).
|
||||||
expect(totalLen(dashed)).toBeGreaterThan(0);
|
expect(totalRunLen(dashed)).toBeGreaterThan(0);
|
||||||
expect(totalLen(dashed)).toBeLessThan(totalLen(segs));
|
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;
|
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).
|
* Achsen-ausgerichtetes Bounding-Rechteck eines Polygons (Modell-Meter).
|
||||||
* Leeres Polygon → degeneriert (min>max), der Aufrufer erzeugt dann keine Linien.
|
* 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;
|
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:
|
* Erzeugt eine Schar paralleler, aufs Polygon geclippter Linien-Segmente:
|
||||||
* Richtung `angleRad` (Modell-Raum, gegen den Uhrzeigersinn), Abstand `spacingM`
|
* 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;
|
const DASH_MM_TO_M = (1 / 0.13) * PX_TO_M;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Zerlegt eine Segment-Schar entlang eines Strichmusters (`dash`, in mm-Papier)
|
* Zerlegt zusammenhängende Läufe entlang eines Strichmusters (`dash`, in mm-
|
||||||
* in die durchgezogenen Teilstücke. `dash` ist eine Sequenz [an, aus, an, …];
|
* Papier) in die durchgezogenen Teil-Läufe. `dash` ist eine Sequenz
|
||||||
* ungerade Längen werden (wie SVG/Canvas) verdoppelt. Leeres/ungültiges Muster
|
* [an, aus, an, …]; ungerade Längen werden (wie SVG/Canvas) verdoppelt. Leeres/
|
||||||
* → Segmente unverändert (durchgezogen). Die Phase läuft je Segment neu bei 0
|
* ungültiges Muster → Läufe unverändert (durchgezogen). Die Phase läuft ENTLANG
|
||||||
* (Segmente sind bereits kurze Musterlinien-Stücke → sichtbar konsistent).
|
* 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[] {
|
export function applyDashRuns(runs: HatchRun[], dash: number[] | null): HatchRun[] {
|
||||||
if (!dash || dash.length === 0) return segs;
|
if (!dash || dash.length === 0) return runs;
|
||||||
// Musterlängen in Meter; nicht-positive/ungültige Werte verwerfen.
|
// 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);
|
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).
|
// Ungerade Länge → verdoppeln (Standard-Dash-Semantik).
|
||||||
const cycle = pat.length % 2 === 0 ? pat : pat.concat(pat);
|
const cycle = pat.length % 2 === 0 ? pat : pat.concat(pat);
|
||||||
const total = cycle.reduce((s, d) => s + d, 0);
|
const total = cycle.reduce((s, d) => s + d, 0);
|
||||||
if (total <= 1e-9) return segs;
|
if (total <= 1e-9) return runs;
|
||||||
|
|
||||||
const out: HatchSegment[] = [];
|
const out: HatchRun[] = [];
|
||||||
for (const seg of segs) {
|
for (const run of runs) {
|
||||||
const dx = seg.b.x - seg.a.x;
|
if (run.length < 2) continue;
|
||||||
const dy = seg.b.y - seg.a.y;
|
// Bogenlänge je Stützpunkt vorab, um Dash-Positionen auf Segmente abzubilden.
|
||||||
const len = Math.hypot(dx, dy);
|
|
||||||
if (len < 1e-12) continue;
|
|
||||||
const ux = dx / len, uy = dy / len;
|
|
||||||
let pos = 0;
|
|
||||||
let idx = 0; // gerade Index = „an", ungerade = „aus"
|
let idx = 0; // gerade Index = „an", ungerade = „aus"
|
||||||
while (pos < len - 1e-9) {
|
let dashLeft = cycle[0]; // verbleibende Länge im aktuellen Dash-Zustand
|
||||||
const remain = cycle[idx % cycle.length];
|
let on = true;
|
||||||
const end = Math.min(len, pos + remain);
|
let piece: Vec2[] | null = [run[0]]; // aktuell offenes „an"-Stück
|
||||||
if (idx % 2 === 0 && end - pos > 1e-9) {
|
|
||||||
out.push({
|
const flush = () => {
|
||||||
a: { x: seg.a.x + ux * pos, y: seg.a.y + uy * pos },
|
if (piece && piece.length >= 2) out.push(piece);
|
||||||
b: { x: seg.a.x + ux * end, y: seg.a.y + uy * end },
|
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++;
|
idx++;
|
||||||
|
dashLeft = cycle[idx % cycle.length];
|
||||||
|
if (on) piece = [a]; // neues „an"-Stück beginnt hier
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,19 +356,21 @@ function toModelAngleRad(screenDeg: number): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Baut die Schraffur eines Polygons als Linien-Segmente (Modell-Meter). Bildet
|
* Baut die Schraffur eines Polygons als zusammenhängende Läufe ({@link HatchRun},
|
||||||
* die SVG-`<pattern>`-Geometrie 1:1 nach:
|
* Modell-Meter). Bildet die SVG-`<pattern>`-Geometrie 1:1 nach:
|
||||||
* • diagonal — eine Schar; SVG-Kachel 8·scale px mit senkrechter Musterlinie
|
* • 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.
|
* • crosshatch — zwei Scharen (90° versetzt), gleicher Abstand.
|
||||||
* • insulation — weiche Wellenlinie, Kachel 14×10·scale px, gedreht um `angle`;
|
* • 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).
|
* • solid/none — keine Linien (Vollfüllung/Umriss übernimmt der Aufrufer).
|
||||||
*
|
*
|
||||||
* Skala 0/negativ wird auf 1 geklemmt (kein Division-durch-Null, keine leere
|
* Skala 0/negativ wird auf 1 geklemmt (kein Division-durch-Null, keine leere
|
||||||
* Kachel), damit degeneriert konfigurierte Hatches trotzdem sichtbar bleiben.
|
* 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 [];
|
if (poly.length < 3) return [];
|
||||||
const pattern = hatch.pattern;
|
const pattern = hatch.pattern;
|
||||||
if (pattern === 'none' || pattern === 'solid') return [];
|
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;
|
const scale = hatch.scale > 1e-6 ? hatch.scale : 1;
|
||||||
|
|
||||||
if (pattern === 'insulation') {
|
if (pattern === 'insulation') {
|
||||||
return insulationSegments(poly, hatch, scale);
|
return insulationRuns(poly, hatch, scale);
|
||||||
}
|
}
|
||||||
|
|
||||||
// diagonal / crosshatch: parallele Geradenscharen.
|
// diagonal / crosshatch: parallele Geradenscharen.
|
||||||
@@ -296,14 +392,15 @@ export function buildHatchSegments(poly: Vec2[], hatch: HatchRender): HatchSegme
|
|||||||
const rot = toModelAngleRad(hatch.angle);
|
const rot = toModelAngleRad(hatch.angle);
|
||||||
const dir1 = rotate({ x: 0, y: -1 }, rot);
|
const dir1 = rotate({ x: 0, y: -1 }, rot);
|
||||||
const a1 = Math.atan2(dir1.y, dir1.x);
|
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') {
|
if (pattern === 'crosshatch') {
|
||||||
// Zweite Schar: die SVG-Kachel trägt zusätzlich eine horizontale Linie
|
// Zweite Schar: die SVG-Kachel trägt zusätzlich eine horizontale Linie
|
||||||
// (x-Achse der Kachel) → 90° zur ersten, gleicher Abstand.
|
// (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
|
* 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
|
* Schar paralleler „Musterachsen" im Abstand der Kachelhöhe (10·scale px), auf
|
||||||
* jeder Achse eine tessellierte Sinuswelle mit Wellenlänge = Kachelbreite
|
* jeder Achse eine tessellierte Sinuswelle mit Wellenlänge = Kachelbreite
|
||||||
* (14·scale px) und weicher Amplitude. Jede Wellen-Polylinie wird Segment für
|
* (14·scale px) und weicher Amplitude. Jede Wellen-Polylinie wird als
|
||||||
* Segment aufs Polygon geclippt (even-odd), sodass die Welle exakt an den
|
* DURCHGEHENDER Zug aufs Polygon geclippt ({@link clipPolylineToPolygon}), sodass
|
||||||
* Polygonkanten endet.
|
* 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 bb = bbox(poly);
|
||||||
const rot = toModelAngleRad(hatch.angle);
|
const rot = toModelAngleRad(hatch.angle);
|
||||||
|
|
||||||
@@ -341,11 +440,12 @@ function insulationSegments(poly: Vec2[], hatch: HatchRender, scale: number): Ha
|
|||||||
}
|
}
|
||||||
if (!(uMax > uMin) || !(vMax > vMin)) return [];
|
if (!(uMax > uMin) || !(vMax > vMin)) return [];
|
||||||
|
|
||||||
// Feinheit der Wellen-Tessellierung (Segmente je Wellenlänge).
|
// Feinheit der Wellen-Tessellierung (Segmente je Wellenlänge). Höher als beim
|
||||||
const STEPS = 12;
|
// segmentweisen Ansatz, da die Läufe jetzt glatt gehrt werden → weichere Welle.
|
||||||
|
const STEPS = 22;
|
||||||
const du = wavelength / STEPS;
|
const du = wavelength / STEPS;
|
||||||
|
|
||||||
const out: HatchSegment[] = [];
|
const runs: HatchRun[] = [];
|
||||||
const toWorld = (u: number, v: number): Vec2 => ({
|
const toWorld = (u: number, v: number): Vec2 => ({
|
||||||
x: center.x + uDir.x * u + vDir.x * v,
|
x: center.x + uDir.x * u + vDir.x * v,
|
||||||
y: center.y + uDir.y * u + vDir.y * 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++) {
|
for (let r = firstRow; r <= lastRow; r++) {
|
||||||
const vBase = r * rowGap;
|
const vBase = r * rowGap;
|
||||||
// Eine Welle als Polylinie tessellieren, dann JEDES Segment clippen.
|
// Ganze Welle als EINE Polylinie tessellieren, dann DURCHGEHEND clippen.
|
||||||
let prev: Vec2 | null = null;
|
const wavePts: Vec2[] = [];
|
||||||
for (let u = uStart; u <= uEnd + 1e-9; u += du) {
|
for (let u = uStart; u <= uEnd + 1e-9; u += du) {
|
||||||
const wave = amplitude * Math.sin((2 * Math.PI * u) / wavelength);
|
const wave = amplitude * Math.sin((2 * Math.PI * u) / wavelength);
|
||||||
const pt = toWorld(u, vBase + wave);
|
wavePts.push(toWorld(u, vBase + wave));
|
||||||
if (prev) {
|
|
||||||
for (const s of clipSegmentToPolygon(prev, pt, poly)) out.push(s);
|
|
||||||
}
|
}
|
||||||
prev = pt;
|
for (const run of clipPolylineToPolygon(wavePts, poly)) runs.push(run);
|
||||||
}
|
}
|
||||||
}
|
return runs;
|
||||||
return out;
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user