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:
+122
-2
@@ -20,6 +20,7 @@ import type { DraftShape, SnapResult, ToolHandlers, ToolId, ToolMods } from "../
|
||||
import { useGlPlanRenderer } from "./useGlPlanRenderer";
|
||||
import { useWasmPlanRenderer } from "./useWasmPlanRenderer";
|
||||
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 PAD = 60; // Rand in viewBox-Einheiten
|
||||
@@ -1601,7 +1602,7 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
||||
const hatched = useMemo(() => {
|
||||
const list: HatchedPoly[] = [];
|
||||
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 });
|
||||
}
|
||||
});
|
||||
@@ -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. */
|
||||
function needsPattern(pattern: HatchRender["pattern"]): boolean {
|
||||
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,
|
||||
* Winkel dreht das Muster (patternTransform), Farbe/Linienstärke kommen aus dem
|
||||
@@ -2407,6 +2424,26 @@ function HatchPattern({
|
||||
.join(" ")
|
||||
: 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") {
|
||||
// Weiche Zickzack-/Wellenlinie (SIA-nah). Kachel 14×10 × Maßstab.
|
||||
const w = 14 * hatch.scale;
|
||||
@@ -2487,6 +2524,8 @@ interface DrawingRun {
|
||||
dash: number[] | null | undefined;
|
||||
color?: string;
|
||||
greyed?: boolean;
|
||||
/** Zickzack-Parameter (Papier-mm); gesetzt ⇒ Lauf als Zickzack-Pfad zeichnen. */
|
||||
zigzag?: { amplitude: number; wavelength: number };
|
||||
}
|
||||
|
||||
/** Kleiner Toleranz-Test auf Punktgleichheit (Modell-Meter). */
|
||||
@@ -2541,6 +2580,22 @@ function buildDrawingRuns(prims: Primitive[]): DrawingRun[] {
|
||||
flush();
|
||||
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)) {
|
||||
// An den laufenden Zug anhängen.
|
||||
curPts.push(p.b);
|
||||
@@ -2583,7 +2638,17 @@ function DrawingRunShape({
|
||||
.map((mm) => (print ? printStrokeVb(mm, paperScale!) : mmToPx(mm)))
|
||||
.join(" ")
|
||||
: 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 ? (
|
||||
<polygon
|
||||
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).
|
||||
if (p.hatch.pattern === "none") {
|
||||
return (
|
||||
@@ -2746,6 +2845,27 @@ function renderPrimitive(
|
||||
case "line": {
|
||||
const a = toScreen(p.a);
|
||||
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
|
||||
// der CSS-Klasse (z. B. .door-leaf / .wall-axis).
|
||||
return (
|
||||
|
||||
@@ -210,6 +210,13 @@ export type Primitive =
|
||||
weightMm: number;
|
||||
/** Strichmuster in mm Papier; null/undefined = durchgezogen. */
|
||||
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). */
|
||||
color?: string;
|
||||
/** 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 weightMm = d.weightMm ?? ls?.weight ?? lwByCode.get(d.categoryCode) ?? WALL_FALLBACK_MM;
|
||||
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) =>
|
||||
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
|
||||
@@ -1027,6 +1037,8 @@ function addWallPoche(
|
||||
weightMm: jls ? jls.weight * LAYER_DETAIL_FACTOR[detail] : layerLineMm,
|
||||
color: jls ? jls.color : stroke,
|
||||
dash: jls ? jls.dash : null,
|
||||
// Zickzack-Fuge (LineStyle kind==="zigzag") ebenfalls durchreichen.
|
||||
zigzag: jls?.kind === "zigzag" ? jls.zigzag : undefined,
|
||||
greyed,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import type { Primitive } from '../generatePlan';
|
||||
import type { Vec2 } from '../../model/types';
|
||||
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;
|
||||
|
||||
@@ -405,7 +405,15 @@ export function compilePrimitivesToGpu(
|
||||
// deckungsgleich mit den Schichtfugen in beiden Modi.
|
||||
// `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') {
|
||||
// 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 hatchMm = prim.hatch.lineWeight > 0 ? prim.hatch.lineWeight : 0.13;
|
||||
const runs = applyDashRuns(buildHatchRuns(prim.pts, prim.hatch), prim.hatch.dash);
|
||||
@@ -420,7 +428,16 @@ export function compilePrimitivesToGpu(
|
||||
}
|
||||
} else if (prim.kind === 'line') {
|
||||
const color = parseColor(prim.color) ?? [0.1, 0.1, 0.1, 1];
|
||||
pushLine(prim.a, prim.b, color, prim.weightMm || 0.18);
|
||||
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);
|
||||
}
|
||||
} else if (prim.kind === 'arc') {
|
||||
// Bogen → ein gehrter Polylinienzug (glatt, keine Segment-Stufen).
|
||||
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
|
||||
* 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-
|
||||
@@ -379,6 +379,14 @@ export function buildHatchRuns(poly: Vec2[], hatch: HatchRender): HatchRun[] {
|
||||
|
||||
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') {
|
||||
return insulationRuns(poly, hatch, scale);
|
||||
}
|
||||
@@ -486,3 +494,131 @@ function insulationRuns(poly: Vec2[], hatch: HatchRender, scale: number): HatchR
|
||||
}
|
||||
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.
|
||||
|
||||
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";
|
||||
|
||||
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
|
||||
// (glPlanHatch — identische Geometrie), als Polylinien. "none"/"solid"
|
||||
// brauchen keine Linien (solid deckt die Füllung farbig ab).
|
||||
if (p.hatch && p.hatch.pattern !== "none" && p.hatch.pattern !== "solid") {
|
||||
// brauchen keine Linien (solid deckt die Füllung farbig ab). Bild-Schraffuren
|
||||
// (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(
|
||||
toRgba(p.hatch.color, 1) ?? [0.1, 0.1, 0.1, 1],
|
||||
undefined,
|
||||
@@ -414,7 +422,24 @@ export function planToRenderScene(plan: Plan, paperScaleN: number = STAMP_DEFAUL
|
||||
}
|
||||
}
|
||||
} 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.
|
||||
const a: RPoint = [p.a.x, p.a.y];
|
||||
const b: RPoint = [p.b.x, p.b.y];
|
||||
|
||||
Reference in New Issue
Block a user