# ! python3 # -*- coding: utf-8 -*- """ massstab.py MASSSTAB-Panel: zeigt + setzt den aktuellen Massstab des aktiven Viewports. Funktioniert nur in Parallelprojektion. In Perspective wird "—" gemeldet. Maesstabs-Mathematik: frustum_width_in_doc_units = vp.GetFrustum() -> (right - left) frustum_width_mm = frustum_width_in_doc_units * mm_per_doc_unit screen_width_mm = pixel_width * 25.4 / dpi N = frustum_width_mm / screen_width_mm (Skala 1:N) DPI kann pro Doc kalibriert werden (Default 96), gespeichert in doc.Strings. """ import os import sys import math import json import Rhino import scriptcontext as sc _HERE = os.path.dirname(os.path.abspath(__file__)) if _HERE not in sys.path: sys.path.insert(0, _HERE) import panel_base PANEL_GUID_STR = "5c8e4f3f-6d0e-4f1a-a3d4-e5f607182941" # DPI ist eine Hardware-Eigenschaft (Bildschirm) — NICHT pro Dokument speichern. # Wir legen sie in einer Config-Datei im Home des Users ab. _DOC_DPI_KEY = "dossier_dpi" # Legacy, fuer Migration # Pro Viewport-Name der zuletzt vom User explizit gesetzte Massstab # (Dropdown/Input/100%-Button/Ausschnitt-Restore). NICHT der Live-Zoom — der # drifted bei Pan/Zoom. Wird von Ausschnitten beim Speichern als "der # eingestellte Massstab" gelesen. Per-doc persistiert in doc.Strings als # JSON-Dict, damit ein Wechsel zurueck auf einen frueher gesetzten Viewport # den korrekten Wert wieder rausgibt — auch nach Restart. _user_set_scales = {} # {viewport_name: float} _user_set_scales_loaded = False # lazy load aus doc.Strings beim ersten Zugriff _DOC_USER_SCALES_KEY = "dossier_user_scales" # JSON-Dict {vp_name: ratio} _DOC_USER_SCALE_KEY = "dossier_user_scale" # Legacy: globaler letzter Wert, # wird fuer Plotweight/Hatch-Rescale # weiter doc-weit genutzt. _CONFIG_DIR = os.path.expanduser("~/Library/Application Support/RhinoPanel") _CONFIG_PATH = os.path.join(_CONFIG_DIR, "config.json") _DEFAULT_DPI = 96.0 # Mac WKWebView verschluckt schnelle ExecuteScript-Bursts -> wir limitieren # das Live-Update auf alle N Idle-Ticks (~5-10/s). _IDLE_THROTTLE = 6 def _mm_per_doc_unit(doc): """Faktor: doc-Einheit -> Millimeter.""" us = doc.ModelUnitSystem UnitSystem = Rhino.UnitSystem if us == UnitSystem.Millimeters: return 1.0 if us == UnitSystem.Centimeters: return 10.0 if us == UnitSystem.Meters: return 1000.0 if us == UnitSystem.Kilometers: return 1000000.0 if us == UnitSystem.Inches: return 25.4 if us == UnitSystem.Feet: return 304.8 if us == UnitSystem.Yards: return 914.4 if us == UnitSystem.Miles: return 1609344.0 # Fallback ueber Rhinos UnitScale try: return Rhino.RhinoMath.UnitScale(us, UnitSystem.Millimeters) except Exception: return 1.0 _DETECT_JXA = ( 'ObjC.import("CoreGraphics");' 'var id=$.CGMainDisplayID();' 'var sz=$.CGDisplayScreenSize(id);' 'var m=$.CGDisplayCopyDisplayMode(id);' 'JSON.stringify({mm:sz.width,mh:sz.height,' 'px:$.CGDisplayModeGetPixelWidth(m),' # echte/physische Pixel 'py:$.CGDisplayModeGetPixelHeight(m),' 'lpx:$.CGDisplayPixelsWide(id),' # logische Pixel (zum Vergleich) 'lpy:$.CGDisplayPixelsHigh(id)});' ) def _detect_dpi(): """Automatische DPI-Erkennung auf macOS via CoreGraphics. Ruft `osascript -l JavaScript` mit einem CoreGraphics-Snippet auf: CGDisplayScreenSize(mainDisplay) -> physische Groesse in mm CGDisplayPixelsWide -> logische Aufloesung in px DPI = px * 25.4 / mm. Funktioniert auf Intel- und Apple-Silicon-Macs und passt sich automatisch an macOS-Scaling-Aenderungen an. Implementiert ueber System.Diagnostics.Process (IronPython auf .NET unterstuetzt kein `subprocess.check_output`). """ try: from System.Diagnostics import Process, ProcessStartInfo except Exception as ex: print("[MASSSTAB] auto-detect: .NET Process nicht verfuegbar:", ex) return None if not os.path.isfile("/usr/bin/osascript"): # Vermutlich nicht macOS -> nichts zu detecten return None # JXA-Snippet in eine temp-Datei schreiben, damit wir uns das # Shell-Quoting fuer Argumente sparen koennen. script_path = None try: import tempfile fd, script_path = tempfile.mkstemp(suffix=".js", prefix="rhinopanel_dpi_") try: os.write(fd, _DETECT_JXA.encode("utf-8")) finally: os.close(fd) psi = ProcessStartInfo() psi.FileName = "/usr/bin/osascript" psi.Arguments = '-l JavaScript "' + script_path + '"' psi.RedirectStandardOutput = True psi.RedirectStandardError = True psi.UseShellExecute = False psi.CreateNoWindow = True p = Process.Start(psi) out = p.StandardOutput.ReadToEnd() err = p.StandardError.ReadToEnd() # Timeout-Schutz: maximal 2s warten try: finished = p.WaitForExit(2000) except Exception: finished = True if not finished: try: p.Kill() except Exception: pass print("[MASSSTAB] auto-detect: osascript timeout") return None if p.ExitCode != 0: print("[MASSSTAB] auto-detect osascript ExitCode={}:".format(p.ExitCode), err) return None import json as _json data = _json.loads((out or "").strip()) mm = float(data.get("mm") or 0) # Rhinos vp.Size auf Mac liefert PHYSISCHE Pixel (Retina-Backing), # daher physische Pixelzahl fuer die DPI verwenden — sonst sind # die Modelle nach Skala-Anwendung halb so gross. px = float(data.get("px") or 0) lpx = float(data.get("lpx") or 0) if mm <= 0 or px <= 0: return None dpi = px * 25.4 / mm if dpi < 30.0 or dpi > 600.0: print("[MASSSTAB] auto-detect: DPI {:.1f} ausserhalb 30..600 -> ignoriert".format(dpi)) return None print("[MASSSTAB] DPI auto-detected: {:.1f} physisch (Bildschirm {:.0f}x{:.0f}px / {:.1f}x{:.1f}mm, logisch {:.0f}x{:.0f})".format( dpi, px, float(data.get("py") or 0), mm, float(data.get("mh") or 0), lpx, float(data.get("lpy") or 0))) return dpi except Exception as ex: print("[MASSSTAB] auto-detect fehlgeschlagen:", ex) return None finally: if script_path: try: os.remove(script_path) except Exception: pass _config_cache = None # in-memory Cache fuer die Config-Datei (DPI etc.) def _read_config(): """Liest die Config-Datei nur einmal, danach aus Memory-Cache. Wird via _compute_scale/_get_dpi sehr haeufig (jeden Idle-Tick) aufgerufen -> File-IO darf nicht jeden Tick passieren.""" global _config_cache if _config_cache is not None: return _config_cache cfg = {} try: if os.path.isfile(_CONFIG_PATH): with open(_CONFIG_PATH, "rb") as f: data = json.loads(f.read().decode("utf-8")) if isinstance(data, dict): cfg = data except Exception as ex: print("[MASSSTAB] config lesen:", ex) _config_cache = cfg return cfg def _write_config(cfg): """Schreibt + invalidiert den Cache.""" global _config_cache try: if not os.path.isdir(_CONFIG_DIR): os.makedirs(_CONFIG_DIR) with open(_CONFIG_PATH, "wb") as f: f.write(json.dumps(cfg, ensure_ascii=False, indent=2).encode("utf-8")) _config_cache = cfg # Cache mit dem geschriebenen Stand aktualisieren return True except Exception as ex: print("[MASSSTAB] config schreiben:", ex) return False def _get_dpi(doc): """Liefert den aktuell gueltigen DPI-Wert aus der Config. Loest KEINEN Subprocess aus — Auto-Detection passiert nur explizit (bei READY, Button-Klick oder via _force_redetect_dpi).""" cfg = _read_config() # Legacy-Migration: alter doc.Strings Wert -> einmalig in Config if doc is not None and "dpi" not in cfg: try: legacy = doc.Strings.GetValue(_DOC_DPI_KEY) if legacy: v = float(legacy) if 30.0 <= v <= 600.0: cfg["dpi"] = v cfg["dpi_source"] = "manual" _write_config(cfg) return v except Exception: pass try: v = float(cfg.get("dpi")) if 30.0 <= v <= 600.0: return v except Exception: pass return _DEFAULT_DPI def _bootstrap_dpi(): """Beim Panel-Start einmal aufgerufen. Verhalten: - Keine Config: detected + speichert - Source 'manual': unangetastet (User hat bewusst gewaehlt) - Source 'auto'/sonst: detected, ueberschreibt (so faengt eine neue Detection-Logik auch alte Werte ein und der User reagiert auf Display-Scaling-Wechsel automatisch) Subprocess laeuft hier nur einmal pro Panel-Open.""" cfg = _read_config() if cfg.get("dpi_source") == "manual": return # User-Override respektieren auto = _detect_dpi() if auto is None: return cfg["dpi"] = auto cfg["dpi_source"] = "auto" _write_config(cfg) def _set_dpi(doc, value, source="manual"): try: v = float(value) except Exception: return False if not (30.0 <= v <= 600.0): return False cfg = _read_config() cfg["dpi"] = v cfg["dpi_source"] = source if not _write_config(cfg): return False print("[MASSSTAB] DPI={:.1f} ({}) -> {}".format(v, source, _CONFIG_PATH)) return True def _force_redetect_dpi(): """Manueller "Auto-Detect"-Trigger via Button. Ueberschreibt auch einen manuellen Override. Liefert den neuen Wert oder None.""" auto = _detect_dpi() if auto is None: return None cfg = _read_config() cfg["dpi"] = auto cfg["dpi_source"] = "auto" _write_config(cfg) return auto def _active_vp(): doc = Rhino.RhinoDoc.ActiveDoc if doc is None: return None, None view = doc.Views.ActiveView if view is None: return doc, None try: # Page-Views (Layouts) haben einen eigenen Viewport — fuer den # Massstab interessiert uns dort das aktive Detail. if isinstance(view, Rhino.Display.RhinoPageView): for d in view.GetDetailViews(): if d.IsActive: return doc, d.Viewport return doc, view.ActiveViewport except Exception: pass return doc, view.ActiveViewport def _get_frustum_width(vp): """Liefert die sichtbare Welt-Breite (right - left) oder None.""" try: ok, l, r, b, t, n, f = vp.GetFrustum() if not ok: return None, None return (r - l), (t - b) except Exception: return None, None def _viewport_pixels(vp): try: sz = vp.Size return float(sz.Width), float(sz.Height) except Exception: return None, None def _compute_scale(doc, vp): """Liefert dict mit Scale-Info fuer aktuellen Viewport. Felder: viewName, parallel, scale (1:N float), pixelWidth, pixelHeight, unitSystem (string), dpi. scale=None bei Perspective oder unbekannten Groessen. """ cfg = _read_config() info = { "viewName": None, "parallel": False, "scale": None, "appliedScale": None, "pixelWidth": None, "pixelHeight": None, "unitSystem": "?", "dpi": _get_dpi(doc) if doc else _DEFAULT_DPI, "dpiSource": cfg.get("dpi_source", "default"), "showLineweights": _get_lineweights_enabled(doc), } if vp is None or doc is None: return info try: info["viewName"] = vp.Name info["parallel"] = bool(vp.IsParallelProjection) info["unitSystem"] = str(doc.ModelUnitSystem) except Exception: pass # appliedScale pro Viewport. Map ist gefuettert durch _apply_scale und # Ausschnitt-Restore — wenn ein anderer Viewport aktiv ist als beim letzten # Setzen, kommt entweder dessen frueher gesetzter Wert oder None zurueck. # Niemals auf die Live-Skala mappen — das Dropdown soll STATISCH sein. # Wichtig: nur bei Parallelprojektion zurueckgeben. In Perspective ist ein # Massstab konzeptionell unsinnig — selbst wenn der gleiche Viewport vorher # parallel mit Massstab war, soll das Dropdown auf "-" springen, sobald die # Projektion auf perspective wechselt. try: if info.get("parallel"): v = _get_applied_scale_for_vp(doc, info["viewName"]) if v is not None and v > 0: info["appliedScale"] = v except Exception: pass if not info["parallel"]: return info fw, fh = _get_frustum_width(vp) pw, ph = _viewport_pixels(vp) info["pixelWidth"] = pw info["pixelHeight"] = ph if fw is None or pw is None or pw <= 0: return info mm_per_u = _mm_per_doc_unit(doc) dpi = info["dpi"] if dpi <= 0: return info frustum_mm = fw * mm_per_u screen_mm = pw * 25.4 / dpi if screen_mm <= 0: return info info["scale"] = frustum_mm / screen_mm return info _LW_KEY = "dossier_show_lineweights" _LW_ORIG_KEY = "dossier_plotweight_orig" # per-objekt/layer Original-Wert _HATCH_ORIG_KEY = "dossier_hatch_scale_orig" # auf Hatch-Attrs: original PatternScale def _apply_scaled_lineweights(doc, enabled, scale_n): """Skaliert die PlotWeights aller Layer + Objekte mit scale_n wenn enabled, sonst stellt Originalwerte wieder her. Originals werden in UserStrings persistiert -> idempotent, ueberlebt Restart. Default-Werte (0 oder -1 = "by parent") werden nicht angefasst — sonst wuerden eigentlich-default-Objekte plotweight-fixiert. """ if doc is None: return if scale_n is None or scale_n <= 0: scale_n = 1.0 factor = float(scale_n) if enabled else 1.0 n_layer = 0 n_obj = 0 # -- Layer --------------------------------------------------------------- try: for layer in doc.Layers: if layer is None or layer.IsDeleted: continue try: stored = layer.GetUserString(_LW_ORIG_KEY) if stored: orig = float(stored) else: orig = float(layer.PlotWeight) if layer.PlotWeight else 0.0 # Nur sichern wenn ueberhaupt was anzuspielen ist if orig > 0: layer.SetUserString(_LW_ORIG_KEY, "{:.6f}".format(orig)) if orig <= 0: continue # default-Layer (hairline) — nicht anfassen new = orig * factor if abs(float(layer.PlotWeight) - new) > 1e-6: layer.PlotWeight = new n_layer += 1 except Exception as ex: print("[MASSSTAB] LW scale layer '{}': {}".format(layer.Name, ex)) except Exception as ex: print("[MASSSTAB] LW scale layers:", ex) # -- Objekte ------------------------------------------------------------- try: for obj in doc.Objects: if obj is None or obj.IsDeleted: continue try: a = obj.Attributes stored = a.GetUserString(_LW_ORIG_KEY) if stored: orig = float(stored) elif a.PlotWeight and a.PlotWeight > 0: orig = float(a.PlotWeight) new_a = a.Duplicate() new_a.SetUserString(_LW_ORIG_KEY, "{:.6f}".format(orig)) doc.Objects.ModifyAttributes(obj, new_a, True) else: continue # PlotWeightSource ist "by layer" — nichts zu tun new = orig * factor if abs(float(a.PlotWeight) - new) > 1e-6: new_a = a.Duplicate() new_a.PlotWeight = new doc.Objects.ModifyAttributes(obj, new_a, True) n_obj += 1 except Exception: pass except Exception as ex: print("[MASSSTAB] LW scale objects:", ex) try: doc.Views.Redraw() except Exception: pass print("[MASSSTAB] PlotWeight-Skalierung x{:.1f}: {} Layer, {} Objekte angepasst".format( factor, n_layer, n_obj)) # Diagnose: zeige die ersten paar Layer mit ihren echten PlotWeights try: shown = 0 for layer in doc.Layers: if layer.IsDeleted or not layer.PlotWeight: continue stored = layer.GetUserString(_LW_ORIG_KEY) or "-" print("[MASSSTAB] Layer '{}' PlotWeight={:.3f}mm (orig={})".format( layer.Name, float(layer.PlotWeight), stored)) shown += 1 if shown >= 5: break except Exception: pass # (Hatch-Skalierung ist in apply_scaled_hatches ausgelagert — die laeuft # IMMER, unabhaengig vom Print-Mode, weil Hatches sich am Massstab # orientieren sollten unabhaengig von Plot-Weight-Anzeige.) def write_plotweight(doc, target, value): """Setze PlotWeight auf target (Layer oder ObjectAttributes-Duplicate), Print-Mode-aware. value = "echter" Wert in mm wie er auf Papier landet. Speichert value als Original-UserString. Wenn Print-Mode aktiv ist wird PlotWeight = value * scale gesetzt damit die Anzeige direkt skaliert. Aufrufer ist verantwortlich fuer ModifyAttributes / Doc-Refresh.""" if target is None: return try: v = float(value) if value is not None else 0.0 except Exception: v = 0.0 try: if v > 0: target.SetUserString(_LW_ORIG_KEY, "{:.6f}".format(v)) else: target.SetUserString(_LW_ORIG_KEY, "") except Exception: pass print_on = _get_lineweights_enabled(doc) if doc is not None else False factor = _read_user_scale(doc, default=1.0) if print_on else 1.0 try: target.PlotWeight = v * factor except Exception as ex: print("[MASSSTAB] write_plotweight set:", ex) def apply_scaled_hatches(doc, scale_n): """Skaliert alle Hatches im Doc gemaess Massstab scale_n. Formel: factor = sqrt(N) / 10 — moderate Skalierung mit 1:100 als Referenz-Punkt (factor = 1.0). 1:1 -> 0.10 1:50 -> 0.71 1:100 -> 1.0 (Referenz) 1:500 -> 2.24 1:1000 -> 3.16 Originale werden in Hatch-Attributes UserString gesichert. Returns: (n_gefunden, n_skaliert) """ import math if doc is None: return (0, 0) if not scale_n or scale_n <= 0: scale_n = 1.0 factor = math.sqrt(float(scale_n)) / 10.0 # IDs vorab sammeln (sonst invalidiert Replace die Iteration) hatch_ids = [] try: settings = Rhino.DocObjects.ObjectEnumeratorSettings() settings.ObjectTypeFilter = Rhino.DocObjects.ObjectType.Hatch settings.NormalObjects = True settings.LockedObjects = True settings.HiddenObjects = True settings.IncludeLights = False settings.IncludeGrips = False for obj in doc.Objects.GetObjectList(settings): if obj is not None and not obj.IsDeleted: hatch_ids.append(obj.Id) except Exception: try: for obj in doc.Objects: if obj is None or obj.IsDeleted: continue try: if obj.ObjectType == Rhino.DocObjects.ObjectType.Hatch: hatch_ids.append(obj.Id) except Exception: pass except Exception: pass n_scaled = 0 for hid in hatch_ids: try: obj = doc.Objects.FindId(hid) if obj is None or obj.IsDeleted: continue g = obj.Geometry a = obj.Attributes stored = a.GetUserString(_HATCH_ORIG_KEY) if stored: try: orig = float(stored) except Exception: orig = 0.0 else: cur = float(g.PatternScale) if g.PatternScale else 0.0 if cur > 0: orig = cur new_a = a.Duplicate() new_a.SetUserString(_HATCH_ORIG_KEY, "{:.6f}".format(orig)) doc.Objects.ModifyAttributes(obj, new_a, True) else: continue if orig <= 0: continue new_val = orig * factor if abs(float(g.PatternScale) - new_val) > 1e-6: new_g = g.Duplicate() try: new_g.PatternScale = new_val if doc.Objects.Replace(hid, new_g): n_scaled += 1 except Exception as ex: print("[MASSSTAB] hatch set PatternScale:", ex) except Exception as ex: print("[MASSSTAB] hatch iter:", ex) if n_scaled or hatch_ids: print("[MASSSTAB] Hatch-Skalierung: {} gefunden, {} mit Faktor x{:.2f} angepasst".format( len(hatch_ids), n_scaled, factor)) try: doc.Views.Redraw() except Exception: pass return (len(hatch_ids), n_scaled) def post_create_hatch_scale(doc, hatch_obj, user_scale): """Nach AddHatch oder Replace einer Hatch aufrufen. Speichert user_scale als Original-UserString und skaliert die Geometrie mit sqrt(N) (siehe apply_scaled_hatches).""" import math if doc is None or hatch_obj is None: return try: u = float(user_scale) except Exception: return if u <= 0: return try: a = hatch_obj.Attributes.Duplicate() a.SetUserString(_HATCH_ORIG_KEY, "{:.6f}".format(u)) doc.Objects.ModifyAttributes(hatch_obj, a, True) except Exception as ex: print("[MASSSTAB] post_create_hatch_scale orig:", ex) # Mit aktuellem Massstab skalieren (sqrt-Formel /10, siehe apply_scaled_hatches) scale_n = _read_user_scale(doc, default=1.0) if not scale_n or scale_n <= 0: scale_n = 1.0 if abs(scale_n - 1.0) < 1e-6: return factor = math.sqrt(float(scale_n)) / 10.0 try: h2 = doc.Objects.FindId(hatch_obj.Id) if h2 is None: return new_g = h2.Geometry.Duplicate() new_g.PatternScale = u * factor doc.Objects.Replace(h2.Id, new_g) except Exception as ex: print("[MASSSTAB] post_create_hatch_scale rescale:", ex) def read_plotweight(target): """Lese den "echten" PlotWeight (vor Print-Mode-Skalierung). Faellt auf das aktuelle PlotWeight zurueck wenn kein Original gespeichert ist.""" if target is None: return 0.0 try: s = target.GetUserString(_LW_ORIG_KEY) if s: return float(s) except Exception: pass try: v = float(target.PlotWeight) if target.PlotWeight else 0.0 return v if v > 0 else 0.0 except Exception: return 0.0 def _get_lineweights_enabled(doc): """Print-View / Strichstaerken-Anzeige. Default OFF — Rhino zeigt sonst Linien als Hairlines was beim Zeichnen praktischer ist.""" if doc is None: return False try: v = doc.Strings.GetValue(_LW_KEY) if v is None: return False return v == "1" except Exception: return False def _set_lineweights_enabled(doc, enabled): """Togglet Rhinos Print-Display (zeigt PlotWeights mit ihren echten Strichstaerken anstatt Hairlines), persistiert den State im Doc UND skaliert die PlotWeights mit dem aktuellen Massstab. Auf Mac heisst der relevante Befehl _PrintDisplay (nicht _LineWeights — das oeffnet einen Dialog). Wir feuern beide Varianten als Fallback.""" if doc is None: return False flag = "1" if enabled else "0" try: doc.Strings.SetString(_LW_KEY, flag) except Exception as ex: print("[MASSSTAB] _set_lineweights_enabled persist:", ex) # Print-Display togglen — primaerer Befehl auf Mac Rhino on_off = "_On" if enabled else "_Off" yes_no = "_Yes" if enabled else "_No" cmds = [ "_-PrintDisplay _State {} _Enter".format(on_off), "_-LineweightsDisplay _State {} _Enter".format(on_off), "_-LineWeights _DisplayLineweights {} _Enter".format(yes_no), ] for c in cmds: try: Rhino.RhinoApp.RunScript(c, False) except Exception: pass # PlotWeight-Skalierung mit aktuellem Massstab try: scale_n = _read_user_scale(doc, default=1.0) _apply_scaled_lineweights(doc, enabled, scale_n) except Exception as ex: print("[MASSSTAB] PlotWeight-Scale:", ex) try: for v in doc.Views: v.Redraw() except Exception: pass print("[MASSSTAB] Print-Display:", "AN (Strichstaerken sichtbar)" if enabled else "AUS") return True def _read_user_scale(doc, default=1.0): """Persistierter eingestellter Massstab oder default. Setze default=None um "nie gesetzt" zu erkennen.""" if doc is None: return default try: raw = doc.Strings.GetValue(_DOC_USER_SCALE_KEY) if raw: v = float(raw) if v > 0: return v except Exception: pass return default def _write_user_scale(doc, ratio): if doc is None: return try: doc.Strings.SetString(_DOC_USER_SCALE_KEY, "{:.6f}".format(float(ratio))) except Exception as ex: print("[MASSSTAB] _write_user_scale:", ex) def _ensure_user_scales_loaded(doc): """Liest die persistierte per-Viewport-Map einmal pro Session aus doc.Strings.""" global _user_set_scales_loaded if _user_set_scales_loaded or doc is None: return try: raw = doc.Strings.GetValue(_DOC_USER_SCALES_KEY) if raw: data = json.loads(raw) if isinstance(data, dict): for k, v in data.items(): try: f = float(v) if f > 0 and k: _user_set_scales[str(k)] = f except Exception: pass except Exception as ex: print("[MASSSTAB] _ensure_user_scales_loaded:", ex) _user_set_scales_loaded = True def _write_user_scales(doc): if doc is None: return try: doc.Strings.SetString(_DOC_USER_SCALES_KEY, json.dumps(_user_set_scales, ensure_ascii=False)) except Exception as ex: print("[MASSSTAB] _write_user_scales:", ex) def _get_applied_scale_for_vp(doc, vp_name): """Eingestellter Massstab fuer einen Viewport, oder None.""" if not vp_name: return None _ensure_user_scales_loaded(doc) return _user_set_scales.get(vp_name) def _set_applied_scale_for_vp(doc, vp_name, ratio): if not vp_name: return _ensure_user_scales_loaded(doc) _user_set_scales[vp_name] = float(ratio) _write_user_scales(doc) def _rescale_doc_patterns(doc, factor): """Multipliziert alle Hatch-PatternScales und per-objekt Linetype-Scales um factor. Iteriert das gesamte Doc — laeuft auch bei 1000+ Objekten in unter 100ms (standard CAD-Verhalten). Globale Linetype-Skala wird auch versucht zu setzen (Rhino-API inkonsistent ueber Versionen — wir probieren mehrere Property-Namen).""" if factor is None or factor <= 0 or abs(factor - 1.0) < 1e-9: return # nichts zu tun n_h = 0 n_l = 0 HatchT = Rhino.Geometry.Hatch try: for obj in doc.Objects: if obj is None or obj.IsDeleted: continue g = None try: g = obj.Geometry except Exception: g = None # Hatch-Geometrie -> PatternScale skalieren if g is not None and isinstance(g, HatchT): try: g2 = g.Duplicate() cur = float(g2.PatternScale) if g2.PatternScale else 1.0 g2.PatternScale = cur * factor doc.Objects.Replace(obj.Id, g2) n_h += 1 except Exception as ex: print("[MASSSTAB] hatch rescale:", ex) # Per-Objekt Linetype-Scale (Rhino 8 Attribut) try: a = obj.Attributes for prop in ("LinetypePatternLengthScale", "LinetypeScale"): if hasattr(a, prop): cur = getattr(a, prop) if cur and cur > 0 and abs(cur - 1.0) > 1e-9: # Nur Objekte mit explizit gesetzter Skala anfassen # (Default=1.0 ueberlassen wir dem globalen Multiplikator). new_a = a.Duplicate() setattr(new_a, prop, cur * factor) doc.Objects.ModifyAttributes(obj, new_a, True) n_l += 1 break except Exception: pass except Exception as ex: print("[MASSSTAB] _rescale_doc_patterns:", ex) # Globale Linetype-Pattern-Length-Skala (Rhino-doc-Setting) versuchen. # Property-Namen variieren je nach Version — wir probieren. set_global = False for prop in ("LinetypeAndPatternScale", "LinetypeAndPatternLengthScale", "ModelSpaceLinetypeScale"): try: if hasattr(doc, prop): cur = float(getattr(doc, prop)) if cur > 0: setattr(doc, prop, cur * factor) set_global = True break except Exception: pass try: doc.Views.Redraw() except Exception: pass print("[MASSSTAB] Rescale x{:.4f}: {} Hatches, {} per-obj Linetypes{}".format( factor, n_h, n_l, ", global Linetype-Scale" if set_global else "")) def get_current_massstab_factor(doc=None): """Multiplier fuer Pattern-Skalen — aktuell deaktiviert (Faktor 1.0). Massstab beeinflusst aktuell NUR den Viewport-Zoom, nicht die Pattern- Skalen oder Strichstaerken.""" return 1.0 def _apply_scale(doc, vp, ratio): """Setzt den Frustum so dass 1mm Bildschirm == ratio doc-Einheiten in mm. ratio = 1:N -> ratio_value = N. Liefert True bei Erfolg. """ if vp is None or doc is None: return False try: if not vp.IsParallelProjection: print("[MASSSTAB] Viewport ist nicht parallel — Skala nicht setzbar") return False except Exception: return False pw, ph = _viewport_pixels(vp) if pw is None or pw <= 0: return False dpi = _get_dpi(doc) mm_per_u = _mm_per_doc_unit(doc) if mm_per_u <= 0 or dpi <= 0: return False screen_mm = pw * 25.4 / dpi new_frustum_mm = screen_mm * float(ratio) new_frustum_u = new_frustum_mm / mm_per_u # in doc-units try: ok, l, r, b, t, n, f = vp.GetFrustum() if not ok: return False cur_w = (r - l) if cur_w <= 0: return False # RhinoViewport.SetFrustum existiert nicht — wir benutzen Magnify(factor). # factor > 1 zoomt rein (kleineres Frustum). factor = cur_w / new_w. factor = cur_w / new_frustum_u if factor <= 0 or not (factor < 1e9 and factor > 1e-9): print("[MASSSTAB] _apply_scale: ungueltiger Faktor", factor) return False applied = False # Verschiedene API-Signaturen je nach Rhino-Version durchprobieren. # 1) Magnify(factor, mode) — mode=False -> mittig try: vp.Magnify(float(factor), False) applied = True except Exception as ex1: # 2) Magnify(factor) try: vp.Magnify(float(factor)) applied = True except Exception as ex2: # 3) Camera-Skript-Fallback (zoom faktorisch via _Zoom Factor) try: Rhino.RhinoApp.RunScript("_-Zoom _Factor {:.6f} _Enter".format(factor), False) applied = True except Exception as ex3: print("[MASSSTAB] _apply_scale alle Varianten fehlgeschlagen:", ex1, ex2, ex3) if not applied: return False # PlotWeights nur skalieren wenn Print-Mode aktiv ist. try: if _get_lineweights_enabled(doc): _apply_scaled_lineweights(doc, True, float(ratio)) except Exception as ex: print("[MASSSTAB] LW-Rescale:", ex) # Hatches mit sqrt(N) skalieren — moderate Anpassung. try: apply_scaled_hatches(doc, float(ratio)) except Exception as ex: print("[MASSSTAB] Hatch-Rescale:", ex) # Neuen Wert persistieren — sowohl per-Viewport (fuer das Dropdown, # damit jeder Viewport seinen eigenen Massstab behaelt) als auch als # globaler "letzter Wert" (Legacy-Key; wird von Plotweight/Hatch-Rescale # doc-weit benutzt — dort ist nur EIN Faktor sinnvoll). _write_user_scale(doc, ratio) try: _set_applied_scale_for_vp(doc, vp.Name, float(ratio)) except Exception as ex: print("[MASSSTAB] per-vp scale write:", ex) try: doc.Views.Redraw() except Exception: pass print("[MASSSTAB] Skala 1:{:.2f} gesetzt (Faktor {:.4f}, soll-frustum {:.4f} {})".format( ratio, factor, new_frustum_u, str(doc.ModelUnitSystem))) return True except Exception as ex: print("[MASSSTAB] _apply_scale:", ex) return False def _zoom_extents(doc, vp, selected_only=False): if vp is None or doc is None: return False try: if selected_only: objs = list(doc.Objects.GetSelectedObjects(False, False)) if not objs: print("[MASSSTAB] Keine Selektion fuer Zoom-Selection") return False bbox = Rhino.Geometry.BoundingBox.Empty for o in objs: try: b = o.Geometry.GetBoundingBox(True) if bbox.IsValid: bbox.Union(b) else: bbox = b except Exception: continue if not bbox.IsValid: return False # Etwas Padding (10%) d = bbox.Diagonal pad = max(d.X, d.Y, d.Z) * 0.05 if pad > 0: bbox.Inflate(pad, pad, pad) try: vp.ZoomBoundingBox(bbox) except Exception: # Fallback ueber Rhino-Skript Rhino.RhinoApp.RunScript("_-Zoom _Selected _Enter", False) else: try: vp.ZoomExtents() except Exception: Rhino.RhinoApp.RunScript("_-Zoom _All _Extents _Enter", False) try: doc.Views.Redraw() except Exception: pass return True except Exception as ex: print("[MASSSTAB] _zoom_extents:", ex) return False # --- Bridge ----------------------------------------------------------------- class MassstabBridge(panel_base.BaseBridge): def __init__(self): panel_base.BaseBridge.__init__(self, "massstab") self._idle_counter = 0 self._last_info = None def _on_ready(self): # Einmalige Bootstrap-Detection falls noch keine DPI in der Config. try: _bootstrap_dpi() except Exception as ex: print("[MASSSTAB] bootstrap:", ex) self._send_state(force=True) def handle(self, data): if not isinstance(data, dict): return t = data.get("type", "") p = data.get("payload") or {} if not isinstance(p, dict): p = {} if t == "READY": self._on_ready() elif t == "REQUEST_STATE": self._send_state(force=True) elif t == "SET_SCALE": doc, vp = _active_vp() try: ratio = float(p.get("ratio")) except Exception: return if ratio <= 0: return if _apply_scale(doc, vp, ratio): self._send_state(force=True) elif t == "ZOOM_ONE_TO_ONE": doc, vp = _active_vp() if _apply_scale(doc, vp, 1.0): self._send_state(force=True) elif t == "ZOOM_EXTENTS": doc, vp = _active_vp() _zoom_extents(doc, vp, selected_only=False) self._send_state(force=True) elif t == "ZOOM_SELECTION": doc, vp = _active_vp() _zoom_extents(doc, vp, selected_only=True) self._send_state(force=True) elif t == "SET_DPI": doc, _ = _active_vp() if _set_dpi(doc, p.get("dpi"), source="manual"): self._send_state(force=True) elif t == "DETECT_DPI": v = _force_redetect_dpi() if v is None: print("[MASSSTAB] Auto-Detect: keine Bildschirminfo verfuegbar") self._send_state(force=True) elif t == "SET_LINEWEIGHTS": doc, _ = _active_vp() _set_lineweights_enabled(doc, bool(p.get("enabled"))) self._send_state(force=True) def _send_state(self, force=False): doc, vp = _active_vp() info = _compute_scale(doc, vp) # Vergleich gegen letzten Stand — Nachrichten sparen if not force and info == self._last_info: return self._last_info = info self.send("STATE", info) def tick_idle(self): self._idle_counter += 1 if self._idle_counter < _IDLE_THROTTLE: return self._idle_counter = 0 self._send_state(force=False) # --- Listener fuer Live-Updates --------------------------------------------- def _install_listeners(bridge): flag = "massstab_listeners" sc.sticky["massstab_bridge"] = bridge if sc.sticky.get(flag): return def on_idle(s, e): b = sc.sticky.get("massstab_bridge") if b is not None: try: b.tick_idle() except Exception: pass def on_view_change(*args): b = sc.sticky.get("massstab_bridge") if b is not None: try: b._send_state(force=True) except Exception: pass Rhino.RhinoApp.Idle += on_idle Rhino.RhinoDoc.ActiveDocumentChanged += on_view_change sc.sticky[flag] = True print("[MASSSTAB] Listener aktiv (Idle-Poll + Doc-Change)") def get_current_scale_ratio(): """Aktuelle LIVE-Skala 1:N als Float (aus Viewport-Frustum berechnet). Drifted bei Pan/Zoom mit. None bei Perspective.""" doc, vp = _active_vp() info = _compute_scale(doc, vp) return info.get("scale") def get_applied_scale_ratio(): """Eingestellter Massstab fuer den aktuell aktiven Viewport, oder None. Wird vom Ausschnitt-Capture als "der gerade gepinnte Massstab" gelesen.""" doc, vp = _active_vp() if doc is None or vp is None: return None try: v = _get_applied_scale_for_vp(doc, vp.Name) if v is not None and v > 0: return v except Exception: pass return None def _bridge_factory(): b = MassstabBridge() _install_listeners(b) return b # Hinweis: das eigenstaendige MASSSTAB-Panel wird nicht mehr automatisch # registriert — OBERLEISTE enthaelt seit der Refactor alle Funktionen. # Das Modul bleibt als Library bestehen (massstab._compute_scale, # get_applied_scale_ratio, write_plotweight, read_plotweight, ...). # Falls du das Panel doch wieder als separaten Tab willst, einfach # register_standalone_panel() aufrufen oder die Zeile darunter auskommentieren. def register_standalone_panel(): panel_base.register_and_open("massstab", "MASSSTAB", PANEL_GUID_STR, _bridge_factory, icon_spec=("M", "#c87050")) # register_standalone_panel() # auskommentiert — Standard ist OBERLEISTE