#! python 3 # -*- coding: utf-8 -*- """ text_editor.py React-WYSIWYG-Editor in Satellite-WebView (Topmost). User picked Frame in create_text(), dann oeffnet sich dieser Editor neben dem Frame. TextEditorBridge haelt Frame-Daten + Settings, auf COMMIT erstellt es die TextEntity und schliesst das Fenster. """ import os import sys import Rhino import Rhino.Geometry as rg 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 import text_create # --------------------------------------------------------------------------- # Doppelklick-Hook: wenn User auf einen DOSSIER-Text doppelklickt, oeffnet # sich UNSER Editor statt Rhinos Standard-TextEdit. class _DossierTextDoubleClickHook(Rhino.UI.MouseCallback): def OnMouseDoubleClick(self, e): try: doc = Rhino.RhinoDoc.ActiveDoc if doc is None: return # Welches Objekt ist gerade selektiert? (Rhino's einfache # Single-Click hat es ausgewaehlt bevor wir hier sind.) target = None for obj in doc.Objects.GetSelectedObjects(False, False): try: if isinstance(obj.Geometry, rg.TextEntity): if obj.Attributes.GetUserString("dossier_text") == "1": target = obj; break except Exception: pass if target is not None: e.Cancel = True # Rhinos eigener TextEdit-Dialog wird geblockt # Defer auf naechsten Idle-Tick — Eto-Form aus MouseCallback # heraus zu oeffnen kann zu Re-Entrancy-Problemen fuehren sc.sticky["dossier_pending_text_edit"] = str(target.Id) except Exception as ex: print("[TEXT-HOOK] OnDoubleClick:", ex) _hook_instance = None def _ensure_double_click_hook(): """Installiert MouseCallback einmalig pro Rhino-Session.""" global _hook_instance if _hook_instance is not None: return try: _hook_instance = _DossierTextDoubleClickHook() _hook_instance.Enabled = True # Idle-Handler einmalig registrieren fuer das deferred Opening if not sc.sticky.get("dossier_text_idle_registered"): Rhino.RhinoApp.Idle += _on_idle_check_pending_edit sc.sticky["dossier_text_idle_registered"] = True print("[TEXT-HOOK] Doppelklick-Hook installiert") except Exception as ex: print("[TEXT-HOOK] install:", ex) def _on_idle_check_pending_edit(sender, e): pid = sc.sticky.get("dossier_pending_text_edit") if not pid: return sc.sticky["dossier_pending_text_edit"] = None doc = Rhino.RhinoDoc.ActiveDoc if doc is None: return try: import System obj = doc.Objects.FindId(System.Guid(pid)) if obj is None: return open_for_edit(obj) except Exception as ex: print("[TEXT-HOOK] open for edit:", ex) class TextEditorBridge(panel_base.BaseBridge): def __init__(self, frame_data, settings, fonts, edit_obj_id=None, initial_text="", initial_runs=None): panel_base.BaseBridge.__init__(self, "text_editor") self._frame = frame_data # (origin, width, height, p1, p2) self._initial_settings = settings self._fonts = fonts self._form_ref = None self._edit_obj_id = edit_obj_id # bei Doppelklick-Edit gesetzt self._initial_text = initial_text self._initial_runs = initial_runs # rich-format-Runs falls vorhanden def set_form(self, form): self._form_ref = form def _on_ready(self): doc = Rhino.RhinoDoc.ActiveDoc styles = [] try: styles = text_create.list_styles(doc) except Exception: pass self.send("INIT", { "settings": self._initial_settings, "fonts": self._fonts, "styles": styles, "initialText": self._initial_text, "initialRuns": self._initial_runs, "editMode": bool(self._edit_obj_id), }) def handle(self, data): if not isinstance(data, dict): return t = data.get("type", "") p = data.get("payload") or {} if t == "READY": self._on_ready() elif t == "COMMIT": self._commit(p) try: self._form_ref.Close() except Exception: pass elif t == "CANCEL": try: self._form_ref.Close() except Exception: pass def _commit(self, payload): import System import math doc = Rhino.RhinoDoc.ActiveDoc if doc is None or self._frame is None: return text = (payload.get("text") or "").strip() if not text: return st = payload.get("settings") or {} runs = payload.get("runs") # Phase 2: rich-text runs (oder None) origin, width, height, _p1, _p2 = self._frame try: te = rg.TextEntity() # Plane mit optionaler Rotation um Z rot_deg = 0.0 try: rot_deg = float(st.get("rotation") or 0) except Exception: pass plane = rg.Plane(origin, rg.Vector3d.ZAxis) if abs(rot_deg) > 1e-6: try: plane.Rotate(math.radians(rot_deg), rg.Vector3d.ZAxis, origin) except Exception: pass te.Plane = plane # RichText (Phase 2) erzeugen wenn Runs nontrivial sind. rtf = _runs_to_rtf( runs, st.get("font") or "Helvetica", base_size_m=float(st.get("size") or 0.2)) if runs else None if rtf: print("[TEXT-EDITOR] RTF len={} preview={!r}".format( len(rtf), rtf[:300])) # Defaults (Height + Align gelten immer) try: te.TextHeight = float(st.get("size") or 0.2) except Exception: pass text_create._apply_align(te, st.get("align") or "left") text_create._apply_valign(te, st.get("valign") or "top") # Content. Bei RichText KEIN _apply_font — sonst ueberschreibt # te.Font die per-Run-Fonts aus der RTF. Stattdessen lassen # wir RichText/SetRichText das selber regeln. if rtf: te.PlainText = text # Initial-Content (RichText ueberschreibt) # Bevorzugt SetRichText(rtf, dimstyle) — robusteres API applied = False try: ds = doc.DimStyles.Current te.SetRichText(rtf, ds) applied = True print("[TEXT-EDITOR] SetRichText OK") except Exception as ex1: print("[TEXT-EDITOR] SetRichText fail:", ex1) if not applied: try: te.RichText = rtf applied = True print("[TEXT-EDITOR] te.RichText = OK") except Exception as ex2: print("[TEXT-EDITOR] te.RichText = fail:", ex2) if not applied: # Letzter Fallback: ohne RTF, mit Toolbar-Defaults text_create._apply_font( te, st.get("font") or "Helvetica", st.get("bold"), st.get("italic"), st.get("underline")) else: te.PlainText = text text_create._apply_font( te, st.get("font") or "Helvetica", st.get("bold"), st.get("italic"), st.get("underline")) # 3. Text-Wrap im Frame — NACH dem Content damit es nicht # durch RichText-Set zurueckgesetzt wird. Beide Setter # versuchen (verschiedene Rhino-Versions-APIs). applied_w = None for attr in ("FormatWidth", "TextWidth", "MaskWidth"): try: setattr(te, attr, width) applied_w = attr break except Exception: pass try: te.TextIsWrapped = True except Exception: try: te.TextWrap = True except Exception: pass print("[TEXT-EDITOR] wrap: width={} applied_attr={}".format( width, applied_w)) # 4. Frame um den Text + Mask-Margin frame_kind = (st.get("frame") or "none").lower() try: MF = Rhino.DocObjects.TextMaskFrame te.MaskFrame = ( MF.RectFrame if frame_kind == "rect" else MF.CapsuleFrame if frame_kind == "capsule" else MF.NoFrame ) except Exception as ex: print("[TEXT-EDITOR] frame:", ex) # Mask: Type entscheidet ob/wie maskiert wird. Margin gilt # nur wenn Maske aktiv. Solid-Color erst dann setzen wenn # Type=solid (sonst dominiert Viewport-Color). try: mask_type = (st.get("maskType") or "none").lower() mask_m = float(st.get("maskMargin") or 0) if mask_type == "none": te.MaskEnabled = False else: te.MaskEnabled = True te.MaskOffset = mask_m if mask_type == "solid": te.MaskUsesViewportColor = False mc = st.get("maskColor") or [255, 255, 255] try: te.MaskColor = System.Drawing.Color.FromArgb( int(mc[0]), int(mc[1]), int(mc[2])) except Exception as ex: print("[TEXT-EDITOR] mask color:", ex) else: te.MaskUsesViewportColor = True except Exception as ex: print("[TEXT-EDITOR] mask:", ex) # 5. Horizontal-to-view (DrawForward) try: te.DrawForward = bool(st.get("horizontalToView")) except Exception: pass # 6. Annotation-Scaling (Masstaeblich) — Rhino 8 hat das pro # Annotation-Objekt. Property-Name variiert je nach Build, # deshalb mehrere Varianten versuchen. scale_flag = bool(st.get("scaleWithModel", True)) applied_scale = None for prop in ("AnnotationScalingEnabled", "IsAnnotationScalingEnabled", "ModelSpaceScalingEnabled"): try: setattr(te, prop, scale_flag) applied_scale = prop break except Exception: pass if applied_scale is None: print("[TEXT-EDITOR] AnnotationScaling-Property nicht gefunden") attrs = Rhino.DocObjects.ObjectAttributes() col = st.get("color") # [r,g,b] oder None if col is not None and len(col) >= 3: try: attrs.ColorSource = Rhino.DocObjects.ObjectColorSource.ColorFromObject attrs.ObjectColor = System.Drawing.Color.FromArgb( int(col[0]), int(col[1]), int(col[2])) except Exception as ex: print("[TEXT-EDITOR] color:", ex) attrs.SetUserString("dossier_text", "1") attrs.SetUserString("dossier_text_scaled", "1" if scale_flag else "0") # Runs als JSON persistieren — beim Re-Open kann der Editor # die ganze Struktur (Fonts/Sizes/Styles pro Segment) wieder # herstellen statt nur PlainText zu zeigen. if runs: try: import json attrs.SetUserString("dossier_text_runs", json.dumps(runs)) except Exception as ex: print("[TEXT-EDITOR] runs persist:", ex) # Edit-Mode: bestehenden TextEntity ersetzen statt neu hinzu if self._edit_obj_id is not None: try: old = doc.Objects.FindId(self._edit_obj_id) if old is not None: # Plane vom Original uebernehmen wenn nicht rotiert # explizit gewuenscht (sonst wandert der Text) if abs(rot_deg) < 1e-6: te.Plane = old.Geometry.Plane doc.Objects.Replace(self._edit_obj_id, te) doc.Objects.ModifyAttributes(self._edit_obj_id, attrs, True) else: doc.Objects.AddText(te, attrs) except Exception as ex: print("[TEXT-EDITOR] replace:", ex) doc.Objects.AddText(te, attrs) else: doc.Objects.AddText(te, attrs) doc.Views.Redraw() # Defaults speichern (ohne color/rotation/frame — die sind # situativ, nicht zu uebernehmen) text_create.save_settings(doc, { "font": st.get("font"), "size": st.get("size"), "bold": st.get("bold"), "italic": st.get("italic"), "underline": st.get("underline"), "align": st.get("align"), }) except Exception as ex: print("[TEXT-EDITOR] commit:", ex) # --------------------------------------------------------------------------- # Phase 2: Rich-Text-Runs (vom Frontend HTML-parsing) → Rhino-RTF. def _parse_color_to_rgb(c): """CSS-Farbe ('rgb(r,g,b)' | '#rrggbb' | '#rgb') → (r,g,b) Ints 0-255.""" if not c: return (0, 0, 0) c = c.strip() if c.startswith("rgb"): import re m = re.search(r"(\d+)\D+(\d+)\D+(\d+)", c) if m: return (int(m.group(1)), int(m.group(2)), int(m.group(3))) if c.startswith("#"): h = c.lstrip("#") if len(h) == 3: h = "".join(ch * 2 for ch in h) if len(h) == 6: return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)) return (0, 0, 0) def _rtf_escape(s): """RTF-Escape: \\, {, }, sowie non-ASCII via \\u-Notation.""" out = [] for ch in s: cp = ord(ch) if ch == "\\": out.append("\\\\") elif ch == "{": out.append("\\{") elif ch == "}": out.append("\\}") elif ch == "\n": out.append("\\par\n") elif cp < 128: out.append(ch) else: # Signed 16-bit → Rhinos RTF erwartet das so v = cp if cp < 0x8000 else cp - 0x10000 out.append("\\u{}?".format(v)) return "".join(out) def _runs_to_rtf(runs, default_font, base_size_m=0.20): """Konvertiert Format-Runs in Rhinos RTF-Dialekt. Runs ist Liste von dicts mit Keys text/font/bold/italic/underline/sup/sub/color/fontSizePx. base_size_m: TextEntity.TextHeight (in m). Frontend rendert 1m = 100px, also entspricht base_size_m * 100 dem "Standard" \\fs20 in RTF.""" if not runs: return None # Triviale Runs (alle plain, ein Font) → kein RTF noetig nontrivial = False for r in runs: if r.get("bold") or r.get("italic") or r.get("underline") \ or r.get("sup") or r.get("sub") or r.get("color") \ or r.get("fontSizePx") \ or (r.get("font") and r["font"] != default_font): nontrivial = True; break if not nontrivial: return None # ──────────────────────────────────────────────────────────────── # PASS 1: Runs verarbeiten + Fonts/Colors sammeln + RTF-Bodies bauen # ──────────────────────────────────────────────────────────────── fonts = [default_font] colors = [] def font_idx(f): if not f: return 0 if f not in fonts: fonts.append(f) return fonts.index(f) def color_idx(c): if not c: return 0 rgb = _parse_color_to_rgb(c) if rgb in colors: return colors.index(rgb) + 1 colors.append(rgb) return len(colors) BASE_PX = max(1.0, base_size_m * 100.0) def _escape_no_par(s): out = [] for ch in s: cp = ord(ch) if ch == "\\": out.append("\\\\") elif ch == "{": out.append("\\{") elif ch == "}": out.append("\\}") elif ch == "\n": out.append("\n") elif cp < 128: out.append(ch) else: v = cp if cp < 0x8000 else cp - 0x10000 out.append("\\u{}?".format(v)) return "".join(out) # Ansatz: jedes Text-Segment in eigene {}-Group mit lokalen Codes. # Zwischen Segmenten (und ueber Run-Grenzen hinweg) \\par fuer # Paragraph-Breaks. So bleibt Per-Run-Formatting (Groups isolieren # State) UND Mehrzeiligkeit (\\par ist Rhinos echter Linebreak). body_parts = [] pending_pars = 0 # Wieviele \\par muessen noch vor naechstem Segment def _emit_group(run, seg): codes = [] codes.append("\\f{}".format(font_idx(run.get("font") or default_font))) ci = color_idx(run.get("color")) if run.get("color") else 0 codes.append("\\cf{}".format(ci) if ci > 0 else "\\cf0") fsp = run.get("fontSizePx") if fsp and abs(fsp - BASE_PX) > 0.1: rtf_fs = max(2, int(round(20.0 * fsp / BASE_PX))) codes.append("\\fs{}".format(rtf_fs)) else: codes.append("\\fs20") codes.append("\\b" if run.get("bold") else "\\b0") codes.append("\\i" if run.get("italic") else "\\i0") codes.append("\\ul" if run.get("underline") else "\\ulnone") if run.get("sup"): codes.append("\\super") elif run.get("sub"): codes.append("\\sub") else: codes.append("\\nosupersub") body_parts.append("{{{} {}}}".format("".join(codes), _escape_no_par(seg))) def _flush_pars(n): # 1 \\par = einfacher Zeilenumbruch. N>1 \\par hintereinander # wuerde Rhino zu einem einzigen Umbruch collapsen — fuer # jede zusaetzliche Leerzeile schieben wir einen Space-Group # dazwischen, damit der leere Paragraph Inhalt hat. if n <= 0: return body_parts.append("\\par ") for _ in range(n - 1): body_parts.append("{ }\\par ") first_emitted = False for run in runs: raw = run.get("text") or "" segments = raw.split("\n") for i, seg in enumerate(segments): if i > 0: pending_pars += 1 if seg: if first_emitted: _flush_pars(pending_pars) pending_pars = 0 _emit_group(run, seg) first_emitted = True if first_emitted and pending_pars > 0: _flush_pars(pending_pars) # ──────────────────────────────────────────────────────────────── # PASS 2: RTF-Header mit JETZT vollstaendigen Tables + Body # ──────────────────────────────────────────────────────────────── parts = ["{\\rtf1\\ansi\\ansicpg1252\\deff0"] parts.append("{\\fonttbl") for i, f in enumerate(fonts): parts.append("{{\\f{}\\fnil\\fcharset0 {};}}".format(i, f)) parts.append("}") if colors: parts.append("{\\colortbl;") for r, g, b in colors: parts.append("\\red{}\\green{}\\blue{};".format(r, g, b)) parts.append("}") parts.append("\\pard") parts.extend(body_parts) parts.append("}") return "".join(parts) def open_with_frame(p1, p2, origin, width, height): """Frame-Pick-Workflow → Editor oeffnen, neuer Text erstellt.""" _ensure_double_click_hook() doc = Rhino.RhinoDoc.ActiveDoc settings = text_create.load_settings(doc) fonts = text_create.available_fonts() bridge = TextEditorBridge((origin, width, height, p1, p2), settings, fonts) sc.sticky["text_editor_bridge"] = bridge form = panel_base.open_satellite_window( "text_editor", title="Dossier Text", size=(640, 480), bridge=bridge, topmost=True) if form is not None: bridge.set_form(form) def open_for_edit(obj): """Bei Doppelklick auf einen DOSSIER-Text: Editor mit den bestehenden Settings + Text-Inhalt oeffnen. COMMIT macht REPLACE.""" _ensure_double_click_hook() doc = Rhino.RhinoDoc.ActiveDoc if doc is None or obj is None: return te = obj.Geometry if not isinstance(te, rg.TextEntity): return # Settings aus dem bestehenden Text lesen settings = text_create.load_settings(doc) try: settings["size"] = float(te.TextHeight) except Exception: pass try: if te.Font: settings["font"] = te.Font.QuartetName settings["bold"] = bool(te.Font.Bold) settings["italic"] = bool(te.Font.Italic) try: settings["underline"] = bool(te.Font.Underlined) except Exception: pass except Exception: pass try: h = te.TextHorizontalAlignment if h == Rhino.DocObjects.TextHorizontalAlignment.Center: settings["align"] = "center" elif h == Rhino.DocObjects.TextHorizontalAlignment.Right: settings["align"] = "right" else: settings["align"] = "left" except Exception: pass try: flag = obj.Attributes.GetUserString("dossier_text_scaled") if flag in ("0", "1"): settings["scaleWithModel"] = (flag == "1") else: for prop in ("AnnotationScalingEnabled", "IsAnnotationScalingEnabled", "ModelSpaceScalingEnabled"): if hasattr(te, prop): settings["scaleWithModel"] = bool(getattr(te, prop)) break except Exception: pass try: v = te.TextVerticalAlignment VA = Rhino.DocObjects.TextVerticalAlignment if v == VA.Middle: settings["valign"] = "middle" elif v == VA.Bottom: settings["valign"] = "bottom" else: settings["valign"] = "top" except Exception: pass try: if te.MaskEnabled: settings["maskType"] = "solid" if not te.MaskUsesViewportColor else "viewport" try: settings["maskMargin"] = float(te.MaskOffset) except Exception: pass try: mc = te.MaskColor settings["maskColor"] = [mc.R, mc.G, mc.B] except Exception: pass else: settings["maskType"] = "none" except Exception: pass initial_text = "" try: initial_text = te.PlainText or "" except Exception: pass # Runs aus UserString lesen (gespeichert beim letzten Save) initial_runs = None try: import json rj = obj.Attributes.GetUserString("dossier_text_runs") if rj: initial_runs = json.loads(rj) except Exception as ex: print("[TEXT-EDITOR] read runs:", ex) # Frame aus dem Text-BBox ableiten (fuer Dialog-Positionierung) p1 = te.Plane.Origin try: bb = te.GetBoundingBox(False) if bb.IsValid: p2 = rg.Point3d(bb.Max.X, bb.Min.Y, p1.Z) origin = rg.Point3d(bb.Min.X, bb.Max.Y, p1.Z) width = bb.Max.X - bb.Min.X height = bb.Max.Y - bb.Min.Y else: p2 = p1 origin = p1 width = 1.0; height = 0.5 except Exception: p2 = p1; origin = p1; width = 1.0; height = 0.5 fonts = text_create.available_fonts() bridge = TextEditorBridge( (origin, width, height, p1, p2), settings, fonts, edit_obj_id=obj.Id, initial_text=initial_text, initial_runs=initial_runs) sc.sticky["text_editor_bridge"] = bridge form = panel_base.open_satellite_window( "text_editor", title="Dossier Text (Bearbeiten)", size=(640, 480), bridge=bridge, topmost=True) if form is not None: bridge.set_form(form)