diff --git a/src/export/exportDxf.ts b/src/export/exportDxf.ts index 42bdca8..8e5cfc1 100644 --- a/src/export/exportDxf.ts +++ b/src/export/exportDxf.ts @@ -12,7 +12,7 @@ import type { Plan, Primitive } from "../plan/generatePlan"; import type { LayerCategory, Project, Vec2 } from "../model/types"; import { flattenCategories } from "../model/types"; import { DxfWriter } from "./dxfWriter"; -import { buildRandomHatchRuns, zigzagPoints } from "../plan/glPlan/glPlanHatch"; +import { buildRandomHatchRuns, zigzagPoints, motifPoints } from "../plan/glPlan/glPlanHatch"; export interface ExportDxfOptions { /** Titel/Geschossname (nur als Kommentar-freundlicher Dateiname genutzt). */ @@ -113,6 +113,9 @@ function emitPrimitive( const amp = p.zigzag.amplitude * DASH_MM_TO_M; const wav = p.zigzag.wavelength * DASH_MM_TO_M; 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 { dxf.addLine(layer, p.a, p.b); } diff --git a/src/export/planToPrintSvg.ts b/src/export/planToPrintSvg.ts index 66fd888..5baa5fd 100644 --- a/src/export/planToPrintSvg.ts +++ b/src/export/planToPrintSvg.ts @@ -23,7 +23,7 @@ import type { HatchRender, Plan, Primitive } from "../plan/generatePlan"; 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. */ const SVG_NS = "http://www.w3.org/2000/svg"; @@ -227,6 +227,7 @@ function appendPrimitive( const b = map(p.b); // Zickzack-Linie (amplitude/wavelength sind Papier-mm = mm-Zielraum) → // Polylinie; sonst gerade (ggf. gestrichelte) Linie wie bisher. + // Zickzack/Custom-Motiv sind in Papier-mm definiert = mm-Zielraum (scale 1). const ln = p.zigzag ? polylineNode( zigzagPoints(a, b, p.zigzag.amplitude, p.zigzag.wavelength), @@ -234,7 +235,14 @@ function appendPrimitive( quantizePen(p.weightMm), p.dash, ) - : lineNode(a, b, p.color ?? "#111111", quantizePen(p.weightMm), p.dash, mmPerM); + : 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); if (opacity) ln.setAttribute("opacity", opacity); svg.appendChild(ln); break; diff --git a/src/i18n/de.ts b/src/i18n/de.ts index 08aabae..2de63c9 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -582,6 +582,10 @@ export const de = { "resources.lines.random": "Zufällig", "resources.lineKind.dash": "Strich", "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. "pattern.none": "Ohne", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index e25cde9..6260d8c 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -577,6 +577,10 @@ export const en: Record = { "resources.lines.random": "Random", "resources.lineKind.dash": "Dash", "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.solid": "Solid", diff --git a/src/model/types.ts b/src/model/types.ts index 0960a61..0689260 100644 --- a/src/model/types.ts +++ b/src/model/types.ts @@ -50,14 +50,25 @@ export interface LineStyle { * • "zigzag" — der Strich variiert in Y (Zickzack/Welle), Parameter in * `zigzag`. Fehlt `kind`, gilt „dash"; die heutigen Renderer ignorieren * `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 * Papier: `amplitude` = Ausschlag quer zur Linie, `wavelength` = Periodenlänge * entlang der Linie. Fehlt es, wird die Linie gerade gezeichnet. */ 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. */ diff --git a/src/plan/PlanView.tsx b/src/plan/PlanView.tsx index 8de7134..94ce608 100644 --- a/src/plan/PlanView.tsx +++ b/src/plan/PlanView.tsx @@ -20,7 +20,7 @@ import type { DraftShape, SnapResult, ToolHandlers, ToolId, ToolMods } from "../ import { useGlPlanRenderer } from "./useGlPlanRenderer"; import { useWasmPlanRenderer } from "./useWasmPlanRenderer"; import { quantizePen } from "../export/sceneToPrintSvg"; -import { buildRandomHatchRuns, zigzagPoints, DASH_MM_TO_M } from "./glPlan/glPlanHatch"; +import { buildRandomHatchRuns, zigzagPoints, motifPoints, DASH_MM_TO_M } from "./glPlan/glPlanHatch"; const PX_PER_M = 90; // viewBox-Einheiten je Meter (Modell → SVG-Benutzerraum) const PAD = 60; // Rand in viewBox-Einheiten @@ -2526,6 +2526,8 @@ interface DrawingRun { greyed?: boolean; /** Zickzack-Parameter (Papier-mm); gesetzt ⇒ Lauf als Zickzack-Pfad zeichnen. */ 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). */ @@ -2596,6 +2598,21 @@ function buildDrawingRuns(prims: Primitive[]): DrawingRun[] { }); 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)) { // An den laufenden Zug anhängen. curPts.push(p.b); @@ -2638,7 +2655,7 @@ function DrawingRunShape({ .map((mm) => (print ? printStrokeVb(mm, paperScale!) : mmToPx(mm))) .join(" ") : 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. const modelPts = run.zigzag ? zigzagPoints( @@ -2647,7 +2664,9 @@ function DrawingRunShape({ run.zigzag.amplitude * DASH_MM_TO_M, run.zigzag.wavelength * DASH_MM_TO_M, ) - : run.pts; + : run.motif + ? motifPoints(run.pts[0], run.pts[run.pts.length - 1], run.motif, DASH_MM_TO_M) + : run.pts; const pts = modelPts.map(toScreen).map((s) => `${s.x},${s.y}`).join(" "); const shape = run.closed ? ( ); } + // 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 ( + `${s.x},${s.y}`).join(" ")} + className={p.cls} + fill="none" + stroke={p.color} + strokeWidth={weight(p.weightMm)} + vectorEffect={vfx} + /> + ); + } // Strichstärke + Strichmuster in mm Papier. Die Farbe kommt weiterhin aus // der CSS-Klasse (z. B. .door-leaf / .wall-axis). return ( diff --git a/src/plan/generatePlan.ts b/src/plan/generatePlan.ts index ae7b5dd..bc5b517 100644 --- a/src/plan/generatePlan.ts +++ b/src/plan/generatePlan.ts @@ -226,6 +226,14 @@ export type Primitive = * die bestehenden Strich/Dash-Linien zu verändern (Feld fehlt ⇒ heute). */ 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). */ color?: string; /** 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"). // Additiv: fehlt es, bleibt die Linie gerade/gestrichelt wie bisher. 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) => - 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 @@ -1053,6 +1063,8 @@ function addWallPoche( dash: jls ? jls.dash : null, // Zickzack-Fuge (LineStyle kind==="zigzag") ebenfalls durchreichen. zigzag: jls?.kind === "zigzag" ? jls.zigzag : undefined, + // Custom-Motiv-Fuge (LineStyle kind==="custom") ebenfalls durchreichen. + motif: jls?.kind === "custom" ? jls.motif : undefined, greyed, }); } diff --git a/src/plan/glPlan/glPlanCompile.ts b/src/plan/glPlan/glPlanCompile.ts index 5f2a05c..af2cbd4 100644 --- a/src/plan/glPlan/glPlanCompile.ts +++ b/src/plan/glPlan/glPlanCompile.ts @@ -11,7 +11,7 @@ import type { Primitive } from '../generatePlan'; import type { Vec2 } from '../../model/types'; 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; @@ -435,6 +435,10 @@ export function compilePrimitivesToGpu( const wav = prim.zigzag.wavelength * DASH_MM_TO_M; const zpts = zigzagPoints(prim.a, prim.b, amp, wav); strokePolyline(zpts, false, color, prim.weightMm || 0.18); + } else 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 { pushLine(prim.a, prim.b, color, prim.weightMm || 0.18); } diff --git a/src/plan/glPlan/glPlanHatch.ts b/src/plan/glPlan/glPlanHatch.ts index 6b0ec61..c918305 100644 --- a/src/plan/glPlan/glPlanHatch.ts +++ b/src/plan/glPlan/glPlanHatch.ts @@ -533,61 +533,108 @@ function hashSeed(...vals: number[]): number { } /** - * Streut kurze Striche gleichmäßig-zufällig (aber DETERMINISTISCH per `seed`) über - * ein Polygon: je Zelle des `cell`-Rasters ein Strich zufälliger Lage, Länge - * (~`strokeLen`) und Orientierung (um `baseAngleRad` ± π), auf das Polygon - * geclippt ({@link clipSegmentToPolygon}, konkav-fähig). Einheiten-agnostisch — - * der Aufrufer gibt `strokeLen`/`cell` in der Ziel-Koordinateneinheit an - * (Modell-Meter bzw. Papier-mm). Ausgabe = 2-Punkt-Läufe. + * Deterministischer Hash EINER Modellraum-Zelle (`ix`,`iy` = absolute Zell-Indizes + * `floor(x/cell)`/`floor(y/cell)`) plus `seed`. Ergebnis ist ein 32-bit-Seed für + * {@link mulberry32}. Zentraler Baustein der modellraum-stabilen Streuung: der + * Zell-Inhalt hängt NUR an den absoluten Zell-Indizes + Seed — NICHT an der + * Bounding-Box der Fläche. Dadurch bleibt ein Strich beim Vergrößern/Verschieben + * 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( poly: Vec2[], - strokeLen: number, cell: number, baseAngleRad: number, seed: number, + strokeLen: number, lenMin?: number, lenMax?: number, ): 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 w = bb.maxX - bb.minX; - const h = bb.maxY - bb.minY; - if (!(w > 0) || !(h > 0)) return []; - const nx = Math.max(1, Math.round(w / cell)); - const ny = Math.max(1, Math.round(h / cell)); - const count = Math.min(20000, Math.max(4, nx * ny)); - // Explizite Längen-Spanne (falls gesetzt) validieren; sonst die heutige - // scale-abhängige Variation (±40 % um `strokeLen`) beibehalten. + if (!(bb.maxX > bb.minX) || !(bb.maxY > bb.minY)) return []; + + // Absolute Zell-Indizes, die die Bounding-Box überdecken (Modell-Ursprung als + // Gitter-Anker → verschiebe-invariant). + let eff = cell; + let ix0 = Math.floor(bb.minX / eff); + let ix1 = Math.floor(bb.maxX / eff); + 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 = 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). - const ang = baseAngleRad + (rnd() * 2 - 1) * Math.PI; - // 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 }; - const b: Vec2 = { x: cx + dx, y: cy + dy }; - for (const seg of clipSegmentToPolygon(a, b, poly)) runs.push([seg.a, seg.b]); + for (let iy = iy0; iy <= iy1; iy++) { + for (let ix = ix0; ix <= ix1; ix++) { + 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). + const ang = baseAngleRad + (rnd() * 2 - 1) * Math.PI; + // 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 }; + const b: Vec2 = { x: cx + dx, y: cy + dy }; + for (const seg of clipSegmentToPolygon(a, b, poly)) runs.push([seg.a, seg.b]); + } } return runs; } /** * Random-Vektor-Schraffur eines Polygons in Modell-Metern (Kies/Splitt). Dichte - * und Strichlänge sind — wie die Parallelschar — an die Kachelgröße - * (`8·scale` px) gekoppelt, damit `scale` die Streudichte steuert. Der Seed - * stammt aus der Flächen-Kennung (gerundete Bounding-Box) plus den Hatch- - * Parametern → über Re-Renders/Print reproduzierbar. + * und Strichlänge sind — wie die Parallelschar — an die Kachelgröße (`8·scale` px) + * gekoppelt, damit `scale`/`density` die Streudichte steuern. Die Streuung ist + * MODELLRAUM-STABIL (siehe {@link scatterStrokes}): der Seed hängt NUR an den + * 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[] { 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 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); - // 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, - ); + // Fester Seed NUR aus Muster-Parametern + explizitem `hatch.seed` (KEINE bbox): + // „Neu würfeln" (neuer seed) ⇒ anderes Feld, aber gleicher seed + gleiche + // Muster-Parameter ⇒ identisches, modellraum-verankertes Feld über alle + // Renderpfade — unabhängig von Lage/Größe der Fläche. + const seed = hashSeed(hatch.angle, s, density, hatch.seed ?? 0); const base = toModelAngleRad(hatch.angle); // 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); + return scatterStrokes(poly, cell, base, seed, strokeLen, lenMin, lenMax); } // ── Zickzack-/Wellen-Linie ─────────────────────────────────────────────────── @@ -644,3 +688,64 @@ export function zigzagPoints(a: Vec2, b: Vec2, amplitude: number, wavelength: nu } 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]; +} diff --git a/src/plan/hatchLineTypes.test.ts b/src/plan/hatchLineTypes.test.ts index 4110763..9a56f2a 100644 --- a/src/plan/hatchLineTypes.test.ts +++ b/src/plan/hatchLineTypes.test.ts @@ -6,7 +6,7 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest"; import type { Plan, Primitive, HatchRender } from "./generatePlan"; import { planToRenderScene } from "./toRenderScene"; -import { buildHatchRuns, zigzagPoints } from "./glPlan/glPlanHatch"; +import { buildHatchRuns, buildRandomHatchRuns, zigzagPoints, motifPoints } from "./glPlan/glPlanHatch"; const SQUARE = [ { 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", () => { it("liefert Streu-Striche (nicht leer) und ist DETERMINISTISCH bei gleichem Seed", () => { 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 }))); }); + 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)", () => { const poly: Primitive = { kind: "polygon", diff --git a/src/plan/toRenderScene.ts b/src/plan/toRenderScene.ts index 885ad24..523f688 100644 --- a/src/plan/toRenderScene.ts +++ b/src/plan/toRenderScene.ts @@ -20,7 +20,7 @@ // gesamte (ggf. verkettete oder adaptiv tessellierte) Strecke durchläuft. 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"; type LinePrim = Extract; @@ -439,6 +439,20 @@ export function planToRenderScene(plan: Plan, paperScaleN: number = STAMP_DEFAUL 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) { // Aufeinanderfolgende 2D-Zeichenlinien gleicher drawingId/Stil verketten. const a: RPoint = [p.a.x, p.a.y]; diff --git a/src/styles.css b/src/styles.css index 1a03053..bad1714 100644 --- a/src/styles.css +++ b/src/styles.css @@ -2253,6 +2253,88 @@ body { 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). */ .res-delete { flex: 0 0 auto; diff --git a/src/ui/MotifEditor.tsx b/src/ui/MotifEditor.tsx new file mode 100644 index 0000000..d806ab1 --- /dev/null +++ b/src/ui/MotifEditor.tsx @@ -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(null); + const [drag, setDrag] = useState(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 ( +
+ setDrag(null)} + onPointerLeave={() => setDrag(null)} + > + {/* Zellen-Rahmen */} + + {/* Raster */} + {gridX.map((x, i) => ( + + ))} + + {/* Motiv-Polylinie */} + + {/* Punkt-Griffe */} + {pxPts.map((s, i) => ( + { + e.stopPropagation(); + (e.target as Element).setPointerCapture?.(e.pointerId); + setDrag(i); + }} + onDoubleClick={(e) => { + e.stopPropagation(); + removePoint(i); + }} + /> + ))} + + +
+ + {t("resources.motif.hint")} +
+ + +
+ ); +} + +/** + * 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 ( + + + {pts.length >= 2 && ( + + )} + + ); +} diff --git a/src/ui/ResourceManager.tsx b/src/ui/ResourceManager.tsx index e7ad1c1..029e663 100644 --- a/src/ui/ResourceManager.tsx +++ b/src/ui/ResourceManager.tsx @@ -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 = { + 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") }, ]} /> @@ -1287,6 +1309,14 @@ function LineStyleDetail({ /> + ) : kind === "custom" ? ( + + onPatch({ motif: { points, length } })} + /> + ) : ( <> diff --git a/src/ui/hatchPreview.tsx b/src/ui/hatchPreview.tsx index efa80b4..115c7dc 100644 --- a/src/ui/hatchPreview.tsx +++ b/src/ui/hatchPreview.tsx @@ -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 = ( + + ); } else { const dasharray = style.dash ? style.dash.map((d) => d * 4).join(" ") : undefined; path = (