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:
@@ -24,6 +24,7 @@
|
|||||||
import type { HatchRender, Plan, Primitive } from "../plan/generatePlan";
|
import type { HatchRender, Plan, Primitive } from "../plan/generatePlan";
|
||||||
import type { Vec2 } from "../model/types";
|
import type { Vec2 } from "../model/types";
|
||||||
import { buildRandomHatchRuns, zigzagPoints, motifPoints } from "../plan/glPlan/glPlanHatch";
|
import { buildRandomHatchRuns, zigzagPoints, motifPoints } from "../plan/glPlan/glPlanHatch";
|
||||||
|
import { dashHasDot } from "../ui/lineSegments";
|
||||||
|
|
||||||
/** SVG-Namespace für die programmatisch erzeugten Knoten. */
|
/** SVG-Namespace für die programmatisch erzeugten Knoten. */
|
||||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||||
@@ -403,6 +404,8 @@ function arcNode(
|
|||||||
el.setAttribute("stroke-width", String(quantizePen(p.weightMm)));
|
el.setAttribute("stroke-width", String(quantizePen(p.weightMm)));
|
||||||
if (p.dash && p.dash.length) {
|
if (p.dash && p.dash.length) {
|
||||||
el.setAttribute("stroke-dasharray", p.dash.map((x) => round(x)).join(" "));
|
el.setAttribute("stroke-dasharray", p.dash.map((x) => round(x)).join(" "));
|
||||||
|
// Punktmuster (dash enthält eine 0): runde Kappe → Dots am Bogen.
|
||||||
|
if (dashHasDot(p.dash)) el.setAttribute("stroke-linecap", "round");
|
||||||
}
|
}
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-2
@@ -572,10 +572,21 @@ export const de = {
|
|||||||
"resources.random.reroll": "Neu würfeln",
|
"resources.random.reroll": "Neu würfeln",
|
||||||
"resources.stroke.solid": "Vollinie",
|
"resources.stroke.solid": "Vollinie",
|
||||||
"resources.stroke.dash": "Strich",
|
"resources.stroke.dash": "Strich",
|
||||||
"resources.dash.segments": "Segmente (mm)",
|
"resources.dash.segments": "Segmente",
|
||||||
"resources.dash.hint": "Alternierend Strich/Lücke in mm (z. B. 1, 2 = 1 mm Strich, 2 mm Lücke).",
|
"resources.dash.hint": "Geordnete, loopende Folge aus Strich/Punkt/Lücke (mm). Die letzte Lücke ist der Abstand zum nächsten Loop.",
|
||||||
"resources.dash.add": "Segment",
|
"resources.dash.add": "Segment",
|
||||||
"resources.dash.remove": "Segment entfernen",
|
"resources.dash.remove": "Segment entfernen",
|
||||||
|
"resources.linePreset.label": "Muster",
|
||||||
|
"resources.linePreset.solid": "Vollinie",
|
||||||
|
"resources.linePreset.dash": "Strichlinie",
|
||||||
|
"resources.linePreset.dot": "Punktlinie",
|
||||||
|
"resources.linePreset.dashDot": "Strich-Punkt",
|
||||||
|
"resources.seg.dash": "Strich",
|
||||||
|
"resources.seg.dot": "Punkt",
|
||||||
|
"resources.seg.gap": "Lücke",
|
||||||
|
"resources.seg.addDash": "+ Strich",
|
||||||
|
"resources.seg.addDot": "+ Punkt",
|
||||||
|
"resources.seg.addGap": "+ Lücke",
|
||||||
"resources.hatchKind.vector": "Vektor",
|
"resources.hatchKind.vector": "Vektor",
|
||||||
"resources.hatchKind.image": "Bild",
|
"resources.hatchKind.image": "Bild",
|
||||||
"resources.lines.parallel": "Parallel",
|
"resources.lines.parallel": "Parallel",
|
||||||
|
|||||||
+13
-2
@@ -567,10 +567,21 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"resources.random.reroll": "Re-roll",
|
"resources.random.reroll": "Re-roll",
|
||||||
"resources.stroke.solid": "Solid",
|
"resources.stroke.solid": "Solid",
|
||||||
"resources.stroke.dash": "Dash",
|
"resources.stroke.dash": "Dash",
|
||||||
"resources.dash.segments": "Segments (mm)",
|
"resources.dash.segments": "Segments",
|
||||||
"resources.dash.hint": "Alternating dash/gap in mm (e.g. 1, 2 = 1 mm dash, 2 mm gap).",
|
"resources.dash.hint": "Ordered, looping sequence of dash/dot/gap (mm). The last gap is the spacing to the next loop.",
|
||||||
"resources.dash.add": "Segment",
|
"resources.dash.add": "Segment",
|
||||||
"resources.dash.remove": "Remove segment",
|
"resources.dash.remove": "Remove segment",
|
||||||
|
"resources.linePreset.label": "Pattern",
|
||||||
|
"resources.linePreset.solid": "Solid",
|
||||||
|
"resources.linePreset.dash": "Dashed",
|
||||||
|
"resources.linePreset.dot": "Dotted",
|
||||||
|
"resources.linePreset.dashDot": "Dash-dot",
|
||||||
|
"resources.seg.dash": "Dash",
|
||||||
|
"resources.seg.dot": "Dot",
|
||||||
|
"resources.seg.gap": "Gap",
|
||||||
|
"resources.seg.addDash": "+ Dash",
|
||||||
|
"resources.seg.addDot": "+ Dot",
|
||||||
|
"resources.seg.addGap": "+ Gap",
|
||||||
"resources.hatchKind.vector": "Vector",
|
"resources.hatchKind.vector": "Vector",
|
||||||
"resources.hatchKind.image": "Image",
|
"resources.hatchKind.image": "Image",
|
||||||
"resources.lines.parallel": "Parallel",
|
"resources.lines.parallel": "Parallel",
|
||||||
|
|||||||
+9
-1
@@ -42,7 +42,15 @@ export interface LineStyle {
|
|||||||
weight: number;
|
weight: number;
|
||||||
/** Farbe (hex). */
|
/** Farbe (hex). */
|
||||||
color: string;
|
color: string;
|
||||||
/** Strichmuster in Millimetern; `null` = durchgezogen. */
|
/**
|
||||||
|
* Strichmuster in Millimetern, alternierend AN/AUS (`[on, off, on, off, …]`),
|
||||||
|
* loopend; `null` = durchgezogen (Volllinie). Konvention des modularen
|
||||||
|
* Segment-Systems (siehe `src/ui/lineSegments.ts`): ein AN-Wert von 0 bedeutet
|
||||||
|
* einen PUNKT (Dot) — er wird nur mit runder Strichkappe sichtbar (die
|
||||||
|
* betroffene Linie erhält dann `stroke-linecap: round`). So bilden sich
|
||||||
|
* Volllinie/Strichlinie/Punktlinie/Strich-Punkt und frei modulare Folgen aus
|
||||||
|
* Strich/Punkt/Lücke ab. Additiv — kein zusätzliches Feld nötig.
|
||||||
|
*/
|
||||||
dash: number[] | null;
|
dash: number[] | null;
|
||||||
/**
|
/**
|
||||||
* Linien-Typ (additiv, Default `undefined` ⇒ "dash"):
|
* Linien-Typ (additiv, Default `undefined` ⇒ "dash"):
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { useGlPlanRenderer } from "./useGlPlanRenderer";
|
|||||||
import { useWasmPlanRenderer } from "./useWasmPlanRenderer";
|
import { useWasmPlanRenderer } from "./useWasmPlanRenderer";
|
||||||
import { quantizePen } from "../export/sceneToPrintSvg";
|
import { quantizePen } from "../export/sceneToPrintSvg";
|
||||||
import { buildRandomHatchRuns, zigzagPoints, motifPoints, DASH_MM_TO_M } from "./glPlan/glPlanHatch";
|
import { buildRandomHatchRuns, zigzagPoints, motifPoints, DASH_MM_TO_M } from "./glPlan/glPlanHatch";
|
||||||
|
import { dashHasDot } from "../ui/lineSegments";
|
||||||
|
|
||||||
const PX_PER_M = 90; // viewBox-Einheiten je Meter (Modell → SVG-Benutzerraum)
|
const PX_PER_M = 90; // viewBox-Einheiten je Meter (Modell → SVG-Benutzerraum)
|
||||||
const PAD = 60; // Rand in viewBox-Einheiten
|
const PAD = 60; // Rand in viewBox-Einheiten
|
||||||
@@ -2423,6 +2424,8 @@ function HatchPattern({
|
|||||||
.map((mm) => (print ? printStrokeVb(mm, paperScale!) : mmToPx(mm)))
|
.map((mm) => (print ? printStrokeVb(mm, paperScale!) : mmToPx(mm)))
|
||||||
.join(" ")
|
.join(" ")
|
||||||
: undefined;
|
: undefined;
|
||||||
|
// Punktmuster in den Musterlinien: runde Kappe → Dots (Punkt/Strich-Punkt).
|
||||||
|
const dashCap = dashHasDot(hatch.dash) ? ("round" as const) : undefined;
|
||||||
|
|
||||||
if (hatch.kind === "image" && hatch.image) {
|
if (hatch.kind === "image" && hatch.image) {
|
||||||
// Bild-Schraffur: gekacheltes <image>-Muster. Kachel-Basismaß in viewBox-
|
// Bild-Schraffur: gekacheltes <image>-Muster. Kachel-Basismaß in viewBox-
|
||||||
@@ -2468,6 +2471,7 @@ function HatchPattern({
|
|||||||
stroke={hatch.color}
|
stroke={hatch.color}
|
||||||
strokeWidth={sw}
|
strokeWidth={sw}
|
||||||
strokeDasharray={dashes}
|
strokeDasharray={dashes}
|
||||||
|
strokeLinecap={dashCap}
|
||||||
vectorEffect={vfx}
|
vectorEffect={vfx}
|
||||||
/>
|
/>
|
||||||
</pattern>
|
</pattern>
|
||||||
@@ -2493,6 +2497,7 @@ function HatchPattern({
|
|||||||
stroke={hatch.color}
|
stroke={hatch.color}
|
||||||
strokeWidth={sw}
|
strokeWidth={sw}
|
||||||
strokeDasharray={dashes}
|
strokeDasharray={dashes}
|
||||||
|
strokeLinecap={dashCap}
|
||||||
vectorEffect={vfx}
|
vectorEffect={vfx}
|
||||||
/>
|
/>
|
||||||
{hatch.pattern === "crosshatch" && (
|
{hatch.pattern === "crosshatch" && (
|
||||||
@@ -2504,6 +2509,7 @@ function HatchPattern({
|
|||||||
stroke={hatch.color}
|
stroke={hatch.color}
|
||||||
strokeWidth={sw}
|
strokeWidth={sw}
|
||||||
strokeDasharray={dashes}
|
strokeDasharray={dashes}
|
||||||
|
strokeLinecap={dashCap}
|
||||||
vectorEffect={vfx}
|
vectorEffect={vfx}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -2668,6 +2674,9 @@ function DrawingRunShape({
|
|||||||
? motifPoints(run.pts[0], run.pts[run.pts.length - 1], run.motif, DASH_MM_TO_M)
|
? motifPoints(run.pts[0], run.pts[run.pts.length - 1], run.motif, DASH_MM_TO_M)
|
||||||
: run.pts;
|
: run.pts;
|
||||||
const pts = modelPts.map(toScreen).map((s) => `${s.x},${s.y}`).join(" ");
|
const pts = modelPts.map(toScreen).map((s) => `${s.x},${s.y}`).join(" ");
|
||||||
|
// Punktmuster (dash enthält eine 0): runde Kappe, damit die 0-Längen-Segmente
|
||||||
|
// als Dots erscheinen (Punktlinie / Strich-Punkt).
|
||||||
|
const cap = dashHasDot(run.dash) ? ("round" as const) : undefined;
|
||||||
const shape = run.closed ? (
|
const shape = run.closed ? (
|
||||||
<polygon
|
<polygon
|
||||||
points={pts}
|
points={pts}
|
||||||
@@ -2676,6 +2685,7 @@ function DrawingRunShape({
|
|||||||
stroke={run.color}
|
stroke={run.color}
|
||||||
strokeWidth={sw}
|
strokeWidth={sw}
|
||||||
strokeDasharray={dashStr}
|
strokeDasharray={dashStr}
|
||||||
|
strokeLinecap={cap}
|
||||||
vectorEffect={vfx}
|
vectorEffect={vfx}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -2686,6 +2696,7 @@ function DrawingRunShape({
|
|||||||
stroke={run.color}
|
stroke={run.color}
|
||||||
strokeWidth={sw}
|
strokeWidth={sw}
|
||||||
strokeDasharray={dashStr}
|
strokeDasharray={dashStr}
|
||||||
|
strokeLinecap={cap}
|
||||||
vectorEffect={vfx}
|
vectorEffect={vfx}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -2912,6 +2923,7 @@ function renderPrimitive(
|
|||||||
stroke={p.color}
|
stroke={p.color}
|
||||||
strokeWidth={weight(p.weightMm)}
|
strokeWidth={weight(p.weightMm)}
|
||||||
strokeDasharray={dashOf(p.dash)}
|
strokeDasharray={dashOf(p.dash)}
|
||||||
|
strokeLinecap={dashHasDot(p.dash) ? "round" : undefined}
|
||||||
vectorEffect={vfx}
|
vectorEffect={vfx}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -2934,6 +2946,7 @@ function renderPrimitive(
|
|||||||
className={p.cls}
|
className={p.cls}
|
||||||
strokeWidth={weight(p.weightMm)}
|
strokeWidth={weight(p.weightMm)}
|
||||||
strokeDasharray={dashOf(p.dash)}
|
strokeDasharray={dashOf(p.dash)}
|
||||||
|
strokeLinecap={dashHasDot(p.dash) ? "round" : undefined}
|
||||||
vectorEffect={vfx}
|
vectorEffect={vfx}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2569,6 +2569,35 @@ body {
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Modularer Linien-Segment-Editor (Strich / Punkt / Lücke). */
|
||||||
|
.line-seg-editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.line-seg-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.line-seg-num {
|
||||||
|
width: 56px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.line-seg-dotlabel {
|
||||||
|
color: var(--muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
padding-left: 4px;
|
||||||
|
}
|
||||||
|
.line-seg-add {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ══ Panel-Chrome (Andockrahmen: Docks, Tabs, Panel-Rahmen) ═══════════════════
|
/* ══ Panel-Chrome (Andockrahmen: Docks, Tabs, Panel-Rahmen) ═══════════════════
|
||||||
Der erweiterbare Rahmen: links/rechts je ein Dock mit Tab-Leiste und dem
|
Der erweiterbare Rahmen: links/rechts je ein Dock mit Tab-Leiste und dem
|
||||||
aktiven Panel. Greift dieselben Tokens wie die nav- und res-Klassen (dunkles DOSSIER-
|
aktiven Panel. Greift dieselben Tokens wie die nav- und res-Klassen (dunkles DOSSIER-
|
||||||
|
|||||||
+93
-52
@@ -43,6 +43,15 @@ import {
|
|||||||
import { requestMaterialPreview, cancelMaterialPreview } from "../materials/spherePreview";
|
import { requestMaterialPreview, cancelMaterialPreview } from "../materials/spherePreview";
|
||||||
import { EyeIcon } from "./EyeIcon";
|
import { EyeIcon } from "./EyeIcon";
|
||||||
import { HatchSwatch, LineSwatch } from "./hatchPreview";
|
import { HatchSwatch, LineSwatch } from "./hatchPreview";
|
||||||
|
import {
|
||||||
|
segmentsToDash,
|
||||||
|
dashToSegments,
|
||||||
|
presetOfDash,
|
||||||
|
LINE_PRESETS,
|
||||||
|
type LineSegment,
|
||||||
|
type LineSegmentType,
|
||||||
|
type LinePresetKey,
|
||||||
|
} from "./lineSegments";
|
||||||
import { MotifEditor } from "./MotifEditor";
|
import { MotifEditor } from "./MotifEditor";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
@@ -1235,26 +1244,35 @@ function LineStyleDetail({
|
|||||||
onPatch({ zigzag: { ...cur, ...part } });
|
onPatch({ zigzag: { ...cur, ...part } });
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Strich (dash) ──────────────────────────────────────────────────────────
|
// ── Modulares Segment-System (dash) ─────────────────────────────────────────
|
||||||
// „Vollinie" = `dash: null` (durchgezogen); „Strich" = editierbare Liste der
|
// Eine musterbasierte Linie ist eine geordnete, loopende Folge aus Segmenten
|
||||||
// Segmentlängen in mm (alternierend Strich/Lücke). Die Dicke gehört NICHT mehr
|
// (Strich / Punkt / Lücke). Die Folge wird aus `dash` abgeleitet und beim
|
||||||
// hierher — sie wird per Element-Attribut aufgelöst (By-Object/By-Layer folgt).
|
// 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 dash = style.dash;
|
||||||
const isSolid = dash === null;
|
const isSolid = dash === null;
|
||||||
const dashArr = dash ?? [];
|
const segments = dashToSegments(dash);
|
||||||
const setStroke = (mode: "solid" | "dash") =>
|
const preset = presetOfDash(dash);
|
||||||
onPatch({ dash: mode === "solid" ? null : dashArr.length ? dashArr : [1, 2] });
|
const applyPreset = (p: LinePresetKey) => {
|
||||||
const setSeg = (i: number, v: number) => {
|
if (p === "custom") return;
|
||||||
const next = dashArr.slice();
|
onPatch({ dash: LINE_PRESETS[p] });
|
||||||
next[i] = Math.max(0, v);
|
|
||||||
onPatch({ dash: next });
|
|
||||||
};
|
};
|
||||||
const addSeg = () =>
|
const commitSegments = (next: LineSegment[]) => onPatch({ dash: segmentsToDash(next) });
|
||||||
onPatch({ dash: [...dashArr, dashArr.length ? dashArr[dashArr.length - 1] : 1] });
|
const setSegType = (i: number, type: LineSegmentType) => {
|
||||||
const removeSeg = (i: number) => {
|
const next = segments.slice();
|
||||||
const next = dashArr.filter((_, k) => k !== i);
|
const cur = next[i];
|
||||||
onPatch({ dash: next.length ? next : null });
|
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 (
|
return (
|
||||||
<div className="res-md-detail-inner">
|
<div className="res-md-detail-inner">
|
||||||
@@ -1319,13 +1337,16 @@ function LineStyleDetail({
|
|||||||
</FieldRow>
|
</FieldRow>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<FieldRow label={t("resources.col.stroke")}>
|
{/* Schnell-Presets: Volllinie / Strichlinie / Punktlinie / Strich-Punkt. */}
|
||||||
<Segmented
|
<FieldRow label={t("resources.linePreset.label")}>
|
||||||
value={isSolid ? "solid" : "dash"}
|
<Segmented<string>
|
||||||
onChange={setStroke}
|
value={preset}
|
||||||
|
onChange={(p) => applyPreset(p as LinePresetKey)}
|
||||||
options={[
|
options={[
|
||||||
{ value: "solid", label: t("resources.stroke.solid") },
|
{ value: "solid", label: t("resources.linePreset.solid") },
|
||||||
{ value: "dash", label: t("resources.stroke.dash") },
|
{ value: "dash", label: t("resources.linePreset.dash") },
|
||||||
|
{ value: "dot", label: t("resources.linePreset.dot") },
|
||||||
|
{ value: "dashDot", label: t("resources.linePreset.dashDot") },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
@@ -1334,26 +1355,29 @@ function LineStyleDetail({
|
|||||||
label={t("resources.dash.segments")}
|
label={t("resources.dash.segments")}
|
||||||
hint={t("resources.dash.hint")}
|
hint={t("resources.dash.hint")}
|
||||||
>
|
>
|
||||||
<div
|
<div className="line-seg-editor">
|
||||||
style={{
|
{segments.map((seg, i) => (
|
||||||
display: "flex",
|
<div className="line-seg-row" key={i}>
|
||||||
flexWrap: "wrap",
|
<SelectField
|
||||||
gap: 4,
|
value={seg.type}
|
||||||
alignItems: "center",
|
onChange={(type) => setSegType(i, type)}
|
||||||
}}
|
options={[
|
||||||
>
|
{ value: "dash", label: t("resources.seg.dash") },
|
||||||
{dashArr.map((v, i) => (
|
{ value: "dot", label: t("resources.seg.dot") },
|
||||||
<span
|
{ value: "gap", label: t("resources.seg.gap") },
|
||||||
key={i}
|
]}
|
||||||
style={{ display: "inline-flex", alignItems: "center", gap: 2 }}
|
/>
|
||||||
>
|
<span className="line-seg-num">
|
||||||
<span style={{ width: 46 }}>
|
{seg.type === "dot" ? (
|
||||||
<NumberField
|
<span className="line-seg-dotlabel">0</span>
|
||||||
value={v}
|
) : (
|
||||||
onChange={(x) => setSeg(i, x)}
|
<NumberField
|
||||||
step={0.5}
|
value={seg.length}
|
||||||
min={0}
|
onChange={(x) => setSegLen(i, x)}
|
||||||
/>
|
step={0.5}
|
||||||
|
min={0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1364,17 +1388,34 @@ function LineStyleDetail({
|
|||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</div>
|
||||||
))}
|
))}
|
||||||
<button
|
<div className="line-seg-add">
|
||||||
type="button"
|
<button
|
||||||
className="res-seg-btn"
|
type="button"
|
||||||
title={t("resources.dash.add")}
|
className="res-seg-btn"
|
||||||
onClick={addSeg}
|
onClick={() => addSegOf("dash")}
|
||||||
style={{ padding: "2px 8px", lineHeight: 1 }}
|
style={{ padding: "2px 8px", lineHeight: 1 }}
|
||||||
>
|
>
|
||||||
+ {t("resources.dash.add")}
|
{t("resources.seg.addDash")}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+42
-3
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
import { useId } from "react";
|
import { useId } from "react";
|
||||||
import type { HatchStyle, LineStyle } from "../model/types";
|
import type { HatchStyle, LineStyle } from "../model/types";
|
||||||
|
import { dashHasDot } from "./lineSegments";
|
||||||
|
|
||||||
// ── Kleiner deterministischer PRNG (mulberry32) ────────────────────────────
|
// ── Kleiner deterministischer PRNG (mulberry32) ────────────────────────────
|
||||||
// Für „random"-Striche: stabil pro Seed, damit die Vorschau bei gleichem
|
// Für „random"-Striche: stabil pro Seed, damit die Vorschau bei gleichem
|
||||||
@@ -330,8 +331,8 @@ export function LineSwatch({
|
|||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else {
|
} else if (!style.dash || !style.dash.length) {
|
||||||
const dasharray = style.dash ? style.dash.map((d) => d * 4).join(" ") : undefined;
|
// Volllinie: durchgezogen, eine dunkle Linie.
|
||||||
path = (
|
path = (
|
||||||
<line
|
<line
|
||||||
x1={x0}
|
x1={x0}
|
||||||
@@ -340,10 +341,48 @@ export function LineSwatch({
|
|||||||
y2={midY}
|
y2={midY}
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth={px}
|
strokeWidth={px}
|
||||||
strokeDasharray={dasharray}
|
|
||||||
strokeLinecap="butt"
|
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 (
|
return (
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user