Linien: modulares Segment-System (Strich/Punkt/Luecke) + Loop-Vorschau

Der Linien-Editor ist modular: eine Linie ist eine geordnete, loopende Folge
aus Segmenten Strich (Laenge), Punkt (Dot) und Luecke (Laenge) — beliebige
Sequenzen (Volllinie/Strichlinie/Punktlinie/Strich-Punkt als Presets, plus
frei), die Schluss-Luecke ist die letzte Luecke. Datenbasis bleibt
LineStyle.dash (mm, alternierend); ein Punkt ist ein 0-Laengen-AN-Segment.
Enthaelt dash eine 0, wird die Linie mit runder Kappe gezeichnet, damit
Punkte als Dots erscheinen (Live + Print; GL/DXF Folgearbeit). Neue reine
Segment-Logik in ui/lineSegments.ts. LineSwatch zeigt den ersten Loop dunkel
und 2 weitere grau (Loop-Kontext). 14 neue Tests, 127 gruen.
This commit is contained in:
2026-07-04 01:21:04 +02:00
parent f09ba0e49f
commit a302d512d9
10 changed files with 433 additions and 60 deletions
+93 -52
View File
@@ -43,6 +43,15 @@ import {
import { requestMaterialPreview, cancelMaterialPreview } from "../materials/spherePreview";
import { EyeIcon } from "./EyeIcon";
import { HatchSwatch, LineSwatch } from "./hatchPreview";
import {
segmentsToDash,
dashToSegments,
presetOfDash,
LINE_PRESETS,
type LineSegment,
type LineSegmentType,
type LinePresetKey,
} from "./lineSegments";
import { MotifEditor } from "./MotifEditor";
import { t } from "../i18n";
@@ -1235,26 +1244,35 @@ 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).
// ── Modulares Segment-System (dash) ─────────────────────────────────────────
// Eine musterbasierte Linie ist eine geordnete, loopende Folge aus Segmenten
// (Strich / Punkt / Lücke). Die Folge wird aus `dash` abgeleitet und beim
// Editieren via `segmentsToDash` wieder nach `dash` geschrieben — `dash` bleibt
// die einzige Datenbasis. „Volllinie" = `dash: null`. 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 segments = dashToSegments(dash);
const preset = presetOfDash(dash);
const applyPreset = (p: LinePresetKey) => {
if (p === "custom") return;
onPatch({ dash: LINE_PRESETS[p] });
};
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 });
const commitSegments = (next: LineSegment[]) => onPatch({ dash: segmentsToDash(next) });
const setSegType = (i: number, type: LineSegmentType) => {
const next = segments.slice();
const cur = next[i];
next[i] = { type, length: type === "dot" ? 0 : cur.length > 0 ? cur.length : 2 };
commitSegments(next);
};
const setSegLen = (i: number, v: number) => {
const next = segments.slice();
next[i] = { ...next[i], length: Math.max(0, v) };
commitSegments(next);
};
const addSegOf = (type: LineSegmentType) =>
commitSegments([...segments, { type, length: type === "dot" ? 0 : 2 }]);
const removeSeg = (i: number) => commitSegments(segments.filter((_, k) => k !== i));
return (
<div className="res-md-detail-inner">
@@ -1319,13 +1337,16 @@ function LineStyleDetail({
</FieldRow>
) : (
<>
<FieldRow label={t("resources.col.stroke")}>
<Segmented
value={isSolid ? "solid" : "dash"}
onChange={setStroke}
{/* Schnell-Presets: Volllinie / Strichlinie / Punktlinie / Strich-Punkt. */}
<FieldRow label={t("resources.linePreset.label")}>
<Segmented<string>
value={preset}
onChange={(p) => applyPreset(p as LinePresetKey)}
options={[
{ value: "solid", label: t("resources.stroke.solid") },
{ value: "dash", label: t("resources.stroke.dash") },
{ value: "solid", label: t("resources.linePreset.solid") },
{ value: "dash", label: t("resources.linePreset.dash") },
{ value: "dot", label: t("resources.linePreset.dot") },
{ value: "dashDot", label: t("resources.linePreset.dashDot") },
]}
/>
</FieldRow>
@@ -1334,26 +1355,29 @@ function LineStyleDetail({
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}
/>
<div className="line-seg-editor">
{segments.map((seg, i) => (
<div className="line-seg-row" key={i}>
<SelectField
value={seg.type}
onChange={(type) => setSegType(i, type)}
options={[
{ value: "dash", label: t("resources.seg.dash") },
{ value: "dot", label: t("resources.seg.dot") },
{ value: "gap", label: t("resources.seg.gap") },
]}
/>
<span className="line-seg-num">
{seg.type === "dot" ? (
<span className="line-seg-dotlabel">0</span>
) : (
<NumberField
value={seg.length}
onChange={(x) => setSegLen(i, x)}
step={0.5}
min={0}
/>
)}
</span>
<button
type="button"
@@ -1364,17 +1388,34 @@ function LineStyleDetail({
>
×
</button>
</span>
</div>
))}
<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 className="line-seg-add">
<button
type="button"
className="res-seg-btn"
onClick={() => addSegOf("dash")}
style={{ padding: "2px 8px", lineHeight: 1 }}
>
{t("resources.seg.addDash")}
</button>
<button
type="button"
className="res-seg-btn"
onClick={() => addSegOf("dot")}
style={{ padding: "2px 8px", lineHeight: 1 }}
>
{t("resources.seg.addDot")}
</button>
<button
type="button"
className="res-seg-btn"
onClick={() => addSegOf("gap")}
style={{ padding: "2px 8px", lineHeight: 1 }}
>
{t("resources.seg.addGap")}
</button>
</div>
</div>
</FieldRow>
)}
+42 -3
View File
@@ -10,6 +10,7 @@
import { useId } from "react";
import type { HatchStyle, LineStyle } from "../model/types";
import { dashHasDot } from "./lineSegments";
// ── Kleiner deterministischer PRNG (mulberry32) ────────────────────────────
// Für „random"-Striche: stabil pro Seed, damit die Vorschau bei gleichem
@@ -330,8 +331,8 @@ export function LineSwatch({
strokeLinecap="round"
/>
);
} else {
const dasharray = style.dash ? style.dash.map((d) => d * 4).join(" ") : undefined;
} else if (!style.dash || !style.dash.length) {
// Volllinie: durchgezogen, eine dunkle Linie.
path = (
<line
x1={x0}
@@ -340,10 +341,48 @@ export function LineSwatch({
y2={midY}
stroke="currentColor"
strokeWidth={px}
strokeDasharray={dasharray}
strokeLinecap="butt"
/>
);
} else {
// Muster-Linie (Strich/Punkt/Lücke, loopend): den ERSTEN Loop in Ink-Farbe
// (currentColor), danach 2 weitere Loops in Grau (var(--muted)) — so sieht man
// den fortgesetzten Loop. Punkte (0-Längen-Segmente) brauchen runde Kappen.
const PX_PER_MM = 4;
const dpx = style.dash.map((d) => d * PX_PER_MM);
const dasharray = dpx.join(" ");
const loopLen = dpx.reduce((a, b) => a + b, 0) || PX_PER_MM;
const cap = dashHasDot(style.dash) ? "round" : "butt";
// Der graue Teil beginnt exakt an der Loop-Grenze (x0 + loopLen) → das Muster
// läuft phasengleich weiter (dash startet bei jedem Pfadanfang neu).
const darkEnd = Math.min(x1, x0 + loopLen);
const greyEnd = Math.min(x1, x0 + loopLen * 3);
path = (
<>
{greyEnd > darkEnd && (
<line
x1={darkEnd}
y1={midY}
x2={greyEnd}
y2={midY}
stroke="var(--muted)"
strokeWidth={px}
strokeDasharray={dasharray}
strokeLinecap={cap}
/>
)}
<line
x1={x0}
y1={midY}
x2={darkEnd}
y2={midY}
stroke="currentColor"
strokeWidth={px}
strokeDasharray={dasharray}
strokeLinecap={cap}
/>
</>
);
}
return (
+111
View File
@@ -0,0 +1,111 @@
// Modulares Linien-Segment-System: Abbildung Segmentliste ↔ dash-Array,
// Punkt-Erkennung (Round-Cap) und die Schnell-Presets.
import { describe, it, expect } from "vitest";
import {
segmentsToDash,
dashToSegments,
dashHasDot,
presetOfDash,
LINE_PRESETS,
type LineSegment,
} from "./lineSegments";
describe("segmentsToDash", () => {
it("bildet eine alternierende Strich/Lücke-Folge direkt ab", () => {
const segs: LineSegment[] = [
{ type: "dash", length: 2 },
{ type: "gap", length: 1 },
{ type: "dash", length: 3 },
{ type: "gap", length: 2 },
];
expect(segmentsToDash(segs)).toEqual([2, 1, 3, 2]);
});
it("bildet einen Punkt als 0-Länge ab (Strich-Punkt)", () => {
const segs: LineSegment[] = [
{ type: "dash", length: 4 },
{ type: "gap", length: 2 },
{ type: "dot", length: 0 },
{ type: "gap", length: 2 },
];
expect(segmentsToDash(segs)).toEqual([4, 2, 0, 2]);
});
it("fügt eine 0-Lücke zwischen zwei AN-Segmenten ein (Strich·Punkt)", () => {
const segs: LineSegment[] = [
{ type: "dash", length: 3 },
{ type: "dot", length: 0 },
];
// 3 (Strich), 0 (eingefügte Lücke), 0 (Punkt), 0 (Schluss-Lücke, gerade Länge)
expect(segmentsToDash(segs)).toEqual([3, 0, 0, 0]);
});
it("fasst zwei Lücken in Folge zusammen", () => {
const segs: LineSegment[] = [
{ type: "dash", length: 2 },
{ type: "gap", length: 1 },
{ type: "gap", length: 2 },
];
expect(segmentsToDash(segs)).toEqual([2, 3]);
});
it("endet stets auf einer AUS-Länge (gerade Array-Länge)", () => {
expect(segmentsToDash([{ type: "dash", length: 5 }])).toEqual([5, 0]);
});
it("leere Folge ⇒ null (Volllinie)", () => {
expect(segmentsToDash([])).toBeNull();
});
});
describe("dashToSegments", () => {
it("kehrt die Abbildung um (0 ⇒ Punkt)", () => {
expect(dashToSegments([4, 2, 0, 2])).toEqual([
{ type: "dash", length: 4 },
{ type: "gap", length: 2 },
{ type: "dot", length: 0 },
{ type: "gap", length: 2 },
]);
});
it("null ⇒ leere Folge", () => {
expect(dashToSegments(null)).toEqual([]);
});
it("round-trip: dash → segments → dash bleibt stabil", () => {
for (const d of [[2, 1, 3, 2], [4, 2, 0, 2], [0, 2], [3, 2]]) {
expect(segmentsToDash(dashToSegments(d))).toEqual(d);
}
});
});
describe("dashHasDot", () => {
it("erkennt Punkte (0 im dash) für die runde Kappe", () => {
expect(dashHasDot([0, 2])).toBe(true);
expect(dashHasDot([4, 2, 0, 2])).toBe(true);
});
it("keine Punkte ⇒ false (Butt-Kappe bleibt)", () => {
expect(dashHasDot([3, 2])).toBe(false);
expect(dashHasDot(null)).toBe(false);
expect(dashHasDot([])).toBe(false);
});
});
describe("Presets", () => {
it("die vier Grundtypen sind erkennbar", () => {
expect(presetOfDash(LINE_PRESETS.solid)).toBe("solid");
expect(presetOfDash(LINE_PRESETS.dash)).toBe("dash");
expect(presetOfDash(LINE_PRESETS.dot)).toBe("dot");
expect(presetOfDash(LINE_PRESETS.dashDot)).toBe("dashDot");
});
it("Punktlinie/Strich-Punkt enthalten einen Dot, Voll/Strich nicht", () => {
expect(dashHasDot(LINE_PRESETS.dot)).toBe(true);
expect(dashHasDot(LINE_PRESETS.dashDot)).toBe(true);
expect(dashHasDot(LINE_PRESETS.solid)).toBe(false);
expect(dashHasDot(LINE_PRESETS.dash)).toBe(false);
});
it("Abweichungen ⇒ custom", () => {
expect(presetOfDash([1, 2, 3, 4])).toBe("custom");
});
});
+107
View File
@@ -0,0 +1,107 @@
// Modulares Linien-Segment-System — die Brücke zwischen dem Segment-Editor
// (ResourceManager) und der Datenbasis `LineStyle.dash` (alternierend AN/AUS in
// mm, loopend). Eine musterbasierte Linie ist eine geordnete, loopende Folge aus
// drei Segment-Arten:
// • "dash" — ein sichtbarer Strich (Länge in mm > 0),
// • "dot" — ein Punkt (AN-Segment der Länge 0; wird mit runder Strichkappe zum
// Dot, siehe dashHasDot + `stroke-linecap: round` in den Renderern),
// • "gap" — eine Lücke (AUS-Länge in mm).
// Die letzte Lücke der Folge ist die „Schluss-Lücke" (Ende des Loops bis zum
// Start des nächsten). Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
/** Eine Segment-Art des Linien-Musters. */
export type LineSegmentType = "dash" | "dot" | "gap";
/** Ein Segment der Muster-Folge (Länge in mm; bei „dot" ignoriert/0). */
export interface LineSegment {
type: LineSegmentType;
/** Länge in mm (Papier). Bei „dot" stets 0. */
length: number;
}
/**
* Bildet eine geordnete Segment-Folge auf ein `dash`-Array ab
* (`[on, off, on, off, …]` in mm; ein `on`-Wert von 0 ⇒ Punkt). Da SVG-Dash
* strikt AN/AUS alterniert, werden 0-Längen eingefügt bzw. gleichartige
* Nachbarn zusammengefasst, um die Alternation zu wahren:
* • zwei AN-Segmente in Folge (z. B. Strich·Punkt) ⇒ 0-Lücke dazwischen,
* • zwei Lücken in Folge ⇒ zur vorigen Lücke addiert.
* Das Muster endet stets auf einer AUS-Länge (gerade Array-Länge), damit es
* sauber loopt. Leere Folge ⇒ `null` (Volllinie).
*/
export function segmentsToDash(segments: LineSegment[]): number[] | null {
const dash: number[] = [];
for (const seg of segments) {
const on = seg.type !== "gap";
const value = seg.type === "dot" ? 0 : Math.max(0, seg.length);
const nextSlotIsOn = dash.length % 2 === 0;
if (on) {
if (!nextSlotIsOn) dash.push(0); // 0-Lücke, um die AN/AUS-Alternation zu wahren
dash.push(value);
} else {
if (nextSlotIsOn) {
if (dash.length > 0) {
// zwei Lücken in Folge → in die vorherige Lücke einrechnen
dash[dash.length - 1] += value;
continue;
}
dash.push(0); // führende Lücke: 0-Strich davor
}
dash.push(value);
}
}
// Muster muss mit einer AUS-Länge enden (gerade Länge), damit es sauber loopt.
if (dash.length % 2 === 1) dash.push(0);
return dash.length ? dash : null;
}
/**
* Kehrt `segmentsToDash` um: ein `dash`-Array (`[on, off, …]`) wird zur
* Segment-Folge. Gerade Indizes sind AN (0 ⇒ Punkt, sonst Strich), ungerade
* Indizes sind Lücken. `null`/leer ⇒ leere Folge (Volllinie).
*/
export function dashToSegments(dash: number[] | null | undefined): LineSegment[] {
if (!dash) return [];
return dash.map((v, i) =>
i % 2 === 0
? { type: v === 0 ? "dot" : "dash", length: v }
: { type: "gap", length: v },
);
}
/**
* Enthält das Strichmuster einen Punkt (ein AN-Segment der Länge 0)? Solche
* Linien müssen mit runder Strichkappe (`stroke-linecap: round`) gezeichnet
* werden, damit die 0-Längen-Segmente als Dots sichtbar werden.
*/
export function dashHasDot(dash: number[] | null | undefined): boolean {
return !!dash && dash.some((v) => v === 0);
}
/** Schnell-Presets für die drei Grundtypen (+ Strich-Punkt), Längen in mm. */
export const LINE_PRESETS = {
/** Volllinie — durchgezogen. */
solid: null as number[] | null,
/** Strichlinie. */
dash: [3, 2] as number[] | null,
/** Punktlinie (Dots). */
dot: [0, 2] as number[] | null,
/** Strich-Punkt-Linie. */
dashDot: [4, 2, 0, 2] as number[] | null,
} as const;
export type LinePresetKey = keyof typeof LINE_PRESETS | "custom";
/** Erkennt, welchem Preset ein `dash`-Array entspricht (sonst „custom"). */
export function presetOfDash(dash: number[] | null | undefined): LinePresetKey {
const eq = (a: number[] | null, b: number[] | null): boolean => {
if (a === null || b === null) return a === b;
return a.length === b.length && a.every((v, i) => v === b[i]);
};
const d = dash ?? null;
if (eq(d, LINE_PRESETS.solid)) return "solid";
if (eq(d, LINE_PRESETS.dash)) return "dash";
if (eq(d, LINE_PRESETS.dot)) return "dot";
if (eq(d, LINE_PRESETS.dashDot)) return "dashDot";
return "custom";
}