diff --git a/src/App.tsx b/src/App.tsx
index c8bcb8a..31ee012 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -70,6 +70,7 @@ import { ResourceManager } from "./ui/ResourceManager";
import type { ResourceManagerHandlers } from "./ui/ResourceManager";
import { HatchSwatch } from "./ui/hatchPreview";
import { TopBar } from "./ui/TopBar";
+import { RibbonBar } from "./ui/ribbon/RibbonBar";
import { Dropdown } from "./ui/Dropdown";
import type { DropdownItem } from "./ui/Dropdown";
import { TextEditorDialog } from "./ui/TextEditorDialog";
@@ -810,6 +811,16 @@ export default function App() {
setActiveTool(id);
};
+ // Ribbon-Befehl (Ändern-Gruppe u. a.): startet einen Engine-Befehl OHNE
+ // eigenes Werkzeug (kein ToolId). Läuft über DIESELBE Engine wie das Tippen des
+ // Befehls; das Werkzeug-Highlight fällt auf „Auswahl" zurück, das Aktiv-
+ // Highlight des Ribbon-Befehls spiegelt der laufende Befehl (engineView).
+ const onRunCommand = (name: string) => {
+ engine.start(name);
+ setActiveTool("select");
+ requestAnimationFrame(() => commandLineRef.current?.focus());
+ };
+
// 3D-Viewport als Erstellungs-Fläche: der Viewport projiziert den Cursor auf
// die Geschoss-Arbeitsebene und liefert einen rohen Modell-`Vec2`; wir füttern
// ihn in DIESELBE ansichts-agnostische CommandEngine wie PlanView (move/pick).
@@ -3244,6 +3255,17 @@ export default function App() {
textTarget={textTarget}
/>
+ {/* Ribbon-Oberleiste (2D · 3D · BIM · Ansichten) — additiv unter der TopBar.
+ Teilt sich denselben Aktivierungs-Pfad wie die Werkzeug-Sidebar; die
+ Sidebar bleibt vorerst erhalten (Plan-Doc Phase 3 entfernt sie). */}
+
+
{/* Linkes Dock: gestapelte Gruppen (Werkzeuge oben, Zeichnungsebenen/
Ebenen unten). Jede Gruppe baut über buildHost ihren eigenen
diff --git a/src/i18n/de.ts b/src/i18n/de.ts
index 94cad1c..a57b977 100644
--- a/src/i18n/de.ts
+++ b/src/i18n/de.ts
@@ -120,6 +120,15 @@ export const de = {
"tool.circle.hint": "Kreis: Mittelpunkt setzen, dann Radius",
"tool.arc": "Bogen",
"tool.arc.hint": "Bogen: Mittelpunkt, Startpunkt, dann Endpunkt (CCW)",
+ // Ribbon-Oberleiste (Tabs + Gruppen)
+ "ribbon.tab.2d": "2D",
+ "ribbon.tab.3d": "3D",
+ "ribbon.tab.bim": "BIM",
+ "ribbon.tab.views": "Ansichten",
+ "ribbon.group.draw": "Zeichnen",
+ "ribbon.group.modify": "Ändern",
+ "ribbon.group.components": "Bauteile",
+ "ribbon.empty": "Noch keine Werkzeuge in diesem Tab",
"tool.select.hint": "Auswahl — Elemente klicken/aufziehen",
"tool.window.hint": "Fenster: Wand wählen, dann Position setzen",
"tool.door.hint": "Tür: Wand wählen, dann Position setzen",
diff --git a/src/i18n/en.ts b/src/i18n/en.ts
index c4b08ed..58e2540 100644
--- a/src/i18n/en.ts
+++ b/src/i18n/en.ts
@@ -119,6 +119,15 @@ export const en: Record = {
"tool.circle.hint": "Circle: set center, then radius",
"tool.arc": "Arc",
"tool.arc.hint": "Arc: center, start point, then end point (CCW)",
+ // Ribbon top bar (tabs + groups)
+ "ribbon.tab.2d": "2D",
+ "ribbon.tab.3d": "3D",
+ "ribbon.tab.bim": "BIM",
+ "ribbon.tab.views": "Views",
+ "ribbon.group.draw": "Draw",
+ "ribbon.group.modify": "Modify",
+ "ribbon.group.components": "Components",
+ "ribbon.empty": "No tools in this tab yet",
"tool.select.hint": "Select — click/drag elements",
"tool.window.hint": "Window: pick a wall, then set the position",
"tool.door.hint": "Door: pick a wall, then set the position",
diff --git a/src/panels/ToolsPanel.tsx b/src/panels/ToolsPanel.tsx
index aa38c46..4dc87b7 100644
--- a/src/panels/ToolsPanel.tsx
+++ b/src/panels/ToolsPanel.tsx
@@ -19,7 +19,7 @@ import { Dropdown } from "../ui/Dropdown";
// Keine echten Asset-Icons vorhanden: schlichte Inline-SVG-Glyphen je Werkzeug
// (~16×16, stroke=currentColor, fill=none), damit das Icon die Textfarbe der
// Zeile übernimmt.
-function ToolIcon({ id }: { id: ToolId }) {
+export function ToolIcon({ id }: { id: ToolId }) {
const common = {
width: 16,
height: 16,
diff --git a/src/styles.css b/src/styles.css
index 870c963..067c69d 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -5340,3 +5340,135 @@ body {
background: #e5484d;
color: #fff;
}
+
+/* ── Ribbon-Oberleiste (2D · 3D · BIM · Ansichten) ─────────────────────────
+ Datengetriebene Tab-Leiste unter der TopBar (RibbonBar). Kompakte Band-Höhe:
+ Tab-Reiter oben, darunter die Werkzeug-/Befehls-Gruppen des aktiven Tabs. */
+.ribbon {
+ display: flex;
+ flex-direction: column;
+ background: var(--panel);
+ border-bottom: 1px solid var(--line);
+ position: relative;
+ z-index: 1;
+ flex: 0 0 auto;
+}
+.ribbon-tabs {
+ display: flex;
+ align-items: flex-end;
+ gap: 2px;
+ padding: 0 10px;
+ border-bottom: 1px solid var(--line);
+}
+.ribbon-tab {
+ appearance: none;
+ border: none;
+ background: transparent;
+ color: var(--muted);
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ padding: 7px 12px 6px;
+ cursor: pointer;
+ border-bottom: 2px solid transparent;
+ transition: color 0.12s, border-color 0.12s;
+}
+.ribbon-tab:hover {
+ color: var(--label);
+}
+.ribbon-tab.active {
+ color: var(--ink);
+ border-bottom-color: var(--accent-border, var(--accent));
+}
+.ribbon-body {
+ display: flex;
+ align-items: stretch;
+ gap: 0;
+ padding: 4px 8px;
+ overflow-x: auto;
+ overflow-y: hidden;
+ scrollbar-width: thin;
+ scrollbar-color: transparent transparent;
+ min-height: 56px;
+}
+.ribbon-body::-webkit-scrollbar {
+ height: 6px;
+}
+.ribbon-body::-webkit-scrollbar-thumb {
+ background: transparent;
+}
+.ribbon-body:hover::-webkit-scrollbar-thumb {
+ background: var(--border);
+ border-radius: 3px;
+}
+/* Eine Gruppe: Buttons oben, kleiner Gruppentitel darunter; rechts ein dezenter
+ Trenner (wie OCS-Ribbon-Gruppen). */
+.ribbon-group {
+ display: flex;
+ flex-direction: column;
+ padding: 0 8px;
+ border-right: 1px solid var(--line);
+}
+.ribbon-group:last-child {
+ border-right: none;
+}
+.ribbon-items {
+ display: flex;
+ align-items: flex-start;
+ gap: 2px;
+ flex: 1;
+}
+.ribbon-group-title {
+ text-align: center;
+ color: var(--muted);
+ font-size: 9px;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ padding-top: 2px;
+}
+/* Ein Ribbon-Button: Icon oben, Label darunter (kompakt, wie eine Palette). */
+.ribbon-btn {
+ appearance: none;
+ border: 1px solid transparent;
+ background: transparent;
+ color: var(--label);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 2px;
+ width: 52px;
+ padding: 4px 2px 3px;
+ border-radius: 5px;
+ cursor: pointer;
+ font-size: 10px;
+ line-height: 1.15;
+ transition: background 0.12s, border-color 0.12s, color 0.12s;
+}
+.ribbon-btn span {
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.ribbon-btn:hover:not(:disabled) {
+ background: var(--panel-2);
+ border-color: var(--line);
+}
+.ribbon-btn.active {
+ background: var(--accent-dim, var(--panel-2));
+ border-color: var(--accent-border, var(--accent));
+ color: var(--ink);
+}
+.ribbon-btn:disabled {
+ opacity: 0.35;
+ cursor: not-allowed;
+}
+.ribbon-empty {
+ display: flex;
+ align-items: center;
+ color: var(--muted);
+ font-size: 11px;
+ font-style: italic;
+ padding: 0 8px;
+}
diff --git a/src/ui/ribbon/CommandIcon.tsx b/src/ui/ribbon/CommandIcon.tsx
new file mode 100644
index 0000000..c85c03b
--- /dev/null
+++ b/src/ui/ribbon/CommandIcon.tsx
@@ -0,0 +1,77 @@
+// Kleine Inline-SVG-Glyphen für Befehls-Ribbon-Elemente (Ändern-Gruppe u. a.),
+// die keine ToolId haben und daher nicht über `ToolIcon` abgedeckt sind. Gleicher
+// Stil wie `ToolIcon` (~16×16, stroke=currentColor, fill=none), damit das Icon
+// die Textfarbe der Zeile übernimmt. Unbekannte Namen → kein Icon (null).
+
+const common = {
+ width: 16,
+ height: 16,
+ viewBox: "0 0 16 16",
+ fill: "none",
+ stroke: "currentColor",
+ strokeWidth: 1.4,
+ strokeLinecap: "round" as const,
+ strokeLinejoin: "round" as const,
+};
+
+export function CommandIcon({ name }: { name: string }) {
+ switch (name) {
+ case "move":
+ // Vier-Wege-Verschiebepfeil.
+ return (
+
+ );
+ case "copy":
+ // Zwei überlappende Rechtecke (Duplikat).
+ return (
+
+ );
+ case "mirror":
+ // Spiegelachse (gestrichelt) mit zwei gespiegelten Dreiecken.
+ return (
+
+ );
+ case "offset":
+ // Zwei parallele Winkel (Ursprung + Versatz).
+ return (
+
+ );
+ case "trim":
+ // Schere (Stutzen).
+ return (
+
+ );
+ case "join":
+ // Zwei Segmente, die sich an einer Ecke treffen (Verbinden).
+ return (
+
+ );
+ default:
+ return null;
+ }
+}
diff --git a/src/ui/ribbon/RibbonBar.tsx b/src/ui/ribbon/RibbonBar.tsx
new file mode 100644
index 0000000..1f4a4b3
--- /dev/null
+++ b/src/ui/ribbon/RibbonBar.tsx
@@ -0,0 +1,134 @@
+// Ribbon-Oberleiste (docs/design/ribbon-ui-plan.md, Phase 1) — datengetriebene
+// Tab-Leiste unter der TopBar. Zeigt je Tab (2D · 3D · BIM · Ansichten) die
+// gruppierten Werkzeuge/Befehle aus `RIBBON_TABS`. ADDITIV: die Werkzeug-Sidebar
+// (ToolsPanel) bleibt vorerst funktionsfähig — beide teilen sich denselben
+// Aktivierungs-Pfad (onSelectTool / engine.start), es gibt keine Duplikat-Logik.
+//
+// Werkzeug-Elemente laufen über `onSelectTool` (gekoppelte Befehle inklusive,
+// wie in der Sidebar); Befehls-Elemente über `onRunCommand` (engine.start). Das
+// Aktiv-Highlight spiegelt für Werkzeuge `activeTool`, für Befehle den laufenden
+// Befehl (`activeCommand`).
+//
+// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
+
+import { useState } from "react";
+import { t } from "../../i18n";
+import { ToolIcon } from "../../panels/ToolsPanel";
+import { getTool } from "../../tools/tools";
+import type { ToolId } from "../../tools/types";
+import { getCommand } from "../../commands/registry";
+import { RIBBON_TABS } from "./ribbonItems";
+import type { RibbonItem, RibbonTabId } from "./ribbonItems";
+import { CommandIcon } from "./CommandIcon";
+
+export interface RibbonBarProps {
+ /** Aktives Werkzeug (Highlight der `tool`-Elemente). */
+ activeTool: ToolId;
+ /** Name des laufenden Engine-Befehls (Highlight der `command`-Elemente). */
+ activeCommand: string | null;
+ /** Ob Boden-gebundene (`floorOnly`) Werkzeuge nutzbar sind (aktives Geschoss). */
+ toolsEnabled: boolean;
+ /** Werkzeug aktivieren (gleicher Pfad wie die Sidebar). */
+ onSelectTool: (id: ToolId) => void;
+ /** Engine-Befehl starten (Ändern-Gruppe u. a.). */
+ onRunCommand: (name: string) => void;
+}
+
+/** Ein einzelner Ribbon-Button (Werkzeug ODER Befehl). */
+function RibbonButton({
+ item,
+ activeTool,
+ activeCommand,
+ toolsEnabled,
+ onSelectTool,
+ onRunCommand,
+}: {
+ item: RibbonItem;
+} & Omit & { activeCommand: string | null }) {
+ if (item.kind === "tool") {
+ const tool = getTool(item.id);
+ // Nur Boden-gebundene Werkzeuge sind ohne aktives Geschoss gesperrt; freie
+ // 2D-Werkzeuge (Linie/Kreis/…) bleiben auch auf Zeichnungsebenen nutzbar.
+ const disabled = !!tool.floorOnly && !toolsEnabled;
+ const active = activeTool === item.id;
+ const label = t(tool.labelKey);
+ return (
+
+ );
+ }
+ // Befehls-Element: Label aus der Befehls-Registry ableiten.
+ const cmd = getCommand(item.name);
+ const label = cmd ? t(cmd.labelKey) : item.name;
+ const active = activeCommand === item.name;
+ return (
+
+ );
+}
+
+export function RibbonBar(props: RibbonBarProps) {
+ const [tab, setTab] = useState("2d");
+ const current = RIBBON_TABS.find((tt) => tt.id === tab) ?? RIBBON_TABS[0];
+
+ return (
+
+ {/* Tab-Reiter */}
+
+ {RIBBON_TABS.map((tt) => (
+
+ ))}
+
+
+ {/* Gruppen des aktiven Tabs */}
+
+ {current.groups.length === 0 ? (
+
{t("ribbon.empty")}
+ ) : (
+ current.groups.map((g) => (
+
+
+ {g.items.map((item) => (
+
+ ))}
+
+
{t(g.titleKey)}
+
+ ))
+ )}
+
+
+ );
+}
diff --git a/src/ui/ribbon/ribbonItems.ts b/src/ui/ribbon/ribbonItems.ts
new file mode 100644
index 0000000..f210230
--- /dev/null
+++ b/src/ui/ribbon/ribbonItems.ts
@@ -0,0 +1,104 @@
+// Ribbon-Registry (docs/design/ribbon-ui-plan.md §Architektur) — EINE
+// datengetriebene Beschreibung aller Ribbon-Elemente. Die Ribbon-Tabs UND eine
+// spätere modulare Custom-Bar (Phase 4) sind bloß zwei Ansichten über dieselbe
+// Registry, ohne Sonderweg pro Element.
+//
+// Ein `RibbonItem` ist entweder ein Werkzeug (`tool` → onSelectTool, wie die
+// Werkzeug-Sidebar) oder ein Engine-Befehl (`command` → engine.start). Beide
+// münden in exakt DENSELBEN Pfad wie Klick in der alten Sidebar bzw. das Tippen
+// des Befehlsnamens — keine Duplikate.
+//
+// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md). Sichtbare
+// Texte kommen als i18n-Schlüssel und werden erst in der Komponente über t(...)
+// aufgelöst.
+
+import type { ToolId } from "../../tools/types";
+
+/** Ein aktivierbares Ribbon-Element. Bei `command` wird die Beschriftung aus der
+ * Befehls-Registry (`getCommand(name).labelKey`) abgeleitet — kein doppeltes
+ * Pflegen von Labels. */
+export type RibbonItem =
+ | { kind: "tool"; id: ToolId }
+ | { kind: "command"; name: string };
+
+/** Eine benannte Gruppe von Elementen innerhalb eines Tabs. */
+export interface RibbonGroup {
+ titleKey: string;
+ items: RibbonItem[];
+}
+
+/** Kennung eines Ribbon-Tabs (fest, für Tab-State + i18n). */
+export type RibbonTabId = "2d" | "3d" | "bim" | "views";
+
+/** Ein Ribbon-Tab: Beschriftung + gruppierte Elemente. */
+export interface RibbonTab {
+ id: RibbonTabId;
+ labelKey: string;
+ groups: RibbonGroup[];
+}
+
+const t = (id: ToolId): RibbonItem => ({ kind: "tool", id });
+const c = (name: string): RibbonItem => ({ kind: "command", name });
+
+/**
+ * Die Ribbon-Tabs. Phase 1 füllt 2D (Zeichnen + Ändern) und BIM (Bauteile) mit
+ * den vorhandenen Werkzeugen/Befehlen; 3D und Ansichten bleiben vorerst leer
+ * (Platzhalter, in einer späteren Phase gefüllt — siehe Plan-Doc).
+ */
+export const RIBBON_TABS: RibbonTab[] = [
+ {
+ id: "2d",
+ labelKey: "ribbon.tab.2d",
+ groups: [
+ {
+ titleKey: "ribbon.group.draw",
+ items: [
+ t("select"),
+ t("line"),
+ t("polyline"),
+ t("rect"),
+ t("circle"),
+ t("arc"),
+ ],
+ },
+ {
+ titleKey: "ribbon.group.modify",
+ items: [
+ c("move"),
+ c("copy"),
+ c("mirror"),
+ c("offset"),
+ c("trim"),
+ c("join"),
+ ],
+ },
+ ],
+ },
+ {
+ id: "bim",
+ labelKey: "ribbon.tab.bim",
+ groups: [
+ {
+ titleKey: "ribbon.group.components",
+ items: [
+ t("wall"),
+ t("window"),
+ t("door"),
+ t("stair"),
+ t("ceiling"),
+ t("room"),
+ ],
+ },
+ ],
+ },
+ {
+ id: "3d",
+ labelKey: "ribbon.tab.3d",
+ groups: [],
+ },
+ {
+ id: "views",
+ labelKey: "ribbon.tab.views",
+ groups: [],
+ },
+];