Random-Schraffur modellraum-verankert + Motiv-Editor fuer Custom-Linien
Random-Verankerung (Bugfix): die Streu-Striche haengen nicht mehr an der Polygon-Bounding-Box, sondern an einem absoluten Modellraum-Gitter (hashCell aus absoluten Zell-Indizes + hatch.seed). Beim Vergroessern der Flaeche bleiben bestehende Striche stehen, am Rand kommen neue dazu, die Dichte bleibt konstant, Verschieben wandert nicht; 'Neu wuerfeln' (neuer Seed) verschiebt das ganze Feld. Alle Renderpfade ziehen aus derselben Funktion. Verankerungs- und Verschiebe-Dichte-Test ergaenzt. Motiv-Editor: neuer wiederverwendbarer MotifEditor (Punkte setzen/ziehen in einer Einheitszelle, Live-Loop-Vorschau). LineStyle.kind 'custom' + motif (points/length), additiv durchgereicht (analog zigzag) und in allen Renderpfaden gekachelt (motifPoints, Verallgemeinerung von zigzagPoints). ResourceManager-Umschalter Vollinie/Strich/Zickzack/Motiv, LineSwatch- Vorschau. Insgesamt 5 neue Tests, 113 gruen.
This commit is contained in:
@@ -12,7 +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";
|
import { buildRandomHatchRuns, zigzagPoints, motifPoints } 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). */
|
||||||
@@ -113,6 +113,9 @@ function emitPrimitive(
|
|||||||
const amp = p.zigzag.amplitude * DASH_MM_TO_M;
|
const amp = p.zigzag.amplitude * DASH_MM_TO_M;
|
||||||
const wav = p.zigzag.wavelength * DASH_MM_TO_M;
|
const wav = p.zigzag.wavelength * DASH_MM_TO_M;
|
||||||
dxf.addPolyline(layer, zigzagPoints(p.a, p.b, amp, wav), false);
|
dxf.addPolyline(layer, zigzagPoints(p.a, p.b, amp, wav), false);
|
||||||
|
} else if (p.motif) {
|
||||||
|
// Custom-Motiv → offene Polylinie (Papier-mm → Modell-Meter, wie oben).
|
||||||
|
dxf.addPolyline(layer, motifPoints(p.a, p.b, p.motif, DASH_MM_TO_M), false);
|
||||||
} else {
|
} else {
|
||||||
dxf.addLine(layer, p.a, p.b);
|
dxf.addLine(layer, p.a, p.b);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
|
|
||||||
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";
|
import { buildRandomHatchRuns, zigzagPoints, motifPoints } 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";
|
||||||
@@ -227,6 +227,7 @@ function appendPrimitive(
|
|||||||
const b = map(p.b);
|
const b = map(p.b);
|
||||||
// Zickzack-Linie (amplitude/wavelength sind Papier-mm = mm-Zielraum) →
|
// Zickzack-Linie (amplitude/wavelength sind Papier-mm = mm-Zielraum) →
|
||||||
// Polylinie; sonst gerade (ggf. gestrichelte) Linie wie bisher.
|
// Polylinie; sonst gerade (ggf. gestrichelte) Linie wie bisher.
|
||||||
|
// Zickzack/Custom-Motiv sind in Papier-mm definiert = mm-Zielraum (scale 1).
|
||||||
const ln = p.zigzag
|
const ln = p.zigzag
|
||||||
? polylineNode(
|
? polylineNode(
|
||||||
zigzagPoints(a, b, p.zigzag.amplitude, p.zigzag.wavelength),
|
zigzagPoints(a, b, p.zigzag.amplitude, p.zigzag.wavelength),
|
||||||
@@ -234,6 +235,13 @@ function appendPrimitive(
|
|||||||
quantizePen(p.weightMm),
|
quantizePen(p.weightMm),
|
||||||
p.dash,
|
p.dash,
|
||||||
)
|
)
|
||||||
|
: p.motif
|
||||||
|
? polylineNode(
|
||||||
|
motifPoints(a, b, p.motif),
|
||||||
|
p.color ?? "#111111",
|
||||||
|
quantizePen(p.weightMm),
|
||||||
|
p.dash,
|
||||||
|
)
|
||||||
: lineNode(a, b, p.color ?? "#111111", quantizePen(p.weightMm), p.dash, mmPerM);
|
: 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);
|
||||||
|
|||||||
@@ -582,6 +582,10 @@ export const de = {
|
|||||||
"resources.lines.random": "Zufällig",
|
"resources.lines.random": "Zufällig",
|
||||||
"resources.lineKind.dash": "Strich",
|
"resources.lineKind.dash": "Strich",
|
||||||
"resources.lineKind.zigzag": "Zickzack",
|
"resources.lineKind.zigzag": "Zickzack",
|
||||||
|
"resources.lineKind.custom": "Motiv",
|
||||||
|
"resources.field.motif": "Motiv",
|
||||||
|
"resources.motif.length": "Länge (mm)",
|
||||||
|
"resources.motif.hint": "Klick = Punkt setzen · Ziehen = verschieben · Doppelklick = entfernen. Das Motiv wiederholt sich entlang der Linie.",
|
||||||
|
|
||||||
// Schraffur-Muster.
|
// Schraffur-Muster.
|
||||||
"pattern.none": "Ohne",
|
"pattern.none": "Ohne",
|
||||||
|
|||||||
@@ -577,6 +577,10 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"resources.lines.random": "Random",
|
"resources.lines.random": "Random",
|
||||||
"resources.lineKind.dash": "Dash",
|
"resources.lineKind.dash": "Dash",
|
||||||
"resources.lineKind.zigzag": "Zigzag",
|
"resources.lineKind.zigzag": "Zigzag",
|
||||||
|
"resources.lineKind.custom": "Motif",
|
||||||
|
"resources.field.motif": "Motif",
|
||||||
|
"resources.motif.length": "Length (mm)",
|
||||||
|
"resources.motif.hint": "Click = add point · drag = move · double-click = remove. The motif repeats along the line.",
|
||||||
|
|
||||||
"pattern.none": "None",
|
"pattern.none": "None",
|
||||||
"pattern.solid": "Solid",
|
"pattern.solid": "Solid",
|
||||||
|
|||||||
+12
-1
@@ -50,14 +50,25 @@ export interface LineStyle {
|
|||||||
* • "zigzag" — der Strich variiert in Y (Zickzack/Welle), Parameter in
|
* • "zigzag" — der Strich variiert in Y (Zickzack/Welle), Parameter in
|
||||||
* `zigzag`. Fehlt `kind`, gilt „dash"; die heutigen Renderer ignorieren
|
* `zigzag`. Fehlt `kind`, gilt „dash"; die heutigen Renderer ignorieren
|
||||||
* `kind` und zeichnen weiterhin gerade Striche (Backward-Compat).
|
* `kind` und zeichnen weiterhin gerade Striche (Backward-Compat).
|
||||||
|
* • "custom" — ein frei gezeichnetes Motiv (offene Polylinie in einer
|
||||||
|
* Einheitszelle), das sich entlang der Linie wiederholt/loopt. Parameter in
|
||||||
|
* `motif`. „zigzag" bleibt der eigene, parametrische Sonderfall.
|
||||||
*/
|
*/
|
||||||
kind?: "dash" | "zigzag";
|
kind?: "dash" | "zigzag" | "custom";
|
||||||
/**
|
/**
|
||||||
* Parameter der Zickzack-/Wellen-Linie (nur `kind==="zigzag"`), beide in mm
|
* Parameter der Zickzack-/Wellen-Linie (nur `kind==="zigzag"`), beide in mm
|
||||||
* Papier: `amplitude` = Ausschlag quer zur Linie, `wavelength` = Periodenlänge
|
* Papier: `amplitude` = Ausschlag quer zur Linie, `wavelength` = Periodenlänge
|
||||||
* entlang der Linie. Fehlt es, wird die Linie gerade gezeichnet.
|
* entlang der Linie. Fehlt es, wird die Linie gerade gezeichnet.
|
||||||
*/
|
*/
|
||||||
zigzag?: { amplitude: number; wavelength: number };
|
zigzag?: { amplitude: number; wavelength: number };
|
||||||
|
/**
|
||||||
|
* Frei gezeichnetes Wiederhol-Motiv (nur `kind==="custom"`): eine OFFENE
|
||||||
|
* Polylinie in einer Einheitszelle. `points` sind die Stützpunkte in mm Papier;
|
||||||
|
* `x` läuft 0..`length` (mm entlang der Linie = Wiederhollänge), `y` = senkrechter
|
||||||
|
* Versatz zur Linienachse (mm, +/−). Das Motiv wird alle `length` entlang der
|
||||||
|
* Linie gekachelt. Fehlt es, wird die Linie gerade gezeichnet.
|
||||||
|
*/
|
||||||
|
motif?: { points: Vec2[]; length: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mögliche Schraffur-Muster im Plan/Schnitt. */
|
/** Mögliche Schraffur-Muster im Plan/Schnitt. */
|
||||||
|
|||||||
+36
-2
@@ -20,7 +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";
|
import { buildRandomHatchRuns, zigzagPoints, motifPoints, 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
|
||||||
@@ -2526,6 +2526,8 @@ interface DrawingRun {
|
|||||||
greyed?: boolean;
|
greyed?: boolean;
|
||||||
/** Zickzack-Parameter (Papier-mm); gesetzt ⇒ Lauf als Zickzack-Pfad zeichnen. */
|
/** Zickzack-Parameter (Papier-mm); gesetzt ⇒ Lauf als Zickzack-Pfad zeichnen. */
|
||||||
zigzag?: { amplitude: number; wavelength: number };
|
zigzag?: { amplitude: number; wavelength: number };
|
||||||
|
/** Custom-Motiv (Papier-mm); gesetzt ⇒ Lauf als gekacheltes Motiv zeichnen. */
|
||||||
|
motif?: { points: Vec2[]; length: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Kleiner Toleranz-Test auf Punktgleichheit (Modell-Meter). */
|
/** Kleiner Toleranz-Test auf Punktgleichheit (Modell-Meter). */
|
||||||
@@ -2596,6 +2598,21 @@ function buildDrawingRuns(prims: Primitive[]): DrawingRun[] {
|
|||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// Custom-Motiv-Linien ebenfalls einzeln (analog Zickzack).
|
||||||
|
if (p.motif) {
|
||||||
|
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,
|
||||||
|
motif: p.motif,
|
||||||
|
});
|
||||||
|
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);
|
||||||
@@ -2638,7 +2655,7 @@ function DrawingRunShape({
|
|||||||
.map((mm) => (print ? printStrokeVb(mm, paperScale!) : mmToPx(mm)))
|
.map((mm) => (print ? printStrokeVb(mm, paperScale!) : mmToPx(mm)))
|
||||||
.join(" ")
|
.join(" ")
|
||||||
: undefined;
|
: undefined;
|
||||||
// Zickzack-Lauf: im MODELL-Raum tessellieren (amplitude/wavelength Papier-mm →
|
// Zickzack-/Custom-Motiv-Lauf: im MODELL-Raum tessellieren (Papier-mm →
|
||||||
// Modell-Meter via DASH_MM_TO_M), dann toScreen. Sonst die Roh-Stützpunkte.
|
// Modell-Meter via DASH_MM_TO_M), dann toScreen. Sonst die Roh-Stützpunkte.
|
||||||
const modelPts = run.zigzag
|
const modelPts = run.zigzag
|
||||||
? zigzagPoints(
|
? zigzagPoints(
|
||||||
@@ -2647,6 +2664,8 @@ function DrawingRunShape({
|
|||||||
run.zigzag.amplitude * DASH_MM_TO_M,
|
run.zigzag.amplitude * DASH_MM_TO_M,
|
||||||
run.zigzag.wavelength * DASH_MM_TO_M,
|
run.zigzag.wavelength * DASH_MM_TO_M,
|
||||||
)
|
)
|
||||||
|
: run.motif
|
||||||
|
? motifPoints(run.pts[0], run.pts[run.pts.length - 1], run.motif, DASH_MM_TO_M)
|
||||||
: run.pts;
|
: run.pts;
|
||||||
const pts = modelPts.map(toScreen).map((s) => `${s.x},${s.y}`).join(" ");
|
const pts = modelPts.map(toScreen).map((s) => `${s.x},${s.y}`).join(" ");
|
||||||
const shape = run.closed ? (
|
const shape = run.closed ? (
|
||||||
@@ -2866,6 +2885,21 @@ function renderPrimitive(
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Custom-Motiv: gekachelt im MODELL-Raum (Papier-mm → Modell-Meter), analog
|
||||||
|
// Zickzack. ADDITIV — gerade/gestrichelte Linien bleiben unverändert.
|
||||||
|
if (p.motif) {
|
||||||
|
const mpts = motifPoints(p.a, p.b, p.motif, DASH_MM_TO_M).map(toScreen);
|
||||||
|
return (
|
||||||
|
<polyline
|
||||||
|
points={mpts.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 (
|
||||||
|
|||||||
@@ -226,6 +226,14 @@ export type Primitive =
|
|||||||
* die bestehenden Strich/Dash-Linien zu verändern (Feld fehlt ⇒ heute).
|
* die bestehenden Strich/Dash-Linien zu verändern (Feld fehlt ⇒ heute).
|
||||||
*/
|
*/
|
||||||
zigzag?: { amplitude: number; wavelength: number };
|
zigzag?: { amplitude: number; wavelength: number };
|
||||||
|
/**
|
||||||
|
* Frei gezeichnetes Wiederhol-Motiv (aus einem LineStyle mit
|
||||||
|
* `kind==="custom"`): eine offene Polylinie in einer Einheitszelle, `points`
|
||||||
|
* in mm Papier, `length` = Wiederhollänge in mm Papier. Ist es gesetzt,
|
||||||
|
* kacheln die Renderer das Motiv entlang der Linie (analog `zigzag`) —
|
||||||
|
* ADDITIV; fehlt es, bleibt die Linie gerade/gestrichelt.
|
||||||
|
*/
|
||||||
|
motif?: { points: Vec2[]; length: 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). */
|
||||||
@@ -797,8 +805,10 @@ function addDrawing2D(
|
|||||||
// Zickzack-Parameter aus dem LineStyle durchreichen (nur kind==="zigzag").
|
// Zickzack-Parameter aus dem LineStyle durchreichen (nur kind==="zigzag").
|
||||||
// Additiv: fehlt es, bleibt die Linie gerade/gestrichelt wie bisher.
|
// Additiv: fehlt es, bleibt die Linie gerade/gestrichelt wie bisher.
|
||||||
const zigzag = ls?.kind === "zigzag" ? ls.zigzag : undefined;
|
const zigzag = ls?.kind === "zigzag" ? ls.zigzag : undefined;
|
||||||
|
// Custom-Motiv analog durchreichen (nur kind==="custom").
|
||||||
|
const motif = ls?.kind === "custom" ? ls.motif : undefined;
|
||||||
const seg = (a: Vec2, b: Vec2) =>
|
const seg = (a: Vec2, b: Vec2) =>
|
||||||
out.push({ kind: "line", a, b, cls: "draw2d", weightMm, dash, zigzag, color, greyed, drawingId: d.id });
|
out.push({ kind: "line", a, b, cls: "draw2d", weightMm, dash, zigzag, motif, 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
|
||||||
@@ -1053,6 +1063,8 @@ function addWallPoche(
|
|||||||
dash: jls ? jls.dash : null,
|
dash: jls ? jls.dash : null,
|
||||||
// Zickzack-Fuge (LineStyle kind==="zigzag") ebenfalls durchreichen.
|
// Zickzack-Fuge (LineStyle kind==="zigzag") ebenfalls durchreichen.
|
||||||
zigzag: jls?.kind === "zigzag" ? jls.zigzag : undefined,
|
zigzag: jls?.kind === "zigzag" ? jls.zigzag : undefined,
|
||||||
|
// Custom-Motiv-Fuge (LineStyle kind==="custom") ebenfalls durchreichen.
|
||||||
|
motif: jls?.kind === "custom" ? jls.motif : 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, zigzagPoints, DASH_MM_TO_M } from './glPlanHatch';
|
import { applyDashRuns, buildHatchRuns, zigzagPoints, motifPoints, DASH_MM_TO_M } from './glPlanHatch';
|
||||||
|
|
||||||
const PX_PER_M = 90;
|
const PX_PER_M = 90;
|
||||||
|
|
||||||
@@ -435,6 +435,10 @@ export function compilePrimitivesToGpu(
|
|||||||
const wav = prim.zigzag.wavelength * DASH_MM_TO_M;
|
const wav = prim.zigzag.wavelength * DASH_MM_TO_M;
|
||||||
const zpts = zigzagPoints(prim.a, prim.b, amp, wav);
|
const zpts = zigzagPoints(prim.a, prim.b, amp, wav);
|
||||||
strokePolyline(zpts, false, color, prim.weightMm || 0.18);
|
strokePolyline(zpts, false, color, prim.weightMm || 0.18);
|
||||||
|
} else if (prim.motif) {
|
||||||
|
// Custom-Motiv → gehrter Polylinienzug (Papier-mm → Modell-Meter, wie Zickzack).
|
||||||
|
const mpts = motifPoints(prim.a, prim.b, prim.motif, DASH_MM_TO_M);
|
||||||
|
strokePolyline(mpts, false, color, prim.weightMm || 0.18);
|
||||||
} else {
|
} else {
|
||||||
pushLine(prim.a, prim.b, color, prim.weightMm || 0.18);
|
pushLine(prim.a, prim.b, color, prim.weightMm || 0.18);
|
||||||
}
|
}
|
||||||
|
|||||||
+138
-33
@@ -533,39 +533,83 @@ function hashSeed(...vals: number[]): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Streut kurze Striche gleichmäßig-zufällig (aber DETERMINISTISCH per `seed`) über
|
* Deterministischer Hash EINER Modellraum-Zelle (`ix`,`iy` = absolute Zell-Indizes
|
||||||
* ein Polygon: je Zelle des `cell`-Rasters ein Strich zufälliger Lage, Länge
|
* `floor(x/cell)`/`floor(y/cell)`) plus `seed`. Ergebnis ist ein 32-bit-Seed für
|
||||||
* (~`strokeLen`) und Orientierung (um `baseAngleRad` ± π), auf das Polygon
|
* {@link mulberry32}. Zentraler Baustein der modellraum-stabilen Streuung: der
|
||||||
* geclippt ({@link clipSegmentToPolygon}, konkav-fähig). Einheiten-agnostisch —
|
* Zell-Inhalt hängt NUR an den absoluten Zell-Indizes + Seed — NICHT an der
|
||||||
* der Aufrufer gibt `strokeLen`/`cell` in der Ziel-Koordinateneinheit an
|
* Bounding-Box der Fläche. Dadurch bleibt ein Strich beim Vergrößern/Verschieben
|
||||||
* (Modell-Meter bzw. Papier-mm). Ausgabe = 2-Punkt-Läufe.
|
* der Fläche an derselben Zelle „verankert".
|
||||||
|
*/
|
||||||
|
function hashCell(ix: number, iy: number, seed: number): number {
|
||||||
|
let h = (0x811c9dc5 ^ (seed | 0)) >>> 0;
|
||||||
|
h = Math.imul(h ^ (ix | 0), 0x01000193);
|
||||||
|
h = Math.imul(h ^ (iy | 0), 0x01000193);
|
||||||
|
h ^= h >>> 15;
|
||||||
|
h = Math.imul(h, 0x2c1b3c6d);
|
||||||
|
h ^= h >>> 12;
|
||||||
|
return h >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Streut kurze Striche gleichmäßig-zufällig (aber DETERMINISTISCH) über ein
|
||||||
|
* Polygon — MODELLRAUM-STABIL: Der Modellraum wird in ein regelmäßiges Gitter der
|
||||||
|
* Zellgröße `cell` gekachelt (absolutes Gitter, Ursprung = Modell-Ursprung). Über
|
||||||
|
* die Zellen, die die Polygon-Bounding-Box überdecken, wird iteriert (nur aus
|
||||||
|
* Performance-Gründen); PRO Zelle liefert {@link hashCell}(ix,iy,seed) einen festen
|
||||||
|
* PRNG, aus dem Lage (innerhalb der Zelle), Orientierung (um `baseAngleRad` ± π)
|
||||||
|
* und Länge folgen. Jeder Strich wird aufs Polygon geclippt
|
||||||
|
* ({@link clipSegmentToPolygon}, konkav-fähig).
|
||||||
|
*
|
||||||
|
* Folge: Beim Vergrößern der Fläche bleiben bestehende Striche STEHEN (gleiche
|
||||||
|
* Zelle ⇒ gleicher Strich), am Rand kommen neue dazu; die Dichte (Striche pro
|
||||||
|
* Fläche) bleibt konstant; beim Verschieben wandert nichts. „Neu würfeln" (neuer
|
||||||
|
* `seed`) verschiebt das ganze Feld. Einheiten-agnostisch (Aufrufer gibt `cell`/
|
||||||
|
* `strokeLen` in Modell-Metern bzw. Papier-mm). Ausgabe = 2-Punkt-Läufe.
|
||||||
*/
|
*/
|
||||||
export function scatterStrokes(
|
export function scatterStrokes(
|
||||||
poly: Vec2[],
|
poly: Vec2[],
|
||||||
strokeLen: number,
|
|
||||||
cell: number,
|
cell: number,
|
||||||
baseAngleRad: number,
|
baseAngleRad: number,
|
||||||
seed: number,
|
seed: number,
|
||||||
|
strokeLen: number,
|
||||||
lenMin?: number,
|
lenMin?: number,
|
||||||
lenMax?: number,
|
lenMax?: number,
|
||||||
): HatchRun[] {
|
): HatchRun[] {
|
||||||
if (poly.length < 3 || strokeLen <= 1e-9 || cell <= 1e-9) return [];
|
if (poly.length < 3 || cell <= 1e-9 || strokeLen <= 1e-9) return [];
|
||||||
const bb = bbox(poly);
|
const bb = bbox(poly);
|
||||||
const w = bb.maxX - bb.minX;
|
if (!(bb.maxX > bb.minX) || !(bb.maxY > bb.minY)) return [];
|
||||||
const h = bb.maxY - bb.minY;
|
|
||||||
if (!(w > 0) || !(h > 0)) return [];
|
// Absolute Zell-Indizes, die die Bounding-Box überdecken (Modell-Ursprung als
|
||||||
const nx = Math.max(1, Math.round(w / cell));
|
// Gitter-Anker → verschiebe-invariant).
|
||||||
const ny = Math.max(1, Math.round(h / cell));
|
let eff = cell;
|
||||||
const count = Math.min(20000, Math.max(4, nx * ny));
|
let ix0 = Math.floor(bb.minX / eff);
|
||||||
// Explizite Längen-Spanne (falls gesetzt) validieren; sonst die heutige
|
let ix1 = Math.floor(bb.maxX / eff);
|
||||||
// scale-abhängige Variation (±40 % um `strokeLen`) beibehalten.
|
let iy0 = Math.floor(bb.minY / eff);
|
||||||
|
let iy1 = Math.floor(bb.maxY / eff);
|
||||||
|
// Sicherheits-Cap gegen pathologisch große Flächen / winzige Zellen: Zellgröße
|
||||||
|
// gröber machen, bis die Zellzahl unter dem Limit liegt (uniform, nur grober).
|
||||||
|
const MAX_CELLS = 250000;
|
||||||
|
let cells = (ix1 - ix0 + 1) * (iy1 - iy0 + 1);
|
||||||
|
if (cells > MAX_CELLS) {
|
||||||
|
eff = cell * Math.sqrt(cells / MAX_CELLS);
|
||||||
|
ix0 = Math.floor(bb.minX / eff);
|
||||||
|
ix1 = Math.floor(bb.maxX / eff);
|
||||||
|
iy0 = Math.floor(bb.minY / eff);
|
||||||
|
iy1 = Math.floor(bb.maxY / eff);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explizite Längen-Spanne (falls gesetzt) validieren; sonst die scale-abhängige
|
||||||
|
// Variation (±40 % um `strokeLen`) beibehalten.
|
||||||
const hasRange =
|
const hasRange =
|
||||||
lenMin != null && lenMax != null && lenMin > 1e-9 && lenMax >= lenMin;
|
lenMin != null && lenMax != null && lenMin > 1e-9 && lenMax >= lenMin;
|
||||||
const rnd = mulberry32(seed);
|
|
||||||
const runs: HatchRun[] = [];
|
const runs: HatchRun[] = [];
|
||||||
for (let i = 0; i < count; i++) {
|
for (let iy = iy0; iy <= iy1; iy++) {
|
||||||
const cx = bb.minX + rnd() * w;
|
for (let ix = ix0; ix <= ix1; ix++) {
|
||||||
const cy = bb.minY + rnd() * h;
|
const rnd = mulberry32(hashCell(ix, iy, seed));
|
||||||
|
// Strich-Mittelpunkt innerhalb der Zelle (absolutes Gitter).
|
||||||
|
const cx = (ix + rnd()) * eff;
|
||||||
|
const cy = (iy + rnd()) * eff;
|
||||||
// Orientierung frei (± π um die Basisrichtung).
|
// Orientierung frei (± π um die Basisrichtung).
|
||||||
const ang = baseAngleRad + (rnd() * 2 - 1) * Math.PI;
|
const ang = baseAngleRad + (rnd() * 2 - 1) * Math.PI;
|
||||||
// Länge: explizite Spanne [lenMin,lenMax] oder Default (±40 % um strokeLen).
|
// Länge: explizite Spanne [lenMin,lenMax] oder Default (±40 % um strokeLen).
|
||||||
@@ -579,15 +623,18 @@ export function scatterStrokes(
|
|||||||
const b: 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]);
|
for (const seg of clipSegmentToPolygon(a, b, poly)) runs.push([seg.a, seg.b]);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return runs;
|
return runs;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Random-Vektor-Schraffur eines Polygons in Modell-Metern (Kies/Splitt). Dichte
|
* Random-Vektor-Schraffur eines Polygons in Modell-Metern (Kies/Splitt). Dichte
|
||||||
* und Strichlänge sind — wie die Parallelschar — an die Kachelgröße
|
* und Strichlänge sind — wie die Parallelschar — an die Kachelgröße (`8·scale` px)
|
||||||
* (`8·scale` px) gekoppelt, damit `scale` die Streudichte steuert. Der Seed
|
* gekoppelt, damit `scale`/`density` die Streudichte steuern. Die Streuung ist
|
||||||
* stammt aus der Flächen-Kennung (gerundete Bounding-Box) plus den Hatch-
|
* MODELLRAUM-STABIL (siehe {@link scatterStrokes}): der Seed hängt NUR an den
|
||||||
* Parametern → über Re-Renders/Print reproduzierbar.
|
* Muster-Parametern (Winkel/Scale/Dichte) plus dem expliziten `hatch.seed` — NICHT
|
||||||
|
* an der Bounding-Box. Dadurch verankern sich die Striche im Modellraum; beim
|
||||||
|
* Vergrößern der Fläche bleiben sie stehen und die Dichte bleibt konstant.
|
||||||
*/
|
*/
|
||||||
export function buildRandomHatchRuns(poly: Vec2[], hatch: HatchRender, scale?: number): HatchRun[] {
|
export function buildRandomHatchRuns(poly: Vec2[], hatch: HatchRender, scale?: number): HatchRun[] {
|
||||||
const s = scale ?? (hatch.scale > 1e-6 ? hatch.scale : 1);
|
const s = scale ?? (hatch.scale > 1e-6 ? hatch.scale : 1);
|
||||||
@@ -596,19 +643,16 @@ export function buildRandomHatchRuns(poly: Vec2[], hatch: HatchRender, scale?: n
|
|||||||
const density = hatch.density != null && hatch.density > 1e-6 ? hatch.density : 1;
|
const density = hatch.density != null && hatch.density > 1e-6 ? hatch.density : 1;
|
||||||
const cell = (8 * s * PX_TO_M) / density; // Meter, wie der Parallel-Schar-Abstand
|
const cell = (8 * s * PX_TO_M) / density; // Meter, wie der Parallel-Schar-Abstand
|
||||||
const strokeLen = cell * 1.1; // Default-Strich ~ Kachelmaß (wenn keine Längenspanne)
|
const strokeLen = cell * 1.1; // Default-Strich ~ Kachelmaß (wenn keine Längenspanne)
|
||||||
const bb = bbox(poly);
|
// Fester Seed NUR aus Muster-Parametern + explizitem `hatch.seed` (KEINE bbox):
|
||||||
// Flächen-Seed (gerundete Bounding-Box + Muster-Parameter) MIT dem expliziten
|
// „Neu würfeln" (neuer seed) ⇒ anderes Feld, aber gleicher seed + gleiche
|
||||||
// `hatch.seed` gemischt: „Neu würfeln" (neuer seed) ⇒ andere Streuung, aber
|
// Muster-Parameter ⇒ identisches, modellraum-verankertes Feld über alle
|
||||||
// gleicher seed + gleiche Fläche ⇒ identische Geometrie über alle Renderpfade.
|
// Renderpfade — unabhängig von Lage/Größe der Fläche.
|
||||||
const seed = hashSeed(
|
const seed = hashSeed(hatch.angle, s, density, hatch.seed ?? 0);
|
||||||
bb.minX, bb.minY, bb.maxX - bb.minX, bb.maxY - bb.minY,
|
|
||||||
hatch.angle, s, density, hatch.seed ?? 0,
|
|
||||||
);
|
|
||||||
const base = toModelAngleRad(hatch.angle);
|
const base = toModelAngleRad(hatch.angle);
|
||||||
// Explizite Längenspanne (mm-Papier → Modell-Meter, wie das Dash-Muster).
|
// Explizite Längenspanne (mm-Papier → Modell-Meter, wie das Dash-Muster).
|
||||||
const lenMin = hatch.lengthMin != null ? hatch.lengthMin * DASH_MM_TO_M : undefined;
|
const lenMin = hatch.lengthMin != null ? hatch.lengthMin * DASH_MM_TO_M : undefined;
|
||||||
const lenMax = hatch.lengthMax != null ? hatch.lengthMax * DASH_MM_TO_M : undefined;
|
const lenMax = hatch.lengthMax != null ? hatch.lengthMax * DASH_MM_TO_M : undefined;
|
||||||
return scatterStrokes(poly, strokeLen, cell, base, seed, lenMin, lenMax);
|
return scatterStrokes(poly, cell, base, seed, strokeLen, lenMin, lenMax);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Zickzack-/Wellen-Linie ───────────────────────────────────────────────────
|
// ── Zickzack-/Wellen-Linie ───────────────────────────────────────────────────
|
||||||
@@ -644,3 +688,64 @@ export function zigzagPoints(a: Vec2, b: Vec2, amplitude: number, wavelength: nu
|
|||||||
}
|
}
|
||||||
return pts;
|
return pts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kachelt ein frei gezeichnetes Motiv (offene Polylinie in einer Einheitszelle)
|
||||||
|
* entlang der Strecke [a,b] und liefert die zusammenhängende Polylinie. Rein
|
||||||
|
* geometrisch/einheiten-agnostisch (die Verallgemeinerung von {@link zigzagPoints}):
|
||||||
|
* • `motif.points` sind die Stützpunkte der Zelle; `x` läuft 0..`motif.length`
|
||||||
|
* ENTLANG der Linie (= Wiederhollänge), `y` = Versatz quer zur Linie (linke
|
||||||
|
* Normale `n = (-u.y, u.x)`).
|
||||||
|
* • `scale` skaliert Punkte UND Länge in die Zielkoordinaten-Einheit (z. B.
|
||||||
|
* Papier-mm → Modell-Meter via `DASH_MM_TO_M`); der Print-SVG-Pfad übergibt 1.
|
||||||
|
* Das Motiv wird alle `length·scale` wiederholt; am Segment-Ende exakt bei `L`
|
||||||
|
* geclippt (letztes Segment interpoliert), sodass die Linie nicht über `b`
|
||||||
|
* hinausläuft. Degeneriert (leeres Motiv, Länge 0, Nullstrecke) → `[a, b]`.
|
||||||
|
*/
|
||||||
|
export function motifPoints(
|
||||||
|
a: Vec2,
|
||||||
|
b: Vec2,
|
||||||
|
motif: { points: Vec2[]; length: number },
|
||||||
|
scale = 1,
|
||||||
|
): Vec2[] {
|
||||||
|
const dx = b.x - a.x;
|
||||||
|
const dy = b.y - a.y;
|
||||||
|
const L = Math.hypot(dx, dy);
|
||||||
|
const cell = motif.length * scale;
|
||||||
|
if (L < 1e-12 || cell <= 1e-9 || motif.points.length < 2) return [a, b];
|
||||||
|
const ux = dx / L;
|
||||||
|
const uy = dy / L;
|
||||||
|
const nx = -uy; // linke Normale (Ausschlagachse)
|
||||||
|
const ny = ux;
|
||||||
|
const toWorld = (ax: number, off: number): Vec2 => ({
|
||||||
|
x: a.x + ux * ax + nx * off,
|
||||||
|
y: a.y + uy * ax + ny * off,
|
||||||
|
});
|
||||||
|
|
||||||
|
const out: Vec2[] = [];
|
||||||
|
const reps = Math.ceil(L / cell) + 1;
|
||||||
|
let last: { ax: number; off: number } | null = null;
|
||||||
|
for (let r = 0; r < reps; r++) {
|
||||||
|
const base = r * cell;
|
||||||
|
for (let i = 0; i < motif.points.length; i++) {
|
||||||
|
const p = motif.points[i];
|
||||||
|
const ax = base + p.x * scale;
|
||||||
|
const off = p.y * scale;
|
||||||
|
// Naht-Duplikat (Zellenende r == Zellenanfang r+1) überspringen.
|
||||||
|
if (last && Math.abs(ax - last.ax) < 1e-9 && Math.abs(off - last.off) < 1e-9) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ax > L + 1e-9) {
|
||||||
|
// Letztes Segment am Segmentende clippen: last→p bei ax = L interpolieren.
|
||||||
|
if (last && ax > last.ax + 1e-12 && L - last.ax > 1e-9) {
|
||||||
|
const tt = (L - last.ax) / (ax - last.ax);
|
||||||
|
out.push(toWorld(L, last.off + (off - last.off) * tt));
|
||||||
|
}
|
||||||
|
return out.length >= 2 ? out : [a, b];
|
||||||
|
}
|
||||||
|
out.push(toWorld(ax, off));
|
||||||
|
last = { ax, off };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.length >= 2 ? out : [a, b];
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||||
import type { Plan, Primitive, HatchRender } from "./generatePlan";
|
import type { Plan, Primitive, HatchRender } from "./generatePlan";
|
||||||
import { planToRenderScene } from "./toRenderScene";
|
import { planToRenderScene } from "./toRenderScene";
|
||||||
import { buildHatchRuns, zigzagPoints } from "./glPlan/glPlanHatch";
|
import { buildHatchRuns, buildRandomHatchRuns, zigzagPoints, motifPoints } from "./glPlan/glPlanHatch";
|
||||||
|
|
||||||
const SQUARE = [
|
const SQUARE = [
|
||||||
{ x: 0, y: 0 },
|
{ x: 0, y: 0 },
|
||||||
@@ -53,6 +53,48 @@ describe("zigzagPoints", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("motifPoints (Custom-Wiederhol-Motiv)", () => {
|
||||||
|
// Dreieckszelle (0..4 entlang, ±1 quer): loopt entlang der Linie.
|
||||||
|
const MOTIF = {
|
||||||
|
points: [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 1, y: 1 },
|
||||||
|
{ x: 2, y: 0 },
|
||||||
|
{ x: 3, y: -1 },
|
||||||
|
{ x: 4, y: 0 },
|
||||||
|
],
|
||||||
|
length: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("kachelt das Motiv entlang der Strecke (mehr Stützpunkte, quer ausgelenkt)", () => {
|
||||||
|
const pts = motifPoints({ x: 0, y: 0 }, { x: 12, y: 0 }, MOTIF);
|
||||||
|
expect(pts.length).toBeGreaterThan(5); // mehrere Kacheln
|
||||||
|
const ys = pts.map((p) => p.y);
|
||||||
|
expect(Math.max(...ys)).toBeGreaterThan(0.5); // +Ausschlag
|
||||||
|
expect(Math.min(...ys)).toBeLessThan(-0.5); // −Ausschlag
|
||||||
|
// Endet nicht über b hinaus (auf L geclippt).
|
||||||
|
expect(Math.max(...pts.map((p) => p.x))).toBeLessThanOrEqual(12 + 1e-6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skaliert Punkte UND Länge (scale) und legt sie auf die Normale", () => {
|
||||||
|
// Vertikale Linie, scale 0.5: y-Versatz wandert in x-Richtung (linke Normale).
|
||||||
|
const pts = motifPoints({ x: 0, y: 0 }, { x: 0, y: 8 }, MOTIF, 0.5);
|
||||||
|
// Bei einer Linie in +y ist die linke Normale (−1, 0): +y-Motiv → −x.
|
||||||
|
expect(Math.min(...pts.map((p) => p.x))).toBeLessThan(-0.1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("degeneriert (leeres Motiv / Länge 0 / Nullstrecke) auf die Gerade", () => {
|
||||||
|
expect(motifPoints({ x: 0, y: 0 }, { x: 4, y: 0 }, { points: [{ x: 0, y: 0 }], length: 4 })).toEqual([
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 4, y: 0 },
|
||||||
|
]);
|
||||||
|
expect(motifPoints({ x: 0, y: 0 }, { x: 4, y: 0 }, { points: MOTIF.points, length: 0 })).toEqual([
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 4, y: 0 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("Random-Vektor-Schraffur", () => {
|
describe("Random-Vektor-Schraffur", () => {
|
||||||
it("liefert Streu-Striche (nicht leer) und ist DETERMINISTISCH bei gleichem Seed", () => {
|
it("liefert Streu-Striche (nicht leer) und ist DETERMINISTISCH bei gleichem Seed", () => {
|
||||||
const h = hatch({ kind: "vector", lines: "random", scale: 1 });
|
const h = hatch({ kind: "vector", lines: "random", scale: 1 });
|
||||||
@@ -87,6 +129,53 @@ describe("Random-Vektor-Schraffur", () => {
|
|||||||
expect(ranged).toEqual(buildHatchRuns(SQUARE, hatch({ ...base, lengthMin: 1, lengthMax: 2 })));
|
expect(ranged).toEqual(buildHatchRuns(SQUARE, hatch({ ...base, lengthMin: 1, lengthMax: 2 })));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("ist MODELLRAUM-VERANKERT: grössere Fläche enthält die Striche der kleineren unverändert", () => {
|
||||||
|
// Zwei konzentrische Quadrate; das grosse enthält das kleine mit Rand ≥ 4 m.
|
||||||
|
const small = [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 4, y: 0 },
|
||||||
|
{ x: 4, y: 4 },
|
||||||
|
{ x: 0, y: 4 },
|
||||||
|
];
|
||||||
|
const big = [
|
||||||
|
{ x: -4, y: -4 },
|
||||||
|
{ x: 8, y: -4 },
|
||||||
|
{ x: 8, y: 8 },
|
||||||
|
{ x: -4, y: 8 },
|
||||||
|
];
|
||||||
|
const h = hatch({ kind: "vector", lines: "random", scale: 1, seed: 5 });
|
||||||
|
const rsSmall = buildRandomHatchRuns(small, h);
|
||||||
|
const rsBig = buildRandomHatchRuns(big, h);
|
||||||
|
expect(rsSmall.length).toBeGreaterThan(0);
|
||||||
|
const key = (r: { x: number; y: number }[]) => JSON.stringify(r);
|
||||||
|
const bigSet = new Set(rsBig.map(key));
|
||||||
|
// Striche des kleinen Quadrats, die STRIKT im Inneren liegen (Clipping hat sie
|
||||||
|
// nicht bewegt), müssen im grossen Quadrat identisch wieder auftauchen —
|
||||||
|
// gleiche Zelle ⇒ gleicher Strich. So bleibt beim Vergrössern nichts stehen-los.
|
||||||
|
const interior = rsSmall.filter((run) =>
|
||||||
|
run.every((p) => p.x > 0.05 && p.x < 3.95 && p.y > 0.05 && p.y < 3.95),
|
||||||
|
);
|
||||||
|
expect(interior.length).toBeGreaterThan(0);
|
||||||
|
for (const run of interior) {
|
||||||
|
expect(bigSet.has(key(run))).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bleibt beim VERSCHIEBEN der Fläche gleich dicht (Striche pro Fläche konstant)", () => {
|
||||||
|
const sq = (ox: number, oy: number) => [
|
||||||
|
{ x: ox, y: oy },
|
||||||
|
{ x: ox + 6, y: oy },
|
||||||
|
{ x: ox + 6, y: oy + 6 },
|
||||||
|
{ x: ox, y: oy + 6 },
|
||||||
|
];
|
||||||
|
const h = hatch({ kind: "vector", lines: "random", scale: 1, seed: 2 });
|
||||||
|
const a = buildRandomHatchRuns(sq(0, 0), h).length;
|
||||||
|
const b = buildRandomHatchRuns(sq(37, -19), h).length;
|
||||||
|
// Gleiche Fläche an anderer Stelle ⇒ näherungsweise gleiche Strichzahl (Dichte
|
||||||
|
// konstant; ±1 Reihe Rand-Toleranz durch die Zell-Ausrichtung).
|
||||||
|
expect(Math.abs(a - b)).toBeLessThan(a * 0.25 + 5);
|
||||||
|
});
|
||||||
|
|
||||||
it("erscheint als Streu-Polylinien in der RenderScene (deterministisch)", () => {
|
it("erscheint als Streu-Polylinien in der RenderScene (deterministisch)", () => {
|
||||||
const poly: Primitive = {
|
const poly: Primitive = {
|
||||||
kind: "polygon",
|
kind: "polygon",
|
||||||
|
|||||||
@@ -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, zigzagPoints, DASH_MM_TO_M } from "./glPlan/glPlanHatch";
|
import { applyDashRuns, buildHatchRuns, zigzagPoints, motifPoints, 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" }>;
|
||||||
@@ -439,6 +439,20 @@ export function planToRenderScene(plan: Plan, paperScaleN: number = STAMP_DEFAUL
|
|||||||
z: zc++,
|
z: zc++,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} else if (p.motif) {
|
||||||
|
// Custom-Motiv: entlang der Linie gekachelt (Papier-mm → Modell-Meter via
|
||||||
|
// DASH_MM_TO_M) → Polylinie. Nicht verketten (wie Zickzack).
|
||||||
|
flushRun();
|
||||||
|
const mpts = motifPoints(p.a, p.b, p.motif, DASH_MM_TO_M);
|
||||||
|
const col = withOpacity(strokeColorFor(p.cls, p.color), p.cls, p.greyed);
|
||||||
|
if (mpts.length >= 2) {
|
||||||
|
polylines.push({
|
||||||
|
pts: mpts.map((v) => [v.x, v.y]),
|
||||||
|
color: col,
|
||||||
|
widthMm: p.weightMm,
|
||||||
|
z: zc++,
|
||||||
|
});
|
||||||
|
}
|
||||||
} else if (p.drawingId) {
|
} 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];
|
||||||
|
|||||||
@@ -2253,6 +2253,88 @@ body {
|
|||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Motiv-Editor (Custom-Linie / Kachel-Motiv) ─────────────────────────── */
|
||||||
|
.motif-editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.motif-canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
background: var(--input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
touch-action: none;
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
.motif-frame {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--border);
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
.motif-grid {
|
||||||
|
stroke: var(--border);
|
||||||
|
stroke-width: 0.5;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.motif-axis {
|
||||||
|
stroke: var(--ink-2);
|
||||||
|
stroke-width: 0.75;
|
||||||
|
stroke-dasharray: 3 3;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.motif-path {
|
||||||
|
stroke: var(--accent-light);
|
||||||
|
stroke-width: 1.75;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
.motif-handle {
|
||||||
|
fill: var(--input);
|
||||||
|
stroke: var(--accent-light);
|
||||||
|
stroke-width: 1.5;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
.motif-handle:hover {
|
||||||
|
fill: var(--accent-light);
|
||||||
|
}
|
||||||
|
.motif-handle.is-drag {
|
||||||
|
fill: var(--accent-light);
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
.motif-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.motif-len {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ink-2);
|
||||||
|
}
|
||||||
|
.motif-len input {
|
||||||
|
width: 62px;
|
||||||
|
}
|
||||||
|
.motif-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--ink-2);
|
||||||
|
opacity: 0.85;
|
||||||
|
flex: 1 1 120px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.motif-preview {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
background: var(--input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Löschen-Knopf (Mülleimer in der letzten Spalte, zentriert). */
|
/* Löschen-Knopf (Mülleimer in der letzten Spalte, zentriert). */
|
||||||
.res-delete {
|
.res-delete {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
// Wiederverwendbarer Mini-Editor für ein loopendes Wiederhol-Motiv: eine OFFENE
|
||||||
|
// Polylinie in einer Einheitszelle (`x` 0..length entlang der Linie, `y` quer zur
|
||||||
|
// Achse). Der Nutzer setzt Punkte (Klick auf die freie Fläche), zieht sie (Drag)
|
||||||
|
// und entfernt sie (Doppelklick). Eine Live-Vorschau kachelt 2–3 Wiederholungen.
|
||||||
|
//
|
||||||
|
// Bewusst GENERISCH gehalten (Props = Punkte + Länge + onChange): derselbe Editor
|
||||||
|
// dient später auch einem Schraffur-Kachel-Motiv. Rein zeichenflächen-lokal, KEIN
|
||||||
|
// Rückgriff auf die Plan-Renderer — die Kachelung wird für die Vorschau lokal
|
||||||
|
// nachgebaut (deckungsgleich zur `motifPoints`-Kachelung im Renderer).
|
||||||
|
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||||
|
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import type { Vec2 } from "../model/types";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
|
||||||
|
/** Motiv (offene Polylinie in einer Einheitszelle). Einheit: mm Papier. */
|
||||||
|
export interface Motif {
|
||||||
|
points: Vec2[];
|
||||||
|
length: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeichenflächen-Geometrie (px). */
|
||||||
|
const PLOT_W = 260;
|
||||||
|
const PLOT_H = 120;
|
||||||
|
const PAD = 10;
|
||||||
|
const INNER_W = PLOT_W - 2 * PAD;
|
||||||
|
const INNER_H = PLOT_H - 2 * PAD;
|
||||||
|
const MID_Y = PLOT_H / 2;
|
||||||
|
|
||||||
|
/** Halbe vertikale Spanne (mm) der Zeichenfläche, mind. 2 mm, sonst aus Daten. */
|
||||||
|
function yRangeOf(points: Vec2[]): number {
|
||||||
|
let m = 2;
|
||||||
|
for (const p of points) m = Math.max(m, Math.abs(p.y) + 0.5);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Editor für ein Wiederhol-Motiv. `points`/`length` in mm Papier; `onChange`
|
||||||
|
* liefert die neue Punktliste UND die (ggf. per Feld geänderte) Länge zurück.
|
||||||
|
*/
|
||||||
|
export function MotifEditor({
|
||||||
|
points,
|
||||||
|
length,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
points: Vec2[];
|
||||||
|
length: number;
|
||||||
|
onChange: (points: Vec2[], length: number) => void;
|
||||||
|
}) {
|
||||||
|
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||||
|
const [drag, setDrag] = useState<number | null>(null);
|
||||||
|
const len = length > 0.01 ? length : 4;
|
||||||
|
const yRange = yRangeOf(points);
|
||||||
|
|
||||||
|
// ── Koordinaten-Abbildung Modell (mm) ↔ Zeichenfläche (px) ─────────────────
|
||||||
|
const toPx = (p: Vec2): Vec2 => ({
|
||||||
|
x: PAD + (Math.max(0, Math.min(len, p.x)) / len) * INNER_W,
|
||||||
|
y: MID_Y - (p.y / yRange) * (INNER_H / 2),
|
||||||
|
});
|
||||||
|
const toModel = (clientX: number, clientY: number): Vec2 => {
|
||||||
|
const rect = svgRef.current!.getBoundingClientRect();
|
||||||
|
const px = ((clientX - rect.left) / rect.width) * PLOT_W;
|
||||||
|
const py = ((clientY - rect.top) / rect.height) * PLOT_H;
|
||||||
|
const x = ((px - PAD) / INNER_W) * len;
|
||||||
|
const y = ((MID_Y - py) / (INNER_H / 2)) * yRange;
|
||||||
|
return {
|
||||||
|
x: Math.max(0, Math.min(len, Math.round(x * 20) / 20)),
|
||||||
|
y: Math.max(-yRange, Math.min(yRange, Math.round(y * 20) / 20)),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Punkte nach x sortiert halten → die Polylinie läuft stets links→rechts. */
|
||||||
|
const sortByX = (list: Vec2[]): Vec2[] => [...list].sort((a, b) => a.x - b.x);
|
||||||
|
|
||||||
|
const addPoint = (e: React.PointerEvent) => {
|
||||||
|
if (drag != null) return;
|
||||||
|
const p = toModel(e.clientX, e.clientY);
|
||||||
|
onChange(sortByX([...points, p]), len);
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveDragged = (e: React.PointerEvent) => {
|
||||||
|
if (drag == null) return;
|
||||||
|
const p = toModel(e.clientX, e.clientY);
|
||||||
|
const next = points.slice();
|
||||||
|
next[drag] = p;
|
||||||
|
onChange(next, len);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePoint = (i: number) => {
|
||||||
|
if (points.length <= 2) return; // mind. 2 Punkte (offene Linie)
|
||||||
|
onChange(points.filter((_, k) => k !== i), len);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pxPts = points.map(toPx);
|
||||||
|
const polyStr = pxPts.map((s) => `${s.x.toFixed(1)},${s.y.toFixed(1)}`).join(" ");
|
||||||
|
|
||||||
|
// Raster-Linien (nur optische Hilfe): senkrecht bei 1/4-Schritten, Mittelachse.
|
||||||
|
const gridX = [0.25, 0.5, 0.75].map((f) => PAD + f * INNER_W);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="motif-editor">
|
||||||
|
<svg
|
||||||
|
ref={svgRef}
|
||||||
|
className="motif-canvas"
|
||||||
|
width={PLOT_W}
|
||||||
|
height={PLOT_H}
|
||||||
|
viewBox={`0 0 ${PLOT_W} ${PLOT_H}`}
|
||||||
|
onPointerDown={addPoint}
|
||||||
|
onPointerMove={moveDragged}
|
||||||
|
onPointerUp={() => setDrag(null)}
|
||||||
|
onPointerLeave={() => setDrag(null)}
|
||||||
|
>
|
||||||
|
{/* Zellen-Rahmen */}
|
||||||
|
<rect
|
||||||
|
x={PAD}
|
||||||
|
y={PAD}
|
||||||
|
width={INNER_W}
|
||||||
|
height={INNER_H}
|
||||||
|
className="motif-frame"
|
||||||
|
/>
|
||||||
|
{/* Raster */}
|
||||||
|
{gridX.map((x, i) => (
|
||||||
|
<line key={i} x1={x} y1={PAD} x2={x} y2={PLOT_H - PAD} className="motif-grid" />
|
||||||
|
))}
|
||||||
|
<line x1={PAD} y1={MID_Y} x2={PLOT_W - PAD} y2={MID_Y} className="motif-axis" />
|
||||||
|
{/* Motiv-Polylinie */}
|
||||||
|
<polyline points={polyStr} className="motif-path" fill="none" />
|
||||||
|
{/* Punkt-Griffe */}
|
||||||
|
{pxPts.map((s, i) => (
|
||||||
|
<circle
|
||||||
|
key={i}
|
||||||
|
cx={s.x}
|
||||||
|
cy={s.y}
|
||||||
|
r={5}
|
||||||
|
className={`motif-handle${drag === i ? " is-drag" : ""}`}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
(e.target as Element).setPointerCapture?.(e.pointerId);
|
||||||
|
setDrag(i);
|
||||||
|
}}
|
||||||
|
onDoubleClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
removePoint(i);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div className="motif-controls">
|
||||||
|
<label className="motif-len">
|
||||||
|
<span>{t("resources.motif.length")}</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="res-input"
|
||||||
|
value={len}
|
||||||
|
step={0.5}
|
||||||
|
min={0.5}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = parseFloat(e.target.value);
|
||||||
|
if (Number.isFinite(v)) onChange(points, Math.max(0.5, v));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<span className="motif-hint">{t("resources.motif.hint")}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MotifTilePreview points={points} length={len} yRange={yRange} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live-Vorschau: kachelt das Motiv ~3× horizontal (deckungsgleich zur
|
||||||
|
* `motifPoints`-Kachelung: Zelle [0,length] wird alle `length` wiederholt). Rein
|
||||||
|
* schematisch (px); zeigt, wie der Loop entlang einer Linie aussieht.
|
||||||
|
*/
|
||||||
|
function MotifTilePreview({
|
||||||
|
points,
|
||||||
|
length,
|
||||||
|
yRange,
|
||||||
|
}: {
|
||||||
|
points: Vec2[];
|
||||||
|
length: number;
|
||||||
|
yRange: number;
|
||||||
|
}) {
|
||||||
|
const W = PLOT_W;
|
||||||
|
const H = 44;
|
||||||
|
const mid = H / 2;
|
||||||
|
const reps = 3;
|
||||||
|
const pxPerMm = W / (reps * length);
|
||||||
|
const ampPx = (H / 2 - 4) / yRange;
|
||||||
|
const pts: string[] = [];
|
||||||
|
if (points.length >= 2 && length > 0.01) {
|
||||||
|
// Zellen aneinanderreihen; Naht-Duplikate überspringen (wie motifPoints).
|
||||||
|
let lastX = -1;
|
||||||
|
let lastY = 0;
|
||||||
|
for (let r = 0; r < reps; r++) {
|
||||||
|
const base = r * length;
|
||||||
|
for (const p of points) {
|
||||||
|
const x = (base + p.x) * pxPerMm;
|
||||||
|
const y = mid - p.y * ampPx;
|
||||||
|
if (x > W + 0.5) break;
|
||||||
|
if (Math.abs(x - lastX) < 0.01 && Math.abs(y - lastY) < 0.01) continue;
|
||||||
|
pts.push(`${x.toFixed(1)},${y.toFixed(1)}`);
|
||||||
|
lastX = x;
|
||||||
|
lastY = y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<svg className="motif-preview" width={W} height={H} viewBox={`0 0 ${W} ${H}`}>
|
||||||
|
<line x1={0} y1={mid} x2={W} y2={mid} className="motif-axis" />
|
||||||
|
{pts.length >= 2 && (
|
||||||
|
<polyline points={pts.join(" ")} className="motif-path" fill="none" />
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
import { requestMaterialPreview, cancelMaterialPreview } from "../materials/spherePreview";
|
import { requestMaterialPreview, cancelMaterialPreview } from "../materials/spherePreview";
|
||||||
import { EyeIcon } from "./EyeIcon";
|
import { EyeIcon } from "./EyeIcon";
|
||||||
import { HatchSwatch, LineSwatch } from "./hatchPreview";
|
import { HatchSwatch, LineSwatch } from "./hatchPreview";
|
||||||
|
import { MotifEditor } from "./MotifEditor";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
// ── Auswahl-Optionen (UI-Beschriftung übersetzt, Werte englisch) ───────────
|
// ── Auswahl-Optionen (UI-Beschriftung übersetzt, Werte englisch) ───────────
|
||||||
@@ -1188,6 +1189,21 @@ function HatchesTab({
|
|||||||
// ── Linien (Line Styles) ───────────────────────────────────────────────────
|
// ── Linien (Line Styles) ───────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Detail-Panel EINES Linienstils (rechte Seite des Master-Detail-Layouts). */
|
/** Detail-Panel EINES Linienstils (rechte Seite des Master-Detail-Layouts). */
|
||||||
|
/**
|
||||||
|
* Default-Motiv beim Umschalten auf „Custom": eine Dreieckszelle (0..4 mm entlang,
|
||||||
|
* ±1.5 mm quer), die entlang der Linie loopt. Der Nutzer passt sie im MotifEditor an.
|
||||||
|
*/
|
||||||
|
const DEFAULT_MOTIF: NonNullable<LineStyle["motif"]> = {
|
||||||
|
points: [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 1, y: 1.5 },
|
||||||
|
{ x: 2, y: 0 },
|
||||||
|
{ x: 3, y: -1.5 },
|
||||||
|
{ x: 4, y: 0 },
|
||||||
|
],
|
||||||
|
length: 4,
|
||||||
|
};
|
||||||
|
|
||||||
function LineStyleDetail({
|
function LineStyleDetail({
|
||||||
style,
|
style,
|
||||||
onPatch,
|
onPatch,
|
||||||
@@ -1199,10 +1215,15 @@ function LineStyleDetail({
|
|||||||
}) {
|
}) {
|
||||||
const kind = style.kind ?? "dash";
|
const kind = style.kind ?? "dash";
|
||||||
|
|
||||||
/** Wechselt den Typ; beim Umschalten auf „Zickzack" Default-Parameter setzen. */
|
/**
|
||||||
const setKind = (k: "dash" | "zigzag") => {
|
* Wechselt den Typ; beim Umschalten auf „Zickzack"/„Custom" Default-Parameter
|
||||||
|
* setzen, falls noch keine vorhanden.
|
||||||
|
*/
|
||||||
|
const setKind = (k: "dash" | "zigzag" | "custom") => {
|
||||||
if (k === "zigzag" && !style.zigzag) {
|
if (k === "zigzag" && !style.zigzag) {
|
||||||
onPatch({ kind: k, zigzag: { amplitude: 1, wavelength: 4 } });
|
onPatch({ kind: k, zigzag: { amplitude: 1, wavelength: 4 } });
|
||||||
|
} else if (k === "custom" && !style.motif) {
|
||||||
|
onPatch({ kind: k, motif: DEFAULT_MOTIF });
|
||||||
} else {
|
} else {
|
||||||
onPatch({ kind: k });
|
onPatch({ kind: k });
|
||||||
}
|
}
|
||||||
@@ -1262,6 +1283,7 @@ function LineStyleDetail({
|
|||||||
options={[
|
options={[
|
||||||
{ value: "dash", label: t("resources.lineKind.dash") },
|
{ value: "dash", label: t("resources.lineKind.dash") },
|
||||||
{ value: "zigzag", label: t("resources.lineKind.zigzag") },
|
{ value: "zigzag", label: t("resources.lineKind.zigzag") },
|
||||||
|
{ value: "custom", label: t("resources.lineKind.custom") },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
@@ -1287,6 +1309,14 @@ function LineStyleDetail({
|
|||||||
/>
|
/>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
</>
|
</>
|
||||||
|
) : kind === "custom" ? (
|
||||||
|
<FieldRow label={t("resources.field.motif")}>
|
||||||
|
<MotifEditor
|
||||||
|
points={style.motif?.points ?? DEFAULT_MOTIF.points}
|
||||||
|
length={style.motif?.length ?? DEFAULT_MOTIF.length}
|
||||||
|
onChange={(points, length) => onPatch({ motif: { points, length } })}
|
||||||
|
/>
|
||||||
|
</FieldRow>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<FieldRow label={t("resources.col.stroke")}>
|
<FieldRow label={t("resources.col.stroke")}>
|
||||||
|
|||||||
@@ -292,6 +292,44 @@ export function LineSwatch({
|
|||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
} else if (style.kind === "custom" && style.motif && style.motif.points.length >= 2) {
|
||||||
|
// Custom-Motiv: die Einheitszelle (mm) wird entlang der Linie gekachelt
|
||||||
|
// (mm-Papier → px ·4, wie das Dash-Muster). Deckungsgleich zur motifPoints-
|
||||||
|
// Kachelung im Plan-Renderer, hier nur schematisch/px.
|
||||||
|
const PX_PER_MM = 4;
|
||||||
|
const motif = style.motif;
|
||||||
|
const cellPx = Math.max(2, motif.length * PX_PER_MM);
|
||||||
|
let d = "";
|
||||||
|
let started = false;
|
||||||
|
let lastX = -1;
|
||||||
|
let lastY = 0;
|
||||||
|
let x = x0;
|
||||||
|
let rep = 0;
|
||||||
|
outer: while (x <= x1 && rep < 400) {
|
||||||
|
const base = x0 + rep * cellPx;
|
||||||
|
for (const p of motif.points) {
|
||||||
|
const px2 = base + p.x * PX_PER_MM;
|
||||||
|
if (px2 > x1 + 0.5) break outer;
|
||||||
|
const py2 = midY - p.y * PX_PER_MM;
|
||||||
|
if (Math.abs(px2 - lastX) < 0.01 && Math.abs(py2 - lastY) < 0.01) continue;
|
||||||
|
d += `${started ? " L" : "M"} ${px2.toFixed(1)} ${py2.toFixed(1)}`;
|
||||||
|
started = true;
|
||||||
|
lastX = px2;
|
||||||
|
lastY = py2;
|
||||||
|
}
|
||||||
|
x = base + cellPx;
|
||||||
|
rep++;
|
||||||
|
}
|
||||||
|
path = (
|
||||||
|
<path
|
||||||
|
d={d || `M ${x0} ${midY} L ${x1} ${midY}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={px}
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
const dasharray = style.dash ? style.dash.map((d) => d * 4).join(" ") : undefined;
|
const dasharray = style.dash ? style.dash.map((d) => d * 4).join(" ") : undefined;
|
||||||
path = (
|
path = (
|
||||||
|
|||||||
Reference in New Issue
Block a user