From f4e70902d0f90045cca4e75478b4e42465743b31 Mon Sep 17 00:00:00 2001 From: Karim Date: Fri, 3 Jul 2026 21:29:13 +0200 Subject: [PATCH] Raumstempel: Ausrichtung pro Zeile + Anker als Snappunkt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Jede Stempelzeile (Name, Name-2, Bodenflaeche, Nutzung) hat jetzt eine eigene Ausrichtung links/mitte/rechts, im Stempel-Editor pro Zeile waehlbar und in SVG- wie nativer Darstellung honoriert (text-anchor bzw. align). Bodenflaeche/Nutzung fallen auf mitte zurueck (bisheriges Bild). Der Stempel-Anker (stampAnchor bzw. Schwerpunkt) meldet sich zusaetzlich als Endpunkt-Snapkandidat an — gefiltert nach Geschoss und sichtbarer Kategorie; der ziehbare Griff war bereits verdrahtet. --- src/model/roomStamp.test.ts | 4 +- src/model/roomStamp.ts | 32 +++++-- src/model/types.ts | 10 ++- src/panels/RoomStampEditor.tsx | 148 ++++++++++++++++++++++++++------- src/plan/PlanView.tsx | 4 +- src/plan/generatePlan.ts | 13 ++- src/plan/toRenderScene.ts | 2 +- src/styles.css | 51 ++++++++++++ src/tools/snapping.ts | 10 ++- 9 files changed, 224 insertions(+), 50 deletions(-) diff --git a/src/model/roomStamp.test.ts b/src/model/roomStamp.test.ts index ac4cc1d..8f9a7e4 100644 --- a/src/model/roomStamp.test.ts +++ b/src/model/roomStamp.test.ts @@ -71,8 +71,8 @@ describe("roomStampExtraLines", () => { { name: "Bad", showFloorArea: true, showUsage: true, floorAreaPrefix: "BF " }, { netArea: 12.345, siaCategory: "HNF", siaLabel: "Hauptnutzfläche" }, ); - expect(lines[0]).toBe("BF 12.35 m²"); - expect(lines[1]).toBe("HNF · Hauptnutzfläche"); + expect(lines[0].text).toBe("BF 12.35 m²"); + expect(lines[1].text).toBe("HNF · Hauptnutzfläche"); }); it("blendet Zeilen aus, wenn Flags falsch", () => { diff --git a/src/model/roomStamp.ts b/src/model/roomStamp.ts index 4e42eab..b796679 100644 --- a/src/model/roomStamp.ts +++ b/src/model/roomStamp.ts @@ -7,7 +7,7 @@ import type { Room, RoomStamp } from "./types"; import type { SiaCategory } from "../geometry/roomArea"; -import type { RichTextDoc, Paragraph, TextRun } from "../text/richText"; +import type { RichTextDoc, Paragraph, TextRun, Align } from "../text/richText"; import { makeRun } from "../text/richText"; /** Grösse (pt) des Raumnamens (Zeile 1) relativ zur Stempel-Basis. */ @@ -43,10 +43,13 @@ export function roomStampToDoc(stamp: RoomStamp): RichTextDoc { } line1.push(makeRun(stamp.name, { bold: true, sizePt: NAME_PT })); - const paragraphs: Paragraph[] = [{ runs: line1 }]; + const paragraphs: Paragraph[] = [{ runs: line1, align: stamp.nameAlign }]; const line2 = (stamp.nameLine2 ?? "").trim(); if (line2) { - paragraphs.push({ runs: [makeRun(line2, { italic: true, sizePt: LINE2_PT })] }); + paragraphs.push({ + runs: [makeRun(line2, { italic: true, sizePt: LINE2_PT })], + align: stamp.line2Align, + }); } return { version: 1, paragraphs }; } @@ -58,18 +61,31 @@ export interface RoomStampContext { siaLabel: string; } +/** Eine Live-Zeile des Stempels mit Text und Ausrichtung. */ +export interface RoomStampLine { + text: string; + align: Align; +} + /** * Die Live-Zeilen unter dem Stempel: Bodenfläche (mit Präfix) wenn showFloorArea, - * Nutzung (SIA-Kürzel · Bezeichnung) wenn showUsage. + * Nutzung (SIA-Kürzel · Bezeichnung) wenn showUsage. Die Ausrichtung folgt den + * Stempel-Feldern; fehlt sie, gilt „zentriert" (Alt-Verhalten). */ -export function roomStampExtraLines(stamp: RoomStamp, ctx: RoomStampContext): string[] { - const lines: string[] = []; +export function roomStampExtraLines(stamp: RoomStamp, ctx: RoomStampContext): RoomStampLine[] { + const lines: RoomStampLine[] = []; if (stamp.showFloorArea) { const prefix = stamp.floorAreaPrefix ?? ""; - lines.push(`${prefix}${ctx.netArea.toFixed(2)} m²`); + lines.push({ + text: `${prefix}${ctx.netArea.toFixed(2)} m²`, + align: stamp.floorAreaAlign ?? "center", + }); } if (stamp.showUsage) { - lines.push(`${ctx.siaCategory} · ${ctx.siaLabel}`); + lines.push({ + text: `${ctx.siaCategory} · ${ctx.siaLabel}`, + align: stamp.usageAlign ?? "center", + }); } return lines; } diff --git a/src/model/types.ts b/src/model/types.ts index 008af69..f630c04 100644 --- a/src/model/types.ts +++ b/src/model/types.ts @@ -16,7 +16,7 @@ export type Vec2 = { x: number; y: number }; import type { SiaCategory } from "../geometry/roomArea"; export type { SiaCategory } from "../geometry/roomArea"; // Rich-Text-Dokument für den Raum-Stempel (frei editierbarer Teil). -import type { RichTextDoc } from "../text/richText"; +import type { RichTextDoc, Align } from "../text/richText"; export type { RichTextDoc } from "../text/richText"; // ── Ressourcen-Bibliotheken (Vectorworks-/DOSSIER-Stil) ──────────────────── @@ -679,6 +679,14 @@ export interface RoomStamp { floorAreaPrefix?: string; /** Nutzung (HNF/… · Bezeichnung) anzeigen (Live-Zeile). */ showUsage: boolean; + /** Ausrichtung der Namenszeile (Zeile 1). Fehlt sie, gilt „links". */ + nameAlign?: Align; + /** Ausrichtung der zweiten Namenszeile. Fehlt sie, gilt „links". */ + line2Align?: Align; + /** Ausrichtung der Bodenflächen-Zeile. Fehlt sie, gilt „zentriert" (Alt-Verhalten). */ + floorAreaAlign?: Align; + /** Ausrichtung der Nutzungs-Zeile. Fehlt sie, gilt „zentriert" (Alt-Verhalten). */ + usageAlign?: Align; } /** Eine Tür, gehostet in einer Wand. Ihr Geschoss ergibt sich aus der Wand. */ diff --git a/src/panels/RoomStampEditor.tsx b/src/panels/RoomStampEditor.tsx index 57f51ba..dacecf5 100644 --- a/src/panels/RoomStampEditor.tsx +++ b/src/panels/RoomStampEditor.tsx @@ -1,5 +1,6 @@ // Schwebender Dialog zum Bearbeiten des strukturierten Raum-Stempels (Feldmodell): -// Raumnummer, Raumname (+ Zeile 2), Bodenfläche (mit Präfix) und Nutzung. Ersetzt +// Raumnummer, Raumname (+ Zeile 2), Bodenfläche (mit Präfix) und Nutzung. Jede +// Zeile trägt zusätzlich eine eigene Ausrichtung (links/mitte/rechts). Ersetzt // für neue Räume den freien Text-Editor. Kontrolliert (value/onApply/onCancel); // arbeitet auf einem lokalen Entwurf, erst „Übernehmen" schreibt zurück. // Bezeichner englisch, UI-Text über t(). @@ -10,6 +11,7 @@ import { useState } from "react"; import { t } from "../i18n"; import type { RoomStamp } from "../model/types"; +import type { Align } from "../text/richText"; export interface RoomStampEditorProps { /** Ausgangsstempel (wird beim Öffnen kopiert; Editor arbeitet lokal). */ @@ -20,11 +22,60 @@ export interface RoomStampEditorProps { onCancel: () => void; } +/** Die drei Ausrichtungen mit Icon und Beschriftung (für die Umschalter). */ +const ALIGN_CELLS: { align: Align; icon: string; titleKey: string }[] = [ + { align: "left", icon: "format_align_left", titleKey: "text.align.left" }, + { align: "center", icon: "format_align_center", titleKey: "text.align.center" }, + { align: "right", icon: "format_align_right", titleKey: "text.align.right" }, +]; + +/** + * Drei-Wege-Umschalter für die Zeilen-Ausrichtung. `value` ist der aktuelle Wert + * (fehlt er, gilt `fallback`); `onChange` liefert die gewählte Ausrichtung. + */ +function AlignToggle({ + value, + fallback, + onChange, + disabled, + label, +}: { + value: Align | undefined; + fallback: Align; + onChange: (align: Align) => void; + disabled?: boolean; + label: string; +}) { + const current = value ?? fallback; + return ( +
+ {ALIGN_CELLS.map((cell) => ( + + ))} +
+ ); +} + export function RoomStampEditor({ value, onApply, onCancel }: RoomStampEditorProps) { const [draft, setDraft] = useState(value); const patch = (p: Partial) => setDraft((d) => ({ ...d, ...p })); + const hasLine2 = (draft.nameLine2 ?? "").trim().length > 0; + return (
-
diff --git a/src/plan/PlanView.tsx b/src/plan/PlanView.tsx index 57139b4..c986d00 100644 --- a/src/plan/PlanView.tsx +++ b/src/plan/PlanView.tsx @@ -2814,8 +2814,8 @@ function renderPrimitive( color: p.color, }); const extra: SvgLine[] = p.extraLines.map((line) => ({ - align: "center" as const, - tspans: [{ text: line, fontSize: baseFs * 0.8, fill: p.color }], + align: line.align, + tspans: [{ text: line.text, fontSize: baseFs * 0.8, fill: p.color }], })); const lines = [...docLines, ...extra]; if (lines.length === 0) return null; diff --git a/src/plan/generatePlan.ts b/src/plan/generatePlan.ts index 7160a11..0a482aa 100644 --- a/src/plan/generatePlan.ts +++ b/src/plan/generatePlan.ts @@ -34,6 +34,7 @@ import { evaluateRoom, siaLabel } from "../geometry/roomArea"; import type { RichTextDoc } from "../text/richText"; import { docFromText } from "../text/richText"; import { roomStampToDoc, roomStampExtraLines } from "../model/roomStamp"; +import type { RoomStampLine } from "../model/roomStamp"; import { stairVerticalExtent, wallReferenceOffset } from "../model/wall"; import { stairGeometry, stairCut } from "../geometry/stair"; import { doorSymbol, openingInterval, windowSymbol } from "../geometry/opening"; @@ -229,9 +230,10 @@ export type Primitive = doc: RichTextDoc; /** * LIVE-Zusatzzeilen (z. B. Fläche „24.30 m²" + SIA-Tag), UNTER dem Doc - * gezeichnet — unabhängig vom editierten Text, stets aktuell. + * gezeichnet — unabhängig vom editierten Text, stets aktuell. Jede Zeile + * trägt ihre eigene Ausrichtung (`align`). */ - extraLines: string[]; + extraLines: RoomStampLine[]; /** Basis-Schriftgrösse in PUNKT (für Runs ohne eigene Grösse + Zusatzzeilen). */ basePt: number; /** Textfarbe (hex) als Default. */ @@ -1309,8 +1311,11 @@ function addRoomArea( siaLabel: siaLabel(room.siaCategory), }) : [ - `${res.netArea.toFixed(2)} m²`, - `${room.siaCategory} · ${siaLabel(room.siaCategory)}`, + { text: `${res.netArea.toFixed(2)} m²`, align: "center" as const }, + { + text: `${room.siaCategory} · ${siaLabel(room.siaCategory)}`, + align: "center" as const, + }, ]; out.push({ kind: "text", diff --git a/src/plan/toRenderScene.ts b/src/plan/toRenderScene.ts index 6f4e202..2e53c6d 100644 --- a/src/plan/toRenderScene.ts +++ b/src/plan/toRenderScene.ts @@ -483,7 +483,7 @@ export function planToRenderScene(plan: Plan, paperScaleN: number = STAMP_DEFAUL }); } for (const line of p.extraLines) { - flat.push({ content: line, sizeMm: baseMm * 0.8, color: defCol, align: "center" }); + flat.push({ content: line.text, sizeMm: baseMm * 0.8, color: defCol, align: line.align }); } if (flat.length > 0) { const baseH = baseMm * mmToM; // Basisgröße in Modell-Metern, beim gewählten Massstab diff --git a/src/styles.css b/src/styles.css index 3bc902c..91e90f2 100644 --- a/src/styles.css +++ b/src/styles.css @@ -4518,6 +4518,57 @@ body { color: var(--ink); cursor: pointer; } +/* Zeile mit Feld/Häkchen links und Ausrichtungs-Umschalter rechts. */ +.roomstamp-row { + display: flex; + align-items: flex-end; + gap: 8px; +} +.roomstamp-row .roomstamp-field { + flex: 1; +} +.roomstamp-row .roomstamp-check { + flex: 1; +} +/* Drei-Wege-Umschalter (links/mitte/rechts) je Stempelzeile. */ +.roomstamp-align { + display: inline-flex; + border: 1px solid var(--border); + border-radius: 6px; + overflow: hidden; + flex: none; +} +.roomstamp-align-btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 28px; + height: 28px; + border: none; + border-left: 1px solid var(--border); + background: transparent; + color: var(--ink-2); + cursor: pointer; + transition: background 0.14s, color 0.14s; +} +.roomstamp-align-btn:first-child { + border-left: none; +} +.roomstamp-align-btn:hover:not(:disabled) { + background: var(--accent-dim); + color: var(--accent); +} +.roomstamp-align-btn.active { + background: var(--accent); + color: #fff; +} +.roomstamp-align-btn:disabled { + opacity: 0.4; + cursor: default; +} +.roomstamp-align .material-symbols-outlined { + font-size: 18px; +} /* Flächenbilanz-Panel (SIA 416): Kopf, Raumliste, Summentabelle, Export. */ .balance-panel { diff --git a/src/tools/snapping.ts b/src/tools/snapping.ts index 9207c2e..41ca434 100644 --- a/src/tools/snapping.ts +++ b/src/tools/snapping.ts @@ -8,6 +8,7 @@ import type { Project, Vec2 } from "../model/types"; import { lineIntersect } from "../model/geometry"; +import { centroid as roomCentroid } from "../geometry/roomArea"; import type { SnapKind, SnapResult, SnapSettings } from "./types"; const dist = (a: Vec2, b: Vec2): number => Math.hypot(a.x - b.x, a.y - b.y); @@ -109,13 +110,20 @@ export function computeSnap(input: SnapInput): SnapResult | null { ? collectSegments(input) : []; - // endpoint (Strecken-Endpunkte + Draft-Knoten). + // endpoint (Strecken-Endpunkte + Draft-Knoten + Raum-Stempel-Anker). if (settings.endpoint) { for (const [a, b] of segs) { consider(a, "endpoint"); consider(b, "endpoint"); } for (const p of input.draftPoints) consider(p, "endpoint"); + // Raum-Stempel-Anker (fixer Anker, sonst Flächenschwerpunkt) des Geschosses. + for (const room of input.project.rooms ?? []) { + if (room.floorId !== input.levelId) continue; + if (!input.visibleCodes.has(room.categoryCode)) continue; + if (room.boundary.length < 3) continue; + consider(room.stampAnchor ?? roomCentroid(room.boundary), "endpoint"); + } } // midpoint. if (settings.midpoint) {