Akkumulierten grünen Arbeitsstand landen (Basis für Weiterarbeit)

Bündelt den über mehrere Sessions gewachsenen, uncommitteten Stand in
einem Basis-Commit, damit Folge-Features isoliert darauf aufsetzen.
Verifikation: tsc --noEmit sauber, vitest 600/600 grün.

Enthalten (Details in PENDENZEN.md -Liste / HANDOVER.md):
- truck-Integration: Profil-Extrusion + Verjüngung + Boolean-CSG (csgrs),
  Crate src-tauri/trucksolid, Werkzeug `extrude`, ExtrudedSolid-Modell.
- kernel2d-Port nach Rust/WASM (Phasen 1–5, Diff-Harness).
- render3d 3D-Live-Schnitt = 2D-Schnitt: geschichteter Bodenaufbau,
  Prioritäts-Verschneidung (section_boolean.rs), einstellbare
  Schichttrennlinien, per-Hatch-Strichstärke, relativeToWall-Orientierung.
- Interop-Export IFC4/STL/OBJ (Loch-Ausschnitt wallMeshCut), Schnellexport.
- Projektdatei .obp + OS-Lock (lock.rs, LockConflictDialog).
- Layout-Blätter (Modell/Editor/Panel/PDF), Ausschnitte, Override-Engine,
  Tragwerk-Stützen (Column), BIM-Tree-Panel.
- Bauteil-Typsystem (Tür/Fenster/Treppe-Typen), Betontreppe mit schräger
  Laufplatte, Text-/Textbox-Werkzeug, Mess-Werkzeug, 2D/3D-Griffe für
  Öffnungen/Treppen, Snap-Symbol-Restyle.
This commit is contained in:
2026-07-09 00:57:29 +02:00
parent 889cbb2c12
commit 35299307d6
131 changed files with 26501 additions and 852 deletions
+287
View File
@@ -0,0 +1,287 @@
// Inhalts-Panel „Elemente" — BIM-Baum Geschoss → Bauteilklasse → Element
// (ROADMAP §11 „Element-Übersicht", Phase 1).
//
// Datenquelle: `scheduleRows()` (export/exportSchedule.ts) — dieselbe
// Auswertung wie die Bauteilliste (CSV-Export), hier aber live zu einem
// klick-/zoombaren Baum gruppiert statt exportiert. Legacy-„Tür"-Datensätze
// (project.doors) werden ausgeblendet: dafür existiert aktuell KEIN
// Auswahl-Kanal (nichts legt sie mehr an, siehe evalDoor-Kommentar) — echte
// Türen laufen als Öffnung (`Opening.kind === "door"`) und erscheinen hier
// als Bauteilklasse „Öffnung".
//
// Klick selektiert das Element (leert alle anderen Auswahl-Kanäle, wechselt
// bei Bedarf das Geschoss); Shift-Klick oder Doppelklick selektiert UND passt
// die Plan-Ansicht ein. Auf-/zuklappbare Geschoss- und Klassen-Knoten, ein
// Textfilter oben durchsucht Name/Typ/Klasse.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { useMemo, useState } from "react";
import { usePanelHost } from "./host";
import type { ScheduleKind, ScheduleRow } from "./host";
import { scheduleRows } from "../export/exportSchedule";
import { t } from "../i18n";
/** Bauteilklassen, die im Baum erscheinen (Reihenfolge = Anzeigereihenfolge). */
const CLASS_ORDER: Exclude<ScheduleKind, "Tür">[] = [
"Wand",
"Decke",
"Fenster",
"Öffnung",
"Treppe",
"Extrusion",
];
/** i18n-Key des Klassen-Labels je Bauteilklasse. */
const CLASS_LABEL_KEY: Record<Exclude<ScheduleKind, "Tür">, string> = {
Wand: "elements.kind.wall",
Decke: "elements.kind.ceiling",
Fenster: "elements.kind.window",
Öffnung: "elements.kind.opening",
Treppe: "elements.kind.stair",
Extrusion: "elements.kind.extrusion",
};
/** Kurzform einer ID für die Anzeige (voller Wert bleibt im Tooltip). */
function shortId(id: string): string {
return id.length <= 14 ? id : `${id.slice(0, 4)}${id.slice(-6)}`;
}
/** Ein Geschoss-Knoten des Baums (auch für Elemente ohne bekanntes Geschoss). */
interface FloorGroup {
/** Gruppierungsschlüssel: die Geschoss-ID, oder ein Namens-Fallback. */
key: string;
/** Anzeigename (Geschoss-Name bzw. bereits aufgelöster Fallback aus row.floor). */
name: string;
rows: ScheduleRow[];
}
/** Gruppiert Zeilen nach Geschoss, in der Reihenfolge der Zeichnungsebenen (oben→unten). */
function buildFloorGroups(
rows: ScheduleRow[],
levels: { id: string; name: string }[],
): FloorGroup[] {
const byKey = new Map<string, ScheduleRow[]>();
for (const r of rows) {
const key = r.floorId ?? `name:${r.floor}`;
const list = byKey.get(key);
if (list) list.push(r);
else byKey.set(key, [r]);
}
// Oberstes Geschoss zuoberst, wie im Zeichnungsebenen-Panel.
const levelsTopDown = [...levels].reverse();
const groups: FloorGroup[] = [];
const used = new Set<string>();
for (const lvl of levelsTopDown) {
const list = byKey.get(lvl.id);
if (list && list.length) {
groups.push({ key: lvl.id, name: lvl.name, rows: list });
used.add(lvl.id);
}
}
// Rest (Elemente ohne bekannte Zeichnungsebene, z. B. verwaiste Referenz).
for (const [key, list] of byKey) {
if (used.has(key)) continue;
groups.push({ key, name: list[0].floor, rows: list });
}
return groups;
}
/** Ob eine Zeile zum Suchtext passt (Name/Typ/Klasse, case-insensitive). */
function matchesQuery(row: ScheduleRow, classLabel: string, query: string): boolean {
const haystack = `${row.typeName} ${classLabel} ${row.id}`.toLowerCase();
return haystack.includes(query);
}
export function ElementTreePanel() {
const host = usePanelHost();
const [query, setQuery] = useState("");
const [collapsedFloors, setCollapsedFloors] = useState<Set<string>>(new Set());
const [collapsedClasses, setCollapsedClasses] = useState<Set<string>>(new Set());
const allRows = useMemo(
() => scheduleRows(host.project).filter((r) => r.kind !== "Tür"),
[host.project],
);
const q = query.trim().toLowerCase();
const searching = q.length > 0;
const visibleRows = useMemo(() => {
if (!searching) return allRows;
return allRows.filter((r) =>
matchesQuery(r, t(CLASS_LABEL_KEY[r.kind as Exclude<ScheduleKind, "Tür">]), q),
);
}, [allRows, q, searching]);
const floorGroups = useMemo(
() => buildFloorGroups(visibleRows, host.project.drawingLevels),
[visibleRows, host.project.drawingLevels],
);
const toggleFloor = (key: string) =>
setCollapsedFloors((s) => {
const next = new Set(s);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
const toggleClass = (key: string) =>
setCollapsedClasses((s) => {
const next = new Set(s);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
const onRowSelect = (row: ScheduleRow, zoom: boolean) => {
host.onSelectScheduleRow(row.kind, row.id, row.floorId, { zoom });
};
if (allRows.length === 0) {
return (
<section className="nav-group">
<header className="nav-group-head">
<span className="nav-group-title">{t("elements.title")}</span>
</header>
<div style={{ padding: "10px 10px", fontSize: 12, color: "var(--muted)" }}>
{t("elements.empty")}
</div>
</section>
);
}
return (
<section className="nav-group">
<header className="nav-group-head">
<span className="nav-group-title">{t("elements.title")}</span>
</header>
<div style={{ padding: "0 6px 6px" }}>
<input
type="text"
value={query}
placeholder={t("elements.search")}
onChange={(e) => setQuery(e.target.value)}
style={{
width: "100%",
boxSizing: "border-box",
background: "var(--input)",
border: "1px solid var(--border)",
borderRadius: 6,
color: "var(--label)",
fontFamily: "inherit",
fontSize: 11,
padding: "4px 8px",
}}
/>
</div>
{floorGroups.length === 0 ? (
<div style={{ padding: "10px 10px", fontSize: 12, color: "var(--muted)" }}>
{t("elements.noMatch")}
</div>
) : (
floorGroups.map((floor) => {
const floorOpen = searching || !collapsedFloors.has(floor.key);
return (
<div className="level-cat" key={floor.key}>
<header
className="level-cat-head"
onClick={() => toggleFloor(floor.key)}
>
<span
className={`level-cat-chevron${floorOpen ? " open" : ""}`}
aria-hidden="true"
>
</span>
<span className="level-cat-title">{floor.name}</span>
<span style={{ fontSize: 10, color: "var(--muted)" }}>
{floor.rows.length}
</span>
</header>
{floorOpen && (
<div className="nav-list">
{CLASS_ORDER.map((kind) => {
const rowsOfKind = floor.rows.filter((r) => r.kind === kind);
if (rowsOfKind.length === 0) return null;
const classKey = `${floor.key}::${kind}`;
const classOpen = searching || !collapsedClasses.has(classKey);
return (
<div key={classKey}>
<header
className="level-cat-head"
style={{ paddingLeft: 20, minHeight: 20 }}
onClick={() => toggleClass(classKey)}
>
<span
className={`level-cat-chevron${classOpen ? " open" : ""}`}
aria-hidden="true"
>
</span>
<span className="level-cat-title" style={{ fontSize: 9 }}>
{t(CLASS_LABEL_KEY[kind])}
</span>
<span style={{ fontSize: 10, color: "var(--muted)" }}>
{rowsOfKind.length}
</span>
</header>
{classOpen && (
<div className="nav-list">
{rowsOfKind.map((row) => (
<div
key={row.id}
className="nav-row"
style={{ paddingLeft: 34 }}
title={row.id}
onClick={(e) => onRowSelect(row, e.shiftKey)}
onDoubleClick={() => onRowSelect(row, true)}
>
<span className="nav-label">{row.typeName}</span>
<span className="nav-elev">{shortId(row.id)}</span>
</div>
))}
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
})
)}
</section>
);
}
/** Tab-Symbol „Elemente" — kleiner Baum (Ast + drei Blätter). */
export function ElementsTabIcon() {
return (
<svg
width={16}
height={16}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.4}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
<line x1="3" y1="2.5" x2="3" y2="13.5" />
<line x1="3" y1="5" x2="8" y2="5" />
<line x1="3" y1="8" x2="8" y2="8" />
<line x1="3" y1="11" x2="8" y2="11" />
<circle cx="11" cy="5" r="1.6" />
<circle cx="11" cy="8" r="1.6" />
<circle cx="11" cy="11" r="1.6" />
</svg>
);
}