From f2d36632cbc7d825e7fab9cff400828a026d3920 Mon Sep 17 00:00:00 2001 From: Karim Date: Fri, 3 Jul 2026 19:47:03 +0200 Subject: [PATCH] =?UTF-8?q?SIA-Wandschraffuren:=20Beton-Kreuz,=20Backstein?= =?UTF-8?q?/D=C3=A4mmung=20wandrelativ,=20hairline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HatchStyle.relativeToWall: der Wandwinkel (aus wall.start/end) wird in addWallPoche und resolveWallSectionStyle auf hatch.angle addiert (Vorzeichen zur SVG-CW-Konvention passend), sodass die Schraffur der Wandachse folgt. - sampleProject: sia-concrete (crosshatch 45° absolut), sia-brick (diagonal wandrelativ), sia-insulation (diagonal 90° wandrelativ, solide Haarlinie); Komponenten Beton/Backstein/Dämmung darauf umgehängt. Dichte Muster (scale 0.5/0.5/0.45), hairline (Strichgewicht 0.05 → 0.6px-Boden). - Poché-Hinterlegung: mehrschichtige Wände (und einschichtige) weiss, solid schwarz (Fill + Schraffurfarbe), konsistent in SVG- und glPlan-Fill; Mono behält Schwarz für solid. Gilt auch für den Schnitt-Cut. - ResourceManager: Wandbezug-Schalter im Hatch-Manager. exportDxf: Dash- Splitting für gestrichelte Schraffuren. --- src/export/exportDxf.ts | 45 ++++++++++++++++- src/i18n/de.ts | 2 + src/i18n/en.ts | 2 + src/model/sampleProject.ts | 50 ++++++++++++++++-- src/model/types.ts | 8 +++ src/plan/generatePlan.ts | 101 +++++++++++++++++++++++++++---------- src/ui/ResourceManager.tsx | 11 +++- 7 files changed, 187 insertions(+), 32 deletions(-) diff --git a/src/export/exportDxf.ts b/src/export/exportDxf.ts index db5f81f..64e3f76 100644 --- a/src/export/exportDxf.ts +++ b/src/export/exportDxf.ts @@ -126,8 +126,13 @@ function emitPrimitive( p.hatch.pattern !== "none" && p.hatch.pattern !== "solid" ) { + const cycle = dashCycleMeters(p.hatch.dash); for (const seg of hatchSegments(pts, p.hatch)) { - dxf.addLine(LAYER_HATCH, seg.a, seg.b); + if (cycle) { + for (const d of dashSeg(seg, cycle)) dxf.addLine(LAYER_HATCH, d.a, d.b); + } else { + dxf.addLine(LAYER_HATCH, seg.a, seg.b); + } } } break; @@ -174,6 +179,44 @@ interface Seg { b: Vec2; } +// Strichmuster (`dash`, mm-Papier) → Zyklus in WELT-Metern. Gleiche Umrechnung +// wie der Bildschirm/GL-Pfad: 1 mm ≙ (1/0.13) Kachel-Einheiten, 1 Einheit = 1/90 m. +// `null` = durchgezogen (keine Zerlegung). Ungerade Musterlängen werden verdoppelt. +const DASH_MM_TO_M = (1 / 0.13) * (1 / 90); +function dashCycleMeters(dash: number[] | null): number[] | null { + if (!dash || dash.length === 0) return null; + const pat = dash.map((d) => Math.max(0, d) * DASH_MM_TO_M).filter((d) => d > 0); + if (pat.length === 0) return null; + return pat.length % 2 === 0 ? pat : pat.concat(pat); +} + +// Zerlegt EINE Schraffurlinie in ihre durchgezogenen Teilstücke gemäß `cycle` +// ([an, aus, …], Meter). Die Phase startet je Linie neu — visuell identisch zum +// SVG-``, das das Muster je Kachellinie zurücksetzt. +function dashSeg(seg: Seg, cycle: number[]): Seg[] { + const out: Seg[] = []; + const len = Math.hypot(seg.b.x - seg.a.x, seg.b.y - seg.a.y); + if (len < 1e-9) return out; + const ux = (seg.b.x - seg.a.x) / len; + const uy = (seg.b.y - seg.a.y) / len; + let pos = 0; + let idx = 0; + let on = true; + while (pos < len - 1e-9) { + const step = Math.min(cycle[idx % cycle.length], len - pos); + if (on && step > 1e-9) { + out.push({ + a: { x: seg.a.x + ux * pos, y: seg.a.y + uy * pos }, + b: { x: seg.a.x + ux * (pos + step), y: seg.a.y + uy * (pos + step) }, + }); + } + pos += step; + idx++; + on = !on; + } + return out; +} + function hatchSegments( poly: Vec2[], hatch: Extract["hatch"], diff --git a/src/i18n/de.ts b/src/i18n/de.ts index a4e73d8..c725aae 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -510,6 +510,8 @@ export const de = { "resources.col.pattern": "Muster", "resources.col.scale": "Maßstab", "resources.col.angle": "Winkel °", + "resources.col.relativeToWall": "Wandbezug", + "resources.col.relativeToWall.hint": "Winkel relativ zur Wandachse — das Muster dreht mit der Wand mit", "resources.col.lineStyle": "Linienstil", "resources.lineStyle.none": "— ohne —", "resources.delete.hatch": "Schraffur „{name}\" löschen", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 0126070..a5e6c58 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -505,6 +505,8 @@ export const en: Record = { "resources.col.pattern": "Pattern", "resources.col.scale": "Scale", "resources.col.angle": "Angle °", + "resources.col.relativeToWall": "Wall-rel.", + "resources.col.relativeToWall.hint": "Angle relative to the wall axis — the pattern rotates with the wall", "resources.col.lineStyle": "Line style", "resources.lineStyle.none": "— none —", "resources.delete.hatch": "Delete hatch “{name}”", diff --git a/src/model/sampleProject.ts b/src/model/sampleProject.ts index 6b37153..9b4e466 100644 --- a/src/model/sampleProject.ts +++ b/src/model/sampleProject.ts @@ -24,6 +24,12 @@ export const sampleProject: Project = { { id: "thick", name: "Stark 0.5", weight: 0.5, color: "#0a0a0a", dash: null }, // Schraffurlinie = schwarze Haarlinie (Grundeinstellung für alle Schraffuren). { id: "hatch-line", name: "Schraffurlinie", weight: 0.13, color: "#1a1a1a", dash: null }, + // Feinste Haarlinie für die SIA-Schnittschraffuren (Beton/Backstein/Dämmung): + // Gewicht unter der Bildschirm-Untergrenze, damit die Musterlinien als dünnste + // Haarlinie (Stroke-Minimum) rendern. + { id: "hatch-hair", name: "Schraffur-Haarlinie", weight: 0.05, color: "#1a1a1a", dash: null }, + // Gestrichelte Haarlinie für die Dämmung (Striche quer durch die Wanddicke). + { id: "hatch-dash", name: "Dämmungsstrich", weight: 0.05, color: "#1a1a1a", dash: [0.35, 0.3] }, ], // Schraffuren: color = Linien-/Vollfüllfarbe des Musters; angle in Grad. // "none" = keine Schraffur (nur Component-Füllung). @@ -67,17 +73,53 @@ export const sampleProject: Project = { color: "#9aa0a6", lineStyleId: "thin", }, + // ── SIA-Schnittschraffuren (Haarlinie) ────────────────────────────────── + // Dichte Teilung (scale < 1; Muster-Kachel = 8·scale) für den SIA-Look. + // Beton: Kreuzschraffur, absolut 45° (bildschirmfest, NICHT wandbezogen). + { + id: "sia-concrete", + name: "Beton (Kreuz)", + pattern: "crosshatch", + scale: 0.5, + angle: 45, + color: "#1a1a1a", + lineStyleId: "hatch-hair", + }, + // Backstein: Diagonale, 45° RELATIV zur Wandachse (dreht mit der Wand mit). + { + id: "sia-brick", + name: "Backstein (Diagonal)", + pattern: "diagonal", + scale: 0.5, + angle: 45, + relativeToWall: true, + color: "#1a1a1a", + lineStyleId: "hatch-hair", + }, + // Dämmung: DURCHGEZOGENE (nicht gestrichelte) Linien QUER durch die Wanddicke + // (senkrecht zur Wandachse), wandbezogen. Basiswinkel 0 = Musterlinie steht + // senkrecht zur Wandrichtung; durchgezogene Haarlinie, dichte Teilung. + { + id: "sia-insulation", + name: "Dämmung (Strich)", + pattern: "diagonal", + scale: 0.45, + angle: 0, + relativeToWall: true, + color: "#1a1a1a", + lineStyleId: "hatch-hair", + }, ], // Bauteil-Materialien: color = Poché-Füllung im Grundriss UND 3D-Diffusfarbe; // joinPriority höher = läuft am Stoß durch (für spätere T-Stöße). components: [ { id: "render-ext", name: "Aussenputz", color: "#d8d2c7", hatchId: "none", joinPriority: 10 }, - // Weißer Grund unter der schwarzen Dämmungs-Haarlinie (Schraffur-Grundeinstellung). - { id: "insulation", name: "Wärmedämmung", color: "#ffffff", hatchId: "insulation", joinPriority: 20 }, - { id: "brick", name: "Backstein", color: "#8c5544", hatchId: "none", joinPriority: 50 }, + // Weißer Grund unter den durchgezogenen Dämmungs-Haarlinien (quer zur Wand). + { id: "insulation", name: "Wärmedämmung", color: "#ffffff", hatchId: "sia-insulation", joinPriority: 20 }, + { id: "brick", name: "Backstein", color: "#8c5544", hatchId: "sia-brick", joinPriority: 50 }, { id: "render-int", name: "Innenputz", color: "#efece6", hatchId: "none", joinPriority: 10 }, // Für spätere T-Stöße: Beton als durchlaufender Backbone (höchste Priorität). - { id: "concrete", name: "Beton", color: "#9aa0a6", hatchId: "solid-concrete", joinPriority: 100 }, + { id: "concrete", name: "Beton", color: "#9aa0a6", hatchId: "sia-concrete", joinPriority: 100 }, ], wallTypes: [ { diff --git a/src/model/types.ts b/src/model/types.ts index 7071b14..02e3d95 100644 --- a/src/model/types.ts +++ b/src/model/types.ts @@ -55,6 +55,14 @@ export interface HatchStyle { scale: number; /** Drehung des Musters in Grad. */ angle: number; + /** + * Wenn `true`, ist `angle` NICHT bildschirmfest, sondern relativ zur Achse der + * schraffierten Wand: das Muster dreht mit der Wandorientierung mit (z. B. eine + * Diagonale, die stets 45° zur Wand steht, oder Dämmungsstriche quer durch die + * Wanddicke). Nur die Wand-Poché wertet dies aus; ohne Wandkontext (z. B. Decke) + * degradiert es zu einem absoluten `angle`. + */ + relativeToWall?: boolean; /** * Farbe der Musterlinien bzw. der Vollfüllung (`pattern==="solid"`). Bei * `pattern==="none"` ungenutzt. diff --git a/src/plan/generatePlan.ts b/src/plan/generatePlan.ts index 9d1f2e9..9cfdd2a 100644 --- a/src/plan/generatePlan.ts +++ b/src/plan/generatePlan.ts @@ -242,19 +242,40 @@ export type Primitive = * {@link HatchRender} auf. Der Linienstil der Musterlinien wird, falls gesetzt, * aus dem Line Manager nachgeschlagen; sonst gilt ein dünner Default. */ -function resolveHatch(project: Project, hatchId: string): HatchRender { +function resolveHatch(project: Project, hatchId: string, wallAngleDeg?: number): HatchRender { const h = getHatch(project, hatchId); const ls = h.lineStyleId ? getLineStyle(project, h.lineStyleId) : null; + // Wandbezogener Winkel: `HatchRender.angle` wird von allen Renderern in der + // SVG-Konvention (im Uhrzeigersinn, Bildschirm-Y nach unten) verstanden — der + // Modell-Renderer negiert ihn in `toModelAngleRad`. Die Wandachse `wallAngleDeg` + // ist dagegen ein MODELL-Winkel (atan2 der Richtung, gegen den Uhrzeigersinn, + // Y nach oben). Wegen der Y-Spiegelung entspricht die Wandrichtung am Bildschirm + // dem Winkel −wallAngleDeg; das Muster folgt der Wand also, indem der + // Wandwinkel SUBTRAHIERT wird. Ohne Wandkontext (Decke) bleibt `angle` absolut. + const angle = + h.relativeToWall && wallAngleDeg != null ? h.angle - wallAngleDeg : h.angle; return { pattern: h.pattern, scale: h.scale, - angle: h.angle, + angle, color: h.color, lineWeight: ls?.weight ?? 0.13, dash: ls?.dash ?? null, }; } +// ── SIA-Poché-Füllung ──────────────────────────────────────────────────── +// Die 2D-Poché einer (auch mehrschichtigen) Wand trägt NICHT die rohe Bauteil- +// Albedo (3D-Materialfarbe), sondern einen neutralen Hintergrund, auf dem die +// Schraffur-Musterlinien liegen: Vollmuster ("solid") = reine SCHWARZE Poché +// (kein Linienmuster), jedes andere Muster (inkl. "none") = WEISSER Hintergrund. +const POCHE_FILL_WHITE = "#ffffff"; +const POCHE_FILL_BLACK = "#000000"; +/** Neutrale Poché-Füllfarbe für ein Muster: "solid" → schwarz, sonst weiß. */ +function pocheFill(pattern: HatchPattern): string { + return pattern === "solid" ? POCHE_FILL_BLACK : POCHE_FILL_WHITE; +} + /** Füllfarbe + Schraffur eines aufgelösten Bauteils, fertig für eine Cut-Poché. */ export interface SectionCutStyle { fill: string; @@ -263,8 +284,8 @@ export interface SectionCutStyle { /** * Löst das tragende Bauteil (höchste `joinPriority`) einer Wand auf — dieselbe - * Regel wie {@link backboneColor} für die grobe Grundriss-Poché — und liefert - * dessen Füllfarbe + echte Schraffur für die Schnitt-Poché. `null`, wenn Wandtyp + * Regel wie {@link backbonePocheFill} für die grobe Grundriss-Poché — und liefert + * dessen Poché-Füllung + echte Schraffur für die Schnitt-Poché. `null`, wenn Wandtyp * oder Bauteil nicht auflösbar sind (z. B. verwaistes `wallTypeId`); der * Aufrufer fällt dann auf die generische Schnitt-Schraffur zurück. */ @@ -277,7 +298,15 @@ export function resolveWallSectionStyle(project: Project, wall: Wall): SectionCu if (!best || comp.joinPriority > best.joinPriority) best = comp; } if (!best) return null; - return { fill: best.color, hatch: resolveHatch(project, best.hatchId) }; + const wallAngleDeg = + (Math.atan2(wall.end.y - wall.start.y, wall.end.x - wall.start.x) * 180) / Math.PI; + const hatch = resolveHatch(project, best.hatchId, wallAngleDeg); + // Poché-Hintergrund neutral (weiß) statt Bauteil-Albedo; Vollmuster → schwarz. + const solid = hatch.pattern === "solid"; + return { + fill: solid ? POCHE_FILL_BLACK : POCHE_FILL_WHITE, + hatch: solid ? { ...hatch, color: POCHE_FILL_BLACK } : hatch, + }; } catch { return null; } @@ -528,17 +557,20 @@ export function generateSectionPlan(output: SectionOutput, mono = false): Plan { }); const fill = cp.fill ?? rgbToHex(cp.color); const hatch = cp.hatch ?? SECTION_HATCH; - // Mono: reines Weiss statt Bauteilfarbe, Schraffurfarbe → Tinte (nur - // Umriss + Schraffur-Musterlinien tragen; wie der toMono()-Postpass für - // die normale Poché, hier direkt inline, da Cut-Polygone kein "solid" - // führen). + // Mono: neutrale Poché — Weiss statt Bauteilfarbe, Schraffur-Musterlinien → + // Tinte. Eine schwarze SIA-Vollpoché ("solid") BLEIBT jedoch schwarz + // (Schwarz bleibt Schwarz, Weiss bleibt Weiss). + const solid = hatch.pattern === "solid"; + const monoFill = solid ? POCHE_FILL_BLACK : "#ffffff"; + const monoHatch = + solid ? hatch : hatch.pattern !== "none" ? { ...hatch, color: SECTION_INK } : hatch; primitives.push({ kind: "polygon", pts, - fill: mono ? "#ffffff" : fill, + fill: mono ? monoFill : fill, stroke: SECTION_INK, strokeWidthMm: SECTION_CUT_OUTLINE_MM, - hatch: mono && hatch.pattern !== "none" ? { ...hatch, color: SECTION_INK } : hatch, + hatch: mono ? monoHatch : hatch, }); } @@ -619,14 +651,16 @@ function addContextContours(out: Primitive[], project: Project): void { function toMono(primitives: Primitive[]): void { for (const p of primitives) { if (p.kind === "polygon") { - // Vollfüllung „solid" deckte die Poché farbig — in mono auf Weiss setzen, - // damit nur der Umriss trägt. Linien-/Kreuzschraffur bleibt, aber in Tinte. + // SIA-Poché in mono: Vollmuster „solid" ist eine SCHWARZE Poché und bleibt + // schwarz (Schwarz bleibt Schwarz); jede andere Füllung wird weiß, die + // Linien-/Kreuzschraffur bleibt sichtbar, aber in Tinte. if (p.hatch.pattern === "solid") { - p.hatch = { ...p.hatch, color: "#ffffff" }; - } else if (p.hatch.pattern !== "none") { - p.hatch = { ...p.hatch, color: MONO_INK }; + p.hatch = { ...p.hatch, color: POCHE_FILL_BLACK }; + p.fill = p.fill === "none" ? "none" : POCHE_FILL_BLACK; + } else { + if (p.hatch.pattern !== "none") p.hatch = { ...p.hatch, color: MONO_INK }; + p.fill = p.fill === "none" ? "none" : "#ffffff"; } - p.fill = p.fill === "none" ? "none" : "#ffffff"; p.stroke = MONO_INK; } else if (p.kind === "line") { p.color = MONO_INK; @@ -801,6 +835,11 @@ function addWallPoche( // GANZE Wand (alle Bänder) markiert und die PlanView die wallId nach oben // melden kann. const wallId = wall.id; + // Achswinkel der Wand (Modell, Grad) — treibt wandbezogene Schraffuren + // ({@link resolveHatch}), damit z. B. eine Diagonale oder die Dämmungsstriche + // mit der Wandorientierung mitdrehen. + const wallAngleDeg = + (Math.atan2(wall.end.y - wall.start.y, wall.end.x - wall.start.x) * 180) / Math.PI; // Strich-/Umrandungsfarbe der Wand: optionale Übersteuerung (wall.color), // sonst der Default-Poché-Strich. Betrifft NUR die Linienfarbe (Umriss + // Schichtfugen), nicht die Schicht-Füllfarben/Schraffuren. @@ -821,8 +860,9 @@ function addWallPoche( const outlineMm = wallLwMm * OUTLINE_DETAIL_FACTOR[detail]; const layerLineMm = LAYER_LINE_MM * LAYER_DETAIL_FACTOR[detail]; - // Vereinfachte Füllung (grob): Farbe des tragenden Bauteils (höchste Priorität). - const simpleFill = backboneColor(project, wt); + // Vereinfachte Füllung (grob): neutrale SIA-Poché des tragenden Bauteils + // (höchste Priorität) — weiß, bzw. schwarz falls dessen Muster "solid" ist. + const simpleFill = backbonePocheFill(project, wt); // Kanten-Indizes der inneren Gehrungs-Stirnflächen (die Diagonalen am Knoten). // clippedBand liefert [A.start, A.end, B.end, B.start] → Kante 1 (A.end→B.end) @@ -868,13 +908,17 @@ function addWallPoche( let off = refOff - total / 2; for (const layer of wt.layers) { const comp = getComponent(project, layer.componentId); + const layerHatch = resolveHatch(project, comp.hatchId, wallAngleDeg); + // SIA-Poché: neutraler Hintergrund (weiß) statt Bauteil-Albedo, die + // Schichtschraffur liegt darüber; Vollmuster ("solid") = schwarze Poché. + const layerSolid = layerHatch.pattern === "solid"; out.push({ kind: "polygon", pts: clippedBand(p1, p2, off, off + layer.thickness, startCut, endCut), - fill: comp.color, + fill: layerSolid ? POCHE_FILL_BLACK : POCHE_FILL_WHITE, stroke, strokeWidthMm: layerLineMm, - hatch: resolveHatch(project, comp.hatchId), + 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. @@ -900,15 +944,20 @@ function addWallPoche( } } -/** Farbe des tragenden Bauteils (höchste joinPriority) für die grobe Poché. */ -function backboneColor(project: Project, wt: Project["wallTypes"][number]): string { - let best: { color: string; prio: number } | null = null; +/** + * Neutrale SIA-Poché-Füllung des tragenden Bauteils (höchste joinPriority) für + * die grobe Sammelfläche: weiß, bzw. schwarz falls dessen Schraffur "solid" ist. + * (Ersetzt die frühere rohe Bauteil-Albedo — im Plan zählt die Poché, nicht die + * 3D-Materialfarbe.) + */ +function backbonePocheFill(project: Project, wt: Project["wallTypes"][number]): string { + let best: { pattern: HatchPattern; prio: number } | null = null; for (const layer of wt.layers) { const comp = getComponent(project, layer.componentId); if (!best || comp.joinPriority > best.prio) - best = { color: comp.color, prio: comp.joinPriority }; + best = { pattern: getHatch(project, comp.hatchId).pattern, prio: comp.joinPriority }; } - return best?.color ?? "#9aa0a6"; + return best ? pocheFill(best.pattern) : POCHE_FILL_WHITE; } /** diff --git a/src/ui/ResourceManager.tsx b/src/ui/ResourceManager.tsx index bd61696..55d23c2 100644 --- a/src/ui/ResourceManager.tsx +++ b/src/ui/ResourceManager.tsx @@ -860,12 +860,13 @@ const HATCH_COLUMNS: ResColumn[] = [ { 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 auto minmax(120px, 0.9fr) 36px"; + "minmax(110px, 1fr) minmax(120px, 0.9fr) 64px 60px 64px auto minmax(120px, 0.9fr) 36px"; function HatchRow({ hatch, @@ -918,6 +919,14 @@ function HatchRow({ step={5} /> + + onPatch({ relativeToWall: e.target.checked })} + title={t("resources.col.relativeToWall.hint")} + /> + onPatch({ color })} />