Wandstile: Schichtfuge pro Fuge als LineStyle waehlbar + ResourceManager-Abteilung

Mehrschichtige Waende zeichnen die Trennlinie an jeder Materialfuge jetzt mit
einem pro Fuge waehlbaren LineStyle statt einer festen Haarlinie.

- Layer.jointLineStyleId (optional): LineStyle der Fuge an der Innenkante
  dieser Schicht; fehlt er, gilt die Default-Haarlinie (0.02) in Wandfarbe.
- generatePlan: Schicht-Baender tragen nur noch Fuellung + Schraffur (kein
  Band-Umriss); jede innere Fuge wird als eigene Linie mit ihrem LineStyle
  (Gewicht/Farbe/Dash, x Detailfaktor) gezeichnet. Wand-Umriss (0.35) und
  grob-Pfad unveraendert.
- ResourceManager: neue Abteilung "Wandstile" - je Wandtyp die Schichten
  mit Fugen-LineStyle-Dropdown.
- Sample: LineStyle "Schichtfuge 0.13"; der Daemmung-Backstein-Fuge (massiv/
  massiv) zugewiesen, Putzfugen bleiben Haarlinie.
- 2 Joint-Tests ergaenzt.
This commit is contained in:
2026-07-03 20:55:07 +02:00
parent 3a0652a337
commit bd80eb1c59
11 changed files with 211 additions and 9 deletions
+11
View File
@@ -34,6 +34,7 @@ import type {
Project,
Vec2,
Wall,
WallType,
} from "./model/types";
import { generatePlan, generateSectionPlan } from "./plan/generatePlan";
import type { CategoryDisplayResolver } from "./plan/generatePlan";
@@ -1619,6 +1620,14 @@ export default function App() {
const patchLineStyle = useStore((s) => s.patchLineStyle);
const addLineStyle = useStore((s) => s.addLineStyle);
const deleteLineStyle = useStore((s) => s.deleteLineStyle);
// Wandstile: immutable Patch eines Wandtyps (z. B. Schichtfugen-Stile). Kein
// eigener Store-Slice nötig — läuft direkt über setProject wie die übrigen
// Ressourcen-Mutationen.
const patchWallType = (id: string, patch: Partial<WallType>) =>
setProject((p) => ({
...p,
wallTypes: p.wallTypes.map((wt) => (wt.id === id ? { ...wt, ...patch } : wt)),
}));
const importLineStyles = useStore((s) => s.importLineStyles);
const importHatches = useStore((s) => s.importHatches);
// Site-/Kontext-Schicht (siteSlice): Import/Entfernen/Gelände.
@@ -1904,6 +1913,7 @@ export default function App() {
onPatchLineStyle: patchLineStyle,
onAddLineStyle: addLineStyle,
onDeleteLineStyle: deleteLineStyle,
onPatchWallType: patchWallType,
onImportLineStyles: importLineStyles,
onImportHatches: importHatches,
selection,
@@ -2135,6 +2145,7 @@ export default function App() {
// Handler-Bündel für das schwebende Ressourcen-Fenster (ResourceManager).
const resourceHandlers: ResourceManagerHandlers = {
onPatchComponent: patchComponent,
onPatchWallType: patchWallType,
onAddComponent: addComponent,
onDeleteComponent: deleteComponent,
onPatchHatch: patchHatch,
+9
View File
@@ -438,8 +438,17 @@ export const de = {
"resources.tab.components": "Bauteile",
"resources.tab.hatches": "Schraffuren",
"resources.tab.lines": "Linien",
"resources.tab.wallStyles": "Wandstile",
"resources.tab.materials": "Materialien",
"resources.wallStyles.hint":
"Je Wandtyp die Schichtfugen (zwischen benachbarten Schichten) als wählbaren Linienstil. Ohne Zuweisung: Haarlinie 0.02 mm.",
"resources.wallStyles.empty": "Noch keine Wandtypen.",
"resources.col.layer": "Schicht",
"resources.col.thickness": "Dicke (mm)",
"resources.col.joint": "Fuge",
"resources.joint.default": "— (Haarlinie)",
"resources.materials.hint":
"Kugel-Vorschau der eingebauten PBR-Materialbibliothek — durchsuchen und vergleichen. Zuweisen an ein Bauteil weiterhin über dessen Spalte „Material …\".",
"resources.materials.searchPlaceholder": "Material suchen …",
+9
View File
@@ -435,8 +435,17 @@ export const en: Record<TranslationKey, string> = {
"resources.tab.components": "Components",
"resources.tab.hatches": "Hatches",
"resources.tab.lines": "Lines",
"resources.tab.wallStyles": "Wall styles",
"resources.tab.materials": "Materials",
"resources.wallStyles.hint":
"Per wall type, the layer joints (between adjacent layers) as a selectable line style. Unassigned: 0.02 mm hairline.",
"resources.wallStyles.empty": "No wall types yet.",
"resources.col.layer": "Layer",
"resources.col.thickness": "Thickness (mm)",
"resources.col.joint": "Joint",
"resources.joint.default": "— (hairline)",
"resources.materials.hint":
"Sphere preview of the built-in PBR material library — browse and compare. Assign to a component still via its “Material …” column.",
"resources.materials.searchPlaceholder": "Search materials …",
+4 -1
View File
@@ -30,6 +30,8 @@ export const sampleProject: Project = {
{ id: "hatch-hair", name: "Schraffur-Haarlinie", weight: 0.02, color: "#1a1a1a", dash: null },
// Gestrichelte Haarlinie für die Dämmung (Striche quer durch die Wanddicke).
{ id: "hatch-dash", name: "Dämmungsstrich", weight: 0.02, color: "#1a1a1a", dash: [0.35, 0.3] },
// Kräftige Schichtfuge, wo zwei massive Materialien aneinanderstoßen (Wandstile).
{ id: "joint-massive", name: "Schichtfuge 0.13", weight: 0.13, color: "#1a1a1a", dash: null },
],
// Schraffuren: color = Linien-/Vollfüllfarbe des Musters; angle in Grad.
// "none" = keine Schraffur (nur Component-Füllung).
@@ -128,7 +130,8 @@ export const sampleProject: Project = {
// Schichten außen → innen; Priorität lebt jetzt am Component.
layers: [
{ componentId: "render-ext", thickness: 0.02 },
{ componentId: "insulation", thickness: 0.16 },
// Innere Fuge der Dämmung trifft auf den Backstein (massiv↔massiv) → 0.13 mm.
{ componentId: "insulation", thickness: 0.16, jointLineStyleId: "joint-massive" },
{ componentId: "brick", thickness: 0.15 },
{ componentId: "render-int", thickness: 0.015 },
],
+7
View File
@@ -128,6 +128,13 @@ export interface Layer {
componentId: string;
/** Schichtdicke in Metern. */
thickness: number;
/**
* Linienstil (LineStyle) der Schichtfuge an der INNEREN Kante DIESER Schicht —
* also der Fuge zwischen dieser und der nächst-inneren Schicht. Bei der
* innersten Schicht ungenutzt (ihre innere Kante ist der Wand-Innenumriss).
* Fehlt das Feld, wird die Standard-Haarlinie (0.02 mm) gezeichnet.
*/
jointLineStyleId?: string;
}
/** Ein Wandtyp = geordneter Schichtaufbau (außen → innen). */
+1
View File
@@ -34,6 +34,7 @@ export function ResourcesPanel() {
onPatchLineStyle: host.onPatchLineStyle,
onAddLineStyle: host.onAddLineStyle,
onDeleteLineStyle: host.onDeleteLineStyle,
onPatchWallType: host.onPatchWallType,
onImportLineStyles: host.onImportLineStyles,
onImportHatches: host.onImportHatches,
};
+3
View File
@@ -26,6 +26,7 @@ import type {
StairShape,
VerticalAnchor,
WallReferenceLine,
WallType,
} from "../model/types";
import type { SnapSettings, ToolId } from "../tools/types";
import type { Selection } from "../state/selectionInfo";
@@ -114,6 +115,8 @@ export interface PanelHostValue {
onPatchLineStyle: (id: string, patch: Partial<LineStyle>) => void;
onAddLineStyle: () => void;
onDeleteLineStyle: (id: string) => void;
/** Wandstile: immutable Änderung eines Wandtyps (Schichtfugen-Stile). */
onPatchWallType: (id: string, patch: Partial<WallType>) => void;
/** Import fertiger (id-loser) Linienstile/Schraffuren (.lin/.pat). */
onImportLineStyles: (styles: Omit<LineStyle, "id">[]) => void;
onImportHatches: (hatches: Omit<HatchStyle, "id">[]) => void;
+28
View File
@@ -114,3 +114,31 @@ describe("generatePlan — Wandecken-Gehrung", () => {
for (const p of wallPolys) expect(p.noStrokeEdges).toEqual([]);
});
});
describe("generatePlan — Schichtfugen als stilisierbare Linien", () => {
const jointLines = (proj: Project) =>
generatePlan(proj, "eg", visible, undefined, "mittel").primitives.filter(
(p): p is Extract<typeof p, { kind: "line" }> =>
p.kind === "line" && p.cls === "layer-joint",
);
it("zieht die innere Fuge als eigene Linie und übernimmt Gewicht/Farbe des LineStyle", () => {
const proj = cornerProject();
proj.walls = [proj.walls[0]]; // nur W1, zwei Schichten → genau eine innere Fuge
proj.wallTypes[0].layers[0].jointLineStyleId = "thin"; // weight 0.13, color #111
const joints = jointLines(proj);
expect(joints.length).toBe(1);
// LAYER_DETAIL_FACTOR["mittel"] == 1 → das LineStyle-Gewicht wirkt direkt.
expect(joints[0].weightMm).toBeCloseTo(0.13, 6);
expect(joints[0].color).toBe("#111");
expect(joints[0].dash ?? null).toBeNull();
});
it("fällt ohne Zuweisung auf die Haarlinie (0.02 mm) in Wandfarbe zurück", () => {
const proj = cornerProject();
proj.walls = [proj.walls[0]];
const joints = jointLines(proj);
expect(joints.length).toBe(1);
expect(joints[0].weightMm).toBeCloseTo(0.02, 6);
});
});
+37 -7
View File
@@ -80,7 +80,9 @@ const MONO_INK = "#111111";
// dpi = 96·devicePixelRatio in konstante Bildschirm-Pixel um (non-scaling-stroke),
// damit Linien beim Zoomen ihre Papiergröße behalten und der Massstab stimmt.
/** Haarlinie zwischen den Schichten (Schichtfugen) in mm — gleiche Stärke wie die Schraffur-Tinte. */
/** Default-Schichtfuge zwischen den Materialschichten in mm Haarlinie (0.02),
* gleiche Stärke wie die Schraffur-Tinte. Pro Fuge im Wandstil überschreibbar
* (z. B. 0.13, wenn zwei massive Materialien aufeinandertreffen). */
const LAYER_LINE_MM = 0.02;
/** Haarlinie für Tür-/Fenstersymbole (Flügel, Bogen, Rahmen, Glas) in mm. */
const SYMBOL_HAIRLINE_MM = 0.02;
@@ -905,10 +907,12 @@ function addWallPoche(
// mittel/fein: Schichten von außen (-T/2) nach innen (+T/2) stapeln. Jede
// Schicht löst Bauteil (Füllfarbe) + Schraffur (Muster/Maßstab/Winkel/Farbe)
// auf; die Schichtfugen werden dünn gestrichen.
// auf. Die Bänder tragen NUR Füllung + Schraffur (kein eigener Umriss); jede
// innere Schichtfuge wird als EIGENSTÄNDIGE, stilisierbare Linie gezeichnet.
const bandJoinEdges = joinEdges(startCut, endCut);
let off = refOff - total / 2;
for (const layer of wt.layers) {
for (let li = 0; li < wt.layers.length; li++) {
const layer = wt.layers[li];
const comp = getComponent(project, layer.componentId);
const layerHatch = resolveHatch(project, comp.hatchId, wallAngleDeg);
// SIA-Poché: neutraler Hintergrund (weiß) statt Bauteil-Albedo, die
@@ -918,16 +922,42 @@ function addWallPoche(
kind: "polygon",
pts: clippedBand(p1, p2, off, off + layer.thickness, startCut, endCut),
fill: layerSolid ? POCHE_FILL_BLACK : POCHE_FILL_WHITE,
stroke,
strokeWidthMm: layerLineMm,
// Band ohne Umriss: keine Schichtfuge über die Bandkante, keine 45°-Naht
// am Knoten. Die Fugen kommen als separate `line`-Primitive (siehe unten).
stroke: "none",
strokeWidthMm: 0,
hatch: layerSolid ? { ...layerHatch, color: POCHE_FILL_BLACK } : layerHatch,
greyed,
// Die Diagonale am Knoten NICHT stricheln → keine 45°-Naht zwischen
// gleichfarbigen Schichten der beiden Wände.
// Für die Ecktests/Konsistenz beibehalten (bei stroke:"none" wirkungslos).
noStrokeEdges: bandJoinEdges,
wallId,
});
off += layer.thickness;
// Innere Schichtfuge (zwischen dieser und der nächst-inneren Schicht) als
// eigene Linie am kumulierten Offset `off`, mit dem Cut der Bänder geclippt.
// Stil aus layer.jointLineStyleId (Gewicht/Farbe/Strich); Rückfall = Haar-
// linie (LAYER_LINE_MM) in Wandfarbe. Das Gewicht folgt demselben Detail-
// Faktor wie die frühere Schichtfuge (`layerLineMm`), damit die Detailgrade
// sich unverändert verhalten. Die innerste Schicht hat keine innere Fuge.
if (li < wt.layers.length - 1) {
// clippedBand(p1, p2, off, off, …) liefert [edgeStart, edgeEnd, edgeEnd,
// edgeStart] → die zwei distinkten Endpunkte sind Index 0 (Start) und 1
// (Ende), beide am Cut abgeschnitten wie die Bandlängskante.
const jq = clippedBand(p1, p2, off, off, startCut, endCut);
const jls = layer.jointLineStyleId
? project.lineStyles.find((l) => l.id === layer.jointLineStyleId)
: undefined;
out.push({
kind: "line",
a: jq[0],
b: jq[1],
cls: "layer-joint",
weightMm: jls ? jls.weight * LAYER_DETAIL_FACTOR[detail] : layerLineMm,
color: jls ? jls.color : stroke,
dash: jls ? jls.dash : null,
greyed,
});
}
}
// Kräftige Wand-Umrisslinie über die volle Dicke (nur Kontur, keine Füllung),
// damit die Wand sich klar von den dünnen Schichtfugen abhebt. Die Gehrungs-
+20
View File
@@ -1971,6 +1971,26 @@ body {
line-height: 1.4;
}
/* ── Wandstile: ein Block je Wandtyp (Überschrift + Schicht-/Fugen-Tabelle) ─── */
.res-wallstyle {
display: flex;
flex-direction: column;
gap: 6px;
}
.res-wallstyle-head {
font-size: 12px;
font-weight: 600;
color: var(--text);
padding: 2px 2px 0;
}
/* Innerste Schicht: keine innere Fuge → gedämpfter Platzhalter statt Dropdown. */
.res-joint-none {
color: var(--muted);
padding-left: 4px;
}
/* Tabelle (saubere Liste, CONVENTIONS.md)
Ein CSS-Grid trägt die Spalten (gridTemplateColumns wird je Tab inline
gesetzt). Kopfzeile und Datenzeilen sind `display:contents`, damit ihre
+82 -1
View File
@@ -24,6 +24,7 @@ import type {
HatchStyle,
LineStyle,
Project,
WallType,
} from "../model/types";
import { PEN_WEIGHTS } from "../model/types";
import { parseLin } from "../io/linParser";
@@ -1129,6 +1130,77 @@ function LinesTab({
);
}
// ── Wandstile (Wall Styles) ────────────────────────────────────────────────
// Pro Wandtyp die geordneten Schichten (Bauteil + Dicke) und je INNERER Fuge
// (zwischen zwei aufeinanderfolgenden Schichten) ein LineStyle-Dropdown, das an
// layer[i].jointLineStyleId hängt (i = Schicht, deren innere Kante die Fuge ist).
// Die innerste Schicht hat keine innere Fuge (ihre Kante ist der Wand-Innenumriss).
// Spalten: Schicht | Dicke (mm) | Fuge (innere).
const WALLSTYLE_COLUMNS: ResColumn[] = [
{ titleKey: "resources.col.layer" },
{ titleKey: "resources.col.thickness", align: "right" },
{ titleKey: "resources.col.joint" },
];
const WALLSTYLE_TEMPLATE = "minmax(120px, 1fr) 72px minmax(140px, 1.2fr)";
function WallStylesTab({
project,
onPatchWallType,
}: {
project: Project;
onPatchWallType: (id: string, patch: Partial<WallType>) => void;
}) {
// Sentinel „— (Haarlinie)": kein Feld gesetzt → Standard-Haarlinie 0.02 mm.
const DEFAULT = "__default__";
const jointOptions = [
{ value: DEFAULT, label: t("resources.joint.default") },
...project.lineStyles.map((l) => ({ value: l.id, label: l.name })),
];
const setJoint = (wt: WallType, li: number, styleId: string | undefined) => {
const layers = wt.layers.map((l, i) =>
i === li ? { ...l, jointLineStyleId: styleId } : l,
);
onPatchWallType(wt.id, { layers });
};
return (
<div className="res-tab">
<div className="res-hint">{t("resources.wallStyles.hint")}</div>
{project.wallTypes.map((wt) => (
<div key={wt.id} className="res-wallstyle">
<div className="res-wallstyle-head">{wt.name}</div>
<ResTable columns={WALLSTYLE_COLUMNS} template={WALLSTYLE_TEMPLATE}>
{wt.layers.map((layer, li) => {
const comp = project.components.find((c) => c.id === layer.componentId);
const isInner = li === wt.layers.length - 1;
return (
<ResRow key={li}>
<ResCell emphasis>{comp ? comp.name : layer.componentId}</ResCell>
<ResCell align="right">{Math.round(layer.thickness * 1000)}</ResCell>
<ResCell>
{isInner ? (
<span className="res-joint-none"></span>
) : (
<SelectField
value={layer.jointLineStyleId ?? DEFAULT}
onChange={(v) => setJoint(wt, li, v === DEFAULT ? undefined : v)}
options={jointOptions}
/>
)}
</ResCell>
</ResRow>
);
})}
</ResTable>
</div>
))}
{project.wallTypes.length === 0 && (
<EmptyState text={t("resources.wallStyles.empty")} />
)}
</div>
);
}
/**
* Datei-Import-Schaltfläche (verstecktes <input type=file>). Liest die gewählte
* Datei als Text und reicht ihn nach oben. Für .lin/.pat-Import.
@@ -1377,12 +1449,13 @@ function EmptyState({ text }: { text: string }) {
// ── Manager-Schublade ──────────────────────────────────────────────────────
type TabId = "components" | "hatches" | "lines" | "materials";
type TabId = "components" | "hatches" | "lines" | "wallStyles" | "materials";
const TABS: { id: TabId; labelKey: string }[] = [
{ id: "components", labelKey: "resources.tab.components" },
{ id: "hatches", labelKey: "resources.tab.hatches" },
{ id: "lines", labelKey: "resources.tab.lines" },
{ id: "wallStyles", labelKey: "resources.tab.wallStyles" },
{ id: "materials", labelKey: "resources.tab.materials" },
];
@@ -1397,6 +1470,8 @@ export interface ResourceManagerHandlers {
onPatchLineStyle: (id: string, patch: Partial<LineStyle>) => void;
onAddLineStyle: () => void;
onDeleteLineStyle: (id: string) => void;
/** Wandstile: immutable Änderung eines Wandtyps (z. B. Schichtfugen-Stile). */
onPatchWallType: (id: string, patch: Partial<WallType>) => void;
/** Import: fügt fertige (id-lose) Linienstile/Schraffuren hinzu (.lin/.pat). */
onImportLineStyles: (styles: Omit<LineStyle, "id">[]) => void;
onImportHatches: (hatches: Omit<HatchStyle, "id">[]) => void;
@@ -1483,6 +1558,12 @@ export function ResourceManager({
onImportLineStyles={handlers.onImportLineStyles}
/>
)}
{tab === "wallStyles" && (
<WallStylesTab
project={project}
onPatchWallType={handlers.onPatchWallType}
/>
)}
{tab === "materials" && <MaterialsTab />}
</div>
</aside>