Schraffur/Linien: Rendering der neuen Typen (Bild, Random, Zickzack)
Bild-Schraffur als getiltes <pattern>/<image> (scaleX/scaleY/rotation) im Live-SVG- und Print-Pfad; GL/WASM/DXF vorerst neutraler Fallback (Folgearbeit, im Code vermerkt). Random-Vektor-Schraffur als deterministische Streu-Striche (mulberry32-Seed aus Flaechen-Bounding-Box, kein Math.random) in allen Pfaden. Zickzack-Linie als getilteter Pfad; LineStyle.kind/zigzag additiv durch die Linien-Emission (generatePlan) bis zu den Renderern durchgereicht. Geteilte Geometrie in glPlanHatch (scatterStrokes/buildRandomHatchRuns/zigzagPoints) — eine Wahrheit fuer Live/Print/GL/DXF. 8 neue Tests.
This commit is contained in:
+21
-1
@@ -12,6 +12,7 @@ import type { Plan, Primitive } from "../plan/generatePlan";
|
|||||||
import type { LayerCategory, Project, Vec2 } from "../model/types";
|
import type { LayerCategory, Project, Vec2 } from "../model/types";
|
||||||
import { flattenCategories } from "../model/types";
|
import { flattenCategories } from "../model/types";
|
||||||
import { DxfWriter } from "./dxfWriter";
|
import { DxfWriter } from "./dxfWriter";
|
||||||
|
import { buildRandomHatchRuns, zigzagPoints } from "../plan/glPlan/glPlanHatch";
|
||||||
|
|
||||||
export interface ExportDxfOptions {
|
export interface ExportDxfOptions {
|
||||||
/** Titel/Geschossname (nur als Kommentar-freundlicher Dateiname genutzt). */
|
/** Titel/Geschossname (nur als Kommentar-freundlicher Dateiname genutzt). */
|
||||||
@@ -106,7 +107,15 @@ function emitPrimitive(
|
|||||||
): void {
|
): void {
|
||||||
switch (p.kind) {
|
switch (p.kind) {
|
||||||
case "line":
|
case "line":
|
||||||
|
if (p.zigzag) {
|
||||||
|
// Zickzack-Linie → offene Polylinie (amplitude/wavelength Papier-mm →
|
||||||
|
// Modell-Meter über dieselbe Kopplung wie das Strichmuster oben).
|
||||||
|
const amp = p.zigzag.amplitude * DASH_MM_TO_M;
|
||||||
|
const wav = p.zigzag.wavelength * DASH_MM_TO_M;
|
||||||
|
dxf.addPolyline(layer, zigzagPoints(p.a, p.b, amp, wav), false);
|
||||||
|
} else {
|
||||||
dxf.addLine(layer, p.a, p.b);
|
dxf.addLine(layer, p.a, p.b);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "arc":
|
case "arc":
|
||||||
@@ -121,13 +130,24 @@ function emitPrimitive(
|
|||||||
dxf.addPolyline(layer, pts, true);
|
dxf.addPolyline(layer, pts, true);
|
||||||
// Schraffur als geklippte Linien (optional). Vollfüllung/„none" tragen keine
|
// Schraffur als geklippte Linien (optional). Vollfüllung/„none" tragen keine
|
||||||
// sichtbaren Musterlinien → wir geben nur echte Muster (nicht solid/none) aus.
|
// sichtbaren Musterlinien → wir geben nur echte Muster (nicht solid/none) aus.
|
||||||
|
// Bild-Schraffur (kind==="image"): DXF trägt keine Rasterbilder →
|
||||||
|
// FALLBACK ohne Musterlinien (nur die Umriss-Polylinie oben). Als
|
||||||
|
// Folge-Arbeit dokumentiert. Random-Vektor: deterministische Streu-Striche
|
||||||
|
// (Modell-Meter, identischer Seed wie Bildschirm/PDF).
|
||||||
if (
|
if (
|
||||||
includeHatches &&
|
includeHatches &&
|
||||||
|
p.hatch.kind !== "image" &&
|
||||||
p.hatch.pattern !== "none" &&
|
p.hatch.pattern !== "none" &&
|
||||||
p.hatch.pattern !== "solid"
|
p.hatch.pattern !== "solid"
|
||||||
) {
|
) {
|
||||||
const cycle = dashCycleMeters(p.hatch.dash);
|
const cycle = dashCycleMeters(p.hatch.dash);
|
||||||
for (const seg of hatchSegments(pts, p.hatch)) {
|
const segs: Seg[] =
|
||||||
|
p.hatch.lines === "random"
|
||||||
|
? buildRandomHatchRuns(pts, p.hatch)
|
||||||
|
.filter((r) => r.length >= 2)
|
||||||
|
.map((r) => ({ a: r[0], b: r[r.length - 1] }))
|
||||||
|
: hatchSegments(pts, p.hatch);
|
||||||
|
for (const seg of segs) {
|
||||||
if (cycle) {
|
if (cycle) {
|
||||||
for (const d of dashSeg(seg, cycle)) dxf.addLine(LAYER_HATCH, d.a, d.b);
|
for (const d of dashSeg(seg, cycle)) dxf.addLine(LAYER_HATCH, d.a, d.b);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -23,10 +23,75 @@
|
|||||||
|
|
||||||
import type { HatchRender, Plan, Primitive } from "../plan/generatePlan";
|
import type { HatchRender, Plan, Primitive } from "../plan/generatePlan";
|
||||||
import type { Vec2 } from "../model/types";
|
import type { Vec2 } from "../model/types";
|
||||||
|
import { buildRandomHatchRuns, zigzagPoints } from "../plan/glPlan/glPlanHatch";
|
||||||
|
|
||||||
/** SVG-Namespace für die programmatisch erzeugten Knoten. */
|
/** SVG-Namespace für die programmatisch erzeugten Knoten. */
|
||||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||||
|
|
||||||
|
/** Basis-Kachelmaß (mm) einer Bild-Schraffur bei scaleX/scaleY = 1. */
|
||||||
|
const IMG_TILE_MM = 20;
|
||||||
|
|
||||||
|
/** Monoton wachsender Zähler für eindeutige `<pattern>`-IDs im Dokument. */
|
||||||
|
let patternSeq = 0;
|
||||||
|
|
||||||
|
/** Liefert (oder erzeugt) das gemeinsame `<defs>`-Element des SVG. */
|
||||||
|
function ensureDefs(svg: SVGSVGElement): SVGDefsElement {
|
||||||
|
const existing = svg.querySelector("defs");
|
||||||
|
if (existing) return existing as SVGDefsElement;
|
||||||
|
const defs = document.createElementNS(SVG_NS, "defs") as SVGDefsElement;
|
||||||
|
// Vor den Inhalt setzen, damit Referenzen (url(#…)) sicher aufgelöst werden.
|
||||||
|
svg.insertBefore(defs, svg.firstChild);
|
||||||
|
return defs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt ein gekacheltes Bild-`<pattern>` an und liefert dessen ID. `scaleX`/
|
||||||
|
* `scaleY` verzerren die Kachel unabhängig in L×B (preserveAspectRatio="none"),
|
||||||
|
* `rotation` (Grad) dreht das Muster (patternTransform). Einheiten = Papier-mm.
|
||||||
|
*/
|
||||||
|
function ensureImagePattern(
|
||||||
|
svg: SVGSVGElement,
|
||||||
|
image: NonNullable<HatchRender["image"]>,
|
||||||
|
): string {
|
||||||
|
const id = `imgpat-${patternSeq++}`;
|
||||||
|
const w = Math.max(0.5, IMG_TILE_MM * (image.scaleX > 0 ? image.scaleX : 1));
|
||||||
|
const h = Math.max(0.5, IMG_TILE_MM * (image.scaleY > 0 ? image.scaleY : 1));
|
||||||
|
const pattern = document.createElementNS(SVG_NS, "pattern");
|
||||||
|
pattern.setAttribute("id", id);
|
||||||
|
pattern.setAttribute("patternUnits", "userSpaceOnUse");
|
||||||
|
pattern.setAttribute("width", String(round(w)));
|
||||||
|
pattern.setAttribute("height", String(round(h)));
|
||||||
|
if (image.rotation) pattern.setAttribute("patternTransform", `rotate(${image.rotation})`);
|
||||||
|
const img = document.createElementNS(SVG_NS, "image");
|
||||||
|
img.setAttribute("href", image.src);
|
||||||
|
img.setAttribute("x", "0");
|
||||||
|
img.setAttribute("y", "0");
|
||||||
|
img.setAttribute("width", String(round(w)));
|
||||||
|
img.setAttribute("height", String(round(h)));
|
||||||
|
img.setAttribute("preserveAspectRatio", "none");
|
||||||
|
pattern.appendChild(img);
|
||||||
|
ensureDefs(svg).appendChild(pattern);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `<polyline>`-Knoten (mm-Koordinaten) — für Zickzack-Linien/-Läufe. */
|
||||||
|
function polylineNode(
|
||||||
|
pts: Vec2[],
|
||||||
|
color: string,
|
||||||
|
strokeMm: number,
|
||||||
|
dash: number[] | null | undefined,
|
||||||
|
): SVGElement {
|
||||||
|
const el = document.createElementNS(SVG_NS, "polyline");
|
||||||
|
el.setAttribute("points", pts.map((p) => `${round(p.x)},${round(p.y)}`).join(" "));
|
||||||
|
el.setAttribute("fill", "none");
|
||||||
|
el.setAttribute("stroke", color);
|
||||||
|
el.setAttribute("stroke-width", String(strokeMm));
|
||||||
|
el.setAttribute("stroke-linecap", "round");
|
||||||
|
el.setAttribute("stroke-linejoin", "round");
|
||||||
|
if (dash && dash.length) el.setAttribute("stroke-dasharray", dash.map((d) => round(d)).join(" "));
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Standard-Stift-Stufen (mm). Beliebige Kategorie-Strichstärken werden auf die
|
* Standard-Stift-Stufen (mm). Beliebige Kategorie-Strichstärken werden auf die
|
||||||
* NÄCHSTHÖHERE dieser ISO-nahen Stufen gequantelt, damit der Plan saubere,
|
* NÄCHSTHÖHERE dieser ISO-nahen Stufen gequantelt, damit der Plan saubere,
|
||||||
@@ -160,7 +225,16 @@ function appendPrimitive(
|
|||||||
case "line": {
|
case "line": {
|
||||||
const a = map(p.a);
|
const a = map(p.a);
|
||||||
const b = map(p.b);
|
const b = map(p.b);
|
||||||
const ln = lineNode(a, b, p.color ?? "#111111", quantizePen(p.weightMm), p.dash, mmPerM);
|
// Zickzack-Linie (amplitude/wavelength sind Papier-mm = mm-Zielraum) →
|
||||||
|
// Polylinie; sonst gerade (ggf. gestrichelte) Linie wie bisher.
|
||||||
|
const ln = p.zigzag
|
||||||
|
? polylineNode(
|
||||||
|
zigzagPoints(a, b, p.zigzag.amplitude, p.zigzag.wavelength),
|
||||||
|
p.color ?? "#111111",
|
||||||
|
quantizePen(p.weightMm),
|
||||||
|
p.dash,
|
||||||
|
)
|
||||||
|
: lineNode(a, b, p.color ?? "#111111", quantizePen(p.weightMm), p.dash, mmPerM);
|
||||||
if (opacity) ln.setAttribute("opacity", opacity);
|
if (opacity) ln.setAttribute("opacity", opacity);
|
||||||
svg.appendChild(ln);
|
svg.appendChild(ln);
|
||||||
break;
|
break;
|
||||||
@@ -195,7 +269,15 @@ function appendPolygon(
|
|||||||
if (opacity) g.setAttribute("opacity", opacity);
|
if (opacity) g.setAttribute("opacity", opacity);
|
||||||
|
|
||||||
const pattern = p.hatch.pattern;
|
const pattern = p.hatch.pattern;
|
||||||
if (pattern === "none") {
|
if (p.hatch.kind === "image" && p.hatch.image) {
|
||||||
|
// Bild-Schraffur: gekacheltes <pattern> mit <image> (scaleX/scaleY/rotation)
|
||||||
|
// über die Fläche, unabhängig vom `pattern`-Feld. Grundfüllung darunter,
|
||||||
|
// Umriss obenauf.
|
||||||
|
if (p.fill !== "none") g.appendChild(polyNode(ptsAttr, p.fill, "none", 0));
|
||||||
|
const patId = ensureImagePattern(svg, p.hatch.image);
|
||||||
|
g.appendChild(polyNode(ptsAttr, `url(#${patId})`, "none", 0));
|
||||||
|
if (strokeMm > 0) g.appendChild(polyNode(ptsAttr, "none", p.stroke, strokeMm));
|
||||||
|
} else if (pattern === "none") {
|
||||||
g.appendChild(polyNode(ptsAttr, p.fill, p.stroke, strokeMm));
|
g.appendChild(polyNode(ptsAttr, p.fill, p.stroke, strokeMm));
|
||||||
} else if (pattern === "solid") {
|
} else if (pattern === "solid") {
|
||||||
g.appendChild(polyNode(ptsAttr, p.hatch.color, p.stroke, strokeMm));
|
g.appendChild(polyNode(ptsAttr, p.hatch.color, p.stroke, strokeMm));
|
||||||
@@ -205,6 +287,24 @@ function appendPolygon(
|
|||||||
if (p.fill !== "none") {
|
if (p.fill !== "none") {
|
||||||
g.appendChild(polyNode(ptsAttr, p.fill, "none", 0));
|
g.appendChild(polyNode(ptsAttr, p.fill, "none", 0));
|
||||||
}
|
}
|
||||||
|
if (p.hatch.lines === "random") {
|
||||||
|
// Random-Vektor (Kies/Splitt): deterministische Streu-Striche. Wird im
|
||||||
|
// MODELL-Raum erzeugt (identischer Seed/Geometrie wie Bildschirm/GL/PDF)
|
||||||
|
// und dann nach Papier-mm abgebildet.
|
||||||
|
for (const run of buildRandomHatchRuns(p.pts, p.hatch)) {
|
||||||
|
if (run.length < 2) continue;
|
||||||
|
g.appendChild(
|
||||||
|
lineNode(
|
||||||
|
map(run[0]),
|
||||||
|
map(run[run.length - 1]),
|
||||||
|
p.hatch.color,
|
||||||
|
quantizePen(p.hatch.lineWeight),
|
||||||
|
null,
|
||||||
|
mmPerM,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
for (const seg of hatchLines(ptsMm, p.hatch, mmPerM)) {
|
for (const seg of hatchLines(ptsMm, p.hatch, mmPerM)) {
|
||||||
g.appendChild(
|
g.appendChild(
|
||||||
lineNode(
|
lineNode(
|
||||||
@@ -217,6 +317,7 @@ function appendPolygon(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (strokeMm > 0) {
|
if (strokeMm > 0) {
|
||||||
g.appendChild(polyNode(ptsAttr, "none", p.stroke, strokeMm));
|
g.appendChild(polyNode(ptsAttr, "none", p.stroke, strokeMm));
|
||||||
}
|
}
|
||||||
|
|||||||
+122
-2
@@ -20,6 +20,7 @@ import type { DraftShape, SnapResult, ToolHandlers, ToolId, ToolMods } from "../
|
|||||||
import { useGlPlanRenderer } from "./useGlPlanRenderer";
|
import { useGlPlanRenderer } from "./useGlPlanRenderer";
|
||||||
import { useWasmPlanRenderer } from "./useWasmPlanRenderer";
|
import { useWasmPlanRenderer } from "./useWasmPlanRenderer";
|
||||||
import { quantizePen } from "../export/sceneToPrintSvg";
|
import { quantizePen } from "../export/sceneToPrintSvg";
|
||||||
|
import { buildRandomHatchRuns, zigzagPoints, DASH_MM_TO_M } from "./glPlan/glPlanHatch";
|
||||||
|
|
||||||
const PX_PER_M = 90; // viewBox-Einheiten je Meter (Modell → SVG-Benutzerraum)
|
const PX_PER_M = 90; // viewBox-Einheiten je Meter (Modell → SVG-Benutzerraum)
|
||||||
const PAD = 60; // Rand in viewBox-Einheiten
|
const PAD = 60; // Rand in viewBox-Einheiten
|
||||||
@@ -1601,7 +1602,7 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
|||||||
const hatched = useMemo(() => {
|
const hatched = useMemo(() => {
|
||||||
const list: HatchedPoly[] = [];
|
const list: HatchedPoly[] = [];
|
||||||
plan.primitives.forEach((p, i) => {
|
plan.primitives.forEach((p, i) => {
|
||||||
if (p.kind === "polygon" && needsPattern(p.hatch.pattern)) {
|
if (p.kind === "polygon" && polyUsesPattern(p.hatch)) {
|
||||||
list.push({ patternId: `hatch-${i}`, hatch: p.hatch });
|
list.push({ patternId: `hatch-${i}`, hatch: p.hatch });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -2366,6 +2367,9 @@ function onSegment(a: Vec2, b: Vec2, p: Vec2): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Basis-Kachelmaß (viewBox-Einheiten) einer Bild-Schraffur bei scaleX/scaleY = 1. */
|
||||||
|
const IMG_TILE_VB = 40;
|
||||||
|
|
||||||
/** Lineare/Kreuz-Muster brauchen ein <pattern>; solid/none nicht. */
|
/** Lineare/Kreuz-Muster brauchen ein <pattern>; solid/none nicht. */
|
||||||
function needsPattern(pattern: HatchRender["pattern"]): boolean {
|
function needsPattern(pattern: HatchRender["pattern"]): boolean {
|
||||||
return (
|
return (
|
||||||
@@ -2375,6 +2379,19 @@ function needsPattern(pattern: HatchRender["pattern"]): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ob ein Polygon ein SVG-`<pattern>` (in `<defs>`) braucht:
|
||||||
|
* • Bild-Schraffur (`kind==="image"`) → ja (gekacheltes Bild-Muster).
|
||||||
|
* • Random-Vektor (`lines==="random"`) → NEIN: die Streu-Striche werden als
|
||||||
|
* geclippte Polylinien direkt gezeichnet (Determinismus, Parität zu GL/PDF).
|
||||||
|
* • sonst die klassischen Parallel-/Kreuz-/Dämmungs-Muster.
|
||||||
|
*/
|
||||||
|
function polyUsesPattern(h: HatchRender): boolean {
|
||||||
|
if (h.kind === "image") return !!h.image;
|
||||||
|
if (h.lines === "random") return false;
|
||||||
|
return needsPattern(h.pattern);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ein parametrisiertes Schraffur-<pattern>. Maßstab skaliert die Kachelgröße,
|
* Ein parametrisiertes Schraffur-<pattern>. Maßstab skaliert die Kachelgröße,
|
||||||
* Winkel dreht das Muster (patternTransform), Farbe/Linienstärke kommen aus dem
|
* Winkel dreht das Muster (patternTransform), Farbe/Linienstärke kommen aus dem
|
||||||
@@ -2407,6 +2424,26 @@ function HatchPattern({
|
|||||||
.join(" ")
|
.join(" ")
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
if (hatch.kind === "image" && hatch.image) {
|
||||||
|
// Bild-Schraffur: gekacheltes <image>-Muster. Kachel-Basismaß in viewBox-
|
||||||
|
// Einheiten, unabhängig in L×B verzerrt (scaleX/scaleY), um `rotation` gedreht.
|
||||||
|
// preserveAspectRatio="none" erlaubt die Verzerrung.
|
||||||
|
const img = hatch.image;
|
||||||
|
const w = Math.max(1, IMG_TILE_VB * (img.scaleX > 0 ? img.scaleX : 1));
|
||||||
|
const h = Math.max(1, IMG_TILE_VB * (img.scaleY > 0 ? img.scaleY : 1));
|
||||||
|
return (
|
||||||
|
<pattern
|
||||||
|
id={id}
|
||||||
|
patternUnits="userSpaceOnUse"
|
||||||
|
width={w}
|
||||||
|
height={h}
|
||||||
|
patternTransform={img.rotation ? `rotate(${img.rotation})` : undefined}
|
||||||
|
>
|
||||||
|
<image href={img.src} x={0} y={0} width={w} height={h} preserveAspectRatio="none" />
|
||||||
|
</pattern>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (hatch.pattern === "insulation") {
|
if (hatch.pattern === "insulation") {
|
||||||
// Weiche Zickzack-/Wellenlinie (SIA-nah). Kachel 14×10 × Maßstab.
|
// Weiche Zickzack-/Wellenlinie (SIA-nah). Kachel 14×10 × Maßstab.
|
||||||
const w = 14 * hatch.scale;
|
const w = 14 * hatch.scale;
|
||||||
@@ -2487,6 +2524,8 @@ interface DrawingRun {
|
|||||||
dash: number[] | null | undefined;
|
dash: number[] | null | undefined;
|
||||||
color?: string;
|
color?: string;
|
||||||
greyed?: boolean;
|
greyed?: boolean;
|
||||||
|
/** Zickzack-Parameter (Papier-mm); gesetzt ⇒ Lauf als Zickzack-Pfad zeichnen. */
|
||||||
|
zigzag?: { amplitude: number; wavelength: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Kleiner Toleranz-Test auf Punktgleichheit (Modell-Meter). */
|
/** Kleiner Toleranz-Test auf Punktgleichheit (Modell-Meter). */
|
||||||
@@ -2541,6 +2580,22 @@ function buildDrawingRuns(prims: Primitive[]): DrawingRun[] {
|
|||||||
flush();
|
flush();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// Zickzack-Linien werden NICHT verkettet (jede als eigener Zickzack-Lauf) —
|
||||||
|
// sonst würde die Tessellierung über eine Gehrung hinweg zerreißen.
|
||||||
|
if (p.zigzag) {
|
||||||
|
flush();
|
||||||
|
runs.push({
|
||||||
|
pts: [p.a, p.b],
|
||||||
|
closed: false,
|
||||||
|
cls: p.cls,
|
||||||
|
weightMm: p.weightMm,
|
||||||
|
dash: p.dash,
|
||||||
|
color: p.color,
|
||||||
|
greyed: p.greyed,
|
||||||
|
zigzag: p.zigzag,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (curPts && curSrc && sameLineStyle(curSrc, p) && samePt(curPts[curPts.length - 1], p.a)) {
|
if (curPts && curSrc && sameLineStyle(curSrc, p) && samePt(curPts[curPts.length - 1], p.a)) {
|
||||||
// An den laufenden Zug anhängen.
|
// An den laufenden Zug anhängen.
|
||||||
curPts.push(p.b);
|
curPts.push(p.b);
|
||||||
@@ -2583,7 +2638,17 @@ function DrawingRunShape({
|
|||||||
.map((mm) => (print ? printStrokeVb(mm, paperScale!) : mmToPx(mm)))
|
.map((mm) => (print ? printStrokeVb(mm, paperScale!) : mmToPx(mm)))
|
||||||
.join(" ")
|
.join(" ")
|
||||||
: undefined;
|
: undefined;
|
||||||
const pts = run.pts.map(toScreen).map((s) => `${s.x},${s.y}`).join(" ");
|
// Zickzack-Lauf: im MODELL-Raum tessellieren (amplitude/wavelength Papier-mm →
|
||||||
|
// Modell-Meter via DASH_MM_TO_M), dann toScreen. Sonst die Roh-Stützpunkte.
|
||||||
|
const modelPts = run.zigzag
|
||||||
|
? zigzagPoints(
|
||||||
|
run.pts[0],
|
||||||
|
run.pts[run.pts.length - 1],
|
||||||
|
run.zigzag.amplitude * DASH_MM_TO_M,
|
||||||
|
run.zigzag.wavelength * DASH_MM_TO_M,
|
||||||
|
)
|
||||||
|
: run.pts;
|
||||||
|
const pts = modelPts.map(toScreen).map((s) => `${s.x},${s.y}`).join(" ");
|
||||||
const shape = run.closed ? (
|
const shape = run.closed ? (
|
||||||
<polygon
|
<polygon
|
||||||
points={pts}
|
points={pts}
|
||||||
@@ -2716,6 +2781,40 @@ function renderPrimitive(
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
// Bild-Schraffur: Grundfüllung + gekacheltes Bild-Muster (<pattern> aus
|
||||||
|
// <defs>, id=hatch-index) + Umriss — unabhängig vom `pattern`-Feld.
|
||||||
|
if (p.hatch.kind === "image" && p.hatch.image) {
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
<polygon points={pts} fill={p.fill} stroke="none" />
|
||||||
|
<polygon points={pts} fill={`url(#hatch-${index})`} stroke="none" />
|
||||||
|
{outline()}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Random-Vektor-Schraffur (Kies/Splitt): Grundfüllung + deterministische
|
||||||
|
// Streu-Striche (im MODELL-Raum erzeugt → toScreen; identischer Seed wie
|
||||||
|
// GL/PDF) + Umriss. KEIN <pattern> (die Striche werden direkt gezeichnet).
|
||||||
|
if (p.hatch.kind !== "image" && p.hatch.lines === "random") {
|
||||||
|
const hsw = weight(p.hatch.lineWeight > 0 ? p.hatch.lineWeight : 0.13);
|
||||||
|
const runs = buildRandomHatchRuns(p.pts, p.hatch);
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
<polygon points={pts} fill={p.fill} stroke="none" />
|
||||||
|
{runs.map((run, ri) => (
|
||||||
|
<polyline
|
||||||
|
key={`r${ri}`}
|
||||||
|
points={run.map(toScreen).map((s) => `${s.x},${s.y}`).join(" ")}
|
||||||
|
fill="none"
|
||||||
|
stroke={p.hatch.color}
|
||||||
|
strokeWidth={hsw}
|
||||||
|
vectorEffect={vfx}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{outline()}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
// Ohne Schraffur: reine Component-Füllung bzw. „none" (nur Umriss).
|
// Ohne Schraffur: reine Component-Füllung bzw. „none" (nur Umriss).
|
||||||
if (p.hatch.pattern === "none") {
|
if (p.hatch.pattern === "none") {
|
||||||
return (
|
return (
|
||||||
@@ -2746,6 +2845,27 @@ function renderPrimitive(
|
|||||||
case "line": {
|
case "line": {
|
||||||
const a = toScreen(p.a);
|
const a = toScreen(p.a);
|
||||||
const b = toScreen(p.b);
|
const b = toScreen(p.b);
|
||||||
|
// Zickzack-Linie: als Polylinie (im MODELL-Raum tesselliert → toScreen;
|
||||||
|
// amplitude/wavelength Papier-mm → Modell-Meter via DASH_MM_TO_M, wie
|
||||||
|
// GL/PDF). ADDITIV — gerade/gestrichelte Linien bleiben unverändert.
|
||||||
|
if (p.zigzag) {
|
||||||
|
const zpts = zigzagPoints(
|
||||||
|
p.a,
|
||||||
|
p.b,
|
||||||
|
p.zigzag.amplitude * DASH_MM_TO_M,
|
||||||
|
p.zigzag.wavelength * DASH_MM_TO_M,
|
||||||
|
).map(toScreen);
|
||||||
|
return (
|
||||||
|
<polyline
|
||||||
|
points={zpts.map((s) => `${s.x},${s.y}`).join(" ")}
|
||||||
|
className={p.cls}
|
||||||
|
fill="none"
|
||||||
|
stroke={p.color}
|
||||||
|
strokeWidth={weight(p.weightMm)}
|
||||||
|
vectorEffect={vfx}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
// Strichstärke + Strichmuster in mm Papier. Die Farbe kommt weiterhin aus
|
// Strichstärke + Strichmuster in mm Papier. Die Farbe kommt weiterhin aus
|
||||||
// der CSS-Klasse (z. B. .door-leaf / .wall-axis).
|
// der CSS-Klasse (z. B. .door-leaf / .wall-axis).
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -210,6 +210,13 @@ export type Primitive =
|
|||||||
weightMm: number;
|
weightMm: number;
|
||||||
/** Strichmuster in mm Papier; null/undefined = durchgezogen. */
|
/** Strichmuster in mm Papier; null/undefined = durchgezogen. */
|
||||||
dash?: number[] | null;
|
dash?: number[] | null;
|
||||||
|
/**
|
||||||
|
* Zickzack-/Wellen-Parameter (aus einem LineStyle mit `kind==="zigzag"`),
|
||||||
|
* beide in mm Papier. Ist es gesetzt, zeichnen die Renderer die Linie als
|
||||||
|
* Zickzack-Pfad statt als gerade (ggf. gestrichelte) Linie — ADDITIV, ohne
|
||||||
|
* die bestehenden Strich/Dash-Linien zu verändern (Feld fehlt ⇒ heute).
|
||||||
|
*/
|
||||||
|
zigzag?: { amplitude: number; wavelength: number };
|
||||||
/** Optionale explizite Strichfarbe (überschreibt die CSS-Klasse). */
|
/** Optionale explizite Strichfarbe (überschreibt die CSS-Klasse). */
|
||||||
color?: string;
|
color?: string;
|
||||||
/** ID des 2D-Zeichenelements (für Links-Klick-Auswahl von Drawing2D). */
|
/** ID des 2D-Zeichenelements (für Links-Klick-Auswahl von Drawing2D). */
|
||||||
@@ -773,8 +780,11 @@ function addDrawing2D(
|
|||||||
const color = d.color ?? ls?.color ?? colorByCode.get(d.categoryCode) ?? MONO_INK;
|
const color = d.color ?? ls?.color ?? colorByCode.get(d.categoryCode) ?? MONO_INK;
|
||||||
const weightMm = d.weightMm ?? ls?.weight ?? lwByCode.get(d.categoryCode) ?? WALL_FALLBACK_MM;
|
const weightMm = d.weightMm ?? ls?.weight ?? lwByCode.get(d.categoryCode) ?? WALL_FALLBACK_MM;
|
||||||
const dash = ls?.dash ?? null;
|
const dash = ls?.dash ?? null;
|
||||||
|
// Zickzack-Parameter aus dem LineStyle durchreichen (nur kind==="zigzag").
|
||||||
|
// Additiv: fehlt es, bleibt die Linie gerade/gestrichelt wie bisher.
|
||||||
|
const zigzag = ls?.kind === "zigzag" ? ls.zigzag : undefined;
|
||||||
const seg = (a: Vec2, b: Vec2) =>
|
const seg = (a: Vec2, b: Vec2) =>
|
||||||
out.push({ kind: "line", a, b, cls: "draw2d", weightMm, dash, color, greyed, drawingId: d.id });
|
out.push({ kind: "line", a, b, cls: "draw2d", weightMm, dash, zigzag, color, greyed, drawingId: d.id });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gefüllte Fläche einer GESCHLOSSENEN Form: trägt die Vollton-Füllfarbe
|
* Gefüllte Fläche einer GESCHLOSSENEN Form: trägt die Vollton-Füllfarbe
|
||||||
@@ -1027,6 +1037,8 @@ function addWallPoche(
|
|||||||
weightMm: jls ? jls.weight * LAYER_DETAIL_FACTOR[detail] : layerLineMm,
|
weightMm: jls ? jls.weight * LAYER_DETAIL_FACTOR[detail] : layerLineMm,
|
||||||
color: jls ? jls.color : stroke,
|
color: jls ? jls.color : stroke,
|
||||||
dash: jls ? jls.dash : null,
|
dash: jls ? jls.dash : null,
|
||||||
|
// Zickzack-Fuge (LineStyle kind==="zigzag") ebenfalls durchreichen.
|
||||||
|
zigzag: jls?.kind === "zigzag" ? jls.zigzag : undefined,
|
||||||
greyed,
|
greyed,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { applyDashRuns, buildHatchRuns } from './glPlanHatch';
|
import { applyDashRuns, buildHatchRuns, zigzagPoints, DASH_MM_TO_M } from './glPlanHatch';
|
||||||
|
|
||||||
const PX_PER_M = 90;
|
const PX_PER_M = 90;
|
||||||
|
|
||||||
@@ -405,7 +405,15 @@ export function compilePrimitivesToGpu(
|
|||||||
// deckungsgleich mit den Schichtfugen in beiden Modi.
|
// deckungsgleich mit den Schichtfugen in beiden Modi.
|
||||||
// `hatch.dash` wird geometrisch (Strich/Lücke) aufgelöst, da die GL-
|
// `hatch.dash` wird geometrisch (Strich/Lücke) aufgelöst, da die GL-
|
||||||
// Linien-Pipeline kein Dash kennt.
|
// Linien-Pipeline kein Dash kennt.
|
||||||
if (prim.hatch && prim.hatch.pattern !== 'none' && prim.hatch.pattern !== 'solid') {
|
// Bild-Schraffur (kind==="image"): WebGL2-Ebene kennt keine Muster-Textur →
|
||||||
|
// FALLBACK auf die neutrale Poché-Füllung (oben), keine Musterlinien. Der
|
||||||
|
// Random-Untermodus läuft transparent über `buildHatchRuns` (Streu-Striche).
|
||||||
|
if (
|
||||||
|
prim.hatch &&
|
||||||
|
prim.hatch.kind !== 'image' &&
|
||||||
|
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 runs = applyDashRuns(buildHatchRuns(prim.pts, prim.hatch), prim.hatch.dash);
|
const runs = applyDashRuns(buildHatchRuns(prim.pts, prim.hatch), prim.hatch.dash);
|
||||||
@@ -420,7 +428,16 @@ export function compilePrimitivesToGpu(
|
|||||||
}
|
}
|
||||||
} 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];
|
||||||
|
if (prim.zigzag) {
|
||||||
|
// Zickzack-Linie → gehrter Polylinienzug (amplitude/wavelength Papier-mm
|
||||||
|
// → Modell-Meter über dieselbe Kopplung wie Strichmuster).
|
||||||
|
const amp = prim.zigzag.amplitude * DASH_MM_TO_M;
|
||||||
|
const wav = prim.zigzag.wavelength * DASH_MM_TO_M;
|
||||||
|
const zpts = zigzagPoints(prim.a, prim.b, amp, wav);
|
||||||
|
strokePolyline(zpts, false, color, prim.weightMm || 0.18);
|
||||||
|
} else {
|
||||||
pushLine(prim.a, prim.b, color, prim.weightMm || 0.18);
|
pushLine(prim.a, prim.b, color, prim.weightMm || 0.18);
|
||||||
|
}
|
||||||
} else if (prim.kind === 'arc') {
|
} else if (prim.kind === 'arc') {
|
||||||
// Bogen → ein gehrter Polylinienzug (glatt, keine Segment-Stufen).
|
// Bogen → ein gehrter Polylinienzug (glatt, keine Segment-Stufen).
|
||||||
const color = parseColor((prim as { color?: string }).color) ?? [0.1, 0.1, 0.1, 1];
|
const color = parseColor((prim as { color?: string }).color) ?? [0.1, 0.1, 0.1, 1];
|
||||||
|
|||||||
@@ -277,7 +277,7 @@ function rotate(v: Vec2, rad: number): Vec2 {
|
|||||||
* mit `PX_TO_M`, ergeben sich die Strichlängen in Modell-Metern (skalieren mit
|
* mit `PX_TO_M`, ergeben sich die Strichlängen in Modell-Metern (skalieren mit
|
||||||
* dem Zoom wie die Kachel selbst — genau wie die SVG-Schraffur).
|
* dem Zoom wie die Kachel selbst — genau wie die SVG-Schraffur).
|
||||||
*/
|
*/
|
||||||
const DASH_MM_TO_M = (1 / 0.13) * PX_TO_M;
|
export const DASH_MM_TO_M = (1 / 0.13) * PX_TO_M;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Zerlegt zusammenhängende Läufe entlang eines Strichmusters (`dash`, in mm-
|
* Zerlegt zusammenhängende Läufe entlang eines Strichmusters (`dash`, in mm-
|
||||||
@@ -379,6 +379,14 @@ export function buildHatchRuns(poly: Vec2[], hatch: HatchRender): HatchRun[] {
|
|||||||
|
|
||||||
const scale = hatch.scale > 1e-6 ? hatch.scale : 1;
|
const scale = hatch.scale > 1e-6 ? hatch.scale : 1;
|
||||||
|
|
||||||
|
// Random-Vektor-Untermodus (Kies/Splitt): kurze, deterministisch gestreute
|
||||||
|
// Striche statt der regelmäßigen Parallel-/Kreuzschar. Bild-Schraffuren
|
||||||
|
// (kind==="image") werden hier NICHT als Vektorlinien erzeugt — sie fallen im
|
||||||
|
// Aufrufer auf eine neutrale Füllung zurück (RScene/GL kennen keine Textur).
|
||||||
|
if (hatch.kind !== 'image' && hatch.lines === 'random') {
|
||||||
|
return buildRandomHatchRuns(poly, hatch, scale);
|
||||||
|
}
|
||||||
|
|
||||||
if (pattern === 'insulation') {
|
if (pattern === 'insulation') {
|
||||||
return insulationRuns(poly, hatch, scale);
|
return insulationRuns(poly, hatch, scale);
|
||||||
}
|
}
|
||||||
@@ -486,3 +494,131 @@ function insulationRuns(poly: Vec2[], hatch: HatchRender, scale: number): HatchR
|
|||||||
}
|
}
|
||||||
return runs;
|
return runs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Random-Vektor-Schraffur (deterministisch) ────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministischer 32-bit-PRNG (mulberry32). Gleicher Seed ⇒ gleiche Folge —
|
||||||
|
* KEIN `Math.random()`: die Random-Schraffur muss über Re-Renders/Print
|
||||||
|
* reproduzierbar sein (sonst flackert der Splitt und der Druck ist nicht
|
||||||
|
* deterministisch).
|
||||||
|
*/
|
||||||
|
function mulberry32(seed: number): () => number {
|
||||||
|
let a = seed >>> 0;
|
||||||
|
return () => {
|
||||||
|
a |= 0;
|
||||||
|
a = (a + 0x6d2b79f5) | 0;
|
||||||
|
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||||
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Faltet mehrere Fließkomma-Kennwerte zu einem stabilen 32-bit-Seed. Die Werte
|
||||||
|
* werden gequantelt (·1000, gerundet), damit numerisches Rauschen den Seed nicht
|
||||||
|
* verschiebt — gleiche Fläche/gleiche Hatch-Parameter ⇒ gleicher Seed ⇒ gleiche
|
||||||
|
* Streuung.
|
||||||
|
*/
|
||||||
|
function hashSeed(...vals: number[]): number {
|
||||||
|
let h = 0x811c9dc5;
|
||||||
|
for (const v of vals) {
|
||||||
|
const q = Math.round(v * 1000) | 0;
|
||||||
|
h ^= q & 0xffff;
|
||||||
|
h = Math.imul(h, 0x01000193);
|
||||||
|
h ^= (q >>> 16) & 0xffff;
|
||||||
|
h = Math.imul(h, 0x01000193);
|
||||||
|
}
|
||||||
|
return h >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Streut kurze Striche gleichmäßig-zufällig (aber DETERMINISTISCH per `seed`) über
|
||||||
|
* ein Polygon: je Zelle des `cell`-Rasters ein Strich zufälliger Lage, Länge
|
||||||
|
* (~`strokeLen`) und Orientierung (um `baseAngleRad` ± π), auf das Polygon
|
||||||
|
* geclippt ({@link clipSegmentToPolygon}, konkav-fähig). Einheiten-agnostisch —
|
||||||
|
* der Aufrufer gibt `strokeLen`/`cell` in der Ziel-Koordinateneinheit an
|
||||||
|
* (Modell-Meter bzw. Papier-mm). Ausgabe = 2-Punkt-Läufe.
|
||||||
|
*/
|
||||||
|
export function scatterStrokes(
|
||||||
|
poly: Vec2[],
|
||||||
|
strokeLen: number,
|
||||||
|
cell: number,
|
||||||
|
baseAngleRad: number,
|
||||||
|
seed: number,
|
||||||
|
): HatchRun[] {
|
||||||
|
if (poly.length < 3 || strokeLen <= 1e-9 || cell <= 1e-9) return [];
|
||||||
|
const bb = bbox(poly);
|
||||||
|
const w = bb.maxX - bb.minX;
|
||||||
|
const h = bb.maxY - bb.minY;
|
||||||
|
if (!(w > 0) || !(h > 0)) return [];
|
||||||
|
const nx = Math.max(1, Math.round(w / cell));
|
||||||
|
const ny = Math.max(1, Math.round(h / cell));
|
||||||
|
const count = Math.min(20000, Math.max(4, nx * ny));
|
||||||
|
const rnd = mulberry32(seed);
|
||||||
|
const runs: HatchRun[] = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const cx = bb.minX + rnd() * w;
|
||||||
|
const cy = bb.minY + rnd() * h;
|
||||||
|
// Orientierung frei (± π um die Basisrichtung), Länge um ±40 % variiert.
|
||||||
|
const ang = baseAngleRad + (rnd() * 2 - 1) * Math.PI;
|
||||||
|
const half = (strokeLen * 0.5) * (0.6 + 0.8 * rnd());
|
||||||
|
const dx = Math.cos(ang) * half;
|
||||||
|
const dy = Math.sin(ang) * half;
|
||||||
|
const a: Vec2 = { x: cx - dx, y: cy - dy };
|
||||||
|
const b: Vec2 = { x: cx + dx, y: cy + dy };
|
||||||
|
for (const seg of clipSegmentToPolygon(a, b, poly)) runs.push([seg.a, seg.b]);
|
||||||
|
}
|
||||||
|
return runs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Random-Vektor-Schraffur eines Polygons in Modell-Metern (Kies/Splitt). Dichte
|
||||||
|
* und Strichlänge sind — wie die Parallelschar — an die Kachelgröße
|
||||||
|
* (`8·scale` px) gekoppelt, damit `scale` die Streudichte steuert. Der Seed
|
||||||
|
* stammt aus der Flächen-Kennung (gerundete Bounding-Box) plus den Hatch-
|
||||||
|
* Parametern → über Re-Renders/Print reproduzierbar.
|
||||||
|
*/
|
||||||
|
export function buildRandomHatchRuns(poly: Vec2[], hatch: HatchRender, scale?: number): HatchRun[] {
|
||||||
|
const s = scale ?? (hatch.scale > 1e-6 ? hatch.scale : 1);
|
||||||
|
const cell = 8 * s * PX_TO_M; // Meter, wie der Parallel-Schar-Abstand
|
||||||
|
const strokeLen = cell * 1.1; // kurzer Strich ~ Kachelmaß
|
||||||
|
const bb = bbox(poly);
|
||||||
|
const seed = hashSeed(bb.minX, bb.minY, bb.maxX - bb.minX, bb.maxY - bb.minY, hatch.angle, s);
|
||||||
|
const base = toModelAngleRad(hatch.angle);
|
||||||
|
return scatterStrokes(poly, strokeLen, cell, base, seed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Zickzack-/Wellen-Linie ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tesselliert die Strecke [a,b] zu einem Zickzack (Dreieckswelle) mit `amplitude`
|
||||||
|
* (Ausschlag quer, Einheit wie a/b) und `wavelength` (Periodenlänge längs). Rein
|
||||||
|
* geometrisch, einheiten-agnostisch: der Aufrufer übergibt amplitude/wavelength
|
||||||
|
* in der Zielkoordinaten-Einheit. Die Auslenkung folgt dem Muster [0,+A,0,−A] je
|
||||||
|
* Viertel-Wellenlänge; Start liegt auf der Achse, das Ende exakt auf `b`.
|
||||||
|
*/
|
||||||
|
export function zigzagPoints(a: Vec2, b: Vec2, amplitude: number, wavelength: number): Vec2[] {
|
||||||
|
const dx = b.x - a.x;
|
||||||
|
const dy = b.y - a.y;
|
||||||
|
const L = Math.hypot(dx, dy);
|
||||||
|
if (L < 1e-12 || wavelength <= 1e-9 || amplitude <= 0) return [a, b];
|
||||||
|
const ux = dx / L;
|
||||||
|
const uy = dy / L;
|
||||||
|
const nx = -uy; // linke Normale (Ausschlagachse)
|
||||||
|
const ny = ux;
|
||||||
|
const quarter = wavelength / 4;
|
||||||
|
const steps = Math.max(1, Math.round(L / quarter));
|
||||||
|
const tri = [0, 1, 0, -1];
|
||||||
|
const pts: Vec2[] = [];
|
||||||
|
for (let i = 0; i <= steps; i++) {
|
||||||
|
const t = Math.min(L, i * quarter);
|
||||||
|
const off = amplitude * tri[i % 4];
|
||||||
|
pts.push({ x: a.x + ux * t + nx * off, y: a.y + uy * t + ny * off });
|
||||||
|
}
|
||||||
|
const last = pts[pts.length - 1];
|
||||||
|
if (Math.abs(last.x - b.x) > 1e-9 || Math.abs(last.y - b.y) > 1e-9) {
|
||||||
|
pts.push({ x: b.x, y: b.y });
|
||||||
|
}
|
||||||
|
return pts;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
// Phase-C-Rendering der NEUEN Schraffur-/Linien-Typen (Bild-Schraffur,
|
||||||
|
// Random-Vektor, Zickzack-Linie). Prüft die drei Darstellungen an den SVG-nahen
|
||||||
|
// Pfaden (planToRenderScene = Live/WASM/PDF-Quelle, planToPrintSvg = Print) sowie
|
||||||
|
// die geteilten Geometrie-Helfer. Der Default-Sample bleibt unberührt.
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||||
|
import type { Plan, Primitive, HatchRender } from "./generatePlan";
|
||||||
|
import { planToRenderScene } from "./toRenderScene";
|
||||||
|
import { buildHatchRuns, zigzagPoints } from "./glPlan/glPlanHatch";
|
||||||
|
|
||||||
|
const SQUARE = [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 4, y: 0 },
|
||||||
|
{ x: 4, y: 4 },
|
||||||
|
{ x: 0, y: 4 },
|
||||||
|
];
|
||||||
|
|
||||||
|
function hatch(over: Partial<HatchRender> = {}): HatchRender {
|
||||||
|
return {
|
||||||
|
pattern: "diagonal",
|
||||||
|
scale: 1,
|
||||||
|
angle: 0,
|
||||||
|
color: "#123456",
|
||||||
|
lineWeight: 0.13,
|
||||||
|
dash: null,
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function planOf(prims: Primitive[]): Plan {
|
||||||
|
return { primitives: prims, bounds: { minX: 0, minY: 0, maxX: 4, maxY: 4 } };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("zigzagPoints", () => {
|
||||||
|
it("erzeugt einen Zickzack-Pfad mit alternierender Auslenkung", () => {
|
||||||
|
const pts = zigzagPoints({ x: 0, y: 0 }, { x: 8, y: 0 }, 1, 4);
|
||||||
|
// Mehr Stützpunkte als die reine Gerade (Zickzack), und quer ausgelenkt.
|
||||||
|
expect(pts.length).toBeGreaterThan(3);
|
||||||
|
const ys = pts.map((p) => p.y);
|
||||||
|
expect(Math.max(...ys)).toBeGreaterThan(0.5); // +A
|
||||||
|
expect(Math.min(...ys)).toBeLessThan(-0.5); // -A
|
||||||
|
// Beginnt auf der Achse, endet exakt auf b.
|
||||||
|
expect(pts[0]).toEqual({ x: 0, y: 0 });
|
||||||
|
expect(pts[pts.length - 1].x).toBeCloseTo(8, 6);
|
||||||
|
expect(pts[pts.length - 1].y).toBeCloseTo(0, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("degeneriert (amplitude 0 / wavelength 0) auf die Gerade", () => {
|
||||||
|
expect(zigzagPoints({ x: 0, y: 0 }, { x: 1, y: 0 }, 0, 4)).toEqual([
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 1, y: 0 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Random-Vektor-Schraffur", () => {
|
||||||
|
it("liefert Streu-Striche (nicht leer) und ist DETERMINISTISCH bei gleichem Seed", () => {
|
||||||
|
const h = hatch({ kind: "vector", lines: "random", scale: 1 });
|
||||||
|
const a = buildHatchRuns(SQUARE, h);
|
||||||
|
const b = buildHatchRuns(SQUARE, h);
|
||||||
|
expect(a.length).toBeGreaterThan(0);
|
||||||
|
expect(a).toEqual(b); // identische Geometrie bei identischen Eingaben
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unterscheidet sich von der regelmäßigen Parallelschar", () => {
|
||||||
|
const random = buildHatchRuns(SQUARE, hatch({ kind: "vector", lines: "random" }));
|
||||||
|
const parallel = buildHatchRuns(SQUARE, hatch({ lines: "parallel" }));
|
||||||
|
expect(JSON.stringify(random)).not.toEqual(JSON.stringify(parallel));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("erscheint als Streu-Polylinien in der RenderScene (deterministisch)", () => {
|
||||||
|
const poly: Primitive = {
|
||||||
|
kind: "polygon",
|
||||||
|
pts: SQUARE,
|
||||||
|
fill: "#ffffff",
|
||||||
|
stroke: "none",
|
||||||
|
strokeWidthMm: 0,
|
||||||
|
hatch: hatch({ kind: "vector", lines: "random" }),
|
||||||
|
};
|
||||||
|
const s1 = planToRenderScene(planOf([poly]));
|
||||||
|
const s2 = planToRenderScene(planOf([poly]));
|
||||||
|
expect(s1.polylines.length).toBeGreaterThan(0);
|
||||||
|
expect(s1.polylines).toEqual(s2.polylines);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Zickzack-Linie in der RenderScene", () => {
|
||||||
|
it("emittiert eine tessellierte Polylinie statt einer geraden Linie", () => {
|
||||||
|
const line: Primitive = {
|
||||||
|
kind: "line",
|
||||||
|
a: { x: 0, y: 0 },
|
||||||
|
b: { x: 4, y: 0 },
|
||||||
|
cls: "draw2d",
|
||||||
|
weightMm: 0.18,
|
||||||
|
zigzag: { amplitude: 2, wavelength: 4 },
|
||||||
|
};
|
||||||
|
const scene = planToRenderScene(planOf([line]));
|
||||||
|
expect(scene.lines.length).toBe(0); // KEINE gerade Linie
|
||||||
|
expect(scene.polylines.length).toBe(1);
|
||||||
|
expect(scene.polylines[0].pts.length).toBeGreaterThan(3); // Zickzack
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Bild-Schraffur — Fallback in der (texturlosen) RenderScene", () => {
|
||||||
|
it("erzeugt KEINE Musterlinien (nur neutrale Füllung)", () => {
|
||||||
|
const poly: Primitive = {
|
||||||
|
kind: "polygon",
|
||||||
|
pts: SQUARE,
|
||||||
|
fill: "#eeeeee",
|
||||||
|
stroke: "none",
|
||||||
|
strokeWidthMm: 0,
|
||||||
|
hatch: hatch({
|
||||||
|
kind: "image",
|
||||||
|
pattern: "diagonal", // würde OHNE image-Fallback Diagonalen zeichnen
|
||||||
|
image: { src: "data:image/png;base64,AAAA", scaleX: 1, scaleY: 1, rotation: 0 },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const scene = planToRenderScene(planOf([poly]));
|
||||||
|
expect(scene.polylines.length).toBe(0); // Fallback: keine Vektor-Musterlinien
|
||||||
|
expect(scene.fills.length).toBe(1); // neutrale Poché-Füllung bleibt
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Print-SVG (planToPrintSvg): Bild-<pattern>/<image> + Zickzack-<polyline> ──
|
||||||
|
// Node-Umgebung ohne DOM → minimaler document-Stub (nur die von planToPrintSvg
|
||||||
|
// genutzten Methoden). So lässt sich der erzeugte SVG-Knotenbaum inspizieren.
|
||||||
|
|
||||||
|
interface StubEl {
|
||||||
|
tagName: string;
|
||||||
|
attrs: Map<string, string>;
|
||||||
|
childNodes: StubEl[];
|
||||||
|
setAttribute(k: string, v: unknown): void;
|
||||||
|
getAttribute(k: string): string | null;
|
||||||
|
appendChild(c: StubEl): StubEl;
|
||||||
|
insertBefore(n: StubEl, ref: StubEl | null): StubEl;
|
||||||
|
querySelector(sel: string): StubEl | null;
|
||||||
|
readonly firstChild: StubEl | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeEl(tag: string): StubEl {
|
||||||
|
const el: StubEl = {
|
||||||
|
tagName: tag,
|
||||||
|
attrs: new Map(),
|
||||||
|
childNodes: [],
|
||||||
|
setAttribute(k, v) {
|
||||||
|
this.attrs.set(k, String(v));
|
||||||
|
},
|
||||||
|
getAttribute(k) {
|
||||||
|
return this.attrs.get(k) ?? null;
|
||||||
|
},
|
||||||
|
appendChild(c) {
|
||||||
|
this.childNodes.push(c);
|
||||||
|
return c;
|
||||||
|
},
|
||||||
|
insertBefore(n, ref) {
|
||||||
|
const i = ref ? this.childNodes.indexOf(ref) : -1;
|
||||||
|
if (i < 0) this.childNodes.unshift(n);
|
||||||
|
else this.childNodes.splice(i, 0, n);
|
||||||
|
return n;
|
||||||
|
},
|
||||||
|
querySelector(sel) {
|
||||||
|
for (const c of this.childNodes) {
|
||||||
|
if (c.tagName === sel) return c;
|
||||||
|
const r = c.querySelector(sel);
|
||||||
|
if (r) return r;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
get firstChild() {
|
||||||
|
return this.childNodes[0] ?? null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function walk(el: StubEl, pred: (e: StubEl) => boolean, acc: StubEl[] = []): StubEl[] {
|
||||||
|
if (pred(el)) acc.push(el);
|
||||||
|
for (const c of el.childNodes) walk(c, pred, acc);
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("planToPrintSvg — neue Typen", () => {
|
||||||
|
let prevDoc: unknown;
|
||||||
|
beforeAll(() => {
|
||||||
|
prevDoc = (globalThis as { document?: unknown }).document;
|
||||||
|
(globalThis as { document: unknown }).document = {
|
||||||
|
createElementNS: (_ns: string, tag: string) => makeEl(tag),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
afterAll(() => {
|
||||||
|
(globalThis as { document?: unknown }).document = prevDoc;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Bild-Schraffur ⇒ <pattern> mit gekacheltem <image>, Zickzack ⇒ <polyline>", async () => {
|
||||||
|
const { planToPrintSvg } = await import("../export/planToPrintSvg");
|
||||||
|
const imgPoly: Primitive = {
|
||||||
|
kind: "polygon",
|
||||||
|
pts: SQUARE,
|
||||||
|
fill: "none",
|
||||||
|
stroke: "#111111",
|
||||||
|
strokeWidthMm: 0.25,
|
||||||
|
hatch: hatch({
|
||||||
|
kind: "image",
|
||||||
|
image: { src: "data:image/png;base64,ZZZ", scaleX: 2, scaleY: 0.5, rotation: 30 },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const zline: Primitive = {
|
||||||
|
kind: "line",
|
||||||
|
a: { x: 0, y: 0 },
|
||||||
|
b: { x: 4, y: 0 },
|
||||||
|
cls: "draw2d",
|
||||||
|
weightMm: 0.18,
|
||||||
|
zigzag: { amplitude: 1, wavelength: 4 },
|
||||||
|
};
|
||||||
|
const { svg } = planToPrintSvg(planOf([imgPoly, zline]), {
|
||||||
|
scaleDenominator: 100,
|
||||||
|
pageWidthMm: 210,
|
||||||
|
pageHeightMm: 297,
|
||||||
|
});
|
||||||
|
const root = svg as unknown as StubEl;
|
||||||
|
const patterns = walk(root, (e) => e.tagName === "pattern");
|
||||||
|
expect(patterns.length).toBe(1);
|
||||||
|
const images = walk(patterns[0], (e) => e.tagName === "image");
|
||||||
|
expect(images.length).toBe(1);
|
||||||
|
expect(images[0].getAttribute("href")).toBe("data:image/png;base64,ZZZ");
|
||||||
|
// Verzerrte Kachel (scaleX≠scaleY) + Rotation im patternTransform.
|
||||||
|
expect(patterns[0].getAttribute("width")).not.toBe(patterns[0].getAttribute("height"));
|
||||||
|
expect(patterns[0].getAttribute("patternTransform")).toContain("rotate(30)");
|
||||||
|
// Zickzack-Linie als Polylinie mit mehreren Stützpunkten.
|
||||||
|
const polylines = walk(root, (e) => e.tagName === "polyline");
|
||||||
|
expect(polylines.length).toBeGreaterThan(0);
|
||||||
|
expect((polylines[0].getAttribute("points") ?? "").trim().split(/\s+/).length).toBeGreaterThan(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
// gesamte (ggf. verkettete oder adaptiv tessellierte) Strecke durchläuft.
|
// gesamte (ggf. verkettete oder adaptiv tessellierte) Strecke durchläuft.
|
||||||
|
|
||||||
import type { Plan, Primitive } from "./generatePlan";
|
import type { Plan, Primitive } from "./generatePlan";
|
||||||
import { applyDashRuns, buildHatchRuns } from "./glPlan/glPlanHatch";
|
import { applyDashRuns, buildHatchRuns, zigzagPoints, DASH_MM_TO_M } from "./glPlan/glPlanHatch";
|
||||||
import { docToLines } from "../text/renderHtml";
|
import { docToLines } from "../text/renderHtml";
|
||||||
|
|
||||||
type LinePrim = Extract<Primitive, { kind: "line" }>;
|
type LinePrim = Extract<Primitive, { kind: "line" }>;
|
||||||
@@ -371,8 +371,16 @@ export function planToRenderScene(plan: Plan, paperScaleN: number = STAMP_DEFAUL
|
|||||||
|
|
||||||
// Schraffur: die aufs Polygon geclippten Musterlinien wie im Browser
|
// Schraffur: die aufs Polygon geclippten Musterlinien wie im Browser
|
||||||
// (glPlanHatch — identische Geometrie), als Polylinien. "none"/"solid"
|
// (glPlanHatch — identische Geometrie), als Polylinien. "none"/"solid"
|
||||||
// brauchen keine Linien (solid deckt die Füllung farbig ab).
|
// brauchen keine Linien (solid deckt die Füllung farbig ab). Bild-Schraffuren
|
||||||
if (p.hatch && p.hatch.pattern !== "none" && p.hatch.pattern !== "solid") {
|
// (kind==="image") kann die render2d-Szene nicht tragen (keine Textur) →
|
||||||
|
// FALLBACK: nur die neutrale Poché-Füllung (oben), keine Musterlinien. Der
|
||||||
|
// Random-Untermodus läuft transparent über `buildHatchRuns` (Streu-Striche).
|
||||||
|
if (
|
||||||
|
p.hatch &&
|
||||||
|
p.hatch.kind !== "image" &&
|
||||||
|
p.hatch.pattern !== "none" &&
|
||||||
|
p.hatch.pattern !== "solid"
|
||||||
|
) {
|
||||||
const hatchCol = withOpacity(
|
const hatchCol = withOpacity(
|
||||||
toRgba(p.hatch.color, 1) ?? [0.1, 0.1, 0.1, 1],
|
toRgba(p.hatch.color, 1) ?? [0.1, 0.1, 0.1, 1],
|
||||||
undefined,
|
undefined,
|
||||||
@@ -414,7 +422,24 @@ export function planToRenderScene(plan: Plan, paperScaleN: number = STAMP_DEFAUL
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (p.kind === "line") {
|
} else if (p.kind === "line") {
|
||||||
if (p.drawingId) {
|
if (p.zigzag) {
|
||||||
|
// Zickzack-Linie: als Polylinie tesselliert (render2d kennt keinen
|
||||||
|
// Zickzack-Primitiv). amplitude/wavelength sind Papier-mm → Modell-Meter
|
||||||
|
// über dieselbe Kopplung wie Strichmuster (DASH_MM_TO_M). Nicht verketten.
|
||||||
|
flushRun();
|
||||||
|
const amp = p.zigzag.amplitude * DASH_MM_TO_M;
|
||||||
|
const wav = p.zigzag.wavelength * DASH_MM_TO_M;
|
||||||
|
const zpts = zigzagPoints(p.a, p.b, amp, wav);
|
||||||
|
const col = withOpacity(strokeColorFor(p.cls, p.color), p.cls, p.greyed);
|
||||||
|
if (zpts.length >= 2) {
|
||||||
|
polylines.push({
|
||||||
|
pts: zpts.map((v) => [v.x, v.y]),
|
||||||
|
color: col,
|
||||||
|
widthMm: p.weightMm,
|
||||||
|
z: zc++,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (p.drawingId) {
|
||||||
// Aufeinanderfolgende 2D-Zeichenlinien gleicher drawingId/Stil verketten.
|
// Aufeinanderfolgende 2D-Zeichenlinien gleicher drawingId/Stil verketten.
|
||||||
const a: RPoint = [p.a.x, p.a.y];
|
const a: RPoint = [p.a.x, p.a.y];
|
||||||
const b: RPoint = [p.b.x, p.b.y];
|
const b: RPoint = [p.b.x, p.b.y];
|
||||||
|
|||||||
Reference in New Issue
Block a user