2D-Plan-Renderer auf WebGL2 (GPU) + akkumulierter Funktionsstand

Neuer GPU-Renderer fuer den Grundriss (src/plan/glPlan/): Earcut-Tessellierung
(konkav-faehig), gehrte Linienzuege (Miter), echte Papier-mm-Strichbreiten im
Massstab (repliziert den SVG-printStrokeVb-Pfad), Hybrid mit scharfem SVG-Text-
Overlay. GPU ist der Standardpfad; der SVG-Renderer bleibt automatischer Fallback,
falls WebGL2/Shader nicht verfuegbar sind. Imperativer Pan (rAF + CSS-transform)
fuer fluessige Interaktion ohne React-Re-Render je Frame.

Enthaelt zudem den bisher nicht committeten Arbeitsstand des Browser-BIM
(Oeffnungen, Treppen, Raeume, Decken, DXF-Export, Materialbibliothek, Kontext-
Import, Tauri-Compute-Boundary-PoC).
This commit is contained in:
2026-07-02 00:12:39 +02:00
parent 7b3b597abc
commit 8fd8987b70
184 changed files with 29421 additions and 669 deletions
+51 -13
View File
@@ -13,14 +13,20 @@ export interface CommandLineOption {
id: string;
labelKey: string;
value?: string;
/** Aktuell gewählte Option (hervorgehoben)? */
active?: boolean;
/** Tastatur-Kürzel (z. B. „U"), wirkt AUCH bei fokussiertem Eingabefeld. */
key?: string;
}
/** Ein Zahlenfeld des Tab-Feld-Zyklus (§2.7). */
export interface CommandLineField {
id: string;
labelKey: string;
/** Gelockter Wert (oder null = folgt der Maus). */
/** Gelockter ODER live (maus-folgender) Wert; null = (noch) keiner. */
value: number | null;
/** Ist der Wert gelockt (getippt) statt live (folgt der Maus)? */
locked: boolean;
/** Aktuelles Tab-Ziel? */
active: boolean;
}
@@ -73,6 +79,25 @@ export const CommandLine = forwardRef<CommandLineHandle, CommandLineProps>(
};
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
// Options-Kürzel (z. B. U/I/O/P der Kopiermodi): auch bei fokussiertem Feld
// aktiv. Ein einzelner Buchstabe, der einer Option zugeordnet ist, löst die
// Option aus statt in die Zahleneingabe zu fallen (Ziffern bleiben Eingabe).
if (
active &&
e.key.length === 1 &&
!e.ctrlKey &&
!e.metaKey &&
!e.altKey
) {
const opt = options.find(
(o) => o.key && o.key.toLowerCase() === e.key.toLowerCase(),
);
if (opt) {
e.preventDefault();
onOption(opt.id);
return;
}
}
// Tasten NICHT zum globalen App-Handler durchreichen (eigener Eingabefokus).
if (e.key === "Enter") {
e.preventDefault();
@@ -130,12 +155,13 @@ export const CommandLine = forwardRef<CommandLineHandle, CommandLineProps>(
<button
key={o.id}
type="button"
className="cmdline-option"
className={`cmdline-option${o.active ? " active" : ""}`}
onMouseDown={(e) => {
e.preventDefault();
onOption(o.id);
}}
>
{o.key && <span className="cmdline-option-key">{o.key}</span>}
{t(o.labelKey as TranslationKey)}
{o.value != null && <span className="cmdline-option-val">={o.value}</span>}
</button>
@@ -144,18 +170,30 @@ export const CommandLine = forwardRef<CommandLineHandle, CommandLineProps>(
)}
{hasFields && (
<span className="cmdline-fields">
{fields.map((f) => (
<span
key={f.id}
className={`cmdline-field${f.active ? " active" : ""}`}
title={t(f.labelKey as TranslationKey)}
>
<span className="cmdline-field-label">{t(f.labelKey as TranslationKey)}</span>
<span className="cmdline-field-val">
{f.value == null ? "—" : Number(f.value.toFixed(3)).toString()}
{fields.map((f) => {
// Winkelfeld in Grad, sonst Längen in Metern. Gelockte Werte fest
// (mehr Nachkommastellen), live (maus-folgende) Werte gerundet.
const isAngle = f.id === "angle";
const digits = isAngle ? (f.locked ? 1 : 0) : f.locked ? 3 : 2;
const unit = isAngle ? "°" : " m";
const text =
f.value == null
? "—"
: `${Number(f.value.toFixed(digits)).toString()}${unit}`;
return (
<span
key={f.id}
className={
`cmdline-field${f.active ? " active" : ""}` +
(f.locked ? " locked" : f.value != null ? " live" : "")
}
title={t(f.labelKey as TranslationKey)}
>
<span className="cmdline-field-label">{t(f.labelKey as TranslationKey)}</span>
<span className="cmdline-field-val">{text}</span>
</span>
</span>
))}
);
})}
</span>
)}
<input
+306
View File
@@ -0,0 +1,306 @@
// Standort-Import-Dialog (modal). Sucht einen Ort/Adresse (geo.admin
// SearchServer), lässt einen Radius wählen und importiert Gebäude-Grundrisse
// (swisstopo) sowie OSM-Features (Gebäude/Strassen/Wasser/Grün) als Kontext-
// Geometrie ins Projekt.
//
// Muster wie ExportPdfDialog/ImportDialog: abgedunkeltes Overlay, Klick auf den
// Hintergrund + Esc schließen. Der eigentliche Netz-Abruf läuft hier (io/*); das
// Ergebnis wird über `onImport(objs)` nach oben gereicht (SitePanel legt es via
// Host in `project.context` ab). Bezeichner englisch, UI-Text/Kommentare deutsch
// (CONVENTIONS.md).
import { useEffect, useRef, useState } from "react";
import { t } from "../i18n";
import { geocode, fetchBuildings } from "../io/swissTopo";
import type { GeocodeCandidate } from "../io/swissTopo";
import { fetchOsm } from "../io/osm";
import type { OsmSelection } from "../io/osm";
import { featuresToContextObjects } from "../io/geoContext";
import type { GeoFeature } from "../io/geoContext";
import type { GeoOrigin } from "../io/lv95";
import type { ContextObject } from "../model/types";
export interface ContextImportDialogProps {
/** Übernimmt die fertigen Kontext-Objekte (nach erfolgreichem Import). */
onImport: (objs: ContextObject[]) => void;
onClose: () => void;
}
type Sources = {
swissBuildings: boolean;
osmBuildings: boolean;
osmRoads: boolean;
osmWater: boolean;
osmGreen: boolean;
};
const DEFAULT_SOURCES: Sources = {
swissBuildings: true,
osmBuildings: false,
osmRoads: true,
osmWater: true,
osmGreen: true,
};
export function ContextImportDialog({
onImport,
onClose,
}: ContextImportDialogProps) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const [query, setQuery] = useState("");
const [candidates, setCandidates] = useState<GeocodeCandidate[]>([]);
const [selected, setSelected] = useState<GeocodeCandidate | null>(null);
const [radius, setRadius] = useState(200);
const [sources, setSources] = useState<Sources>(DEFAULT_SOURCES);
const [searching, setSearching] = useState(false);
const [importing, setImporting] = useState(false);
const [status, setStatus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const searchSeq = useRef(0);
const runSearch = async () => {
const q = query.trim();
if (!q) return;
setError(null);
setSearching(true);
const seq = ++searchSeq.current;
try {
const res = await geocode(q, 8);
if (seq !== searchSeq.current) return; // veraltet
setCandidates(res);
setSelected(res[0] ?? null);
if (res.length === 0) setStatus(t("ctxImport.noResults"));
else setStatus(null);
} catch {
if (seq === searchSeq.current) setError(t("ctxImport.searchError"));
} finally {
if (seq === searchSeq.current) setSearching(false);
}
};
const toggle = (key: keyof Sources) =>
setSources((s) => ({ ...s, [key]: !s[key] }));
const anySource =
sources.swissBuildings ||
sources.osmBuildings ||
sources.osmRoads ||
sources.osmWater ||
sources.osmGreen;
const runImport = async () => {
if (!selected || !anySource) return;
setError(null);
setImporting(true);
setStatus(t("ctxImport.working"));
try {
const center = selected.lv95;
let origin: GeoOrigin | undefined;
const all: GeoFeature[] = [];
if (sources.swissBuildings) {
const r = await fetchBuildings(center, radius, origin);
origin = r.origin;
all.push(...r.features);
}
const osmSel: OsmSelection = {
buildings: sources.osmBuildings,
roads: sources.osmRoads,
water: sources.osmWater,
green: sources.osmGreen,
};
if (
osmSel.buildings ||
osmSel.roads ||
osmSel.water ||
osmSel.green
) {
const r = await fetchOsm(center, radius, osmSel, origin);
origin = r.origin;
all.push(...r.features);
}
if (all.length === 0) {
setError(t("ctxImport.empty"));
setStatus(null);
return;
}
const objs = featuresToContextObjects(all, selected.label);
onImport(objs);
setStatus(
t("ctxImport.imported", { count: all.length, sets: objs.length }),
);
onClose();
} catch {
setError(t("ctxImport.importError"));
setStatus(null);
} finally {
setImporting(false);
}
};
return (
<div className="imp-overlay" onClick={onClose}>
<div
className="imp-dialog"
role="dialog"
aria-modal="true"
aria-label={t("ctxImport.title")}
onClick={(e) => e.stopPropagation()}
>
<header className="imp-head">
<span className="imp-head-title">{t("ctxImport.title")}</span>
<button
className="imp-close"
onClick={onClose}
aria-label={t("ctxImport.close")}
>
×
</button>
</header>
<div className="imp-body">
{/* Ortssuche */}
<section className="imp-section">
<div className="imp-section-title">{t("ctxImport.location")}</div>
<div className="cim-search-row">
<input
className="cim-input"
type="text"
value={query}
placeholder={t("ctxImport.searchPlaceholder")}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void runSearch();
}
}}
aria-label={t("ctxImport.location")}
/>
<button
className="imp-btn"
onClick={() => void runSearch()}
disabled={searching || !query.trim()}
>
{searching ? t("ctxImport.searching") : t("ctxImport.search")}
</button>
</div>
{candidates.length > 0 && (
<ul className="cim-results">
{candidates.map((c, i) => (
<li
key={`${c.label}-${i}`}
className={
"cim-result" + (c === selected ? " selected" : "")
}
onClick={() => setSelected(c)}
title={c.label}
>
{c.label}
</li>
))}
</ul>
)}
</section>
{/* Radius */}
<section className="imp-section">
<div className="imp-section-title">{t("ctxImport.radius")}</div>
<div className="cim-radius-row">
<input
className="cim-input cim-input-num"
type="number"
min={20}
max={2000}
step={10}
value={radius}
onChange={(e) =>
setRadius(
Math.max(20, Math.min(2000, Number(e.target.value) || 0)),
)
}
aria-label={t("ctxImport.radius")}
/>
<span className="cim-unit">m</span>
</div>
</section>
{/* Quellen */}
<section className="imp-section">
<div className="imp-section-title">{t("ctxImport.sources")}</div>
<div className="cim-checks">
<label className="cim-check">
<input
type="checkbox"
checked={sources.swissBuildings}
onChange={() => toggle("swissBuildings")}
/>
{t("ctxImport.src.swissBuildings")}
</label>
<label className="cim-check">
<input
type="checkbox"
checked={sources.osmBuildings}
onChange={() => toggle("osmBuildings")}
/>
{t("ctxImport.src.osmBuildings")}
</label>
<label className="cim-check">
<input
type="checkbox"
checked={sources.osmRoads}
onChange={() => toggle("osmRoads")}
/>
{t("ctxImport.src.osmRoads")}
</label>
<label className="cim-check">
<input
type="checkbox"
checked={sources.osmWater}
onChange={() => toggle("osmWater")}
/>
{t("ctxImport.src.osmWater")}
</label>
<label className="cim-check">
<input
type="checkbox"
checked={sources.osmGreen}
onChange={() => toggle("osmGreen")}
/>
{t("ctxImport.src.osmGreen")}
</label>
</div>
</section>
{status && <div className="cim-status">{status}</div>}
{error && <div className="imp-warn">{error}</div>}
</div>
<footer className="imp-foot">
<button className="imp-btn" onClick={onClose}>
{t("ctxImport.cancel")}
</button>
<button
className="imp-btn primary"
onClick={() => void runImport()}
disabled={importing || !selected || !anySource}
>
{importing ? t("ctxImport.importing") : t("ctxImport.confirm")}
</button>
</footer>
</div>
</div>
);
}
+1 -1
View File
@@ -14,7 +14,7 @@
// bzw. den festen `placeholder`.
//
// Reine Darstellung + Callbacks; voll gesteuert. Identifier englisch, UI-Text
// kommt als Props (i18n) herein (CLAUDE.md).
// kommt als Props (i18n) herein (CONVENTIONS.md).
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
+89
View File
@@ -0,0 +1,89 @@
// DXF-Export-Dialog (modal). Fragt „Schraffuren mitexportieren?" ab und weist auf
// die Meter-Einheit hin (DXF ist Modell-Space in echten Metern, 1:1). Muster wie
// ExportPdfDialog: abgedunkeltes Overlay, Klick auf den Hintergrund + Esc schliessen.
//
// Rein gesteuert: hält nur Formular-Zustand und meldet die Entscheidung über
// `onExport` nach oben (App führt den Export aus, da es den Plan erzeugt).
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { useEffect, useState } from "react";
import { t } from "../i18n";
import { Dropdown } from "./Dropdown";
/** Entscheidung, die der Dialog beim Bestätigen liefert. */
export interface ExportDxfDecision {
includeHatches: boolean;
}
export interface ExportDxfDialogProps {
/** Titel/Geschossname (nur Anzeige im Dialogkopf). */
planTitle: string;
onExport: (decision: ExportDxfDecision) => void;
onClose: () => void;
}
export function ExportDxfDialog({ planTitle, onExport, onClose }: ExportDxfDialogProps) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const [includeHatches, setIncludeHatches] = useState(true);
const onConfirm = () => onExport({ includeHatches });
return (
<div className="imp-overlay" onClick={onClose}>
<div
className="imp-dialog"
role="dialog"
aria-modal="true"
aria-label={t("exportDxf.title")}
onClick={(e) => e.stopPropagation()}
>
<header className="imp-head">
<span className="imp-head-title">{t("exportDxf.title")}</span>
<button className="imp-close" onClick={onClose} aria-label={t("export.close")}>
×
</button>
</header>
<div className="imp-body">
<div className="imp-file">
<span className="imp-file-label">{t("export.plan")}</span>
<span className="imp-file-name" title={planTitle}>
{planTitle}
</span>
</div>
<section className="imp-section">
<div className="imp-section-title">{t("exportDxf.hatches")}</div>
<Dropdown
value={includeHatches ? "yes" : "no"}
onChange={(v) => setIncludeHatches(v === "yes")}
title={t("exportDxf.hatches")}
options={[
{ value: "yes", label: t("exportDxf.hatches.yes") },
{ value: "no", label: t("exportDxf.hatches.no") },
]}
/>
</section>
<div className="imp-note">{t("exportDxf.unitsNote")}</div>
</div>
<footer className="imp-foot">
<button className="imp-btn" onClick={onClose}>
{t("export.cancel")}
</button>
<button className="imp-btn primary" onClick={onConfirm}>
{t("export.confirm")}
</button>
</footer>
</div>
</div>
);
}
+156
View File
@@ -0,0 +1,156 @@
// PDF-Export-Dialog (modal). Fragt Massstab (1:N), Papierformat (A4/A3) und
// Ausrichtung (Hoch/Quer) ab und löst den Vektor-PDF-Export aus. Muster wie
// ImportDialog: abgedunkeltes Overlay, Klick auf den Hintergrund + Esc schließen.
// Themedes Dropdown für die Auswahlfelder.
//
// Rein gesteuert: hält nur Formular-Zustand und meldet die Entscheidung über
// `onExport` nach oben (App führt den eigentlichen Export aus, da es den Plan
// erzeugt). Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { useEffect, useState } from "react";
import { t } from "../i18n";
import { Dropdown } from "./Dropdown";
import { SCALE_PRESETS } from "./TopBar";
import type { Orientation, PaperFormat } from "../export/exportPdf";
import { pageSizeMm } from "../export/exportPdf";
/** Entscheidung, die der Dialog beim Bestätigen liefert. */
export interface ExportPdfDecision {
scaleDenominator: number;
paper: PaperFormat;
orientation: Orientation;
}
export interface ExportPdfDialogProps {
/** Vorbelegung des Massstabs (aktueller Plan-Massstab). */
initialScale: number;
/** Titel/Geschossname (nur Anzeige im Dialogkopf). */
planTitle: string;
/**
* Massstäbliche Inhalts-Maße des Plans in Metern (Breite/Höhe), für die
* „passt aufs Blatt?"-Warnung. Optional.
*/
contentMeters?: { w: number; h: number };
onExport: (decision: ExportPdfDecision) => void;
onClose: () => void;
}
export function ExportPdfDialog({
initialScale,
planTitle,
contentMeters,
onExport,
onClose,
}: ExportPdfDialogProps) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const [scale, setScale] = useState<number>(initialScale);
const [paper, setPaper] = useState<PaperFormat>("a4");
const [orientation, setOrientation] = useState<Orientation>("landscape");
// Passt der Plan im gewählten Massstab aufs Blatt (abzüglich Rand)? Best-effort-
// Hinweis; der Export läuft trotzdem (Plan ragt dann über den Blattrand).
const fits = (() => {
if (!contentMeters) return true;
const { w: pageW, h: pageH } = pageSizeMm(paper, orientation);
const margin = 12; // Rand + etwas Luft fürs Schriftfeld
const k = 1000 / scale; // mm je Meter
return (
contentMeters.w * k <= pageW - margin * 2 &&
contentMeters.h * k <= pageH - margin * 2
);
})();
const onConfirm = () => onExport({ scaleDenominator: scale, paper, orientation });
const scaleOptions = SCALE_PRESETS.map((n) => ({
value: String(n),
label: `1:${n}`,
}));
return (
<div className="imp-overlay" onClick={onClose}>
<div
className="imp-dialog"
role="dialog"
aria-modal="true"
aria-label={t("export.title")}
onClick={(e) => e.stopPropagation()}
>
<header className="imp-head">
<span className="imp-head-title">{t("export.title")}</span>
<button
className="imp-close"
onClick={onClose}
aria-label={t("export.close")}
>
×
</button>
</header>
<div className="imp-body">
<div className="imp-file">
<span className="imp-file-label">{t("export.plan")}</span>
<span className="imp-file-name" title={planTitle}>
{planTitle}
</span>
</div>
<section className="imp-section">
<div className="imp-section-title">{t("export.scale")}</div>
<Dropdown
value={String(scale)}
onChange={(v) => setScale(Number(v))}
title={t("export.scale")}
triggerClassName="tb-dd-mono"
options={scaleOptions}
/>
</section>
<section className="imp-section">
<div className="imp-section-title">{t("export.paper")}</div>
<Dropdown
value={paper}
onChange={(v) => setPaper(v as PaperFormat)}
title={t("export.paper")}
options={[
{ value: "a4", label: "A4" },
{ value: "a3", label: "A3" },
]}
/>
</section>
<section className="imp-section">
<div className="imp-section-title">{t("export.orientation")}</div>
<Dropdown
value={orientation}
onChange={(v) => setOrientation(v as Orientation)}
title={t("export.orientation")}
options={[
{ value: "portrait", label: t("export.orientation.portrait") },
{ value: "landscape", label: t("export.orientation.landscape") },
]}
/>
</section>
{!fits && <div className="imp-warn">{t("export.warnFit")}</div>}
</div>
<footer className="imp-foot">
<button className="imp-btn" onClick={onClose}>
{t("export.cancel")}
</button>
<button className="imp-btn primary" onClick={onConfirm}>
{t("export.confirm")}
</button>
</footer>
</div>
</div>
);
}
+30 -3
View File
@@ -11,14 +11,14 @@
// eigentlichen Store-Mutationen (Ebene anlegen, Drawings anhängen, Kontext)
// führt App in der richtigen Reihenfolge aus.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CLAUDE.md). Alle sichtbaren
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md). Alle sichtbaren
// Texte über t(...).
import { useEffect, useState } from "react";
import { t } from "../i18n";
import type { TranslationKey } from "../i18n";
import type { DrawingLevelKind, ImportedMesh, Project } from "../model/types";
import type { DxfImportResult } from "../io/dxfParser";
import type { DxfImportResult, ImportDiagnostics } from "../io/dxfParser";
import {
contoursToDrawings,
countContours,
@@ -52,6 +52,21 @@ function kindBadgeKey(kind: DrawingLevelKind): TranslationKey {
}
}
/**
* Formatiert das Entity-Typ-Histogramm der Import-Diagnose zu einer kompakten
* Daten-Zeile, z. B. „120 Entities — INSERT×80, ARC×30, LINE×10". Die Entity-
* Typ-Codes sind technische DWG-Bezeichner (keine übersetzbare UI-Prosa); nur
* die Gesamtzahl ist eine reine Zahl. Bei 0 Entities zeigt sie „0 Entities" —
* das deutet auf ein Encoding-/Versions-Problem (nicht auf eine Abdeckungslücke).
*/
function formatDiagnostics(d: ImportDiagnostics): string {
const parts = Object.entries(d.entityCounts)
.sort((a, b) => b[1] - a[1])
.map(([type, n]) => `${type}×${n}`);
const head = `${d.total} Entities`;
return parts.length > 0 ? `${head}${parts.join(", ")}` : head;
}
export interface ImportDialogProps {
fileName: string;
/**
@@ -179,7 +194,19 @@ export function ImportDialog({
</li>
</ul>
{!hasDrawings && !hasMeshes && (
<div className="imp-warn">{t("import.summary.empty")}</div>
<div className="imp-warn">
{t("import.summary.empty")}
{/* Diagnose-Histogramm (vom DWG-Parser): macht sichtbar,
WORAN es liegt — 0 Entities (Encoding/Version) vs.
Abdeckungslücke (z. B. „500 ARC, 200 INSERT"). Entity-Typ-
Codes + Zahlen sind technische DWG-Bezeichner (Daten,
keine übersetzbare Prosa). */}
{parsed?.diagnostics && (
<div className="imp-diag">
{formatDiagnostics(parsed.diagnostics)}
</div>
)}
</div>
)}
</section>
+475 -3
View File
@@ -16,9 +16,10 @@
// Rendering noch Persistenz. Bezeichner englisch, UI-Text deutsch
// (CONVENTIONS.md).
import { useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import type {
Component,
ComponentMaterial,
HatchPattern,
HatchStyle,
LineStyle,
@@ -27,6 +28,15 @@ import type {
import { PEN_WEIGHTS } from "../model/types";
import { parseLin } from "../io/linParser";
import { parsePat } from "../io/patParser";
import { MATERIAL_LIBRARY } from "../materials/library";
import { materialFromAsset, DEFAULT_TILE_SIZE_M } from "../materials/runtime";
import {
searchMaterials,
fetchMaterialMaps,
AMBIENT_CATEGORIES,
type AmbientMaterial,
type AmbientResolution,
} from "../materials/ambientcg";
import { EyeIcon } from "./EyeIcon";
import { t } from "../i18n";
@@ -269,7 +279,7 @@ function ResCell({
// ── Bauteile (Components) ──────────────────────────────────────────────────
// Spalten: Name | Farbe | Schraffur | Prio | Textur | (löschen).
// Spalten: Name | Farbe | Schraffur | Prio | Textur | Material | (löschen).
const COMPONENT_COLUMNS: ResColumn[] = [
{ titleKey: "resources.col.name" },
{ titleKey: "resources.col.color" },
@@ -280,10 +290,465 @@ const COMPONENT_COLUMNS: ResColumn[] = [
hintKey: "resources.col.prio.hint",
},
{ titleKey: "resources.col.texture" },
{ titleKey: "resources.col.material" },
{ titleKey: "" },
];
const COMPONENT_TEMPLATE =
"minmax(120px, 1fr) auto minmax(120px, 0.8fr) 64px minmax(100px, 0.8fr) 36px";
"minmax(120px, 1fr) auto minmax(120px, 0.8fr) 64px minmax(90px, 0.7fr) minmax(130px, 0.9fr) 36px";
/**
* Material-Zelle der Bauteil-Tabelle: ein Button, der den aktuellen Material-
* Namen zeigt (oder „ohne") und ein Auswahl-Modal öffnet. Die Zuweisung läuft
* über `onAssign`/`onClear` (immutabel via onPatchComponent).
*/
function MaterialCell({
material,
onAssign,
onClear,
}: {
material: ComponentMaterial | undefined;
onAssign: (m: ComponentMaterial) => void;
onClear: () => void;
}) {
const [open, setOpen] = useState(false);
const label = materialLabel(material);
return (
<>
<button
className="res-select res-material-btn"
onClick={() => setOpen(true)}
title={t("material.assign")}
>
{material?.color && (
<span
className="res-material-swatch"
style={{ backgroundImage: `url(${material.color})` }}
/>
)}
<span className="res-material-name">{label}</span>
</button>
{open && (
<MaterialPicker
material={material}
onAssign={(m) => {
onAssign(m);
}}
onClear={() => {
onClear();
}}
onClose={() => setOpen(false)}
/>
)}
</>
);
}
/** Anzeigename eines zugewiesenen Materials (Bibliothek → Name, sonst Upload). */
function materialLabel(m: ComponentMaterial | undefined): string {
if (!m) return t("material.none");
if (m.libraryId) {
const asset = MATERIAL_LIBRARY.find((a) => a.id === m.libraryId);
if (asset) return asset.name;
}
return t("material.uploaded");
}
/**
* Material-Auswahl-Modal: oben die eingebaute Bibliothek als Vorschau-Grid
* (Farb-Karte als Thumbnail) — Klick weist sofort zu; darunter ein Upload-
* Bereich (Farbe/Normal/Rauheit/Höhe/AO als Bild-Dateien → ObjectURL) plus die
* physische Kachelgröße. „Zuweisen" übernimmt den Upload; „Material entfernen"
* setzt das Bauteil auf kein Material zurück.
*/
function MaterialPicker({
material,
onAssign,
onClear,
onClose,
}: {
material: ComponentMaterial | undefined;
onAssign: (m: ComponentMaterial) => void;
onClear: () => void;
onClose: () => void;
}) {
// Upload-Zustand: gewählte Karten als ObjectURLs + Kachelgröße. Startwert aus
// einem bereits zugewiesenen Upload-Material (damit Größe/Karten sichtbar sind).
const [upload, setUpload] = useState<ComponentMaterial>(() =>
material && !material.libraryId ? { ...material } : { sizeM: DEFAULT_TILE_SIZE_M },
);
const [sizeM, setSizeM] = useState<number>(material?.sizeM ?? DEFAULT_TILE_SIZE_M);
// Modus des Pickers: Schnellauswahl (lokaler Starter, offline), Live-Browse
// der kompletten ambientCG-Bibliothek, oder Upload eigener Karten.
const [mode, setMode] = useState<"starter" | "browse" | "upload">("starter");
const pickFile = (kind: keyof ComponentMaterial) => (file: File | null) => {
if (!file) return;
const url = URL.createObjectURL(file);
setUpload((u) => ({ ...u, [kind]: url }));
};
const uploadFields: { kind: keyof ComponentMaterial; labelKey: string }[] = [
{ kind: "color", labelKey: "material.upload.color" },
{ kind: "normal", labelKey: "material.upload.normal" },
{ kind: "roughness", labelKey: "material.upload.roughness" },
{ kind: "displacement", labelKey: "material.upload.displacement" },
{ kind: "ao", labelKey: "material.upload.ao" },
];
const applyUpload = () => {
if (!upload.color && !upload.normal && !upload.roughness && !upload.ao && !upload.displacement) {
return;
}
onAssign({ ...upload, libraryId: undefined, sizeM });
onClose();
};
return (
<div className="res-overlay mat-overlay" onClick={onClose}>
<div
className="mat-dialog"
role="dialog"
aria-label={t("material.dialog.title")}
onClick={(e) => e.stopPropagation()}
>
<header className="mat-head">
<span className="mat-title">{t("material.dialog.title")}</span>
<button className="res-close" onClick={onClose} aria-label={t("material.close")}>
×
</button>
</header>
<div className="mat-hint">{t("material.dialog.hint")}</div>
{/* Modus-Umschalter: Schnellauswahl / Bibliothek durchsuchen / Upload. */}
<nav className="mat-modes" role="tablist">
<button
role="tab"
aria-selected={mode === "starter"}
className={`mat-mode-btn${mode === "starter" ? " active" : ""}`}
onClick={() => setMode("starter")}
>
{t("material.browse.starter")}
</button>
<button
role="tab"
aria-selected={mode === "browse"}
className={`mat-mode-btn${mode === "browse" ? " active" : ""}`}
onClick={() => setMode("browse")}
>
{t("material.browse")}
</button>
<button
role="tab"
aria-selected={mode === "upload"}
className={`mat-mode-btn${mode === "upload" ? " active" : ""}`}
onClick={() => setMode("upload")}
>
{t("material.upload")}
</button>
</nav>
{/* Schnellauswahl: lokaler 12er-Starter (offline verfügbar). */}
{mode === "starter" && (
<div className="mat-grid">
{MATERIAL_LIBRARY.map((asset) => (
<button
key={asset.id}
className={`mat-tile${material?.libraryId === asset.id ? " active" : ""}`}
title={asset.name}
onClick={() => {
onAssign(materialFromAsset(asset, sizeM));
onClose();
}}
>
<span
className="mat-thumb"
style={{ backgroundImage: `url(${asset.maps.color})` }}
/>
<span className="mat-tile-name">{asset.name}</span>
</button>
))}
</div>
)}
{/* Live-Browse der kompletten ambientCG-Bibliothek. */}
{mode === "browse" && (
<AmbientBrowser
sizeM={sizeM}
activeId={material?.libraryId}
onAssign={(m) => {
onAssign(m);
onClose();
}}
/>
)}
{/* Upload eigener Karten. */}
{mode === "upload" && (
<>
<div className="mat-hint">{t("material.upload.hint")}</div>
<div className="mat-upload-grid">
{uploadFields.map((f) => (
<label key={f.kind} className="mat-upload-row">
<span className="mat-upload-label">{t(f.labelKey)}</span>
<ImagePickButton onFile={pickFile(f.kind)} />
{upload[f.kind] && <span className="mat-upload-ok"></span>}
</label>
))}
<label className="mat-upload-row">
<span className="mat-upload-label">{t("material.size")}</span>
<input
type="number"
className="res-input mono num"
value={sizeM}
step={0.1}
min={0.05}
onChange={(e) => {
const v = Number(e.target.value);
if (!Number.isNaN(v) && v > 0) setSizeM(v);
}}
/>
</label>
</div>
<div className="mat-actions">
<button className="res-add" onClick={applyUpload}>
{t("material.upload.apply")}
</button>
</div>
</>
)}
{/* Kachelgröße (m) für Schnellauswahl + Browse, plus Material entfernen. */}
{mode !== "upload" && (
<div className="mat-actions mat-actions-browse">
<label className="mat-size-inline">
<span className="mat-upload-label">{t("material.size")}</span>
<input
type="number"
className="res-input mono num"
value={sizeM}
step={0.1}
min={0.05}
onChange={(e) => {
const v = Number(e.target.value);
if (!Number.isNaN(v) && v > 0) setSizeM(v);
}}
/>
</label>
<button
className="res-add mat-danger"
onClick={() => {
onClear();
onClose();
}}
>
{t("material.clear")}
</button>
</div>
)}
</div>
</div>
);
}
/**
* Live-Browse der kompletten ambientCG-Bibliothek: Suchfeld + Kategorie-Filter
* + Auflösungswahl über der Trefferliste, Thumbnail-Grid (aus der API, direkt
* geladen — CORS `*`) mit Pagination („mehr laden"). Ein Klick lädt die Textur-
* Karten des Materials herunter (`fetchMaterialMaps`, über den Proxy), entpackt
* sie und weist das entstehende `ComponentMaterial` zu. Lade-/Fehlerzustände
* sauber (Spinner, Offline-/CORS-Hinweis).
*/
function AmbientBrowser({
sizeM,
activeId,
onAssign,
}: {
sizeM: number;
activeId: string | undefined;
onAssign: (m: ComponentMaterial) => void;
}) {
const [query, setQuery] = useState("");
const [committedQuery, setCommittedQuery] = useState("");
const [category, setCategory] = useState("");
const [resolution, setResolution] = useState<AmbientResolution>("1K");
const [items, setItems] = useState<AmbientMaterial[]>([]);
const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// ID des gerade herunterladenden Materials (Spinner auf der Kachel).
const [downloadingId, setDownloadingId] = useState<string | null>(null);
const LIMIT = 24;
// Suche (bei committedQuery/category-Wechsel neu laden — Seite 0).
useEffect(() => {
const ctrl = new AbortController();
setLoading(true);
setError(null);
searchMaterials({
query: committedQuery,
category,
limit: LIMIT,
offset: 0,
signal: ctrl.signal,
})
.then((res) => {
setItems(res.items);
setTotal(res.total);
setOffset(0);
})
.catch((e) => {
if (ctrl.signal.aborted) return;
setError(t("material.browse.error"));
setItems([]);
setTotal(0);
console.error("[ambientcg] search failed:", e);
})
.finally(() => {
if (!ctrl.signal.aborted) setLoading(false);
});
return () => ctrl.abort();
}, [committedQuery, category]);
const loadMore = () => {
const nextOffset = offset + LIMIT;
setLoading(true);
searchMaterials({ query: committedQuery, category, limit: LIMIT, offset: nextOffset })
.then((res) => {
setItems((prev) => [...prev, ...res.items]);
setTotal(res.total);
setOffset(nextOffset);
})
.catch((e) => {
setError(t("material.browse.error"));
console.error("[ambientcg] load more failed:", e);
})
.finally(() => setLoading(false));
};
const pick = async (id: string) => {
setDownloadingId(id);
setError(null);
try {
const { material } = await fetchMaterialMaps(id, resolution, sizeM);
onAssign(material);
} catch (e) {
setError(t("material.browse.downloadError"));
console.error("[ambientcg] download failed:", e);
} finally {
setDownloadingId(null);
}
};
const submitSearch = () => setCommittedQuery(query);
return (
<div className="mat-browse">
<div className="mat-hint">{t("material.browse.hint")}</div>
<div className="mat-browse-bar">
<input
type="text"
className="res-input mat-search"
value={query}
placeholder={t("material.browse.searchPlaceholder")}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") submitSearch();
}}
/>
<button className="res-add" onClick={submitSearch}>
{t("material.browse.search")}
</button>
<select
className="res-select"
value={category}
onChange={(e) => setCategory(e.target.value)}
>
<option value="">{t("material.browse.category.all")}</option>
{AMBIENT_CATEGORIES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
<select
className="res-select"
value={resolution}
title={t("material.browse.resolution")}
onChange={(e) => setResolution(e.target.value as AmbientResolution)}
>
<option value="1K">1K</option>
<option value="2K">2K</option>
<option value="4K">4K</option>
</select>
</div>
{error && <div className="mat-browse-error">{error}</div>}
<div className="mat-grid mat-browse-grid">
{items.map((it) => (
<button
key={it.id}
className={`mat-tile${activeId === it.id ? " active" : ""}${
downloadingId === it.id ? " loading" : ""
}`}
title={`${it.name}${it.category ? `${it.category}` : ""}`}
disabled={downloadingId !== null}
onClick={() => pick(it.id)}
>
<span
className="mat-thumb"
style={{ backgroundImage: it.thumbUrl ? `url(${it.thumbUrl})` : undefined }}
/>
{downloadingId === it.id && <span className="mat-tile-spinner" />}
<span className="mat-tile-name">{it.name}</span>
</button>
))}
</div>
{!loading && items.length === 0 && !error && (
<div className="res-empty">{t("material.browse.empty")}</div>
)}
<div className="mat-browse-foot">
{loading && <span className="mat-browse-status">{t("material.browse.loading")}</span>}
{!loading && items.length > 0 && (
<span className="mat-browse-status">
{t("material.browse.count", { shown: items.length, total })}
</span>
)}
{!loading && items.length < total && (
<button className="res-add" onClick={loadMore}>
{t("material.browse.more")}
</button>
)}
</div>
</div>
);
}
/** Versteckter Bild-Datei-Picker mit sichtbarem Button. */
function ImagePickButton({ onFile }: { onFile: (file: File | null) => void }) {
const ref = useRef<HTMLInputElement | null>(null);
return (
<>
<button className="res-add mat-pick" onClick={() => ref.current?.click()}>
{t("material.upload.choose")}
</button>
<input
ref={ref}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={(e) => {
onFile(e.target.files?.[0] ?? null);
e.target.value = "";
}}
/>
</>
);
}
function ComponentRow({
component,
@@ -335,6 +800,13 @@ function ComponentRow({
mono
/>
</ResCell>
<ResCell>
<MaterialCell
material={component.material}
onAssign={(material) => onPatch({ material })}
onClear={() => onPatch({ material: undefined })}
/>
</ResCell>
<ResCell>
<DeleteButton
onClick={onDelete}
+74
View File
@@ -0,0 +1,74 @@
// Schwebendes Dialog-Fenster zum Bearbeiten eines Rich-Text-Dokuments (Raum-
// Stempel / Textobjekt). Doppelklick auf einen Stempel öffnet es (nicht den
// Footer). Der Body ist der bestehende RichTextEditor (mit eigener Mini-Toolbar,
// hier ok, weil eigenes Fenster). „Übernehmen" schreibt das bearbeitete Dokument
// zurück, „Abbrechen" verwirft. Bezeichner englisch, UI-Text deutsch.
import { useState } from "react";
import { t } from "../i18n";
import { RichTextEditor } from "../text/RichTextEditor";
import type { RichTextDoc } from "../text/richText";
export interface TextEditorDialogProps {
/** Ausgangsdokument (wird beim Öffnen kopiert; Editor arbeitet lokal). */
value: RichTextDoc;
/** „Übernehmen" — das bearbeitete Dokument zurückschreiben. */
onApply: (doc: RichTextDoc) => void;
/** „Abbrechen"/Schliessen — ohne Übernahme. */
onCancel: () => void;
}
export function TextEditorDialog({ value, onApply, onCancel }: TextEditorDialogProps) {
// Lokaler Arbeitszustand — erst „Übernehmen" schreibt zurück (echtes
// Abbrechen möglich). Start = übergebenes Dokument.
const [draft, setDraft] = useState<RichTextDoc>(value);
return (
<div
className="texteditor-overlay"
role="dialog"
aria-modal="true"
aria-label={t("text.editTitle")}
onMouseDown={(e) => {
if (e.target === e.currentTarget) onCancel();
}}
onKeyDown={(e) => {
if (e.key === "Escape") onCancel();
}}
>
<div className="texteditor-dialog">
<div className="texteditor-head">
<span className="texteditor-title">{t("text.editTitle")}</span>
<button
type="button"
className="texteditor-x"
onClick={onCancel}
title={t("text.cancel")}
aria-label={t("text.cancel")}
>
<span className="material-symbols-outlined" aria-hidden="true">
close
</span>
</button>
</div>
<div className="texteditor-body">
<RichTextEditor value={draft} onChange={setDraft} autoFocus />
</div>
<div className="texteditor-foot">
<button type="button" className="texteditor-btn" onClick={onCancel}>
{t("text.cancel")}
</button>
<button
type="button"
className="texteditor-btn texteditor-btn-primary"
onClick={() => onApply(draft)}
>
{t("text.apply")}
</button>
</div>
</div>
</div>
);
}
export default TextEditorDialog;
+566 -70
View File
@@ -14,6 +14,21 @@ import { t } from "../i18n";
import { useEffect, useRef, useState } from "react";
import { Dropdown } from "./Dropdown";
import type { DropdownItem, DropdownOption } from "./Dropdown";
import {
applyMark,
applyPreset,
commonMarkValue,
DEFAULT_PRESETS,
docLength,
isMarkActive,
toggleMark,
} from "../text/richText";
import type {
Align,
BoolMark,
RichTextDoc,
TextRange,
} from "../text/richText";
/** Ansichtstyp eines Geschosses (wie in App). */
export type ViewType = "grundriss" | "perspektive";
@@ -27,9 +42,16 @@ export type DetailLevel = "grob" | "mittel" | "fein";
/**
* Render-/Anzeigemodus der 3D-Ansicht: schattiert, einheitlich weiss (Clay-Look),
* Drahtgitter, Verdeckte Kanten (Hidden-Line). Wirkt nur in der Perspektive.
* texturiert (echte PBR-Materialien je Bauteil), Drahtgitter, Verdeckte Kanten
* (Hidden-Line). Wirkt nur in der Perspektive. Der Slot „section" (Schnitt-
* schraffur, Welle B) ist bewusst freigelassen.
*/
export type RenderMode = "shaded" | "white" | "wireframe" | "hidden";
export type RenderMode =
| "shaded"
| "white"
| "textured"
| "wireframe"
| "hidden";
/**
* Kanonischer Blickwinkel der 3D-Ansicht (Vectorworks/DOSSIER-Stil). „front",
@@ -97,6 +119,10 @@ export interface TopBarProps {
onFit: () => void;
onFitSelection: () => void;
onZoom100: () => void;
/** Öffnet den PDF-Export-Dialog (nur im Grundriss sinnvoll). */
onExportPdf: () => void;
/** Öffnet den DXF-Export-Dialog (nur im Grundriss sinnvoll). */
onExportDxf: () => void;
// Darstellungsart — kontextabhängig: Perspektive nutzt `renderMode`,
// Grundriss nutzt `planColorMode`. Genau einer der beiden ist je View aktiv.
@@ -131,6 +157,30 @@ export interface TopBarProps {
/** Layout-Menü (von App geliefert, damit dessen Logik dort bleibt). */
layoutMenu: ReactNode;
// ── Text-Gruppe (Stempel-/Text-Formatierung) ───────────────────────────────
/**
* Ziel der Text-Formatierung: `null` = nichts Text-artiges selektiert (die
* Controls setzen nur Defaults für neuen Text). Sonst das aktuell selektierte
* Rich-Text-Dokument (z. B. Raumstempel) samt optionalem Bereich und einem
* Setter. Ist `range` `null`, wirken Aktionen auf das GANZE Doc.
*/
textTarget: TextTarget | null;
/**
* Startet das Text-Werkzeug (neues Textobjekt platzieren). `null`, solange das
* Werkzeug noch nicht existiert → der „+ Text"-Knopf ist dann deaktiviert.
*/
onAddText: (() => void) | null;
}
/**
* Beschreibt das aktuell formatierbare Rich-Text-Ziel der Oberleiste. `apply`
* schreibt das neue Dokument zurück in den App-State (z. B. via setRoomStampDoc).
*/
export interface TextTarget {
doc: RichTextDoc;
range: TextRange | null;
apply: (doc: RichTextDoc) => void;
}
/**
@@ -155,6 +205,19 @@ function Sep() {
return <span className="tb-sep" aria-hidden="true" />;
}
/**
* Material-Symbols-Icon für die Oberleiste (gleiche Icon-Font wie ContextMenu/
* MenuIcon). Rein dekorativ — der sprechende Text steckt im `title`/aria-label
* des umgebenden Buttons.
*/
function TbIcon({ name }: { name: string }) {
return (
<span className="material-symbols-outlined tb-ico" aria-hidden="true">
{name}
</span>
);
}
/**
* Kleines Kombinations-Menü (Vectorworks-Stil) — gespiegelt von `LayoutMenu`:
* aktuelle Sichtbarkeit unter einem Namen sichern, eine gespeicherte Kombination
@@ -392,6 +455,376 @@ function ViewIcon({ name }: { name: View3d | "camera" | "grundriss" }) {
}
}
/** Eine Zelle einer Segmentpille. */
interface SegmentCell {
/** Stabiler Schlüssel. */
key: string;
/** Material-Symbols-Icon-Name. */
icon: string;
/** Tooltip/aria-label (Klartext). */
title: string;
/** Aktiver Zustand (Akzentfüllung). */
active?: boolean;
onClick: () => void;
disabled?: boolean;
}
/**
* Segmentpille (DOSSIER-Look): ein Aussencontainer (radius 999, overflow hidden)
* mit gleich hohen Zellen; interne Trenner über `border-left`. Aktive Zelle in
* Akzentfarbe. Genutzt für Zoom-Aktionen, B/I/U und L/C/R. `accent` setzt einen
* Akzent-Rand am Container (Auswahl-Bewusstsein der Text-Gruppe).
*/
function Segment({
cells,
ariaLabel,
accent,
style,
}: {
cells: SegmentCell[];
ariaLabel: string;
accent?: boolean;
style?: React.CSSProperties;
}) {
return (
<div
className={`tb-seg${accent ? " tb-seg-accent" : ""}`}
role="group"
aria-label={ariaLabel}
style={style}
>
{cells.map((c) => (
<button
key={c.key}
type="button"
className={`tb-seg-cell${c.active ? " active" : ""}`}
onClick={c.onClick}
disabled={c.disabled}
aria-pressed={c.active}
title={c.title}
aria-label={c.title}
>
<span className="material-symbols-outlined tb-ico" aria-hidden="true">
{c.icon}
</span>
</button>
))}
</div>
);
}
/** Systemfont-Auswahl der Text-Gruppe. */
const TEXT_FONTS: string[] = [
"Helvetica",
"Arial",
"Inter",
"Times New Roman",
"Georgia",
"Courier New",
];
/** Grössen-Presets (pt) der Text-Gruppe. */
const TEXT_SIZES: number[] = [8, 9, 10, 11, 12, 14, 18, 24, 36, 48];
/**
* Text-Gruppe der Oberleiste (immer sichtbar, 3×2-Raster). Setzt Defaults für
* neuen Text (lokaler State) UND formatiert das aktuelle `textTarget` live. Ist
* `textTarget` gesetzt, tragen die Zeile-2-Pillen einen Akzent-Rand und alle
* Aktionen wirken auf `textTarget.doc` via `textTarget.apply`.
*/
function TextGroup({
textTarget,
onAddText,
}: {
textTarget: TextTarget | null;
onAddText: (() => void) | null;
}) {
// Defaults für neuen Text, solange nichts Text-artiges selektiert ist. Bei
// aktivem Ziel spiegeln die Controls die gemeinsamen Werte des Ziels wider.
const [defFont, setDefFont] = useState<string>("Helvetica");
const [defSize, setDefSize] = useState<number>(12);
const [defAlign, setDefAlign] = useState<Align>("left");
const [defBold, setDefBold] = useState(false);
const [defItalic, setDefItalic] = useState(false);
const [defUnderline, setDefUnderline] = useState(false);
// Effektiver Bereich am Ziel: expliziter Bereich oder — ohne Bereich — das
// ganze Dokument (mind. 1 Zeichen, damit isMarkActive/applyMark greifen).
const targetRange = (doc: RichTextDoc, range: TextRange | null): TextRange =>
range && range.start !== range.end
? range
: { start: 0, end: Math.max(1, docLength(doc)) };
const active = textTarget !== null;
// Aktueller Anzeige-Zustand: vom Ziel abgeleitet, sonst aus den Defaults.
const rng = textTarget ? targetRange(textTarget.doc, textTarget.range) : null;
const isBold = textTarget && rng ? isMarkActive(textTarget.doc, rng, "bold") : defBold;
const isItalic = textTarget && rng ? isMarkActive(textTarget.doc, rng, "italic") : defItalic;
const isUnderline =
textTarget && rng ? isMarkActive(textTarget.doc, rng, "underline") : defUnderline;
const curFont =
textTarget && rng ? commonMarkValue(textTarget.doc, rng, "font") ?? "" : defFont;
const curSize =
textTarget && rng ? commonMarkValue(textTarget.doc, rng, "sizePt") ?? 0 : defSize;
// Ausrichtung: gemeinsamer Wert der berührten Absätze, sonst Default.
const curAlign: Align = textTarget
? commonAlign(textTarget.doc, rng) ?? "left"
: defAlign;
// ── Aktions-Helfer (wirken auf das Ziel oder setzen Defaults) ──────────────
const toggle = (mark: BoolMark, setDefault: (v: boolean) => void, cur: boolean) => {
if (textTarget) {
const r = targetRange(textTarget.doc, textTarget.range);
textTarget.apply(toggleMark(textTarget.doc, r, mark));
} else {
setDefault(!cur);
}
};
const setFontValue = (font: string) => {
if (textTarget) {
const r = targetRange(textTarget.doc, textTarget.range);
textTarget.apply(applyMark(textTarget.doc, r, "font", font));
} else {
setDefFont(font);
}
};
const setSizeValue = (size: number) => {
if (!Number.isFinite(size) || size <= 0) return;
if (textTarget) {
const r = targetRange(textTarget.doc, textTarget.range);
textTarget.apply(applyMark(textTarget.doc, r, "sizePt", size));
} else {
setDefSize(size);
}
};
const setAlignValue = (align: Align) => {
if (textTarget) {
textTarget.apply(applyAlign(textTarget.doc, textTarget.range, align));
} else {
setDefAlign(align);
}
};
const applyPresetValue = (id: string) => {
const preset = DEFAULT_PRESETS.find((p) => p.id === id);
if (!preset) return;
if (textTarget) {
const r = textTarget.range ?? { start: 0, end: 0 };
textTarget.apply(applyPreset(textTarget.doc, r, preset));
} else {
// Ohne Ziel: Preset-Defaults übernehmen (Font bleibt, Marks spiegeln).
if (preset.marks.sizePt != null) setDefSize(preset.marks.sizePt);
setDefBold(!!preset.marks.bold);
setDefItalic(!!preset.marks.italic);
}
};
// ── Options-Listen ─────────────────────────────────────────────────────────
const styleOptions: DropdownOption[] = [
{ value: "__none__", label: t("text.style.none") },
...DEFAULT_PRESETS.map((p) => ({ value: p.id, label: p.label })),
];
const fontOptions: DropdownOption[] = TEXT_FONTS.map((f) => ({
value: f,
label: f,
}));
const sizeInPresets = TEXT_SIZES.includes(curSize);
const sizeOptions: DropdownOption[] = [
...(sizeInPresets || curSize <= 0
? []
: [{ value: "__current__", label: `${curSize} pt` }]),
...TEXT_SIZES.map((n) => ({ value: String(n), label: `${n} pt` })),
{ value: "__custom__", label: t("text.size.custom") },
];
const onSizeChange = (value: string) => {
if (value === "__custom__") {
const input = window.prompt(
t("text.size.customPrompt"),
String(curSize > 0 ? curSize : 12),
);
const n = input == null ? NaN : Math.round(Number(input.trim()));
if (Number.isFinite(n) && n > 0) setSizeValue(n);
return;
}
if (value === "__current__") return;
setSizeValue(Number(value));
};
return (
<div className="tb-group tb-textgroup" role="group" aria-label={t("text.group")}>
{/* Zeile 1: Stil-Preset · Schrift · Grösse. */}
<div className="tb-textgroup-row">
<Dropdown
value="__none__"
onChange={applyPresetValue}
title={t("text.style")}
width={110}
options={styleOptions}
/>
<Dropdown
value={curFont || "__none__"}
onChange={setFontValue}
title={t("text.font")}
width={130}
options={
curFont && !TEXT_FONTS.includes(curFont)
? [{ value: curFont, label: curFont }, ...fontOptions]
: fontOptions
}
/>
<Dropdown
value={
curSize <= 0
? "__custom__"
: sizeInPresets
? String(curSize)
: "__current__"
}
onChange={onSizeChange}
title={t("text.size")}
width={80}
triggerClassName="tb-dd-mono"
options={sizeOptions}
/>
</div>
{/* Zeile 2: B/I/U · L/C/R · „+ Text". */}
<div className="tb-textgroup-row">
<Segment
ariaLabel={t("text.group")}
accent={active}
style={{ width: 110 }}
cells={[
{
key: "bold",
icon: "format_bold",
title: t("text.bold"),
active: !!isBold,
onClick: () => toggle("bold", setDefBold, defBold),
},
{
key: "italic",
icon: "format_italic",
title: t("text.italic"),
active: !!isItalic,
onClick: () => toggle("italic", setDefItalic, defItalic),
},
{
key: "underline",
icon: "format_underlined",
title: t("text.underline"),
active: !!isUnderline,
onClick: () => toggle("underline", setDefUnderline, defUnderline),
},
]}
/>
<Segment
ariaLabel={t("text.group")}
accent={active}
style={{ width: 130 }}
cells={[
{
key: "left",
icon: "format_align_left",
title: t("text.align.left"),
active: curAlign === "left",
onClick: () => setAlignValue("left"),
},
{
key: "center",
icon: "format_align_center",
title: t("text.align.center"),
active: curAlign === "center",
onClick: () => setAlignValue("center"),
},
{
key: "right",
icon: "format_align_right",
title: t("text.align.right"),
active: curAlign === "right",
onClick: () => setAlignValue("right"),
},
]}
/>
<button
type="button"
className="tb-addtext"
disabled={onAddText == null}
onClick={() => onAddText?.()}
title={onAddText ? t("text.add") : t("text.add.hint")}
aria-label={t("text.add")}
>
<span className="material-symbols-outlined tb-ico" aria-hidden="true">
add
</span>
<span className="tb-addtext-label">{t("text.add")}</span>
</button>
</div>
</div>
);
}
/**
* Gemeinsame Absatz-Ausrichtung der vom Bereich berührten Absätze; `undefined`
* bei uneinheitlicher/leerer Auswahl. Ohne Bereich → Ausrichtung des ersten
* Absatzes (das ganze Doc als Ziel).
*/
function commonAlign(doc: RichTextDoc, range: TextRange | null): Align | undefined {
const paras = touchedParagraphs(doc, range);
if (paras.length === 0) return doc.paragraphs[0]?.align ?? "left";
let value: Align | undefined;
let first = true;
for (const idx of paras) {
const a = doc.paragraphs[idx]?.align ?? "left";
if (first) {
value = a;
first = false;
} else if (a !== value) {
return undefined;
}
}
return value;
}
/**
* Setzt die Absatz-Ausrichtung auf alle vom Bereich berührten Absätze. Ohne
* Bereich (null) auf ALLE Absätze des Dokuments.
*/
function applyAlign(doc: RichTextDoc, range: TextRange | null, align: Align): RichTextDoc {
const touched = new Set(touchedParagraphs(doc, range));
const all = range == null;
return {
version: 1,
paragraphs: doc.paragraphs.map((p, i) =>
all || touched.has(i) ? { ...p, align } : p,
),
};
}
/** Indizes der Absätze, die der Bereich [start,end) berührt. */
function touchedParagraphs(doc: RichTextDoc, range: TextRange | null): number[] {
if (range == null || range.start === range.end) {
return doc.paragraphs.map((_, i) => i);
}
const start = Math.min(range.start, range.end);
const end = Math.max(range.start, range.end);
const out: number[] = [];
let cursor = 0;
doc.paragraphs.forEach((p, i) => {
const len = p.runs.reduce((n, r) => n + r.text.length, 0);
const pStart = cursor;
const pEnd = cursor + len;
if (!(pEnd < start || pStart > end)) out.push(i);
cursor += len + 1;
});
return out;
}
export function TopBar({
project,
viewToggleEnabled,
@@ -412,6 +845,8 @@ export function TopBar({
onFit,
onFitSelection,
onZoom100,
onExportPdf,
onExportDxf,
renderMode,
onRenderMode,
renderModeEnabled,
@@ -427,6 +862,8 @@ export function TopBar({
resourcesOpen,
onToggleResources,
layoutMenu,
textTarget,
onAddText,
}: TopBarProps) {
// Dezente Scrollleiste: die Oberleiste scrollt horizontal (overflow-x). Statt
// der prägnanten Standard-Leiste blenden wir eine eigene, sehr ruhige Leiste
@@ -578,8 +1015,10 @@ export function TopBar({
{/* Detailgrad (grob/mittel/fein) — wirkt im Grundriss auf Symbole +
Schraffuren/Linien. In der Perspektive (noch) ohne Wirkung → dort
deaktiviert mit Tooltip statt stiller No-Op. */}
<div className="tb-group" role="group" aria-label={t("topbar.detailGroup")}>
<label className="tb-field">
{/* Detailgrad + Massstab gestapelt — eine Spalte, platzsparend (wie die
Ebenen-/Zeichnungs-Kombis). */}
<div className="tb-group tb-stack" role="group" aria-label={t("topbar.detailGroup")}>
<label className="tb-field tb-field-row">
<span className="tb-label">{t("topbar.detail")}</span>
<Dropdown
value={detail}
@@ -597,16 +1036,7 @@ export function TopBar({
]}
/>
</label>
</div>
<Sep />
{/* Massstab „1:N" (echter Papier-Massstab) + Zoom-Buttons. Das Dropdown
zeigt den gewählten/angewandten Massstab; rechts steht der LIVE 1:N
aus der View-Transform (ändert sich beim Zoomen). Nur aktiv, wenn ein
Plan gezeichnet wird (Perspektive zeigt „—"). */}
<div className="tb-group" role="group" aria-label={t("topbar.scaleGroup")}>
<label className="tb-field">
<label className="tb-field tb-field-row">
<span className="tb-label">{t("topbar.scale")}</span>
<Dropdown
value={inPresets ? String(scaleDenominator) : "__current__"}
@@ -617,42 +1047,103 @@ export function TopBar({
options={scaleOptions}
/>
</label>
<span className="tb-live-scale" title={t("topbar.scale.live")}>
{zoomEnabled && liveScale != null ? `1:${Math.round(liveScale)}` : "—"}
</span>
<div className="tb-btns">
<button
className="tb-btn"
onClick={onZoom100}
disabled={!zoomEnabled}
title={t("topbar.zoom100.hint", { n: scaleDenominator })}
>
{t("topbar.zoom100")}
</button>
<button
className="tb-btn"
onClick={onFit}
disabled={!zoomEnabled}
title={t("topbar.fit.hint")}
>
{t("topbar.fit")}
</button>
<button
className="tb-btn"
onClick={onFitSelection}
disabled={!zoomEnabled}
title={t("topbar.fitSelection.hint")}
>
{t("topbar.fitSelection")}
</button>
</div>
<span className="tb-zoom" title={t("topbar.zoom.current")}>
{zoomEnabled ? `${Math.round(zoomPercent)}%` : "—"}
</span>
</div>
<Sep />
{/* Massstab/Zoom-Cluster (DOSSIER, 2×2). Links über beide Zeilen die
kombinierte Stat-Pille (Live-Massstab 1:N oben / Zoom% unten); rechts
oben das Massstab-Dropdown, rechts unten die Zoom-Segmentpille. „am
Massstab" (Live-Massstab == gewählter Massstab) hebt die Stat-Pille im
Akzent hervor. */}
<div className="tb-group tb-zoomcluster" role="group" aria-label={t("topbar.scaleGroup")}>
<div
className={`tb-stat${
zoomEnabled && liveScale != null && Math.round(liveScale) === scaleDenominator
? " tb-stat-atscale"
: ""
}`}
title={`${t("topbar.scale.live")} · ${t("topbar.zoom.current")}`}
>
<span className="tb-stat-scale">
{zoomEnabled && liveScale != null ? `1:${Math.round(liveScale)}` : "—"}
</span>
<span className="tb-stat-zoom">
{zoomEnabled ? `${Math.round(zoomPercent)}%` : "—"}
</span>
</div>
<div className="tb-zoomcluster-right">
<Dropdown
value={inPresets ? String(scaleDenominator) : "__current__"}
disabled={!zoomEnabled}
onChange={onScaleChange}
title={t("topbar.scale.apply")}
triggerClassName="tb-dd-mono"
width={140}
options={scaleOptions}
/>
<Segment
ariaLabel={t("topbar.scaleGroup")}
cells={[
{
key: "zoom100",
icon: "straighten",
title: t("topbar.zoom100.hint", { n: scaleDenominator }),
onClick: onZoom100,
disabled: !zoomEnabled,
},
{
key: "fit",
icon: "fit_screen",
title: t("topbar.fit.hint"),
onClick: onFit,
disabled: !zoomEnabled,
},
{
key: "fitSelection",
icon: "center_focus_strong",
title: t("topbar.fitSelection.hint"),
onClick: onFitSelection,
disabled: !zoomEnabled,
},
]}
/>
</div>
</div>
{/* Export als eigene kleine Pillen-Reihe (nicht mehr Teil des Zoom-Blocks,
damit der Cluster ruhig bleibt). */}
<div className="tb-group tb-btns" role="group" aria-label={t("topbar.exportPdf")}>
<button
type="button"
className="tb-iconbtn"
onClick={onExportPdf}
disabled={!zoomEnabled}
title={t("topbar.exportPdf.hint")}
aria-label={t("topbar.exportPdf")}
>
<TbIcon name="picture_as_pdf" />
</button>
<button
type="button"
className="tb-iconbtn"
onClick={onExportDxf}
disabled={!zoomEnabled}
title={t("topbar.exportDxf.hint")}
aria-label={t("topbar.exportDxf")}
>
<TbIcon name="download" />
</button>
</div>
<Sep />
{/* Text-Gruppe (immer sichtbar) — Stil/Schrift/Grösse + B/I/U · L/C/R +
„+ Text". Formatiert das aktuelle Text-Ziel (Raumstempel) live. */}
<TextGroup textTarget={textTarget} onAddText={onAddText} />
<Sep />
{/* Darstellungsart (Vectorworks-Stil) — EIN Dropdown, dessen Optionen vom
Ansichtstyp abhängen: Grundriss → Farbig/Schwarz-Weiss (planColorMode);
Perspektive → Schattiert/Weiss/Drahtgitter/Kanten (renderMode). Der
@@ -669,6 +1160,7 @@ export function TopBar({
options={[
{ value: "shaded", label: t("display.style.shaded") },
{ value: "white", label: t("display.style.white") },
{ value: "textured", label: t("display.style.textured") },
{ value: "wireframe", label: t("display.style.wireframe") },
{ value: "hidden", label: t("display.style.hidden") },
]}
@@ -693,54 +1185,58 @@ export function TopBar({
Achslinien im Grundriss. Nur im Grundriss sinnvoll → sonst deaktiviert. */}
<div className="tb-group" role="group" aria-label={t("topbar.referenceLinesGroup")}>
<button
className={`tb-toggle${referenceLines ? " active" : ""}`}
className={`view-icon${referenceLines ? " active" : ""}`}
onClick={() => onReferenceLines(!referenceLines)}
disabled={!referenceLinesEnabled}
aria-pressed={referenceLines}
aria-label={t("topbar.referenceLines")}
title={
referenceLinesEnabled
? t("topbar.referenceLines.hint")
? `${t("topbar.referenceLines")}${t("topbar.referenceLines.hint")}`
: t("topbar.referenceLines.hintDisabled")
}
>
{t("topbar.referenceLines")}
<TbIcon name="straighten" />
</button>
</div>
<Sep />
{/* Linien-Darstellung: Display (alle Haarlinien) ↔ Print (echte mm-
Strichstärken). Nur im Grundriss sinnvoll. */}
Strichstärken). Eine Einfach-Auswahl → als Dropdown (Trigger-Pill +
Häkchen-Liste, wie Detailgrad/Massstab), nicht mehr als Segment-Pillen.
Optionen tragen ein Icon plus Klartext. Nur im Grundriss sinnvoll. */}
<div className="tb-group" role="group" aria-label={t("topbar.lineModeGroup")}>
<div className="view-toggle">
<button
className={`view-btn${lineMode === "display" ? " active" : ""}`}
onClick={() => onLineMode("display")}
<label className="tb-field">
<span className="tb-label">{t("topbar.lineModeGroup")}</span>
<Dropdown
value={lineMode}
disabled={!lineModeEnabled}
title={t("topbar.lineMode.display.hint")}
>
{t("topbar.lineMode.display")}
</button>
<button
className={`view-btn${lineMode === "print" ? " active" : ""}`}
onClick={() => onLineMode("print")}
disabled={!lineModeEnabled}
title={t("topbar.lineMode.print.hint")}
>
{t("topbar.lineMode.print")}
</button>
</div>
onChange={(v) => onLineMode(v as "display" | "print")}
title={
lineModeEnabled
? `${t("topbar.lineMode.display.hint")} · ${t("topbar.lineMode.print.hint")}`
: t("topbar.lineModeGroup")
}
options={[
{ value: "display", label: t("topbar.lineMode.display") },
{ value: "print", label: t("topbar.lineMode.print") },
]}
/>
</label>
</div>
{/* Rechte Gruppe: Layout-Menü · Ressourcen · Projektname. */}
<div className="topbar-right">
{layoutMenu}
<button
className={`res-trigger${resourcesOpen ? " active" : ""}`}
className={`view-icon${resourcesOpen ? " active" : ""}`}
onClick={onToggleResources}
title={t("topbar.resources.hint")}
aria-pressed={resourcesOpen}
aria-label={t("topbar.resources")}
title={`${t("topbar.resources")}${t("topbar.resources.hint")}`}
>
{t("topbar.resources")}
<TbIcon name="widgets" />
</button>
<span className="project-name">{project.name}</span>
</div>
-65
View File
@@ -1,65 +0,0 @@
// Modusleiste der aktiven Transformation (Bewegen/Spiegeln/Drehen), im Stil von
// Vectorworks. Zeigt die Operation, die Vervielfältigungs-Modi U/I/O/P und einen
// kontextuellen Klick-Hinweis. Tasten U/I/O/P schalten die Modi (in App); ein
// Klick auf die Schaltflächen tut dasselbe. Rein gesteuert (controlled).
import { t } from "../i18n";
import type { CopyMode, TransformOp } from "../tools/transform";
const MODES: { key: string; mode: CopyMode; labelKey: string }[] = [
{ key: "U", mode: "move", labelKey: "transform.mode.move" },
{ key: "I", mode: "copy", labelKey: "transform.mode.copy" },
{ key: "O", mode: "array", labelKey: "transform.mode.array" },
{ key: "P", mode: "distribute", labelKey: "transform.mode.distribute" },
];
/** Kontextueller Hinweis je Operation + Anzahl bereits gesetzter Punkte. */
function hintKey(op: TransformOp, pointsLen: number): string {
return `transform.hint.${op}.${Math.min(pointsLen, op === "rotate" ? 2 : 1)}`;
}
export function TransformBar({
op,
copyMode,
count,
pointsLen,
onMode,
onCancel,
}: {
op: TransformOp;
copyMode: CopyMode;
count: number;
pointsLen: number;
onMode: (mode: CopyMode) => void;
onCancel: () => void;
}) {
return (
<div className="transform-bar" role="group" aria-label={t("transform.bar")}>
<span className="tf-op">{t(`transform.${op}`)}</span>
<span className="tf-sep" />
<div className="tf-modes">
{MODES.map((m) => (
<button
key={m.mode}
className={`tf-mode${copyMode === m.mode ? " active" : ""}`}
onClick={() => onMode(m.mode)}
title={t(m.labelKey)}
>
<span className="tf-key">{m.key}</span>
<span className="tf-mode-label">
{t(m.labelKey)}
{(m.mode === "array" || m.mode === "distribute") && copyMode === m.mode
? ` ×${count}`
: ""}
</span>
</button>
))}
</div>
<span className="tf-sep" />
<span className="tf-hint">{t(hintKey(op, pointsLen))}</span>
<button className="tf-cancel" onClick={onCancel} title={t("transform.cancel")}>
</button>
</div>
);
}