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:
2026-07-04 01:09:00 +02:00
parent b4dd396768
commit 3d1fa40402
15 changed files with 714 additions and 58 deletions
+218
View File
@@ -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 23 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>
);
}
+32 -2
View File
@@ -43,6 +43,7 @@ import {
import { requestMaterialPreview, cancelMaterialPreview } from "../materials/spherePreview";
import { EyeIcon } from "./EyeIcon";
import { HatchSwatch, LineSwatch } from "./hatchPreview";
import { MotifEditor } from "./MotifEditor";
import { t } from "../i18n";
// ── Auswahl-Optionen (UI-Beschriftung übersetzt, Werte englisch) ───────────
@@ -1188,6 +1189,21 @@ function HatchesTab({
// ── Linien (Line Styles) ───────────────────────────────────────────────────
/** 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({
style,
onPatch,
@@ -1199,10 +1215,15 @@ function LineStyleDetail({
}) {
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) {
onPatch({ kind: k, zigzag: { amplitude: 1, wavelength: 4 } });
} else if (k === "custom" && !style.motif) {
onPatch({ kind: k, motif: DEFAULT_MOTIF });
} else {
onPatch({ kind: k });
}
@@ -1262,6 +1283,7 @@ function LineStyleDetail({
options={[
{ value: "dash", label: t("resources.lineKind.dash") },
{ value: "zigzag", label: t("resources.lineKind.zigzag") },
{ value: "custom", label: t("resources.lineKind.custom") },
]}
/>
</FieldRow>
@@ -1287,6 +1309,14 @@ function LineStyleDetail({
/>
</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")}>
+38
View File
@@ -292,6 +292,44 @@ export function LineSwatch({
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 {
const dasharray = style.dash ? style.dash.map((d) => d * 4).join(" ") : undefined;
path = (