Snapshot: Wand/Öffnung Multi-Surface-Select + Z-Drag + Brüstungs-Mitnahme

Stable working state after a long iteration session. The plugin now supports:
- Multi-Surface-Select für alle Element-Typen (Türen/Fenster/Treppen/Tragwerk)
- Wand-Z-Drag → unbound mode (UK/OK-Override, Wand vom Geschoss entkoppelt)
- Wand-Z-Drag nimmt verknüpfte Öffnungen mit (Brüstung += delta_z via Idle-Pfad)
- Öffnungs-XY-Drag snapt direktional auf Wand-Tangente
- Öffnungs-Z-Drag passt Brüstung an (Fenster sofort sync, Tür deferred)
- Wand-Delete kaskadiert Öffnungen (deferred via Idle, robust gegen _Rotate/_Move)
- Source-Cascade beim Öffnungs-Delete (deferred analog Wand-Kaskade)
- Listener-Cleanup robust gegen _reset_panels.py Reload (Refs in
  _dossier_runtime_event_refs gespeichert, vor Re-Install deregistriert)
- _count_same_id_type filtert IsDeleted (verhindert Source-Duplikat-Bug bei Move)
- Frontend: Brüstungs-Slider für Tür ("Schwelle"), Flügel-Block nur bei Fenster

Plus aus früherer Phase dieser Session:
- Dossier-Launcher Auto-Load via Rhinos StartupCommands-XML
- Default-Pfad zeigt auf gebundeltes startup.py (out-of-the-box für neue User)
- Splash-Window beim Plugin-Load mit native macOS rounded corners
- Diverse Launcher-Verbesserungen (Brüstungs-Default, tauri.conf, capabilities)

Known issue: bei Multi-Select-Move mit vielen Sub-Volumen kann sporadisch
"Unable to transform" auftreten (Rhinos Move-Operation kollidiert mit Wand-
Regen). Tür-spezifischer Defer-Pfad mildert das, Fenster läuft sync.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 01:50:45 +02:00
parent 1180d7bedf
commit 961b3c0396
52 changed files with 10760 additions and 765 deletions
+100
View File
@@ -0,0 +1,100 @@
// Material-Symbols-Outlined-style Icons als Inline-SVG. Keine Font-Loads,
// kein Codepoint-Mapping — sauber zu themen via currentColor + stroke-width.
const PATHS = {
folder: (
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" />
),
edit: (
<>
<path d="M4 20h4l10.5-10.5a2.121 2.121 0 0 0-3-3L5 17v3Z" />
<path d="m13.5 6.5 3 3" />
</>
),
plus: (
<>
<path d="M12 5v14" />
<path d="M5 12h14" />
</>
),
search: (
<>
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</>
),
close: (
<>
<path d="M18 6 6 18" />
<path d="M6 6l12 12" />
</>
),
settings: (
<>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9c.36.13.73.31 1.05.55" />
</>
),
layers: (
<>
<path d="m12 2 9 5-9 5-9-5 9-5Z" />
<path d="m3 12 9 5 9-5" />
<path d="m3 17 9 5 9-5" />
</>
),
trash: (
<>
<path d="M3 6h18" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<path d="m19 6-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
</>
),
refresh: (
<>
<path d="M21 12a9 9 0 1 1-3.5-7.1" />
<path d="M21 4v5h-5" />
</>
),
pin: (
<>
<path d="M9 4h6l-1 5 3 3v2H7v-2l3-3-1-5Z" />
<path d="M12 14v6" />
</>
),
pin_filled: (
<path d="M9 4h6l-1 5 3 3v2h-4v6h-2v-6H7v-2l3-3-1-5Z" fill="currentColor" stroke="none" />
),
snapshot: (
<>
<path d="M3 9a2 2 0 0 1 2-2h2.5l1.7-2h5.6l1.7 2H19a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V9Z" />
<circle cx="12" cy="13" r="3.5" />
</>
),
tag: (
<>
<path d="M3 12V4h8l10 10-8 8L3 12Z" />
<circle cx="8" cy="8" r="1.4" fill="currentColor" stroke="none" />
</>
),
chevron_down: <path d="m6 9 6 6 6-6" />,
chevron_up: <path d="m6 15 6-6 6 6" />,
};
export default function Icon({ name, size = 16, strokeWidth = 1.6, style }) {
const path = PATHS[name];
if (!path) return null;
return (
<svg
width={size} height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
style={{ flexShrink: 0, ...style }}
>
{path}
</svg>
);
}
+144
View File
@@ -0,0 +1,144 @@
import { useEffect, useState, useCallback } from "react";
import { checkForAppUpdate, installAppUpdate, skipUpdateVersion, isTauri } from "../utils/updater.js";
export default function UpdateNotifier() {
const [update, setUpdate] = useState(null);
const [state, setState] = useState("idle");
const [downloaded, setDownloaded] = useState(0);
const [total, setTotal] = useState(0);
const [error, setError] = useState(null);
const runCheck = useCallback(async ({ silent }) => {
if (!isTauri()) return;
try {
setState("checking");
setError(null);
const res = await checkForAppUpdate({ respectSkip: silent });
if (!res.available) {
setUpdate(null);
setState("idle");
return;
}
setUpdate(res.update);
setState("available");
} catch (e) {
console.error("Update-Check fehlgeschlagen:", e);
setError(String(e?.message || e));
setState("idle");
}
}, []);
useEffect(() => {
const t = setTimeout(() => runCheck({ silent: true }), 1500);
return () => clearTimeout(t);
}, [runCheck]);
useEffect(() => {
const handler = () => runCheck({ silent: false });
window.addEventListener("dossier:check-update", handler);
return () => window.removeEventListener("dossier:check-update", handler);
}, [runCheck]);
const install = async () => {
if (!update) return;
try {
setState("downloading");
setDownloaded(0);
setTotal(0);
await installAppUpdate(update, (event) => {
if (event.event === "Started") {
setTotal(event.data.contentLength || 0);
} else if (event.event === "Progress") {
setDownloaded((d) => d + (event.data.chunkLength || 0));
} else if (event.event === "Finished") {
setState("installing");
}
});
} catch (e) {
console.error("Update-Installation fehlgeschlagen:", e);
setError(String(e?.message || e));
setState("available");
}
};
const skipVersion = () => {
skipUpdateVersion(update?.version);
setUpdate(null);
setState("idle");
};
const later = () => {
setUpdate(null);
setState("idle");
};
if (!isTauri() || !update || state === "idle" || state === "checking") return null;
const isBusy = state === "downloading" || state === "installing";
const pct = total > 0 ? Math.min(100, Math.round((downloaded / total) * 100)) : null;
return (
<div className="dialog-bg" style={{ zIndex: 300 }}>
<div className="dialog" style={{ width: 460, maxWidth: "92vw" }}>
<header style={{ background: "var(--bg-panel)" }}>
<div style={{ fontSize: 10, letterSpacing: "0.18em", color: "var(--accent)", marginBottom: 4, fontWeight: 600 }}>
UPDATE VERFÜGBAR
</div>
<div style={{ display: "flex", alignItems: "baseline", gap: 10 }}>
<div style={{ fontSize: 18, color: "var(--text)", fontWeight: 500 }}>
Dossier {update.version}
</div>
{update.currentVersion && (
<div style={{ fontSize: 11, color: "var(--text-muted)" }}>von {update.currentVersion}</div>
)}
</div>
</header>
<div className="body">
{update.body && (
<div style={{ display: "flex", gap: 12 }}>
<div style={{ width: 3, flexShrink: 0, background: "var(--accent)", borderRadius: 2 }} />
<div style={{ fontSize: 12, color: "var(--text-muted)", lineHeight: 1.55, whiteSpace: "pre-wrap" }}>{update.body}</div>
</div>
)}
{error && (
<div style={{ padding: "10px 12px", background: "rgba(200, 112, 80, 0.12)", border: "1px solid var(--danger)", borderRadius: 6, fontSize: 12, color: "var(--danger)" }}>
{error}
</div>
)}
{isBusy && (
<div>
<div style={{ fontSize: 11, color: "var(--text-muted)", marginBottom: 6, letterSpacing: "0.04em" }}>
{state === "downloading"
? (pct !== null ? `Wird heruntergeladen … ${pct}%` : "Wird heruntergeladen …")
: "Wird installiert …"}
</div>
<div style={{ height: 4, background: "var(--bg-elev)", borderRadius: 2, overflow: "hidden" }}>
<div style={{
height: "100%",
width: pct !== null ? `${pct}%` : "100%",
background: "var(--accent)",
transition: "width 0.2s",
animation: pct === null ? "dossier-update-pulse 1.2s ease-in-out infinite" : undefined,
}} />
</div>
<style>{`@keyframes dossier-update-pulse { 0%,100% { opacity: 0.5; } 50% { opacity: 1; } }`}</style>
</div>
)}
</div>
<footer style={{ flexDirection: "column", gap: 8, alignItems: "stretch" }}>
<button className="primary pill" style={{ width: "100%" }} onClick={install} disabled={isBusy}>
{isBusy ? "Bitte warten …" : "Jetzt installieren"}
</button>
<div style={{ display: "flex", gap: 8 }}>
<button style={{ flex: 1 }} onClick={later} disabled={isBusy}>Später</button>
<button style={{ flex: 1 }} onClick={skipVersion} disabled={isBusy}>Diese Version überspringen</button>
</div>
</footer>
</div>
</div>
);
}