ResourceManager: Master-Detail-Editoren fuer Schraffur- und Linien-Typen
Schraffuren- und Linien-Tab im Master-Detail-Layout (DOSSIER-Vorbild): Liste links mit Live-SVG-Thumbnail + Name, Detail rechts mit grosser Live-Vorschau, Inline-Rename, Trash-Delete, Typ-Umschalter. Schraffur: Vektor (parallel/ zufaellig) oder Bild (Upload -> Data-URL, scaleX/scaleY/rotation); kein Farbfeld mehr (Farbe kommt von Bauteil/Attribut). Linie: Strich oder Zickzack (Amplitude/Wellenlaenge). Bauteil-Tabelle: Vordergrund + Hintergrund statt einer Farbe. Neuer eigenstaendiger Vorschau-Helfer ui/hatchPreview.tsx (kein Renderer-Import).
This commit is contained in:
@@ -500,6 +500,10 @@ export const de = {
|
|||||||
"resources.col.prio.hint": "Höhere Priorität läuft am Stoß durch",
|
"resources.col.prio.hint": "Höhere Priorität läuft am Stoß durch",
|
||||||
"resources.col.texture": "Textur",
|
"resources.col.texture": "Textur",
|
||||||
"resources.col.material": "Material",
|
"resources.col.material": "Material",
|
||||||
|
"resources.col.foreground": "Vordergrund",
|
||||||
|
"resources.col.foreground.hint": "Farbe der Muster-/Schraffurlinien (Vordergrund)",
|
||||||
|
"resources.col.background": "Hintergrund",
|
||||||
|
"resources.col.background.hint": "Füllfarbe (Poché/Hintergrund)",
|
||||||
"resources.delete.component": "Bauteil „{name}\" löschen",
|
"resources.delete.component": "Bauteil „{name}\" löschen",
|
||||||
|
|
||||||
// 3D-Material (PBR) — Bibliotheks-Auswahl + Upload.
|
// 3D-Material (PBR) — Bibliotheks-Auswahl + Upload.
|
||||||
@@ -553,6 +557,22 @@ export const de = {
|
|||||||
"resources.lineStyle.none": "— ohne —",
|
"resources.lineStyle.none": "— ohne —",
|
||||||
"resources.delete.hatch": "Schraffur „{name}\" löschen",
|
"resources.delete.hatch": "Schraffur „{name}\" löschen",
|
||||||
|
|
||||||
|
// Schraffur-/Linien-Typ (Master-Detail-Editor).
|
||||||
|
"resources.field.type": "Typ",
|
||||||
|
"resources.field.lines": "Striche",
|
||||||
|
"resources.field.image": "Bild",
|
||||||
|
"resources.field.scaleX": "Skalierung X",
|
||||||
|
"resources.field.scaleY": "Skalierung Y",
|
||||||
|
"resources.field.rotation": "Rotation °",
|
||||||
|
"resources.field.amplitude": "Amplitude",
|
||||||
|
"resources.field.wavelength": "Wellenlänge",
|
||||||
|
"resources.hatchKind.vector": "Vektor",
|
||||||
|
"resources.hatchKind.image": "Bild",
|
||||||
|
"resources.lines.parallel": "Parallel",
|
||||||
|
"resources.lines.random": "Zufällig",
|
||||||
|
"resources.lineKind.dash": "Strich",
|
||||||
|
"resources.lineKind.zigzag": "Zickzack",
|
||||||
|
|
||||||
// Schraffur-Muster.
|
// Schraffur-Muster.
|
||||||
"pattern.none": "Ohne",
|
"pattern.none": "Ohne",
|
||||||
"pattern.solid": "Vollfläche",
|
"pattern.solid": "Vollfläche",
|
||||||
|
|||||||
@@ -496,6 +496,10 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"resources.col.prio.hint": "Higher priority runs through at the join",
|
"resources.col.prio.hint": "Higher priority runs through at the join",
|
||||||
"resources.col.texture": "Texture",
|
"resources.col.texture": "Texture",
|
||||||
"resources.col.material": "Material",
|
"resources.col.material": "Material",
|
||||||
|
"resources.col.foreground": "Foreground",
|
||||||
|
"resources.col.foreground.hint": "Color of the pattern/hatch lines (foreground)",
|
||||||
|
"resources.col.background": "Background",
|
||||||
|
"resources.col.background.hint": "Fill color (poché/background)",
|
||||||
"resources.delete.component": "Delete component “{name}”",
|
"resources.delete.component": "Delete component “{name}”",
|
||||||
|
|
||||||
// 3D material (PBR) — built-in library + upload.
|
// 3D material (PBR) — built-in library + upload.
|
||||||
@@ -548,6 +552,22 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"resources.lineStyle.none": "— none —",
|
"resources.lineStyle.none": "— none —",
|
||||||
"resources.delete.hatch": "Delete hatch “{name}”",
|
"resources.delete.hatch": "Delete hatch “{name}”",
|
||||||
|
|
||||||
|
// Hatch/line type (master-detail editor).
|
||||||
|
"resources.field.type": "Type",
|
||||||
|
"resources.field.lines": "Lines",
|
||||||
|
"resources.field.image": "Image",
|
||||||
|
"resources.field.scaleX": "Scale X",
|
||||||
|
"resources.field.scaleY": "Scale Y",
|
||||||
|
"resources.field.rotation": "Rotation °",
|
||||||
|
"resources.field.amplitude": "Amplitude",
|
||||||
|
"resources.field.wavelength": "Wavelength",
|
||||||
|
"resources.hatchKind.vector": "Vector",
|
||||||
|
"resources.hatchKind.image": "Image",
|
||||||
|
"resources.lines.parallel": "Parallel",
|
||||||
|
"resources.lines.random": "Random",
|
||||||
|
"resources.lineKind.dash": "Dash",
|
||||||
|
"resources.lineKind.zigzag": "Zigzag",
|
||||||
|
|
||||||
"pattern.none": "None",
|
"pattern.none": "None",
|
||||||
"pattern.solid": "Solid",
|
"pattern.solid": "Solid",
|
||||||
"pattern.insulation": "Insulation",
|
"pattern.insulation": "Insulation",
|
||||||
|
|||||||
+172
@@ -2315,6 +2315,178 @@ body {
|
|||||||
background: var(--panel-2);
|
background: var(--panel-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Master-Detail (Schraffuren / Linien): Liste links, Detail rechts ──────── */
|
||||||
|
.res-md {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 240px 1fr;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: start;
|
||||||
|
min-height: 320px;
|
||||||
|
}
|
||||||
|
.res-md-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--panel-2);
|
||||||
|
max-height: 62vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.res-md-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s, border-color 0.12s;
|
||||||
|
}
|
||||||
|
.res-md-row:hover {
|
||||||
|
background: var(--accent-dim);
|
||||||
|
}
|
||||||
|
.res-md-row.active {
|
||||||
|
background: var(--accent-dim);
|
||||||
|
border-color: var(--accent-border);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.res-md-row-thumb {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
.res-md-row-name {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-weight: 550;
|
||||||
|
}
|
||||||
|
/* Linien-Zeile: Name oben schmal, Swatch daneben rechts. */
|
||||||
|
.res-md-row-line .res-line-preview {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 150px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.res-md-listfoot {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 4px 2px;
|
||||||
|
}
|
||||||
|
.res-md-empty {
|
||||||
|
padding: 22px 14px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Detail-Panel (rechts). */
|
||||||
|
.res-md-detail {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--panel-2);
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
.res-md-detail-inner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 14px 16px 18px;
|
||||||
|
}
|
||||||
|
.res-md-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.res-md-rename {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.res-md-preview {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--input);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.res-md-preview .res-line-preview {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Detail-Felder: Label links, Steuerelement rechts. */
|
||||||
|
.res-fields {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.res-field {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 120px 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.res-field-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.res-field-control {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.res-field-control > * {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Segmentierter Umschalter (Pillen-Gruppe). */
|
||||||
|
.res-seg {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 2px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--input);
|
||||||
|
}
|
||||||
|
.res-seg-btn {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
padding: 4px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s, color 0.12s;
|
||||||
|
}
|
||||||
|
.res-seg-btn:hover {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.res-seg-btn.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Schraffur-Swatch (kleine Vorschau in Liste + große im Detail). */
|
||||||
|
.res-swatch {
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ══ 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-
|
||||||
|
|||||||
+326
-124
@@ -42,6 +42,7 @@ import {
|
|||||||
} from "../materials/ambientcg";
|
} from "../materials/ambientcg";
|
||||||
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 { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
// ── Auswahl-Optionen (UI-Beschriftung übersetzt, Werte englisch) ───────────
|
// ── Auswahl-Optionen (UI-Beschriftung übersetzt, Werte englisch) ───────────
|
||||||
@@ -210,6 +211,62 @@ function DeleteButton({ onClick, title }: { onClick: () => void; title: string }
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Master-Detail-Bausteine (DOSSIER-Stil: Liste links, Detail rechts) ──────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Segmentierter Umschalter (Pillen-Gruppe) für kleine, sich ausschließende
|
||||||
|
* Auswahlen — z. B. Schraffur-Typ Vektor/Bild oder Linien-Typ Strich/Zickzack.
|
||||||
|
*/
|
||||||
|
function Segmented<T extends string>({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
value: T;
|
||||||
|
onChange: (v: T) => void;
|
||||||
|
options: { value: T; label: string }[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="res-seg" role="group">
|
||||||
|
{options.map((o) => (
|
||||||
|
<button
|
||||||
|
key={o.value}
|
||||||
|
type="button"
|
||||||
|
className={`res-seg-btn${value === o.value ? " active" : ""}`}
|
||||||
|
aria-pressed={value === o.value}
|
||||||
|
onClick={() => onChange(o.value)}
|
||||||
|
>
|
||||||
|
{o.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine beschriftete Detail-Zeile (Label links, Steuerelement rechts). Bewusst
|
||||||
|
* ein <div> (kein <label>): Zeilen enthalten teils mehrere interaktive Elemente
|
||||||
|
* (Segmented-Buttons), und ein umschließendes <label> würde den Klick an das
|
||||||
|
* ERSTE labelbare Kind umleiten — das würde einen Segment-Klick sofort wieder
|
||||||
|
* zurücksetzen. Die Steuerelemente sind selbst klickbar; ein Label ist unnötig.
|
||||||
|
*/
|
||||||
|
function FieldRow({
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
hint?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="res-field" title={hint}>
|
||||||
|
<span className="res-field-label">{label}</span>
|
||||||
|
<span className="res-field-control">{children}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Tabellen-Gerüst ────────────────────────────────────────────────────────
|
// ── Tabellen-Gerüst ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Eine Spaltendefinition: Titel-Key + optionale rechtsbündige Ausrichtung. */
|
/** Eine Spaltendefinition: Titel-Key + optionale rechtsbündige Ausrichtung. */
|
||||||
@@ -283,10 +340,11 @@ function ResCell({
|
|||||||
|
|
||||||
// ── Bauteile (Components) ──────────────────────────────────────────────────
|
// ── Bauteile (Components) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
// Spalten: Name | Farbe | Schnittschraffur | Ansichtsschraffur | Prio | Textur | Material | (löschen).
|
// Spalten: Name | Vordergrund | Hintergrund | Schnittschraffur | Ansichtsschraffur | Prio | Textur | Material | (löschen).
|
||||||
const COMPONENT_COLUMNS: ResColumn[] = [
|
const COMPONENT_COLUMNS: ResColumn[] = [
|
||||||
{ titleKey: "resources.col.name" },
|
{ titleKey: "resources.col.name" },
|
||||||
{ titleKey: "resources.col.color" },
|
{ titleKey: "resources.col.foreground", hintKey: "resources.col.foreground.hint" },
|
||||||
|
{ titleKey: "resources.col.background", hintKey: "resources.col.background.hint" },
|
||||||
{ titleKey: "resources.col.hatch" },
|
{ titleKey: "resources.col.hatch" },
|
||||||
{ titleKey: "resources.col.viewHatch" },
|
{ titleKey: "resources.col.viewHatch" },
|
||||||
{
|
{
|
||||||
@@ -299,7 +357,7 @@ const COMPONENT_COLUMNS: ResColumn[] = [
|
|||||||
{ titleKey: "" },
|
{ titleKey: "" },
|
||||||
];
|
];
|
||||||
const COMPONENT_TEMPLATE =
|
const COMPONENT_TEMPLATE =
|
||||||
"minmax(120px, 1fr) auto minmax(120px, 0.8fr) minmax(120px, 0.8fr) 64px minmax(90px, 0.7fr) minmax(130px, 0.9fr) 36px";
|
"minmax(120px, 1fr) auto auto minmax(120px, 0.8fr) minmax(120px, 0.8fr) 64px minmax(90px, 0.7fr) minmax(130px, 0.9fr) 36px";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Material-Zelle der Bauteil-Tabelle: ein Button, der den aktuellen Material-
|
* Material-Zelle der Bauteil-Tabelle: ein Button, der den aktuellen Material-
|
||||||
@@ -782,9 +840,18 @@ function ComponentRow({
|
|||||||
/>
|
/>
|
||||||
</ResCell>
|
</ResCell>
|
||||||
<ResCell>
|
<ResCell>
|
||||||
|
{/* Vordergrund = Farbe der Muster-/Schraffurlinien. Fehlt sie, greift
|
||||||
|
als Anzeige-Fallback `color`; das Setzen definiert `foreground`. */}
|
||||||
<ColorField
|
<ColorField
|
||||||
color={component.color}
|
color={component.foreground ?? component.color}
|
||||||
onChange={(color) => onPatch({ color })}
|
onChange={(foreground) => onPatch({ foreground })}
|
||||||
|
/>
|
||||||
|
</ResCell>
|
||||||
|
<ResCell>
|
||||||
|
{/* Hintergrund = Füllung (Poché). Fehlt sie, gilt `color` als Fallback. */}
|
||||||
|
<ColorField
|
||||||
|
color={component.background ?? component.color}
|
||||||
|
onChange={(background) => onPatch({ background })}
|
||||||
/>
|
/>
|
||||||
</ResCell>
|
</ResCell>
|
||||||
<ResCell>
|
<ResCell>
|
||||||
@@ -869,21 +936,8 @@ function ComponentsTab({
|
|||||||
|
|
||||||
// ── Schraffuren (Hatches) ──────────────────────────────────────────────────
|
// ── Schraffuren (Hatches) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
// Spalten: Name | Muster | Maßstab | Winkel | Farbe | Linienstil | (löschen).
|
/** Detail-Panel EINER Schraffur (rechte Seite des Master-Detail-Layouts). */
|
||||||
const HATCH_COLUMNS: ResColumn[] = [
|
function HatchDetail({
|
||||||
{ titleKey: "resources.col.name" },
|
|
||||||
{ titleKey: "resources.col.pattern" },
|
|
||||||
{ titleKey: "resources.col.scale", align: "right" },
|
|
||||||
{ titleKey: "resources.col.angle", align: "right" },
|
|
||||||
{ titleKey: "resources.col.relativeToWall" },
|
|
||||||
{ titleKey: "resources.col.color" },
|
|
||||||
{ titleKey: "resources.col.lineStyle" },
|
|
||||||
{ titleKey: "" },
|
|
||||||
];
|
|
||||||
const HATCH_TEMPLATE =
|
|
||||||
"minmax(110px, 1fr) minmax(120px, 0.9fr) 64px 60px 64px auto minmax(120px, 0.9fr) 36px";
|
|
||||||
|
|
||||||
function HatchRow({
|
|
||||||
hatch,
|
hatch,
|
||||||
lineStyles,
|
lineStyles,
|
||||||
onPatch,
|
onPatch,
|
||||||
@@ -894,22 +948,81 @@ function HatchRow({
|
|||||||
onPatch: (patch: Partial<HatchStyle>) => void;
|
onPatch: (patch: Partial<HatchStyle>) => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
}) {
|
}) {
|
||||||
// Sentinel-Eintrag „— ohne —", da lineStyleId optional ist.
|
|
||||||
const NONE = "__none__";
|
const NONE = "__none__";
|
||||||
const lineOptions = [
|
const lineOptions = [
|
||||||
{ value: NONE, label: t("resources.lineStyle.none") },
|
{ value: NONE, label: t("resources.lineStyle.none") },
|
||||||
...lineStyles.map((l) => ({ value: l.id, label: l.name })),
|
...lineStyles.map((l) => ({ value: l.id, label: l.name })),
|
||||||
];
|
];
|
||||||
|
const kind = hatch.kind ?? "vector";
|
||||||
|
const lines = hatch.lines ?? "parallel";
|
||||||
|
|
||||||
|
/** Wechselt den Typ; beim Umschalten auf „Bild" Default-Bildparameter setzen. */
|
||||||
|
const setKind = (k: "vector" | "image") => {
|
||||||
|
if (k === "image" && !hatch.image) {
|
||||||
|
onPatch({ kind: k, image: { src: "", scaleX: 1, scaleY: 1, rotation: 0 } });
|
||||||
|
} else {
|
||||||
|
onPatch({ kind: k });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Merge-Patch für die Bild-Parameter (behält bestehende Werte). */
|
||||||
|
const patchImage = (part: Partial<NonNullable<HatchStyle["image"]>>) => {
|
||||||
|
const cur = hatch.image ?? { src: "", scaleX: 1, scaleY: 1, rotation: 0 };
|
||||||
|
onPatch({ image: { ...cur, ...part } });
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Datei → Data-URL (readAsDataURL) → image.src. */
|
||||||
|
const loadImage = (file: File | null) => {
|
||||||
|
if (!file) return;
|
||||||
|
const r = new FileReader();
|
||||||
|
r.onload = () => patchImage({ src: String(r.result ?? "") });
|
||||||
|
r.readAsDataURL(file);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResRow>
|
<div className="res-md-detail-inner">
|
||||||
<ResCell>
|
<div className="res-md-head">
|
||||||
<TextField
|
<input
|
||||||
|
className="res-input res-md-rename"
|
||||||
value={hatch.name}
|
value={hatch.name}
|
||||||
onChange={(name) => onPatch({ name })}
|
|
||||||
placeholder={t("resources.hatches.placeholder")}
|
placeholder={t("resources.hatches.placeholder")}
|
||||||
|
onChange={(e) => onPatch({ name: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</ResCell>
|
<DeleteButton
|
||||||
<ResCell>
|
onClick={onDelete}
|
||||||
|
title={t("resources.delete.hatch", { name: hatch.name })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="res-md-preview">
|
||||||
|
<HatchSwatch hatch={hatch} size={128} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="res-fields">
|
||||||
|
<FieldRow label={t("resources.field.type")}>
|
||||||
|
<Segmented
|
||||||
|
value={kind}
|
||||||
|
onChange={setKind}
|
||||||
|
options={[
|
||||||
|
{ value: "vector", label: t("resources.hatchKind.vector") },
|
||||||
|
{ value: "image", label: t("resources.hatchKind.image") },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</FieldRow>
|
||||||
|
|
||||||
|
{kind === "vector" ? (
|
||||||
|
<>
|
||||||
|
<FieldRow label={t("resources.field.lines")}>
|
||||||
|
<Segmented
|
||||||
|
value={lines}
|
||||||
|
onChange={(v) => onPatch({ lines: v })}
|
||||||
|
options={[
|
||||||
|
{ value: "parallel", label: t("resources.lines.parallel") },
|
||||||
|
{ value: "random", label: t("resources.lines.random") },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</FieldRow>
|
||||||
|
<FieldRow label={t("resources.col.pattern")}>
|
||||||
<SelectField
|
<SelectField
|
||||||
value={hatch.pattern}
|
value={hatch.pattern}
|
||||||
onChange={(pattern) => onPatch({ pattern })}
|
onChange={(pattern) => onPatch({ pattern })}
|
||||||
@@ -918,47 +1031,72 @@ function HatchRow({
|
|||||||
label: t(o.labelKey),
|
label: t(o.labelKey),
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</ResCell>
|
</FieldRow>
|
||||||
<ResCell align="right">
|
<FieldRow label={t("resources.col.scale")}>
|
||||||
<NumberField
|
<NumberField
|
||||||
value={hatch.scale}
|
value={hatch.scale}
|
||||||
onChange={(scale) => onPatch({ scale })}
|
onChange={(scale) => onPatch({ scale })}
|
||||||
step={0.1}
|
step={0.1}
|
||||||
min={0.01}
|
min={0.01}
|
||||||
/>
|
/>
|
||||||
</ResCell>
|
</FieldRow>
|
||||||
<ResCell align="right">
|
<FieldRow label={t("resources.col.angle")}>
|
||||||
<NumberField
|
<NumberField
|
||||||
value={hatch.angle}
|
value={hatch.angle}
|
||||||
onChange={(angle) => onPatch({ angle })}
|
onChange={(angle) => onPatch({ angle })}
|
||||||
step={5}
|
step={5}
|
||||||
/>
|
/>
|
||||||
</ResCell>
|
</FieldRow>
|
||||||
<ResCell>
|
<FieldRow
|
||||||
|
label={t("resources.col.relativeToWall")}
|
||||||
|
hint={t("resources.col.relativeToWall.hint")}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={!!hatch.relativeToWall}
|
checked={!!hatch.relativeToWall}
|
||||||
onChange={(e) => onPatch({ relativeToWall: e.target.checked })}
|
onChange={(e) => onPatch({ relativeToWall: e.target.checked })}
|
||||||
title={t("resources.col.relativeToWall.hint")}
|
|
||||||
/>
|
/>
|
||||||
</ResCell>
|
</FieldRow>
|
||||||
<ResCell>
|
<FieldRow label={t("resources.col.lineStyle")}>
|
||||||
<ColorField color={hatch.color} onChange={(color) => onPatch({ color })} />
|
|
||||||
</ResCell>
|
|
||||||
<ResCell>
|
|
||||||
<SelectField
|
<SelectField
|
||||||
value={hatch.lineStyleId ?? NONE}
|
value={hatch.lineStyleId ?? NONE}
|
||||||
onChange={(v) => onPatch({ lineStyleId: v === NONE ? undefined : v })}
|
onChange={(v) => onPatch({ lineStyleId: v === NONE ? undefined : v })}
|
||||||
options={lineOptions}
|
options={lineOptions}
|
||||||
/>
|
/>
|
||||||
</ResCell>
|
</FieldRow>
|
||||||
<ResCell>
|
</>
|
||||||
<DeleteButton
|
) : (
|
||||||
onClick={onDelete}
|
<>
|
||||||
title={t("resources.delete.hatch", { name: hatch.name })}
|
<FieldRow label={t("resources.field.image")}>
|
||||||
|
<ImagePickButton onFile={loadImage} />
|
||||||
|
</FieldRow>
|
||||||
|
<FieldRow label={t("resources.field.scaleX")}>
|
||||||
|
<NumberField
|
||||||
|
value={hatch.image?.scaleX ?? 1}
|
||||||
|
onChange={(scaleX) => patchImage({ scaleX })}
|
||||||
|
step={0.1}
|
||||||
|
min={0.05}
|
||||||
/>
|
/>
|
||||||
</ResCell>
|
</FieldRow>
|
||||||
</ResRow>
|
<FieldRow label={t("resources.field.scaleY")}>
|
||||||
|
<NumberField
|
||||||
|
value={hatch.image?.scaleY ?? 1}
|
||||||
|
onChange={(scaleY) => patchImage({ scaleY })}
|
||||||
|
step={0.1}
|
||||||
|
min={0.05}
|
||||||
|
/>
|
||||||
|
</FieldRow>
|
||||||
|
<FieldRow label={t("resources.field.rotation")}>
|
||||||
|
<NumberField
|
||||||
|
value={hatch.image?.rotation ?? 0}
|
||||||
|
onChange={(rotation) => patchImage({ rotation })}
|
||||||
|
step={5}
|
||||||
|
/>
|
||||||
|
</FieldRow>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -975,24 +1113,32 @@ function HatchesTab({
|
|||||||
onDeleteHatch: (id: string) => void;
|
onDeleteHatch: (id: string) => void;
|
||||||
onImportHatches: (hatches: Omit<HatchStyle, "id">[]) => void;
|
onImportHatches: (hatches: Omit<HatchStyle, "id">[]) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const hatches = project.hatches;
|
||||||
|
// Auswahl gegen die Liste validieren (nach Delete/Add stabil bleiben).
|
||||||
|
const selected = hatches.find((h) => h.id === selectedId) ?? hatches[0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="res-tab">
|
<div className="res-tab">
|
||||||
<div className="res-hint">{t("resources.hatches.hint")}</div>
|
<div className="res-hint">{t("resources.hatches.hint")}</div>
|
||||||
<ResTable columns={HATCH_COLUMNS} template={HATCH_TEMPLATE}>
|
<div className="res-md">
|
||||||
{project.hatches.map((h) => (
|
<div className="res-md-list">
|
||||||
<HatchRow
|
{hatches.map((h) => (
|
||||||
|
<button
|
||||||
key={h.id}
|
key={h.id}
|
||||||
hatch={h}
|
className={`res-md-row${selected?.id === h.id ? " active" : ""}`}
|
||||||
lineStyles={project.lineStyles}
|
onClick={() => setSelectedId(h.id)}
|
||||||
onPatch={(patch) => onPatchHatch(h.id, patch)}
|
>
|
||||||
onDelete={() => onDeleteHatch(h.id)}
|
<span className="res-md-row-thumb">
|
||||||
/>
|
<HatchSwatch hatch={h} size={30} />
|
||||||
|
</span>
|
||||||
|
<span className="res-md-row-name">{h.name}</span>
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
{project.hatches.length === 0 && (
|
{hatches.length === 0 && (
|
||||||
<EmptyState text={t("resources.hatches.empty")} />
|
<div className="res-md-empty">{t("resources.hatches.empty")}</div>
|
||||||
)}
|
)}
|
||||||
</ResTable>
|
<div className="res-md-listfoot">
|
||||||
<div className="res-add-row">
|
|
||||||
<button className="res-add" onClick={onAddHatch}>
|
<button className="res-add" onClick={onAddHatch}>
|
||||||
<span className="res-add-plus">+</span> {t("resources.add")}
|
<span className="res-add-plus">+</span> {t("resources.add")}
|
||||||
</button>
|
</button>
|
||||||
@@ -1003,45 +1149,28 @@ function HatchesTab({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="res-md-detail">
|
||||||
|
{selected ? (
|
||||||
|
<HatchDetail
|
||||||
|
key={selected.id}
|
||||||
|
hatch={selected}
|
||||||
|
lineStyles={project.lineStyles}
|
||||||
|
onPatch={(patch) => onPatchHatch(selected.id, patch)}
|
||||||
|
onDelete={() => onDeleteHatch(selected.id)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="res-md-empty">{t("resources.hatches.empty")}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Linien (Line Styles) ───────────────────────────────────────────────────
|
// ── Linien (Line Styles) ───────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Kleine Vorschau eines Linienstils (Stärke + Strichmuster) als SVG. */
|
/** Detail-Panel EINES Linienstils (rechte Seite des Master-Detail-Layouts). */
|
||||||
function LinePreview({ style }: { style: LineStyle }) {
|
function LineStyleDetail({
|
||||||
// Stärke (mm) optisch skaliert auf 1–6 px für die Vorschau.
|
|
||||||
const px = Math.max(1, Math.min(6, style.weight * 6));
|
|
||||||
const dasharray = style.dash ? style.dash.map((d) => d * 4).join(" ") : undefined;
|
|
||||||
return (
|
|
||||||
<svg className="res-line-preview" viewBox="0 0 120 12" preserveAspectRatio="none">
|
|
||||||
<line
|
|
||||||
x1={4}
|
|
||||||
y1={6}
|
|
||||||
x2={116}
|
|
||||||
y2={6}
|
|
||||||
stroke={style.color}
|
|
||||||
strokeWidth={px}
|
|
||||||
strokeDasharray={dasharray}
|
|
||||||
strokeLinecap="butt"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Spalten: Name | Stärke (mm) | Farbe | Strich | Vorschau | (löschen).
|
|
||||||
const LINE_COLUMNS: ResColumn[] = [
|
|
||||||
{ titleKey: "resources.col.name" },
|
|
||||||
{ titleKey: "resources.col.weight", align: "right" },
|
|
||||||
{ titleKey: "resources.col.color" },
|
|
||||||
{ titleKey: "resources.col.stroke" },
|
|
||||||
{ titleKey: "resources.col.preview" },
|
|
||||||
{ titleKey: "" },
|
|
||||||
];
|
|
||||||
const LINE_TEMPLATE =
|
|
||||||
"minmax(110px, 1fr) 80px auto minmax(120px, 0.9fr) minmax(90px, 1fr) 36px";
|
|
||||||
|
|
||||||
function LineStyleRow({
|
|
||||||
style,
|
style,
|
||||||
onPatch,
|
onPatch,
|
||||||
onDelete,
|
onDelete,
|
||||||
@@ -1050,16 +1179,54 @@ function LineStyleRow({
|
|||||||
onPatch: (patch: Partial<LineStyle>) => void;
|
onPatch: (patch: Partial<LineStyle>) => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const kind = style.kind ?? "dash";
|
||||||
|
|
||||||
|
/** Wechselt den Typ; beim Umschalten auf „Zickzack" Default-Parameter setzen. */
|
||||||
|
const setKind = (k: "dash" | "zigzag") => {
|
||||||
|
if (k === "zigzag" && !style.zigzag) {
|
||||||
|
onPatch({ kind: k, zigzag: { amplitude: 1, wavelength: 4 } });
|
||||||
|
} else {
|
||||||
|
onPatch({ kind: k });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Merge-Patch für die Zickzack-Parameter. */
|
||||||
|
const patchZig = (part: Partial<NonNullable<LineStyle["zigzag"]>>) => {
|
||||||
|
const cur = style.zigzag ?? { amplitude: 1, wavelength: 4 };
|
||||||
|
onPatch({ zigzag: { ...cur, ...part } });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResRow>
|
<div className="res-md-detail-inner">
|
||||||
<ResCell>
|
<div className="res-md-head">
|
||||||
<TextField
|
<input
|
||||||
|
className="res-input res-md-rename"
|
||||||
value={style.name}
|
value={style.name}
|
||||||
onChange={(name) => onPatch({ name })}
|
|
||||||
placeholder={t("resources.lines.placeholder")}
|
placeholder={t("resources.lines.placeholder")}
|
||||||
|
onChange={(e) => onPatch({ name: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</ResCell>
|
<DeleteButton
|
||||||
<ResCell align="right">
|
onClick={onDelete}
|
||||||
|
title={t("resources.delete.lineStyle", { name: style.name })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="res-md-preview">
|
||||||
|
<LineSwatch style={style} width={220} height={40} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="res-fields">
|
||||||
|
<FieldRow label={t("resources.field.type")}>
|
||||||
|
<Segmented
|
||||||
|
value={kind}
|
||||||
|
onChange={setKind}
|
||||||
|
options={[
|
||||||
|
{ value: "dash", label: t("resources.lineKind.dash") },
|
||||||
|
{ value: "zigzag", label: t("resources.lineKind.zigzag") },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</FieldRow>
|
||||||
|
<FieldRow label={t("resources.col.weight")}>
|
||||||
<NumberField
|
<NumberField
|
||||||
value={style.weight}
|
value={style.weight}
|
||||||
onChange={(weight) => onPatch({ weight })}
|
onChange={(weight) => onPatch({ weight })}
|
||||||
@@ -1068,11 +1235,31 @@ function LineStyleRow({
|
|||||||
max={2}
|
max={2}
|
||||||
list="pen-weights"
|
list="pen-weights"
|
||||||
/>
|
/>
|
||||||
</ResCell>
|
</FieldRow>
|
||||||
<ResCell>
|
<FieldRow label={t("resources.col.color")}>
|
||||||
<ColorField color={style.color} onChange={(color) => onPatch({ color })} />
|
<ColorField color={style.color} onChange={(color) => onPatch({ color })} />
|
||||||
</ResCell>
|
</FieldRow>
|
||||||
<ResCell>
|
{kind === "zigzag" ? (
|
||||||
|
<>
|
||||||
|
<FieldRow label={t("resources.field.amplitude")}>
|
||||||
|
<NumberField
|
||||||
|
value={style.zigzag?.amplitude ?? 1}
|
||||||
|
onChange={(amplitude) => patchZig({ amplitude })}
|
||||||
|
step={0.1}
|
||||||
|
min={0.05}
|
||||||
|
/>
|
||||||
|
</FieldRow>
|
||||||
|
<FieldRow label={t("resources.field.wavelength")}>
|
||||||
|
<NumberField
|
||||||
|
value={style.zigzag?.wavelength ?? 4}
|
||||||
|
onChange={(wavelength) => patchZig({ wavelength })}
|
||||||
|
step={0.5}
|
||||||
|
min={0.5}
|
||||||
|
/>
|
||||||
|
</FieldRow>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<FieldRow label={t("resources.col.stroke")}>
|
||||||
<SelectField
|
<SelectField
|
||||||
value={dashKey(style.dash)}
|
value={dashKey(style.dash)}
|
||||||
onChange={(key) => {
|
onChange={(key) => {
|
||||||
@@ -1081,17 +1268,10 @@ function LineStyleRow({
|
|||||||
}}
|
}}
|
||||||
options={DASH_OPTIONS.map((o) => ({ value: o.key, label: t(o.labelKey) }))}
|
options={DASH_OPTIONS.map((o) => ({ value: o.key, label: t(o.labelKey) }))}
|
||||||
/>
|
/>
|
||||||
</ResCell>
|
</FieldRow>
|
||||||
<ResCell>
|
)}
|
||||||
<LinePreview style={style} />
|
</div>
|
||||||
</ResCell>
|
</div>
|
||||||
<ResCell>
|
|
||||||
<DeleteButton
|
|
||||||
onClick={onDelete}
|
|
||||||
title={t("resources.delete.lineStyle", { name: style.name })}
|
|
||||||
/>
|
|
||||||
</ResCell>
|
|
||||||
</ResRow>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1108,6 +1288,10 @@ function LinesTab({
|
|||||||
onDeleteLineStyle: (id: string) => void;
|
onDeleteLineStyle: (id: string) => void;
|
||||||
onImportLineStyles: (styles: Omit<LineStyle, "id">[]) => void;
|
onImportLineStyles: (styles: Omit<LineStyle, "id">[]) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const styles = project.lineStyles;
|
||||||
|
const selected = styles.find((l) => l.id === selectedId) ?? styles[0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="res-tab">
|
<div className="res-tab">
|
||||||
{/* Vorschlagswerte für die Strichstärke (Standard-Stiftbreiten in mm). */}
|
{/* Vorschlagswerte für die Strichstärke (Standard-Stiftbreiten in mm). */}
|
||||||
@@ -1117,20 +1301,24 @@ function LinesTab({
|
|||||||
))}
|
))}
|
||||||
</datalist>
|
</datalist>
|
||||||
<div className="res-hint">{t("resources.lines.hint")}</div>
|
<div className="res-hint">{t("resources.lines.hint")}</div>
|
||||||
<ResTable columns={LINE_COLUMNS} template={LINE_TEMPLATE}>
|
<div className="res-md">
|
||||||
{project.lineStyles.map((l) => (
|
<div className="res-md-list">
|
||||||
<LineStyleRow
|
{styles.map((l) => (
|
||||||
|
<button
|
||||||
key={l.id}
|
key={l.id}
|
||||||
style={l}
|
className={`res-md-row res-md-row-line${
|
||||||
onPatch={(patch) => onPatchLineStyle(l.id, patch)}
|
selected?.id === l.id ? " active" : ""
|
||||||
onDelete={() => onDeleteLineStyle(l.id)}
|
}`}
|
||||||
/>
|
onClick={() => setSelectedId(l.id)}
|
||||||
|
>
|
||||||
|
<span className="res-md-row-name">{l.name}</span>
|
||||||
|
<LineSwatch style={l} width={150} height={14} />
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
{project.lineStyles.length === 0 && (
|
{styles.length === 0 && (
|
||||||
<EmptyState text={t("resources.lines.empty")} />
|
<div className="res-md-empty">{t("resources.lines.empty")}</div>
|
||||||
)}
|
)}
|
||||||
</ResTable>
|
<div className="res-md-listfoot">
|
||||||
<div className="res-add-row">
|
|
||||||
<button className="res-add" onClick={onAddLineStyle}>
|
<button className="res-add" onClick={onAddLineStyle}>
|
||||||
<span className="res-add-plus">+</span> {t("resources.add")}
|
<span className="res-add-plus">+</span> {t("resources.add")}
|
||||||
</button>
|
</button>
|
||||||
@@ -1141,6 +1329,20 @@ function LinesTab({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="res-md-detail">
|
||||||
|
{selected ? (
|
||||||
|
<LineStyleDetail
|
||||||
|
key={selected.id}
|
||||||
|
style={selected}
|
||||||
|
onPatch={(patch) => onPatchLineStyle(selected.id, patch)}
|
||||||
|
onDelete={() => onDeleteLineStyle(selected.id)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="res-md-empty">{t("resources.lines.empty")}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
// Live-SVG-Vorschau (Swatch) für Schraffuren und Linienstile — eigenständig.
|
||||||
|
//
|
||||||
|
// Bewusst OHNE Rückgriff auf die Plan-Renderer (generatePlan/toRenderScene):
|
||||||
|
// diese Datei zeichnet die Muster direkt aus den HatchStyle-/LineStyle-Parametern
|
||||||
|
// als SVG nach, damit die Vorschau im Ressourcen-Manager auch dann konsistent
|
||||||
|
// bleibt, während der Plan-Renderer separat umgebaut wird. Die Farbe kommt hier
|
||||||
|
// bewusst NICHT vom (deprecated) HatchStyle.color, sondern über `currentColor`
|
||||||
|
// aus dem umgebenden CSS (var(--ink)) — die echte Vordergrundfarbe stammt im Plan
|
||||||
|
// vom Bauteil/Attribut. Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||||
|
|
||||||
|
import { useId } from "react";
|
||||||
|
import type { HatchStyle, LineStyle } from "../model/types";
|
||||||
|
|
||||||
|
// ── Kleiner deterministischer PRNG (mulberry32) ────────────────────────────
|
||||||
|
// Für „random"-Striche: stabil pro Seed, damit die Vorschau bei gleichem
|
||||||
|
// Maßstab/Winkel nicht bei jedem Render „springt".
|
||||||
|
function mulberry32(seed: number): () => number {
|
||||||
|
let a = seed >>> 0;
|
||||||
|
return () => {
|
||||||
|
a |= 0;
|
||||||
|
a = (a + 0x6d2b79f5) | 0;
|
||||||
|
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||||
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parallele Linienschar über die Box (w×h) unter `angleDeg`, Abstand `spacing`
|
||||||
|
* px senkrecht zur Linienrichtung. Liefert fertige <line>-Elemente, die die Box
|
||||||
|
* komplett überdecken (Clip übernimmt der Aufrufer).
|
||||||
|
*/
|
||||||
|
function parallelLines(
|
||||||
|
w: number,
|
||||||
|
h: number,
|
||||||
|
angleDeg: number,
|
||||||
|
spacing: number,
|
||||||
|
strokeWidth: number,
|
||||||
|
keyPrefix: string,
|
||||||
|
): React.ReactNode[] {
|
||||||
|
const a = (angleDeg * Math.PI) / 180;
|
||||||
|
const cos = Math.cos(a);
|
||||||
|
const sin = Math.sin(a);
|
||||||
|
// Senkrechte zur Linienrichtung.
|
||||||
|
const px = -sin;
|
||||||
|
const py = cos;
|
||||||
|
const cx = w / 2;
|
||||||
|
const cy = h / 2;
|
||||||
|
const half = Math.hypot(w, h); // Linien lang genug, um die Box zu überdecken.
|
||||||
|
const step = Math.max(spacing, 1.2);
|
||||||
|
const n = Math.ceil(half / step) + 2;
|
||||||
|
const out: React.ReactNode[] = [];
|
||||||
|
for (let k = -n; k <= n; k++) {
|
||||||
|
const mx = cx + k * step * px;
|
||||||
|
const my = cy + k * step * py;
|
||||||
|
out.push(
|
||||||
|
<line
|
||||||
|
key={`${keyPrefix}${k}`}
|
||||||
|
x1={mx - cos * half}
|
||||||
|
y1={my - sin * half}
|
||||||
|
x2={mx + cos * half}
|
||||||
|
y2={my + sin * half}
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live-SVG-Swatch einer Schraffur. Rendert:
|
||||||
|
* • Bild-Schraffur (`kind==="image"`) als gekachelten <pattern>/<image>-Fill
|
||||||
|
* mit unabhängiger Skalierung (scaleX/scaleY) und Rotation.
|
||||||
|
* • Vektor-Schraffur nach `pattern`: solid/none/diagonal/crosshatch/insulation.
|
||||||
|
* Untermodus `lines==="random"` verteilt kurze Striche zufällig (Kies/Splitt).
|
||||||
|
* `scale` verdichtet/streckt das Muster, `angle` dreht es.
|
||||||
|
*/
|
||||||
|
export function HatchSwatch({
|
||||||
|
hatch,
|
||||||
|
size = 34,
|
||||||
|
}: {
|
||||||
|
hatch: HatchStyle;
|
||||||
|
size?: number;
|
||||||
|
}) {
|
||||||
|
const rawId = useId().replace(/[^a-zA-Z0-9_-]/g, "");
|
||||||
|
const clipId = `hsc_${rawId}`;
|
||||||
|
const patId = `hsp_${rawId}`;
|
||||||
|
const w = size;
|
||||||
|
const h = size;
|
||||||
|
|
||||||
|
// Grundabstand der Musterlinien (px), über den Maßstab moduliert.
|
||||||
|
const scale = hatch.scale > 0 ? hatch.scale : 1;
|
||||||
|
const spacing = Math.max(3, 8 / Math.sqrt(scale));
|
||||||
|
const angle = hatch.angle || 0;
|
||||||
|
|
||||||
|
let inner: React.ReactNode = null;
|
||||||
|
|
||||||
|
if (hatch.kind === "image") {
|
||||||
|
// Bild-Kachel: Basiskachel skaliert mit scaleX/scaleY, gedreht via
|
||||||
|
// patternTransform. Fehlt die Quelle, zeigen wir einen dezenten Rahmen.
|
||||||
|
const src = hatch.image?.src;
|
||||||
|
const base = Math.max(6, size * 0.6);
|
||||||
|
const tileW = Math.max(2, base * (hatch.image?.scaleX ?? 1));
|
||||||
|
const tileH = Math.max(2, base * (hatch.image?.scaleY ?? 1));
|
||||||
|
const rot = hatch.image?.rotation ?? 0;
|
||||||
|
if (src) {
|
||||||
|
inner = (
|
||||||
|
<>
|
||||||
|
<defs>
|
||||||
|
<pattern
|
||||||
|
id={patId}
|
||||||
|
patternUnits="userSpaceOnUse"
|
||||||
|
width={tileW}
|
||||||
|
height={tileH}
|
||||||
|
patternTransform={`rotate(${rot})`}
|
||||||
|
>
|
||||||
|
<image
|
||||||
|
href={src}
|
||||||
|
x={0}
|
||||||
|
y={0}
|
||||||
|
width={tileW}
|
||||||
|
height={tileH}
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
/>
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect x={0} y={0} width={w} height={h} fill={`url(#${patId})`} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
inner = (
|
||||||
|
<text
|
||||||
|
x={w / 2}
|
||||||
|
y={h / 2}
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="central"
|
||||||
|
fontSize={9}
|
||||||
|
fill="currentColor"
|
||||||
|
opacity={0.5}
|
||||||
|
>
|
||||||
|
?
|
||||||
|
</text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (hatch.pattern === "solid") {
|
||||||
|
inner = <rect x={0} y={0} width={w} height={h} fill="currentColor" />;
|
||||||
|
} else if (hatch.pattern === "none") {
|
||||||
|
inner = null;
|
||||||
|
} else if (hatch.lines === "random") {
|
||||||
|
// Zufällig verteilte kurze Striche (deterministisch geseedet).
|
||||||
|
const rnd = mulberry32(Math.round(scale * 1000) + Math.round(angle));
|
||||||
|
const count = Math.round(18 * Math.min(2, scale)) + 6;
|
||||||
|
const len = Math.max(3, 6 / Math.sqrt(scale));
|
||||||
|
const out: React.ReactNode[] = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const cx = rnd() * w;
|
||||||
|
const cy = rnd() * h;
|
||||||
|
const ang = rnd() * Math.PI;
|
||||||
|
const dx = (Math.cos(ang) * len) / 2;
|
||||||
|
const dy = (Math.sin(ang) * len) / 2;
|
||||||
|
out.push(
|
||||||
|
<line
|
||||||
|
key={i}
|
||||||
|
x1={cx - dx}
|
||||||
|
y1={cy - dy}
|
||||||
|
x2={cx + dx}
|
||||||
|
y2={cy + dy}
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={0.9}
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
inner = out;
|
||||||
|
} else if (hatch.pattern === "crosshatch") {
|
||||||
|
inner = [
|
||||||
|
...parallelLines(w, h, angle, spacing, 0.8, "a"),
|
||||||
|
...parallelLines(w, h, angle + 90, spacing, 0.8, "b"),
|
||||||
|
];
|
||||||
|
} else if (hatch.pattern === "insulation") {
|
||||||
|
// Dämmung: eine gewellte Schar quer über die Box (angenähert als Zickzack).
|
||||||
|
const rows = Math.max(2, Math.round(h / Math.max(6, spacing)));
|
||||||
|
const out: React.ReactNode[] = [];
|
||||||
|
for (let r = 0; r < rows; r++) {
|
||||||
|
const y = ((r + 0.5) / rows) * h;
|
||||||
|
const amp = Math.min(h / rows / 2, 3);
|
||||||
|
const waves = Math.max(2, Math.round(w / 6));
|
||||||
|
let d = `M 0 ${y}`;
|
||||||
|
for (let i = 1; i <= waves; i++) {
|
||||||
|
const x = (i / waves) * w;
|
||||||
|
const yy = y + (i % 2 === 0 ? -amp : amp);
|
||||||
|
d += ` L ${x.toFixed(1)} ${yy.toFixed(1)}`;
|
||||||
|
}
|
||||||
|
out.push(
|
||||||
|
<path key={r} d={d} fill="none" stroke="currentColor" strokeWidth={0.8} />,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
inner = out;
|
||||||
|
} else {
|
||||||
|
// "diagonal" (Default): eine parallele Schar.
|
||||||
|
inner = parallelLines(w, h, angle, spacing, 0.8, "d");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className="res-swatch"
|
||||||
|
width={w}
|
||||||
|
height={h}
|
||||||
|
viewBox={`0 0 ${w} ${h}`}
|
||||||
|
style={{ display: "block" }}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<clipPath id={clipId}>
|
||||||
|
<rect x={0} y={0} width={w} height={h} rx={3} />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<rect
|
||||||
|
x={0.5}
|
||||||
|
y={0.5}
|
||||||
|
width={w - 1}
|
||||||
|
height={h - 1}
|
||||||
|
rx={3}
|
||||||
|
fill="var(--input)"
|
||||||
|
stroke="var(--border)"
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
<g clipPath={`url(#${clipId})`}>{inner}</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live-SVG-Swatch eines Linienstils. `kind==="zigzag"` zeichnet eine Zickzack-/
|
||||||
|
* Wellenlinie aus `amplitude`/`wavelength` (mm Papier, optisch skaliert); sonst
|
||||||
|
* eine gerade Linie mit `dash`-Strichmuster. `weight` (mm) wird auf eine
|
||||||
|
* sichtbare Pixelstärke abgebildet. Farbe über `currentColor`.
|
||||||
|
*/
|
||||||
|
export function LineSwatch({
|
||||||
|
style,
|
||||||
|
width = 120,
|
||||||
|
height = 22,
|
||||||
|
}: {
|
||||||
|
style: LineStyle;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
}) {
|
||||||
|
const px = Math.max(1, Math.min(6, style.weight * 6));
|
||||||
|
const midY = height / 2;
|
||||||
|
const x0 = 4;
|
||||||
|
const x1 = width - 4;
|
||||||
|
|
||||||
|
let path: React.ReactNode;
|
||||||
|
if (style.kind === "zigzag") {
|
||||||
|
const amp = Math.max(1, Math.min(height / 2 - 2, (style.zigzag?.amplitude ?? 1) * 4));
|
||||||
|
const wl = Math.max(4, (style.zigzag?.wavelength ?? 4) * 4);
|
||||||
|
let d = `M ${x0} ${midY}`;
|
||||||
|
// Halbperioden-Segmente: abwechselnd nach oben/unten.
|
||||||
|
let x = x0;
|
||||||
|
let up = true;
|
||||||
|
let seg = 0;
|
||||||
|
while (x < x1) {
|
||||||
|
const nx = Math.min(x + wl / 2, x1);
|
||||||
|
const ny = midY + (up ? -amp : amp);
|
||||||
|
d += ` L ${nx.toFixed(1)} ${ny.toFixed(1)}`;
|
||||||
|
x = nx;
|
||||||
|
up = !up;
|
||||||
|
seg++;
|
||||||
|
if (seg > 200) break;
|
||||||
|
}
|
||||||
|
path = (
|
||||||
|
<path
|
||||||
|
d={d}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={px}
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const dasharray = style.dash ? style.dash.map((d) => d * 4).join(" ") : undefined;
|
||||||
|
path = (
|
||||||
|
<line
|
||||||
|
x1={x0}
|
||||||
|
y1={midY}
|
||||||
|
x2={x1}
|
||||||
|
y2={midY}
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={px}
|
||||||
|
strokeDasharray={dasharray}
|
||||||
|
strokeLinecap="butt"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className="res-line-preview"
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
viewBox={`0 0 ${width} ${height}`}
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
>
|
||||||
|
{path}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user