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
+15 -2
View File
@@ -2,7 +2,7 @@
//
// Beide Quellen liefern am Ende dasselbe: geschlossene/offene Polylinien in
// LOKALEN Modell-Metern (bereits um die Origin verschoben, siehe lv95.ts),
// kategorisiert nach Art (Gebäude/Strasse/Wasser/Grün). Die UI wandelt diese in
// kategorisiert nach Art (Gebäude/Strasse/Wasser/Grün/Parkplatz/Bahn/Wald). Die UI wandelt diese in
// `ContourSet`-Kontext-Objekte pro Kategorie um und legt sie in `project.context`.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
@@ -12,7 +12,14 @@ import type { GeoOrigin } from "./lv95";
import type { ContextObject, Contour, ImportedMesh } from "../model/types";
/** Fachliche Kategorie einer importierten Kontext-Linie. */
export type GeoCategory = "building" | "road" | "water" | "green";
export type GeoCategory =
| "building"
| "road"
| "water"
| "green"
| "parking"
| "railway"
| "forest";
/** Ein importiertes Polygon/Polylinie in lokalen Modell-Metern. */
export interface GeoFeature {
@@ -44,6 +51,9 @@ const CATEGORY_LABEL: Record<GeoCategory, string> = {
road: "Strassen",
water: "Gewässer",
green: "Grünflächen",
parking: "Parkplätze",
railway: "Bahn",
forest: "Wald",
};
/** Layer-Kennung (englischer Identifier) je Kategorie — für spätere Zuordnung. */
@@ -52,6 +62,9 @@ const CATEGORY_LAYER: Record<GeoCategory, string> = {
road: "context-roads",
water: "context-water",
green: "context-green",
parking: "context-parking",
railway: "context-railway",
forest: "context-forest",
};
/** Erzeugt eine (kollisionsarme) ID für ein neues Kontext-Objekt. */
+28 -8
View File
@@ -1,7 +1,8 @@
// OpenStreetMap-Anbindung (Overpass API) für den Standort-Import.
//
// Holt für eine LV95-Box Gebäude / Strassen / Wasser / Grünflächen und wandelt
// sie in Polylinien in LOKALEN Modell-Metern (über LV95 + Origin, siehe
// Holt für eine LV95-Box Gebäude / Strassen / Wasser / Grünflächen / Parkplätze /
// Bahnlinien / Wald und wandelt sie in Polylinien in LOKALEN Modell-Metern (über
// LV95 + Origin, siehe
// lv95.ts). Overpass sendet je nach Mirror unzuverlässige CORS-Header, daher
// läuft der Abruf über den openbureau-core Geo-Proxy (`viaProxy`), sofern
// `VITE_GEO_PROXY` gesetzt ist — sonst Direktabruf gegen den CORS-fähigen
@@ -24,6 +25,9 @@ export interface OsmSelection {
roads: boolean;
water: boolean;
green: boolean;
parking: boolean;
railway: boolean;
forest: boolean;
}
/** Ein Overpass-Element mit inline-Geometrie (`out geom`). */
@@ -55,7 +59,12 @@ function buildQuery(
}
if (sel.green) {
parts.push(`way["leisure"="park"](${b});`);
parts.push(`way["landuse"~"grass|forest|meadow|recreation_ground"](${b});`);
parts.push(`way["landuse"~"grass|meadow|recreation_ground"](${b});`);
}
if (sel.parking) parts.push(`way["amenity"="parking"](${b});`);
if (sel.railway) parts.push(`way["railway"~"rail|tram"](${b});`);
if (sel.forest) {
parts.push(`way["landuse"="forest"](${b});`);
parts.push(`way["natural"="wood"](${b});`);
}
return `[out:json][timeout:40];(${parts.join("")});out geom;`;
@@ -69,20 +78,23 @@ function categorize(
if (!tags) return null;
if (sel.buildings && tags.building) return "building";
if (sel.water && (tags.natural === "water" || tags.waterway)) return "water";
if (sel.forest && (tags.natural === "wood" || tags.landuse === "forest"))
return "forest";
if (
sel.green &&
(tags.leisure === "park" ||
tags.natural === "wood" ||
/grass|forest|meadow|recreation_ground/.test(tags.landuse ?? ""))
/grass|meadow|recreation_ground/.test(tags.landuse ?? ""))
)
return "green";
if (sel.parking && tags.amenity === "parking") return "parking";
if (sel.railway && /rail|tram/.test(tags.railway ?? "")) return "railway";
if (sel.roads && tags.highway) return "road";
return null;
}
/** Geschlossene Kategorien (Flächen) vs. offene (Strassen). */
/** Geschlossene Kategorien (Flächen) vs. offene (Strassen/Bahn). */
function isClosedCategory(cat: GeoCategory): boolean {
return cat !== "road";
return cat !== "road" && cat !== "railway";
}
/**
@@ -118,7 +130,15 @@ export async function fetchOsm(
originIn?: GeoOrigin,
): Promise<{ origin: GeoOrigin; features: GeoFeature[] }> {
const origin = originIn ?? makeOrigin(center);
if (!sel.buildings && !sel.roads && !sel.water && !sel.green) {
if (
!sel.buildings &&
!sel.roads &&
!sel.water &&
!sel.green &&
!sel.parking &&
!sel.railway &&
!sel.forest
) {
return { origin, features: [] };
}
+161
View File
@@ -0,0 +1,161 @@
// Eigenes Projektdatei-Format (Endung `.obp`, "openbureau project") + Lock-Datei
// gegen gleichzeitiges Öffnen derselben Projektdatei aus zwei Instanzen.
//
// Der Dateiinhalt ist unverändert das bisherige Projekt-JSON — nur Endung/
// Dialog-Filter sind neu. Alte `.json`-Projekte bleiben ladbar (Filter erlaubt
// beide, s. PROJECT_DIALOG_FILTERS).
//
// Der Lock ist ein OS-Advisory-Lock im Rust-Kern (src-tauri/src/lock.rs, Crate
// `fs4`): eine Sidecar-Datei `foo.obp.lock` wird exklusiv gelockt, solange die
// Instanz die Datei offen hat. Der OS-Lock fällt beim Prozessende (auch bei
// Absturz) automatisch weg — hier nur "best effort" für sauberes Timing beim
// normalen Schliessen. Nur unter Tauri relevant: im reinen Browser gibt es
// keinen echten Dateipfad (Blob-Download), daher entfällt der Lock dort.
import { saveTextFile } from "./saveFile";
/** Datei-Endung für Projektdateien (ohne Punkt). Einzige Änderungsstelle. */
export const PROJECT_EXT = "obp";
/** Dialog-Filter für den nativen Speichern-/Öffnen-Dialog: neue Endung + Alt-JSON. */
export const PROJECT_DIALOG_FILTERS = [
{ name: "openbureau-Projekt", extensions: [PROJECT_EXT] },
{ name: "openbureau-Projekt (alt, JSON)", extensions: ["json"] },
];
/** Erkennt Tauri wie an anderen Stellen (saveFile.ts, nativeSync.ts). */
export function isTauriRuntime(): boolean {
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
}
/** Ergebnis eines erfolgreichen nativen Öffnen-Vorgangs. */
export interface OpenedProjectFile {
path: string;
content: string;
}
/**
* Öffnet eine Projektdatei über den NATIVEN Öffnen-Dialog (Filter: `.obp` +
* `.json`). Nur unter Tauri sinnvoll — liefert `null` im Browser, dort greift
* weiterhin der bestehende `<input type=file>`-Fluss (FileReader).
*
* @returns `null` bei Abbruch/ohne Tauri, sonst Pfad + Textinhalt.
*/
export async function openProjectFileNative(): Promise<OpenedProjectFile | null> {
if (!isTauriRuntime()) return null;
const { open } = await import("@tauri-apps/plugin-dialog");
const selected = await open({
multiple: false,
directory: false,
filters: PROJECT_DIALOG_FILTERS,
});
if (!selected || Array.isArray(selected)) return null; // Abbruch (oder Mehrfachauswahl, sollte hier nicht vorkommen)
const { readTextFile } = await import("@tauri-apps/plugin-fs");
const content = await readTextFile(selected);
return { path: selected, content };
}
/**
* Speichert das Projekt-JSON in eine `.obp`-Datei. Unter Tauri nativer
* Speichern-Dialog mit Projekt-Filter, im Browser Blob-Download — beides via
* {@link saveTextFile} (dort steckt die WKWebView-Härtung).
*
* @param json Fertig serialisiertes Projekt (JSON.stringify des ProjectSlice).
* @param projectName `project.name` für den Standard-Dateinamen (Fallback "projekt").
*/
export async function saveProjectFile(json: string, projectName: string): Promise<boolean> {
const filename = `${projectName || "projekt"}.${PROJECT_EXT}`;
return saveTextFile(json, filename, "application/json", PROJECT_DIALOG_FILTERS);
}
// ── Lock gegen Doppelöffnung (nur Tauri) ────────────────────────────────────
/** Info-Block aus der Lock-Sidecar-Datei — für die Konflikt-Meldung an den Nutzer. */
export interface LockInfo {
pid: number;
hostname: string;
timestamp: string;
}
export type LockResult = { ok: true } | { ok: false; info: LockInfo | null };
/**
* Holt (ungeguardet) die Tauri-`invoke`-Funktion, oder `null` ohne Tauri-Core.
* Anders als das Muster in compute/index.ts bzw. plan/nativeSync.ts schluckt
* dieser Helfer KEINE Fehler aus dem eigentlichen Aufruf — ein `Err(...)` vom
* Rust-Command (hier: „schon gelockt", inkl. {@link LockInfo}-Payload) muss bis
* zum Aufrufer durchgereicht werden.
*/
async function tauriInvokeFn(): Promise<
((cmd: string, args?: Record<string, unknown>) => Promise<unknown>) | null
> {
try {
// @ts-ignore optionales Paket
const mod = await import(/* @vite-ignore */ "@tauri-apps/api/core").catch(() => null);
if (!mod || typeof (mod as any).invoke !== "function") return null;
return (mod as any).invoke;
} catch {
return null;
}
}
/**
* Fordert den exklusiven Lock für `path` an (Rust-Command `acquire_project_lock`,
* legt `path + ".lock"` an und hält einen fs4-OS-Lock darauf). Im Browser (kein
* Tauri) sofort `{ok:true}` — dort gibt es keinen echten Pfad, also keinen Lock.
*/
export async function acquireProjectLock(path: string): Promise<LockResult> {
if (!isTauriRuntime()) return { ok: true };
const invoke = await tauriInvokeFn();
if (!invoke) return { ok: true }; // Tauri-Core nicht verfügbar -> nicht blockieren
try {
await invoke("acquire_project_lock", { path });
return { ok: true };
} catch (err) {
return { ok: false, info: isLockInfo(err) ? err : null };
}
}
/** Gibt den Lock für `path` frei (Rust-Command `release_project_lock`). No-op ohne Tauri. */
export async function releaseProjectLock(path: string): Promise<void> {
if (!isTauriRuntime()) return;
const invoke = await tauriInvokeFn();
if (!invoke) return;
try {
await invoke("release_project_lock", { path });
} catch {
// Freigabe ist best effort — der OS-Lock fällt beim Prozessende ohnehin weg.
}
}
function isLockInfo(v: unknown): v is LockInfo {
return (
typeof v === "object" &&
v !== null &&
typeof (v as LockInfo).pid === "number" &&
typeof (v as LockInfo).hostname === "string"
);
}
/**
* Best-effort Freigabe beim Schliessen des Fensters (sauberes Timing): der
* OS-Lock fällt beim Prozessende/Absturz ohnehin automatisch weg (fs4-Handle
* schliesst mit dem Prozess) — dieser Hook sorgt nur dafür, dass ein normales
* Schliessen die Lock-Datei nicht unnötig bis zum nächsten GC/Poll blockiert.
* No-op ohne Tauri.
*/
export function registerLockAutoRelease(getPath: () => string | null): void {
if (!isTauriRuntime()) return;
void (async () => {
try {
const { getCurrentWindow } = await import("@tauri-apps/api/window");
const win = getCurrentWindow();
await win.onCloseRequested(() => {
const path = getPath();
if (path) void releaseProjectLock(path);
});
} catch {
// Tauri-window-API nicht verfügbar -> ignorieren, OS-Lock greift ohnehin.
}
})();
}
+51
View File
@@ -0,0 +1,51 @@
// Tests fuer saveTextFile. Vitest laeuft hier im "node"-Env (kein jsdom), daher
// stubben wir eine minimale DOM-/URL-Umgebung, um den Browser-Fallback-Pfad zu
// pruefen — ohne echten Tauri (kein `__TAURI_INTERNALS__`).
import { afterEach, describe, expect, it, vi } from "vitest";
import { saveTextFile } from "./saveFile";
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
/** Stubt Blob, URL und ein rudimentaeres document fuer den Anchor-Download. */
function stubBrowserEnv() {
const clicked: string[] = [];
const anchor: Record<string, unknown> = {
click: () => clicked.push("click"),
};
vi.stubGlobal("URL", {
createObjectURL: () => "blob:stub",
revokeObjectURL: () => {},
});
vi.stubGlobal("document", {
createElement: () => anchor,
body: { appendChild: () => {}, removeChild: () => {} },
});
return { clicked, anchor };
}
describe("saveTextFile", () => {
it("nimmt im Nicht-Tauri-Umfeld den Browser-Download und liefert true", async () => {
// Kein __TAURI_INTERNALS__ → Browser-Pfad.
const hadWindow = typeof globalThis.window !== "undefined";
if (!hadWindow) vi.stubGlobal("window", {});
const { clicked, anchor } = stubBrowserEnv();
const ok = await saveTextFile("hallo", "modell.txt", "text/plain");
expect(ok).toBe(true);
expect(clicked).toContain("click");
expect(anchor.download).toBe("modell.txt");
});
it("nutzt den Standard-MIME-Typ ohne Angabe, ohne zu crashen", async () => {
const hadWindow = typeof globalThis.window !== "undefined";
if (!hadWindow) vi.stubGlobal("window", {});
stubBrowserEnv();
await expect(saveTextFile("x", "a.bin")).resolves.toBe(true);
});
});
+116
View File
@@ -0,0 +1,116 @@
// Datei-Speicherung mit nativem „Speichern unter"-Dialog im Tauri-Fenster,
// im reinen Browser Fallback auf den klassischen Blob-/Anchor-Download.
//
// Wichtig: Die Tauri-Plugins werden NUR dynamisch importiert. So scheitert der
// reine Browser-Build/vitest nicht an fehlenden Tauri-Modulen und der Code laeuft
// auch ohne Tauri-Laufzeit.
/** Erkennt, ob wir im Tauri-Webview laufen (dann steht `__TAURI_INTERNALS__`). */
function isTauri(): boolean {
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
}
let warnedFallback = false;
/** Datei-Endungsfilter fuer den nativen Speichern-/Oeffnen-Dialog (Tauri). */
export interface FileDialogFilter {
name: string;
extensions: string[];
}
/**
* Speichert Text-Inhalt in eine Datei.
* - Unter Tauri: nativer Save-Dialog (Ordner + Name) → `writeTextFile`.
* - Im Browser: Blob + Anchor-Download in den Download-Ordner.
*
* @param filters Optionale Datei-Endungsfilter fuer den nativen Dialog (z. B.
* Projekt-Endung). Additiv/optional, damit bestehende Aufrufer ohne
* Filter unveraendert funktionieren (Default-Verhalten: irgendeine Endung).
* @returns `true` wenn geschrieben/geladen wurde, `false` bei Abbruch (Dialog
* ohne Pfad). Bei einem Tauri-Fehler wird auf den Browser-Fallback
* zurueckgefallen.
*/
export async function saveTextFile(
content: string,
defaultName: string,
mime = "application/octet-stream",
filters?: FileDialogFilter[],
): Promise<boolean> {
if (isTauri()) {
try {
const { save } = await import("@tauri-apps/plugin-dialog");
const path = await save({ defaultPath: defaultName, filters });
if (!path) return false; // Nutzer hat abgebrochen
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
await writeTextFile(path, content);
return true;
} catch (err) {
if (!warnedFallback) {
warnedFallback = true;
console.warn(
"Nativer Speichern-Dialog fehlgeschlagen, nutze Browser-Download.",
err,
);
}
// Weiter zum Browser-Fallback unten.
}
}
return browserDownload(content, defaultName, mime);
}
/**
* Speichert Binaer-Inhalt (z. B. ein PDF) in eine Datei — gleiches Tauri-/
* Browser-Muster wie {@link saveTextFile}, nur mit `writeFile` (Bytes) statt
* `writeTextFile`.
* - Unter Tauri: nativer Save-Dialog → `writeFile(path, bytes)`.
* - Im Browser: Blob + Anchor-Download.
*
* @returns `true` wenn geschrieben/geladen wurde, `false` bei Abbruch.
*/
export async function saveBinaryFile(
bytes: Uint8Array,
defaultName: string,
mime = "application/octet-stream",
): Promise<boolean> {
if (isTauri()) {
try {
const { save } = await import("@tauri-apps/plugin-dialog");
const path = await save({ defaultPath: defaultName });
if (!path) return false; // Nutzer hat abgebrochen
const { writeFile } = await import("@tauri-apps/plugin-fs");
await writeFile(path, bytes);
return true;
} catch (err) {
if (!warnedFallback) {
warnedFallback = true;
console.warn(
"Nativer Speichern-Dialog fehlgeschlagen, nutze Browser-Download.",
err,
);
}
// Weiter zum Browser-Fallback unten.
}
}
return browserDownloadBlob(new Blob([bytes as BlobPart], { type: mime }), defaultName);
}
/** Klassischer Blob-/Anchor-Download (gehaertet wie der bisherige Helfer). */
function browserDownload(content: string, defaultName: string, mime: string): boolean {
return browserDownloadBlob(new Blob([content], { type: mime }), defaultName);
}
/** Anchor-Download eines fertigen Blobs (Text wie Binaer). */
function browserDownloadBlob(blob: Blob, defaultName: string): boolean {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = defaultName;
a.rel = "noopener";
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, 4000);
return true;
}