Schnittebenen 2D↔3D Phase 1+2, native Fenster, Hell/Dunkel-Theme, SWISSIMAGE-Import

- Schnittebenen: 3D-Live-Schnitt folgt der gewählten Grundriss-Schnittlinie
  (section3dCutId/sectionPlaneFromLevel), Schalter "Im 3D schneiden" im
  Objekt-Info, Doppelklick auf Schnittlinie springt in 2D-Schnittansicht,
  unsichtbare Ebenen blenden ihre Führungslinie aus
- Eigene native Tauri-Fenster für Kontext-Import/Zeichnungsebenen/
  Ebenen-Einstellungen/Ressourcen/Einstellungen + klassische Menüleiste
  (AppMenuBar) neben der Wortmarke
- Hell/Dunkel-Umschalter (Einstellungen → Darstellung), persistiert,
  flackerfrei vor erstem Render gesetzt
- SWISSIMAGE-Luftbild-Import (swisstopo WMS) als Kontext-Hintergrundebene
- UI-Politur: Werkzeug-Panel Symbole/Liste umschaltbar, Topbar-Quick-Access-
  Icons entfernt, Zahnrad→Einstellungen in Panel-Köpfen, Footerbar/
  Snap-Marker/Maß-HUD auf helle Pillen-Sprache umgestellt
- Neues Dachziegel-Material (RoofingTiles013A)
This commit is contained in:
2026-07-20 10:51:37 +02:00
parent 0ab0361e3d
commit 68a0459d0e
90 changed files with 7730 additions and 1849 deletions
+135
View File
@@ -0,0 +1,135 @@
// Klassische Menüleiste (Bearbeiten/Ansicht/Fenster) neben der Wortmarke in
// der TopBar — ergänzt die bestehende Quick-Access-Icon-Leiste um textuelle
// Menüs im Rhino-/Desktop-Stil. Reine Darstellung + Callbacks (voll gesteuert,
// wie die übrige TopBar); jeder Eintrag ruft eine bereits existierende
// App.tsx-Funktion auf (kein Dummy-/Platzhalter-Eintrag).
//
// Bezeichner englisch, UI-Text über i18n (CONVENTIONS.md).
import { Dropdown } from "./Dropdown";
import type { DropdownItem } from "./Dropdown";
import { t } from "../i18n";
import type { View3d, ViewType } from "./TopBar";
export interface AppMenuBarProps {
/** Bearbeiten: Undo/Redo (Store) + Löschen der aktuellen Auswahl. */
onUndo: () => void;
onRedo: () => void;
canUndo: boolean;
canRedo: boolean;
onDeleteSelection: () => void;
hasSelection: boolean;
/** Ansicht: Grundriss/Perspektive-Umschalter, 3D-Blickwinkel-Presets,
* Referenzlinien-Toggle. */
viewType: ViewType;
onViewType: (v: ViewType) => void;
view3d: View3d;
onView3d: (v: View3d) => void;
referenceLines: boolean;
onReferenceLines: (v: boolean) => void;
/** Fenster: Ressourcen-Fenster + Einstellungen (bestehende Panels). */
resourcesOpen: boolean;
onToggleResources: () => void;
onOpenSettings: () => void;
}
/** Menüleiste (Bearbeiten/Ansicht/Fenster) — sitzt in der TopBar neben der Wortmarke. */
export function AppMenuBar({
onUndo,
onRedo,
canUndo,
canRedo,
onDeleteSelection,
hasSelection,
viewType,
onViewType,
view3d,
onView3d,
referenceLines,
onReferenceLines,
resourcesOpen,
onToggleResources,
onOpenSettings,
}: AppMenuBarProps) {
const editItems: DropdownItem[] = [
{ label: t("menu.edit.undo"), onSelect: onUndo, disabled: !canUndo },
{ label: t("menu.edit.redo"), onSelect: onRedo, disabled: !canRedo },
{ divider: true },
{
label: t("menu.edit.delete"),
onSelect: onDeleteSelection,
disabled: !hasSelection,
danger: true,
},
];
const VIEW3D_PRESETS: [View3d, string][] = [
["front", t("view3d.front")],
["back", t("view3d.back")],
["side", t("view3d.side")],
["left", t("view3d.left")],
["top", t("view3d.top")],
["iso", t("view3d.iso")],
["perspective", t("view3d.perspective")],
];
const viewItems: DropdownItem[] = [
{
label: t("menu.view.floorplan"),
onSelect: () => onViewType("grundriss"),
active: viewType === "grundriss",
},
{
label: t("menu.view.perspective"),
onSelect: () => onViewType("perspektive"),
active: viewType === "perspektive",
},
{ divider: true },
...VIEW3D_PRESETS.map(([id, label]) => ({
label,
onSelect: () => onView3d(id),
active: view3d === id,
disabled: viewType !== "perspektive",
})),
{ divider: true },
{
label: t("menu.view.referenceLines"),
onSelect: () => onReferenceLines(!referenceLines),
active: referenceLines,
},
];
const windowItems: DropdownItem[] = [
{
label: t("menu.window.resources"),
onSelect: onToggleResources,
active: resourcesOpen,
},
{ divider: true },
{ label: t("menu.window.settings"), onSelect: onOpenSettings },
];
return (
<div className="tb-menubar" role="menubar">
<Dropdown
items={editItems}
placeholder={t("menu.edit")}
title={t("menu.edit")}
triggerClassName="tb-menubar-item"
/>
<Dropdown
items={viewItems}
placeholder={t("menu.view")}
title={t("menu.view")}
triggerClassName="tb-menubar-item"
/>
<Dropdown
items={windowItems}
placeholder={t("menu.window")}
title={t("menu.window")}
triggerClassName="tb-menubar-item"
/>
</div>
);
}
+187 -31
View File
@@ -17,13 +17,15 @@ import { fetchBuildings3d } from "../io/swissBuildings3d";
import type { BuildingsVersion } from "../io/swissBuildings3d";
import { fetchTerrainXyz } from "../io/swissAlti3d";
import type { TerrainResolution } from "../io/swissAlti3d";
import { fetchSwissImage } from "../io/swissImage";
import { fetchOsm } from "../io/osm";
import type { OsmSelection } from "../io/osm";
import { featuresToContextObjects } from "../io/geoContext";
import type { GeoFeature } from "../io/geoContext";
import { terrainMeshFromGrid } from "../model/terrain";
import type { GeoOrigin } from "../io/lv95";
import type { ContextObject } from "../model/types";
import { makeOrigin } from "../io/lv95";
import type { GeoOrigin, LV95 } from "../io/lv95";
import type { ContextObject, TerrainMesh } from "../model/types";
/** Gebäude-Import-Modus: aus, vereinfachte Box (schnell, alt) oder echte
* swissBUILDINGS3D-Geometrie (Wände+Dach) in einer der zwei Generationen. */
@@ -33,6 +35,8 @@ export type SwissBuildingsMode = "off" | "flach" | "v2" | "v3";
* LV95-Koordinate (s. `Project.geoAnchor`). */
export interface GeoAnchor {
lv95: { e: number; n: number };
/** Reale Höhe (m ü. M.) des Bezugspunkts — informativ, s. Project.geoAnchor. */
height?: number;
model: { x: number; y: number };
label?: string;
}
@@ -45,11 +49,21 @@ export interface ContextImportDialogProps {
geoAnchor?: GeoAnchor;
/** Setzt/ersetzt (`undefined` löscht) den Georeferenzierungs-Bezug. */
onSetGeoAnchor: (anchor: GeoAnchor | undefined) => void;
/**
* Läuft in einem EIGENEN nativen Fenster (siehe native/contextImportWindow.ts)
* statt als abgedunkelter Overlay-Dialog im Hauptfenster — kein Backdrop,
* füllt die Fensterfläche, Kopfzeile wird zum Ziehbereich statt Schliessen-
* Knopf (Abbrechen/Importieren im Fuss bleiben — das sind echte Aktionen,
* kein reiner Schliessen-Button wie bei SettingsDialog).
*/
windowed?: boolean;
}
type Sources = {
swissBuildings: SwissBuildingsMode;
swissTerrain: boolean;
swissImage: boolean;
swissImageDrape: boolean;
osmBuildings: boolean;
osmRoads: boolean;
osmWater: boolean;
@@ -64,6 +78,8 @@ const DEFAULT_SOURCES: Sources = {
// Städten sehr grosse Kacheln liefern (s. swissBuildings3d.ts-Kommentar).
swissBuildings: "v2",
swissTerrain: true,
swissImage: false,
swissImageDrape: false,
osmBuildings: false,
osmRoads: true,
osmWater: true,
@@ -78,6 +94,7 @@ export function ContextImportDialog({
onClose,
geoAnchor,
onSetGeoAnchor,
windowed = false,
}: ContextImportDialogProps) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
@@ -91,6 +108,14 @@ export function ContextImportDialog({
const [candidates, setCandidates] = useState<GeocodeCandidate[]>([]);
const [selected, setSelected] = useState<GeocodeCandidate | null>(null);
const [radius, setRadius] = useState(200);
// Optionaler Bezugspunkt: welche LV95-Koordinate soll im Modell auf (0,0)
// liegen (der „neue Nullpunkt", z. B. ein amtlicher Vermessungspunkt aus dem
// Kataster). Leer = das Suchzentrum wird Modell-(0,0) (bisheriges Verhalten).
const [refPoint, setRefPoint] = useState<{ e: string; n: string; h: string }>({
e: "",
n: "",
h: "",
});
const [sources, setSources] = useState<Sources>(DEFAULT_SOURCES);
// 2 m = Default (schneller/kleinere Kacheln); 0.5 m für Detailarbeit.
const [terrainResolution, setTerrainResolution] = useState<TerrainResolution>("2");
@@ -129,6 +154,8 @@ export function ContextImportDialog({
const anySource =
sources.swissBuildings !== "off" ||
sources.swissTerrain ||
sources.swissImage ||
sources.swissImageDrape ||
sources.osmBuildings ||
sources.osmRoads ||
sources.osmWater ||
@@ -144,13 +171,29 @@ export function ContextImportDialog({
setStatus(t("ctxImport.working"));
try {
const center = selected.lv95;
// Bestehenden Georeferenzierungs-Bezug wiederverwenden, statt bei jedem
// Import einen neuen (inkonsistenten) zu berechnen — der Bezugspunkt
// bleibt fix im Modell, nur die GESUCHTE Stelle (center/radius) steuert,
// WOVON Daten geladen werden (s. Project.geoAnchor-Doc).
let origin: GeoOrigin | undefined = geoAnchor
// Optionaler Bezugspunkt (LV95): dieser Punkt wird zu Modell-(0,0). Leer
// -> das Suchzentrum wird der Nullpunkt (bisheriges Verhalten).
const refE = Number(refPoint.e);
const refN = Number(refPoint.n);
const hasRef =
refPoint.e.trim() !== "" &&
refPoint.n.trim() !== "" &&
Number.isFinite(refE) &&
Number.isFinite(refN);
const refLv95: LV95 = hasRef ? { e: refE, n: refN } : center;
// Optionale Höhe (m ü. M.) des Bezugspunkts — rein informativ (kein Shift).
const refH = Number(refPoint.h);
const hasRefH = refPoint.h.trim() !== "" && Number.isFinite(refH);
// Der gesuchte Ort landet nahe dem Modell-Nullpunkt (Modell rechnet in
// float32 — LV95-Koordinaten im Millionenbereich würden 2D/3D unbrauchbar
// ungenau darstellen). Der Bezug (geoAnchor) merkt sich „Modell-(0,0) =
// dieser LV95-Punkt", damit der IFC-/DXF-Export trotzdem die echten
// Landeskoordinaten schreibt und weitere Importe lagerichtig dazu liegen.
// Mit „Neuer Bezugspunkt" (georef) lässt sich der Ursprung später auf einen
// beliebigen Modellpunkt verschieben.
let origin: GeoOrigin = geoAnchor
? { e: geoAnchor.lv95.e - geoAnchor.model.x, n: geoAnchor.lv95.n - geoAnchor.model.y }
: undefined;
: makeOrigin(refLv95);
const all: GeoFeature[] = [];
// Echte swissBUILDINGS3D-Meshes (v2/v3) landen direkt als ContextObject
// (kein Footprint+Box-Umweg über GeoFeature/featuresToContextObjects).
@@ -200,8 +243,10 @@ export function ContextImportDialog({
}
// Gelände-Höhenraster (echtes swissALTI3D-XYZ, native Auflösung) → Terrain-Mesh.
let terrain: ContextObject | null = null;
if (sources.swissTerrain) {
// Die Drapierung braucht das TIN -> Terrain auch dann holen, wenn nur
// „Luftbild aufs Gelände" (ohne eigenen Terrain-Haken) gewählt wäre.
let terrain: TerrainMesh | null = null;
if (sources.swissTerrain || sources.swissImageDrape) {
const r = await fetchTerrainXyz(center, radius, origin, terrainResolution);
origin = r.origin;
if (r.grid) {
@@ -210,7 +255,30 @@ export function ContextImportDialog({
}
}
if (all.length === 0 && !terrain && extraObjs.length === 0) {
// SWISSIMAGE-Luftbild (WMS) → georeferenziertes Rasterbild (EIN ContextImage).
// • `swissImage` → 2D-Grundriss-Hintergrund.
// • `swissImageDrape` → `drape:true`: der 3D-Viewer legt es pixelscharf als
// Textur aufs Gelände (render3d `set_aerial_texture`).
// Beide Modi teilen dasselbe Bild; das Bild wird nur EINMAL geladen.
let image: ContextObject | null = null;
if (sources.swissImage || sources.swissImageDrape) {
const r = await fetchSwissImage(center, radius, origin);
origin = r.origin;
if (r.dataUrl) {
image = {
id: `img-${Date.now()}`,
type: "image",
name: `Luftbild ${selected.label}`,
layer: "Luftbild",
src: r.dataUrl,
min: r.min,
max: r.max,
drape: sources.swissImageDrape,
};
}
}
if (all.length === 0 && !terrain && !image && extraObjs.length === 0) {
setError(
buildingsSkipped > 0
? t("ctxImport.buildingsSkipped", { count: buildingsSkipped })
@@ -220,16 +288,26 @@ export function ContextImportDialog({
return;
}
// Erster Import in diesem Projekt (noch kein Bezug) -> DIESEN Lauf als
// Georeferenzierungs-Bezug speichern (Modell-(0,0) = der gesuchte Ort),
// damit künftige Importe lagerichtig zueinander bleiben.
if (!geoAnchor && origin) {
onSetGeoAnchor({ lv95: { e: origin.e, n: origin.n }, model: { x: 0, y: 0 }, label: selected.label });
// Erster Import in diesem Projekt (noch kein Bezug) -> Bezug speichern:
// Modell-(0,0) entspricht dem gesuchten Ort (LV95 `center`). Damit landet
// der Import nahe dem Ursprung (gut darstellbar) UND der Modell-Export
// kann später auf die echten LV95-Koordinaten zurückrechnen.
if (!geoAnchor) {
const anchorLabel = hasRef
? t("ctxImport.refPoint.anchorLabel", { e: Math.round(refE), n: Math.round(refN) })
: selected.label;
onSetGeoAnchor({
lv95: refLv95,
height: hasRefH ? refH : undefined,
model: { x: 0, y: 0 },
label: anchorLabel,
});
}
const objs = featuresToContextObjects(all, selected.label);
objs.push(...extraObjs);
if (terrain) objs.push(terrain);
if (image) objs.push(image);
onImport(objs);
setStatus(
buildingsSkipped > 0
@@ -252,32 +330,47 @@ export function ContextImportDialog({
}
};
return (
<div className="imp-overlay" onClick={onClose}>
const dialog = (
<div
className="imp-dialog"
className={`imp-dialog${windowed ? " imp-dialog-embedded" : ""}`}
role="dialog"
aria-modal="true"
aria-label={t("ctxImport.title")}
onClick={(e) => e.stopPropagation()}
onClick={windowed ? undefined : (e) => e.stopPropagation()}
>
<header className="imp-head">
<header
className="imp-head"
{...(windowed ? { "data-tauri-drag-region": true } : {})}
>
<span className="imp-head-title">{t("ctxImport.title")}</span>
<button
className="imp-close"
onClick={onClose}
aria-label={t("ctxImport.close")}
>
×
</button>
{!windowed && (
<button
className="imp-close"
onClick={onClose}
aria-label={t("ctxImport.close")}
>
×
</button>
)}
</header>
<div className="imp-body">
{/* Georeferenzierungs-Bezug: sobald gesetzt, teilen sich ALLE Importe
dieses Projekts denselben Modell↔LV95-Bezug (s. Project.geoAnchor). */}
{geoAnchor && (
dieses Projekts denselben Modell↔LV95-Bezug (s. Project.geoAnchor).
Ohne Bezug wird ROH importiert (echte LV95-Koordinaten, weit vom
Nullpunkt) — ein Hinweis erklärt den Folge-Schritt „Neuer
Bezugspunkt". */}
{geoAnchor ? (
<div className="cim-geo-anchor">
<span>{t("ctxImport.geoAnchor", { label: geoAnchor.label ?? "?" })}</span>
<span>
{t("ctxImport.geoAnchor", { label: geoAnchor.label ?? "?" })}
{" — "}
{t("ctxImport.geoAnchor.coords", {
e: geoAnchor.lv95.e.toFixed(2),
n: geoAnchor.lv95.n.toFixed(2),
h: typeof geoAnchor.height === "number" ? geoAnchor.height.toFixed(2) : "—",
})}
</span>
<button
type="button"
className="imp-linklike"
@@ -287,6 +380,8 @@ export function ContextImportDialog({
{t("ctxImport.geoAnchor.reset")}
</button>
</div>
) : (
<div className="cim-geo-anchor cim-geo-hint">{t("ctxImport.rawHint")}</div>
)}
{/* Ortssuche */}
<section className="imp-section">
@@ -355,6 +450,45 @@ export function ContextImportDialog({
</div>
</section>
{/* Bezugspunkt (Modell-Nullpunkt): nur solange noch kein Bezug steht.
Leer -> Suchzentrum wird (0,0); gefüllt -> dieser LV95-Punkt wird
(0,0) (z. B. amtlicher Vermessungspunkt aus dem Kataster). */}
{!geoAnchor && (
<section className="imp-section">
<div className="imp-section-title">{t("ctxImport.refPoint")}</div>
<div className="cim-radius-row">
<input
className="cim-input cim-input-num"
type="number"
step={0.01}
value={refPoint.e}
placeholder={t("ctxImport.refPoint.e")}
onChange={(e) => setRefPoint((p) => ({ ...p, e: e.target.value }))}
aria-label={t("ctxImport.refPoint.e")}
/>
<input
className="cim-input cim-input-num"
type="number"
step={0.01}
value={refPoint.n}
placeholder={t("ctxImport.refPoint.n")}
onChange={(e) => setRefPoint((p) => ({ ...p, n: e.target.value }))}
aria-label={t("ctxImport.refPoint.n")}
/>
<input
className="cim-input cim-input-num"
type="number"
step={0.01}
value={refPoint.h}
placeholder={t("ctxImport.refPoint.h")}
onChange={(e) => setRefPoint((p) => ({ ...p, h: e.target.value }))}
aria-label={t("ctxImport.refPoint.h")}
/>
</div>
<div className="cim-geo-hint">{t("ctxImport.refPoint.hint")}</div>
</section>
)}
{/* Quellen */}
<section className="imp-section">
<div className="imp-section-title">{t("ctxImport.sources")}</div>
@@ -397,6 +531,22 @@ export function ContextImportDialog({
<option value="0.5">{t("ctxImport.terrain.res05")}</option>
</select>
</label>
<label className="cim-check">
<input
type="checkbox"
checked={sources.swissImage}
onChange={() => toggle("swissImage")}
/>
{t("ctxImport.src.swissImage")}
</label>
<label className="cim-check">
<input
type="checkbox"
checked={sources.swissImageDrape}
onChange={() => toggle("swissImageDrape")}
/>
{t("ctxImport.src.swissImageDrape")}
</label>
<label className="cim-check">
<input
type="checkbox"
@@ -473,6 +623,12 @@ export function ContextImportDialog({
</button>
</footer>
</div>
);
if (windowed) return dialog;
return (
<div className="imp-overlay" onClick={onClose}>
{dialog}
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
// Beschriftetes Feld des Ebenen-/Geschoss-InlineEditors (App.tsx) — extrahiert,
// damit das native Ebeneneinstellungs-Fenster (native/LayerSettingsWindowApp.tsx)
// dasselbe Layout wiederverwendet, ohne den ganzen App.tsx-Modulgraph in das
// (bewusst schlanke) Nebenfenster-Bundle zu ziehen.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import type { ReactNode } from "react";
export function EditorField({ label, children }: { label: string; children: ReactNode }) {
return (
<label className="editor-field">
<span className="editor-label">{label}</span>
<span className="editor-control">{children}</span>
</label>
);
}
+91
View File
@@ -0,0 +1,91 @@
// Arbeitsumgebung-Menü (Fenster-/Dock-Layouts) — aktuelles Layout speichern,
// benanntes Layout laden/löschen. Extrahiert aus App.tsx, damit SettingsDialog
// es sowohl inline (Haupt-App) als auch in seinem eigenen nativen Fenster
// (native/SettingsWindowApp.tsx) rendern kann, ohne es als vorgefertigtes
// ReactNode über die Fenstergrenze reichen zu müssen (JSX ist nicht
// serialisierbar). Speichern/Laden/Löschen laufen direkt über localStorage
// (panels/layout.ts) — funktioniert in JEDEM Fenster identisch, da alle
// Tauri-Fenster derselben App dieselbe Origin/denselben Storage teilen. Nur
// „aktuelles Layout übernehmen" (`onApply`) muss zurück ins Fenster mit dem
// echten App-Zustand — daher weiterhin ein Callback-Prop, kein Store-Zugriff
// hier.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { useState } from "react";
import { Dropdown } from "./Dropdown";
import type { DropdownItem } from "./Dropdown";
import { PromptDialog } from "./PromptDialog";
import { t } from "../i18n";
import {
deleteNamedLayout,
listNamedLayouts,
loadNamedLayout,
saveNamedLayout,
} from "../panels/layout";
import type { LayoutState } from "../panels/types";
export function LayoutMenu({
layout,
onApply,
}: {
layout: LayoutState;
onApply: (layout: LayoutState) => void;
}) {
const [names, setNames] = useState<string[]>([]);
// Name-Erfassung über PromptDialog statt window.prompt (im Tauri-WKWebView
// deaktiviert — liefert sofort null, nichts würde je gespeichert).
const [saving, setSaving] = useState(false);
const refresh = () => setNames(listNamedLayouts());
const onSave = () => setSaving(true);
const commitSave = (name: string) => {
saveNamedLayout(name.trim(), layout);
refresh();
setSaving(false);
};
// Aktions-Menü (analog ComboMenu in TopBar.tsx): „Aktuelles speichern" oben,
// dann je eine Gruppe „Laden" und „Löschen" (Trenner statt <optgroup>, die
// die Dropdown-Komponente nicht kennt).
const items: DropdownItem[] = [];
items.push({ label: t("layout.saveCurrent"), onSelect: onSave });
if (names.length > 0) {
items.push({ divider: true });
for (const n of names) {
items.push({
label: n,
onSelect: () => {
const loaded = loadNamedLayout(n);
if (loaded) onApply(loaded);
},
});
}
items.push({ divider: true });
for (const n of names) {
items.push({
label: `✕ ${n}`,
danger: true,
onSelect: () => {
deleteNamedLayout(n);
refresh();
},
});
}
}
return (
<span onPointerDownCapture={refresh}>
<Dropdown items={items} placeholder={t("layout.placeholder")} title={t("layout.title")} />
{saving && (
<PromptDialog
title={t("layout.title")}
label={t("layout.savePrompt")}
onConfirm={commitSave}
onClose={() => setSaving(false)}
/>
)}
</span>
);
}
+56 -23
View File
@@ -2393,6 +2393,14 @@ function WindowStylesTab({
/>
</FieldRow>
)}
<FieldRow label={t("resources.field.innerLayerRecess")}>
<input
type="checkbox"
checked={!!win.innerLayerRecess}
onChange={(e) => patch({ innerLayerRecess: e.target.checked })}
title={t("resources.field.innerLayerRecess.hint")}
/>
</FieldRow>
<FieldRow label={t("resources.field.transomHeight")}>
<NumberField
value={win.transomHeight ?? 0}
@@ -3114,12 +3122,20 @@ export function ResourceManager({
onClose,
handlers,
initialTab,
windowed = false,
}: {
project: Project;
onClose: () => void;
handlers: ResourceManagerHandlers;
/** Beim Öffnen (bzw. bei erneutem Sprung) direkt aktiver Tab, z. B. „doorStyles". */
initialTab?: TabId;
/**
* Läuft in einem EIGENEN nativen Fenster (siehe native/resourceWindow.ts)
* statt als schwebendes Overlay im Hauptfenster: füllt die Fensterfläche
* komplett (kein eigenes Verschieben/Grössenändern per JS — das übernimmt
* das OS-Fenster), Kopfzeile bleibt nur als Tab-Träger ohne Drag-Cursor.
*/
windowed?: boolean;
}) {
const [tab, setTab] = useState<TabId>(initialTab ?? "components");
// Erneuter Sprung aus einem Element heraus (z. B. „Typ bearbeiten" an einer
@@ -3185,30 +3201,44 @@ export function ResourceManager({
return (
<aside
className="res-window"
className={`res-window${windowed ? " res-window-embedded" : ""}`}
role="dialog"
aria-label={t("resources.label")}
style={{
left: `${rect.x}px`,
top: `${rect.y}px`,
width: `${rect.w}px`,
height: `${rect.h}px`,
}}
style={
windowed
? undefined
: {
left: `${rect.x}px`,
top: `${rect.y}px`,
width: `${rect.w}px`,
height: `${rect.h}px`,
}
}
>
<header className="res-head" onPointerDown={onTitlePointerDown}>
<header
className="res-head"
onPointerDown={windowed ? undefined : onTitlePointerDown}
// Im eigenen nativen Fenster (randlos, s. BORDERLESS_WINDOW_CHROME)
// ist diese Kopfzeile der einzige Ziehbereich — genau wie die
// TopBar des Hauptfensters (`data-tauri-drag-region`). Ohne OS-
// Titelzeile bleibt so trotzdem EIN Griff zum Verschieben.
{...(windowed ? { "data-tauri-drag-region": true } : {})}
>
<div className="res-head-title">
<span className="res-head-icon">
<EyeIcon open />
</span>
{t("resources.title")}
</div>
<button
className="res-close"
onClick={onClose}
aria-label={t("resources.close")}
>
×
</button>
{!windowed && (
<button
className="res-close"
onClick={onClose}
aria-label={t("resources.close")}
>
×
</button>
)}
</header>
<nav className="res-tabs" role="tablist">
@@ -3309,14 +3339,17 @@ export function ResourceManager({
)}
</div>
{/* Greifer unten rechts (Größe ändern). */}
<div
className="floating-resize res-resize"
role="separator"
aria-label={t("dock.resizePanel")}
title={t("dock.resizePanel")}
onPointerDown={onResizePointerDown}
/>
{/* Greifer unten rechts (Größe ändern) — im eigenen OS-Fenster übernimmt
das Betriebssystem das Grössenändern, daher windowed ohne Greifer. */}
{!windowed && (
<div
className="floating-resize res-resize"
role="separator"
aria-label={t("dock.resizePanel")}
title={t("dock.resizePanel")}
onPointerDown={onResizePointerDown}
/>
)}
</aside>
);
}
+79 -30
View File
@@ -10,20 +10,26 @@
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { useEffect, useState } from "react";
import type { ReactNode } from "react";
import { t } from "../i18n";
import { ACCENT_SWATCHES } from "../theme/accents";
import type { Project } from "../model/types";
import { ColorHexField } from "./ColorHexField";
import { LayoutMenu } from "./LayoutMenu";
import type { RendererMode } from "./StatusBar";
import type { LayoutState } from "../panels/types";
import type { ThemeMode } from "../theme/themeMode";
export interface SettingsDialogProps {
/** Hell/Dunkel — Standard ist Hell (siehe theme/themeMode.ts). */
themeMode: ThemeMode;
onThemeModeChange: (mode: ThemeMode) => void;
marqueeColor: string;
onMarqueeColorChange: (hex: string) => void;
snapColor: string;
onSnapColorChange: (hex: string) => void;
project: Project;
setProject: (next: Project | ((p: Project) => Project)) => void;
/** Referenzhöhe des Erdgeschosses (m ü. M.) — statt des ganzen Projekts nur
* dieses eine Feld + sein Setter (schmale Grenzfläche, s. `windowed`). */
referenceElevationMasl: number | undefined;
onReferenceElevationChange: (n: number | undefined) => void;
/** Aktuell gewählter Plan-Renderer (aus dem `?engine`-URL-Param abgeleitet). */
rendererMode: RendererMode;
/** Ist `navigator.gpu` vorhanden? Steuert, ob „Nordstern" wählbar ist. */
@@ -33,23 +39,37 @@ export interface SettingsDialogProps {
* Live-Remount der Renderer-Hooks).
*/
onRendererModeChange: (mode: RendererMode) => void;
/** Arbeitsumgebung-Auswahl (Fenster-/Dock-Layouts) — früher in der TopBar. */
layoutMenu: ReactNode;
/** Arbeitsumgebung (Fenster-/Dock-Layouts) — LayoutMenu wird HIER gerendert
* (nicht mehr als vorgefertigtes ReactNode hereingereicht: JSX ist über eine
* Fenstergrenze nicht serialisierbar, s. `windowed`). */
layout: LayoutState;
onApplyLayout: (layout: LayoutState) => void;
onClose: () => void;
/**
* Läuft in einem EIGENEN nativen Fenster (siehe native/settingsWindow.ts)
* statt als abgedunkelter Overlay-Dialog im Hauptfenster: kein Backdrop/
* Klick-aussen-schliesst (kein „aussen" in einem echten OS-Fenster), füllt
* die Fensterfläche, Kopfzeile wird zum Ziehbereich statt zum Schliessen-Klick.
*/
windowed?: boolean;
}
export function SettingsDialog({
themeMode,
onThemeModeChange,
marqueeColor,
onMarqueeColorChange,
snapColor,
onSnapColorChange,
project,
setProject,
referenceElevationMasl,
onReferenceElevationChange,
rendererMode,
webGpuAvailable,
onRendererModeChange,
layoutMenu,
layout,
onApplyLayout,
onClose,
windowed = false,
}: SettingsDialogProps) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
@@ -64,47 +84,49 @@ export function SettingsDialog({
// gerundeten Projektwert zu schreiben. Synchronisiert sich, wenn sich das
// Projekt von aussen ändert (z. B. Undo/Import).
const [elevationText, setElevationText] = useState(
project.referenceElevationMasl != null ? String(project.referenceElevationMasl) : "",
referenceElevationMasl != null ? String(referenceElevationMasl) : "",
);
useEffect(() => {
setElevationText(
project.referenceElevationMasl != null ? String(project.referenceElevationMasl) : "",
);
}, [project.referenceElevationMasl]);
setElevationText(referenceElevationMasl != null ? String(referenceElevationMasl) : "");
}, [referenceElevationMasl]);
const commitElevation = (raw: string) => {
setElevationText(raw);
const trimmed = raw.trim();
if (trimmed === "") {
setProject((p) => ({ ...p, referenceElevationMasl: undefined }));
onReferenceElevationChange(undefined);
return;
}
const n = Number(trimmed);
if (Number.isFinite(n)) {
setProject((p) => ({ ...p, referenceElevationMasl: n }));
onReferenceElevationChange(n);
}
};
return (
<div className="imp-overlay" onClick={onClose}>
const dialog = (
<div
className="imp-dialog"
className={`imp-dialog${windowed ? " imp-dialog-embedded" : ""}`}
role="dialog"
aria-modal="true"
aria-label={t("settings.title")}
onClick={(e) => e.stopPropagation()}
onClick={windowed ? undefined : (e) => e.stopPropagation()}
>
<header className="imp-head">
<header
className="imp-head"
{...(windowed ? { "data-tauri-drag-region": true } : {})}
>
<span className="imp-head-title">{t("settings.title")}</span>
<button className="imp-close" onClick={onClose} aria-label={t("export.close")}>
×
</button>
{!windowed && (
<button className="imp-close" onClick={onClose} aria-label={t("export.close")}>
×
</button>
)}
</header>
<div className="imp-body">
<section className="imp-section">
<div className="imp-section-title">{t("settings.section.workspace")}</div>
{layoutMenu}
<LayoutMenu layout={layout} onApply={onApplyLayout} />
</section>
<section className="imp-section">
@@ -136,6 +158,25 @@ export function SettingsDialog({
<section className="imp-section">
<div className="imp-section-title">{t("settings.section.display")}</div>
<span className="settings-field-label">{t("settings.theme")}</span>
<span className="sb-renderer-toggle" role="group" aria-label={t("settings.theme")}>
<button
type="button"
className={`sb-renderer-btn${themeMode === "light" ? " active" : ""}`}
aria-pressed={themeMode === "light"}
onClick={() => onThemeModeChange("light")}
>
{t("settings.theme.light")}
</button>
<button
type="button"
className={`sb-renderer-btn${themeMode === "dark" ? " active" : ""}`}
aria-pressed={themeMode === "dark"}
onClick={() => onThemeModeChange("dark")}
>
{t("settings.theme.dark")}
</button>
</span>
<AccentColorField
label={t("settings.marqueeColor")}
hint={t("settings.marqueeColor.hint")}
@@ -169,12 +210,20 @@ export function SettingsDialog({
</section>
</div>
<footer className="imp-foot">
<button className="imp-btn primary" onClick={onClose}>
{t("export.close")}
</button>
</footer>
{!windowed && (
<footer className="imp-foot">
<button className="imp-btn primary" onClick={onClose}>
{t("export.close")}
</button>
</footer>
)}
</div>
);
if (windowed) return dialog;
return (
<div className="imp-overlay" onClick={onClose}>
{dialog}
</div>
);
}
+168 -638
View File
@@ -16,10 +16,6 @@ import { Dropdown } from "./Dropdown";
import type { DropdownItem, DropdownOption } from "./Dropdown";
import { PromptDialog } from "./PromptDialog";
import { WindowControls } from "./WindowControls";
import { RibbonTabs } from "./ribbon/RibbonBar";
import type { RibbonTabId } from "./ribbon/ribbonItems";
import { getCommand } from "../commands/registry";
import { CommandIcon } from "./ribbon/CommandIcon";
import {
applyMark,
applyPreset,
@@ -57,6 +53,7 @@ export type DetailLevel = "grob" | "mittel" | "fein";
export type RenderMode =
| "shaded"
| "white"
| "contour"
| "textured"
| "wireframe"
| "hidden"
@@ -105,14 +102,11 @@ const ZOOM_CLUSTER_WIDTH = 140;
export interface TopBarProps {
project: Project;
// Ribbon-Tab-Reiter (2D/3D/BIM/Ansichten) sitzen in DIESER Zeile — der Ribbon-
// Inhalt darunter (RibbonBar). Der aktive Tab liegt in App (geteilter State).
ribbonTab: RibbonTabId;
onRibbonTab: (id: RibbonTabId) => void;
// Ansichts-/Zoom-/Darstellungs-Steuerung ist in den „Ansichten"-Ribbon-Tab
// gewandert (siehe ViewRibbonTab). Die TopBar ist nur noch die schmale globale
// Leiste: Marke/Ressourcen · Tabs · Text-Formatierung · Datei/Export/Einstell.
// Kein Ribbon-Tab-Reiter mehr (Werkzeuge leben im ToolsPanel, siehe
// ribbonItems.ts/layout.ts v12) — die TopBar ist nur noch die schmale
// globale Leiste: Marke/Menü · Datei/Export/Einstellungen. Ansichts-/Zoom-/
// Darstellungs-Steuerung folgt direkt darunter (siehe ViewRibbonTab, von
// App unconditional gerendert statt über Tab-Umschaltung).
/** Ob Zoom/Plan aktiv ist — steuert nur noch, ob Export im Datei-Menü geht. */
zoomEnabled: boolean;
@@ -155,8 +149,16 @@ export interface TopBarProps {
/** Projekt aus einer Datei laden. */
onOpenProject: () => void;
// Die Text-/Font-Formatierung (TextGroup) liegt jetzt im „Text"-Ribbon-Tab
// (App reicht sie via RibbonBar.tabContent), nicht mehr in dieser Leiste.
/** Menüleiste (Bearbeiten/Ansicht/Fenster, {@link AppMenuBar}) — von App
* zusammengesetzt (dort leben Undo/Redo/Auswahl/View-State), hier nur
* platziert. `undefined` blendet sie aus (z. B. für künftige eingebettete
* Kontexte ohne volle Chrome). */
menuBar?: React.ReactNode;
/** Ansichts-/Zoom-/Darstellungs-Steuerung ({@link ViewRibbonTab}) — von App
* geliefert (dort leben View-State/Setter), hier direkt in DERSELBEN
* schmalen Leiste platziert (kein eigenes Band mehr, Mockup: EINE Zeile). */
viewControls?: React.ReactNode;
}
/**
@@ -191,18 +193,6 @@ 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`:
@@ -288,88 +278,6 @@ function ComboMenu({
);
}
/**
* Kamera-Einstellungen als kleines Popover (Vectorworks-Stil): ein FOV-Regler
* für den Sichtwinkel der Perspektivkamera. Öffnet/schliesst per Button; ein
* Klick ausserhalb schliesst es. Rein gesteuert über `fov`/`onFov`.
*/
function CameraMenu({
fov,
onFov,
disabled,
}: {
fov: number;
onFov: (n: number) => void;
disabled: boolean;
}) {
const [open, setOpen] = useState(false);
// Popover-Position (fixed), aus dem Button-Rect berechnet — die Oberleiste
// hat overflow:hidden und würde ein absolut positioniertes Popover abschneiden.
const [pos, setPos] = useState<{ left: number; top: number }>({ left: 0, top: 0 });
const wrapRef = useRef<HTMLDivElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [open]);
const toggle = () => {
const next = !open;
if (next && btnRef.current) {
const r = btnRef.current.getBoundingClientRect();
setPos({ left: r.left, top: r.bottom + 6 });
}
setOpen(next);
};
return (
<div className="tb-popwrap" ref={wrapRef}>
<button
ref={btnRef}
className={`view-icon${open ? " active" : ""}`}
disabled={disabled}
onClick={toggle}
title={`${t("view3d.camera")} — ${t("view3d.camera.hint")}`}
aria-label={t("view3d.camera")}
aria-haspopup="true"
aria-expanded={open}
>
<ViewIcon name="camera" />
</button>
{open && !disabled && (
<div
className="tb-popover"
role="dialog"
aria-label={t("view3d.camera")}
style={{ left: pos.left, top: pos.top }}
>
<label className="tb-poprow">
<span className="tb-label">
{t("view3d.fov")}: {Math.round(fov)}°
</span>
<input
type="range"
min={20}
max={90}
step={1}
value={fov}
onChange={(e) => onFov(Number(e.target.value))}
title={t("view3d.fov.hint")}
/>
</label>
</div>
)}
</div>
);
}
/** Die vier oberen Iso-Oktanten (untere vier bleiben im Hochbau ungenutzt). */
const ISO_VARIANTS: View3d[] = ["iso", "isoFrontLeft", "isoBackRight", "isoBackLeft"];
@@ -389,27 +297,42 @@ function IsoCheck() {
);
}
/** Die Elevations-Ansichten (Vorne/Hinten/Links/Rechts/Oben) — als EIN Symbol
* + Dropdown zusammengefasst (statt fünf Buchstaben-Knöpfen). */
const ELEVATION_VIEWS: View3d[] = ["front", "back", "left", "side", "top"];
/**
* Iso-Oktanten als Split-Button (Vectorworks-Stil): Klick auf das Würfel-Icon
* springt direkt auf die zuletzt gewählte Iso-Ansicht (Default „iso" =
* vorne-oben-rechts, s. View3d); der schmale Pfeil daneben öffnet ein Popover
* mit allen vier oberen Iso-Oktanten (untere vier bleiben im Hochbau ungenutzt).
* Popover-Markup/Klassen wie `Dropdown`s Listenmodus (`ctx-menu`/`tb-dd-item`),
* Positionierung wie `CameraMenu` (fixed, kein Portal — entkommt dem
* `overflow:hidden` der Oberleiste).
* Split-Button mit Dropdown (Vectorworks-Stil): Klick auf das Symbol springt
* direkt auf die aktuelle/zuletzt gewählte Ansicht der Gruppe; der schmale
* Pfeil daneben öffnet ein Popover mit allen Varianten. Generisch für die
* „Ansichten" (Elevations) UND „Iso" (Oktanten) — beide teilen dieselbe
* robuste Popover-Logik (fixed positioniert, entkommt dem Oberleisten-
* Overflow; schliesst bei Aussenklick).
*/
function IsoMenu({
function ViewSplitMenu({
view3d,
onView3d,
viewType,
onViewType,
disabled,
variants,
fallback,
icon,
menuLabel,
}: {
view3d: View3d;
onView3d: (v: View3d) => void;
viewType: ViewType;
onViewType: (v: ViewType) => void;
disabled: boolean;
/** Die im Dropdown wählbaren Ansichten dieser Gruppe. */
variants: View3d[];
/** Ziel des Symbol-Klicks, falls die aktuelle Ansicht nicht zur Gruppe gehört. */
fallback: View3d;
/** Symbol des Haupt-Knopfs. */
icon: React.ReactNode;
/** Beschriftung (aria/Tooltip) der Gruppe. */
menuLabel: string;
}) {
const [open, setOpen] = useState(false);
const [pos, setPos] = useState<{ left: number; top: number }>({ left: 0, top: 0 });
@@ -427,8 +350,8 @@ function IsoMenu({
return () => document.removeEventListener("mousedown", onDown);
}, [open]);
const current: View3d = ISO_VARIANTS.includes(view3d) ? view3d : "iso";
const active = viewType === "perspektive" && ISO_VARIANTS.includes(view3d);
const current: View3d = variants.includes(view3d) ? view3d : fallback;
const active = viewType === "perspektive" && variants.includes(view3d);
const select = (v: View3d) => {
onView3d(v);
@@ -448,21 +371,23 @@ function IsoMenu({
return (
<div className="tb-popwrap view-icon-split" ref={wrapRef}>
<button
type="button"
className={`view-icon view-icon-split-main${active ? " active" : ""}`}
disabled={disabled}
onClick={() => select(current)}
title={disabled ? t("topbar.viewGroup.disabled") : `${t(`view3d.${current}`)} — ${t(`view3d.${current}.hint`)}`}
aria-label={t("view3d.iso")}
aria-label={menuLabel}
>
<ViewIcon name="iso" />
{icon}
</button>
<button
ref={caretRef}
type="button"
className={`view-icon-caret${open ? " active" : ""}`}
disabled={disabled}
onClick={toggle}
title={disabled ? t("topbar.viewGroup.disabled") : `${t("view3d.iso.menu")} — ${t("view3d.iso.menu.hint")}`}
aria-label={t("view3d.iso.menu")}
title={disabled ? t("topbar.viewGroup.disabled") : menuLabel}
aria-label={menuLabel}
aria-haspopup="true"
aria-expanded={open}
>
@@ -470,29 +395,32 @@ function IsoMenu({
<path d="M0 0l5 6 5-6z" fill="currentColor" />
</svg>
</button>
{open && !disabled && (
<div
className="ctx-menu tb-dd-menu"
role="menu"
aria-label={t("view3d.iso.menu")}
style={{ left: pos.left, top: pos.top }}
>
{ISO_VARIANTS.map((v) => (
<button
key={v}
type="button"
role="menuitemradio"
aria-checked={view3d === v}
className={`ctx-item tb-dd-item${view3d === v ? " is-active" : ""}`}
onClick={() => select(v)}
title={t(`view3d.${v}.hint`)}
>
<span className="tb-dd-check-slot">{view3d === v && <IsoCheck />}</span>
<span className="ctx-item-label">{t(`view3d.${v}`)}</span>
</button>
))}
</div>
)}
{open &&
!disabled &&
createPortal(
<div
className="ctx-menu tb-dd-menu"
role="menu"
aria-label={menuLabel}
style={{ left: pos.left, top: pos.top }}
>
{variants.map((v) => (
<button
key={v}
type="button"
role="menuitemradio"
aria-checked={view3d === v}
className={`ctx-item tb-dd-item${view3d === v ? " is-active" : ""}`}
onClick={() => select(v)}
title={t(`view3d.${v}.hint`)}
>
<span className="tb-dd-check-slot">{view3d === v && <IsoCheck />}</span>
<span className="ctx-item-label">{t(`view3d.${v}`)}</span>
</button>
))}
</div>,
document.body,
)}
</div>
);
}
@@ -596,6 +524,28 @@ function ViewIcon({ name }: { name: View3d | "camera" | "grundriss" }) {
}
}
/** Symbol für die „Ansichten" (Elevations): eine Fassade — Rechteck mit Tür +
* Bodenlinie (statt des unklaren Quadrat-mit-Steg-Icons). */
function ElevationIcon() {
return (
<svg
width={16}
height={16}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinejoin="round"
strokeLinecap="round"
aria-hidden="true"
>
<rect x="3.5" y="2.5" width="9" height="10.5" />
<rect x="6.4" y="8" width="3.2" height="5" />
<line x1="1.5" y1="13" x2="14.5" y2="13" />
</svg>
);
}
/** Eine Zelle einer Segmentpille. */
interface SegmentCell {
/** Stabiler Schlüssel. */
@@ -1059,191 +1009,10 @@ function touchedParagraphs(doc: RichTextDoc, range: TextRange | null): number[]
return out;
}
/**
* Datei-Menü der Oberleiste (Burger, ganz rechts): Speichern/Öffnen des
* Projekts sowie Import (DXF/DWG) und Export (PDF/DXF) — vorher eigene
* Icon-Pillen im Zoom-Bereich, jetzt gebündelt hinter einem Menü, damit die
* Leiste ruhiger bleibt (GTK-Burger-Stil).
*/
/** Ein kleiner Icon-Knopf der Quick-Access-Leiste (OCS-Stil). */
function QaButton({
icon,
label,
onClick,
disabled,
active,
}: {
icon: string;
label: string;
onClick: () => void;
disabled?: boolean;
active?: boolean;
}) {
return (
<button
type="button"
className={`tb-qa-btn${active ? " active" : ""}`}
onClick={onClick}
disabled={disabled}
aria-pressed={active}
aria-label={label}
title={label}
>
<TbIcon name={icon} />
</button>
);
}
/** Kleiner Trenner in der Quick-Access-Leiste. */
function QaSep() {
return <span className="tb-qa-sep" aria-hidden="true" />;
}
/**
* Quick-Access-Leiste (OCS-Stil) — alle früheren Datei-Burger-Aktionen plus
* Ressourcen/Einstellungen als kleine Icon-Knöpfe direkt in der TopBar-Zeile.
* Ersetzt das Burger-Menü (`AppMenu`). Rein gesteuert über Callbacks (i18n via t).
*/
/**
* Kompakte Kamera-Gruppe der Quick-Access-Leiste: NUR das Kamera-Settings-
* Popover (FOV), damit es immer erreichbar ist, ohne die Ansichts-Presets
* (die bleiben unverändert im „Ansichten"-Tab und im 3D-Ribbon-Tab).
*/
function QaCameraGroup({
viewType,
fov,
onFov,
cameraEnabled,
}: {
viewType: ViewType;
fov: number;
onFov: (n: number) => void;
cameraEnabled: boolean;
}) {
return (
<div className="tb-qa-camera" role="toolbar" aria-label={t("topbar.viewGroup")}>
<CameraMenu
fov={fov}
onFov={onFov}
disabled={!cameraEnabled || viewType !== "perspektive"}
/>
</div>
);
}
/** App-Version (spiegelt src-tauri/tauri.conf.json). */
const APP_VERSION = "0.1.0";
/**
* Export-Sammelmenü: EIN „Export"-Knopf in der Quick-Access-Leiste öffnet ein
* Dropdown (wie ein Kontextmenü) mit allen Export-Formaten — ersetzt die früher
* einzeln aufgereihten Export-Icons. Popover fixed positioniert (die Oberleiste
* hat overflow:hidden), Muster wie {@link CameraMenu}.
*/
function ExportMenu({
onExportPdf,
onExportDxf,
onExportSchedule,
onExportIfc,
onExportObj,
onExportStl,
exportEnabled,
}: {
onExportPdf: () => void;
onExportDxf: () => void;
onExportSchedule: () => void;
onExportIfc: () => void;
onExportObj: () => void;
onExportStl: () => void;
exportEnabled: boolean;
}) {
const [open, setOpen] = useState(false);
const [pos, setPos] = useState<{ left: number; top: number }>({ left: 0, top: 0 });
const wrapRef = useRef<HTMLDivElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
// Das Popover hängt per Portal an body (NICHT in wrapRef) — der Outside-Handler
// muss es daher separat als „innen" behandeln, sonst schliesst der mousedown
// das Menü vor dem Klick.
const popRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
const target = e.target as Node;
if (wrapRef.current?.contains(target)) return;
if (popRef.current?.contains(target)) return;
setOpen(false);
};
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [open]);
const toggle = () => {
const next = !open;
if (next && btnRef.current) {
const r = btnRef.current.getBoundingClientRect();
setPos({ left: r.left, top: r.bottom + 6 });
}
setOpen(next);
};
const pick = (fn: () => void) => {
setOpen(false);
fn();
};
const items: { icon: string; label: string; onClick: () => void; disabled?: boolean }[] = [
{ icon: "picture_as_pdf", label: t("file.exportPdf"), onClick: onExportPdf, disabled: !exportEnabled },
{ icon: "output", label: t("file.exportDxf"), onClick: onExportDxf, disabled: !exportEnabled },
{ icon: "table_view", label: t("file.exportSchedule"), onClick: onExportSchedule },
{ icon: "deployed_code", label: t("file.exportIfc"), onClick: onExportIfc },
{ icon: "view_in_ar", label: t("file.exportObj"), onClick: onExportObj },
{ icon: "landscape", label: t("file.exportStl"), onClick: onExportStl },
];
return (
<div className="tb-popwrap" ref={wrapRef}>
<button
ref={btnRef}
type="button"
className={`tb-qa-btn${open ? " active" : ""}`}
onClick={toggle}
title={t("file.export")}
aria-label={t("file.export")}
aria-haspopup="true"
aria-expanded={open}
>
<TbIcon name="ios_share" />
</button>
{open &&
createPortal(
<div
ref={popRef}
className="tb-popover tb-menu"
role="menu"
aria-label={t("file.export")}
style={{ left: pos.left, top: pos.top }}
>
{items.map((it) => (
<button
key={it.label}
type="button"
className="ctx-item"
role="menuitem"
disabled={it.disabled}
onClick={() => pick(it.onClick)}
>
<span className="ctx-item-icon">
<TbIcon name={it.icon} />
</span>
<span className="ctx-item-label">{it.label}</span>
</button>
))}
</div>,
document.body,
)}
</div>
);
}
/** App-Version (spiegelt src-tauri/tauri.conf.json). Exportiert für appMenu.ts
* (native macOS-Menüleiste, „Über Dossier"), damit die Versionsnummer nur an
* dieser einen Stelle gepflegt wird. */
export const APP_VERSION = "0.1.0";
/**
* „Über"-Dialog (In-App, dunkel) — App-Marke, Version, Kurzbeschreibung und die
@@ -1298,77 +1067,6 @@ function AboutDialog({ onClose }: { onClose: () => void }) {
);
}
function QuickAccess({
onOpenProject,
onSaveProject,
onImport,
onExportPdf,
onExportDxf,
onExportSchedule,
onExportIfc,
onExportObj,
onExportStl,
onToggleResources,
onOpenSettings,
resourcesOpen,
exportEnabled,
viewType,
fov,
onFov,
cameraEnabled,
}: {
onOpenProject: () => void;
onSaveProject: () => void;
onImport: () => void;
onExportPdf: () => void;
onExportDxf: () => void;
onExportSchedule: () => void;
onExportIfc: () => void;
onExportObj: () => void;
onExportStl: () => void;
onToggleResources: () => void;
onOpenSettings: () => void;
resourcesOpen: boolean;
exportEnabled: boolean;
viewType: ViewType;
fov: number;
onFov: (n: number) => void;
cameraEnabled: boolean;
}) {
return (
<div className="tb-qa" role="toolbar" aria-label={t("file.menu")}>
<QaButton icon="folder_open" label={t("file.open")} onClick={onOpenProject} />
<QaButton icon="save" label={t("file.save")} onClick={onSaveProject} />
<QaSep />
<QaButton icon="download" label={t("file.import")} onClick={onImport} />
<ExportMenu
onExportPdf={onExportPdf}
onExportDxf={onExportDxf}
onExportSchedule={onExportSchedule}
onExportIfc={onExportIfc}
onExportObj={onExportObj}
onExportStl={onExportStl}
exportEnabled={exportEnabled}
/>
<QaSep />
<QaCameraGroup
viewType={viewType}
fov={fov}
onFov={onFov}
cameraEnabled={cameraEnabled}
/>
<QaSep />
<QaButton
icon="widgets"
label={t("topbar.resources")}
onClick={onToggleResources}
active={resourcesOpen}
/>
<QaButton icon="settings" label={t("topbar.settings")} onClick={onOpenSettings} />
</div>
);
}
/**
* Props des „Ansichten"-Tabs — die Ansichts-/Zoom-/Darstellungs-Steuerung, die
* früher in der Mitte der TopBar sass. Eine Teilmenge von {@link TopBarProps}.
@@ -1469,70 +1167,61 @@ export function ViewRibbonTab({
return (
<div className="ribbon-views">
{/* Ansichten als EIN kompaktes Icon-Grid (2D-Grundriss + 3D-Blicke). */}
<div className="tb-group" role="group" aria-label={t("topbar.viewGroup")}>
<div className="view-grid view-grid-4">
<button
className={`view-icon${
viewToggleEnabled && viewType === "grundriss" ? " active" : ""
}`}
onClick={() => onViewType("grundriss")}
disabled={!viewToggleEnabled}
title={
viewToggleEnabled
? t("topbar.grundriss")
: t("topbar.viewGroup.disabled")
}
aria-label={t("topbar.grundriss")}
>
<ViewIcon name="grundriss" />
</button>
{(
[
["top", t("view3d.top")],
["iso", t("view3d.iso")],
["perspective", t("view3d.perspective")],
["front", t("view3d.front")],
["side", t("view3d.side")],
["back", t("view3d.back")],
["left", t("view3d.left")],
] as [View3d, string][]
).map(([key, label]) =>
key === "iso" ? (
<IsoMenu
key={key}
view3d={view3d}
onView3d={onView3d}
viewType={viewType}
onViewType={onViewType}
disabled={!viewToggleEnabled}
/>
) : (
<button
key={key}
className={`view-icon${
viewToggleEnabled && viewType === "perspektive" && view3d === key
? " active"
: ""
}`}
onClick={() => {
onView3d(key);
if (viewType !== "perspektive") onViewType("perspektive");
}}
disabled={!viewToggleEnabled}
title={
viewToggleEnabled
? `${label} — ${t(`view3d.${key}.hint`)}`
: t("topbar.viewGroup.disabled")
}
aria-label={label}
>
<ViewIcon name={key} />
</button>
),
)}
</div>
</div>
{/* Reihenfolge: Grundriss · Ansichten · Iso · Axo — ohne Trennstriche
(eine zusammenhängende Symbol-Zeile, Nutzer-Wunsch). */}
<span className="tb-viewtoggle" role="group" aria-label={t("topbar.viewGroup")}>
<button
type="button"
className={`tb-vt-btn${
viewToggleEnabled && viewType === "grundriss" ? " active" : ""
}`}
onClick={() => onViewType("grundriss")}
disabled={!viewToggleEnabled}
title={viewToggleEnabled ? t("topbar.grundriss") : t("topbar.viewGroup.disabled")}
aria-label={t("topbar.grundriss")}
>
<ViewIcon name="grundriss" />
</button>
<ViewSplitMenu
view3d={view3d}
onView3d={onView3d}
viewType={viewType}
onViewType={onViewType}
disabled={!viewToggleEnabled}
variants={ELEVATION_VIEWS}
fallback="front"
icon={<ElevationIcon />}
menuLabel={t("topbar.views")}
/>
<ViewSplitMenu
view3d={view3d}
onView3d={onView3d}
viewType={viewType}
onViewType={onViewType}
disabled={!viewToggleEnabled}
variants={ISO_VARIANTS}
fallback="iso"
icon={<ViewIcon name="iso" />}
menuLabel={t("view3d.iso.menu")}
/>
<button
type="button"
className={`tb-vt-btn${
viewToggleEnabled && viewType === "perspektive" && view3d === "perspective"
? " active"
: ""
}`}
onClick={() => {
onView3d("perspective");
if (viewType !== "perspektive") onViewType("perspektive");
}}
disabled={!viewToggleEnabled}
title={viewToggleEnabled ? `${t("topbar.axo")} — ${t("topbar.axo.hint")}` : t("topbar.viewGroup.disabled")}
aria-label={t("topbar.axo")}
>
<ViewIcon name="perspective" />
</button>
</span>
<Sep />
{/* Sichtbarkeits-Kombinationen (Ebenen-/Zeichnungskombinationen). */}
@@ -1595,6 +1284,7 @@ export function ViewRibbonTab({
{ value: "shaded", label: t("display.style.shaded") },
{ value: "shaded-edges", label: t("display.style.shadedEdges") },
{ value: "white", label: t("display.style.white") },
{ value: "contour", label: t("display.style.contour") },
{ value: "textured", label: t("display.style.textured") },
{ value: "wireframe", label: t("display.style.wireframe") },
{ value: "hidden", label: t("display.style.hidden") },
@@ -1613,9 +1303,9 @@ export function ViewRibbonTab({
)}
</label>
</div>
<Sep />
{/* Massstab/Zoom-Cluster (2×2). */}
{/* Massstab/Zoom + Text-Formatierung OHNE Trennstriche, gleich gross wie
die Dropdowns (Mockup). */}
<div className="tb-group tb-zoomcluster" role="group" aria-label={t("topbar.scaleGroup")}>
<div
className={`tb-stat${
@@ -1671,8 +1361,6 @@ export function ViewRibbonTab({
/>
</div>
</div>
<Sep />
{/* Schatten ein/aus — NUR für eine aktive Ansicht (kind "elevation").
Schreibt DrawingLevel.shadows; der Ansichts-Generator (toElevation)
@@ -1710,149 +1398,10 @@ export function ViewRibbonTab({
);
}
export interface Ribbon3dTabProps {
viewToggleEnabled: boolean;
viewType: ViewType;
onViewType: (v: ViewType) => void;
view3d: View3d;
onView3d: (v: View3d) => void;
renderMode: RenderMode;
onRenderMode: (m: RenderMode) => void;
renderModeEnabled: boolean;
onRunCommand: (name: string) => void;
activeCommand: string | null;
}
/** 3D-Tab des Ribbons — Kamera-Presets + Darstellungsart. Kein Duplikat des
* Ansichten-Tabs: hier nur 3D-relevante Steuerung (Blickwinkel, Rendermodus). */
export function Ribbon3dTab({
viewToggleEnabled,
viewType,
onViewType,
view3d,
onView3d,
renderMode,
onRenderMode,
renderModeEnabled,
onRunCommand,
activeCommand,
}: Ribbon3dTabProps) {
return (
<div className="ribbon-views">
<div className="tb-group" role="group" aria-label={t("topbar.viewGroup")}>
<div className="view-grid view-grid-4">
<button
className={`view-icon${viewToggleEnabled && viewType === "grundriss" ? " active" : ""}`}
onClick={() => onViewType("grundriss")}
disabled={!viewToggleEnabled}
title={viewToggleEnabled ? t("topbar.grundriss") : t("topbar.viewGroup.disabled")}
aria-label={t("topbar.grundriss")}
>
<ViewIcon name="grundriss" />
</button>
{(
[
["top", t("view3d.top")],
["iso", t("view3d.iso")],
["perspective", t("view3d.perspective")],
["front", t("view3d.front")],
["side", t("view3d.side")],
["back", t("view3d.back")],
["left", t("view3d.left")],
] as [View3d, string][]
).map(([key, label]) =>
key === "iso" ? (
<IsoMenu
key={key}
view3d={view3d}
onView3d={onView3d}
viewType={viewType}
onViewType={onViewType}
disabled={!viewToggleEnabled}
/>
) : (
<button
key={key}
className={`view-icon${viewToggleEnabled && viewType === "perspektive" && view3d === key ? " active" : ""}`}
onClick={() => {
onView3d(key);
if (viewType !== "perspektive") onViewType("perspektive");
}}
disabled={!viewToggleEnabled}
title={viewToggleEnabled ? `${label} — ${t(`view3d.${key}.hint`)}` : t("topbar.viewGroup.disabled")}
aria-label={label}
>
<ViewIcon name={key} />
</button>
),
)}
</div>
</div>
<Sep />
<div className="tb-group" role="group" aria-label={t("display.style.group")}>
<label className="tb-field">
<span className="tb-label">{t("display.style")}</span>
<Dropdown
value={renderMode}
disabled={!renderModeEnabled}
onChange={(v) => onRenderMode(v as RenderMode)}
title={t("display.style.hint")}
options={[
{ value: "shaded", label: t("display.style.shaded") },
{ value: "shaded-edges", label: t("display.style.shadedEdges") },
{ 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") },
]}
/>
</label>
</div>
<Sep />
<div className="tb-group" role="group" aria-label={t("cmd.extrude.label")}>
{(() => {
const cmd = getCommand("extrude");
const label = cmd ? t(cmd.labelKey) : "extrude";
const active = activeCommand === "extrude";
return (
<button
type="button"
className={`ribbon-btn${active ? " active" : ""}`}
disabled={!viewToggleEnabled}
title={viewToggleEnabled ? label : t("tool.floorOnlyDisabled")}
onClick={() => onRunCommand("extrude")}
>
<CommandIcon name="extrude" />
<span>{label}</span>
</button>
);
})()}
</div>
</div>
);
}
export function TopBar({
project,
ribbonTab,
onRibbonTab,
zoomEnabled,
onExportPdf,
onExportDxf,
onExportSchedule,
onExportIfc,
onExportObj,
onExportStl,
viewType,
fov,
onFov,
cameraEnabled,
resourcesOpen,
onToggleResources,
onOpenSettings,
onImport,
onSaveProject,
onOpenProject,
menuBar,
viewControls,
}: TopBarProps) {
// Dezente Scrollleiste: die Oberleiste scrollt horizontal (overflow-x). Statt
// der prägnanten Standard-Leiste blenden wir eine eigene, sehr ruhige Leiste
@@ -1911,36 +1460,17 @@ export function TopBar({
</button>
</div>
{aboutOpen && <AboutDialog onClose={() => setAboutOpen(false)} />}
<QuickAccess
onOpenProject={onOpenProject}
onSaveProject={onSaveProject}
onImport={onImport}
onExportPdf={onExportPdf}
onExportDxf={onExportDxf}
onExportSchedule={onExportSchedule}
onExportIfc={onExportIfc}
onExportObj={onExportObj}
onExportStl={onExportStl}
onToggleResources={onToggleResources}
onOpenSettings={onOpenSettings}
resourcesOpen={resourcesOpen}
exportEnabled={zoomEnabled}
viewType={viewType}
fov={fov}
onFov={onFov}
cameraEnabled={cameraEnabled}
/>
{menuBar}
<Sep />
{/* Ansichts-/Zoom-/Darstellungs-Steuerung direkt in DERSELBEN Zeile
(Mockup: EINE schmale Leiste, kein eigenes Band mehr). */}
{viewControls && <Sep />}
{viewControls}
{/* Ribbon-Tab-Reiter (2D/3D/BIM/Text/Ansichten) — sitzen in dieser Zeile,
der Ribbon-Inhalt folgt darunter (RibbonBar). So gibt es nur ZWEI
Bänder: diese schmale Leiste (Chrome + Tabs) und den Inhalt. */}
<RibbonTabs tab={ribbonTab} onTab={onRibbonTab} />
{/* Rechte Gruppe: Projektname. Die Fensterknöpfe sitzen ganz links (macOS-
Titelleisten-Konvention); Datei-/Export-Aktionen in der Quick-Access-
Leiste; die Arbeitsumgebung (Layout) liegt jetzt in den Einstellungen. */}
{/* Rechte Gruppe: Datei-/Export-Aktionen (Quick-Access) + Projektname —
per margin-left:auto an den rechten Rand geschoben (Mockup: ⤓ ⤒
Projektname ganz rechts). Die Fensterknöpfe sitzen ganz links
(macOS-Titelleisten-Konvention). */}
<div className="topbar-right">
<span className="project-name">{project.name}</span>
</div>
+28
View File
@@ -116,6 +116,34 @@ export function CommandIcon({ name }: { name: string }) {
<rect x="6" y="6.5" width="4" height="3" strokeDasharray="1.4 1.2" />
</svg>
);
case "extrude":
// Extrusion: Grundfläche + nach oben herausgezogenes Volumen (Prisma).
return (
<svg {...common}>
<rect x="2.5" y="7.5" width="7" height="5" />
<path d="M2.5 7.5 L5.5 4.5 L12.5 4.5 L9.5 7.5" />
<path d="M9.5 12.5 L12.5 9.5 L12.5 4.5" />
</svg>
);
case "sectionline":
// Schnittlinie: Achse mit Blickrichtungs-Winkeln (SIA-Schnittsymbol).
return (
<svg {...common}>
<line x1="2.5" y1="8" x2="13.5" y2="8" />
<polyline points="4.5,4.5 4.5,8 8,8" />
<polyline points="11.5,11.5 11.5,8 8,8" />
</svg>
);
case "viewline":
// Ansichtslinie: Standpunkt (Kreis) mit Blickkegel.
return (
<svg {...common}>
<circle cx="4" cy="8" r="1.6" />
<line x1="5.4" y1="8" x2="13" y2="4" />
<line x1="5.4" y1="8" x2="13" y2="12" />
<line x1="13" y1="4" x2="13" y2="12" />
</svg>
);
default:
return null;
}
+8 -51
View File
@@ -1,17 +1,14 @@
// Ribbon-Inhaltsleiste (docs/design/ribbon-ui-plan.md) — rendert den INHALT des
// aktiven Ribbon-Tabs. Die Tab-REITER selbst sitzen in der TopBar-Zeile (eine
// Leiste: globales Chrome + Tabs), damit es nur ZWEI Bänder gibt: TopBar-Zeile
// (mit Tabs) oben, Ribbon-Inhalt darunter. Der aktive Tab wird als `tab`-Prop
// hereingereicht (State liegt in App, geteilt mit der TopBar).
// Ribbon-Inhaltsleiste (docs/design/ribbon-ui-plan.md) — rendert die Gruppen
// EINES festen Tabs (kein Tab-Umschalter mehr, v12: Werkzeuge leben im
// ToolsPanel). App rendert mehrere `<RibbonBar tab="...">`-Instanzen permanent
// nebeneinander (2d/bim/custom) statt einer einzigen, umschaltbaren — jede
// zeigt nur die Befehls-Gruppen ohne Panel-Entsprechung.
//
// Werkzeug-Elemente laufen über `onSelectTool` (gekoppelte Befehle inklusive, wie
// in der Sidebar); Befehls-Elemente über `onRunCommand` (engine.start). Der
// „Ansichten"-Tab trägt reichen Custom-Inhalt (`viewsContent`, aus der TopBar
// gemergt) statt datengetriebener Item-Gruppen.
// im ToolsPanel); Befehls-Elemente über `onRunCommand` (engine.start).
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import type { ReactNode } from "react";
import { t } from "../../i18n";
import { ToolIcon } from "../../panels/ToolsPanel";
import { getTool } from "../../tools/tools";
@@ -43,13 +40,6 @@ export interface RibbonBarProps {
onSelectTool: (id: ToolId) => void;
/** Engine-Befehl starten (Ändern-Gruppe u. a.). */
onRunCommand: (name: string) => void;
/**
* Reicher Custom-Inhalt je Tab (statt datengetriebener Item-Gruppen) — z. B.
* „Ansichten" (`ViewRibbonTab`) und „Text" (`TextGroup`). Von App geliefert,
* damit deren Setter/State dort bleiben. Ein Tab ohne Custom-Inhalt und ohne
* Gruppen zeigt den Leer-Hinweis.
*/
tabContent?: Partial<Record<RibbonTabId, ReactNode>>;
/** Persistierte Item-Schlüssel der modularen Custom-Bar (Tab „Eigene"). */
customItems: string[];
/** Ein Item in der Custom-Bar an-/abwählen (Picker im „Eigene"-Tab). */
@@ -106,35 +96,6 @@ function RibbonButton({ item, ctx }: { item: RibbonItem; ctx: ButtonCtx }) {
);
}
/**
* Die Tab-REITER — als eigene Komponente exportiert, weil sie in der TopBar-Zeile
* gerendert werden (nicht hier). Rein gesteuert über `tab`/`onTab`.
*/
export function RibbonTabs({
tab,
onTab,
}: {
tab: RibbonTabId;
onTab: (id: RibbonTabId) => void;
}) {
return (
<div className="ribbon-tabs" role="tablist">
{RIBBON_TABS.map((tt) => (
<button
key={tt.id}
type="button"
role="tab"
aria-selected={tt.id === tab}
className={`ribbon-tab${tt.id === tab ? " active" : ""}`}
onClick={() => onTab(tt.id)}
>
{t(tt.labelKey)}
</button>
))}
</div>
);
}
/**
* Modulare Custom-Bar (Tab „Eigene") — rendert die vom Nutzer gewählten Items
* plus einen „+"-Picker, der ALLE verfügbaren Werkzeuge/Befehle mit Häkchen für
@@ -184,7 +145,6 @@ function CustomBar({
export function RibbonBar(props: RibbonBarProps) {
const current = RIBBON_TABS.find((tt) => tt.id === props.tab) ?? RIBBON_TABS[0];
const custom = props.tabContent?.[current.id];
const ctx: ButtonCtx = {
activeTool: props.activeTool,
activeCommand: props.activeCommand,
@@ -195,9 +155,8 @@ export function RibbonBar(props: RibbonBarProps) {
return (
<div className="ribbon">
{/* Inhalt des aktiven Tabs. Tabs mit reichem Custom-Inhalt („Ansichten",
„Text") rendern diesen; der „Eigene"-Tab die modulare Custom-Bar; sonst
die datengetriebenen Item-Gruppen. */}
{/* Der „Eigene"-Tab rendert die modulare Custom-Bar; sonst die
datengetriebenen Item-Gruppen. */}
<div className="ribbon-body">
{current.id === "custom" ? (
<CustomBar
@@ -205,8 +164,6 @@ export function RibbonBar(props: RibbonBarProps) {
onToggle={props.onToggleCustomItem}
ctx={ctx}
/>
) : custom ? (
custom
) : current.groups.length === 0 ? (
<div className="ribbon-empty">{t("ribbon.empty")}</div>
) : (
+14 -43
View File
@@ -28,7 +28,7 @@ export interface RibbonGroup {
}
/** Kennung eines Ribbon-Tabs (fest, für Tab-State + i18n). */
export type RibbonTabId = "views" | "2d" | "3d" | "bim" | "custom";
export type RibbonTabId = "2d" | "bim" | "custom";
/** Ein Ribbon-Tab: Beschriftung + gruppierte Elemente. */
export interface RibbonTab {
@@ -37,41 +37,24 @@ export interface RibbonTab {
groups: RibbonGroup[];
}
const t = (id: ToolId): RibbonItem => ({ kind: "tool", id });
const c = (name: string): RibbonItem => ({ kind: "command", name });
/**
* Die Ribbon-Tabs (Reihenfolge Ansichten·2D·3D·BIM). „Ansichten" ist der
* Standard-Tab (zuerst, initial aktiv) und trägt reichen Custom-Inhalt: die aus
* der TopBar gemergte Ansichts-/Zoom-/Darstellungs-Steuerung UND die Text-/Font-
* Formatierung auf DERSELBEN Leiste (`ViewRibbonTab` — `groups` bleibt leer). 2D
* (Zeichnen + Ändern) und BIM (Bauteile) sind datengetrieben aus Werkzeugen/
* Befehlen; 3D ist vorerst leer (spätere Phase).
* Die Ribbon-Tabs (2D·BIM·Eigene) — keine Tab-Reiter mehr in der UI (v12,
* siehe layout.ts): App rendert alle drei permanent nebeneinander (kein
* Umschalter). Reine Werkzeug-Elemente (`t(...)`, persistentes Werkzeug wie
* Wand/Linie/Fenster) leben AUSSCHLIESSLICH im ToolsPanel (kein Doppel-
* Auftritt). Reine Befehls-Elemente (`c(...)`, Einmal-Aktionen wie
* Verschieben/Kopieren/Säule setzen ohne eigenen Werkzeug-Modus) bleiben hier,
* da sie im Panel keine Entsprechung haben. Ansichts-/Zoom-/Darstellungs-
* Steuerung + Text-Formatierung sitzen direkt in App (ViewRibbonTab), nicht
* in dieser Registry.
*/
export const RIBBON_TABS: RibbonTab[] = [
{
// Custom-Inhalt (ViewRibbonTab): Ansichts-/Zoom-/Darstellungssteuerung + Text.
id: "views",
labelKey: "ribbon.tab.views",
groups: [],
},
{
id: "2d",
labelKey: "ribbon.tab.2d",
groups: [
{
titleKey: "ribbon.group.draw",
items: [
t("select"),
t("line"),
t("polyline"),
t("rect"),
t("circle"),
t("arc"),
t("text"),
t("textbox"),
],
},
{
titleKey: "ribbon.group.modify",
items: [
@@ -87,28 +70,16 @@ export const RIBBON_TABS: RibbonTab[] = [
},
],
},
{
id: "3d",
labelKey: "ribbon.tab.3d",
groups: [],
},
{
id: "bim",
labelKey: "ribbon.tab.bim",
groups: [
{
// "extrude" war vormals im (jetzt entfernten) 3D-Ribbon-Tab
// (Ribbon3dTab) — einziger Zugang zum Befehl, daher hier statt
// gestrichen.
titleKey: "ribbon.group.components",
items: [
t("wall"),
t("window"),
t("door"),
t("stair"),
c("column"),
t("ceiling"),
c("ceilingopening"),
c("roof"),
t("room"),
],
items: [c("column"), c("ceilingopening"), c("roof"), c("extrude")],
},
{
titleKey: "ribbon.group.sections",