From b4dd3967687d8fbb4eb9527ba460089a582b0a43 Mon Sep 17 00:00:00 2001 From: Karim Date: Sat, 4 Jul 2026 00:53:19 +0200 Subject: [PATCH] Linien-/Random-Editor: Strich-Segmente, Vollinie, Random-Kontrollen; Weight raus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/i18n/de.ts | 10 ++ src/i18n/en.ts | 10 ++ src/model/types.ts | 27 +++++- src/plan/generatePlan.ts | 14 +++ src/plan/glPlan/glPlanHatch.ts | 34 +++++-- src/plan/hatchLineTypes.test.ts | 19 ++++ src/ui/ResourceManager.tsx | 166 +++++++++++++++++++++++--------- src/ui/hatchPreview.tsx | 22 ++++- 8 files changed, 248 insertions(+), 54 deletions(-) diff --git a/src/i18n/de.ts b/src/i18n/de.ts index bd551dd..08aabae 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -566,6 +566,16 @@ export const de = { "resources.field.rotation": "Rotation °", "resources.field.amplitude": "Amplitude", "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.image": "Bild", "resources.lines.parallel": "Parallel", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 5a0159c..e25cde9 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -561,6 +561,16 @@ export const en: Record = { "resources.field.rotation": "Rotation °", "resources.field.amplitude": "Amplitude", "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.image": "Image", "resources.lines.parallel": "Parallel", diff --git a/src/model/types.ts b/src/model/types.ts index d6eacb2..0960a61 100644 --- a/src/model/types.ts +++ b/src/model/types.ts @@ -29,7 +29,16 @@ export type { RichTextDoc } from "../text/richText"; export interface LineStyle { id: 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; /** Farbe (hex). */ color: string; @@ -112,6 +121,22 @@ export interface HatchStyle { color: string; /** Optionaler Linienstil für die Musterlinien (Line Manager). */ 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; } /** diff --git a/src/plan/generatePlan.ts b/src/plan/generatePlan.ts index 9dd1475..ae7b5dd 100644 --- a/src/plan/generatePlan.ts +++ b/src/plan/generatePlan.ts @@ -135,6 +135,15 @@ export interface HatchRender { lines?: "parallel" | "random"; /** Bild-Muster-Parameter (nur `kind==="image"`); durchgereicht aus dem HatchStyle. */ 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, lines: h.lines, image: h.image, + // Random-Streu-Parameter unverändert durchreichen (Erzeugung in glPlanHatch). + seed: h.seed, + density: h.density, + lengthMin: h.lengthMin, + lengthMax: h.lengthMax, }; } diff --git a/src/plan/glPlan/glPlanHatch.ts b/src/plan/glPlan/glPlanHatch.ts index e6633a0..6b0ec61 100644 --- a/src/plan/glPlan/glPlanHatch.ts +++ b/src/plan/glPlan/glPlanHatch.ts @@ -546,6 +546,8 @@ export function scatterStrokes( cell: number, baseAngleRad: number, seed: number, + lenMin?: number, + lenMax?: number, ): HatchRun[] { if (poly.length < 3 || strokeLen <= 1e-9 || cell <= 1e-9) return []; const bb = bbox(poly); @@ -555,14 +557,22 @@ export function scatterStrokes( 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)); + // 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 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. + // Orientierung frei (± π um die Basisrichtung). 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 dy = Math.sin(ang) * half; 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[] { 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ß + // Dichte: Multiplikator auf den Grundabstand (>1 = dichter → kleinere Zelle). + // 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 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); - 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 ─────────────────────────────────────────────────── diff --git a/src/plan/hatchLineTypes.test.ts b/src/plan/hatchLineTypes.test.ts index a7abfcc..4110763 100644 --- a/src/plan/hatchLineTypes.test.ts +++ b/src/plan/hatchLineTypes.test.ts @@ -68,6 +68,25 @@ describe("Random-Vektor-Schraffur", () => { 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)", () => { const poly: Primitive = { kind: "polygon", diff --git a/src/ui/ResourceManager.tsx b/src/ui/ResourceManager.tsx index 33fbd8d..e7ad1c1 100644 --- a/src/ui/ResourceManager.tsx +++ b/src/ui/ResourceManager.tsx @@ -56,29 +56,6 @@ const PATTERN_OPTIONS: { value: HatchPattern; labelKey: string }[] = [ { 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) ──────────────────────── // Alle Steuerelemente füllen ihre Tabellenzelle (width:100%) und werden über // die Spaltenbreite des Grids dimensioniert; Zahlenfelder sind rechtsbündig. @@ -979,6 +956,14 @@ function HatchDetail({ 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 (
@@ -1064,6 +1049,39 @@ function HatchDetail({ options={lineOptions} /> + {lines === "random" && ( + <> + + onPatch({ density })} + step={0.1} + min={0.1} + /> + + + onPatch({ lengthMin: v, lengthMax: hatch.lengthMax ?? lenMax })} + step={0.5} + min={0} + /> + + + onPatch({ lengthMax: v, lengthMin: hatch.lengthMin ?? lenMin })} + step={0.5} + min={0} + /> + + + + + + )} ) : ( <> @@ -1196,6 +1214,27 @@ function LineStyleDetail({ 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 (
@@ -1226,16 +1265,6 @@ function LineStyleDetail({ ]} /> - - onPatch({ weight })} - step={0.05} - min={0.01} - max={2} - list="pen-weights" - /> - onPatch({ color })} /> @@ -1259,16 +1288,67 @@ function LineStyleDetail({ ) : ( - - { - const opt = DASH_OPTIONS.find((o) => o.key === key); - if (opt) onPatch({ dash: opt.dash }); - }} - options={DASH_OPTIONS.map((o) => ({ value: o.key, label: t(o.labelKey) }))} - /> - + <> + + + + {!isSolid && ( + +
+ {dashArr.map((v, i) => ( + + + setSeg(i, x)} + step={0.5} + min={0} + /> + + + + ))} + +
+
+ )} + )}
diff --git a/src/ui/hatchPreview.tsx b/src/ui/hatchPreview.tsx index e6f8638..efa80b4 100644 --- a/src/ui/hatchPreview.tsx +++ b/src/ui/hatchPreview.tsx @@ -148,15 +148,29 @@ export function HatchSwatch({ } else if (hatch.pattern === "none") { inner = null; } else if (hatch.lines === "random") { - // Zufällig verteilte kurze Striche (deterministisch geseedet). - const rnd = mulberry32(Math.round(scale * 1000) + Math.round(angle)); - const count = Math.round(18 * Math.min(2, scale)) + 6; - const len = Math.max(3, 6 / Math.sqrt(scale)); + // Zufällig verteilte kurze Striche (deterministisch geseedet). Dichte, Länge + // und der explizite Seed spiegeln die Plan-Parameter (HatchStyle) — so ändert + // „Neu würfeln"/Dichte/Länge die Vorschau wie im Plan. Rein schematisch (px), + // 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[] = []; for (let i = 0; i < count; i++) { const cx = rnd() * w; const cy = rnd() * h; 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 dy = (Math.sin(ang) * len) / 2; out.push(