#! 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 class TextEditorBridge(panel_base.BaseBridge): def __init__(self, frame_data, settings, fonts): 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 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, }) 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 # Rich-Text (Phase 2) wenn vorhanden + nicht-trivial, sonst Plain rtf = _runs_to_rtf(runs, st.get("font") or "Helvetica") if runs else None applied_rtf = False if rtf: try: te.RichText = rtf applied_rtf = True except Exception as ex: print("[TEXT-EDITOR] RichText set fail:", ex) if not applied_rtf: te.PlainText = text try: te.TextHeight = float(st.get("size") or 0.2) except Exception: pass text_create._apply_font( te, st.get("font") or "Helvetica", st.get("bold"), st.get("italic"), st.get("underline")) text_create._apply_align(te, st.get("align") or "left") for attr in ("FormatWidth", "TextWidth", "MaskWidth"): try: setattr(te, attr, width); break except Exception: pass try: te.TextIsWrapped = True except Exception: try: te.TextWrap = True except Exception: pass # 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) try: mask_m = float(st.get("maskMargin") or 0) if mask_m > 0: te.MaskEnabled = True te.MaskOffset = mask_m te.MaskUsesViewportColor = True except Exception as ex: print("[TEXT-EDITOR] mask:", ex) # Horizontal-to-view (DrawForward = text steht immer zur Kamera) try: te.DrawForward = bool(st.get("horizontalToView")) except Exception: pass 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") 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): """Konvertiert Format-Runs in Rhinos RTF-Dialekt. Runs ist Liste von dicts mit Keys text/font/bold/italic/underline/sup/sub/color.""" 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("font") and r["font"] != default_font): nontrivial = True; break if not nontrivial: return None # Font-Tabelle + Color-Tabelle fonts = [default_font] colors = [] # nur explizit gesetzte (Index 1+) 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) parts = ["{\\rtf1\\ansi\\deff0"] parts.append("{\\fonttbl") for i, f in enumerate(fonts): parts.append("{{\\f{} {};}}".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") for run in runs: 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 if ci > 0: codes.append("\\cf{}".format(ci)) 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 = _rtf_escape(run.get("text") or "") parts.append("{} {}".format("".join(codes), body)) parts.append("}") return "".join(parts) def open_with_frame(p1, p2, origin, width, height): """Aufgerufen aus text_create.create_text() nach Frame-Pick. Oeffnet das React-WYSIWYG-Editor-Fenster (Topmost) neben dem Frame. Non-blocking — Bridge handlet die Eingabe + erstellt TextEntity bei COMMIT. """ 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)