f4404db64a
User: Doppelklick auf DOSSIER-Text oeffnet weiterhin Rhinos Editor. Verschiedene Groessen im Editor erscheinen nicht in Rhino. Doppelklick-Hook (rhino/text_editor.py): - _DossierTextDoubleClickHook subklassiert Rhino.UI.MouseCallback. OnMouseDoubleClick prueft selektierte TextEntities auf UserString "dossier_text"="1" und cancelled das Event (= blockt Rhinos Standard-TextEdit-Dialog), setzt sticky["dossier_pending_text_edit"] mit der Obj-ID - _on_idle_check_pending_edit (RhinoApp.Idle event): nimmt sticky-ID auf naechstem Idle-Tick und ruft open_for_edit(obj) — defer noetig weil Eto-Form aus MouseCallback heraus oeffnen Re-Entrancy macht - _ensure_double_click_hook() installiert Hook + Idle-Handler einmalig pro Rhino-Session (idempotent) - startup.py ruft das jetzt direkt nach Modul-Load auf Edit-Mode (open_for_edit): - Liest aus bestehendem TextEntity die Settings (Font, Size, Bold, Italic, Underline, Align) + PlainText - Frame fuer Dialog-Positionierung aus BBox abgeleitet - TextEditorBridge mit edit_obj_id + initial_text gestartet - INIT-Payload um initialText + editMode erweitert - COMMIT: bei edit_obj_id gesetzt → doc.Objects.Replace statt AddText. Plane wird vom Original uebernommen wenn keine explizite Rotation, damit der Text an seinem Platz bleibt Frontend (TextEditorApp.jsx): - Bei INIT mit initialText: editor.innerText wird damit befuellt - htmlToRuns extrahiert font-size in Pixel pro Run (inline style oder computed style != base) - baseCtx _basePx aus computed style des Editor-divs Size-Mapping (rhino/text_editor.py _runs_to_rtf): - fontSizePx in Runs triggert non-trivial (RTF wird generiert) - Pro Run: \fs in Halb-Punkten = 20 * (run_px / 14_base_px) round - 14px = \fs20 (1.0× TextEntity.TextHeight) - 21px = \fs30 (1.5×) - 28px = \fs40 (2.0×) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
913 lines
31 KiB
Python
913 lines
31 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.
|
||
|
||
# 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)
|
||
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 = "x²"
|
||
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 _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_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
|
||
# Aktuelle Werte lesen (vor Modifikation)
|
||
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
|
||
|
||
# Neue Werte aus Patch + Fallback auf aktuell
|
||
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
|
||
|
||
# FRESH TextEntity bauen statt Duplicate-Modify. Bypassed
|
||
# Probleme wo te.Font-Setter wegen Rich-Text-Runs oder
|
||
# DimensionStyle-Override nicht greift.
|
||
te = rg.TextEntity()
|
||
te.Plane = old.Plane
|
||
try: te.PlainText = old.PlainText
|
||
except Exception: pass
|
||
te.TextHeight = size
|
||
# DimensionStyle entkoppeln damit unser Font nicht von Style
|
||
# ueberschrieben wird.
|
||
try: te.DimensionStyleId = System.Guid.Empty
|
||
except Exception: pass
|
||
_apply_font(te, face, bool(bold), bool(italic), bool(underline))
|
||
# Alignment: aus Patch oder vom alten Entity uebernehmen
|
||
if align:
|
||
_apply_align(te, align)
|
||
else:
|
||
try: te.TextHorizontalAlignment = old.TextHorizontalAlignment
|
||
except Exception: pass
|
||
|
||
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
|
||
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
|
||
return {
|
||
"font": face,
|
||
"size": float(te.TextHeight),
|
||
"bold": bold,
|
||
"italic": italic,
|
||
"underline": underline,
|
||
"align": align,
|
||
}
|
||
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)
|