Doppelklick-Hook auf DOSSIER-Texte + Size-Mapping in RTF

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>
This commit is contained in:
2026-05-21 01:44:45 +02:00
parent e7a1753519
commit f4404db64a
4 changed files with 196 additions and 16 deletions
+168 -10
View File
@@ -21,13 +21,77 @@ import panel_base
import text_create
# ---------------------------------------------------------------------------
# Doppelklick-Hook: wenn User auf einen DOSSIER-Text doppelklickt, oeffnet
# sich UNSER Editor statt Rhinos Standard-TextEdit.
class _DossierTextDoubleClickHook(Rhino.UI.MouseCallback):
def OnMouseDoubleClick(self, e):
try:
doc = Rhino.RhinoDoc.ActiveDoc
if doc is None: return
# Welches Objekt ist gerade selektiert? (Rhino's einfache
# Single-Click hat es ausgewaehlt bevor wir hier sind.)
target = None
for obj in doc.Objects.GetSelectedObjects(False, False):
try:
if isinstance(obj.Geometry, rg.TextEntity):
if obj.Attributes.GetUserString("dossier_text") == "1":
target = obj; break
except Exception: pass
if target is not None:
e.Cancel = True # Rhinos eigener TextEdit-Dialog wird geblockt
# Defer auf naechsten Idle-Tick — Eto-Form aus MouseCallback
# heraus zu oeffnen kann zu Re-Entrancy-Problemen fuehren
sc.sticky["dossier_pending_text_edit"] = str(target.Id)
except Exception as ex:
print("[TEXT-HOOK] OnDoubleClick:", ex)
_hook_instance = None
def _ensure_double_click_hook():
"""Installiert MouseCallback einmalig pro Rhino-Session."""
global _hook_instance
if _hook_instance is not None: return
try:
_hook_instance = _DossierTextDoubleClickHook()
_hook_instance.Enabled = True
# Idle-Handler einmalig registrieren fuer das deferred Opening
if not sc.sticky.get("dossier_text_idle_registered"):
Rhino.RhinoApp.Idle += _on_idle_check_pending_edit
sc.sticky["dossier_text_idle_registered"] = True
print("[TEXT-HOOK] Doppelklick-Hook installiert")
except Exception as ex:
print("[TEXT-HOOK] install:", ex)
def _on_idle_check_pending_edit(sender, e):
pid = sc.sticky.get("dossier_pending_text_edit")
if not pid: return
sc.sticky["dossier_pending_text_edit"] = None
doc = Rhino.RhinoDoc.ActiveDoc
if doc is None: return
try:
import System
obj = doc.Objects.FindId(System.Guid(pid))
if obj is None: return
open_for_edit(obj)
except Exception as ex:
print("[TEXT-HOOK] open for edit:", ex)
class TextEditorBridge(panel_base.BaseBridge):
def __init__(self, frame_data, settings, fonts):
def __init__(self, frame_data, settings, fonts,
edit_obj_id=None, initial_text=""):
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
self._edit_obj_id = edit_obj_id # bei Doppelklick-Edit gesetzt
self._initial_text = initial_text
def set_form(self, form):
self._form_ref = form
@@ -38,9 +102,11 @@ class TextEditorBridge(panel_base.BaseBridge):
try: styles = text_create.list_styles(doc)
except Exception: pass
self.send("INIT", {
"settings": self._initial_settings,
"fonts": self._fonts,
"styles": styles,
"settings": self._initial_settings,
"fonts": self._fonts,
"styles": styles,
"initialText": self._initial_text,
"editMode": bool(self._edit_obj_id),
})
def handle(self, data):
@@ -145,7 +211,25 @@ class TextEditorBridge(panel_base.BaseBridge):
except Exception as ex:
print("[TEXT-EDITOR] color:", ex)
attrs.SetUserString("dossier_text", "1")
doc.Objects.AddText(te, attrs)
# Edit-Mode: bestehenden TextEntity ersetzen statt neu hinzu
if self._edit_obj_id is not None:
try:
old = doc.Objects.FindId(self._edit_obj_id)
if old is not None:
# Plane vom Original uebernehmen wenn nicht rotiert
# explizit gewuenscht (sonst wandert der Text)
if abs(rot_deg) < 1e-6:
te.Plane = old.Geometry.Plane
doc.Objects.Replace(self._edit_obj_id, te)
doc.Objects.ModifyAttributes(self._edit_obj_id, attrs, True)
else:
doc.Objects.AddText(te, attrs)
except Exception as ex:
print("[TEXT-EDITOR] replace:", ex)
doc.Objects.AddText(te, attrs)
else:
doc.Objects.AddText(te, attrs)
doc.Views.Redraw()
# Defaults speichern (ohne color/rotation/frame — die sind
@@ -207,6 +291,7 @@ def _runs_to_rtf(runs, default_font):
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("fontSizePx") \
or (r.get("font") and r["font"] != default_font):
nontrivial = True; break
if not nontrivial: return None
@@ -237,11 +322,19 @@ def _runs_to_rtf(runs, default_font):
parts.append("}")
parts.append("\\pard")
# Editor Default-Font-Size in px (siehe TextEditorApp Editor-div: 14px)
BASE_PX = 14
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))
# Font-Size: \fs in Halb-Punkten relativ zur TextEntity.TextHeight.
# Annahme: \fs20 = 1.0× base. Scaling: \fs = 20 * (run_px / base_px).
fsp = run.get("fontSizePx")
if fsp and abs(fsp - BASE_PX) > 0.1:
rtf_fs = max(2, int(round(20.0 * fsp / BASE_PX)))
codes.append("\\fs{}".format(rtf_fs))
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")
@@ -256,11 +349,8 @@ def _runs_to_rtf(runs, default_font):
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.
"""
"""Frame-Pick-Workflow → Editor oeffnen, neuer Text erstellt."""
_ensure_double_click_hook()
doc = Rhino.RhinoDoc.ActiveDoc
settings = text_create.load_settings(doc)
fonts = text_create.available_fonts()
@@ -276,3 +366,71 @@ def open_with_frame(p1, p2, origin, width, height):
topmost=True)
if form is not None:
bridge.set_form(form)
def open_for_edit(obj):
"""Bei Doppelklick auf einen DOSSIER-Text: Editor mit den
bestehenden Settings + Text-Inhalt oeffnen. COMMIT macht REPLACE."""
_ensure_double_click_hook()
doc = Rhino.RhinoDoc.ActiveDoc
if doc is None or obj is None: return
te = obj.Geometry
if not isinstance(te, rg.TextEntity): return
# Settings aus dem bestehenden Text lesen
settings = text_create.load_settings(doc)
try: settings["size"] = float(te.TextHeight)
except Exception: pass
try:
if te.Font:
settings["font"] = te.Font.QuartetName
settings["bold"] = bool(te.Font.Bold)
settings["italic"] = bool(te.Font.Italic)
try: settings["underline"] = bool(te.Font.Underlined)
except Exception: pass
except Exception: pass
try:
h = te.TextHorizontalAlignment
if h == Rhino.DocObjects.TextHorizontalAlignment.Center:
settings["align"] = "center"
elif h == Rhino.DocObjects.TextHorizontalAlignment.Right:
settings["align"] = "right"
else: settings["align"] = "left"
except Exception: pass
initial_text = ""
try: initial_text = te.PlainText or ""
except Exception: pass
# Frame aus dem Text-BBox ableiten (fuer Dialog-Positionierung)
p1 = te.Plane.Origin
try:
bb = te.GetBoundingBox(False)
if bb.IsValid:
p2 = rg.Point3d(bb.Max.X, bb.Min.Y, p1.Z)
origin = rg.Point3d(bb.Min.X, bb.Max.Y, p1.Z)
width = bb.Max.X - bb.Min.X
height = bb.Max.Y - bb.Min.Y
else:
p2 = p1
origin = p1
width = 1.0; height = 0.5
except Exception:
p2 = p1; origin = p1; width = 1.0; height = 0.5
fonts = text_create.available_fonts()
bridge = TextEditorBridge(
(origin, width, height, p1, p2),
settings, fonts,
edit_obj_id=obj.Id,
initial_text=initial_text)
sc.sticky["text_editor_bridge"] = bridge
form = panel_base.open_satellite_window(
"text_editor",
title="Dossier Text (Bearbeiten)",
size=(640, 480),
bridge=bridge,
topmost=True)
if form is not None:
bridge.set_form(form)