Linien-/Random-Editor: Strich-Segmente, Vollinie, Random-Kontrollen; Weight raus

Linien-Editor: Umschalter Vollinie (dash null) / Strich mit editierbarer
Segment-Liste (mm, alternierend Strich/Luecke, hinzufuegen/entfernen), Live-
Vorschau. Das Strichstaerken-Feld ist aus dem Linien-Editor entfernt —
LineStyle.weight ist @deprecated (bleibt Render-Fallback), die echte
per-Element-Aufloesung (Attribut->Ebene->Default) folgt separat.

Random-Schraffur: additive HatchStyle-Parameter seed/density/lengthMin/Max +
Neu-wuerfeln-Button (neuer Seed nur zur Editzeit). buildRandomHatchRuns mischt
den expliziten Seed in den Hash und nutzt Dichte/Laenge — Determinismus
gewahrt (gleicher Seed+Flaeche => gleiche Streuung), ueber alle Renderpfade
konsistent, da alle aus derselben Funktion ziehen. resolveHatch/HatchRender
reichen die Parameter rein additiv durch. 2 neue Tests.
This commit is contained in:
2026-07-04 00:53:19 +02:00
parent f0e777efc5
commit b4dd396768
8 changed files with 248 additions and 54 deletions
+10
View File
@@ -566,6 +566,16 @@ export const de = {
"resources.field.rotation": "Rotation °", "resources.field.rotation": "Rotation °",
"resources.field.amplitude": "Amplitude", "resources.field.amplitude": "Amplitude",
"resources.field.wavelength": "Wellenlänge", "resources.field.wavelength": "Wellenlänge",
"resources.field.density": "Dichte",
"resources.field.lengthMin": "Länge min (mm)",
"resources.field.lengthMax": "Länge max (mm)",
"resources.random.reroll": "Neu würfeln",
"resources.stroke.solid": "Vollinie",
"resources.stroke.dash": "Strich",
"resources.dash.segments": "Segmente (mm)",
"resources.dash.hint": "Alternierend Strich/Lücke in mm (z. B. 1, 2 = 1 mm Strich, 2 mm Lücke).",
"resources.dash.add": "Segment",
"resources.dash.remove": "Segment entfernen",
"resources.hatchKind.vector": "Vektor", "resources.hatchKind.vector": "Vektor",
"resources.hatchKind.image": "Bild", "resources.hatchKind.image": "Bild",
"resources.lines.parallel": "Parallel", "resources.lines.parallel": "Parallel",
+10
View File
@@ -561,6 +561,16 @@ export const en: Record<TranslationKey, string> = {
"resources.field.rotation": "Rotation °", "resources.field.rotation": "Rotation °",
"resources.field.amplitude": "Amplitude", "resources.field.amplitude": "Amplitude",
"resources.field.wavelength": "Wavelength", "resources.field.wavelength": "Wavelength",
"resources.field.density": "Density",
"resources.field.lengthMin": "Length min (mm)",
"resources.field.lengthMax": "Length max (mm)",
"resources.random.reroll": "Re-roll",
"resources.stroke.solid": "Solid",
"resources.stroke.dash": "Dash",
"resources.dash.segments": "Segments (mm)",
"resources.dash.hint": "Alternating dash/gap in mm (e.g. 1, 2 = 1 mm dash, 2 mm gap).",
"resources.dash.add": "Segment",
"resources.dash.remove": "Remove segment",
"resources.hatchKind.vector": "Vector", "resources.hatchKind.vector": "Vector",
"resources.hatchKind.image": "Image", "resources.hatchKind.image": "Image",
"resources.lines.parallel": "Parallel", "resources.lines.parallel": "Parallel",
+26 -1
View File
@@ -29,7 +29,16 @@ export type { RichTextDoc } from "../text/richText";
export interface LineStyle { export interface LineStyle {
id: string; id: string;
name: string; name: string;
/** Strichstärke in Millimetern (≙ Rhino PlotWeight). */ /**
* @deprecated Die Strichstärke gehört NICHT mehr in den Linienstil — es gibt nur
* „Vollinie/Strich", die tatsächliche Dicke wird per Element-Attribut (bzw.
* Ebene/Default) aufgelöst. Das Feld bleibt für die Backward-Compat-Auflösung
* erhalten und dient den Renderern noch als Fallback für die Muster-/Fugen-Stärke
* (siehe `resolveHatch` → `HatchRender.lineWeight`). Der Linien-Detail-Editor
* zeigt es nicht mehr an; neue Linienstile bekommen einen Haarlinien-Default.
* Die echte per-Element-Strichstärken-Auflösung (Attribut → Ebene → Default)
* folgt separat mit dem By-Layer/By-Object-Refactor — NICHT hier.
*/
weight: number; weight: number;
/** Farbe (hex). */ /** Farbe (hex). */
color: string; color: string;
@@ -112,6 +121,22 @@ export interface HatchStyle {
color: string; color: string;
/** Optionaler Linienstil für die Musterlinien (Line Manager). */ /** Optionaler Linienstil für die Musterlinien (Line Manager). */
lineStyleId?: string; lineStyleId?: string;
/**
* Steuer-Parameter der Random-Vektor-Schraffur (`kind`/"vector" + `lines`===
* "random", z. B. Kies/Splitt). ALLE additiv & optional — fehlen sie, gilt das
* bestehende deterministische Streu-Verhalten (Seed aus der Flächen-Bounding-Box,
* Dichte/Länge aus `scale`). Die Streuung bleibt bei gleichem `seed` + gleicher
* Fläche reproduzierbar über ALLE Renderpfade (single render truth); KEIN
* `Math.random()` zur Renderzeit.
*/
/** Expliziter Streu-Seed. Wird in den Flächen-Seed eingemischt; „Neu würfeln" setzt einen neuen Wert. */
seed?: number;
/** Streudichte als Multiplikator auf den Grundabstand (Default 1; >1 = dichter). */
density?: number;
/** Minimale Strichlänge in mm (Papier). Fehlt es, greift die `scale`-abhängige Default-Länge. */
lengthMin?: number;
/** Maximale Strichlänge in mm (Papier). Fehlt es, greift die `scale`-abhängige Default-Länge. */
lengthMax?: number;
} }
/** /**
+14
View File
@@ -135,6 +135,15 @@ export interface HatchRender {
lines?: "parallel" | "random"; lines?: "parallel" | "random";
/** Bild-Muster-Parameter (nur `kind==="image"`); durchgereicht aus dem HatchStyle. */ /** Bild-Muster-Parameter (nur `kind==="image"`); durchgereicht aus dem HatchStyle. */
image?: { src: string; scaleX: number; scaleY: number; rotation: number }; image?: { src: string; scaleX: number; scaleY: number; rotation: number };
/**
* Steuer-Parameter der Random-Vektor-Streuung (nur `lines==="random"`), 1:1 aus
* dem HatchStyle durchgereicht. Alle optional die eigentliche Erzeugung/
* Default-Auflösung liegt zentral in `glPlan/glPlanHatch.ts` (single render truth).
*/
seed?: number;
density?: number;
lengthMin?: number;
lengthMax?: number;
} }
/** /**
@@ -307,6 +316,11 @@ export function resolveHatch(
kind: h.kind, kind: h.kind,
lines: h.lines, lines: h.lines,
image: h.image, image: h.image,
// Random-Streu-Parameter unverändert durchreichen (Erzeugung in glPlanHatch).
seed: h.seed,
density: h.density,
lengthMin: h.lengthMin,
lengthMax: h.lengthMax,
}; };
} }
+28 -6
View File
@@ -546,6 +546,8 @@ export function scatterStrokes(
cell: number, cell: number,
baseAngleRad: number, baseAngleRad: number,
seed: number, seed: number,
lenMin?: number,
lenMax?: number,
): HatchRun[] { ): HatchRun[] {
if (poly.length < 3 || strokeLen <= 1e-9 || cell <= 1e-9) return []; if (poly.length < 3 || strokeLen <= 1e-9 || cell <= 1e-9) return [];
const bb = bbox(poly); const bb = bbox(poly);
@@ -555,14 +557,22 @@ export function scatterStrokes(
const nx = Math.max(1, Math.round(w / cell)); const nx = Math.max(1, Math.round(w / cell));
const ny = Math.max(1, Math.round(h / cell)); const ny = Math.max(1, Math.round(h / cell));
const count = Math.min(20000, Math.max(4, nx * ny)); const count = Math.min(20000, Math.max(4, nx * ny));
// Explizite Längen-Spanne (falls gesetzt) validieren; sonst die heutige
// scale-abhängige Variation (±40 % um `strokeLen`) beibehalten.
const hasRange =
lenMin != null && lenMax != null && lenMin > 1e-9 && lenMax >= lenMin;
const rnd = mulberry32(seed); const rnd = mulberry32(seed);
const runs: HatchRun[] = []; const runs: HatchRun[] = [];
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const cx = bb.minX + rnd() * w; const cx = bb.minX + rnd() * w;
const cy = bb.minY + rnd() * h; const cy = bb.minY + rnd() * h;
// Orientierung frei (± π um die Basisrichtung), Länge um ±40 % variiert. // Orientierung frei (± π um die Basisrichtung).
const ang = baseAngleRad + (rnd() * 2 - 1) * Math.PI; const ang = baseAngleRad + (rnd() * 2 - 1) * Math.PI;
const half = (strokeLen * 0.5) * (0.6 + 0.8 * rnd()); // Länge: explizite Spanne [lenMin,lenMax] oder Default (±40 % um strokeLen).
const len = hasRange
? lenMin! + rnd() * (lenMax! - lenMin!)
: strokeLen * (0.6 + 0.8 * rnd());
const half = len * 0.5;
const dx = Math.cos(ang) * half; const dx = Math.cos(ang) * half;
const dy = Math.sin(ang) * half; const dy = Math.sin(ang) * half;
const a: Vec2 = { x: cx - dx, y: cy - dy }; const a: Vec2 = { x: cx - dx, y: cy - dy };
@@ -581,12 +591,24 @@ export function scatterStrokes(
*/ */
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);
const cell = 8 * s * PX_TO_M; // Meter, wie der Parallel-Schar-Abstand // Dichte: Multiplikator auf den Grundabstand (>1 = dichter → kleinere Zelle).
const strokeLen = cell * 1.1; // kurzer Strich ~ Kachelmaß // Fehlt/ungültig ⇒ 1 ⇒ heutiges Verhalten.
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 strokeLen = cell * 1.1; // Default-Strich ~ Kachelmaß (wenn keine Längenspanne)
const bb = bbox(poly); const bb = bbox(poly);
const seed = hashSeed(bb.minX, bb.minY, bb.maxX - bb.minX, bb.maxY - bb.minY, hatch.angle, s); // Flächen-Seed (gerundete Bounding-Box + Muster-Parameter) MIT dem expliziten
// `hatch.seed` gemischt: „Neu würfeln" (neuer seed) ⇒ andere Streuung, aber
// gleicher seed + gleiche Fläche ⇒ identische Geometrie über alle Renderpfade.
const seed = hashSeed(
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);
return scatterStrokes(poly, strokeLen, cell, base, seed); // Explizite Längenspanne (mm-Papier → Modell-Meter, wie das Dash-Muster).
const lenMin = hatch.lengthMin != null ? hatch.lengthMin * 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);
} }
// ── Zickzack-/Wellen-Linie ─────────────────────────────────────────────────── // ── Zickzack-/Wellen-Linie ───────────────────────────────────────────────────
+19
View File
@@ -68,6 +68,25 @@ describe("Random-Vektor-Schraffur", () => {
expect(JSON.stringify(random)).not.toEqual(JSON.stringify(parallel)); expect(JSON.stringify(random)).not.toEqual(JSON.stringify(parallel));
}); });
it("expliziter Seed ist reproduzierbar und aendert die Streuung (Neu wuerfeln)", () => {
const base = { kind: "vector", lines: "random", scale: 1 } as const;
const s7a = buildHatchRuns(SQUARE, hatch({ ...base, seed: 7 }));
const s7b = buildHatchRuns(SQUARE, hatch({ ...base, seed: 7 }));
const s8 = buildHatchRuns(SQUARE, hatch({ ...base, seed: 8 }));
expect(s7a).toEqual(s7b); // gleicher Seed ⇒ identische Geometrie
expect(JSON.stringify(s7a)).not.toEqual(JSON.stringify(s8)); // neuer Seed ⇒ neue Streuung
});
it("Dichte verdichtet die Streuung, Längenspanne wird angewandt", () => {
const base = { kind: "vector", lines: "random", scale: 1, seed: 3 } as const;
const sparse = buildHatchRuns(SQUARE, hatch({ ...base }));
const dense = buildHatchRuns(SQUARE, hatch({ ...base, density: 3 }));
expect(dense.length).toBeGreaterThan(sparse.length);
// Längenspanne greift ohne zu werfen und bleibt deterministisch.
const ranged = buildHatchRuns(SQUARE, hatch({ ...base, lengthMin: 1, lengthMax: 2 }));
expect(ranged).toEqual(buildHatchRuns(SQUARE, hatch({ ...base, lengthMin: 1, lengthMax: 2 })));
});
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",
+120 -40
View File
@@ -56,29 +56,6 @@ const PATTERN_OPTIONS: { value: HatchPattern; labelKey: string }[] = [
{ value: "crosshatch", labelKey: "pattern.crosshatch" }, { value: "crosshatch", labelKey: "pattern.crosshatch" },
]; ];
/**
* Benannte Strichmuster (mm). `null` = durchgezogen. Werte als Schlüssel
* serialisiert, damit das Dropdown den aktuellen Stil wiedererkennt.
*/
const DASH_OPTIONS: { key: string; labelKey: string; dash: number[] | null }[] = [
{ key: "solid", labelKey: "dash.solid", dash: null },
{ key: "dashed", labelKey: "dash.dashed", dash: [6, 4] },
{ key: "dotted", labelKey: "dash.dotted", dash: [1, 3] },
];
/** Findet den Dash-Schlüssel, der zu einem Strichmuster passt (sonst custom). */
function dashKey(dash: number[] | null): string {
const hit = DASH_OPTIONS.find(
(o) =>
(o.dash === null && dash === null) ||
(o.dash !== null &&
dash !== null &&
o.dash.length === dash.length &&
o.dash.every((v, i) => v === dash[i])),
);
return hit ? hit.key : "custom";
}
// ── Wiederverwendbare Feld-Bausteine (DOSSIER-Look) ──────────────────────── // ── Wiederverwendbare Feld-Bausteine (DOSSIER-Look) ────────────────────────
// Alle Steuerelemente füllen ihre Tabellenzelle (width:100%) und werden über // Alle Steuerelemente füllen ihre Tabellenzelle (width:100%) und werden über
// die Spaltenbreite des Grids dimensioniert; Zahlenfelder sind rechtsbündig. // die Spaltenbreite des Grids dimensioniert; Zahlenfelder sind rechtsbündig.
@@ -979,6 +956,14 @@ function HatchDetail({
r.readAsDataURL(file); r.readAsDataURL(file);
}; };
// ── Random-Vektor-Schraffur (Kies/Splitt) ───────────────────────────────────
// Dichte + Längenspanne + „Neu würfeln". Der Seed wird beim Klick gesetzt (KEIN
// Math.random() zur Renderzeit — die Streuung selbst ist deterministisch per
// Seed). Länge min/max in mm; bis der Nutzer sie berührt, bleibt der Default.
const lenMin = hatch.lengthMin ?? 1;
const lenMax = hatch.lengthMax ?? 3;
const reroll = () => onPatch({ seed: Math.floor(Math.random() * 0x7fffffff) });
return ( return (
<div className="res-md-detail-inner"> <div className="res-md-detail-inner">
<div className="res-md-head"> <div className="res-md-head">
@@ -1064,6 +1049,39 @@ function HatchDetail({
options={lineOptions} options={lineOptions}
/> />
</FieldRow> </FieldRow>
{lines === "random" && (
<>
<FieldRow label={t("resources.field.density")}>
<NumberField
value={hatch.density ?? 1}
onChange={(density) => onPatch({ density })}
step={0.1}
min={0.1}
/>
</FieldRow>
<FieldRow label={t("resources.field.lengthMin")}>
<NumberField
value={lenMin}
onChange={(v) => onPatch({ lengthMin: v, lengthMax: hatch.lengthMax ?? lenMax })}
step={0.5}
min={0}
/>
</FieldRow>
<FieldRow label={t("resources.field.lengthMax")}>
<NumberField
value={lenMax}
onChange={(v) => onPatch({ lengthMax: v, lengthMin: hatch.lengthMin ?? lenMin })}
step={0.5}
min={0}
/>
</FieldRow>
<FieldRow label={t("resources.random.reroll")}>
<button type="button" className="res-seg-btn" onClick={reroll}>
{t("resources.random.reroll")}
</button>
</FieldRow>
</>
)}
</> </>
) : ( ) : (
<> <>
@@ -1196,6 +1214,27 @@ function LineStyleDetail({
onPatch({ zigzag: { ...cur, ...part } }); onPatch({ zigzag: { ...cur, ...part } });
}; };
// ── Strich (dash) ──────────────────────────────────────────────────────────
// „Vollinie" = `dash: null` (durchgezogen); „Strich" = editierbare Liste der
// Segmentlängen in mm (alternierend Strich/Lücke). Die Dicke gehört NICHT mehr
// hierher — sie wird per Element-Attribut aufgelöst (By-Object/By-Layer folgt).
const dash = style.dash;
const isSolid = dash === null;
const dashArr = dash ?? [];
const setStroke = (mode: "solid" | "dash") =>
onPatch({ dash: mode === "solid" ? null : dashArr.length ? dashArr : [1, 2] });
const setSeg = (i: number, v: number) => {
const next = dashArr.slice();
next[i] = Math.max(0, v);
onPatch({ dash: next });
};
const addSeg = () =>
onPatch({ dash: [...dashArr, dashArr.length ? dashArr[dashArr.length - 1] : 1] });
const removeSeg = (i: number) => {
const next = dashArr.filter((_, k) => k !== i);
onPatch({ dash: next.length ? next : null });
};
return ( return (
<div className="res-md-detail-inner"> <div className="res-md-detail-inner">
<div className="res-md-head"> <div className="res-md-head">
@@ -1226,16 +1265,6 @@ function LineStyleDetail({
]} ]}
/> />
</FieldRow> </FieldRow>
<FieldRow label={t("resources.col.weight")}>
<NumberField
value={style.weight}
onChange={(weight) => onPatch({ weight })}
step={0.05}
min={0.01}
max={2}
list="pen-weights"
/>
</FieldRow>
<FieldRow label={t("resources.col.color")}> <FieldRow label={t("resources.col.color")}>
<ColorField color={style.color} onChange={(color) => onPatch({ color })} /> <ColorField color={style.color} onChange={(color) => onPatch({ color })} />
</FieldRow> </FieldRow>
@@ -1259,16 +1288,67 @@ function LineStyleDetail({
</FieldRow> </FieldRow>
</> </>
) : ( ) : (
<>
<FieldRow label={t("resources.col.stroke")}> <FieldRow label={t("resources.col.stroke")}>
<SelectField <Segmented
value={dashKey(style.dash)} value={isSolid ? "solid" : "dash"}
onChange={(key) => { onChange={setStroke}
const opt = DASH_OPTIONS.find((o) => o.key === key); options={[
if (opt) onPatch({ dash: opt.dash }); { value: "solid", label: t("resources.stroke.solid") },
}} { value: "dash", label: t("resources.stroke.dash") },
options={DASH_OPTIONS.map((o) => ({ value: o.key, label: t(o.labelKey) }))} ]}
/> />
</FieldRow> </FieldRow>
{!isSolid && (
<FieldRow
label={t("resources.dash.segments")}
hint={t("resources.dash.hint")}
>
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 4,
alignItems: "center",
}}
>
{dashArr.map((v, i) => (
<span
key={i}
style={{ display: "inline-flex", alignItems: "center", gap: 2 }}
>
<span style={{ width: 46 }}>
<NumberField
value={v}
onChange={(x) => setSeg(i, x)}
step={0.5}
min={0}
/>
</span>
<button
type="button"
className="res-seg-btn"
title={t("resources.dash.remove")}
onClick={() => removeSeg(i)}
style={{ padding: "2px 6px", lineHeight: 1 }}
>
×
</button>
</span>
))}
<button
type="button"
className="res-seg-btn"
title={t("resources.dash.add")}
onClick={addSeg}
style={{ padding: "2px 8px", lineHeight: 1 }}
>
+ {t("resources.dash.add")}
</button>
</div>
</FieldRow>
)}
</>
)} )}
</div> </div>
</div> </div>
+18 -4
View File
@@ -148,15 +148,29 @@ export function HatchSwatch({
} else if (hatch.pattern === "none") { } else if (hatch.pattern === "none") {
inner = null; inner = null;
} else if (hatch.lines === "random") { } else if (hatch.lines === "random") {
// Zufällig verteilte kurze Striche (deterministisch geseedet). // Zufällig verteilte kurze Striche (deterministisch geseedet). Dichte, Länge
const rnd = mulberry32(Math.round(scale * 1000) + Math.round(angle)); // und der explizite Seed spiegeln die Plan-Parameter (HatchStyle) — so ändert
const count = Math.round(18 * Math.min(2, scale)) + 6; // „Neu würfeln"/Dichte/Länge die Vorschau wie im Plan. Rein schematisch (px),
const len = Math.max(3, 6 / Math.sqrt(scale)); // NICHT deckungsgleich mit der Modell-Streuung, aber qualitativ konsistent.
const density = hatch.density != null && hatch.density > 0 ? hatch.density : 1;
const seed = (Math.round(scale * 1000) + Math.round(angle) + Math.round(hatch.seed ?? 0)) >>> 0;
const rnd = mulberry32(seed);
const count = Math.min(400, Math.max(4, Math.round((18 * Math.min(2, scale) + 6) * density)));
// mm-Papier → Vorschau-px (wie das Dash-Muster im LineSwatch: ·4).
const PX_PER_MM = 4;
const hasRange =
hatch.lengthMin != null && hatch.lengthMax != null &&
hatch.lengthMin > 0 && hatch.lengthMax >= hatch.lengthMin;
const baseLen = Math.max(3, 6 / Math.sqrt(scale));
const out: React.ReactNode[] = []; const out: React.ReactNode[] = [];
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const cx = rnd() * w; const cx = rnd() * w;
const cy = rnd() * h; const cy = rnd() * h;
const ang = rnd() * Math.PI; const ang = rnd() * Math.PI;
const lr = rnd();
const len = hasRange
? (hatch.lengthMin! + lr * (hatch.lengthMax! - hatch.lengthMin!)) * PX_PER_MM
: baseLen;
const dx = (Math.cos(ang) * len) / 2; const dx = (Math.cos(ang) * len) / 2;
const dy = (Math.sin(ang) * len) / 2; const dy = (Math.sin(ang) * len) / 2;
out.push( out.push(