diff --git a/rhino/oberleiste.py b/rhino/oberleiste.py index 93b8e5b..b50eea8 100644 --- a/rhino/oberleiste.py +++ b/rhino/oberleiste.py @@ -934,12 +934,35 @@ class OberleisteBridge(panel_base.BaseBridge): doc = Rhino.RhinoDoc.ActiveDoc patch = p.get("settings") or {} text_create.save_settings(doc, patch) - # Wenn TextEntities selektiert: gleiche Aenderung direkt - # auf die selektierten Texte applizieren. text_create.apply_settings_to_selection(doc, patch) except Exception as ex: print("[OBERLEISTE] text settings:", ex) self._send_state(force=True) + elif t == "APPLY_TEXT_STYLE": + try: + import text_create + text_create.apply_style( + Rhino.RhinoDoc.ActiveDoc, p.get("id")) + except Exception as ex: + print("[OBERLEISTE] apply text style:", ex) + self._send_state(force=True) + elif t == "SAVE_TEXT_STYLE": + try: + import text_create + doc = Rhino.RhinoDoc.ActiveDoc + sid = text_create.save_style(doc, p.get("name") or "Stil") + if sid: text_create.set_active_style_id(doc, sid) + except Exception as ex: + print("[OBERLEISTE] save text style:", ex) + self._send_state(force=True) + elif t == "DELETE_TEXT_STYLE": + try: + import text_create + text_create.delete_style( + Rhino.RhinoDoc.ActiveDoc, p.get("id")) + except Exception as ex: + print("[OBERLEISTE] delete text style:", ex) + self._send_state(force=True) # --- Masse (Mass-Style) ----------------------------------------- elif t == "SET_MASSE_ACTIVE": @@ -1187,19 +1210,22 @@ class OberleisteBridge(panel_base.BaseBridge): except Exception: info["massePresets"] = [] info["masseActiveId"] = None - # Text-Settings + verfuegbare Fonts (Fonts nur einmal initial). - # Wenn TextEntity selektiert ist, deren Settings ergaenzen damit - # die UI die Werte des selektierten Textes spiegelt. + # Text-Settings + verfuegbare Fonts + Styles. Fonts werden bei + # jedem _send_state mitgeschickt damit nach Re-Mount (z.B. Panel- + # Andocken) die Liste nicht leer ist. try: import text_create info["textSettings"] = text_create.load_settings(doc) info["textSelectionSettings"] = text_create.read_selection_settings(doc) - if not getattr(self, "_fonts_sent", False): - info["textFonts"] = text_create.available_fonts() - self._fonts_sent = True + info["textFonts"] = text_create.available_fonts() + info["textStyles"] = text_create.list_styles(doc) + info["textStyleActiveId"] = text_create.get_active_style_id(doc) except Exception: info["textSettings"] = {} info["textSelectionSettings"] = None + info["textFonts"] = [] + info["textStyles"] = [] + info["textStyleActiveId"] = None # Norden-Rotation fuer N/O/S/W-Buttons try: import kamera diff --git a/rhino/text_create.py b/rhino/text_create.py index 3c97b92..9dc649c 100644 --- a/rhino/text_create.py +++ b/rhino/text_create.py @@ -27,25 +27,37 @@ 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, + "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) - s = json.loads(raw) - if not isinstance(s, dict): return dict(_DEFAULTS) - out = dict(_DEFAULTS) - out.update({k: s[k] for k in s if k in _DEFAULTS}) - return out + return _normalize(json.loads(raw)) except Exception: return dict(_DEFAULTS) @@ -54,22 +66,120 @@ 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) - cur.update({k: partial[k] for k in partial if k in _DEFAULTS}) + 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(): - """Liefert sortierte Liste verfuegbarer System-Fonts (Face-Names).""" + """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}) - return out + if out: return out except Exception as ex: print("[TEXT] available_fonts:", ex) - return ["Helvetica", "Arial", "Times New Roman", "Courier New"] + return list(_FONT_FALLBACK) def _prompt_for_text(default=""): @@ -127,9 +237,25 @@ def _selected_text_objects(doc): 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 auf alle selektierten TextEntities an. - Returns Anzahl der geaenderten Objekte.""" + """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 @@ -153,6 +279,10 @@ def apply_settings_to_selection(doc, patch): 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: @@ -164,8 +294,7 @@ def apply_settings_to_selection(doc, patch): def read_selection_settings(doc): - """Wenn TextEntities selektiert: liefert die Settings des ersten als - dict (font/size/bold/italic). Sonst None.""" + """Wenn TextEntities selektiert: liefert die Settings des ersten.""" sel = _selected_text_objects(doc) if not sel: return None try: @@ -174,11 +303,19 @@ def read_selection_settings(doc): 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, + "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) @@ -208,6 +345,7 @@ def create_text(): 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") doc.Objects.AddText(te) doc.Views.Redraw() except Exception as ex: diff --git a/src/OberleisteApp.jsx b/src/OberleisteApp.jsx index b9c66e1..133c415 100644 --- a/src/OberleisteApp.jsx +++ b/src/OberleisteApp.jsx @@ -12,6 +12,7 @@ import { openDossierSettings, openKameraPanel, setMasseActive, openMasseSettings, openAbout, createText, setTextSettings, + applyTextStyle, saveTextStyle, deleteTextStyle, } from './lib/rhinoBridge' const PRESETS = [ @@ -271,9 +272,12 @@ export default function OberleisteApp() { overridesActivePreset: null, overridesPresets: [], layerCombinations: [], layerCombinationActive: null, massePresets: [], masseActiveId: null, - textSettings: { font: 'Helvetica', size: 0.20, bold: false, italic: false }, + textSettings: { font: 'Helvetica', size: 0.20, bold: false, italic: false, + underline: false, align: 'left' }, textSelectionSettings: null, textFonts: [], + textStyles: [], + textStyleActiveId: null, northAngle: 0, lastSetView: null, }) @@ -282,6 +286,7 @@ export default function OberleisteApp() { const [draft, setDraft] = useState('') const [customMode, setCustomMode] = useState(false) // Dropdown -> Custom-Input switch const customInputRef = useRef(null) + const [textSizeCustom, setTextSizeCustom] = useState(false) useEffect(() => { onMessage('STATE', (s) => { @@ -781,151 +786,229 @@ export default function OberleisteApp() {
{/* ====== TEXT-Block (Vectorworks-Stil) ====== - Reihe 1: Font-Dropdown | Size + "m" - Reihe 2: B/I/+ kompakte Segmented-Pill + Reihe 1: Style ▼ | Font ▼ | Size ▼ + Reihe 2: [B][I][U] | [L][C][R] | [+] Wenn TextEntity selektiert: Werte spiegeln Selektion, Aenderungen - applizieren AUF die Selektion + speichern als Default. + gehen direkt rauf und werden zusaetzlich als Default gespeichert. */} {(() => { - const sel = state.textSelectionSettings // null wenn nichts selektiert + const sel = state.textSelectionSettings const ts = sel || state.textSettings || {} const fonts = state.textFonts || [] + const styles = state.textStyles || [] + const activeStyleId = state.textStyleActiveId const updateTs = (patch) => setTextSettings({ ...ts, ...patch }) - const FONT_W = 130 - const SIZE_W = 60 - const SEG_W = FONT_W + 6 + SIZE_W // B/I/+ Pill spannt ueber beide Spalten in Row 2 + const STYLE_W = 110 + const FONT_W = 130 + const SIZE_W = 80 const ACTIVE_BORDER = sel ? 'var(--accent)' : 'var(--border)' + const SIZE_PRESETS = [0.05, 0.10, 0.15, 0.20, 0.25, 0.30, 0.50, 0.70, 1.00] + + // Toggle-Button-Helper fuer B/I/U/Align + const ToggleBtn = ({ active, onClick, title, children, borderLeft, lastInRow }) => ( + + ) + return (
- {/* Reihe 1, Spalte 1: Font-Dropdown */} + {/* === Reihe 1 === */} + {/* Style-Dropdown */} + { + if (v === '__save__') { + const n = (window.prompt('Name für neuen Text-Stil:', 'Stil') || '').trim() + if (n) saveTextStyle(n) + return + } + if (v === '__delete__') { + if (activeStyleId && + window.confirm(`Stil "${styles.find(s => s.id === activeStyleId)?.name}" löschen?`)) + deleteTextStyle(activeStyleId) + return + } + if (v) applyTextStyle(v) + }} + width={STYLE_W} + title="Text-Stil — gespeicherte Settings-Presets" + > + + {styles.map(s => )} + + + {activeStyleId && } + + {/* Font-Dropdown */} updateTs({ font: v })} width={FONT_W} title={sel ? 'Schriftart (auf Selektion appliziert)' : 'Schriftart'} > - {fonts.length === 0 && } + {fonts.length === 0 && } {fonts.map(f => )} - {/* Reihe 1, Spalte 2: Size */} -
- { - const v = parseFloat(e.target.value) - if (!isNaN(v) && v > 0) updateTs({ size: v }) - }} - style={{ - flex: 1, minWidth: 0, - background: 'transparent', border: 'none', outline: 'none', - color: 'var(--text-primary)', - fontSize: 11, fontFamily: 'DM Mono, monospace', - padding: 0, textAlign: 'right', appearance: 'auto', + {/* Size-Dropdown mit "Eigene" / Custom-Input */} + {textSizeCustom ? ( +
+ { + const v = parseFloat(e.target.value) + if (!isNaN(v) && v > 0) updateTs({ size: v }) + }} + onBlur={() => setTextSizeCustom(false)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === 'Escape') { + e.target.blur() + } + }} + style={{ + flex: 1, minWidth: 0, + background: 'transparent', border: 'none', outline: 'none', + color: 'var(--text-primary)', + fontSize: 11, fontFamily: 'DM Mono, monospace', + padding: 0, textAlign: 'right', appearance: 'auto', + }} + /> + m +
+ ) : ( + { + if (v === '__custom__') { setTextSizeCustom(true); return } + const n = parseFloat(v) + if (!isNaN(n) && n > 0) updateTs({ size: n }) }} + width={SIZE_W} title={sel ? 'Texthoehe (auf Selektion appliziert)' : 'Texthoehe (m)'} - /> - m -
- {/* Reihe 2: B / I / + in einem Segmented-Pill */} + > + {SIZE_PRESETS.map(s => ( + + ))} + {ts.size != null && !SIZE_PRESETS.some(s => Math.abs(s - ts.size) < 0.001) && ( + + )} + + + + )} + + {/* === Reihe 2 === */} + {/* B/I/U Segmented */}
- {/* B */} - - {/* I */} - - {/* + Neuen Text einfuegen */} - + title={(sel ? 'Kursiv auf Selektion' : 'Kursiv') + ' (Default)'}> + + + updateTs({ underline: !ts.underline })} + title="Unterstrichen (Settings — Rendering kommt mit RichText-Support)"> + +
+ {/* L/C/R Align Segmented */} +
+ updateTs({ align: 'left' })} + title={(sel ? 'Linksbuendig auf Selektion' : 'Linksbuendig')}> + + + updateTs({ align: 'center' })} + title={(sel ? 'Zentriert auf Selektion' : 'Zentriert')}> + + + updateTs({ align: 'right' })} + title={(sel ? 'Rechtsbuendig auf Selektion' : 'Rechtsbuendig')}> + + +
+ {/* + Neuen Text einfuegen */} +
) })()} diff --git a/src/lib/rhinoBridge.js b/src/lib/rhinoBridge.js index 603663b..ea52bfa 100644 --- a/src/lib/rhinoBridge.js +++ b/src/lib/rhinoBridge.js @@ -173,6 +173,9 @@ export function openKameraPanel() { send('OPEN_KAMERA_PANEL', {}) } export function openAbout() { send('OPEN_ABOUT', {}) } export function createText() { send('CREATE_TEXT', {}) } export function setTextSettings(settings) { send('SET_TEXT_SETTINGS', { settings }) } +export function applyTextStyle(id) { send('APPLY_TEXT_STYLE', { id }) } +export function saveTextStyle(name) { send('SAVE_TEXT_STYLE', { name }) } +export function deleteTextStyle(id) { send('DELETE_TEXT_STYLE', { id }) } // --- Masse (in Oberleiste + Satellite-Fenster MasseSettings) --- // Topbar: aktives Mass setzen + Settings-Fenster oeffnen