Files
DOSSIER/rhino/text_create.py
T
karim 26c7d9e67d Text-Editor: Rhino RTF-Dialekt + Round-Trip + Oberleiste-Sync
Lange Iteration mit dem Rhino TextEntity-RTF-Parser (siehe MEMORY:
rhino_textentity_rtf_limits.md). Finale Form:

- RTF-Body: per-Segment {\fN\cfN\b\i\ulnone seg}-Groups, \par
  zwischen Groups als Linebreak, { }-Space-Group fuer Leerzeilen
  (Rhino collapsed sonst aufeinanderfolgende \par). \fs (Font-Size)
  ist NICHT unterstuetzt → eine Size pro TextEntity (global).
- htmlToRuns: emittiert \n VOR Block-Elementen wenn schon Content
  davor — fixt nested <div>A<div>B</div></div> die sonst A+B ohne
  Trenner als ein Run liefern.
- Round-Trip-Erhaltung: editor.innerHTML 1:1 als UserString
  "dossier_text_html" persistiert, beim Reopen direkt gesetzt
  (kein runsToHtml-Konvertieren das Zeilen verlieren kann).
- Oberleiste-Editing: in-place modify von obj.Geometry + Commit-
  Changes statt Duplicate+Replace (Mac Rhino gibt False zurueck
  bei RichText-Klonen). Plus _patch_rtf_b_i_ul: regex-flippt
  \b/\b0, \i/\i0, \ul/\ulnone global in der RTF damit Bold/Italic/
  Underline OFF in der Oberleiste auch wirklich auf DOSSIER-Texte
  greift (per-Segment-Codes wuerden te.Font-Aenderung sonst
  uebersteuern).
- Stil-ID am Text persistiert + von read_selection_settings
  zurueckgelesen → Stil-Dropdown spiegelt Selektion.
- Editor neu: V-Align (Top/Middle/Bottom), Mask-Type (None/Viewport/
  Solid) mit Farb-Picker, Case-Transform (upper/lower/capitalize/
  invert), Masstaeblich-Toggle (AnnotationScalingEnabled),
  Symbol-Popover, Frame-Optionen.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 23:56:16 +02:00

990 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#! 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.
# Default-Stile fuer Architektur-Workflow — werden bei erstem list_styles
# automatisch erzeugt wenn das Doc noch keine eigenen hat.
_DEFAULT_STYLES = [
{"name": "Titel", "font": "Helvetica", "size": 0.40,
"bold": True, "italic": False, "underline": False, "align": "left"},
{"name": "Heading 1", "font": "Helvetica", "size": 0.30,
"bold": True, "italic": False, "underline": False, "align": "left"},
{"name": "Heading 2", "font": "Helvetica", "size": 0.20,
"bold": True, "italic": False, "underline": False, "align": "left"},
{"name": "Paragraph (Helvetica)", "font": "Helvetica", "size": 0.15,
"bold": False, "italic": False, "underline": False, "align": "left"},
{"name": "Paragraph (Georgia)", "font": "Georgia", "size": 0.15,
"bold": False, "italic": False, "underline": False, "align": "left"},
{"name": "Notiz", "font": "Helvetica", "size": 0.10,
"bold": False, "italic": True, "underline": False, "align": "left"},
{"name": "Bildlegende", "font": "Helvetica", "size": 0.08,
"bold": False, "italic": True, "underline": False, "align": "left"},
]
def list_styles(doc):
if doc is None: return []
try:
raw = doc.Strings.GetValue(_STYLES_KEY)
if not raw:
# Seed Defaults bei erstem Zugriff
seeded = [_normalize(s) for s in _DEFAULT_STYLES]
for i, s in enumerate(seeded):
s["id"] = "ts_default_" + str(i)
s["name"] = _DEFAULT_STYLES[i]["name"]
try:
doc.Strings.SetString(_STYLES_KEY, json.dumps(seeded))
except Exception: pass
return seeded
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)
# styleId mitschicken damit apply_settings_to_selection ihn als
# UserString an die Texte haengt — fuer "Stil aktiv"-Anzeige
patch["__style_id__"] = sid
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 _dossier_text_editor(p1, p2, settings, fonts, initial=""):
"""DOSSIER Custom Text-Editor: eigene Toolbar (Font, Size, Farbe,
B/I/U, Align), Symbol-Palette mit Sonderzeichen, Multi-Line-TextArea.
Returns dict {text, font, size, bold, italic, underline, align, color}
oder None bei Abbruch. color ist (r,g,b)-Tuple oder None (= Layer-Farbe).
"""
import Eto.Forms as forms
import Eto.Drawing as drawing
import System
# Frame-Position fuer Dialog-Positionierung (rechts neben dem Frame)
sx = sy = None
try:
view = Rhino.RhinoDoc.ActiveDoc.Views.ActiveView
vp = view.ActiveViewport
c1 = vp.WorldToClient(p1)
c2 = vp.WorldToClient(p2)
view_rect = view.ScreenRectangle
fx = view_rect.X + int(min(c1.X, c2.X))
fy = view_rect.Y + int(min(c1.Y, c2.Y))
fw = abs(int(c2.X - c1.X))
sx = int(fx + fw + 20)
sy = int(fy)
except Exception as ex:
print("[TEXT] viewport-coords:", ex)
# Editor-State (mutable durch Closures)
st = {
"text": initial or "",
"font": settings.get("font") or "Helvetica",
"size": float(settings.get("size") or 0.2),
"bold": bool(settings.get("bold")),
"italic": bool(settings.get("italic")),
"underline": bool(settings.get("underline")),
"align": settings.get("align") or "left",
"color": None, # None = Layer-Farbe
}
result = {"committed": False, "state": None}
dlg = forms.Dialog()
dlg.Title = "Dossier Text"
dlg.Resizable = True
dlg.Padding = drawing.Padding(10)
try: dlg.MinimumSize = drawing.Size(580, 440)
except Exception: pass
if sx is not None and sy is not None:
try: dlg.Location = drawing.Point(sx, sy)
except Exception: pass
# === TextArea zuerst (wird von Symbol-Inserts angesprochen) ===
ta = forms.TextArea()
ta.AcceptsReturn = True
ta.AcceptsTab = False
ta.Wrap = True
ta.Text = st["text"]
try: ta.Font = drawing.Font(st["font"], 14)
except Exception: pass
try: ta.Size = drawing.Size(560, 240)
except Exception: pass
def _update_ta_font():
try: ta.Font = drawing.Font(st["font"], 14)
except Exception: pass
# === Toolbar Row 1: Font / Size / Color ===
font_dd = forms.DropDown()
for f in fonts: font_dd.Items.Add(f)
try:
for i, f in enumerate(fonts):
if f == st["font"]: font_dd.SelectedIndex = i; break
except Exception: pass
def on_font_change(s, e):
try:
idx = font_dd.SelectedIndex
if idx >= 0 and idx < len(fonts):
st["font"] = fonts[idx]
_update_ta_font()
except Exception: pass
font_dd.SelectedIndexChanged += on_font_change
size_input = forms.NumericStepper()
size_input.MinValue = 0.001
size_input.MaxValue = 100.0
size_input.Increment = 0.05
size_input.DecimalPlaces = 3
size_input.Value = st["size"]
def on_size_change(s, e):
try: st["size"] = float(size_input.Value)
except Exception: pass
size_input.ValueChanged += on_size_change
color_picker = forms.ColorPicker()
try: color_picker.AllowAlpha = False
except Exception: pass
try: color_picker.Value = drawing.Colors.Black
except Exception: pass
def on_color_change(s, e):
try:
c = color_picker.Value
st["color"] = (int(c.Rb), int(c.Gb), int(c.Bb))
except Exception: pass
color_picker.ValueChanged += on_color_change
color_reset = forms.Button()
color_reset.Text = "Layer"
color_reset.ToolTip = "Farbe der Ebene benutzen"
def on_color_reset(s, e):
st["color"] = None
try: color_picker.Value = drawing.Colors.Black
except Exception: pass
color_reset.Click += on_color_reset
# === Toolbar Row 2: B I U + Align ===
bold_check = forms.CheckBox()
bold_check.Text = "B"
bold_check.Checked = st["bold"]
bold_check.CheckedChanged += lambda s, e: st.update(bold=bool(bold_check.Checked))
italic_check = forms.CheckBox()
italic_check.Text = "I"
italic_check.Checked = st["italic"]
italic_check.CheckedChanged += lambda s, e: st.update(italic=bool(italic_check.Checked))
underline_check = forms.CheckBox()
underline_check.Text = "U"
underline_check.Checked = st["underline"]
underline_check.CheckedChanged += lambda s, e: st.update(underline=bool(underline_check.Checked))
align_left = forms.RadioButton()
align_left.Text = "L"
align_center = forms.RadioButton(align_left)
align_center.Text = "C"
align_right = forms.RadioButton(align_left)
align_right.Text = "R"
if st["align"] == "center": align_center.Checked = True
elif st["align"] == "right": align_right.Checked = True
else: align_left.Checked = True
align_left.CheckedChanged += lambda s, e: (st.update(align="left") if align_left.Checked else None)
align_center.CheckedChanged += lambda s, e: (st.update(align="center") if align_center.Checked else None)
align_right.CheckedChanged += lambda s, e: (st.update(align="right") if align_right.Checked else None)
# === Symbol-Palette (Unicode-Buttons, inserten am Cursor) ===
# Architektur, Mathe, Pfeile, Auszeichnungen
SYMBOLS = [
'', 'Ø', '', '°', '±', '×', '÷',
'²', '³', '½', '¼', '¾', '', '',
'', '', '', '', '', '', '', 'π', 'µ',
'', '', '', '', '', '',
'', '·', '', '', '', '',
'', '', '§', '', '©', '®', '',
]
def insert_at_cursor(s_char):
try:
pos = ta.CaretIndex
txt = ta.Text or ""
ta.Text = txt[:pos] + s_char + txt[pos:]
try: ta.CaretIndex = pos + len(s_char)
except Exception: pass
try: ta.Focus()
except Exception: pass
except Exception as ex:
print("[TEXT] insert:", ex)
def make_sym_handler(s_char):
return lambda sender, e: insert_at_cursor(s_char)
sym_panel = forms.DynamicLayout()
sym_panel.Spacing = drawing.Size(2, 2)
# 7 Symbole pro Reihe
PER_ROW = 12
for row_start in range(0, len(SYMBOLS), PER_ROW):
sym_panel.BeginHorizontal()
for sym in SYMBOLS[row_start:row_start + PER_ROW]:
btn = forms.Button()
btn.Text = sym
btn.MinimumSize = drawing.Size(28, 24)
btn.ToolTip = "Einfuegen: " + sym
btn.Click += make_sym_handler(sym)
sym_panel.Add(btn)
sym_panel.Add(None, True, False)
sym_panel.EndHorizontal()
# === Hochstellen / Tiefstellen (Unicode-Konvertierung der Selektion
# bzw. der zuletzt eingegebenen Zahl. Einfacher Quick-Insert: ²³ etc.)
SUP_MAP = {'0':'','1':'¹','2':'²','3':'³','4':'','5':'','6':'','7':'','8':'','9':'','+':'','-':'','=':''}
SUB_MAP = {'0':'','1':'','2':'','3':'','4':'','5':'','6':'','7':'','8':'','9':'','+':'','-':'','=':''}
def convert_selection(mapping):
try:
sel = ta.SelectedText or ""
if not sel: return
converted = "".join(mapping.get(c, c) for c in sel)
txt = ta.Text or ""
start = ta.Selection.Start
ta.Text = txt[:start] + converted + txt[start + len(sel):]
try: ta.Focus()
except Exception: pass
except Exception as ex:
print("[TEXT] convert:", ex)
sup_btn = forms.Button()
sup_btn.Text = ""
sup_btn.ToolTip = "Markierte Ziffern hochstellen"
sup_btn.Click += lambda s, e: convert_selection(SUP_MAP)
sub_btn = forms.Button()
sub_btn.Text = "x₂"
sub_btn.ToolTip = "Markierte Ziffern tiefstellen"
sub_btn.Click += lambda s, e: convert_selection(SUB_MAP)
# === Buttons unten ===
ok_btn = forms.Button()
ok_btn.Text = "Einfuegen"
cancel_btn = forms.Button()
cancel_btn.Text = "Abbrechen"
def on_ok(s, e):
st["text"] = ta.Text or ""
result["committed"] = True
result["state"] = dict(st)
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
# Cmd/Ctrl+Enter im TextArea = OK
def on_ta_keydown(sender, e):
try:
is_cmd = (e.Modifiers == forms.Keys.Application or
e.Modifiers == forms.Keys.Control)
except Exception:
is_cmd = False
if e.Key == forms.Keys.Enter and is_cmd:
on_ok(sender, e); e.Handled = True
elif e.Key == forms.Keys.Escape:
on_cancel(sender, e); e.Handled = True
ta.KeyDown += on_ta_keydown
# === Layout zusammenbauen ===
def lbl(text, width=None):
l = forms.Label()
l.Text = text
try: l.VerticalAlignment = forms.VerticalAlignment.Center
except Exception: pass
return l
layout = forms.DynamicLayout()
layout.Spacing = drawing.Size(8, 8)
# Row 1: Font + Size + Color
layout.BeginHorizontal()
layout.Add(lbl("Schrift"))
layout.Add(font_dd, True, False)
layout.Add(lbl("Grösse"))
layout.Add(size_input)
layout.Add(lbl("Farbe"))
layout.Add(color_picker)
layout.Add(color_reset)
layout.EndHorizontal()
# Row 2: B I U + Align + Sup/Sub
layout.BeginHorizontal()
layout.Add(bold_check)
layout.Add(italic_check)
layout.Add(underline_check)
layout.Add(lbl(" "))
layout.Add(align_left)
layout.Add(align_center)
layout.Add(align_right)
layout.Add(lbl(" "))
layout.Add(sup_btn)
layout.Add(sub_btn)
layout.Add(None, True, False)
layout.EndHorizontal()
# Symbol-Palette
layout.BeginVertical()
layout.AddRow(lbl("Sonderzeichen"))
layout.AddRow(sym_panel)
layout.EndVertical()
# TextArea
layout.BeginVertical()
layout.AddRow(ta)
layout.EndVertical()
# Buttons unten
layout.BeginHorizontal()
layout.Add(None, True, False)
layout.Add(cancel_btn)
layout.Add(ok_btn)
layout.EndHorizontal()
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] dialog:", ex)
return None
if not result["committed"]: return None
return result["state"]
def _inline_editor(p1, p2, initial=""):
"""Editor-Dialog mit Multi-Line-TextArea, positioniert NEBEN dem
gepickten Frame (statt zentriert). Eto.Dialog.ShowModal — reliable
auf Mac/Win. Returns Text-String oder None."""
import Eto.Forms as forms
import Eto.Drawing as drawing
# Frame-Position im Screen ermitteln (fuer Dialog-Positionierung)
sx = sy = None
sw = max(360, 280)
sh = max(180, 150)
try:
view = Rhino.RhinoDoc.ActiveDoc.Views.ActiveView
vp = view.ActiveViewport
# WorldToClient → System.Drawing.Point (viewport-lokale Pixel)
c1 = vp.WorldToClient(p1)
c2 = vp.WorldToClient(p2)
view_rect = view.ScreenRectangle # absolute Viewport-Position
fx = view_rect.X + int(min(c1.X, c2.X))
fy = view_rect.Y + int(min(c1.Y, c2.Y))
fw = abs(int(c2.X - c1.X))
fh = abs(int(c2.Y - c1.Y))
sx = int(fx + fw + 20)
sy = int(fy)
sw = max(360, fw)
sh = max(180, fh)
except Exception as ex:
print("[TEXT] viewport-coords:", ex)
dlg = forms.Dialog()
dlg.Title = "Text einfuegen"
dlg.Resizable = True
dlg.Padding = drawing.Padding(8)
try: dlg.MinimumSize = drawing.Size(360, 180)
except Exception: pass
try: dlg.ClientSize = drawing.Size(int(sw), int(sh))
except Exception: pass
# Position neben dem Frame (falls verfuegbar)
if sx is not None and sy is not None:
try: dlg.Location = drawing.Point(sx, sy)
except Exception: pass
ta = forms.TextArea()
ta.AcceptsReturn = True
ta.AcceptsTab = False
ta.Wrap = True
ta.Text = initial or ""
try: ta.Font = drawing.Font("Helvetica", 13)
except Exception: pass
# Explizit Groesse setzen — DynamicLayout expandiert TextArea sonst
# nicht zuverlaessig (zeigte sich als 1-Zeilen-Streifen)
try:
ta.Size = drawing.Size(int(sw) - 20, max(120, int(sh) - 70))
except Exception: pass
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
# Cmd/Ctrl+Enter Shortcut im TextArea
def on_keydown(sender, e):
try:
is_cmd = (e.Modifiers == forms.Keys.Application or
e.Modifiers == forms.Keys.Control)
except Exception:
is_cmd = False
if e.Key == forms.Keys.Enter and is_cmd:
on_ok(sender, e); e.Handled = True
elif e.Key == forms.Keys.Escape:
on_cancel(sender, e); e.Handled = True
ta.KeyDown += on_keydown
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] dialog show:", 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 mit Live-Rechteck-Vorschau
(wie Rhinos _Rectangle). Returns (p1, p2, origin, width, height)
oder None bei Abbruch."""
try:
import System
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
# Live Rechteck-Preview via DynamicDraw
rect_color = System.Drawing.Color.FromArgb(180, 95, 168, 150) # petrol
def _draw_rect(sender, e):
try:
pt = e.CurrentPoint
z = p1.Z
corners = [
p1,
Rhino.Geometry.Point3d(pt.X, p1.Y, z),
Rhino.Geometry.Point3d(pt.X, pt.Y, z),
Rhino.Geometry.Point3d(p1.X, pt.Y, z),
p1,
]
e.Display.DrawDottedPolyline(corners, rect_color, False)
except Exception: pass
gp2.DynamicDraw += _draw_rect
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 = rg.Point3d(min_x, max_y, p1.Z)
return (p1, p2, origin, width, height)
except Exception as ex:
print("[TEXT] pick frame:", ex)
return None
def _patch_rtf_b_i_ul(rt, bold, italic, underline):
"""Patcht alle Bold/Italic/Underline-Codes in der RTF auf den
gewuenschten globalen State. Erhaelt aber per-Segment Font/Color/
Sup/Sub.
Wird vom Oberleiste-Path benutzt: die te.Font-Aenderung greift bei
DOSSIER-Texten nicht (RTF-per-Segment-Codes ueberschreiben sie).
Indem wir die Codes selber auf den globalen Toggle setzen, wirken
Bold/Italic/Underline OFF auch tatsaechlich auf den ganzen Text."""
import re
if not rt: return rt
# Bold: \b oder \b0 als komplettes Token (nicht gefolgt von alpha/digit,
# damit z.B. \bullet nicht versehentlich matched)
pat_b = re.compile(r'\\b0?(?![a-zA-Z0-9])')
rt = pat_b.sub(lambda m: '\\b' if bold else '\\b0', rt)
# Italic
pat_i = re.compile(r'\\i0?(?![a-zA-Z0-9])')
rt = pat_i.sub(lambda m: '\\i' if italic else '\\i0', rt)
# Underline: \ul (on) oder \ulnone (off) — nicht gefolgt von alpha
pat_ul = re.compile(r'\\ul(?:none)?(?![a-zA-Z])')
rt = pat_ul.sub(lambda m: '\\ul' if underline else '\\ulnone', rt)
return rt
def _apply_font(te, face, bold, italic, underline=False):
"""Setzt Font auf TextEntity. Mehrere Konstruktor-Pfade fuer
verschiedene RhinoCommon-Versionen:
- 5-arg Font(face,bold,italic,underline,strike) — unterstuetzt underline
- FromQuartetProperties — keine underline
- FontTable.FindOrCreate — keine underline
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)
underline = bool(underline)
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={} underline={}".format(
face, bold, italic, underline))
# Pfad 1: 5-arg Font-Konstruktor mit echten Enums (Python.NET 3 erlaubt
# keinen impliziten bool→Enum-Cast mehr). Underline-Support nur hier.
try:
FW = Rhino.DocObjects.Font.FontWeight
FS = Rhino.DocObjects.Font.FontStyle
weight = FW.Bold if bold else FW.Normal
style = FS.Italic if italic else FS.Upright
font = Rhino.DocObjects.Font(face, weight, style, underline, False)
if font is not None:
te.Font = font
return True
except Exception as ex:
if underline: print("[TEXT] Font(5-arg) fail:", ex)
# Pfad 2: 3-arg Font-Konstruktor
try:
font = Rhino.DocObjects.Font(face, bold, italic)
if font is not None:
te.Font = font
return True
except Exception: pass
# Pfad 3: FromQuartetProperties
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 4: 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_valign(te, valign):
"""Setzt TextVerticalAlignment (Top/Middle/Bottom)."""
try:
m = {
"top": Rhino.DocObjects.TextVerticalAlignment.Top,
"middle": Rhino.DocObjects.TextVerticalAlignment.Middle,
"bottom": Rhino.DocObjects.TextVerticalAlignment.Bottom,
}
if valign in m:
te.TextVerticalAlignment = m[valign]
return True
except Exception as ex:
print("[TEXT] apply valign:", 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
import System
n = 0
for obj in selected:
try:
old = obj.Geometry
cur = old.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
try: cur_underline = bool(cur.Underlined) if cur else False
except Exception: cur_underline = 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
underline = patch["underline"] if "underline" in patch else cur_underline
size = float(patch["size"]) if "size" in patch else float(old.TextHeight)
align = patch["align"] if patch.get("align") in _ALIGNS else None
is_dossier = False
try:
is_dossier = obj.Attributes.GetUserString("dossier_text") == "1"
except Exception: pass
# IN-PLACE Modifikation der Live-Geometry (kein Duplicate,
# keine fresh-Entity — Mac Rhino hat Probleme mit Replace
# auf RichText-Entities die nicht aus dem Doc kommen).
old.TextHeight = size
_apply_font(old, face, bool(bold), bool(italic), bool(underline))
if align:
_apply_align(old, align)
# DOSSIER-Texte: die RTF hat per-Segment Codes (\b0, \i0,
# \ulnone) die die te.Font-Aenderung uebersteuern. Wir
# patchen die Codes global damit Bold/Italic/Underline OFF
# auch wirklich greifen.
if is_dossier:
try:
rt = old.RichText
if rt:
new_rt = _patch_rtf_b_i_ul(
rt, bool(bold), bool(italic), bool(underline))
if new_rt != rt:
old.RichText = new_rt
except Exception as ex:
print("[TEXT] RTF patch fail:", ex)
# Style-ID am Text persistieren wenn ueber apply_style
# appliziert (Oberleiste-Anzeige "Stil aktiv" bei Selektion)
sid = patch.get("__style_id__")
if sid:
try: obj.Attributes.SetUserString("dossier_text_style_id", sid)
except Exception: pass
# CommitChanges ist die RhinoObject-API um Aenderungen an der
# in-place modifizierten Geometry persistent zu machen.
try:
ok = obj.CommitChanges()
print("[TEXT] CommitChanges: {} (dossier={})".format(ok, is_dossier))
except Exception as ex:
print("[TEXT] CommitChanges fail:", ex)
ok = False
# Falls CommitChanges nicht greift → Replace als Fallback
if not ok:
try:
ok2 = doc.Objects.Replace(obj.Id, old)
print("[TEXT] Replace fallback: {}".format(ok2))
except Exception as ex:
print("[TEXT] Replace fallback fail:", ex)
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:
obj = sel[0]
te = obj.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
try: underline = bool(font.Underlined) if font else False
except Exception: underline = 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
# Style-ID falls am Text gespeichert (= via apply_style appliziert)
style_id = None
try: style_id = obj.Attributes.GetUserString("dossier_text_style_id") or None
except Exception: pass
return {
"font": face,
"size": float(te.TextHeight),
"bold": bold,
"italic": italic,
"underline": underline,
"align": align,
"styleId": style_id,
}
except Exception as ex:
print("[TEXT] read selection:", ex)
return None
def create_text():
"""DOSSIER Custom Text-Workflow (React WYSIWYG-Editor):
1. Frame ziehen (Live-Rechteck-Vorschau)
2. text_editor.open_with_frame oeffnet das React-WYSIWYG-Fenster
(Topmost, neben dem Frame). Editor handlet die Eingabe + erstellt
die TextEntity bei COMMIT.
"""
doc = Rhino.RhinoDoc.ActiveDoc
if doc is None: return
frame = _pick_text_frame()
if frame is None: return
p1, p2, origin, width, height = frame
try:
import text_editor
text_editor._ensure_double_click_hook()
text_editor.open_with_frame(p1, p2, origin, width, height)
except Exception as ex:
print("[TEXT] open editor:", ex)