#! python 3 # -*- coding: utf-8 -*- """ text_create.py Text-Erstellungs-Workflow mit Floating-Input-Box statt Rhino-Dialog. Flow: 1. User klickt Topbar-Button "Text einfuegen" 2. RhinoGet.GetPoint(): User picked Position im Viewport 3. Eto.Dialog mit TextBox erscheint neben Maus-Cursor 4. User tippt, Enter = commit, Escape = abbrechen 5. TextEntity wird mit Topbar-Settings (Font/Size/Bold/Italic) am gepickten Punkt erstellt Settings werden pro Dokument in doc.Strings["dossier_text_settings"] persistiert (JSON: font/size/bold/italic). """ import os import sys import json 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) _SETTINGS_KEY = "dossier_text_settings" _STYLES_KEY = "dossier_text_styles" _STYLE_ACTIVE_KEY = "dossier_text_style_active" _ALIGNS = ("left", "center", "right") _DEFAULTS = { "font": "Helvetica", "size": 0.20, # in Model-Units (m bei m-Doc, mm bei mm-Doc) "bold": False, "italic": False, "underline": False, "align": "left", } def _normalize(s): out = dict(_DEFAULTS) if isinstance(s, dict): for k, v in s.items(): if k not in _DEFAULTS: continue if k == "align" and v not in _ALIGNS: continue out[k] = v return out def load_settings(doc): if doc is None: return dict(_DEFAULTS) try: raw = doc.Strings.GetValue(_SETTINGS_KEY) if not raw: return dict(_DEFAULTS) return _normalize(json.loads(raw)) except Exception: return dict(_DEFAULTS) def save_settings(doc, partial): """Merged partial-Updates rein und persistiert.""" if doc is None or not isinstance(partial, dict): return cur = load_settings(doc) for k, v in partial.items(): if k not in _DEFAULTS: continue if k == "align" and v not in _ALIGNS: continue cur[k] = v try: doc.Strings.SetString(_SETTINGS_KEY, json.dumps(cur)) except Exception as ex: print("[TEXT] save settings:", ex) # --------------------------------------------------------------------------- # Text-Stile (Presets, analog mass_style): doc.Strings JSON-Liste mit # benannten Settings + aktiver Style-ID. def list_styles(doc): if doc is None: return [] try: raw = doc.Strings.GetValue(_STYLES_KEY) if not raw: return [] items = json.loads(raw) if not isinstance(items, list): return [] out = [] for it in items: if not isinstance(it, dict): continue norm = _normalize(it) norm["id"] = it.get("id") or ("ts_" + uuid.uuid4().hex[:8]) norm["name"] = it.get("name") or "Stil" out.append(norm) return out except Exception as ex: print("[TEXT] list_styles:", ex) return [] def get_active_style_id(doc): if doc is None: return None try: return doc.Strings.GetValue(_STYLE_ACTIVE_KEY) or None except Exception: return None def set_active_style_id(doc, sid): if doc is None: return try: doc.Strings.SetString(_STYLE_ACTIVE_KEY, sid or "") except Exception: pass def save_style(doc, name, settings=None): """Speichert aktuelle Settings als benannten Style. Returns die ID.""" if doc is None or not name: return None items = list_styles(doc) # Existing same-name → update sid = None for it in items: if it["name"] == name: sid = it["id"]; break norm = _normalize(settings if settings is not None else load_settings(doc)) norm["id"] = sid or ("ts_" + uuid.uuid4().hex[:8]) norm["name"] = name if sid: items = [norm if it["id"] == sid else it for it in items] else: items.append(norm) try: doc.Strings.SetString(_STYLES_KEY, json.dumps(items)) except Exception as ex: print("[TEXT] save_style:", ex) return norm["id"] def delete_style(doc, sid): if doc is None or not sid: return items = [it for it in list_styles(doc) if it.get("id") != sid] try: doc.Strings.SetString(_STYLES_KEY, json.dumps(items)) except Exception: pass if get_active_style_id(doc) == sid: set_active_style_id(doc, "") def apply_style(doc, sid): """Wendet einen Style als aktive Defaults an + (wenn Selektion) auch auf die selektierten TextEntities.""" if doc is None or not sid: return items = list_styles(doc) style = next((it for it in items if it["id"] == sid), None) if not style: return set_active_style_id(doc, sid) # Defaults aus Style schreiben (ohne id/name) patch = {k: style[k] for k in style if k in _DEFAULTS} save_settings(doc, patch) apply_settings_to_selection(doc, patch) _FONT_FALLBACK = [ "Helvetica", "Helvetica Neue", "Arial", "Arial Narrow", "Times New Roman", "Times", "Courier", "Courier New", "Menlo", "Monaco", "Verdana", "Geneva", "Lucida Grande", "Avenir", "Avenir Next", "Optima", "Palatino", ] def available_fonts(): """Sortierte Liste verfuegbarer System-Fonts. Mit Fallback auf haeufige Mac-Fonts falls die Rhino-API nichts liefert.""" try: names = Rhino.DocObjects.Font.AvailableFontFaceNames() out = sorted({str(n) for n in names if n}) if out: return out except Exception as ex: print("[TEXT] available_fonts:", ex) return list(_FONT_FALLBACK) def _prompt_for_text(default=""): """Nativer Rhino-Dialog fuer Text-Eingabe via ShowEditBox. Wirkt cross-platform (Mac+Windows), kein Eto-Modal-Bug. Returns text oder None bei Abbruch/leer.""" try: rc, text = Rhino.UI.Dialogs.ShowEditBox( "Text", "Text:", default or "", False) if not rc: return None text = (text or "").strip() return text or None except Exception as ex: print("[TEXT] ShowEditBox:", ex) return None def _frame_editor_dialog(initial=""): """InDesign-Stil Editor: multi-line TextArea in Eto-Dialog. User tippt rein, OK → Text-String zurueck. Esc/Cancel → None.""" import Eto.Forms as forms import Eto.Drawing as drawing dlg = forms.Dialog() dlg.Title = "Text einfuegen" dlg.Resizable = True dlg.MinimumSize = drawing.Size(360, 180) dlg.Padding = drawing.Padding(10) ta = forms.TextArea() ta.AcceptsReturn = True ta.AcceptsTab = False ta.Wrap = True ta.Text = initial or "" ta.Font = drawing.Font("Helvetica", 13) ta.Width = 380 ta.Height = 160 result = {"text": None, "committed": False} ok_btn = forms.Button() ok_btn.Text = "Einfuegen" cancel_btn = forms.Button() cancel_btn.Text = "Abbrechen" def on_ok(s, e): result["text"] = ta.Text or "" result["committed"] = True try: dlg.Close() except Exception: pass def on_cancel(s, e): try: dlg.Close() except Exception: pass ok_btn.Click += on_ok cancel_btn.Click += on_cancel # Layout: TextArea ueber volle Breite, Buttons rechtsbuendig unten layout = forms.DynamicLayout() layout.Spacing = drawing.Size(6, 6) layout.BeginVertical() layout.AddRow(ta) layout.EndVertical() layout.BeginVertical() layout.BeginHorizontal() layout.Add(None, True, False) layout.Add(cancel_btn) layout.Add(ok_btn) layout.EndHorizontal() layout.EndVertical() dlg.Content = layout try: dlg.DefaultButton = ok_btn dlg.AbortButton = cancel_btn except Exception: pass try: parent = Rhino.UI.RhinoEtoApp.MainWindow if parent is not None: dlg.ShowModal(parent) else: dlg.ShowModal() except Exception as ex: print("[TEXT] frame editor:", ex) return None if not result["committed"]: return None return (result["text"] or "").strip() or None def _pick_text_frame(): """Picked 2 Ecken eines Text-Feldes (Rechteck in der XY-Ebene des ersten Punkts). Returns (origin_pt, width, height) oder None.""" try: gp = Rhino.Input.Custom.GetPoint() gp.SetCommandPrompt("Erste Ecke des Text-Feldes") gp.Get() if gp.CommandResult() != Rhino.Commands.Result.Success: return None p1 = gp.Point() gp2 = Rhino.Input.Custom.GetPoint() gp2.SetCommandPrompt("Gegenueberliegende Ecke") try: gp2.SetBasePoint(p1, True) except Exception: pass try: gp2.DrawLineFromPoint(p1, True) except Exception: pass gp2.Get() if gp2.CommandResult() != Rhino.Commands.Result.Success: return None p2 = gp2.Point() min_x = min(p1.X, p2.X); max_x = max(p1.X, p2.X) min_y = min(p1.Y, p2.Y); max_y = max(p1.Y, p2.Y) width = max_x - min_x height = max_y - min_y if width < 1e-6 or height < 1e-6: print("[TEXT] frame zu klein"); return None # Origin = obere linke Ecke (Text laeuft von dort nach unten) origin = rg.Point3d(min_x, max_y, p1.Z) return (origin, width, height) except Exception as ex: print("[TEXT] pick frame:", ex) return None def _apply_font(te, face, bold, italic): """Setzt Font auf TextEntity. FromQuartetProperties zuerst (sauberer — konstruiert Font-Objekt direkt mit (face, bold, italic), unabhaengig von einem evtl. schon im FontTable existierenden Eintrag mit gleichem Namen aber anderen Flags). Fallback FindOrCreate. `face` wird normalisiert (Bold/Italic-Suffixe entfernen) damit QuartetNames wie "Helvetica-Bold" nicht den Quartet-Lookup blockieren.""" face = str(face or "Helvetica").strip() bold = bool(bold) italic = bool(italic) # Suffix-Stripping (haeufige Endungen die manche Fonts in der QuartetName # haben — wir wollen die Base-Family) for suffix in ("-BoldItalic", "-BoldOblique", "-Bold", "-Italic", "-Oblique", " Bold Italic", " Bold Oblique", " Bold", " Italic", " Oblique"): if face.endswith(suffix): face = face[:-len(suffix)].strip() break print("[TEXT] _apply_font face={!r} bold={} italic={}".format(face, bold, italic)) # Pfad 1: FromQuartetProperties (direkter, keine FontTable-Cache-Bugs) try: font = Rhino.DocObjects.Font.FromQuartetProperties(face, bold, italic) if font is not None: te.Font = font return True except Exception as ex: print("[TEXT] FromQuartet:", ex) # Pfad 2: doc.Fonts.FindOrCreate try: doc = Rhino.RhinoDoc.ActiveDoc font_idx = doc.Fonts.FindOrCreate(face, bold, italic) if font_idx >= 0: font = doc.Fonts[font_idx] if font is not None: te.Font = font return True except Exception as ex: print("[TEXT] FindOrCreate:", ex) return False def _selected_text_objects(doc): """Liefert Liste der selektierten TextEntity-Objekte.""" out = [] if doc is None: return out try: for obj in doc.Objects.GetSelectedObjects(False, False): try: if isinstance(obj.Geometry, rg.TextEntity): out.append(obj) except Exception: pass except Exception: pass return out def _apply_align(te, align): """Setzt TextHorizontalAlignment.""" try: m = { "left": Rhino.DocObjects.TextHorizontalAlignment.Left, "center": Rhino.DocObjects.TextHorizontalAlignment.Center, "right": Rhino.DocObjects.TextHorizontalAlignment.Right, } if align in m: te.TextHorizontalAlignment = m[align] return True except Exception as ex: print("[TEXT] apply align:", ex) return False def apply_settings_to_selection(doc, patch): """Wendet font/size/bold/italic/align auf alle selektierten TextEntities an. Returns Anzahl der geaenderten Objekte.""" if doc is None or not isinstance(patch, dict): return 0 selected = _selected_text_objects(doc) if not selected: return 0 n = 0 for obj in selected: try: te = obj.Geometry.Duplicate() if "size" in patch: try: te.TextHeight = float(patch["size"]) except Exception: pass # Font: bei jeder Aenderung neu setzen (face+bold+italic kombiniert) if any(k in patch for k in ("font", "bold", "italic")): cur = te.Font try: cur_face = cur.QuartetName if cur else "Helvetica" except Exception: cur_face = "Helvetica" try: cur_bold = bool(cur.Bold) if cur else False except Exception: cur_bold = False try: cur_italic = bool(cur.Italic) if cur else False except Exception: cur_italic = False face = patch.get("font") or cur_face bold = patch["bold"] if "bold" in patch else cur_bold italic = patch["italic"] if "italic" in patch else cur_italic _apply_font(te, face, bool(bold), bool(italic)) if "align" in patch and patch["align"] in _ALIGNS: _apply_align(te, patch["align"]) # underline: derzeit nicht auf TextEntity-Font-API mappbar. # Wird in den Settings gespeichert, aber visuell (noch) nicht angewendet. doc.Objects.Replace(obj.Id, te) n += 1 except Exception as ex: print("[TEXT] apply selection:", ex) if n > 0: try: doc.Views.Redraw() except Exception: pass return n def read_selection_settings(doc): """Wenn TextEntities selektiert: liefert die Settings des ersten.""" sel = _selected_text_objects(doc) if not sel: return None try: te = sel[0].Geometry font = te.Font face = font.QuartetName if font else "Helvetica" bold = bool(font.Bold) if font else False italic = bool(font.Italic) if font else False align = "left" try: h = te.TextHorizontalAlignment if h == Rhino.DocObjects.TextHorizontalAlignment.Center: align = "center" elif h == Rhino.DocObjects.TextHorizontalAlignment.Right: align = "right" except Exception: pass return { "font": face, "size": float(te.TextHeight), "bold": bold, "italic": italic, "underline": False, # derzeit nicht lesbar "align": align, } except Exception as ex: print("[TEXT] read selection:", ex) return None def create_text(): """InDesign-Stil: User picked 2 Ecken (Frame) → Multi-Line-Editor- Dialog → TextEntity wird im Frame mit Text-Wrap erstellt. Frame-Breite bestimmt den Word-Wrap.""" doc = Rhino.RhinoDoc.ActiveDoc if doc is None: return settings = load_settings(doc) frame = _pick_text_frame() if frame is None: return origin, width, height = frame text = _frame_editor_dialog() if not text: return try: te = rg.TextEntity() te.Plane = rg.Plane(origin, rg.Vector3d.ZAxis) te.PlainText = text try: te.TextHeight = float(settings.get("size", 0.20)) except Exception: pass _apply_font(te, settings.get("font") or "Helvetica", settings.get("bold"), settings.get("italic")) _apply_align(te, settings.get("align") or "left") # Text-Wrap an der Frame-Breite (Property-Namen variieren je nach # RhinoCommon-Version — best-effort, schluckt Fehler). 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 doc.Objects.AddText(te) doc.Views.Redraw() except Exception as ex: print("[TEXT] create:", ex)