Zeichnungsmanager Master-Controls + Scheren + Startup-Perf + Oeffnung-Preview

UI:
- GeschossManager: Master-Eye + Master-Lock im Header (analog EbenenManager).
  Scheren-Button pro Geschoss togglet hasClipping. Auge ganz links wie bei
  Ebenen. Eye-Logik klar 4-Wege: aktive Z immer hell+on, in 'active'/'all_force'
  fuer non-active gedimmt, sonst spiegelt Flag direkt. Schrift wird NIE gegrayt.
  Neuer Mode 'all_force' = "Alle anzeigen" (ignoriert Eye), 'all' jetzt mit
  Label "Ausgewaehlte" (respektiert Eye). Klick aufs Auge in 'active'/'all_force'
  wechselt automatisch in "Ausgewaehlte" damit Aktion sofort wirkt.

- layer_builder.apply_visibility: neuer z_mode 'all_force' vor visible-Check —
  zeigt jede Z auch wenn Eye=false war.

- elemente._cmd_create_oeffnung: gruene Live-Preview (vertikales Oeffnungs-
  Rechteck + Breiten-Marker + Diagonale) waehrend Fenster/Tuer-Platzierung
  entlang der Wand-Achse. Brueest-Offset aus Geschoss-UK korrekt im Z.

Performance Cold-Start:
- panel_base: Inlined-HTML als Modul-Cache (1x build, n-mal mount). Pro
  Panel-Mount nur noch str.replace + LoadHtml. Spart bei 10 Panels 9x
  ~395 KB Disk-Read + Regex-Pass. Cache-Key = mtime von dist/index.html.

- Timing-Instrumentierung: _t_mark + print_startup_summary. Hook in startup.py
  feuert 3s nach Plugin-Load + listet Wall-time, Top-10, Aggregat pro Phase.

- OberleisteBridge: Command-Enumeration (~1000 Commands) jetzt lazy via
  Rhino.RhinoApp.Idle statt synchron im __init__. Cold-Start nicht blockiert,
  Autocomplete kommt ~1 Frame spaeter.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 04:36:56 +02:00
parent 95031ee2c0
commit 222b00c113
6 changed files with 325 additions and 48 deletions
+57
View File
@@ -503,6 +503,46 @@ def _make_circle_preview(center):
return handler return handler
def _make_oeffnung_preview(axis_curve, breite, hoehe, brueest, base_z):
"""Preview fuer Fenster/Tuer-Platzierung. Zeigt das vertikale Rechteck
der Oeffnungsflaeche auf Hoehe der Achse (entlang Tangente x Hoehe),
zusaetzlich eine Marker-Linie auf der Achse die die Breite andeutet."""
import System.Drawing as SD
color = SD.Color.FromArgb(255, 95, 200, 180) # Accent gruen
color_axis = SD.Color.FromArgb(180, 95, 200, 180) # halbtransparent
def handler(sender, e):
try:
cur = e.CurrentPoint
ok, t = axis_curve.ClosestPoint(cur)
if not ok: return
on_axis = axis_curve.PointAt(t)
tan = axis_curve.TangentAt(t)
tlen = (tan.X * tan.X + tan.Y * tan.Y) ** 0.5
if tlen < 1e-9: return
tx = tan.X / tlen; ty = tan.Y / tlen
hb = breite * 0.5
z_bot = base_z + brueest
z_top = z_bot + hoehe
cx, cy = on_axis.X, on_axis.Y
# Breiten-Marker auf der Achse
left_xy = rg.Point3d(cx - hb * tx, cy - hb * ty, on_axis.Z)
right_xy = rg.Point3d(cx + hb * tx, cy + hb * ty, on_axis.Z)
e.Display.DrawLine(left_xy, right_xy, color_axis, 1)
# 4 Kanten der vertikalen Oeffnungs-Flaeche
p_lb = rg.Point3d(cx - hb * tx, cy - hb * ty, z_bot)
p_rb = rg.Point3d(cx + hb * tx, cy + hb * ty, z_bot)
p_rt = rg.Point3d(cx + hb * tx, cy + hb * ty, z_top)
p_lt = rg.Point3d(cx - hb * tx, cy - hb * ty, z_top)
for a, b in ((p_lb, p_rb), (p_rb, p_rt),
(p_rt, p_lt), (p_lt, p_lb)):
e.Display.DrawLine(a, b, color, 2)
# Diagonalen — Andeutung der Glasflaeche
e.Display.DrawLine(p_lb, p_rt, color_axis, 1)
e.Display.DrawLine(p_lt, p_rb, color_axis, 1)
except Exception: pass
return handler
def _collect_rectangle(doc, c1): def _collect_rectangle(doc, c1):
"""Achsen-aligned Rechteck aus 2 diagonalen Ecken. Liefert geschlossene """Achsen-aligned Rechteck aus 2 diagonalen Ecken. Liefert geschlossene
PolylineCurve in XY-Ebene auf Z=0.""" PolylineCurve in XY-Ebene auf Z=0."""
@@ -5514,6 +5554,18 @@ class ElementeBridge(panel_base.BaseBridge):
axis_curve = wall_obj.Geometry axis_curve = wall_obj.Geometry
if not isinstance(axis_curve, rg.Curve): return if not isinstance(axis_curve, rg.Curve): return
# Base-Z fuer das Preview: UK des Geschosses, damit das Brueest-
# Offset visuell stimmt (Achse selbst kann auf einem anderen Z
# liegen je nach Modellierung).
try:
wuk, _wok = _resolve_uk_ok(doc, wall_meta.get("geschoss"),
wall_meta.get("uk_override"),
wall_meta.get("ok_override"))
preview_base_z = float(wuk)
except Exception:
try: preview_base_z = float(axis_curve.PointAtStart.Z)
except Exception: preview_base_z = 0.0
# 2) Punkt auf der Achse — constrained an die Wand-Achse # 2) Punkt auf der Achse — constrained an die Wand-Achse
try: try:
while True: while True:
@@ -5526,6 +5578,11 @@ class ElementeBridge(panel_base.BaseBridge):
gp.SetCommandPrompt(prompt) gp.SetCommandPrompt(prompt)
try: gp.Constrain(axis_curve, False) try: gp.Constrain(axis_curve, False)
except Exception: pass except Exception: pass
# Live-Preview: gruenes Oeffnungs-Rechteck auf der Wand
try:
gp.DynamicDraw += _make_oeffnung_preview(
axis_curve, breite, hoehe, brueest, preview_base_z)
except Exception: pass
opt_b = gp.AddOption("Breite") opt_b = gp.AddOption("Breite")
opt_h = gp.AddOption("Hoehe") opt_h = gp.AddOption("Hoehe")
opt_br = gp.AddOption("Bruestung") if typ == "fenster" else None opt_br = gp.AddOption("Bruestung") if typ == "fenster" else None
+5
View File
@@ -581,10 +581,15 @@ def apply_visibility(doc, zeichnungsebenen, ebenen, active_z_id, active_code, z_
z_locked_flag = bool(z.get("locked", False)) z_locked_flag = bool(z.get("locked", False))
# Z-Mode -> Parent-Zustand # Z-Mode -> Parent-Zustand
# 'all_force' kommt VOR dem visible-Flag-Check: zeigt jede Z auch wenn
# das User-Eye sie ausgeblendet hatte. 'all' dagegen respektiert das
# Eye-Flag (= "Ausgewählte" im UI).
if is_active_z: if is_active_z:
p_vis, p_grey, p_lock = True, False, False p_vis, p_grey, p_lock = True, False, False
elif z_mode == "active": elif z_mode == "active":
p_vis, p_grey, p_lock = False, False, False p_vis, p_grey, p_lock = False, False, False
elif z_mode == "all_force":
p_vis, p_grey, p_lock = True, False, False
elif not z_visible_flag: elif not z_visible_flag:
p_vis, p_grey, p_lock = False, False, False p_vis, p_grey, p_lock = False, False, False
elif z_mode == "all": elif z_mode == "all":
+32 -3
View File
@@ -772,12 +772,36 @@ class OberleisteBridge(panel_base.BaseBridge):
self._last_state_sig = None # Fingerprint des letzten Push — dedupe self._last_state_sig = None # Fingerprint des letzten Push — dedupe
self._cached_overrides = None # (enabled, count) — invalidiert bei Toggle/Update self._cached_overrides = None # (enabled, count) — invalidiert bei Toggle/Update
self._cached_combinations = None # (names, active) — invalidiert bei jeder Comb-Aenderung self._cached_combinations = None # (names, active) — invalidiert bei jeder Comb-Aenderung
# Command-Liste einmalig laden (kann teuer sein -> cachen) # Command-Liste LAZY laden — die Enumeration durchlaeuft alle Plugins
# und ist teuer (~hundert ms). Wird erst beim ersten _send_state, oder
# explizit bei Command-Input-Fokus, gebaut.
self._all_commands = None
def _ensure_commands_loaded_async(self):
"""Schedult die teure Command-Enum auf den naechsten Idle-Tick statt
sie synchron beim ersten _send_state zu laden. Cold-Start-Pfad wird
nicht geblockt; Autocomplete-Liste ist ~1 Frame spaeter da."""
if self._all_commands is not None: return
if getattr(self, "_commands_loading", False): return
self._commands_loading = True
def _load_once(s, e):
try: Rhino.RhinoApp.Idle -= _load_once
except Exception: pass
try: try:
self._all_commands = _list_all_command_names() self._all_commands = _list_all_command_names()
except Exception as ex: except Exception as ex:
print("[OBERLEISTE] command-enum:", ex) print("[OBERLEISTE] command-enum:", ex)
self._all_commands = [] self._all_commands = []
# Sofort an UI pushen (Autocomplete wird live)
try:
if not getattr(self, "_commands_sent", False):
self.send("STATE", {"allCommands": self._all_commands})
self._commands_sent = True
except Exception: pass
try: Rhino.RhinoApp.Idle += _load_once
except Exception as ex:
print("[OBERLEISTE] schedule command-load:", ex)
self._commands_loading = False
def _on_ready(self): def _on_ready(self):
# Bootstrap DPI (gemeinsam mit massstab.py) # Bootstrap DPI (gemeinsam mit massstab.py)
@@ -1081,11 +1105,16 @@ class OberleisteBridge(panel_base.BaseBridge):
prompt = _get_command_prompt() prompt = _get_command_prompt()
info["cmdPrompt"] = prompt info["cmdPrompt"] = prompt
info["cmdOptions"] = _parse_command_options(prompt) info["cmdOptions"] = _parse_command_options(prompt)
# Command-Autocomplete-Liste — nur einmal initial schicken (gross) # Command-Autocomplete-Liste — Lazy via Idle-Tick (statt im Bridge-
# Init blockierend). Wenn sie noch nicht da ist: einplanen. Wenn da:
# einmalig mitsenden.
if not getattr(self, "_commands_sent", False): if not getattr(self, "_commands_sent", False):
if self._all_commands is None:
self._ensure_commands_loaded_async()
else:
info["allCommands"] = self._all_commands info["allCommands"] = self._all_commands
self._commands_sent = True self._commands_sent = True
force = True # Erste Push immer feuern force = True
# Diff-Check: wenn weder Daten noch force, gar nichts schicken # Diff-Check: wenn weder Daten noch force, gar nichts schicken
# (dedupe Idle-Ticks ohne Aenderung — spart WebView-ExecuteScript Roundtrip) # (dedupe Idle-Ticks ohne Aenderung — spart WebView-ExecuteScript Roundtrip)
sig = ( sig = (
+111 -17
View File
@@ -8,6 +8,7 @@ Wird von rhinopanel.py (EBENEN) und gestaltung.py (GESTALTUNG) verwendet.
import os import os
import re import re
import json import json
import time
import Rhino import Rhino
import Rhino.UI as RhinoUI import Rhino.UI as RhinoUI
import Eto.Forms as forms import Eto.Forms as forms
@@ -19,6 +20,45 @@ _HERE = os.path.dirname(os.path.abspath(__file__))
_DIST = os.path.join(_HERE, "..", "dist", "index.html") _DIST = os.path.join(_HERE, "..", "dist", "index.html")
# --- Timing-Instrumentierung ------------------------------------------------
# Schaltbar via sc.sticky["dossier_timing"] = True. Wir loggen die Hot-Paths
# beim Plugin-Start. Tabelle am Ende per panel_base.print_startup_summary().
_TIMINGS = [] # Liste von (phase, label, ms)
_T0 = None # Zeitstempel des allerersten Aufrufs
def _t_mark(phase, label, t_start):
"""Loggt eine Phase + Dauer. Print sofort, plus Aggregat fuers Summary."""
ms = (time.time() - t_start) * 1000.0
_TIMINGS.append((phase, label, ms))
global _T0
if _T0 is None: _T0 = t_start
try:
print("[STARTUP] {:>22s} {:>22s} {:7.1f} ms".format(phase, label, ms))
except Exception: pass
return ms
def print_startup_summary():
"""Aufruf am Ende von startup.py: zeigt total + sortierte Topliste."""
if not _TIMINGS: return
total_wall = (time.time() - (_T0 or time.time())) * 1000.0
total_work = sum(ms for _, _, ms in _TIMINGS)
print("[STARTUP] ===== SUMMARY =====")
print("[STARTUP] Wall-time (first to last mark): {:.1f} ms".format(total_wall))
print("[STARTUP] Sum of measured work: {:.1f} ms".format(total_work))
# Top-10 nach Dauer
top = sorted(_TIMINGS, key=lambda x: -x[2])[:10]
print("[STARTUP] --- Top-10 nach Dauer ---")
for phase, label, ms in top:
print("[STARTUP] {:7.1f} ms {} / {}".format(ms, phase, label))
# Aggregat nach Phase
by_phase = {}
for phase, _, ms in _TIMINGS:
by_phase[phase] = by_phase.get(phase, 0.0) + ms
print("[STARTUP] --- Aggregat nach Phase ---")
for phase, ms in sorted(by_phase.items(), key=lambda x: -x[1]):
print("[STARTUP] {:7.1f} ms {}".format(ms, phase))
# --- Legacy-Migration: traite_* / pause_* -> dossier_* ---------------------- # --- Legacy-Migration: traite_* / pause_* -> dossier_* ----------------------
# #
# Historisch hatte das Plugin nacheinander die Praefixe "traite_" und "pause_" # Historisch hatte das Plugin nacheinander die Praefixe "traite_" und "pause_"
@@ -198,31 +238,37 @@ class BaseBridge(object):
# --- HTML laden ------------------------------------------------------------- # --- HTML laden -------------------------------------------------------------
def load_inline(wv, mode, params=None): # Cache der fertig zusammengebauten Inline-HTML — Disk-IO + CSS/JS-String-
"""Laedt dist/index.html inline und injiziert window.PANEL_MODE. # Inlining laeuft nur EINMAL pro Plugin-Session statt pro Panel-Mount. Bei
# 10 Panels eliminiert das 9x ~395 KB Disk-Read + 9x Regex/Concat-Pass.
# Cache-Key = mtime von dist/index.html — wenn der User neu build, wird
# der Cache automatisch invalidiert.
_INLINE_TEMPLATE = None # (mtime_signature, head_html, body_html_template)
# Sentinel-Marker im Template fuer die per-Mount unterschiedlichen Scripts.
# Wir nutzen einen "Magic-String" statt format/{} damit die JS-Bundle-Inhalte
# (die {} Token enthalten koennen) nicht versehentlich matchen.
_MODE_SCRIPT_PLACEHOLDER = "/*__DOSSIER_MODE_SCRIPT__*/"
`params` (optional dict): wird als `window.PANEL_PARAMS` injiziert. Wird
von Satelliten-Fenstern (z.B. Settings-Dialoge) verwendet um initial- def _build_inline_template():
State an die React-App zu uebergeben.""" """Liest dist/index.html + inline alle CSS/JS. Liefert die HTML-String
mit Placeholder fuer den per-Mount Mode-Script-Block."""
t0 = time.time()
if not os.path.exists(_DIST): if not os.path.exists(_DIST):
print("[{}] dist nicht gefunden".format(mode.upper())) return None, None
return
dist_dir = os.path.dirname(_DIST) dist_dir = os.path.dirname(_DIST)
try:
mtime_sig = os.path.getmtime(_DIST)
except Exception:
mtime_sig = 0
with open(_DIST, "rb") as f: with open(_DIST, "rb") as f:
html = f.read().decode("utf-8") html = f.read().decode("utf-8")
parts = ['<script>window.PANEL_MODE="{}";'.format(mode)] placeholder_script = '<script>' + _MODE_SCRIPT_PLACEHOLDER + '</script>'
if params is not None:
try:
parts.append('window.PANEL_PARAMS=' + json.dumps(params, ensure_ascii=False) + ';')
except Exception as ex:
print("[{}] PANEL_PARAMS serialize: {}".format(mode.upper(), ex))
parts.append('</script>')
mode_script = ''.join(parts)
if "</head>" in html: if "</head>" in html:
html = html.replace("</head>", mode_script + "</head>") html = html.replace("</head>", placeholder_script + "</head>")
else: else:
html = mode_script + html html = placeholder_script + html
def inline_css(m): def inline_css(m):
p = os.path.join(dist_dir, m.group(1).lstrip("./").replace("/", os.sep)) p = os.path.join(dist_dir, m.group(1).lstrip("./").replace("/", os.sep))
@@ -238,7 +284,47 @@ def load_inline(wv, mode, params=None):
html = re.sub(r'<link[^>]+href="(\./assets/[^"]+\.css)"[^>]*/?>', inline_css, html) html = re.sub(r'<link[^>]+href="(\./assets/[^"]+\.css)"[^>]*/?>', inline_css, html)
html = re.sub(r'<script[^>]+src="(\./assets/[^"]+\.js)"[^>]*></script>', inline_js, html) html = re.sub(r'<script[^>]+src="(\./assets/[^"]+\.js)"[^>]*></script>', inline_js, html)
_t_mark("html-build", "build_inline_template", t0)
return mtime_sig, html
def load_inline(wv, mode, params=None):
"""Laedt dist/index.html inline und injiziert window.PANEL_MODE.
`params` (optional dict): wird als `window.PANEL_PARAMS` injiziert. Wird
von Satelliten-Fenstern (z.B. Settings-Dialoge) verwendet um initial-
State an die React-App zu uebergeben.
Performance: das fertige Inline-HTML (mit allen CSS/JS embedded) wird
modul-weit gecached. Pro Aufruf nur noch Mode-Script-Substitute + die
teure WebView.LoadHtml — Disk-IO laeuft 1x pro Plugin-Session."""
t0 = time.time()
global _INLINE_TEMPLATE
# Cache-Refresh wenn dist/index.html sich geaendert hat (neuer Build)
try:
cur_mtime = os.path.getmtime(_DIST)
except Exception:
cur_mtime = 0
if _INLINE_TEMPLATE is None or _INLINE_TEMPLATE[0] != cur_mtime:
sig, tmpl = _build_inline_template()
if tmpl is None:
print("[{}] dist nicht gefunden".format(mode.upper()))
return
_INLINE_TEMPLATE = (sig, tmpl)
# Per-Mount: nur das Mode-Script-Snippet bauen
parts = ['window.PANEL_MODE="{}";'.format(mode)]
if params is not None:
try:
parts.append('window.PANEL_PARAMS=' + json.dumps(params, ensure_ascii=False) + ';')
except Exception as ex:
print("[{}] PANEL_PARAMS serialize: {}".format(mode.upper(), ex))
mode_script = ''.join(parts)
html = _INLINE_TEMPLATE[1].replace(_MODE_SCRIPT_PLACEHOLDER, mode_script)
t_loadhtml = time.time()
wv.LoadHtml(html) wv.LoadHtml(html)
_t_mark("load_inline", mode, t0)
_t_mark("LoadHtml", mode, t_loadhtml)
def attach_webview(panel, bridge, mode): def attach_webview(panel, bridge, mode):
@@ -727,10 +813,12 @@ def register_and_open(mode, caption, guid_str, bridge_factory, icon_spec=None, m
Bei docked Panels wirkt's als Hint, bei float Panels als Bei docked Panels wirkt's als Hint, bei float Panels als
tatsaechliche Startgroesse. tatsaechliche Startgroesse.
""" """
t_outer = time.time()
sticky_reg = "panel_registered_" + mode sticky_reg = "panel_registered_" + mode
sticky_guid = "panel_guid_" + mode sticky_guid = "panel_guid_" + mode
if not sc.sticky.get(sticky_reg): if not sc.sticky.get(sticky_reg):
t_reg = time.time()
plugin = find_plugin() plugin = find_plugin()
if plugin is None: if plugin is None:
print("[{}] Plugin nicht gefunden".format(mode.upper())) print("[{}] Plugin nicht gefunden".format(mode.upper()))
@@ -762,11 +850,13 @@ def register_and_open(mode, caption, guid_str, bridge_factory, icon_spec=None, m
) )
icon = None icon = None
if icon_spec: if icon_spec:
t_icon = time.time()
try: try:
icon = make_panel_icon(icon_spec[0], icon_spec[1]) icon = make_panel_icon(icon_spec[0], icon_spec[1])
except Exception as ex: except Exception as ex:
print("[{}] Icon-Erstellung uebersprungen: {}".format(mode.upper(), ex)) print("[{}] Icon-Erstellung uebersprungen: {}".format(mode.upper(), ex))
icon = None icon = None
_t_mark("icon", mode, t_icon)
registered = False registered = False
registered_with_icon = False registered_with_icon = False
# Erst mit Icon versuchen, dann stillschweigend ohne (Mac Rhino-Panels # Erst mit Icon versuchen, dann stillschweigend ohne (Mac Rhino-Panels
@@ -800,10 +890,14 @@ def register_and_open(mode, caption, guid_str, bridge_factory, icon_spec=None, m
except Exception as ex: except Exception as ex:
print("[{}] Registrierung fehlgeschlagen: {}".format(mode.upper(), ex)) print("[{}] Registrierung fehlgeschlagen: {}".format(mode.upper(), ex))
return return
_t_mark("register", mode, t_reg)
try: try:
t_open = time.time()
guid = sc.sticky.get(sticky_guid, System.Guid(guid_str)) guid = sc.sticky.get(sticky_guid, System.Guid(guid_str))
RhinoUI.Panels.OpenPanel(guid) RhinoUI.Panels.OpenPanel(guid)
_t_mark("OpenPanel", mode, t_open)
print("[{}] Panel geoeffnet".format(mode.upper())) print("[{}] Panel geoeffnet".format(mode.upper()))
except Exception as ex: except Exception as ex:
print("[{}] OpenPanel fehlgeschlagen: {}".format(mode.upper(), ex)) print("[{}] OpenPanel fehlgeschlagen: {}".format(mode.upper(), ex))
_t_mark("register_and_open", mode, t_outer)
+11
View File
@@ -135,6 +135,17 @@ def _load_all(sender, e):
print("[STARTUP] {} ({}) FEHLER: {}".format(mod_id, py_name, ex)) print("[STARTUP] {} ({}) FEHLER: {}".format(mod_id, py_name, ex))
# DOSSIERUI Window-Layout — Hinweis fuer manuelles Laden # DOSSIERUI Window-Layout — Hinweis fuer manuelles Laden
_hint_dossier_ui() _hint_dossier_ui()
# Startup-Timing-Summary 3 Sekunden spaeter (nachdem alle async Idle-
# Loads + WebView-Renders durch sind). Manueller Aufruf:
# _RunPythonScript -c "import panel_base; panel_base.print_startup_summary()"
def _summary():
try:
import panel_base
panel_base.print_startup_summary()
except Exception as ex:
print("[STARTUP] summary:", ex)
import threading
threading.Timer(3.0, _summary).start()
# Marker fuer den Launcher-Splash mit Verzoegerung: erst nachdem Rhino die # Marker fuer den Launcher-Splash mit Verzoegerung: erst nachdem Rhino die
# Panels visuell platziert hat (~2s nach Modul-Imports). Pfad ist projekt- # Panels visuell platziert hat (~2s nach Modul-Imports). Pfad ist projekt-
# stabil (gleich wie dossier_settings.json), damit Launcher ohne # stabil (gleich wie dossier_settings.json), damit Launcher ohne
+103 -22
View File
@@ -9,10 +9,38 @@ function GeschossBadge({ name }) {
function ZeichnungsebeneRow({ function ZeichnungsebeneRow({
z, active, mode, onClick, onContextMenu, z, active, mode, onClick, onContextMenu,
onToggleVisible, onToggleLock, onDelete, onToggleVisible, onToggleLock, onToggleClipping, onDelete,
}) { }) {
const eyeShown = mode !== 'active'
const isGeschoss = !!z.isGeschoss const isGeschoss = !!z.isGeschoss
// Eye-Logik: die aktive Z ist IMMER sichtbar (Backend forciert das), also
// zeigen wir ihr Auge immer als "an" — ohne Ruecksicht aufs visible-Flag.
// Nicht-aktive: in 'all_force' ist visible-Flag ueberschrieben (alle an),
// in 'active' ueberschrieben (alle aus) — Auge dimmt. Sonst (Ausgewaehlte/
// grey) reflektiert es das Flag direkt.
let eyeIcon, eyeOn, eyeOpacity, eyeTitle
if (active) {
eyeIcon = 'visibility'
eyeOn = true
eyeOpacity = 1
eyeTitle = z.visible !== false
? 'Sichtbar (aktive Zeichnungsebene)'
: 'Normalerweise ausgeblendet — wird gezeigt weil aktiv'
} else if (mode === 'all_force') {
eyeIcon = 'visibility'
eyeOn = true
eyeOpacity = 0.35
eyeTitle = 'Im „Alle anzeigen"-Mode immer sichtbar — Klick wechselt in „Ausgewählte"'
} else if (mode === 'active') {
eyeIcon = z.visible !== false ? 'visibility' : 'visibility_off'
eyeOn = false
eyeOpacity = 0.35
eyeTitle = 'Im „Nur aktive"-Mode ausgeblendet — Klick wechselt in „Ausgewählte"'
} else {
eyeIcon = z.visible !== false ? 'visibility' : 'visibility_off'
eyeOn = z.visible !== false
eyeOpacity = 1
eyeTitle = z.visible !== false ? 'Ausblenden' : 'Einblenden'
}
return ( return (
<div <div
onClick={onClick} onClick={onClick}
@@ -21,18 +49,22 @@ function ZeichnungsebeneRow({
display: 'flex', alignItems: 'center', gap: 6, display: 'flex', alignItems: 'center', gap: 6,
padding: '4px 12px', padding: '4px 12px',
margin: active ? '1px 6px' : '0', margin: active ? '1px 6px' : '0',
background: active ? 'var(--active-dim)' background: active ? 'var(--active-dim)' : 'var(--bg-item)',
: (z.visible !== false) ? 'var(--bg-item)'
: 'var(--bg-panel)',
borderRadius: active ? 999 : 0, borderRadius: active ? 999 : 0,
borderLeft: active ? 'none' : '3px solid transparent', borderLeft: active ? 'none' : '3px solid transparent',
borderBottom: active ? 'none' : '1px solid var(--border-light)', borderBottom: active ? 'none' : '1px solid var(--border-light)',
boxShadow: active ? 'inset 0 0 0 1px var(--active-light)' : 'none', boxShadow: active ? 'inset 0 0 0 1px var(--active-light)' : 'none',
cursor: 'pointer', cursor: 'pointer',
userSelect: 'none', userSelect: 'none',
opacity: (!active && z.visible === false && mode !== 'all') ? 0.45 : 1,
}} }}
> >
<button
className={`btn-icon-sm ${eyeOn ? 'is-on' : ''}`}
onClick={(ev) => { ev.stopPropagation(); onToggleVisible() }}
title={eyeTitle}
style={{ opacity: eyeOpacity }}
><Icon name={eyeIcon} size={14} /></button>
<span style={{ <span style={{
fontWeight: active ? 700 : 500, fontWeight: active ? 700 : 500,
fontSize: 12, fontSize: 12,
@@ -49,22 +81,15 @@ function ZeichnungsebeneRow({
{isGeschoss && <GeschossBadge name={z.name} />} {isGeschoss && <GeschossBadge name={z.name} />}
{isGeschoss && z.hasClipping && ( {isGeschoss ? (
<Icon name="content_cut" size={12} style={{ color: 'var(--accent)', flexShrink: 0 }} title="Clipping Plane aktiv" />
)}
{eyeShown ? (
<button <button
className={`btn-icon-sm ${z.visible !== false ? 'is-on' : ''}`} className={`btn-icon-xs ${z.hasClipping ? 'is-on' : ''}`}
onClick={(ev) => { ev.stopPropagation(); onToggleVisible() }} onClick={(ev) => { ev.stopPropagation(); onToggleClipping() }}
title={ title={z.hasClipping
active ? 'Clipping Plane ausschalten'
? (z.visible !== false : 'Clipping Plane einschalten (Schnitt auf Schnitthöhe)'}
? 'Normalerweise sichtbar (aktive Zeichnungsebene wird trotzdem gezeigt)' style={{ color: z.hasClipping ? 'var(--accent)' : undefined }}
: 'Normalerweise ausgeblendet — wird nur sichtbar weil aktiv') ><Icon name="content_cut" size={12} /></button>
: (z.visible !== false ? 'Ausblenden' : 'Einblenden')
}
><Icon name={z.visible !== false ? 'visibility' : 'visibility_off'} size={14} /></button>
) : ( ) : (
<span style={{ width: 18, flexShrink: 0 }} /> <span style={{ width: 18, flexShrink: 0 }} />
)} )}
@@ -86,7 +111,8 @@ function ZeichnungsebeneRow({
} }
const MODES = [ const MODES = [
{ value: 'all', label: 'Alle anzeigen' }, { value: 'all_force', label: 'Alle anzeigen' },
{ value: 'all', label: 'Ausgewählte' },
{ value: 'active', label: 'Nur aktive' }, { value: 'active', label: 'Nur aktive' },
{ value: 'grey', label: 'Andere grau' }, { value: 'grey', label: 'Andere grau' },
{ value: 'grey_locked', label: 'Andere grau & gesperrt' }, { value: 'grey_locked', label: 'Andere grau & gesperrt' },
@@ -119,12 +145,20 @@ export default function GeschossManager({
const toggleVisible = (id) => { const toggleVisible = (id) => {
onChange(zeichnungsebenen.map(z => z.id === id ? { ...z, visible: !(z.visible !== false) } : z)) onChange(zeichnungsebenen.map(z => z.id === id ? { ...z, visible: !(z.visible !== false) } : z))
// In "active" / "all_force" greift visible-Flag nicht — wer aufs Auge
// klickt will offensichtlich Sichtbarkeit kontrollieren, also direkt
// in den "Ausgewählte"-Mode wechseln damit die Aktion wirkt.
if (mode === 'active' || mode === 'all_force') onModeChange('all')
} }
const toggleLock = (id) => { const toggleLock = (id) => {
onChange(zeichnungsebenen.map(z => z.id === id ? { ...z, locked: !z.locked } : z)) onChange(zeichnungsebenen.map(z => z.id === id ? { ...z, locked: !z.locked } : z))
} }
const toggleClipping = (id) => {
onChange(zeichnungsebenen.map(z => z.id === id ? { ...z, hasClipping: !z.hasClipping } : z))
}
const duplicate = (id) => { const duplicate = (id) => {
const src = zeichnungsebenen.find(z => z.id === id) const src = zeichnungsebenen.find(z => z.id === id)
if (!src) return if (!src) return
@@ -211,6 +245,52 @@ export default function GeschossManager({
</span> </span>
</div> </div>
{/* Master-Row: Master-Eye links + Master-Lock rechts (analog
EbenenManager). */}
<div style={{
display: 'flex', alignItems: 'center', gap: 5,
padding: '2px 14px',
background: 'var(--bg-section)',
borderBottom: '1px solid var(--border)',
}}>
<button
className="btn-icon-xs"
onClick={() => {
const anyVisible = zeichnungsebenen.some(z => z.visible !== false)
onChange(zeichnungsebenen.map(z => ({ ...z, visible: !anyVisible })))
if (mode === 'active' || mode === 'all_force') onModeChange('all')
}}
title={zeichnungsebenen.every(z => z.visible !== false)
? 'Alle Zeichnungsebenen ausblenden'
: 'Alle Zeichnungsebenen einblenden'}
style={{ width: 18, height: 18,
opacity: (mode === 'active' || mode === 'all_force') ? 0.5 : 1 }}
>
<Icon
name={zeichnungsebenen.every(z => z.visible !== false) ? 'visibility' : 'visibility_off'}
size={12}
/>
</button>
<span style={{ flex: 1 }} />
<button
className="btn-icon-xs"
onClick={() => {
const anyLocked = zeichnungsebenen.some(z => z.locked === true)
onChange(zeichnungsebenen.map(z => ({ ...z, locked: !anyLocked })))
}}
title={zeichnungsebenen.every(z => z.locked === true)
? 'Alle Zeichnungsebenen entsperren'
: 'Alle Zeichnungsebenen sperren'}
style={{ width: 18, height: 18 }}
>
<Icon
name={zeichnungsebenen.every(z => z.locked === true) ? 'lock' : 'lock_open'}
size={11}
/>
</button>
<div style={{ width: 18 }} />
</div>
<div> <div>
{sorted.map(z => ( {sorted.map(z => (
<ZeichnungsebeneRow <ZeichnungsebeneRow
@@ -222,6 +302,7 @@ export default function GeschossManager({
onContextMenu={(ev) => openContextMenu(ev, z.id)} onContextMenu={(ev) => openContextMenu(ev, z.id)}
onToggleVisible={() => toggleVisible(z.id)} onToggleVisible={() => toggleVisible(z.id)}
onToggleLock={() => toggleLock(z.id)} onToggleLock={() => toggleLock(z.id)}
onToggleClipping={() => toggleClipping(z.id)}
onDelete={() => remove(z.id)} onDelete={() => remove(z.id)}
/> />
))} ))}