Files
DOSSIER/rhino/text_create.py
T
karim e9f0e255a0 Text-Create: InDesign-Stil Frame + Multi-Line-Editor (Phase 1)
User-Wunsch: wie InDesign — Text-Feld aufziehen und reinschreiben statt
Single-Point + ein-Zeilen-Prompt.

Phase 1 — Frame + Multi-Line-Eingabe:
- _pick_text_frame(): User picked 2 Ecken (mit Live-Vorschaulinie ab
  Erstpunkt), liefert (origin, width, height)
- _frame_editor_dialog(initial): Eto.Dialog mit forms.TextArea
  (multi-line, wrap, AcceptsReturn) + OK/Abbrechen-Buttons.
  Default = OK-Button (Enter im TextArea aber legt Newline → OK
  per Maus-Klick oder DefaultButton-Enter-Sequenz)
- create_text(): pickt Frame → oeffnet Editor → erstellt TextEntity an
  origin (= obere linke Ecke), Plane mit Z-Axis, Text-Wrap auf Frame-
  Breite (best-effort: FormatWidth/TextWidth/MaskWidth/TextIsWrapped
  je nach RhinoCommon-Version vorhanden)

Phase 2 (noch nicht): verschiedene Fonts INNERHALB eines Texts (Rich-
Text-Runs). Braucht Mapping Eto.RichTextArea → Rhino's RTF-Format.

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

484 lines
16 KiB
Python

#! 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)