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 51e57c368b
commit b9e330ecb7
89 changed files with 7723 additions and 1848 deletions
+4 -2
View File
@@ -13,8 +13,9 @@ import { t } from "../i18n";
import { Dropdown } from "../ui/Dropdown";
/**
* Beschriftungs-Keys je Modus (Werte englisch, vgl. types.ts) — exakt die
* fünf DOSSIER-Modi aus docs/design/context-menu.md.
* Beschriftungs-Keys je Modus (Werte englisch, vgl. types.ts) — die fünf
* DOSSIER-Modi aus docs/design/context-menu.md plus die eigene Ergänzung
* "locked" (aktiv farbig, andere gesperrt aber NICHT grau).
*/
const MODE_OPTIONS: { value: DisplayMode; labelKey: string }[] = [
{ value: "all_force", labelKey: "display.all_force" },
@@ -22,6 +23,7 @@ const MODE_OPTIONS: { value: DisplayMode; labelKey: string }[] = [
{ value: "active", labelKey: "display.active" },
{ value: "grey", labelKey: "display.grey" },
{ value: "grey_locked", labelKey: "display.grey_locked" },
{ value: "locked", labelKey: "display.locked" },
];
/**
+35 -10
View File
@@ -20,6 +20,7 @@ import { itemDisplay } from "./displayMode";
import { usePanelHost } from "./host";
import type { DrawingLevel } from "./host";
import type { DisplayMode } from "./types";
import { DisplayModeSelect } from "./DisplayModeSelect";
import type { DrawingLevelKind } from "../model/types";
import { t } from "../i18n";
@@ -31,8 +32,9 @@ const KIND_BADGE_KEY: Record<DrawingLevelKind, string> = {
drawing: "badge.drawing",
};
/** Kategorien der Zeichnungsebenen, in Anzeigereihenfolge. */
const CATEGORY_ORDER: DrawingLevelKind[] = [
/** Kategorien der Zeichnungsebenen, in Anzeigereihenfolge. Exportiert für das
* native Zeichnungsebenen-Fenster (native/DrawingLevelsWindowApp.tsx). */
export const CATEGORY_ORDER: DrawingLevelKind[] = [
"floor",
"section",
"elevation",
@@ -40,7 +42,7 @@ const CATEGORY_ORDER: DrawingLevelKind[] = [
];
/** i18n-Key des Kategorie-Titels. */
const CATEGORY_TITLE_KEY: Record<DrawingLevelKind, string> = {
export const CATEGORY_TITLE_KEY: Record<DrawingLevelKind, string> = {
floor: "levelCat.floor",
section: "levelCat.section",
elevation: "levelCat.elevation",
@@ -48,7 +50,7 @@ const CATEGORY_TITLE_KEY: Record<DrawingLevelKind, string> = {
};
/** i18n-Key der Beschriftung des „+"-Glyphs (Tooltip/aria-label). */
const CATEGORY_ADD_KEY: Record<DrawingLevelKind, string> = {
export const CATEGORY_ADD_KEY: Record<DrawingLevelKind, string> = {
floor: "levels.addFloor",
section: "levels.addSection",
elevation: "levels.addElevation",
@@ -93,10 +95,13 @@ export function DrawingLevelsPanel() {
return (
<section className="nav-group">
<header className="nav-group-head">
<span className="nav-group-title">{t("nav.drawingLevels")}</span>
</header>
{/* Kein eigener Titel/Kopf mehr — der PanelFrame liefert die EINE
Kopfzeile; die Fenster-Ausklinken-Aktion sitzt dort via headerActions
(DrawingLevelsHeaderActions), kein doppelter Titel. */}
<div className="panel-vis">
<span className="panel-vis-label">{t("nav.visibility")}</span>
<DisplayModeSelect value={displayMode.mode} onChange={displayMode.setMode} />
</div>
{CATEGORY_ORDER.map((kind) => {
const levelsOfKind = levelsTopDown.filter((l) => l.kind === kind);
const isOpen = !collapsed[kind];
@@ -151,8 +156,28 @@ export function DrawingLevelsPanel() {
);
}
/** Eine Zeile im Zeichnungsebenen-Bereich. */
function LevelRow({
/** Aktionen für die gemeinsame PanelFrame-Kopfzeile (statt eigener Titelzeile):
* Zahnrad → Einstellungen (Darstellungsfarben/Projekt-Felder). */
export function DrawingLevelsHeaderActions() {
const host = usePanelHost();
return (
<button
className="nav-settings"
aria-label={t("topbar.settings")}
title={t("topbar.settings")}
onClick={host.onOpenSettings}
>
<span className="material-symbols-outlined" aria-hidden="true">
settings
</span>
</button>
);
}
/** Eine Zeile im Zeichnungsebenen-Bereich. Exportiert, damit das native
* Zeichnungsebenen-Fenster (native/DrawingLevelsWindowApp.tsx) dieselbe
* Zeilen-Darstellung wiederverwendet statt sie zu duplizieren. */
export function LevelRow({
level,
active,
mode,
+4
View File
@@ -26,6 +26,8 @@ import { t } from "../i18n";
const CLASS_ORDER: Exclude<ScheduleKind, "Tür">[] = [
"Wand",
"Decke",
"Dach",
"Stütze",
"Fenster",
"Öffnung",
"Treppe",
@@ -36,6 +38,8 @@ const CLASS_ORDER: Exclude<ScheduleKind, "Tür">[] = [
const CLASS_LABEL_KEY: Record<Exclude<ScheduleKind, "Tür">, string> = {
Wand: "elements.kind.wall",
Decke: "elements.kind.ceiling",
Dach: "elements.kind.roof",
Stütze: "elements.kind.column",
Fenster: "elements.kind.window",
Öffnung: "elements.kind.opening",
Treppe: "elements.kind.stair",
+64 -15
View File
@@ -4,14 +4,18 @@
// Navigator: Farb-Swatch, Code, Name, EyeIcon zum Schalten, eingerückte
// Kinder, Kopfzeile mit + Ebene. Daten/Handler über usePanelHost.
//
// Das Panel deklariert hasDisplayMode. Kategorien kennen keine „aktive"
// Auswahl (anders als Zeichnungsebenen), daher ist isActive je Zeile false;
// `itemDisplay` ergibt damit (fünf DOSSIER-Modi):
// Das Panel deklariert hasDisplayMode. „Aktiv" ist die per Klick gewählte
// Kategorie (activeCategoryCode, Zeichen-Ziel für neue Elemente); dieselbe
// activeCategoryCode speist auch App.tsx' categoryDisplay-Resolver, der die
// tatsächliche Grundriss-/3D-Darstellung steuert. `itemDisplay` ergibt damit
// (fünf DOSSIER-Modi plus eigene Ergänzung "locked"):
// • "all_force" — alle erzwungen sichtbar; Augen gedimmt.
// • "all" — alle sichtbaren normal (Standard, wie bisher).
// • "active" — keine Kategorie aktiv → alle gedimmt.
// • "grey" — sichtbare Kategorien ausgegraut.
// • "grey_locked" — wie „grey", zusätzlich gesperrt.
// • "active" — nur die gewählte Kategorie sichtbar, Rest ausgeblendet.
// • "grey" — gewählte normal, übrige sichtbare ausgegraut.
// • "grey_locked" — wie „grey", übrige zusätzlich gesperrt.
// • "locked" — gewählte normal, übrige sichtbare UNVERÄNDERT farbig,
// nur gesperrt (nicht anwählbar/bearbeitbar).
// Wie im Zeichnungsebenen-Panel bleibt jede Zeile bedienbar (die Liste ist die
// Steuerung); statt auszublenden dimmen wir gedimmte Zeilen per Inline-Opacity
// (styles.css bleibt unangetastet).
@@ -23,6 +27,7 @@ import { itemDisplay } from "./displayMode";
import { usePanelHost } from "./host";
import type { LayerCategory } from "./host";
import type { DisplayMode } from "./types";
import { DisplayModeSelect } from "./DisplayModeSelect";
import { t } from "../i18n";
/** Inhalt des Panels „Ebenen". */
@@ -46,15 +51,13 @@ export function LayersPanel() {
return (
<section className="nav-group">
<header className="nav-group-head">
<span className="nav-group-title">{t("nav.layers")}</span>
<span className="nav-group-actions">
<button className="nav-add" onClick={host.onAddCategory}>
{t("layers.addCategory")}
</button>
</span>
</header>
{/* Kein eigener Titel/Kopf mehr — der PanelFrame liefert die EINE
Kopfzeile; die „+ Kategorie"-Aktion sitzt dort via headerActions
(LayersHeaderActions), kein doppelter „Ebenen"-Titel. */}
<div className="panel-vis">
<span className="panel-vis-label">{t("nav.visibility")}</span>
<DisplayModeSelect value={displayMode.mode} onChange={displayMode.setMode} />
</div>
<div className="nav-list">
{project.layers.map((cat) => (
<CategoryRow
@@ -66,6 +69,7 @@ export function LayersPanel() {
onSelect={host.onSelectCategory}
onToggle={toggle}
onContext={onRowContext}
onSettings={host.onOpenLayerSettings}
/>
))}
</div>
@@ -82,6 +86,7 @@ function CategoryRow({
onSelect,
onToggle,
onContext,
onSettings,
}: {
category: LayerCategory;
depth: number;
@@ -90,6 +95,7 @@ function CategoryRow({
onSelect: (code: string) => void;
onToggle: (code: string) => void;
onContext: (code: string, e: React.MouseEvent) => void;
onSettings: (code: string, clientX: number, clientY: number) => void;
}) {
// „aktiv" = diese Kategorie ist das Zeichen-Ziel (per Klick gesetzt).
const isActive = category.code === activeCode;
@@ -137,6 +143,19 @@ function CategoryRow({
<span className="cat-code">{category.code}</span>
<span className="cat-swatch" style={{ background: category.color }} />
<span className="cat-name">{category.name}</span>
<button
className="cat-settings"
aria-label={t("nav.categorySettings", { name: category.name })}
title={t("nav.categorySettings", { name: category.name })}
onClick={(e) => {
e.stopPropagation();
onSettings(category.code, e.clientX, e.clientY);
}}
>
<span className="material-symbols-outlined" aria-hidden="true">
settings
</span>
</button>
</div>
{category.children?.map((child) => (
<CategoryRow
@@ -148,8 +167,38 @@ function CategoryRow({
onSelect={onSelect}
onToggle={onToggle}
onContext={onContext}
onSettings={onSettings}
/>
))}
</>
);
}
/** Aktionen für die gemeinsame PanelFrame-Kopfzeile (statt eigener Titelzeile):
* „+ Kategorie" + Zahnrad → Einstellungen (wie im Zeichnungsebenen-Kopf).
* Läuft im PanelHostContext, nutzt daher usePanelHost. */
export function LayersHeaderActions() {
const host = usePanelHost();
return (
<>
<button
className="nav-settings"
onClick={host.onOpenSettings}
aria-label={t("topbar.settings")}
title={t("topbar.settings")}
>
<span className="material-symbols-outlined" aria-hidden="true">
settings
</span>
</button>
<button
className="nav-add"
onClick={host.onAddCategory}
aria-label={t("layers.addCategory")}
title={t("layers.addCategory")}
>
+
</button>
</>
);
}
+64
View File
@@ -242,6 +242,35 @@ export function ObjectInfoPanel() {
onCommit={(v) => host.onSetDrawingCircleRadius(v)}
/>
)}
{/* Kontext-Objekt (importiertes Gebäude/Terrain/Konturen): reale LV95-
Koordinate des Bbox-Mittelpunkts — direkt sichtbar, ob/wohin
georeferenziert importiert wurde (Nutzer-Wunsch zum Prüfen). */}
{sel.contextObject && (
<>
<div className="objinfo-sep" />
<div className="objinfo-section-label">{t("objinfo.contextObject.section")}</div>
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.contextObject.type")}</span>
<span className="objinfo-fval">{t(`objinfo.contextObject.type.${sel.contextObject.objType}`)}</span>
</div>
{sel.contextObject.lv95Center ? (
<>
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.contextObject.lv95E")}</span>
<span className="objinfo-fval">{sel.contextObject.lv95Center.e.toFixed(1)}</span>
</div>
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.contextObject.lv95N")}</span>
<span className="objinfo-fval">{sel.contextObject.lv95Center.n.toFixed(1)}</span>
</div>
</>
) : (
<div className="objinfo-field">
<span className="objinfo-fval objinfo-muted">{t("objinfo.contextObject.noAnchor")}</span>
</div>
)}
</>
)}
{/* Die element-spezifischen Abschnitte (Wand/Decke/Öffnung/Treppe/Raum)
werden jetzt im Attribute-Panel gerendert (AttributesPanel importiert
die exportierten *Section-Komponenten). */}
@@ -393,6 +422,20 @@ export function SectionLineSection({
{t("objinfo.sectionLine.flip")}
</button>
{/* 3D-Live-Schnitt an diese Linie binden (nur mit gesetzter Linie sinnvoll):
der Viewport clippt dann vertikal entlang dieser Ebene. */}
{line && (
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.sectionLine.cut3d")}</span>
<input
type="checkbox"
checked={host.section3dCutActive}
onChange={() => host.onToggleSection3dCut(level.id)}
title={t("objinfo.sectionLine.cut3d")}
/>
</div>
)}
{/* Endpunkt-Koordinaten (nur wenn eine Linie gesetzt ist). */}
{line && (
<>
@@ -854,6 +897,27 @@ export function StairSection({
</div>
</div>
{/* Pfeil-Darstellung (offener Winkel- oder gefüllter SIA-Vollpfeil). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.stair.arrow")}</span>
<div className="objinfo-segment">
<button
type="button"
className={"objinfo-seg-btn" + ((stair.arrowStyle ?? "line") === "line" ? " active" : "")}
onClick={() => host.onSetStairArrowStyle("line")}
>
{t("objinfo.stair.arrow.line")}
</button>
<button
type="button"
className={"objinfo-seg-btn" + (stair.arrowStyle === "filled" ? " active" : "")}
onClick={() => host.onSetStairArrowStyle("filled")}
>
{t("objinfo.stair.arrow.filled")}
</button>
</div>
</div>
{/* Editierbare Maße. */}
<DimensionField
label={t("objinfo.stair.width")}
+9 -6
View File
@@ -54,12 +54,15 @@ export function PanelFrame({
<section className="panel-frame">
<header className="panel-head">
<span className="panel-title">{t(def.title)}</span>
{showMode && (
<DisplayModeSelect
value={displayMode as DisplayMode}
onChange={onDisplayModeChange as (m: DisplayMode) => void}
/>
)}
<span className="panel-head-actions">
{def.headerActions}
{showMode && (
<DisplayModeSelect
value={displayMode as DisplayMode}
onChange={onDisplayModeChange as (m: DisplayMode) => void}
/>
)}
</span>
</header>
<div className="panel-body">{def.render(ctx ?? {})}</div>
</section>
+82 -23
View File
@@ -19,6 +19,8 @@ import type { ContextObject } from "../model/types";
import { ContextImportDialog } from "../ui/ContextImportDialog";
import { Dropdown } from "../ui/Dropdown";
import { useStore } from "../state/appStore";
import { isTauriRuntime } from "../io/projectFile";
import { openContextImportWindowNative } from "../native/contextImportWindow";
/** Typ-spezifisches Badge-Label (i18n-Key) je Kontext-Objekt-Art. */
function typeBadgeKey(obj: ContextObject): TranslationKey {
@@ -29,9 +31,43 @@ function typeBadgeKey(obj: ContextObject): TranslationKey {
return "site.type.contours";
case "terrainMesh":
return "site.type.terrain";
case "image":
return "site.type.image";
}
}
/** Ebenen-/Gruppenname eines Kontext-Objekts (aus `layer`, sonst Typ-Fallback). */
function layerOf(obj: ContextObject): string {
if (obj.layer && obj.layer.trim() !== "") return obj.layer;
switch (obj.type) {
case "importedMesh":
return t("site.group.imported");
case "terrainMesh":
return t("site.group.terrain");
case "contourSet":
return t("site.group.contours");
case "image":
return t("site.group.image");
}
}
/** Gruppiert Kontext-Objekte nach Ebene, Reihenfolge des ersten Auftretens. */
function groupByLayer(objects: ContextObject[]): { layer: string; items: ContextObject[] }[] {
const order: string[] = [];
const byLayer = new Map<string, ContextObject[]>();
for (const obj of objects) {
const layer = layerOf(obj);
let items = byLayer.get(layer);
if (!items) {
items = [];
byLayer.set(layer, items);
order.push(layer);
}
items.push(obj);
}
return order.map((layer) => ({ layer, items: byLayer.get(layer)! }));
}
export function SitePanel() {
const host = usePanelHost();
const notify = useStore((s) => s.notify);
@@ -72,7 +108,10 @@ export function SitePanel() {
<button
type="button"
className="site-btn"
onClick={() => setCtxImportOpen(true)}
onClick={() => {
if (isTauriRuntime()) void openContextImportWindowNative();
else setCtxImportOpen(true);
}}
>
{t("site.importLocation")}
</button>
@@ -117,37 +156,57 @@ export function SitePanel() {
<div className="site-sep" />
{/* Liste der Kontext-Objekte (Name + Typ-Badge + Entfernen). */}
{/* Kontext-Objekte nach Ebene (`layer`) gruppiert: importierte Gebäude
liegen als EINZELNE, getrennt lösch-/anwählbare Elemente unter ihrer
Ebene (z. B. „Gebäude"), Terrain unter „Terrain". Je Gruppe ein Kopf
mit Anzahl + „ganze Ebene löschen". */}
{objects.length === 0 ? (
<div className="site-empty">{t("site.empty")}</div>
) : (
<ul className="site-list">
{objects.map((obj) => (
<li
key={obj.id}
className={
"site-row" +
(host.selectedContextObjectIds.includes(obj.id) ? " site-row-selected" : "")
}
>
<span className="site-name" title={obj.name}>
{obj.name}
</span>
<span className={"site-badge site-badge-" + obj.type}>
{t(typeBadgeKey(obj))}
</span>
groupByLayer(objects).map(({ layer, items }) => (
<div key={layer} className="site-group">
<div className="site-group-head">
<span className="site-group-name">{layer}</span>
<span className="site-group-count">{items.length}</span>
<button
type="button"
className="site-remove"
title={t("site.delete")}
aria-label={t("site.delete")}
onClick={() => host.onRemoveContextObject(obj.id)}
title={t("site.deleteLayer")}
aria-label={t("site.deleteLayer")}
onClick={() => host.onRemoveContextObjects(items.map((o) => o.id))}
>
×
</button>
</li>
))}
</ul>
</div>
<ul className="site-list">
{items.map((obj) => (
<li
key={obj.id}
className={
"site-row" +
(host.selectedContextObjectIds.includes(obj.id) ? " site-row-selected" : "")
}
>
<span className="site-name" title={obj.name}>
{obj.name}
</span>
<span className={"site-badge site-badge-" + obj.type}>
{t(typeBadgeKey(obj))}
</span>
<button
type="button"
className="site-remove"
title={t("site.delete")}
aria-label={t("site.delete")}
onClick={() => host.onRemoveContextObject(obj.id)}
>
×
</button>
</li>
))}
</ul>
</div>
))
)}
<div className="site-hint">{t("site.dwgHint")}</div>
+153 -52
View File
@@ -1,18 +1,23 @@
// Inhalts-Panel „Werkzeuge" — die vertikale Werkzeug-Palette (ArchiCAD-/
// Vectorworks-Stil): eine Zeile je Zeichenwerkzeug, jeweils Icon + Name, das
// aktive Werkzeug hervorgehoben. Darunter — wenn das Wand-Werkzeug aktiv ist —
// ein Wandtyp-Auswahlfeld, und ein kompakter Fang-Abschnitt mit Checkboxen.
// Inhalts-Panel „Werkzeuge" — die Werkzeug-Palette (ArchiCAD-/Vectorworks-Stil).
// Oben ein Umschalter „Liste ↔ Symbole" (Icon+Name als Zeilen ODER kompaktes
// Icon-Raster). Darunter drei einheitlich gestylte Rubriken: 2D (Zeichnen),
// 3D (BIM-Bauteile) und Bearbeiten (Ändern-Aktionen) — Werkzeuge UND Befehle
// teilen denselben Knopf-Look (`.tp-item`), damit „Ändern" nicht mehr optisch
// aus der Reihe fällt.
//
// Daten/Handler kommen über usePanelHost (kein Prop-Drilling). Die Werkzeug-
// Liste und ihre Metadaten stammen aus der Tools-Registry (TOOL_ORDER/getTool).
// Daten/Handler kommen über usePanelHost (kein Prop-Drilling). Werkzeug-Metadaten
// aus der Tools-Registry (getTool), Befehls-Labels aus der Command-Registry.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md). Alle sichtbaren
// Texte über t(...).
import { useState } from "react";
import { t } from "../i18n";
import { usePanelHost } from "./host";
import type { ToolId } from "./host";
import { TOOL_ORDER, getTool } from "../tools/tools";
import { getTool } from "../tools/tools";
import { getCommand } from "../commands/registry";
import { CommandIcon } from "../ui/ribbon/CommandIcon";
// ── Icons ──────────────────────────────────────────────────────────────────
// Keine echten Asset-Icons vorhanden: schlichte Inline-SVG-Glyphen je Werkzeug
@@ -31,18 +36,17 @@ export function ToolIcon({ id }: { id: ToolId }) {
};
switch (id) {
case "select":
// Pfeil-Cursor.
// Pfeil-Cursor (klassischer Zeiger mit Schweif).
return (
<svg {...common} fill="currentColor" stroke="none">
<path d="M3 2l9 5-3.6 1.1L10.4 13 8.5 13.7 6.6 9.4 3 11z" />
<svg {...common} fill="currentColor" stroke="currentColor" strokeWidth={0.8}>
<path d="M4 2.5 L4 13.2 L6.9 10.4 L8.9 14.6 L10.7 13.8 L8.7 9.7 L12.6 9.6 Z" />
</svg>
);
case "wall":
// Zwei parallele Linien (Wandband).
// Wandband: zwei parallele Kanten mit geschlossenen Enden (Wandstück).
return (
<svg {...common}>
<line x1="2" y1="5.5" x2="14" y2="5.5" />
<line x1="2" y1="10.5" x2="14" y2="10.5" />
<rect x="2" y="5.5" width="12" height="5" />
</svg>
);
case "ceiling":
@@ -55,29 +59,37 @@ export function ToolIcon({ id }: { id: ToolId }) {
</svg>
);
case "window":
// Fenster: Rahmen-Rechteck mit Mittelsprosse (Verglasung).
// Fenster (SIA-Aufsicht): Wandöffnung mit doppelter Glaslinie.
return (
<svg {...common}>
<rect x="2.5" y="4" width="11" height="8" />
<line x1="8" y1="4" x2="8" y2="12" />
<line x1="2" y1="5.5" x2="14" y2="5.5" />
<line x1="2" y1="10.5" x2="14" y2="10.5" />
<line x1="2" y1="8" x2="14" y2="8" />
<line x1="2" y1="5.5" x2="2" y2="10.5" />
<line x1="14" y1="5.5" x2="14" y2="10.5" />
</svg>
);
case "door":
// Tür: Pfosten + Türblatt mit Schwenkbogen.
// Tür (SIA-Aufsicht): Türblatt aufrecht + Viertelkreis-Schwenkbogen.
return (
<svg {...common}>
<line x1="3" y1="13" x2="3" y2="4" />
<line x1="3" y1="4" x2="11" y2="4" />
<path d="M3 13 A9 9 0 0 0 11 4" />
<line x1="3.5" y1="13" x2="3.5" y2="3" />
<path d="M3.5 3 A9.5 9.5 0 0 1 13 12.5" strokeDasharray="1.4 1.4" />
<line x1="3.5" y1="13" x2="13" y2="13" />
</svg>
);
case "stair":
// Treppensymbol: gestufte Linie (Tritte) + Lauflinie mit Pfeil.
// Treppe (SIA-Aufsicht): Wangen, gleichmäßige Tritte, Lauflinie mit
// Aufwärts-Pfeil (Idee: Vollbogen ersetzt durch eine echte Treppe).
return (
<svg {...common}>
<polyline points="2,14 2,11 6,11 6,8 10,8 10,5 14,5 14,2" />
<line x1="4" y1="12.5" x2="12" y2="3.5" />
<polyline points="10.5,3 12,3.5 11.5,5" />
<rect x="3" y="2" width="10" height="12" />
<line x1="3" y1="4.4" x2="13" y2="4.4" />
<line x1="3" y1="6.8" x2="13" y2="6.8" />
<line x1="3" y1="9.2" x2="13" y2="9.2" />
<line x1="3" y1="11.6" x2="13" y2="11.6" />
<line x1="8" y1="13.2" x2="8" y2="3.4" />
<polyline points="6.3,5.1 8,3.2 9.7,5.1" />
</svg>
);
case "room":
@@ -118,10 +130,12 @@ export function ToolIcon({ id }: { id: ToolId }) {
</svg>
);
case "arc":
// Offener Halbbogen.
// Offener Bogen (gut 180°) mit Endpunkt-Markierungen.
return (
<svg {...common}>
<path d="M 2.5 12 A 5.5 5.5 0 0 1 13.5 12" fill="none" />
<path d="M 2.5 11 A 5.6 5.6 0 0 1 13.5 11" fill="none" />
<circle cx="2.5" cy="11" r="0.9" fill="currentColor" stroke="none" />
<circle cx="13.5" cy="11" r="0.9" fill="currentColor" stroke="none" />
</svg>
);
case "text":
@@ -147,44 +161,131 @@ export function ToolIcon({ id }: { id: ToolId }) {
}
}
// ── Kategorien ───────────────────────────────────────────────────────────────
// Werkzeuge/Befehle in drei Rubriken sortiert (Nutzerwunsch: „sortiere nach 2D
// und 3D und Bearbeiten"). Befehlsnamen spiegeln die Ribbon-Registry
// (ui/ribbon/ribbonItems.ts) — dort liegt die kanonische Liste; hier nur die
// Panel-Gruppierung (2D-Zeichnen · 3D/BIM-Bauteile · Bearbeiten-Aktionen).
const TOOLS_2D: ToolId[] = ["line", "polyline", "rect", "circle", "arc", "text", "textbox"];
const TOOLS_3D: ToolId[] = ["wall", "ceiling", "window", "door", "stair", "room"];
const COMMANDS_3D = ["column", "ceilingopening", "roof", "extrude", "sectionline", "viewline"];
const COMMANDS_EDIT = ["move", "copy", "rotate", "mirror", "offset", "trim", "join", "measure"];
type ToolsView = "list" | "icons";
const VIEW_STORAGE_KEY = "dossier.toolsPanel.view";
function loadView(): ToolsView {
try {
return localStorage.getItem(VIEW_STORAGE_KEY) === "icons" ? "icons" : "list";
} catch {
return "list";
}
}
// ── Panel ────────────────────────────────────────────────────────────────────
export function ToolsPanel() {
const host = usePanelHost();
const [view, setView] = useState<ToolsView>(loadView);
const chooseView = (v: ToolsView) => {
setView(v);
try {
localStorage.setItem(VIEW_STORAGE_KEY, v);
} catch {
/* Persistenz ist Best-Effort (privater Modus o. ä.). */
}
};
// Ein Werkzeug-Knopf (Icon + Name). Boden-gebundene (`floorOnly`) Werkzeuge
// sind ohne aktives Geschoss gesperrt; freie 2D-Werkzeuge bleiben nutzbar.
const toolButton = (id: ToolId) => {
const tool = getTool(id);
const active = host.activeTool === id;
const disabled = !!tool.floorOnly && !host.toolsEnabled;
const label = t(tool.labelKey);
const title = disabled ? t("tool.floorOnlyDisabled") : t(tool.hintKey(tool.init()));
return (
<button
key={id}
type="button"
className={"tp-item" + (active ? " active" : "") + (disabled ? " disabled" : "")}
title={view === "icons" ? label : title}
disabled={disabled}
onClick={() => host.onSelectTool(id)}
>
<ToolIcon id={id} />
<span className="tp-label">{label}</span>
</button>
);
};
// Ein Befehls-Knopf (Ändern-/BIM-Kommando ohne eigenen Werkzeug-Modus) —
// gleicher Look wie die Werkzeuge (Nutzerwunsch: nicht „ganz anders").
const commandButton = (name: string) => {
const cmd = getCommand(name);
const label = cmd ? t(cmd.labelKey) : t(`cmd.${name}.label`);
const active = host.activeCommand === name;
return (
<button
key={name}
type="button"
className={"tp-item" + (active ? " active" : "")}
title={label}
onClick={() => host.onRunCommand(name)}
>
<CommandIcon name={name} />
<span className="tp-label">{label}</span>
</button>
);
};
const bodyClass = "tp-body" + (view === "icons" ? " icons" : "");
return (
<div className="tools-panel">
{/* Werkzeug-Zeilen: Icon + Name, aktives hervorgehoben. */}
{TOOL_ORDER.map((id) => {
const tool = getTool(id);
const active = host.activeTool === id;
// Nur Boden-gebundene (`floorOnly`) Werkzeuge sind ohne aktives Geschoss
// gesperrt; freie 2D-Werkzeuge (Linie/Kreis/Text/…) bleiben auf
// Zeichnungsebenen nutzbar — Parität mit dem Ribbon (RibbonBar).
const disabled = !!tool.floorOnly && !host.toolsEnabled;
const title = disabled
? t("tool.floorOnlyDisabled")
: t(tool.hintKey(tool.init()));
const cls =
"tool-row" + (active ? " active" : "") + (disabled ? " disabled" : "");
return (
{/* Anzeige-Umschalter: nur Symbole ODER Symbol + Name (Liste). */}
<div className="tp-head">
<div className="tp-viewtoggle" role="group" aria-label={t("tools.view.label")}>
<button
key={id}
type="button"
className={cls}
title={title}
disabled={disabled}
onClick={() => host.onSelectTool(id)}
className={"tp-viewbtn" + (view === "list" ? " active" : "")}
aria-pressed={view === "list"}
onClick={() => chooseView("list")}
>
<ToolIcon id={id} />
<span>{t(tool.labelKey)}</span>
{t("tools.view.list")}
</button>
);
})}
<button
type="button"
className={"tp-viewbtn" + (view === "icons" ? " active" : "")}
aria-pressed={view === "icons"}
onClick={() => chooseView("icons")}
>
{t("tools.view.icons")}
</button>
</div>
</div>
{/* Wandtyp-/Deckentyp-Auswahl ist ins Attribute-Panel gewandert (Ribbon-UI
Phase 3: Werkzeug-Sidebar nicht mehr im Default-Layout). Der Picker
erscheint dort bei aktivem Wand-/Decken-Werkzeug. */}
{/* Auswahl-Werkzeug: immer als volle Zeile zuoberst (meistgenutzt). */}
<div className="tp-select">{toolButton("select")}</div>
<section className="tp-section">
<div className="tp-section-title">{t("tools.cat.2d")}</div>
<div className={bodyClass}>{TOOLS_2D.map(toolButton)}</div>
</section>
<section className="tp-section">
<div className="tp-section-title">{t("tools.cat.3d")}</div>
<div className={bodyClass}>
{TOOLS_3D.map(toolButton)}
{COMMANDS_3D.map(commandButton)}
</div>
</section>
<section className="tp-section">
<div className="tp-section-title">{t("tools.cat.edit")}</div>
<div className={bodyClass}>{COMMANDS_EDIT.map(commandButton)}</div>
</section>
</div>
);
}
+8 -4
View File
@@ -15,8 +15,8 @@
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { registerPanel } from "./registry";
import { DrawingLevelsPanel } from "./DrawingLevelsPanel";
import { LayersPanel } from "./LayersPanel";
import { DrawingLevelsPanel, DrawingLevelsHeaderActions } from "./DrawingLevelsPanel";
import { LayersPanel, LayersHeaderActions } from "./LayersPanel";
import { ToolsPanel } from "./ToolsPanel";
import { AttributesPanel } from "./AttributesPanel";
import { ObjectInfoPanel } from "./ObjectInfoPanel";
@@ -82,7 +82,10 @@ registerPanel({
// damit der Titel der gewählten Sprache folgt.
title: "nav.drawingLevels",
icon: <DrawingLevelsTabIcon />,
hasDisplayMode: true,
// Der Sichtbarkeits-Umschalter sitzt jetzt IM Körper mit „Sichtbarkeit"-
// Beschriftung (nicht mehr im PanelFrame-Kopf) — hasDisplayMode: false.
hasDisplayMode: false,
headerActions: <DrawingLevelsHeaderActions />,
render: () => <DrawingLevelsPanel />,
});
@@ -90,7 +93,8 @@ registerPanel({
id: BUILTIN_PANEL_IDS.layers,
title: "nav.layers",
icon: <LayersTabIcon />,
hasDisplayMode: true,
hasDisplayMode: false,
headerActions: <LayersHeaderActions />,
render: () => <LayersPanel />,
});
+10 -1
View File
@@ -32,8 +32,10 @@ export interface ItemDisplay {
* ausgegraut.
* • "grey_locked" — wie „grey", übrige (sichtbare, nicht aktive) zusätzlich
* gesperrt.
* • "locked" — aktives normal; übrige sichtbare UNVERÄNDERT farbig,
* aber gesperrt (nicht anwählbar/bearbeitbar).
*
* Das aktive Element wird in "active"/"grey"/"grey_locked" stets gezeichnet,
* Das aktive Element wird in "active"/"grey"/"grey_locked"/"locked" stets gezeichnet,
* auch wenn seine Sichtbarkeit abgeschaltet ist — es ist der Arbeitsfokus. Ein
* nicht aktives, unsichtbares Element wird (außer in "all_force") nie
* gezeichnet.
@@ -65,5 +67,12 @@ export function itemDisplay(
const other = isVisible && !isActive;
return { render: isActive || isVisible, greyed: other, locked: other };
}
case "locked": {
// Wie „grey_locked", aber OHNE das Ausgrauen — übrige sichtbare bleiben
// farbig, sind nur nicht mehr anwählbar/bearbeitbar.
const other = isVisible && !isActive;
return { render: isActive || isVisible, greyed: false, locked: other };
}
}
}
+29 -1
View File
@@ -74,6 +74,11 @@ export interface PanelHostValue {
onSelectTool: (id: ToolId) => void;
/** Werkzeuge nur im Grundriss eines Geschosses sinnvoll. */
toolsEnabled: boolean;
/** Laufender Engine-Befehl (Highlight der Befehls-Knöpfe im ToolsPanel). */
activeCommand: string | null;
/** Engine-Befehl starten (Ändern-/BIM-Befehle im ToolsPanel — gleicher Pfad
* wie das Tippen des Befehlsnamens in der Befehlszeile). */
onRunCommand: (name: string) => void;
/** Aktiver Wandtyp (für das Wand-Werkzeug). */
activeWallTypeId: string;
onActiveWallTypeId: (id: string) => void;
@@ -112,6 +117,14 @@ export interface PanelHostValue {
* Löschen) und hält das offene Menü.
*/
onLayerContextMenu: (code: string, clientX: number, clientY: number) => void;
/** Öffnet die Ebeneneinstellungen direkt (Zahnrad-Symbol je Zeile) — unter
* Tauri als natives Fenster, sonst derselbe InlineEditor-Popover wie über
* das Kontextmenü (siehe App.tsx `openLayerSettings`). */
onOpenLayerSettings: (code: string, clientX: number, clientY: number) => void;
/** Öffnet das Einstellungs-Fenster (Darstellungsfarben, Projekt-Felder) —
* unter Tauri als natives Fenster, sonst als In-App-Dialog (App.openSettings).
* Als Zahnrad in den Panel-Kopfzeilen „Zeichnungsebenen"/„Ebenen". */
onOpenSettings: () => void;
// Ressourcen-Handler (Bauteile / Schraffuren / Linienstile). Spiegelt
// ResourceManagerHandlers, hier flach getippt, damit dieses Modul nicht von
@@ -351,6 +364,15 @@ export interface PanelHostValue {
* linePoints/depth) — wirkt NUR auf die selektierte Linie.
*/
onSetSectionLinePatch: (patch: Partial<DrawingLevel>) => void;
/**
* Ob die aktuell im Objekt-Info gezeigte Schnittlinie (`sectionLine`) den
* 3D-Live-Schnitt treibt — der `Wasm3DViewport` clippt dann vertikal entlang
* dieser Ebene (`sectionPlaneFromLevel`). Reiner Ansichts-Zustand (nicht im
* Dokument).
*/
section3dCutActive: boolean;
/** Bindet den 3D-Live-Schnitt an diese Schnittlinie / hebt die Bindung auf. */
onToggleSection3dCut: (id: string) => void;
/**
* Löscht die Schnittlinie der gewählten Ebene (`linePoints` = undefined); die
* Ebene selbst bleibt bestehen (der Schnitt zeigt dann wieder den Hinweis).
@@ -368,6 +390,8 @@ export interface PanelHostValue {
onSetStairRise: (totalRise: number) => void;
/** Setzt die Laufrichtung (aufwärts/abwärts). */
onSetStairUp: (up: boolean) => void;
/** Setzt die Pfeil-Darstellung (offener Winkel- vs. gefüllter Vollpfeil). */
onSetStairArrowStyle: (style: "line" | "filled") => void;
/** Setzt den Referenzpunkt der Laufbreite (links/mitte/rechts). */
onSetStairReferenz: (referenz: "links" | "mitte" | "rechts") => void;
/** Weist der Treppe einen Treppentyp (Bibliothek) zu; "" löst die Zuweisung. */
@@ -410,6 +434,8 @@ export interface PanelHostValue {
onAddContextObjects: (objs: ContextObject[]) => void;
/** Entfernt ein Kontext-Objekt per ID. */
onRemoveContextObject: (id: string) => void;
/** Entfernt mehrere Kontext-Objekte in einem Schritt (ganze Ebene/Gruppe). */
onRemoveContextObjects: (ids: string[]) => void;
/**
* Erzeugt aus einem ContourSet (per ID) ein Gelände-TIN. Liefert die neue
* TerrainMesh-ID oder null (unbekannte ID / kein sinnvolles TIN).
@@ -421,7 +447,9 @@ export interface PanelHostValue {
* den Bezug (nächster Import setzt ihn automatisch neu).
*/
onSetGeoAnchor: (
anchor: { lv95: { e: number; n: number }; model: { x: number; y: number }; label?: string } | undefined,
anchor:
| { lv95: { e: number; n: number }; height?: number; model: { x: number; y: number }; label?: string }
| undefined,
) => void;
/** Öffnet den (in App montierten) versteckten DXF-Datei-Dialog. */
onImportDxf: () => void;
+28 -5
View File
@@ -40,7 +40,10 @@ import type {
// als weiterer Tab in der rechten unteren Gruppe.
// v11: Layouts-Panel ("layouts", DOSSIER A3 / ROADMAP Paragraf 11) als weiterer
// Tab in der rechten unteren Gruppe.
export const LAYOUT_VERSION = 11;
// v12: Werkzeug-Palette ZURÜCK ins Default-Layout (Nutzer-Entscheid: Werkzeuge
// gehören in ein andockbares Panel statt ins Ribbon-Band) — wieder oben links,
// Attribute darunter (Vectorworks-Bild aus v6, vor dem Ribbon-UI-Phase-3-Bump).
export const LAYOUT_VERSION = 12;
/** localStorage-Schlüssel des zuletzt benutzten Layouts. */
const CURRENT_KEY = "cad.layout";
@@ -64,11 +67,11 @@ const NAMED_PREFIX = "cad.layouts.";
* OBEN, Zeichnungsebenen + Ebenen als Tab-Stapel UNTEN. (Ressourcen bleibt ein
* schwebendes Fenster.) Jede Gruppe trägt ein Höhen-Gewicht.
*/
// Ribbon-UI Phase 3: die Werkzeug-Sidebar ("tools") ist NICHT mehr im Default-
// Layout — die Werkzeuge liegen im Ribbon, der Wandtyp-/Deckentyp-Picker im
// Attribute-Panel. `ToolsPanel` bleibt registriert (per Fenster-Menü andockbar),
// nur nicht mehr per Default sichtbar → Attribute bekommt die volle linke Höhe.
// v12: Werkzeug-Palette ("tools") wieder im Default-Layout — oben links, mit
// der Attribute-Palette darunter (Vectorworks-Bild). Das Ribbon-Band bleibt als
// Reiter erreichbar, ist aber nicht mehr der primäre Weg zu den Werkzeugen.
const DEFAULT_LEFT_GROUPS: DockGroup[] = [
{ tabs: ["tools"], activeTab: "tools", weight: 1 },
{ tabs: ["attributes"], activeTab: "attributes", weight: 1 },
];
const DEFAULT_RIGHT_GROUPS: DockGroup[] = [
@@ -376,6 +379,26 @@ export function setActiveTab(
return { ...layout, [dock]: { ...d, groups } };
}
/**
* Macht ein Panel sichtbar, gleich wo es gerade steckt — NIE destruktiv (schliesst/
* versteckt nie etwas anderes). Für „Fenster > Panels" (native Menüleiste,
* appMenu.ts) und künftige ähnliche Einstiege:
* • schon schwebend → nach vorne holen (bringToFront).
* • angedockt, inaktiver Tab → als aktiven Tab seiner Gruppe setzen.
* • nirgends vorhanden → als neues schwebendes Fenster öffnen (Default-Rect).
*/
export function revealPanel(layout: LayoutState, panelId: string): LayoutState {
if (layout.floating.some((f) => f.panelId === panelId)) {
return bringToFront(layout, panelId);
}
for (const dock of ["left", "right"] as DockId[]) {
if (layout[dock].groups.some((g) => g.tabs.includes(panelId))) {
return setActiveTab(layout, dock, panelId);
}
}
return moveToFloat(layout, panelId, { x: 120, y: 120, w: 320, h: 420 });
}
// `otherDock` wird (noch) nicht extern gebraucht, ist aber Teil der Aufräum-
// Semantik; als Helfer exportiert, damit Wiring-Code symmetrisch arbeiten kann.
export { otherDock };
+13 -3
View File
@@ -97,16 +97,18 @@ export interface LayoutState {
/**
* Darstellungsmodus für ortsabhängige Inhalte (Navigator-Listen, 3D, Grundriss).
* Steuert, welche Elemente bezogen auf die aktive Auswahl gezeigt werden — die
* fünf DOSSIER-Modi (siehe docs/design/context-menu.md), für Ebenen UND
* Zeichnungsebenen identisch:
* fünf DOSSIER-Modi (siehe docs/design/context-menu.md) plus eine eigene
* Ergänzung ("locked"), für Ebenen UND Zeichnungsebenen identisch:
* • "all_force" — alle erzwungen sichtbar; Augen gedimmt; Klick aufs Auge
* wechselt zu „Ausgewählte".
* • "all" — sichtbar nach per-Zeile-Flag (Standard).
* • "active" — nur das aktive Element; andere stark gedimmt.
* • "grey" — aktives normal, andere 45 % (Sichtbarkeits-Flags gelten).
* • "grey_locked" — wie „grey", andere zusätzlich gesperrt.
* • "locked" — aktives normal, andere sichtbare UNVERÄNDERT farbig,
* aber gesperrt (nicht anwählbar/bearbeitbar).
*/
export type DisplayMode = "all_force" | "all" | "active" | "grey" | "grey_locked";
export type DisplayMode = "all_force" | "all" | "active" | "grey" | "grey_locked" | "locked";
// ── Panel-Definition & Kontext ─────────────────────────────────────────────
@@ -151,6 +153,14 @@ export interface PanelDef {
* Kopfzeile anbieten soll. Default: kein Umschalter.
*/
hasDisplayMode?: boolean;
/**
* Optionale Aktions-Elemente rechts in der EINEN Kopfzeile des PanelFrame
* (z. B. „+ Kategorie", Fenster-Ausklinken). So bündeln Panels ihre Aktionen
* in der gemeinsamen Frame-Kopfzeile statt einer zweiten eigenen Titelzeile
* (kein doppelter Titel mehr). Wird INNERHALB des PanelHostContext gerendert,
* darf also `usePanelHost()` nutzen.
*/
headerActions?: ReactNode;
}
// ── Host-Context (App-Zustand + Handler für Panels) ────────────────────────