Files
karim 95031ee2c0 Panels poliert: Ebenenkombi in Oberleiste, Satelliten-Dialoge, Caps weg, Perf
- Ebenenkombination raus aus Ebenen-Panel, in Oberleiste-Topbar +
  Editor-Satellite (AusschnittLayerDialog embedded). doc.Strings
  haelt active_comb_name, auto-clear bei manueller Eye/Lock-Aenderung.
- EbenenSettingsDialog jetzt Satellite mit Ebene-Picker-Dropdown
  (auto-save on switch via SAVE_KEEP).
- Per-Ausschnitt Einstellungen-Satellite (Massstab, Display, Overrides,
  Ebenenkombi). Alte 'Sichtbarkeit bearbeiten'-Option entfernt.
- Layouts/Ausschnitte: Top-Header weg, Sticky-Footer mit Anzahl +
  Aktionen. LayoutDialog ist jetzt Satellite mit Format-Live-Preview.
- Panel-Captions + Default-Ebenen-Namen auf Mixed-Case (Ausschnitte,
  Ebenen, Waende ...). Nur DOSSIER bleibt caps.
- DimensionenApp: Card-Optik raus, REF-Wuerfel mit Kreisen statt
  Quadraten + Hover-Scale.
- GeschossManager angeglichen an EbenenManager: Rechtsklick-Menue,
  Lock-Button, Delete-X, Duplizieren. layer_builder honoriert z.locked.
- Active Sublayer folgt jetzt dem Geschoss-Wechsel (gleicher Code
  unter neuem Parent).

Performance Geschoss-Wechsel:
- elemente._send_state() ersetzt durch _notify_active_geschoss()
  (Partial-Push statt 200+ Elements re-enumerieren).
- _apply_visibility dedupe via sticky last-applied-signature
  (STATE_SYNC-Echo loopt nicht mehr durch alle Layer).
- _update_clipping nur wenn alt oder neu hasClipping=True.
- Redundante doc.Views.Redraw() im CPlane-Pfad entfernt — die folgende
  apply_visibility-Roundtrip redrawt 30ms spaeter ohnehin.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 03:58:28 +02:00

1097 lines
39 KiB
Python

#! python 3
# -*- 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=("straighten", "#c87050"))
# register_standalone_panel() # auskommentiert — Standard ist OBERLEISTE