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
+37 -3
View File
@@ -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 ? (
<polygon
@@ -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
// der CSS-Klasse (z. B. .door-leaf / .wall-axis).
return (
+13 -1
View File
@@ -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,
});
}
+5 -1
View File
@@ -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);
}
+150 -45
View File
@@ -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];
}
+90 -1
View File
@@ -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",
+15 -1
View File
@@ -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<Primitive, { kind: "line" }>;
@@ -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];