From bd80eb1c59947be1730e8a2fbe1a0bb9714bf8b1 Mon Sep 17 00:00:00 2001 From: Karim Date: Fri, 3 Jul 2026 20:55:07 +0200 Subject: [PATCH] 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. --- src/App.tsx | 11 ++++ src/i18n/de.ts | 9 +++ src/i18n/en.ts | 9 +++ src/model/sampleProject.ts | 5 +- src/model/types.ts | 7 +++ src/panels/ResourcesPanel.tsx | 1 + src/panels/host.ts | 3 + src/plan/generatePlan.corner.test.ts | 28 ++++++++++ src/plan/generatePlan.ts | 44 ++++++++++++--- src/styles.css | 20 +++++++ src/ui/ResourceManager.tsx | 83 +++++++++++++++++++++++++++- 11 files changed, 211 insertions(+), 9 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 05c6ed8..a05f5ce 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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) => + 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, diff --git a/src/i18n/de.ts b/src/i18n/de.ts index c725aae..a1cebbc 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -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 …", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index a5e6c58..604d84a 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -435,8 +435,17 @@ export const en: Record = { "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 …", diff --git a/src/model/sampleProject.ts b/src/model/sampleProject.ts index 9890c09..fbc6685 100644 --- a/src/model/sampleProject.ts +++ b/src/model/sampleProject.ts @@ -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 }, ], diff --git a/src/model/types.ts b/src/model/types.ts index 02e3d95..008af69 100644 --- a/src/model/types.ts +++ b/src/model/types.ts @@ -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). */ diff --git a/src/panels/ResourcesPanel.tsx b/src/panels/ResourcesPanel.tsx index a810bd0..e73ec76 100644 --- a/src/panels/ResourcesPanel.tsx +++ b/src/panels/ResourcesPanel.tsx @@ -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, }; diff --git a/src/panels/host.ts b/src/panels/host.ts index b5e2ef0..07e3eae 100644 --- a/src/panels/host.ts +++ b/src/panels/host.ts @@ -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) => void; onAddLineStyle: () => void; onDeleteLineStyle: (id: string) => void; + /** Wandstile: immutable Änderung eines Wandtyps (Schichtfugen-Stile). */ + onPatchWallType: (id: string, patch: Partial) => void; /** Import fertiger (id-loser) Linienstile/Schraffuren (.lin/.pat). */ onImportLineStyles: (styles: Omit[]) => void; onImportHatches: (hatches: Omit[]) => void; diff --git a/src/plan/generatePlan.corner.test.ts b/src/plan/generatePlan.corner.test.ts index a39fee9..bf91ed5 100644 --- a/src/plan/generatePlan.corner.test.ts +++ b/src/plan/generatePlan.corner.test.ts @@ -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 => + 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); + }); +}); diff --git a/src/plan/generatePlan.ts b/src/plan/generatePlan.ts index 3bdd618..36c1a10 100644 --- a/src/plan/generatePlan.ts +++ b/src/plan/generatePlan.ts @@ -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- diff --git a/src/styles.css b/src/styles.css index 11eae35..3bc902c 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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 diff --git a/src/ui/ResourceManager.tsx b/src/ui/ResourceManager.tsx index 55d23c2..cd71d2b 100644 --- a/src/ui/ResourceManager.tsx +++ b/src/ui/ResourceManager.tsx @@ -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) => 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 ( +
+
{t("resources.wallStyles.hint")}
+ {project.wallTypes.map((wt) => ( +
+
{wt.name}
+ + {wt.layers.map((layer, li) => { + const comp = project.components.find((c) => c.id === layer.componentId); + const isInner = li === wt.layers.length - 1; + return ( + + {comp ? comp.name : layer.componentId} + {Math.round(layer.thickness * 1000)} + + {isInner ? ( + + ) : ( + setJoint(wt, li, v === DEFAULT ? undefined : v)} + options={jointOptions} + /> + )} + + + ); + })} + +
+ ))} + {project.wallTypes.length === 0 && ( + + )} +
+ ); +} + /** * Datei-Import-Schaltfläche (verstecktes ). 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) => void; onAddLineStyle: () => void; onDeleteLineStyle: (id: string) => void; + /** Wandstile: immutable Änderung eines Wandtyps (z. B. Schichtfugen-Stile). */ + onPatchWallType: (id: string, patch: Partial) => void; /** Import: fügt fertige (id-lose) Linienstile/Schraffuren hinzu (.lin/.pat). */ onImportLineStyles: (styles: Omit[]) => void; onImportHatches: (hatches: Omit[]) => void; @@ -1483,6 +1558,12 @@ export function ResourceManager({ onImportLineStyles={handlers.onImportLineStyles} /> )} + {tab === "wallStyles" && ( + + )} {tab === "materials" && }