diff --git a/rhino/elemente.py b/rhino/elemente.py index 7de99f4..6f63daf 100644 --- a/rhino/elemente.py +++ b/rhino/elemente.py @@ -297,6 +297,34 @@ _KEY_RAUM_TXT_H = "dossier_raum_txt_h" # Texthoehe in m _KEY_RAUM_ALIGN = "dossier_raum_align" # "links"|"mid"|"rechts" _KEY_RAUM_SIA = "dossier_raum_sia" # "" | "hnf" | "nnf" | "vf" | "ff" _KEY_RAUM_FUELL = "dossier_raum_fuellung" # "" (keine) | "Solid" | Pattern-Name | "ByLayer" +# Stempel-Typografie (per-Raum-Override) — leer = Doc-Default-Font, Defaults siehe _read_meta +_KEY_RAUM_TXT_FONT = "dossier_raum_txt_font" # Font-Quartet-Name (z.B. "DM Mono", "Helvetica") +_KEY_RAUM_TXT_BOLD = "dossier_raum_txt_bold" # "0"|"1" +_KEY_RAUM_TXT_ITAL = "dossier_raum_txt_italic" # "0"|"1" +# Sichtbare Felder im Stempel — alle default "1" ausser SIA ("0"). Empty +# UserString wird wie "1" behandelt, damit alte Raeume ohne Migrate die +# bisherige Anzeige behalten. +_KEY_RAUM_SHOW_NUM = "dossier_raum_show_nummer" +_KEY_RAUM_SHOW_NAM = "dossier_raum_show_name" +_KEY_RAUM_SHOW_FKT = "dossier_raum_show_funktion" +_KEY_RAUM_SHOW_ARE = "dossier_raum_show_area" +_KEY_RAUM_SHOW_SIA = "dossier_raum_show_sia" +# Stempel-Position als Offset zum Outline-Centroid (m). Persistiert vom +# Auto-Sync in _on_command_end — User-Move des Stempels wird so beim +# naechsten Outline-Modify nicht wieder auf den Centroid zurueck-snapped. +_KEY_RAUM_STAMP_DX = "dossier_raum_stamp_dx" +_KEY_RAUM_STAMP_DY = "dossier_raum_stamp_dy" +# Field-Layout als JSON-Array von Rows. Jede Row ist eine Liste von +# Field-IDs. Z.B. [["nummer","name","area"],["funktion"]] → eine Zeile mit +# Nummer+Name+Flaeche, dann Funktion auf eigener Zeile. Leer = legacy +# show_*-Flags benutzen. +_KEY_RAUM_LAYOUT = "dossier_raum_layout" + +# Verfuegbare Field-IDs fuers Layout +_RAUM_FIELD_IDS = ("nummer", "name", "funktion", "area", "sia") + +# Default-Layout (entspricht dem alten Verhalten) +_RAUM_LAYOUT_DEFAULT = [["nummer", "name"], ["funktion"], ["area"]] _RAUM_RUNDUNGEN = ("exakt", "0.01", "0.1", "0.5", "1") @@ -407,6 +435,40 @@ def _list_hatch_patterns(doc): print("[ELEMENTE] list_hatch_patterns:", ex) return out + +def _list_system_fonts(): + """Sammelt installierte Font-Quartet-Namen via Rhino.DocObjects.Font. + Liefert sortierte Liste mit den haeufig benutzten DOSSIER-Fonts oben.""" + out = [] + preferred = ["DM Mono", "Krungthep", "Archivo Black", "Playfair Display", + "Helvetica", "Helvetica Neue", "Arial", "SF Pro Display", + "Times New Roman", "Courier New"] + try: + from Rhino.DocObjects import Font as _Font + installed = _Font.InstalledFontsAsString() + # InstalledFontsAsString liefert ; oder \n separated — beide handeln + raw = [] + if installed: + for sep in (";", "\n", "\r"): + if sep in installed: + raw = [s.strip() for s in installed.split(sep) if s.strip()] + break + if not raw: raw = [installed.strip()] + seen = set() + # Erst Preferred wenn installiert + for f in preferred: + if f in raw and f not in seen: + out.append(f); seen.add(f) + # Dann der Rest alphabetisch + for f in sorted(raw): + if f and f not in seen: + out.append(f); seen.add(f) + except Exception as ex: + print("[ELEMENTE] list_system_fonts:", ex) + # Fallback: nur die DOSSIER-Defaults + out = preferred[:] + return out + _TREPPE_SOLL_DEFAULT = { "s": [0.15, 0.20, True], "a": [0.21, 0.35, True], @@ -2530,6 +2592,12 @@ def _attach_meta(obj_attrs, wall_id, type_, geschoss, dicke, uk_over, ok_over, raum_name=None, raum_nummer=None, raum_funktion=None, raum_rundung=None, raum_txt_h=None, raum_align=None, raum_sia=None, raum_fuellung=None, + raum_txt_font=None, raum_txt_bold=None, raum_txt_italic=None, + raum_show_nummer=None, raum_show_name=None, + raum_show_funktion=None, raum_show_area=None, + raum_show_sia=None, + raum_stamp_dx=None, raum_stamp_dy=None, + raum_layout=None, wand_layered=None, wand_layers=None, wand_layer_idx=None, wand_chain_members=None, aussp_parent=None): @@ -2694,6 +2762,44 @@ def _attach_meta(obj_attrs, wall_id, type_, geschoss, dicke, uk_over, ok_over, else: v = str(raum_fuellung) obj_attrs.SetUserString(_KEY_RAUM_FUELL, v) + # Stempel-Typografie + Sichtbarkeit (per-Raum-Override) + if raum_txt_font is not None: + obj_attrs.SetUserString(_KEY_RAUM_TXT_FONT, str(raum_txt_font)) + if raum_txt_bold is not None: + obj_attrs.SetUserString(_KEY_RAUM_TXT_BOLD, + "1" if bool(raum_txt_bold) else "0") + if raum_txt_italic is not None: + obj_attrs.SetUserString(_KEY_RAUM_TXT_ITAL, + "1" if bool(raum_txt_italic) else "0") + for key, val in ( + (_KEY_RAUM_SHOW_NUM, raum_show_nummer), + (_KEY_RAUM_SHOW_NAM, raum_show_name), + (_KEY_RAUM_SHOW_FKT, raum_show_funktion), + (_KEY_RAUM_SHOW_ARE, raum_show_area), + (_KEY_RAUM_SHOW_SIA, raum_show_sia), + ): + if val is not None: + obj_attrs.SetUserString(key, "1" if bool(val) else "0") + if raum_stamp_dx is not None: + try: obj_attrs.SetUserString(_KEY_RAUM_STAMP_DX, + "{:.6f}".format(float(raum_stamp_dx))) + except Exception: pass + if raum_stamp_dy is not None: + try: obj_attrs.SetUserString(_KEY_RAUM_STAMP_DY, + "{:.6f}".format(float(raum_stamp_dy))) + except Exception: pass + if raum_layout is not None: + try: + import json as _json + if isinstance(raum_layout, str): + # validate as JSON + _json.loads(raum_layout) + obj_attrs.SetUserString(_KEY_RAUM_LAYOUT, raum_layout) + elif isinstance(raum_layout, list): + obj_attrs.SetUserString(_KEY_RAUM_LAYOUT, + _json.dumps(raum_layout)) + except Exception as ex: + print("[ELEMENTE] raum_layout set:", ex) # Wand-Schichten if wand_layered is not None: obj_attrs.SetUserString(_KEY_WAND_LAYERED, @@ -2870,6 +2976,39 @@ def _read_meta(obj): if r_fuell_raw == "1": r_fuell = "Solid" elif r_fuell_raw == "0": r_fuell = "" else: r_fuell = r_fuell_raw or "" + # Stempel-Typografie (Defaults: leer = Doc-Default-Font, bold/italic off) + r_font = a.GetUserString(_KEY_RAUM_TXT_FONT) or "" + r_bold = (a.GetUserString(_KEY_RAUM_TXT_BOLD) == "1") + r_ital = (a.GetUserString(_KEY_RAUM_TXT_ITAL) == "1") + # Sichtbarkeit — leer/None -> Default "1" (Backwards-Compat), ausser + # SIA (default "0"). Nur explizites "0" deaktiviert ein Feld. + def _show_default_on(key): + v = a.GetUserString(key) + return v != "0" # None, "", "1" -> True + r_show_num = _show_default_on(_KEY_RAUM_SHOW_NUM) + r_show_nam = _show_default_on(_KEY_RAUM_SHOW_NAM) + r_show_fkt = _show_default_on(_KEY_RAUM_SHOW_FKT) + r_show_are = _show_default_on(_KEY_RAUM_SHOW_ARE) + r_show_sia = (a.GetUserString(_KEY_RAUM_SHOW_SIA) == "1") + # Stempel-Position-Offset + try: r_stamp_dx = float(a.GetUserString(_KEY_RAUM_STAMP_DX) or "0") + except Exception: r_stamp_dx = 0.0 + try: r_stamp_dy = float(a.GetUserString(_KEY_RAUM_STAMP_DY) or "0") + except Exception: r_stamp_dy = 0.0 + # Field-Layout — parsed JSON list of rows + r_layout_raw = a.GetUserString(_KEY_RAUM_LAYOUT) or "" + r_layout = [] + if r_layout_raw: + try: + import json as _json + parsed = _json.loads(r_layout_raw) + if isinstance(parsed, list): + for row in parsed: + if not isinstance(row, list): continue + clean = [str(f) for f in row + if isinstance(f, str) and f in _RAUM_FIELD_IDS] + if clean: r_layout.append(clean) + except Exception: r_layout = [] # Wand-Schichten w_layered = (a.GetUserString(_KEY_WAND_LAYERED) == "1") w_layers_raw = a.GetUserString(_KEY_WAND_LAYERS) or "" @@ -2965,6 +3104,17 @@ def _read_meta(obj): "raum_align": r_align, "raum_sia": r_sia, "raum_fuellung": r_fuell, + "raum_txt_font": r_font, + "raum_txt_bold": r_bold, + "raum_txt_italic": r_ital, + "raum_show_nummer": r_show_num, + "raum_show_name": r_show_nam, + "raum_show_funktion": r_show_fkt, + "raum_show_area": r_show_are, + "raum_show_sia": r_show_sia, + "raum_stamp_dx": r_stamp_dx, + "raum_stamp_dy": r_stamp_dy, + "raum_layout": r_layout, "wand_layered": w_layered, "wand_layers": w_layers, "wand_layer_idx": w_layer_idx, @@ -4563,20 +4713,78 @@ def _format_area(area, rundung): def _make_raum_stamp_text(centroid, name, nummer, funktion, area, rundung, - text_height, z=0.0, align="mid"): - """Baut eine TextEntity am Centroid: 'Nummer Name\nA m^2'. - align: 'links' | 'mid' | 'rechts' — wirkt auf die Justification.""" + text_height, z=0.0, align="mid", + font=None, bold=False, italic=False, + show_nummer=True, show_name=True, + show_funktion=True, show_area=True, + show_sia=False, sia_code="", + layout=None, + doc=None): + """Baut eine TextEntity an `centroid` (= bereits offset-applizierte + Position, NICHT der reine Outline-Centroid). Layout bestimmt welche + Felder in welcher Zeile stehen — Liste von Rows, jede Row eine Liste + von Field-IDs (nummer, name, funktion, area, sia). Wenn `layout` + leer/None: Fallback auf die show_*-Flags + Default-Reihenfolge. + """ try: plane = rg.Plane(rg.Point3d(centroid.X, centroid.Y, float(z)), rg.Vector3d.ZAxis) te = rg.TextEntity() - # Zeile 1: Nummer + Name (falls vorhanden), sonst nur Name - line1 = (name or "Raum").strip() - if nummer and str(nummer).strip(): - line1 = "{} {}".format(str(nummer).strip(), line1) - # Zeile 2: Flaeche - area_line = "{} m²".format(_format_area(area, rundung)) - te.Text = "{}\n{}".format(line1, area_line) + + # Field-Value-Resolver — gibt den anzuzeigenden String fuer eine + # Field-ID oder None wenn das Feld leer/inaktiv ist. + def _field_value(fid): + if fid == "nummer": + v = (nummer or "").strip() + return v or None + if fid == "name": + v = (name or "").strip() + return v or None + if fid == "funktion": + v = (funktion or "").strip() + return v or None + if fid == "area": + return "{} m²".format(_format_area(area, rundung)) + if fid == "sia": + tag = _SIA_LABELS.get(sia_code or "", "") + return tag if (tag and tag != "—") else None + return None + + # Layout resolven: explizit gesetzt > show_*-Flags + if layout and isinstance(layout, list) and any(layout): + rows = layout + else: + # Aus show_*-Flags ein implizites Layout bauen + rows = [] + head = [] + if show_nummer: head.append("nummer") + if show_name: head.append("name") + if head: rows.append(head) + if show_funktion: rows.append(["funktion"]) + tail = [] + if show_area: tail.append("area") + if show_sia: tail.append("sia") + if tail: rows.append(tail) + + # Lines aus Rows bauen — pro Row die nicht-leeren Field-Values + # joinen, leere Rows weglassen. + lines = [] + for row in rows: + parts = [] + for fid in row: + v = _field_value(fid) + if v: parts.append(v) + if parts: + # Spezial: "area · sia" mit Mitten-Punkt wenn beide in Row + if (len(parts) == 2 and "area" in row and "sia" in row + and row.index("area") < row.index("sia")): + lines.append("{} · {}".format(parts[0], parts[1])) + else: + lines.append(" ".join(parts)) + if not lines: + # Komplett leerer Stempel waere unsichtbar — Name als Fallback + lines.append((name or "Raum").strip()) + te.Text = "\n".join(lines) te.Plane = plane try: te.TextHeight = float(text_height) except Exception: te.TextHeight = 0.20 @@ -4585,6 +4793,37 @@ def _make_raum_stamp_text(centroid, name, nummer, funktion, area, rundung, elif align == "rechts": te.Justification = rg.TextJustification.MiddleRight else: te.Justification = rg.TextJustification.MiddleCenter except Exception: pass + # Font/Style — nur wenn doc + font_name vorhanden, sonst Doc-Default + if doc is not None and font: + try: + f = Rhino.DocObjects.Font.FromQuartetProperties( + str(font), bool(bold), bool(italic)) + if f is not None: + idx = doc.Fonts.FindOrCreate(f.QuartetName, + bool(bold), bool(italic)) + if idx >= 0: + try: te.FontIndex = idx + except Exception: + try: te.SetFontIndex(idx) + except Exception: pass + except Exception as ex: + print("[ELEMENTE] Raum Stamp Font:", ex) + elif doc is not None and (bold or italic): + # Kein expliziter Font, aber Stil gesetzt — auf Default-Font + # bold/italic anwenden + try: + cur_idx = te.FontIndex + if cur_idx >= 0 and cur_idx < doc.Fonts.Count: + cur_font = doc.Fonts[cur_idx] + idx = doc.Fonts.FindOrCreate(cur_font.QuartetName, + bool(bold), bool(italic)) + if idx >= 0: + try: te.FontIndex = idx + except Exception: + try: te.SetFontIndex(idx) + except Exception: pass + except Exception as ex: + print("[ELEMENTE] Raum Stamp Style:", ex) return te except Exception as ex: print("[ELEMENTE] Raum Stamp:", ex) @@ -5918,9 +6157,14 @@ def _regenerate_element_body(doc, element_id, src_obj, meta, geom, geschoss_name except Exception as ex: print("[ELEMENTE] override re-apply:", ex) - # Stempel + # Stempel-Position: Centroid + persistierter User-Offset (dx,dy). + # So bleibt der Stempel wo der User ihn zuletzt gezogen hat, auch + # nach Outline-Vertex-Drag etc. + sx = float(meta.get("raum_stamp_dx", 0.0)) + sy = float(meta.get("raum_stamp_dy", 0.0)) + stamp_pt = rg.Point3d(ctr.X + sx, ctr.Y + sy, ctr.Z) te = _make_raum_stamp_text( - ctr, + stamp_pt, meta.get("raum_name", "Raum"), meta.get("raum_nummer", ""), meta.get("raum_funktion", ""), @@ -5928,7 +6172,18 @@ def _regenerate_element_body(doc, element_id, src_obj, meta, geom, geschoss_name _resolve_raum_rundung(meta, doc), meta.get("raum_txt_h", 0.20), z=z_uk, - align=meta.get("raum_align", "mid")) + align=meta.get("raum_align", "mid"), + font=meta.get("raum_txt_font", ""), + bold=meta.get("raum_txt_bold", False), + italic=meta.get("raum_txt_italic", False), + show_nummer=meta.get("raum_show_nummer", True), + show_name=meta.get("raum_show_name", True), + show_funktion=meta.get("raum_show_funktion", True), + show_area=meta.get("raum_show_area", True), + show_sia=meta.get("raum_show_sia", False), + sia_code=meta.get("raum_sia", ""), + layout=meta.get("raum_layout") or None, + doc=doc) if te is None: return True # Outline evtl. offen — Source behalten attrs = Rhino.DocObjects.ObjectAttributes() @@ -5942,7 +6197,15 @@ def _regenerate_element_body(doc, element_id, src_obj, meta, geom, geschoss_name raum_txt_h=meta.get("raum_txt_h"), raum_align=meta.get("raum_align"), raum_sia=meta.get("raum_sia"), - raum_fuellung=meta.get("raum_fuellung")) + raum_fuellung=meta.get("raum_fuellung"), + raum_txt_font=meta.get("raum_txt_font"), + raum_txt_bold=meta.get("raum_txt_bold"), + raum_txt_italic=meta.get("raum_txt_italic"), + raum_show_nummer=meta.get("raum_show_nummer"), + raum_show_name=meta.get("raum_show_name"), + raum_show_funktion=meta.get("raum_show_funktion"), + raum_show_area=meta.get("raum_show_area"), + raum_show_sia=meta.get("raum_show_sia")) try: doc.Objects.AddText(te, attrs) except Exception as ex: print("[ELEMENTE] Raum AddText:", ex) return True @@ -6269,7 +6532,16 @@ class ElementeBridge(panel_base.BaseBridge): "txtH": meta.get("raum_txt_h", 0.20), "align": meta.get("raum_align", "mid"), "sia": meta.get("raum_sia", ""), - "fuellung": bool(meta.get("raum_fuellung", False)), + "fuellung": meta.get("raum_fuellung", "") or "", + "font": meta.get("raum_txt_font", "") or "", + "bold": bool(meta.get("raum_txt_bold", False)), + "italic": bool(meta.get("raum_txt_italic", False)), + "showNummer": bool(meta.get("raum_show_nummer", True)), + "showName": bool(meta.get("raum_show_name", True)), + "showFunktion": bool(meta.get("raum_show_funktion", True)), + "showArea": bool(meta.get("raum_show_area", True)), + "showSia": bool(meta.get("raum_show_sia", False)), + "layout": meta.get("raum_layout") or [], "area": area, "areaFmt": _format_area(area, rnd_eff), "umfang": perim, @@ -6320,6 +6592,7 @@ class ElementeBridge(panel_base.BaseBridge): "activeGeschossName": _active_geschoss_name(doc), "siaFillMode": _sia_fill_enabled(doc), "hatchPatterns": _list_hatch_patterns(doc), + "fonts": _list_system_fonts(), "materials": [ {"name": n, "color": m["color"]} for n, m in _get_all_materials(doc).items()], @@ -9747,6 +10020,24 @@ class ElementeBridge(panel_base.BaseBridge): r_fuell = "" else: r_fuell = str(r_fuell) + # Stempel-Typografie + Sichtbarkeit (per-Patch oder vom alten meta) + r_font = p.get("font", old_meta.get("raum_txt_font", "") or "") + r_bold = bool(p.get("bold", old_meta.get("raum_txt_bold", False))) + r_ital = bool(p.get("italic", old_meta.get("raum_txt_italic", False))) + r_show_num = bool(p.get("showNummer", + old_meta.get("raum_show_nummer", True))) + r_show_nam = bool(p.get("showName", + old_meta.get("raum_show_name", True))) + r_show_fkt = bool(p.get("showFunktion", + old_meta.get("raum_show_funktion", True))) + r_show_are = bool(p.get("showArea", + old_meta.get("raum_show_area", True))) + r_show_sia = bool(p.get("showSia", + old_meta.get("raum_show_sia", False))) + # Layout: Liste-of-Rows aus Frontend (JSON-serializable). Wenn + # nicht im Patch → vom alten meta uebernehmen. + r_layout = p.get("layout", old_meta.get("raum_layout", [])) + if not isinstance(r_layout, list): r_layout = [] gstart = p.get("geschoss", old_meta["geschoss"]) attrs = axis_obj.Attributes if gstart != old_meta["geschoss"]: @@ -9762,13 +10053,30 @@ class ElementeBridge(panel_base.BaseBridge): raum_txt_h=r_th, raum_align=r_align, raum_sia=r_sia, - raum_fuellung=r_fuell) + raum_fuellung=r_fuell, + raum_txt_font=r_font, + raum_txt_bold=r_bold, + raum_txt_italic=r_ital, + raum_show_nummer=r_show_num, + raum_show_name=r_show_nam, + raum_show_funktion=r_show_fkt, + raum_show_area=r_show_are, + raum_show_sia=r_show_sia, + raum_layout=r_layout) axis_obj.Attributes = attrs axis_obj.CommitChanges() _save_last(raum_name_last=r_name, raum_rundung=r_rnd, raum_funktion=r_fkt, raum_txt_h=r_th, raum_align=r_align, raum_sia=r_sia, - raum_fuellung=r_fuell) + raum_fuellung=r_fuell, + raum_txt_font=r_font, raum_txt_bold=r_bold, + raum_txt_italic=r_ital, + raum_show_nummer=r_show_num, + raum_show_name=r_show_nam, + raum_show_funktion=r_show_fkt, + raum_show_area=r_show_are, + raum_show_sia=r_show_sia, + raum_layout=r_layout) _regenerate_volume(doc, wall_id) doc.Views.Redraw() self._send_state() @@ -10990,9 +11298,13 @@ _PAIRED_VOLUME_TYPES = ( "wand_volume", "decke_volume", "dach_volume", "oeffnung_volume", "treppe_volume", "stuetze_volume", "traeger_volume", - # Raum: Stempel-Text + SIA-Fuellung haengen an raum_outline. Damit die - # drei gemeinsam markiert + via Rhino-Move zusammen verschoben werden. - "raum_stamp", "raum_fill", + # Raum-Fuellung haengt an der Outline und soll mitwandern → pairing aktiv. + # raum_stamp ABSICHTLICH NICHT hier: Klick auf den Stempel-Text soll nur + # den Text selektieren, damit der User ihn unabhaengig verschieben kann + # (Position bleibt via dx,dy-UserString persistent, siehe + # _sync_raum_stamps_to_source). Klick auf die Outline pairt weiter alles + # drei via _PAIRED_SOURCE_TYPES + _find_all_volumes. + "raum_fill", ) _PAIRED_SOURCE_TYPES = ( "wand_axis", "decke_outline", "dach_outline", @@ -11561,10 +11873,17 @@ def _snapshot_source_positions(doc): "pos": (p.X, p.Y, p.Z)} elif isinstance(geom, rg.Curve): s = geom.PointAtStart; e = geom.PointAtEnd + # length: change-Detection fuer Outline-Sources + # (raum_outline etc.) deren Start/End bei geschlossener + # PolyCurve auf demselben Punkt sitzen — Length aendert + # sich bei jedem Vertex-Drag, Start/End nicht. + try: length = float(geom.GetLength()) + except Exception: length = 0.0 snap["sources"][m["id"]] = {"type": t, "oeff_parent": parent, "start": (s.X, s.Y, s.Z), - "end": (e.X, e.Y, e.Z)} + "end": (e.X, e.Y, e.Z), + "length": length} elif t in VOLUME_TYPES: try: bb = geom.GetBoundingBox(True) @@ -11716,6 +12035,107 @@ def _on_command_begin(sender, e): sc.sticky["_dossier_undo_serial"] = None +def _sync_raum_stamps_to_source(doc): + """Sync raum_stamp TextEntities → raum_outline Source-UserStrings. + + Greift NACH jedem User-Command (Move des Stempels, Aenderungen via + Rhino-Properties oder Oberleiste-Text-Block). Bevor der Regen laeuft + aktualisieren wir die Source so dass die naechste Stempel-Generierung + die User-Edits beibehaelt: + + - Offset (dx,dy) = stamp_position - new_centroid → Stempel bleibt + wo der User ihn gezogen hat, springt nicht auf den neuen Centroid. + - TextHeight aus dem Stempel → raum_txt_h + - Font/Bold/Italic → raum_txt_font/bold/italic + + No-op fuer Raeume ohne Stempel (neu, noch nicht regenned). + """ + if doc is None: return + try: + # Erst alle raum_outlines + dazugehoerige raum_stamps sammeln + outlines = {} # eid -> (src_obj, meta) + stamps = {} # eid -> stamp_obj + for obj in doc.Objects: + try: + m = _read_meta(obj) + if not m: continue + t = m.get("type") + if t == "raum_outline": + outlines[m["id"]] = (obj, m) + elif t == "raum_stamp": + stamps[m["id"]] = obj + except Exception: pass + if not outlines: return + for eid, (src_obj, meta) in outlines.items(): + stamp_obj = stamps.get(eid) + if stamp_obj is None: continue + try: + te = stamp_obj.Geometry + if te is None: continue + # Aktuelle Stempel-Position (TextEntity.Plane.Origin ist die + # Ankerposition — robuster als bbox.center bei Multi-Line-Texten + # mit Justification.MiddleCenter) + try: + pos = te.Plane.Origin + except Exception: + bb = stamp_obj.Geometry.GetBoundingBox(True) + if not bb.IsValid: continue + pos = bb.Center + # Aktueller Outline-Centroid + src_geom = src_obj.Geometry + if not isinstance(src_geom, rg.Curve): continue + _, _, ctr = _raum_amp(src_geom) + if ctr is None: continue + new_dx = pos.X - ctr.X + new_dy = pos.Y - ctr.Y + # Font/Style/Size aus der TextEntity lesen + try: new_h = float(te.TextHeight) + except Exception: new_h = float(meta.get("raum_txt_h", 0.20)) + cur_font = None + try: cur_font = te.Font + except Exception: pass + try: new_face = cur_font.QuartetName if cur_font else "" + except Exception: new_face = "" + try: new_bold = bool(cur_font.Bold) if cur_font else False + except Exception: new_bold = False + try: new_ital = bool(cur_font.Italic) if cur_font else False + except Exception: new_ital = False + # Nur schreiben wenn was geaendert hat — vermeidet Modify- + # Storms bei jedem Idle-Tick. + old_dx = float(meta.get("raum_stamp_dx", 0.0)) + old_dy = float(meta.get("raum_stamp_dy", 0.0)) + old_h = float(meta.get("raum_txt_h", 0.20)) + old_face = meta.get("raum_txt_font", "") or "" + old_bold = bool(meta.get("raum_txt_bold", False)) + old_ital = bool(meta.get("raum_txt_italic", False)) + changed = ( + abs(new_dx - old_dx) > 1e-6 or + abs(new_dy - old_dy) > 1e-6 or + abs(new_h - old_h) > 1e-6 or + new_face != old_face or + new_bold != old_bold or + new_ital != old_ital + ) + if not changed: continue + attrs = src_obj.Attributes.Duplicate() + attrs.SetUserString(_KEY_RAUM_STAMP_DX, + "{:.6f}".format(new_dx)) + attrs.SetUserString(_KEY_RAUM_STAMP_DY, + "{:.6f}".format(new_dy)) + attrs.SetUserString(_KEY_RAUM_TXT_H, + "{:.4f}".format(new_h)) + attrs.SetUserString(_KEY_RAUM_TXT_FONT, new_face) + attrs.SetUserString(_KEY_RAUM_TXT_BOLD, + "1" if new_bold else "0") + attrs.SetUserString(_KEY_RAUM_TXT_ITAL, + "1" if new_ital else "0") + doc.Objects.ModifyAttributes(src_obj.Id, attrs, True) + except Exception as ex: + print("[ELEMENTE] sync stamp", eid, ":", ex) + except Exception as ex: + print("[ELEMENTE] _sync_raum_stamps_to_source:", ex) + + def _on_command_end(sender, e): # Bulk-Op fertig: RedrawEnabled zurueck + EINMAL redrawn + selection # refresh ans Gestaltung-Panel. @@ -11783,6 +12203,17 @@ def _on_command_end(sender, e): sc.sticky["_dossier_undo_serial"] = None return + # Raum-Stempel → Source-Sync: VOR dem Pure-Transform/Regen-Pfad. Damit + # bleibt der User-Move des Stempels (oder Font-Edit via Oberleiste) + # auch wenn die Outline danach regennt wird. + try: + _was_sync = sc.sticky.get(_REGEN_BUSY, False) + sc.sticky[_REGEN_BUSY] = True + try: _sync_raum_stamps_to_source(doc) + finally: sc.sticky[_REGEN_BUSY] = _was_sync + except Exception as ex: + print("[ELEMENTE] stamp-sync:", ex) + # RedrawEnabled wurde idR schon beim ersten Object-Event nach dem # User-Klick auf False gesetzt (`_suppress_redraw_until_cmd_end`). Den # gemerkten prev-Wert lesen. Falls kein Event gefeuert hat (z.B. Move @@ -12279,6 +12710,21 @@ def _on_command_end(sender, e): _apply_oeffnung_constraint(obj, m, pseudo) pid = m.get("oeff_parent") if pid: affected_walls.add(pid) + elif t in ("raum_outline", "decke_outline", "dach_outline", + "decke_aussparung_outline", "treppe_axis"): + # Outline-Source modified? Length-Check faengt Vertex- + # Drag bei geschlossenen PolyCurves (Pure-Transform-Pfad + # abortet hier mit Identity-Transform, weil PointAtStart + # == PointAtEnd → Length-Diff ist der zuverlaessige + # Change-Indikator). Bei Raeumen muss der Stempel-Text + # neu gerechnet werden (Flaeche aendert sich). + geom = obj.Geometry + if not isinstance(geom, rg.Curve): continue + try: new_len = float(geom.GetLength()) + except Exception: new_len = 0.0 + old_len = old.get("length", new_len) + if abs(new_len - old_len) > 1e-6: + affected_walls.add(m["id"]) except Exception as ex: print("[ELEMENTE] post-cmd source:", ex) finally: diff --git a/src/ElementeApp.jsx b/src/ElementeApp.jsx index 982503e..7584b8a 100644 --- a/src/ElementeApp.jsx +++ b/src/ElementeApp.jsx @@ -506,7 +506,7 @@ function NeuesElementSection({ noGeschoss, activeName, elementsCount }) { // PropertiesView: gemeinsame Komponente, rendert die passende Property- // Form je nach Element-Typ. Wiederverwendbar in Inline + Satellite-Window. -export function PropertiesView({ selected, geschosse, materials, hatchPatterns, oeffStyles }) { +export function PropertiesView({ selected, geschosse, materials, hatchPatterns, oeffStyles, fonts }) { if (!selected) return null const upd = (p) => updateElement(selected.id, p) const del = (label) => () => { if (window.confirm(`${label} löschen?`)) deleteElement(selected.id) } @@ -528,7 +528,9 @@ export function PropertiesView({ selected, geschosse, materials, hatchPatterns, } if (selected.kind === 'raum') return + hatchPatterns={hatchPatterns} fonts={fonts || []} + onUpdate={upd} onDelete={del('Raum')} /> + if (selected.kind === 'aussparung') return // fenster/tuer @@ -580,6 +582,7 @@ export default function ElementeApp() { geschosse={geschosse} materials={state.materials || []} hatchPatterns={state.hatchPatterns} + fonts={state.fonts || []} oeffStyles={state.oeffStyles || []} /> )} @@ -711,19 +714,207 @@ function TragwerkProperties({ el, onUpdate, onDelete }) { ) } -function RaumProperties({ raum, geschosse, onUpdate, onDelete, hatchPatterns }) { +// Field-Definitionen fuer den Stempel-Layout-Builder. Symmetrisch zu +// _RAUM_FIELD_IDS im Backend (elemente.py). +const RAUM_LAYOUT_FIELDS = [ + { id: 'nummer', label: 'Nummer', icon: 'tag' }, + { id: 'name', label: 'Name', icon: 'label' }, + { id: 'funktion', label: 'Funktion', icon: 'category' }, + { id: 'area', label: 'Fläche', icon: 'square_foot' }, + { id: 'sia', label: 'SIA-Tag', icon: 'class' }, +] + +// Layout-Builder mit Drag-and-Drop. Rows = Textzeilen, Felder in einer +// Row stehen nebeneinander. Drag-Quelle ist ein "active field" Pill oder +// ein "verfuegbares" Pill. Drop-Ziel ist eine Row (= an die Row anhaengen) +// oder die neue-Row-Drop-Zone unten. +function StempelLayoutBuilder({ layout, availableFields, onChange }) { + const [dragging, setDragging] = useState(null) // { id, fromRow|null } + + const FIELD_META = Object.fromEntries(RAUM_LAYOUT_FIELDS.map(f => [f.id, f])) + + const removeFromLayout = (fid) => { + const next = layout.map(row => row.filter(f => f !== fid)) + .filter(row => row.length > 0) + return next + } + + const handleDragStart = (e, fid, fromRow) => { + setDragging({ id: fid, fromRow }) + try { e.dataTransfer.effectAllowed = 'move' } catch (_) {} + try { e.dataTransfer.setData('text/plain', fid) } catch (_) {} + } + + const handleDragEnd = () => setDragging(null) + + const handleDragOver = (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move' } + + const handleDropOnRow = (e, rowIdx) => { + e.preventDefault() + if (!dragging) return + const next = removeFromLayout(dragging.id) + // Wenn die ge-droppte Source und das Ziel dieselbe Row ist → no-op + // (Within-Row-Reorder waere komplexer; ignorieren wir vorerst) + if (next[rowIdx]) next[rowIdx] = [...next[rowIdx], dragging.id] + else next.push([dragging.id]) + onChange(next) + setDragging(null) + } + + const handleDropOnNewRow = (e) => { + e.preventDefault() + if (!dragging) return + const next = removeFromLayout(dragging.id) + next.push([dragging.id]) + onChange(next) + setDragging(null) + } + + const handleRemove = (fid) => { + onChange(removeFromLayout(fid)) + } + + const handleAddFromAvailable = (fid) => { + onChange([...layout, [fid]]) + } + + const pillStyle = (isDragging) => ({ + display: 'inline-flex', alignItems: 'center', gap: 4, + padding: '4px 8px', fontSize: 10, + background: isDragging ? 'var(--accent)' : 'var(--bg-input)', + color: isDragging ? 'var(--bg-panel)' : 'var(--text-primary)', + border: '1px solid var(--border)', borderRadius: 999, + cursor: 'grab', userSelect: 'none', + fontFamily: 'DM Mono, monospace', + }) + + const rowStyle = { + display: 'flex', flexWrap: 'wrap', gap: 4, + padding: '6px 8px', + background: 'var(--bg-panel)', + border: '1px dashed var(--border)', borderRadius: 'var(--r)', + minHeight: 28, alignItems: 'center', + } + + return ( +
+ Stempel-Layout +
+ Drag Felder zwischen Zeilen — eine Zeile = eine Textzeile im Stempel. +
+ + {/* Verfuegbare Felder (Drag-Quelle) */} + {availableFields.length > 0 && ( +
+ + + {availableFields.map(f => ( + handleDragStart(e, f.id, null)} + onDragEnd={handleDragEnd} + onClick={() => handleAddFromAvailable(f.id)} + title={`${f.label} hinzufügen (klick) oder in Zeile ziehen`} + style={{ + ...pillStyle(dragging && dragging.id === f.id), + opacity: 0.65, cursor: 'pointer', + }}> + {f.label} + + ))} +
+ )} + + {/* Rows */} + {layout.map((row, ri) => ( +
handleDropOnRow(e, ri)} + style={rowStyle}> + {row.map(fid => { + const meta = FIELD_META[fid] || { label: fid, icon: 'label' } + return ( + handleDragStart(e, fid, ri)} + onDragEnd={handleDragEnd} + style={pillStyle(dragging && dragging.id === fid)}> + + {meta.label} + + + ) + })} + Zeile {ri + 1} +
+ ))} + + {/* Neue-Row Drop-Zone (nur wenn was dragable ist) */} +
+ {dragging ? '↓ Hier ablegen für neue Zeile' : 'Drop hier für neue Zeile'} +
+
+ ) +} + +function RaumProperties({ raum, geschosse, onUpdate, onDelete, hatchPatterns, fonts }) { const [name, setName] = useState(raum.name || 'Raum') const [nummer, setNummer] = useState(raum.nummer || '') - const [txtH, setTxtH] = useState(String(raum.txtH || 0.20)) + const [funktion, setFunktion] = useState(raum.funktion || '') useEffect(() => { setName(raum.name || 'Raum') setNummer(raum.nummer || '') - setTxtH(String(raum.txtH || 0.20)) - }, [raum.id, raum.name, raum.nummer, raum.txtH]) + setFunktion(raum.funktion || '') + }, [raum.id, raum.name, raum.nummer, raum.funktion]) // Aktueller Wert von raum_fuellung: "" | "Solid" | "Hatch1" | … | "ByLayer" const fuell = raum.fuellung || '' const patternList = hatchPatterns || [] + // Layout aus State, mit Fallback auf show_*-Flags (backwards-compat + // fuer Raeume ohne explizites Layout). Backend faellt ebenfalls auf + // dieselbe Konvention zurueck. + const rawLayout = Array.isArray(raum.layout) ? raum.layout : [] + const layout = rawLayout.length > 0 ? rawLayout : (() => { + const head = [] + if (raum.showNummer !== false) head.push('nummer') + if (raum.showName !== false) head.push('name') + const rows = [] + if (head.length) rows.push(head) + if (raum.showFunktion !== false) rows.push(['funktion']) + const tail = [] + if (raum.showArea !== false) tail.push('area') + if (raum.showSia) tail.push('sia') + if (tail.length) rows.push(tail) + return rows.length ? rows : [['name']] + })() + const usedFields = new Set(layout.flat()) + const availableFields = RAUM_LAYOUT_FIELDS.filter(f => !usedFields.has(f.id)) return (
- Texthöhe - setTxtH(e.target.value)} + Funktion + setFunktion(e.target.value)} onBlur={() => { - const v = parseFloat(txtH) - if (v > 0 && v !== raum.txtH) onUpdate({ txtH: v }) - else setTxtH(String(raum.txtH)) + const v = (funktion || '').trim() + if (v !== (raum.funktion || '')) onUpdate({ funktion: v }) }} onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }} - style={{ flex: 1, fontSize: 11, - fontFamily: 'DM Mono, monospace' }} /> + placeholder="z.B. Wohnen, Bad, Büro …" + style={{ flex: 1, fontSize: 11 }} /> +
+ + {/* Stempel-Layout — Drag-and-Drop. Jede Row ist eine Textzeile, + Felder innerhalb einer Row landen in derselben Zeile. Drag + zwischen Rows um umzuordnen. Klick auf Field oben fügt es in + eine eigene neue Row hinzu. */} + onUpdate({ layout: newLayout })} /> + + {/* Hinweis: Typografie (Font/Stil/Höhe) wird in der OBERLEISTE + gesetzt — den Stempel im Viewport anklicken. Aenderungen werden + automatisch auf die Raum-Outline gespiegelt damit sie beim + naechsten Regen erhalten bleiben. */} +
+ + Typografie (Font/Stil/Höhe): Stempel im Viewport selektieren → + Oberleiste.
) : (