Raumstempel: Ausrichtung pro Zeile + Anker als Snappunkt

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.
This commit is contained in:
2026-07-03 21:29:13 +02:00
parent 6b6c18b43d
commit e173978135
9 changed files with 224 additions and 50 deletions
+2 -2
View File
@@ -71,8 +71,8 @@ describe("roomStampExtraLines", () => {
{ name: "Bad", showFloorArea: true, showUsage: true, floorAreaPrefix: "BF " }, { name: "Bad", showFloorArea: true, showUsage: true, floorAreaPrefix: "BF " },
{ netArea: 12.345, siaCategory: "HNF", siaLabel: "Hauptnutzfläche" }, { netArea: 12.345, siaCategory: "HNF", siaLabel: "Hauptnutzfläche" },
); );
expect(lines[0]).toBe("BF 12.35 m²"); expect(lines[0].text).toBe("BF 12.35 m²");
expect(lines[1]).toBe("HNF · Hauptnutzfläche"); expect(lines[1].text).toBe("HNF · Hauptnutzfläche");
}); });
it("blendet Zeilen aus, wenn Flags falsch", () => { it("blendet Zeilen aus, wenn Flags falsch", () => {
+24 -8
View File
@@ -7,7 +7,7 @@
import type { Room, RoomStamp } from "./types"; import type { Room, RoomStamp } from "./types";
import type { SiaCategory } from "../geometry/roomArea"; 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"; import { makeRun } from "../text/richText";
/** Grösse (pt) des Raumnamens (Zeile 1) relativ zur Stempel-Basis. */ /** 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 })); 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(); const line2 = (stamp.nameLine2 ?? "").trim();
if (line2) { 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 }; return { version: 1, paragraphs };
} }
@@ -58,18 +61,31 @@ export interface RoomStampContext {
siaLabel: string; 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, * 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[] { export function roomStampExtraLines(stamp: RoomStamp, ctx: RoomStampContext): RoomStampLine[] {
const lines: string[] = []; const lines: RoomStampLine[] = [];
if (stamp.showFloorArea) { if (stamp.showFloorArea) {
const prefix = stamp.floorAreaPrefix ?? ""; const prefix = stamp.floorAreaPrefix ?? "";
lines.push(`${prefix}${ctx.netArea.toFixed(2)}`); lines.push({
text: `${prefix}${ctx.netArea.toFixed(2)}`,
align: stamp.floorAreaAlign ?? "center",
});
} }
if (stamp.showUsage) { if (stamp.showUsage) {
lines.push(`${ctx.siaCategory} · ${ctx.siaLabel}`); lines.push({
text: `${ctx.siaCategory} · ${ctx.siaLabel}`,
align: stamp.usageAlign ?? "center",
});
} }
return lines; return lines;
} }
+9 -1
View File
@@ -16,7 +16,7 @@ export type Vec2 = { x: number; y: number };
import type { SiaCategory } from "../geometry/roomArea"; import type { SiaCategory } from "../geometry/roomArea";
export type { SiaCategory } from "../geometry/roomArea"; export type { SiaCategory } from "../geometry/roomArea";
// Rich-Text-Dokument für den Raum-Stempel (frei editierbarer Teil). // 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"; export type { RichTextDoc } from "../text/richText";
// ── Ressourcen-Bibliotheken (Vectorworks-/DOSSIER-Stil) ──────────────────── // ── Ressourcen-Bibliotheken (Vectorworks-/DOSSIER-Stil) ────────────────────
@@ -679,6 +679,14 @@ export interface RoomStamp {
floorAreaPrefix?: string; floorAreaPrefix?: string;
/** Nutzung (HNF/… · Bezeichnung) anzeigen (Live-Zeile). */ /** Nutzung (HNF/… · Bezeichnung) anzeigen (Live-Zeile). */
showUsage: boolean; 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. */ /** Eine Tür, gehostet in einer Wand. Ihr Geschoss ergibt sich aus der Wand. */
+87 -1
View File
@@ -1,5 +1,6 @@
// Schwebender Dialog zum Bearbeiten des strukturierten Raum-Stempels (Feldmodell): // 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); // für neue Räume den freien Text-Editor. Kontrolliert (value/onApply/onCancel);
// arbeitet auf einem lokalen Entwurf, erst „Übernehmen" schreibt zurück. // arbeitet auf einem lokalen Entwurf, erst „Übernehmen" schreibt zurück.
// Bezeichner englisch, UI-Text über t(). // Bezeichner englisch, UI-Text über t().
@@ -10,6 +11,7 @@
import { useState } from "react"; import { useState } from "react";
import { t } from "../i18n"; import { t } from "../i18n";
import type { RoomStamp } from "../model/types"; import type { RoomStamp } from "../model/types";
import type { Align } from "../text/richText";
export interface RoomStampEditorProps { export interface RoomStampEditorProps {
/** Ausgangsstempel (wird beim Öffnen kopiert; Editor arbeitet lokal). */ /** Ausgangsstempel (wird beim Öffnen kopiert; Editor arbeitet lokal). */
@@ -20,11 +22,60 @@ export interface RoomStampEditorProps {
onCancel: () => void; 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 (
<div className="roomstamp-align" role="group" aria-label={label}>
{ALIGN_CELLS.map((cell) => (
<button
key={cell.align}
type="button"
className={`roomstamp-align-btn${current === cell.align ? " active" : ""}`}
disabled={disabled}
aria-pressed={current === cell.align}
title={t(cell.titleKey)}
aria-label={t(cell.titleKey)}
onClick={() => onChange(cell.align)}
>
<span className="material-symbols-outlined" aria-hidden="true">
{cell.icon}
</span>
</button>
))}
</div>
);
}
export function RoomStampEditor({ value, onApply, onCancel }: RoomStampEditorProps) { export function RoomStampEditor({ value, onApply, onCancel }: RoomStampEditorProps) {
const [draft, setDraft] = useState<RoomStamp>(value); const [draft, setDraft] = useState<RoomStamp>(value);
const patch = (p: Partial<RoomStamp>) => setDraft((d) => ({ ...d, ...p })); const patch = (p: Partial<RoomStamp>) => setDraft((d) => ({ ...d, ...p }));
const hasLine2 = (draft.nameLine2 ?? "").trim().length > 0;
return ( return (
<div <div
className="texteditor-overlay" className="texteditor-overlay"
@@ -66,6 +117,7 @@ export function RoomStampEditor({ value, onApply, onCancel }: RoomStampEditorPro
/> />
</label> </label>
<div className="roomstamp-row">
<label className="roomstamp-field"> <label className="roomstamp-field">
<span className="roomstamp-label">{t("roomStamp.name")}</span> <span className="roomstamp-label">{t("roomStamp.name")}</span>
<input <input
@@ -75,7 +127,15 @@ export function RoomStampEditor({ value, onApply, onCancel }: RoomStampEditorPro
onChange={(e) => patch({ name: e.target.value })} onChange={(e) => patch({ name: e.target.value })}
/> />
</label> </label>
<AlignToggle
value={draft.nameAlign}
fallback="left"
label={`${t("roomStamp.name")}${t("text.group")}`}
onChange={(nameAlign) => patch({ nameAlign })}
/>
</div>
<div className="roomstamp-row">
<label className="roomstamp-field"> <label className="roomstamp-field">
<span className="roomstamp-label">{t("roomStamp.nameLine2")}</span> <span className="roomstamp-label">{t("roomStamp.nameLine2")}</span>
<input <input
@@ -85,7 +145,16 @@ export function RoomStampEditor({ value, onApply, onCancel }: RoomStampEditorPro
onChange={(e) => patch({ nameLine2: e.target.value })} onChange={(e) => patch({ nameLine2: e.target.value })}
/> />
</label> </label>
<AlignToggle
value={draft.line2Align}
fallback="left"
disabled={!hasLine2}
label={`${t("roomStamp.nameLine2")}${t("text.group")}`}
onChange={(line2Align) => patch({ line2Align })}
/>
</div>
<div className="roomstamp-row">
<label className="roomstamp-check"> <label className="roomstamp-check">
<input <input
type="checkbox" type="checkbox"
@@ -94,6 +163,14 @@ export function RoomStampEditor({ value, onApply, onCancel }: RoomStampEditorPro
/> />
<span>{t("roomStamp.showFloorArea")}</span> <span>{t("roomStamp.showFloorArea")}</span>
</label> </label>
<AlignToggle
value={draft.floorAreaAlign}
fallback="center"
disabled={!draft.showFloorArea}
label={`${t("roomStamp.showFloorArea")}${t("text.group")}`}
onChange={(floorAreaAlign) => patch({ floorAreaAlign })}
/>
</div>
<label className="roomstamp-field"> <label className="roomstamp-field">
<span className="roomstamp-label">{t("roomStamp.floorAreaPrefix")}</span> <span className="roomstamp-label">{t("roomStamp.floorAreaPrefix")}</span>
@@ -106,6 +183,7 @@ export function RoomStampEditor({ value, onApply, onCancel }: RoomStampEditorPro
/> />
</label> </label>
<div className="roomstamp-row">
<label className="roomstamp-check"> <label className="roomstamp-check">
<input <input
type="checkbox" type="checkbox"
@@ -114,6 +192,14 @@ export function RoomStampEditor({ value, onApply, onCancel }: RoomStampEditorPro
/> />
<span>{t("roomStamp.showUsage")}</span> <span>{t("roomStamp.showUsage")}</span>
</label> </label>
<AlignToggle
value={draft.usageAlign}
fallback="center"
disabled={!draft.showUsage}
label={`${t("roomStamp.showUsage")}${t("text.group")}`}
onChange={(usageAlign) => patch({ usageAlign })}
/>
</div>
</div> </div>
<div className="texteditor-foot"> <div className="texteditor-foot">
+2 -2
View File
@@ -2814,8 +2814,8 @@ function renderPrimitive(
color: p.color, color: p.color,
}); });
const extra: SvgLine[] = p.extraLines.map((line) => ({ const extra: SvgLine[] = p.extraLines.map((line) => ({
align: "center" as const, align: line.align,
tspans: [{ text: line, fontSize: baseFs * 0.8, fill: p.color }], tspans: [{ text: line.text, fontSize: baseFs * 0.8, fill: p.color }],
})); }));
const lines = [...docLines, ...extra]; const lines = [...docLines, ...extra];
if (lines.length === 0) return null; if (lines.length === 0) return null;
+9 -4
View File
@@ -34,6 +34,7 @@ import { evaluateRoom, siaLabel } from "../geometry/roomArea";
import type { RichTextDoc } from "../text/richText"; import type { RichTextDoc } from "../text/richText";
import { docFromText } from "../text/richText"; import { docFromText } from "../text/richText";
import { roomStampToDoc, roomStampExtraLines } from "../model/roomStamp"; import { roomStampToDoc, roomStampExtraLines } from "../model/roomStamp";
import type { RoomStampLine } from "../model/roomStamp";
import { stairVerticalExtent, wallReferenceOffset } from "../model/wall"; import { stairVerticalExtent, wallReferenceOffset } from "../model/wall";
import { stairGeometry, stairCut } from "../geometry/stair"; import { stairGeometry, stairCut } from "../geometry/stair";
import { doorSymbol, openingInterval, windowSymbol } from "../geometry/opening"; import { doorSymbol, openingInterval, windowSymbol } from "../geometry/opening";
@@ -229,9 +230,10 @@ export type Primitive =
doc: RichTextDoc; doc: RichTextDoc;
/** /**
* LIVE-Zusatzzeilen (z. B. Fläche „24.30 m²" + SIA-Tag), UNTER dem Doc * 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). */ /** Basis-Schriftgrösse in PUNKT (für Runs ohne eigene Grösse + Zusatzzeilen). */
basePt: number; basePt: number;
/** Textfarbe (hex) als Default. */ /** Textfarbe (hex) als Default. */
@@ -1309,8 +1311,11 @@ function addRoomArea(
siaLabel: siaLabel(room.siaCategory), siaLabel: siaLabel(room.siaCategory),
}) })
: [ : [
`${res.netArea.toFixed(2)}`, { text: `${res.netArea.toFixed(2)}`, align: "center" as const },
`${room.siaCategory} · ${siaLabel(room.siaCategory)}`, {
text: `${room.siaCategory} · ${siaLabel(room.siaCategory)}`,
align: "center" as const,
},
]; ];
out.push({ out.push({
kind: "text", kind: "text",
+1 -1
View File
@@ -483,7 +483,7 @@ export function planToRenderScene(plan: Plan, paperScaleN: number = STAMP_DEFAUL
}); });
} }
for (const line of p.extraLines) { 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) { if (flat.length > 0) {
const baseH = baseMm * mmToM; // Basisgröße in Modell-Metern, beim gewählten Massstab const baseH = baseMm * mmToM; // Basisgröße in Modell-Metern, beim gewählten Massstab
+51
View File
@@ -4518,6 +4518,57 @@ body {
color: var(--ink); color: var(--ink);
cursor: pointer; 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. */ /* Flächenbilanz-Panel (SIA 416): Kopf, Raumliste, Summentabelle, Export. */
.balance-panel { .balance-panel {
+9 -1
View File
@@ -8,6 +8,7 @@
import type { Project, Vec2 } from "../model/types"; import type { Project, Vec2 } from "../model/types";
import { lineIntersect } from "../model/geometry"; import { lineIntersect } from "../model/geometry";
import { centroid as roomCentroid } from "../geometry/roomArea";
import type { SnapKind, SnapResult, SnapSettings } from "./types"; import type { SnapKind, SnapResult, SnapSettings } from "./types";
const dist = (a: Vec2, b: Vec2): number => Math.hypot(a.x - b.x, a.y - b.y); 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) ? collectSegments(input)
: []; : [];
// endpoint (Strecken-Endpunkte + Draft-Knoten). // endpoint (Strecken-Endpunkte + Draft-Knoten + Raum-Stempel-Anker).
if (settings.endpoint) { if (settings.endpoint) {
for (const [a, b] of segs) { for (const [a, b] of segs) {
consider(a, "endpoint"); consider(a, "endpoint");
consider(b, "endpoint"); consider(b, "endpoint");
} }
for (const p of input.draftPoints) consider(p, "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. // midpoint.
if (settings.midpoint) { if (settings.midpoint) {