Compare commits
4 Commits
736325fba1
...
f60d643bb7
| Author | SHA1 | Date | |
|---|---|---|---|
| f60d643bb7 | |||
| c050b9aeb6 | |||
| a308ba62d2 | |||
| ee01c7ebdc |
+536
-23
@@ -36,6 +36,7 @@ _KEY_REFERENZ = "dossier_referenz" # "mid" | "left" | "right"
|
||||
_KEY_WAND_LAYERED = "dossier_wand_layered" # "1" = mehrschichtig, sonst solid
|
||||
_KEY_WAND_LAYERS = "dossier_wand_layers" # JSON-Liste [{name, dicke, color}]
|
||||
_KEY_WAND_LAYER_IDX = "dossier_wand_layer_idx" # Layer-Index am Volume-Brep
|
||||
_KEY_WAND_CHAIN_MEMBERS = "dossier_wand_chain_members" # JSON-Liste wand_ids einer Polyline-Chain (nur auf wand_volume)
|
||||
_KEY_DACH_NEIGUNG = "dossier_dach_neigung" # Grad als string ("30")
|
||||
_KEY_DACH_EAVE = "dossier_dach_eave" # Index der Traufkante (string)
|
||||
_KEY_DACH_TYP = "dossier_dach_typ" # "pult"|"sattel"|"walm"|"mansarde"
|
||||
@@ -1808,16 +1809,20 @@ def _miter_dir(out_a, out_b):
|
||||
|
||||
|
||||
def _detect_t_junction(doc, geschoss_id, wall_id, endpoint,
|
||||
pos_tol=0.01, end_tol=0.05):
|
||||
pos_tol=0.01, end_tol=0.05, exclude_ids=None):
|
||||
"""Sucht ob `endpoint` auf der INNEREN Achse einer anderen Wand liegt
|
||||
(T-Stoss). Endpunkte der anderen Wand (Eckverbindung) werden bewusst
|
||||
ausgeschlossen — die werden bereits durch die Corner-Logik abgedeckt.
|
||||
`exclude_ids` (optional): zusaetzliche wall_ids die ignoriert werden
|
||||
sollen (Chain-Members), sonst nur wall_id.
|
||||
Liefert (other_wall_id, b_tangent_vec3, b_dicke) oder None."""
|
||||
skip = set(exclude_ids or ())
|
||||
skip.add(wall_id)
|
||||
for obj in doc.Objects:
|
||||
meta = _read_meta(obj)
|
||||
if not meta or meta["type"] != "wand_axis": continue
|
||||
if meta["geschoss"] != geschoss_id: continue
|
||||
if meta["id"] == wall_id: continue
|
||||
if meta["id"] in skip: continue
|
||||
geom = obj.Geometry
|
||||
if not isinstance(geom, rg.Curve): continue
|
||||
try:
|
||||
@@ -1863,6 +1868,169 @@ def _t_junction_miter(endpoint, out_dir, b_tan, b_dicke):
|
||||
return (mpt, mdir)
|
||||
|
||||
|
||||
# --- Wand-Chain: Polyline-vereinte Volumes -----------------------------------
|
||||
# Wenn N kompatible Waende sequentiell zusammenliegen (gleiche Geometrie/
|
||||
# Material/Hoehe, jeweils 2-Wall-Joints) verbinden wir ihre Achsen zu einer
|
||||
# Polyline und extrudieren ein einziges Brep statt N. Damit verschwinden die
|
||||
# internen Stoss-Linien in der Section-View (gleichfarbige Hatch ueber die
|
||||
# ganze Polyline).
|
||||
#
|
||||
# Anchor-Modell: das gemeinsame wand_volume gehoert zur "Anchor"-Wand
|
||||
# (alphabetisch kleinste wall_id der Chain). Die anderen Chain-Members haben
|
||||
# kein eigenes wand_volume — `_find_volume` schaut deswegen zusaetzlich nach
|
||||
# wand_chain_members-UserStrings.
|
||||
|
||||
def _wand_chain_compat(meta_a, meta_b):
|
||||
"""Sind zwei Waende kompatibel fuer einen gemeinsamen Polyline-Chain?
|
||||
Wenn irgendein geometrie-relevanter Parameter abweicht: nein. Sonst
|
||||
waere das gemeinsame Volume nicht sauber baubar."""
|
||||
if not meta_a or not meta_b: return False
|
||||
if meta_a.get("geschoss") != meta_b.get("geschoss"): return False
|
||||
if abs(float(meta_a.get("dicke", 0)) - float(meta_b.get("dicke", 0))) > 1e-6:
|
||||
return False
|
||||
if meta_a.get("referenz", "mid") != meta_b.get("referenz", "mid"):
|
||||
return False
|
||||
if (meta_a.get("uk_override") or "") != (meta_b.get("uk_override") or ""):
|
||||
return False
|
||||
if (meta_a.get("ok_override") or "") != (meta_b.get("ok_override") or ""):
|
||||
return False
|
||||
if bool(meta_a.get("wand_layered")) != bool(meta_b.get("wand_layered")):
|
||||
return False
|
||||
if meta_a.get("wand_layered"):
|
||||
la = meta_a.get("wand_layers") or []
|
||||
lb = meta_b.get("wand_layers") or []
|
||||
if len(la) != len(lb): return False
|
||||
for x, y in zip(la, lb):
|
||||
if abs(float(x.get("dicke", 0)) - float(y.get("dicke", 0))) > 1e-6:
|
||||
return False
|
||||
# Material muss identisch sein — die Layer-Reihenfolge + Materialien
|
||||
# bestimmen die Sub-Layer-Zuordnung der Schicht-Breps.
|
||||
if (x.get("material") or "") != (y.get("material") or ""):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _find_wall_chain(doc, wall_id):
|
||||
"""Liefert ORDERED Liste der wall_ids im Polyline-Chain von wall_id.
|
||||
Reihenfolge: vom Chain-Start bis zum Chain-Ende (so dass die Achsen
|
||||
aneinander anschliessen). wall_id selbst ist immer dabei.
|
||||
Bei nicht-Wand oder Wand nicht gefunden: leere Liste.
|
||||
Stop-Bedingungen: Verzweigung (>=2 Nachbarn am Joint), T-Stoss,
|
||||
inkompatibler Nachbar, oder kein Nachbar."""
|
||||
src, meta = _find_source(doc, wall_id)
|
||||
if src is None or meta is None or meta.get("type") != "wand_axis":
|
||||
return []
|
||||
geschoss = meta["geschoss"]
|
||||
joints = _collect_wall_joints(doc, geschoss)
|
||||
meta_by_id = {wall_id: meta}
|
||||
geom_by_id = {wall_id: src.Geometry}
|
||||
for obj in doc.Objects:
|
||||
m = _read_meta(obj)
|
||||
if not m or m["type"] != "wand_axis": continue
|
||||
if m["geschoss"] != geschoss: continue
|
||||
if m["id"] in meta_by_id: continue
|
||||
meta_by_id[m["id"]] = m
|
||||
geom_by_id[m["id"]] = obj.Geometry
|
||||
|
||||
def _chain_neighbor(cur_id, cur_pt):
|
||||
"""Am gemeinsamen Punkt: liefert (neighbor_id, neighbor_end) wenn
|
||||
es genau einen kompatiblen Nachbarn gibt, sonst None. neighbor_end
|
||||
ist "start" oder "end" — welcher Endpunkt von neighbor an cur_pt
|
||||
sitzt."""
|
||||
key = _pt_key(cur_pt)
|
||||
partners = [(p_wid, p_end)
|
||||
for (p_wid, p_end, _od) in joints.get(key, [])
|
||||
if p_wid != cur_id]
|
||||
if len(partners) != 1: return None
|
||||
p_wid, p_end = partners[0]
|
||||
if not _wand_chain_compat(meta_by_id.get(cur_id),
|
||||
meta_by_id.get(p_wid)):
|
||||
return None
|
||||
return (p_wid, p_end)
|
||||
|
||||
chain = [wall_id]
|
||||
visited = {wall_id}
|
||||
# Vorwaerts: ans "end" der aktuellen Wand entlang
|
||||
cur_id = wall_id
|
||||
cur_pt = geom_by_id[cur_id].PointAtEnd
|
||||
while True:
|
||||
nb = _chain_neighbor(cur_id, cur_pt)
|
||||
if nb is None: break
|
||||
p_wid, p_end = nb
|
||||
if p_wid in visited: break
|
||||
chain.append(p_wid); visited.add(p_wid)
|
||||
cur_id = p_wid
|
||||
# Anderer Endpunkt des Nachbarn ist der naechste Walk-Point
|
||||
cur_pt = (geom_by_id[p_wid].PointAtEnd if p_end == "start"
|
||||
else geom_by_id[p_wid].PointAtStart)
|
||||
# Rueckwaerts: ans "start" der aktuellen Wand entlang
|
||||
cur_id = wall_id
|
||||
cur_pt = geom_by_id[cur_id].PointAtStart
|
||||
while True:
|
||||
nb = _chain_neighbor(cur_id, cur_pt)
|
||||
if nb is None: break
|
||||
p_wid, p_end = nb
|
||||
if p_wid in visited: break
|
||||
chain.insert(0, p_wid); visited.add(p_wid)
|
||||
cur_id = p_wid
|
||||
cur_pt = (geom_by_id[p_wid].PointAtEnd if p_end == "start"
|
||||
else geom_by_id[p_wid].PointAtStart)
|
||||
return chain
|
||||
|
||||
|
||||
def _chain_anchor(chain_ids):
|
||||
"""Eindeutiger Anchor (= alphabetisch kleinste ID). Anchor besitzt das
|
||||
gemeinsame wand_volume; die anderen Chain-Members haben keins."""
|
||||
if not chain_ids: return None
|
||||
return sorted(chain_ids)[0]
|
||||
|
||||
|
||||
def _build_chain_polyline(doc, ordered_chain_ids):
|
||||
"""Joint die wand_axis-Curves zu einer PolylineCurve. Orientiert die
|
||||
erste Curve so dass ihr End-Punkt am Joint zur zweiten Curve sitzt,
|
||||
danach Auto-Flip pro Folge-Curve. Returns Curve oder None.
|
||||
Bei single-element chain: Duplicate der Original-Curve."""
|
||||
if not ordered_chain_ids: return None
|
||||
if len(ordered_chain_ids) == 1:
|
||||
obj = _find_axis(doc, ordered_chain_ids[0])
|
||||
return obj.Geometry.DuplicateCurve() if obj else None
|
||||
# Alle Segs (Point3d-Listen) sammeln
|
||||
segs = []
|
||||
for wid in ordered_chain_ids:
|
||||
obj = _find_axis(doc, wid)
|
||||
if obj is None: return None
|
||||
geom = obj.Geometry
|
||||
if not isinstance(geom, rg.Curve): return None
|
||||
rc, pl = geom.TryGetPolyline()
|
||||
if not rc:
|
||||
if isinstance(geom, rg.LineCurve):
|
||||
pl = rg.Polyline([geom.PointAtStart, geom.PointAtEnd])
|
||||
else:
|
||||
return None
|
||||
segs.append([pl[i] for i in range(pl.Count)])
|
||||
# Erste Seg orientieren: ihr letzter Punkt MUSS am Joint mit Seg[1]
|
||||
# sitzen. Wenn nicht (Joint ist an seg0[0]) → seg0 flippen. Sonst
|
||||
# springt die Polyline spaeter quer durch den Raum.
|
||||
s0, s1 = segs[0], segs[1]
|
||||
tol = 1e-3
|
||||
if (s0[-1].DistanceTo(s1[0]) > tol
|
||||
and s0[-1].DistanceTo(s1[-1]) > tol):
|
||||
segs[0] = s0[::-1]
|
||||
# Folge-Segs flippen wenn ihr Start nicht am prev_end sitzt.
|
||||
pts = list(segs[0])
|
||||
for seg in segs[1:]:
|
||||
prev_end = pts[-1]
|
||||
d_start = prev_end.DistanceTo(seg[0])
|
||||
d_end = prev_end.DistanceTo(seg[-1])
|
||||
if d_end < d_start:
|
||||
seg = seg[::-1]
|
||||
# erstes pt deckt sich mit prev_end → ueberspringen (sonst Duplikat
|
||||
# im Knick → Offset bricht oder erzeugt komische Mitten-Diskontinuitaet)
|
||||
pts.extend(seg[1:])
|
||||
if len(pts) < 2: return None
|
||||
return rg.PolylineCurve(rg.Polyline(pts))
|
||||
|
||||
|
||||
def _find_dependent_walls(doc, geschoss_id, moving_wall_id, old_curve, new_curve,
|
||||
pos_tol=0.01):
|
||||
"""Findet alle Waende deren Geometrie sich aendert wenn moving_wall sich
|
||||
@@ -2038,6 +2206,29 @@ _MATERIAL_LIBRARY = {
|
||||
}
|
||||
|
||||
|
||||
def _get_all_materials(doc):
|
||||
"""Builtin _MATERIAL_LIBRARY + Projekt-Settings-Materialien gemerged.
|
||||
Returns dict[name] -> {color, hatch, scale}. Projekt-Settings (inkl.
|
||||
Library-Imports) ueberschreibt builtin bei Namensgleichheit, sodass
|
||||
der User builtin-Defaults pro Projekt anpassen kann."""
|
||||
merged = {n: dict(m) for n, m in _MATERIAL_LIBRARY.items()}
|
||||
try:
|
||||
import rhinopanel
|
||||
ps = rhinopanel.load_project_settings(doc) if doc else None
|
||||
if isinstance(ps, dict):
|
||||
for m in ps.get("materials", []):
|
||||
n = m.get("name")
|
||||
if not n: continue
|
||||
merged[n] = {
|
||||
"color": m.get("color", "#888888"),
|
||||
"hatch": m.get("hatch", "Solid"),
|
||||
"scale": float(m.get("scale", 1.0) or 1.0),
|
||||
}
|
||||
except Exception as ex:
|
||||
print("[ELEMENTE] _get_all_materials:", ex)
|
||||
return merged
|
||||
|
||||
|
||||
def _set_layer_section_hatch(doc, layer_idx, hatch_name, scale=1.0,
|
||||
rotation=0.0):
|
||||
"""Konfiguriert Rhinos native Section-Hatch-Properties am Layer.
|
||||
@@ -2073,9 +2264,10 @@ def _ensure_material_sublayer(doc, geschoss_name, material_name):
|
||||
mit Material-Farbe + Section-Hatch konfiguriert. Liefert Layer-Index.
|
||||
Bei leerem oder unbekanntem Material: Fallback auf das normale
|
||||
Wand-Volume-Layer (= Standard fuer Solid-Waende)."""
|
||||
if not material_name or material_name not in _MATERIAL_LIBRARY:
|
||||
all_mats = _get_all_materials(doc)
|
||||
if not material_name or material_name not in all_mats:
|
||||
return _ensure_layer(doc, _layer_path_volume(doc, geschoss_name))
|
||||
mat = _MATERIAL_LIBRARY[material_name]
|
||||
mat = all_mats[material_name]
|
||||
parent_path = _layer_path_volume(doc, geschoss_name)
|
||||
full_path = "{}::{}".format(parent_path, material_name)
|
||||
idx = _ensure_layer(doc, full_path)
|
||||
@@ -2188,6 +2380,7 @@ def _attach_meta(obj_attrs, wall_id, type_, geschoss, dicke, uk_over, ok_over,
|
||||
raum_rundung=None, raum_txt_h=None,
|
||||
raum_align=None, raum_sia=None, raum_fuellung=None,
|
||||
wand_layered=None, wand_layers=None, wand_layer_idx=None,
|
||||
wand_chain_members=None,
|
||||
aussp_parent=None):
|
||||
"""User-Strings auf die Object-Attributes setzen."""
|
||||
obj_attrs.SetUserString(_KEY_ID, wall_id)
|
||||
@@ -2369,6 +2562,17 @@ def _attach_meta(obj_attrs, wall_id, type_, geschoss, dicke, uk_over, ok_over,
|
||||
try: obj_attrs.SetUserString(_KEY_WAND_LAYER_IDX,
|
||||
"{}".format(int(wand_layer_idx)))
|
||||
except Exception: pass
|
||||
if wand_chain_members is not None:
|
||||
try:
|
||||
import json as _json
|
||||
if isinstance(wand_chain_members, str):
|
||||
obj_attrs.SetUserString(_KEY_WAND_CHAIN_MEMBERS,
|
||||
wand_chain_members)
|
||||
else:
|
||||
obj_attrs.SetUserString(_KEY_WAND_CHAIN_MEMBERS,
|
||||
_json.dumps(list(wand_chain_members),
|
||||
ensure_ascii=False))
|
||||
except Exception: pass
|
||||
# Decken-Aussparung
|
||||
if aussp_parent is not None:
|
||||
obj_attrs.SetUserString(_KEY_AUSSP_PARENT, str(aussp_parent))
|
||||
@@ -2538,6 +2742,15 @@ def _read_meta(obj):
|
||||
except Exception: pass
|
||||
try: w_layer_idx = int(a.GetUserString(_KEY_WAND_LAYER_IDX) or "-1")
|
||||
except Exception: w_layer_idx = -1
|
||||
w_chain_raw = a.GetUserString(_KEY_WAND_CHAIN_MEMBERS) or ""
|
||||
w_chain_members = []
|
||||
if w_chain_raw:
|
||||
try:
|
||||
import json as _json
|
||||
parsed = _json.loads(w_chain_raw)
|
||||
if isinstance(parsed, list):
|
||||
w_chain_members = [str(x) for x in parsed if x]
|
||||
except Exception: pass
|
||||
aussp_parent_raw = a.GetUserString(_KEY_AUSSP_PARENT) or ""
|
||||
return {
|
||||
"id": a.GetUserString(_KEY_ID) or "",
|
||||
@@ -2604,6 +2817,7 @@ def _read_meta(obj):
|
||||
"wand_layered": w_layered,
|
||||
"wand_layers": w_layers,
|
||||
"wand_layer_idx": w_layer_idx,
|
||||
"wand_chain_members": w_chain_members,
|
||||
"aussp_parent": aussp_parent_raw,
|
||||
}
|
||||
except Exception:
|
||||
@@ -2628,8 +2842,17 @@ def _find_axis(doc, wall_id):
|
||||
|
||||
|
||||
def _find_volume(doc, wall_id):
|
||||
# Direkter Hit: Volume gehoert genau dieser Wand
|
||||
for obj, meta in _find_objects_by_wall_id(doc, wall_id, "wand_volume"):
|
||||
return obj
|
||||
# Indirekter Hit: Wand ist Non-Anchor in einem Chain — Volume haengt am
|
||||
# Anchor, fuehrt aber wand_chain_members das diese Wand enthaelt
|
||||
for obj in doc.Objects:
|
||||
meta = _read_meta(obj)
|
||||
if meta and meta.get("type") == "wand_volume":
|
||||
members = meta.get("wand_chain_members") or []
|
||||
if wall_id in members:
|
||||
return obj
|
||||
return None
|
||||
|
||||
|
||||
@@ -4815,6 +5038,7 @@ def _make_treppe_volume(axis_curve, breite, referenz, n_stufen, uk, ok,
|
||||
return None
|
||||
|
||||
|
||||
|
||||
def _regenerate_element(doc, element_id):
|
||||
"""Regeneriert das Volumen eines Elements (Wand oder Decke) anhand
|
||||
seines Source-Objekts (Achse bzw. Outline)."""
|
||||
@@ -4860,13 +5084,94 @@ def _regenerate_element_body(doc, element_id, src_obj, meta, geom, geschoss_name
|
||||
"""Eigentliche Implementierung des Regen — der aeussere Wrapper
|
||||
`_regenerate_element` setzt _REGEN_BUSY und dispatcht oeffnung_point."""
|
||||
if meta["type"] == "wand_axis":
|
||||
# Chain-Detection: wenn diese Wand mit Nachbarn (gleiche Geometrie/
|
||||
# Material/Hoehe, 2-Wall-Joints) zu einem Polyline-Chain gehoert,
|
||||
# baut nur die Anchor-Wand (alphabetisch kleinste ID) das gemeinsame
|
||||
# Volume. Andere Chain-Members loeschen ggf. ihr eigenes Volume und
|
||||
# triggern Anchor-Regen.
|
||||
chain_ids = []
|
||||
try:
|
||||
chain_ids = _find_wall_chain(doc, element_id)
|
||||
except Exception as ex:
|
||||
print("[ELEMENTE] chain detect:", ex)
|
||||
if len(chain_ids) > 1:
|
||||
anchor = _chain_anchor(chain_ids)
|
||||
if anchor != element_id:
|
||||
# Non-Anchor: eigenes altes Volume entfernen, Anchor regenen
|
||||
for o, _m in _find_objects_by_wall_id(doc, element_id,
|
||||
"wand_volume"):
|
||||
try: doc.Objects.Delete(o.Id, True)
|
||||
except Exception: pass
|
||||
try: _regenerate_element(doc, anchor)
|
||||
except Exception as ex:
|
||||
print("[ELEMENTE] anchor regen:", ex)
|
||||
# Pruefen: hat Anchor ein Chain-Volume gebaut das uns deckt?
|
||||
# Wenn ja → fertig. Wenn nein (z.B. Polyline-Build failed) →
|
||||
# weiterlaufen + uns als Solo-Wand bauen.
|
||||
covered = False
|
||||
for obj in doc.Objects:
|
||||
m = _read_meta(obj)
|
||||
if m and m.get("type") == "wand_volume":
|
||||
members = m.get("wand_chain_members") or []
|
||||
if element_id in members:
|
||||
covered = True; break
|
||||
if covered:
|
||||
return True
|
||||
# Anchor hat keine Chain — wir bauen Solo. chain_ids leer
|
||||
# damit chain_set + Miter-Logik wie Solo wirkt.
|
||||
chain_ids = []
|
||||
# Anchor: Polyline aus den Chain-Achsen bauen + als geom verwenden.
|
||||
# Vor-Verifikation: Brep-Build mit Polyline probieren. Wenn das
|
||||
# fehlschlaegt (z.B. Innenseite-Offset self-intersect) → Chain-
|
||||
# Modus dropen + per-Wand regenen. Sonst gibt's invisible walls.
|
||||
chain_curve = None
|
||||
try:
|
||||
chain_curve = _build_chain_polyline(doc, chain_ids)
|
||||
except Exception as ex:
|
||||
print("[ELEMENTE] chain polyline:", ex)
|
||||
if chain_curve is not None:
|
||||
_test_uk, _test_ok = _resolve_uk_ok(doc, meta["geschoss"],
|
||||
meta["uk_override"],
|
||||
meta["ok_override"])
|
||||
_test_brep = _make_volume_geometry(
|
||||
chain_curve, meta["dicke"], _test_uk, _test_ok,
|
||||
meta.get("referenz", "mid"))
|
||||
if _test_brep is None:
|
||||
print("[ELEMENTE] chain {} brep build FAILED — fallback "
|
||||
"per-wand (Offset evtl. mehrteilig)".format(chain_ids))
|
||||
chain_curve = None
|
||||
if chain_curve is None:
|
||||
# Chain-Modus dropen: Anchor verhaelt sich wie Solo-Wand.
|
||||
# chain_ids leerziehen damit chain_set spaeter auch nur den
|
||||
# element_id enthaelt (Miter-Logik wirkt wie Solo).
|
||||
# Andere Chain-Members werden NICHT requeued — sonst Loop
|
||||
# (sie wuerden chain detecten + Anchor anrufen + wieder
|
||||
# failure). Stattdessen: ihre eigenen Regen-Calls
|
||||
# (Move-Listener → deps) fallen via covered-Check durch
|
||||
# auf Solo. Bei Erstellung wird _make_wall_from_axis ohnehin
|
||||
# _find_dependent_walls auf alle benachbarten Waende anwenden.
|
||||
chain_ids = []
|
||||
else:
|
||||
# Stale wand_volumes der anderen Chain-Members raeumen
|
||||
for wid in chain_ids:
|
||||
if wid == element_id: continue
|
||||
for o, _m in _find_objects_by_wall_id(doc, wid,
|
||||
"wand_volume"):
|
||||
try: doc.Objects.Delete(o.Id, True)
|
||||
except Exception: pass
|
||||
geom = chain_curve
|
||||
uk, ok = _resolve_uk_ok(doc, meta["geschoss"],
|
||||
meta["uk_override"], meta["ok_override"])
|
||||
print("[ELEMENTE] regen wand {}: uk={:.3f} ok={:.3f} (uk_over='{}' ok_over='{}')".format(
|
||||
element_id, uk, ok, meta.get("uk_override", ""), meta.get("ok_override", "")))
|
||||
print("[ELEMENTE] regen wand {}: uk={:.3f} ok={:.3f} chain={} (uk_over='{}' ok_over='{}')".format(
|
||||
element_id, uk, ok, len(chain_ids) if chain_ids else 1,
|
||||
meta.get("uk_override", ""), meta.get("ok_override", "")))
|
||||
# Wand-Verbindungen: Miter-Linien aus Nachbarwand-Joints (Corner + T).
|
||||
# Bei Chain: alle Chain-Members ausschliessen, weil sie an Polyline-
|
||||
# Endpunkten dort nicht als "externe Nachbarn" zaehlen sollen — die
|
||||
# liegen ja schon in der gejointen Polyline drin.
|
||||
miter_start = None
|
||||
miter_end = None
|
||||
chain_set = set(chain_ids) if (chain_ids and len(chain_ids) > 1) else {element_id}
|
||||
try:
|
||||
joints = _collect_wall_joints(doc, meta["geschoss"])
|
||||
out_s, out_e = _wall_out_dirs(geom)
|
||||
@@ -4876,7 +5181,7 @@ def _regenerate_element_body(doc, element_id, src_obj, meta, geom, geschoss_name
|
||||
key_s = _pt_key(p_s)
|
||||
partners_s = [(wid, end, od)
|
||||
for (wid, end, od) in joints.get(key_s, [])
|
||||
if wid != element_id]
|
||||
if wid not in chain_set]
|
||||
if len(partners_s) == 1:
|
||||
_wid, _end, other_out = partners_s[0]
|
||||
mdir = _miter_dir(out_s, other_out)
|
||||
@@ -4884,7 +5189,8 @@ def _regenerate_element_body(doc, element_id, src_obj, meta, geom, geschoss_name
|
||||
miter_start = (p_s, mdir)
|
||||
elif len(partners_s) == 0:
|
||||
tj = _detect_t_junction(doc, meta["geschoss"],
|
||||
element_id, p_s)
|
||||
element_id, p_s,
|
||||
exclude_ids=chain_set)
|
||||
if tj is not None:
|
||||
_oid, b_tan, b_dicke = tj
|
||||
tm = _t_junction_miter(p_s, out_s, b_tan, b_dicke)
|
||||
@@ -4893,7 +5199,7 @@ def _regenerate_element_body(doc, element_id, src_obj, meta, geom, geschoss_name
|
||||
key_e = _pt_key(p_e)
|
||||
partners_e = [(wid, end, od)
|
||||
for (wid, end, od) in joints.get(key_e, [])
|
||||
if wid != element_id]
|
||||
if wid not in chain_set]
|
||||
if len(partners_e) == 1:
|
||||
_wid, _end, other_out = partners_e[0]
|
||||
mdir = _miter_dir(out_e, other_out)
|
||||
@@ -4901,7 +5207,8 @@ def _regenerate_element_body(doc, element_id, src_obj, meta, geom, geschoss_name
|
||||
miter_end = (p_e, mdir)
|
||||
elif len(partners_e) == 0:
|
||||
tj = _detect_t_junction(doc, meta["geschoss"],
|
||||
element_id, p_e)
|
||||
element_id, p_e,
|
||||
exclude_ids=chain_set)
|
||||
if tj is not None:
|
||||
_oid, b_tan, b_dicke = tj
|
||||
tm = _t_junction_miter(p_e, out_e, b_tan, b_dicke)
|
||||
@@ -4923,12 +5230,29 @@ def _regenerate_element_body(doc, element_id, src_obj, meta, geom, geschoss_name
|
||||
geom, meta["dicke"], uk, ok,
|
||||
meta.get("referenz", "mid"),
|
||||
miter_start=miter_start, miter_end=miter_end)
|
||||
if single_brep is None:
|
||||
# Bei Chain: Polyline ist Curve, Single-Wall-Logik koennte
|
||||
# an mehrteiliger Curve scheitern. Pro-Segment-Fallback.
|
||||
if (chain_ids and len(chain_ids) > 1
|
||||
and isinstance(geom, rg.PolylineCurve)):
|
||||
print("[ELEMENTE] chain volume geom FAILED, fallback "
|
||||
"per-segment (chain={})".format(chain_ids))
|
||||
else:
|
||||
print("[ELEMENTE] wand volume geom FAILED for {}".format(
|
||||
element_id))
|
||||
layer_breps = [(single_brep, "", "")] if single_brep else []
|
||||
|
||||
# Oeffnungen einsammeln + Cutouts pro Schicht anwenden.
|
||||
# Chain: alle Member-Walls liefern Oeffnungen — der Anchor baut ein
|
||||
# gemeinsames Brep, alle Cutouts werden dort eingearbeitet.
|
||||
opening_jobs = []
|
||||
cutouts = []
|
||||
for op_obj, op_meta in _find_openings_for_wall(doc, element_id):
|
||||
opening_walls = (chain_ids if (chain_ids and len(chain_ids) > 1)
|
||||
else [element_id])
|
||||
all_openings = []
|
||||
for _wid in opening_walls:
|
||||
all_openings.extend(_find_openings_for_wall(doc, _wid))
|
||||
for op_obj, op_meta in all_openings:
|
||||
pt_geom = op_obj.Geometry
|
||||
pt_loc = None
|
||||
if hasattr(pt_geom, 'Location'):
|
||||
@@ -5126,8 +5450,9 @@ def _regenerate_element_body(doc, element_id, src_obj, meta, geom, geschoss_name
|
||||
mat_name = lay_def.get("material", "") if is_layered else ""
|
||||
effective_color = color
|
||||
target_layer = layer
|
||||
if mat_name and mat_name in _MATERIAL_LIBRARY:
|
||||
effective_color = _MATERIAL_LIBRARY[mat_name]["color"]
|
||||
all_mats = _get_all_materials(doc)
|
||||
if mat_name and mat_name in all_mats:
|
||||
effective_color = all_mats[mat_name]["color"]
|
||||
target_layer = _ensure_material_sublayer(doc, geschoss_name,
|
||||
mat_name)
|
||||
if target_layer < 0: target_layer = layer
|
||||
@@ -5155,7 +5480,9 @@ def _regenerate_element_body(doc, element_id, src_obj, meta, geom, geschoss_name
|
||||
meta.get("referenz", "mid"),
|
||||
wand_layered=is_layered,
|
||||
wand_layers=layers_json,
|
||||
wand_layer_idx=idx)
|
||||
wand_layer_idx=idx,
|
||||
wand_chain_members=(chain_ids
|
||||
if (chain_ids and len(chain_ids) > 1) else None))
|
||||
try: doc.Objects.AddBrep(lbrep, attrs)
|
||||
except Exception as ex: print("[ELEMENTE] AddBrep wand layer:", ex)
|
||||
return True
|
||||
@@ -5837,7 +6164,7 @@ class ElementeBridge(panel_base.BaseBridge):
|
||||
"materials": [
|
||||
{"name": n, "color": m["color"],
|
||||
"hatch": m.get("hatch", ""), "scale": m.get("scale", 1.0)}
|
||||
for n, m in _MATERIAL_LIBRARY.items()],
|
||||
for n, m in _get_all_materials(doc).items()],
|
||||
"oeffStyles": list_oeff_styles(doc),
|
||||
}
|
||||
self.send("STATE", payload)
|
||||
@@ -9405,6 +9732,14 @@ class ElementeBridge(panel_base.BaseBridge):
|
||||
try:
|
||||
dicke = sum(float(l.get("dicke", 0)) for l in wand_layers)
|
||||
except Exception: pass
|
||||
# ALTE Chain merken: Property-Aenderung kann Chain brechen
|
||||
# (z.B. dicke geaendert -> nicht mehr kompatibel). Nach dem Regen
|
||||
# muessen ehemalige Chain-Members eigenes Volume erhalten.
|
||||
old_chain_members = []
|
||||
if old_meta["type"] == "wand_axis":
|
||||
try:
|
||||
old_chain_members = _find_wall_chain(doc, wall_id)
|
||||
except Exception: pass
|
||||
_attach_meta(attrs, wall_id, old_meta["type"], geschoss, dicke,
|
||||
uk_over, ok_over, referenz,
|
||||
neigung=neigung, eave_idx=eave_idx, dach_typ=dach_typ,
|
||||
@@ -9416,6 +9751,13 @@ class ElementeBridge(panel_base.BaseBridge):
|
||||
axis_obj.CommitChanges()
|
||||
# Volumen regenerieren (Layer ggf. anpassen)
|
||||
_regenerate_volume(doc, wall_id)
|
||||
# Ehemalige Chain-Members re-regen damit sie ihr eigenes Volume
|
||||
# bekommen (oder einen neuen Anchor bilden)
|
||||
for wid in old_chain_members:
|
||||
if wid == wall_id: continue
|
||||
try: _regenerate_element(doc, wid)
|
||||
except Exception as ex:
|
||||
print("[ELEMENTE] old-chain regen:", ex)
|
||||
doc.Views.Redraw()
|
||||
self._send_state()
|
||||
|
||||
@@ -9532,6 +9874,28 @@ def _apply_wand_z_drag_constraint(new_obj, meta):
|
||||
z1 = geom.PointAtEnd.Z
|
||||
if abs(z0) < 1e-6 and abs(z1) < 1e-6:
|
||||
return False
|
||||
# In Top-View kann ein Z != 0 NICHT vom User stammen (er kann in Top gar
|
||||
# nicht Z-draggen). Es ist ein Artefakt — typischerweise CommitChanges
|
||||
# auf GripsOn oder Replace mit veraltetem Display-Z. Wand-Axis flatten
|
||||
# ohne uk/ok zu modifizieren.
|
||||
if _is_active_view_top_like():
|
||||
print("[ELEMENTE] wand z-drag IGNORED (top view, z0={:.3f} z1={:.3f}) "
|
||||
"— axis auf Z=0 flatten".format(z0, z1))
|
||||
try:
|
||||
doc = Rhino.RhinoDoc.ActiveDoc
|
||||
if doc is None: return False
|
||||
if isinstance(geom, rg.LineCurve):
|
||||
line = geom.Line
|
||||
flat = rg.LineCurve(
|
||||
rg.Point3d(line.From.X, line.From.Y, 0.0),
|
||||
rg.Point3d(line.To.X, line.To.Y, 0.0))
|
||||
else:
|
||||
flat = geom.DuplicateCurve()
|
||||
flat.Translate(rg.Vector3d(0, 0, -(z0 if abs(z0) > abs(z1) else z1)))
|
||||
doc.Objects.Replace(new_obj.Id, flat)
|
||||
except Exception as ex:
|
||||
print("[ELEMENTE] flatten in top-view:", ex)
|
||||
return False
|
||||
delta = z1 if abs(z1) > abs(z0) else z0
|
||||
print("[ELEMENTE] wand z-drag triggered: z0={:.3f} z1={:.3f} delta={:.3f}".format(z0, z1, delta))
|
||||
doc = Rhino.RhinoDoc.ActiveDoc
|
||||
@@ -9733,6 +10097,81 @@ def _apply_oeffnung_constraint(new_obj, meta, old_obj=None):
|
||||
return geom_changed
|
||||
|
||||
|
||||
def _is_active_view_top_like():
|
||||
"""True wenn der aktive Viewport von oben/unten reinschaut (Plan-
|
||||
Ansicht). Erkennt Top + Bottom + custom Views deren Kamera-Richtung
|
||||
ueberwiegend vertikal ist."""
|
||||
try:
|
||||
doc = Rhino.RhinoDoc.ActiveDoc
|
||||
if doc is None: return False
|
||||
view = doc.Views.ActiveView
|
||||
if view is None: return False
|
||||
vp = view.ActiveViewport
|
||||
if vp is None: return False
|
||||
cam = vp.CameraDirection
|
||||
if cam.Length < 1e-9: return False
|
||||
cam_n = rg.Vector3d(cam.X, cam.Y, cam.Z)
|
||||
cam_n.Unitize()
|
||||
# Vertikal = |Z-Komponente| nahe 1.0
|
||||
return abs(cam_n.Z) > 0.95
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _suppress_z_drift_if_top_view(e):
|
||||
"""Bei Move in Top-View: Z-Komponente des Drifts zurueckrollen.
|
||||
Vergleicht Z der OldRhinoObject-Geometry mit der NewRhinoObject-
|
||||
Geometry; wenn signifikanter Z-Drift, translatiert die neue Geometry
|
||||
in -Z zurueck.
|
||||
|
||||
Wird NUR fuer SOURCE-Curves gerufen (Caller filtert vorher). Smart-
|
||||
Elements sollen in Plan-Ansicht 2D-Verhalten haben — Z-Drift via
|
||||
Z-Snap oder Gumball-Z ist meist ungewollt.
|
||||
|
||||
Wenn der User WIRKLICH Z aendern will: einfach in eine andere View
|
||||
wechseln (Front, Right, Perspective)."""
|
||||
if not _is_active_view_top_like(): return
|
||||
try:
|
||||
old_obj = e.OldRhinoObject
|
||||
new_obj = e.NewRhinoObject
|
||||
if old_obj is None or new_obj is None: return
|
||||
old_geom = old_obj.Geometry
|
||||
new_geom = new_obj.Geometry
|
||||
if old_geom is None or new_geom is None: return
|
||||
# BBox-Min-Z als Referenz nehmen — robust gegen unterschiedliche
|
||||
# Curve-Typen (Line, Polyline, Point, Curve).
|
||||
try: bb_old = old_geom.GetBoundingBox(True)
|
||||
except Exception: return
|
||||
try: bb_new = new_geom.GetBoundingBox(True)
|
||||
except Exception: return
|
||||
if not bb_old.IsValid or not bb_new.IsValid: return
|
||||
z_old = bb_old.Min.Z
|
||||
z_new = bb_new.Min.Z
|
||||
dz = z_new - z_old
|
||||
# Schwellwert 0.001 Doc-Units (≈ 1mm wenn Doc in m) damit Floating-
|
||||
# Point-Mikro-Drift nicht panisch korrigiert wird.
|
||||
if abs(dz) < 0.001: return
|
||||
# Korrigiere: new geometry um -dz in Z verschieben.
|
||||
corrected = new_geom.Duplicate()
|
||||
try:
|
||||
corrected.Transform(rg.Transform.Translation(0, 0, -dz))
|
||||
except Exception: return
|
||||
# Replace — _REGEN_BUSY setzen damit der Replace-Event nicht
|
||||
# rekursiv wieder unseren Guard triggert.
|
||||
doc = Rhino.RhinoDoc.ActiveDoc
|
||||
if doc is None: return
|
||||
_was = sc.sticky.get(_REGEN_BUSY, False)
|
||||
sc.sticky[_REGEN_BUSY] = True
|
||||
try:
|
||||
doc.Objects.Replace(new_obj.Id, corrected)
|
||||
print("[ELEMENTE] Top-View Z-Guard: Δz={:.4f} → 0 fuer {}".format(
|
||||
dz, new_obj.Attributes.GetUserString(_KEY_ID) or "?"))
|
||||
finally:
|
||||
sc.sticky[_REGEN_BUSY] = _was
|
||||
except Exception as ex:
|
||||
print("[ELEMENTE] _suppress_z_drift_if_top_view:", ex)
|
||||
|
||||
|
||||
def _on_object_replaced(sender, e):
|
||||
"""Wenn eine Source (Wand-Achse/Decke-Outline/etc.) veraendert wird
|
||||
→ Regeneration queuen (debounct ueber Idle, 50 ms Ruhe).
|
||||
@@ -9792,6 +10231,14 @@ def _on_object_replaced_body(sender, e):
|
||||
except Exception: pass
|
||||
if meta is None or meta.get("type") not in SOURCE_TYPES:
|
||||
return
|
||||
# Top-View Z-Guard: in Plan-Ansicht soll keine Z-Verschiebung
|
||||
# passieren. User bewegt Wand/Decke via _Move oder Gumball und
|
||||
# erwartet 2D-Verhalten — Z-Drift durch versehentliches Snappen
|
||||
# auf Z!=0 Objekte oder Gumball-Z wird zurueckgenommen.
|
||||
try:
|
||||
_suppress_z_drift_if_top_view(e)
|
||||
except Exception as ex:
|
||||
print("[ELEMENTE] z-guard:", ex)
|
||||
try:
|
||||
new_obj = e.NewRhinoObject
|
||||
if new_obj and not _read_meta(new_obj):
|
||||
@@ -9864,19 +10311,36 @@ def _on_object_replaced_body(sender, e):
|
||||
|
||||
# Oeffnungen entlang der neuen Achse migrieren + Regen einreihen.
|
||||
if meta.get("type") == "wand_axis":
|
||||
old_geom = e.OldRhinoObject.Geometry if e.OldRhinoObject else None
|
||||
new_geom = e.NewRhinoObject.Geometry if e.NewRhinoObject else None
|
||||
# No-Op-Check: GripsOn/CommitChanges feuert Replace ohne sinnvolle
|
||||
# Geometrie-Aenderung. Toleranz auf 1mm (Architektur-Praezision).
|
||||
unchanged = False
|
||||
try:
|
||||
if (isinstance(old_geom, rg.Curve)
|
||||
and isinstance(new_geom, rg.Curve)):
|
||||
p_o_s, p_o_e = old_geom.PointAtStart, old_geom.PointAtEnd
|
||||
p_n_s, p_n_e = new_geom.PointAtStart, new_geom.PointAtEnd
|
||||
tol_xy = 0.001 # 1 mm
|
||||
if (p_o_s.DistanceTo(p_n_s) < tol_xy
|
||||
and p_o_e.DistanceTo(p_n_e) < tol_xy):
|
||||
try:
|
||||
l_o = old_geom.GetLength()
|
||||
l_n = new_geom.GetLength()
|
||||
if abs(l_o - l_n) < tol_xy:
|
||||
unchanged = True
|
||||
except Exception:
|
||||
unchanged = True
|
||||
except Exception: unchanged = False
|
||||
if unchanged:
|
||||
return
|
||||
# Joint-Cache invalidieren — Wand hat sich geaendert
|
||||
_invalidate_joints_cache(meta.get("geschoss"))
|
||||
try:
|
||||
old_geom = e.OldRhinoObject.Geometry if e.OldRhinoObject else None
|
||||
new_geom = e.NewRhinoObject.Geometry if e.NewRhinoObject else None
|
||||
_migrate_openings_to_new_axis(meta["id"], old_geom, new_geom)
|
||||
except Exception as ex:
|
||||
print("[ELEMENTE] migrate openings:", ex)
|
||||
# Wand-Verbindungen: alle ABHAENGIGEN Waende mit re-regenerieren.
|
||||
# Das umfasst sowohl Corner-Partner (Endpunkte teilen) als auch
|
||||
# T-Stoss-Wande (Endpunkt liegt auf der bewegten Achse). Wir
|
||||
# checken gegen ALTE und NEUE Geometrie damit auch sich-loesende
|
||||
# Verbindungen erkannt werden.
|
||||
try:
|
||||
doc2 = Rhino.RhinoDoc.ActiveDoc
|
||||
if doc2 is not None:
|
||||
@@ -10774,6 +11238,10 @@ def _on_idle_selection(sender, e):
|
||||
_USER_TRANSFORM_CMDS = frozenset((
|
||||
"Move", "Rotate", "Rotate3D", "Mirror", "Scale", "Scale1D", "Scale2D",
|
||||
"Drag", "Gumball", "Orient", "Orient3Pt", "RemapCPlane", "Transform",
|
||||
# Mac Rhino feuert "ReplaceObjectProxy" als Command-Name beim Grip-Drag.
|
||||
# Ohne dieses Eintrag kommt unser Regen in eigenen Idle-Undo-Record →
|
||||
# Cmd+Z muss zweimal gedrueckt werden.
|
||||
"ReplaceObjectProxy",
|
||||
))
|
||||
|
||||
# Bulk-Operations: User selektiert N Objekte + ausfuehrt die Operation
|
||||
@@ -10924,6 +11392,11 @@ def _on_command_begin(sender, e):
|
||||
except Exception: name = ""
|
||||
doc = Rhino.RhinoDoc.ActiveDoc
|
||||
if doc is None: return
|
||||
# ReplaceObjectProxy feuert auch fuer unsere eigenen internen Replace-
|
||||
# Calls (chain-volume rebuild etc.). Wenn _REGEN_BUSY aktiv ist, kommt
|
||||
# der Replace von uns — keinen Snapshot/Undo-Record oeffnen.
|
||||
if name == "ReplaceObjectProxy" and sc.sticky.get(_REGEN_BUSY):
|
||||
return
|
||||
# Undo/Redo: nur Flag setzen, KEIN Snapshot, KEIN Redraw-Suppress —
|
||||
# Rhinos Undo verwaltet RedrawEnabled selbst. Event-Handler ignorieren
|
||||
# waehrend dieser Phase alle Add/Delete/Replace-Events → kein Regen-
|
||||
@@ -11311,6 +11784,37 @@ def _on_command_end(sender, e):
|
||||
elif canonical is not None:
|
||||
pure_transform = canonical
|
||||
|
||||
# Chain-Volumes-Pre-Check (basierend auf EXISTIERENDEN wand_chain_members,
|
||||
# nicht auf Live-Detection — sonst sieht Pre-Check schon die NEUE
|
||||
# Topologie und merkt zerbrochene Chains nicht):
|
||||
# Wenn ein chain-volume einen unserer moved walls referenziert UND nicht
|
||||
# ALLE chain_members mit-bewegt wurden → pure-transform ist unsicher
|
||||
# (Chain-Volume kann nicht einfach mit-translatiert werden, Chain muss
|
||||
# neu vermessen + rebuilded werden). Wenn alle members im Move → Volume
|
||||
# darf mit pure-transform folgen.
|
||||
chain_volume_ids_to_follow = set()
|
||||
affected_chain_members = set() # alle wall_ids deren Chain teil-bewegt wurde
|
||||
if pure_transform is not None:
|
||||
moved_axis_ids = set()
|
||||
for moved_id in moved_ids:
|
||||
old = sources_snap.get(moved_id) or {}
|
||||
if old.get("type") == "wand_axis":
|
||||
moved_axis_ids.add(moved_id)
|
||||
if moved_axis_ids:
|
||||
for obj in doc.Objects:
|
||||
m = _read_meta(obj)
|
||||
if not m or m.get("type") != "wand_volume": continue
|
||||
members = m.get("wand_chain_members") or []
|
||||
if not members: continue
|
||||
if not any(wid in moved_axis_ids for wid in members):
|
||||
continue
|
||||
if not all(wid in moved_axis_ids for wid in members):
|
||||
print("[ELEMENTE] chain partial-move → abort pure-transform")
|
||||
affected_chain_members.update(members)
|
||||
pure_transform = None
|
||||
else:
|
||||
chain_volume_ids_to_follow.add(str(obj.Id))
|
||||
|
||||
if pure_transform is not None:
|
||||
# PURE-TRANSFORM PFAD: Transform auf alle Geometries anwenden die
|
||||
# nicht schon vom User-Move transformed wurden. Funktioniert fuer
|
||||
@@ -11324,11 +11828,14 @@ def _on_command_end(sender, e):
|
||||
tx, ty, tz, rot_deg))
|
||||
|
||||
# Eltern→Kind-Cascade: nur bewegte Sources + deren Children folgen.
|
||||
def _should_follow(m):
|
||||
# Chain-Volumes folgen wenn alle Chain-Members im Move waren.
|
||||
def _should_follow(m, obj_id_str=""):
|
||||
eid = m.get("id")
|
||||
if eid in moved_ids: return True
|
||||
parent = m.get("oeff_parent")
|
||||
if parent and parent in moved_ids: return True
|
||||
if obj_id_str and obj_id_str in chain_volume_ids_to_follow:
|
||||
return True
|
||||
return False
|
||||
|
||||
_was_busy = sc.sticky.get(_REGEN_BUSY, False)
|
||||
@@ -11339,7 +11846,7 @@ def _on_command_end(sender, e):
|
||||
m = _read_meta(obj)
|
||||
if not m: continue
|
||||
t = m.get("type")
|
||||
if not _should_follow(m): continue
|
||||
if not _should_follow(m, str(obj.Id)): continue
|
||||
# Sources die nicht bewegt wurden (= identity transform)
|
||||
# transformen — nur via _should_follow erlaubt (Cascade).
|
||||
if t in SOURCE_TYPES:
|
||||
@@ -11509,6 +12016,12 @@ def _on_command_end(sender, e):
|
||||
sc.sticky[_REGEN_BUSY] = _was_busy
|
||||
sc.sticky["_dossier_skip_sync_regen"] = None
|
||||
|
||||
# Chain-Survivor: alle Members einer teil-bewegten Chain regenen, auch
|
||||
# wenn der einzelne Member nicht in affected_walls steckt. Sonst bleibt
|
||||
# ein non-anchor-survivor ohne Volume zurueck (sein altes Volume gehoerte
|
||||
# der inzwischen geloeschten/verschobenen Anchor).
|
||||
for wid in affected_chain_members:
|
||||
affected_walls.add(wid)
|
||||
# Sync-Regen aller betroffenen Wände — Move ist sauber abgeschlossen,
|
||||
# kein Konflikt mehr moeglich. EIN Regen pro Wand (nicht pro Oeffnung).
|
||||
# Display bleibt suppressed bis ALLE Wände durch sind → kein „Aufbauen".
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
#! python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
library.py — Dossier-Library (Phase A: lokal, read-only)
|
||||
|
||||
Library = wiederverwendbare Material-/Symbol-/Object-Templates die ein User
|
||||
oder ein Team teilt. Phase A: lokaler Ordner mit library.json + Assets.
|
||||
Spaeter (Phase B/C): Cloud-Sync via GitHub-Releases.
|
||||
|
||||
Library-Root: ~/Library/Application Support/Dossier/library/
|
||||
Struktur:
|
||||
library/
|
||||
library.json # Manifest (Schema-Version + items[])
|
||||
previews/ # PNG-Thumbnails
|
||||
assets/ # .3dm-Fragmente fuer symbol/object
|
||||
|
||||
Manifest-Item:
|
||||
{
|
||||
"id": "mat-beton-roh-v1", # global eindeutig (UUID/Slug)
|
||||
"type": "material", # material | symbol | object
|
||||
"name": "Beton roh",
|
||||
"version": 1, # Schema-Version des Items
|
||||
"tags": ["beton", "tragwerk"],
|
||||
"preview": "previews/mat-beton-roh.png",
|
||||
"data": { ... } # Typ-spezifische Felder
|
||||
}
|
||||
|
||||
Material-data: { color, hatch, scale }
|
||||
Symbol/Object-data: { files: ["assets/sym-foo.3dm"] }
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
|
||||
# Lazy Import von Rhino — library.py soll auch in einem Test-Skript ohne
|
||||
# Rhino-Kontext importierbar sein (z.B. fuer Seed-Logik).
|
||||
try:
|
||||
import Rhino # noqa: F401
|
||||
except Exception:
|
||||
Rhino = None
|
||||
|
||||
|
||||
_LIB_DIRNAME = "library"
|
||||
_MANIFEST_FN = "library.json"
|
||||
_SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
def library_root():
|
||||
"""Mac-Pfad zur lokalen Library. Wird bei Bedarf angelegt."""
|
||||
base = os.path.expanduser(
|
||||
"~/Library/Application Support/Dossier")
|
||||
return os.path.join(base, _LIB_DIRNAME)
|
||||
|
||||
|
||||
def ensure_library():
|
||||
"""Legt Library-Folder + Sub-Folders + Seed-Manifest an wenn leer."""
|
||||
root = library_root()
|
||||
if not os.path.isdir(root):
|
||||
try: os.makedirs(root)
|
||||
except Exception as ex:
|
||||
print("[LIBRARY] mkdir:", ex); return root
|
||||
for sub in ("previews", "assets"):
|
||||
p = os.path.join(root, sub)
|
||||
if not os.path.isdir(p):
|
||||
try: os.makedirs(p)
|
||||
except Exception: pass
|
||||
manifest_path = os.path.join(root, _MANIFEST_FN)
|
||||
if not os.path.isfile(manifest_path):
|
||||
_write_seed_manifest(manifest_path)
|
||||
return root
|
||||
|
||||
|
||||
def _write_seed_manifest(path):
|
||||
"""Bootstrappt 4 typische Architektur-Materialien als Start."""
|
||||
seed = {
|
||||
"schemaVersion": _SCHEMA_VERSION,
|
||||
"name": "Dossier-Library (lokal)",
|
||||
"items": [
|
||||
{
|
||||
"id": "mat-beton-sichtbeton-v1",
|
||||
"type": "material", "version": 1,
|
||||
"name": "Beton — Sichtbeton",
|
||||
"tags": ["beton", "tragwerk", "roh"],
|
||||
"data": {"color": "#a8a39b", "hatch": "Solid", "scale": 1.0},
|
||||
},
|
||||
{
|
||||
"id": "mat-mauerwerk-backstein-v1",
|
||||
"type": "material", "version": 1,
|
||||
"name": "Mauerwerk — Backstein",
|
||||
"tags": ["mauerwerk", "stein"],
|
||||
"data": {"color": "#a45a3c", "hatch": "Solid", "scale": 1.0},
|
||||
},
|
||||
{
|
||||
"id": "mat-daemmung-mineralwolle-v1",
|
||||
"type": "material", "version": 1,
|
||||
"name": "Daemmung — Mineralwolle",
|
||||
"tags": ["daemmung", "weich"],
|
||||
"data": {"color": "#e8d36b", "hatch": "Solid", "scale": 1.0},
|
||||
},
|
||||
{
|
||||
"id": "mat-holz-fichte-v1",
|
||||
"type": "material", "version": 1,
|
||||
"name": "Holz — Fichte",
|
||||
"tags": ["holz", "ausbau"],
|
||||
"data": {"color": "#c8a06a", "hatch": "Solid", "scale": 1.0},
|
||||
},
|
||||
],
|
||||
}
|
||||
try:
|
||||
with open(path, "w") as f:
|
||||
json.dump(seed, f, indent=2, ensure_ascii=False)
|
||||
print("[LIBRARY] Seed geschrieben:", path)
|
||||
except Exception as ex:
|
||||
print("[LIBRARY] seed write:", ex)
|
||||
|
||||
|
||||
def load_manifest():
|
||||
"""Liest library.json — legt Library bei Bedarf an. Returns dict mit
|
||||
schemaVersion + name + items[]. Bei Fehler leeres Manifest."""
|
||||
ensure_library()
|
||||
path = os.path.join(library_root(), _MANIFEST_FN)
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
data = json.load(f)
|
||||
if not isinstance(data, dict): return _empty_manifest()
|
||||
items = data.get("items")
|
||||
if not isinstance(items, list): data["items"] = []
|
||||
else: data["items"] = [_normalize_item(x) for x in items
|
||||
if _normalize_item(x) is not None]
|
||||
return data
|
||||
except Exception as ex:
|
||||
print("[LIBRARY] load_manifest:", ex)
|
||||
return _empty_manifest()
|
||||
|
||||
|
||||
def _empty_manifest():
|
||||
return {"schemaVersion": _SCHEMA_VERSION,
|
||||
"name": "Dossier-Library", "items": []}
|
||||
|
||||
|
||||
def _normalize_item(it):
|
||||
if not isinstance(it, dict): return None
|
||||
if not it.get("id") or not it.get("type"): return None
|
||||
out = {
|
||||
"id": str(it["id"]),
|
||||
"type": str(it["type"]),
|
||||
"name": str(it.get("name") or "Unbenannt"),
|
||||
"version": int(it.get("version") or 1),
|
||||
"tags": list(it.get("tags") or []),
|
||||
"preview": it.get("preview"),
|
||||
"data": it.get("data") or {},
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def find_item(item_id):
|
||||
"""Sucht ein Item per id im Manifest. Returns None wenn nicht da."""
|
||||
if not item_id: return None
|
||||
for it in load_manifest().get("items", []):
|
||||
if it.get("id") == item_id: return it
|
||||
return None
|
||||
|
||||
|
||||
# --- Import-Logik -----------------------------------------------------------
|
||||
|
||||
def import_material(doc, item):
|
||||
"""Importiert ein Material-Item in die Projekt-Settings des Doc. Dedupe
|
||||
per libraryId — wenn schon importiert, kein Doppel-Eintrag.
|
||||
Returns (ok, message)."""
|
||||
if doc is None: return False, "Kein aktives Dokument"
|
||||
if not isinstance(item, dict) or item.get("type") != "material":
|
||||
return False, "Item ist kein Material"
|
||||
data = item.get("data") or {}
|
||||
new_mat = {
|
||||
"name": item.get("name") or "Unbenannt",
|
||||
"color": data.get("color", "#888888"),
|
||||
"hatch": data.get("hatch", "Solid"),
|
||||
"scale": float(data.get("scale", 1.0) or 1.0),
|
||||
"source": "library",
|
||||
"libraryId": item.get("id"),
|
||||
}
|
||||
# Lazy-Import um Zyklen zu vermeiden
|
||||
import rhinopanel
|
||||
settings = rhinopanel.load_project_settings(doc)
|
||||
mats = list(settings.get("materials", []))
|
||||
for m in mats:
|
||||
if m.get("libraryId") == new_mat["libraryId"]:
|
||||
return False, "Material bereits importiert"
|
||||
mats.append(new_mat)
|
||||
settings["materials"] = mats
|
||||
rhinopanel.save_project_settings(doc, settings)
|
||||
return True, "Material importiert"
|
||||
|
||||
|
||||
def import_item(doc, item_id):
|
||||
"""Type-dispatching Import. Phase A: nur material. symbol/object kommen
|
||||
spaeter via File3dm.Read + InstanceDefinition."""
|
||||
item = find_item(item_id)
|
||||
if item is None: return False, "Item nicht gefunden: " + str(item_id)
|
||||
t = item.get("type")
|
||||
if t == "material":
|
||||
return import_material(doc, item)
|
||||
# Phase A: symbol/object noch nicht
|
||||
return False, "Typ '{}' wird erst in Phase A.2 unterstuetzt".format(t)
|
||||
+96
-10
@@ -722,17 +722,95 @@ def _set_display_mode(name):
|
||||
|
||||
# --- Snap / Ortho via ModelAidSettings --------------------------------------
|
||||
|
||||
# Architekten-relevante Osnap-Modes — UI-Key → OsnapModes-Flag.
|
||||
# Vollstaendige Liste in Rhino: End, Near, Focus, Center, Vertex, Knot,
|
||||
# Quadrant, Midpoint, Intersection, Perpendicular, Tangent, Point.
|
||||
# Architektur-Workflow nutzt v.a. die ersten 6.
|
||||
def _osnap_flag_map():
|
||||
try:
|
||||
OM = Rhino.ApplicationSettings.OsnapModes
|
||||
return {
|
||||
"end": OM.End,
|
||||
"mid": OM.Midpoint,
|
||||
"int": OM.Intersection,
|
||||
"perp": OM.Perpendicular,
|
||||
"cen": OM.Center,
|
||||
"near": OM.Near,
|
||||
}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _get_osnap_modes_dict():
|
||||
flags = _osnap_flag_map()
|
||||
if not flags: return {}
|
||||
try:
|
||||
cur = int(Rhino.ApplicationSettings.ModelAidSettings.OsnapModes)
|
||||
return {k: bool(cur & int(f)) for k, f in flags.items()}
|
||||
except Exception:
|
||||
return {k: False for k in flags}
|
||||
|
||||
|
||||
def _set_osnap_mode(key, enabled):
|
||||
flags = _osnap_flag_map()
|
||||
flag = flags.get(key)
|
||||
if flag is None: return
|
||||
try:
|
||||
s = Rhino.ApplicationSettings.ModelAidSettings
|
||||
cur = int(s.OsnapModes)
|
||||
flag_i = int(flag)
|
||||
new = (cur | flag_i) if enabled else (cur & ~flag_i)
|
||||
s.OsnapModes = Rhino.ApplicationSettings.OsnapModes(new)
|
||||
except Exception as ex:
|
||||
print("[OBERLEISTE] _set_osnap_mode:", ex)
|
||||
|
||||
|
||||
def _is_grid_visible():
|
||||
try:
|
||||
doc = Rhino.RhinoDoc.ActiveDoc
|
||||
if doc is None: return True
|
||||
view = doc.Views.ActiveView
|
||||
if view is None: return True
|
||||
vp = view.ActiveViewport
|
||||
try: return bool(vp.ConstructionGridVisible)
|
||||
except Exception:
|
||||
try: return bool(vp.ShowConstructionGrid)
|
||||
except Exception: return True
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
def _set_grid_visible(visible):
|
||||
"""Schaltet Konstruktions-Grid in ALLEN Modell-Viewports an/aus."""
|
||||
doc = Rhino.RhinoDoc.ActiveDoc
|
||||
if doc is None: return
|
||||
v_in = bool(visible)
|
||||
for v in doc.Views:
|
||||
try:
|
||||
vp = v.ActiveViewport
|
||||
try: vp.ConstructionGridVisible = v_in
|
||||
except Exception:
|
||||
try: vp.ShowConstructionGrid = v_in
|
||||
except Exception: pass
|
||||
except Exception: pass
|
||||
try: doc.Views.Redraw()
|
||||
except Exception: pass
|
||||
|
||||
|
||||
def _get_snap_state():
|
||||
try:
|
||||
s = Rhino.ApplicationSettings.ModelAidSettings
|
||||
return {
|
||||
"ortho": bool(s.Ortho),
|
||||
"gridSnap": bool(s.GridSnap),
|
||||
"osnap": bool(s.UseHorizontalDialog) if False else bool(getattr(s, "Osnap", False)) or False,
|
||||
"planar": bool(getattr(s, "ProjectOsnapsToCPlane", False)),
|
||||
"ortho": bool(s.Ortho),
|
||||
"gridSnap": bool(s.GridSnap),
|
||||
"osnap": bool(getattr(s, "Osnap", False)) or bool(getattr(s, "OsnapEnabled", False)),
|
||||
"planar": bool(getattr(s, "ProjectOsnapsToCPlane", False)),
|
||||
"gridVisible": _is_grid_visible(),
|
||||
"osnapModes": _get_osnap_modes_dict(),
|
||||
}
|
||||
except Exception:
|
||||
return {"ortho": False, "gridSnap": False, "osnap": False, "planar": False}
|
||||
return {"ortho": False, "gridSnap": False, "osnap": False,
|
||||
"planar": False, "gridVisible": True, "osnapModes": {}}
|
||||
|
||||
|
||||
def _set_ortho(v):
|
||||
@@ -753,11 +831,11 @@ def _set_osnap_master(v):
|
||||
"""Master-Toggle fuer Object-Snap (alle aktiven Snaps)."""
|
||||
try:
|
||||
s = Rhino.ApplicationSettings.ModelAidSettings
|
||||
if hasattr(s, "Osnap"):
|
||||
s.Osnap = bool(v)
|
||||
elif hasattr(s, "UsePoints"):
|
||||
# Fallback: einzelne Modi durch
|
||||
pass
|
||||
# Verschiedene Rhino-Versionen — beide Properties probieren
|
||||
for attr in ("Osnap", "OsnapEnabled"):
|
||||
if hasattr(s, attr):
|
||||
setattr(s, attr, bool(v))
|
||||
return
|
||||
except Exception as ex:
|
||||
print("[OBERLEISTE] _set_osnap_master:", ex)
|
||||
|
||||
@@ -1018,6 +1096,12 @@ class OberleisteBridge(panel_base.BaseBridge):
|
||||
elif t == "TOGGLE_OSNAP":
|
||||
_set_osnap_master(bool(p.get("enabled")))
|
||||
self._send_state(force=True)
|
||||
elif t == "SET_OSNAP_MODE":
|
||||
_set_osnap_mode(p.get("key") or "", bool(p.get("enabled")))
|
||||
self._send_state(force=True)
|
||||
elif t == "TOGGLE_GRID_VISIBLE":
|
||||
_set_grid_visible(bool(p.get("visible")))
|
||||
self._send_state(force=True)
|
||||
|
||||
# --- Graphical Overrides ----------------------------------------
|
||||
elif t == "TOGGLE_OVERRIDES":
|
||||
@@ -1464,6 +1548,8 @@ class OberleisteBridge(panel_base.BaseBridge):
|
||||
info.get("viewMode"),
|
||||
info.get("displayMode"),
|
||||
info.get("ortho"), info.get("gridSnap"), info.get("osnap"),
|
||||
info.get("gridVisible"),
|
||||
tuple(sorted((info.get("osnapModes") or {}).items())),
|
||||
info.get("showLineweights"),
|
||||
info["overridesEnabled"], info["overridesCount"],
|
||||
info.get("overridesActivePreset"),
|
||||
|
||||
@@ -96,6 +96,7 @@ def _broadcast_state(doc=None, hatch_patterns=None):
|
||||
"zeichnungsebenen": json.loads(z_raw) if z_raw else None,
|
||||
"ebenen": json.loads(e_raw) if e_raw else None,
|
||||
"projectZeroMum": zero_mum,
|
||||
"projectSettings": load_project_settings(doc),
|
||||
"hatchPatterns": hatch_patterns if hatch_patterns is not None
|
||||
else _hatch_pattern_names(doc),
|
||||
"layerCombinations": list_layer_preset_names(doc),
|
||||
@@ -120,6 +121,83 @@ def _broadcast_state(doc=None, hatch_patterns=None):
|
||||
_PRESETS_KEY = "dossier_layer_presets"
|
||||
_ACTIVE_COMB_KEY = "dossier_layer_active_comb"
|
||||
|
||||
# Projekt-Einstellungen: zentrale Voreinstellungen die beim Erstellen
|
||||
# neuer Geschosse / Schnitte / etc. als Default genommen werden. Pro-
|
||||
# Element-Werte ueberschreiben das natuerlich — das hier ist nur die
|
||||
# Voreinstellung. Persistiert pro Doc.
|
||||
_PROJECT_SETTINGS_KEY = "dossier_project_settings"
|
||||
|
||||
_PROJECT_SETTINGS_DEFAULTS = {
|
||||
"defaults": {
|
||||
"geschossHoehe": 3.0,
|
||||
"schnitthoehe": 1.0,
|
||||
"schnittDepthBack": 8.0,
|
||||
"schnittHeightMin": -1.0,
|
||||
"schnittHeightMax": 12.0,
|
||||
},
|
||||
"materials": [], # User-erweiterte Materialien (zusaetzlich zur
|
||||
# hardcoded _MATERIAL_LIBRARY in elemente.py)
|
||||
}
|
||||
|
||||
|
||||
def _normalize_material(m):
|
||||
"""Garantiert Material-Schema: name + color + hatch + scale + source
|
||||
+ libraryId. source: 'local' | 'library' | 'builtin'. libraryId: UUID
|
||||
wenn aus Library, sonst null. Felder fehlen bei alten Eintraegen — wir
|
||||
setzen Defaults damit Frontend nicht null-checken muss."""
|
||||
if not isinstance(m, dict): return None
|
||||
return {
|
||||
"name": m.get("name") or "Unbenannt",
|
||||
"color": m.get("color") or "#888888",
|
||||
"hatch": m.get("hatch") or "Solid",
|
||||
"scale": float(m.get("scale", 1.0) or 1.0),
|
||||
"source": m.get("source") or "local",
|
||||
"libraryId": m.get("libraryId"), # None wenn nicht aus Library
|
||||
}
|
||||
|
||||
|
||||
def load_project_settings(doc):
|
||||
"""Liefert die Project-Settings als dict — mit Defaults-Merge wenn
|
||||
Felder fehlen. Garantiert dass `defaults` und `materials` immer da
|
||||
sind, und Materialien normalisiert (source + libraryId)."""
|
||||
raw = None
|
||||
try: raw = doc.Strings.GetValue(_PROJECT_SETTINGS_KEY) if doc else None
|
||||
except Exception: raw = None
|
||||
out = {
|
||||
"defaults": dict(_PROJECT_SETTINGS_DEFAULTS["defaults"]),
|
||||
"materials": list(_PROJECT_SETTINGS_DEFAULTS["materials"]),
|
||||
}
|
||||
if not raw: return out
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
if isinstance(data, dict):
|
||||
d = data.get("defaults")
|
||||
if isinstance(d, dict):
|
||||
for k, v in d.items():
|
||||
out["defaults"][k] = v
|
||||
m = data.get("materials")
|
||||
if isinstance(m, list):
|
||||
out["materials"] = [
|
||||
_normalize_material(x) for x in m
|
||||
if _normalize_material(x) is not None
|
||||
]
|
||||
except Exception as ex:
|
||||
print("[PROJECT-SETTINGS] load:", ex)
|
||||
return out
|
||||
|
||||
|
||||
def save_project_settings(doc, settings):
|
||||
"""Persistiert Settings in doc.Strings. settings: dict mit
|
||||
'defaults' + 'materials'. Caller broadcastet ggf. selbst."""
|
||||
if doc is None or not isinstance(settings, dict): return False
|
||||
try:
|
||||
doc.Strings.SetString(_PROJECT_SETTINGS_KEY,
|
||||
json.dumps(settings, ensure_ascii=False))
|
||||
return True
|
||||
except Exception as ex:
|
||||
print("[PROJECT-SETTINGS] save:", ex)
|
||||
return False
|
||||
|
||||
|
||||
def load_layer_presets(doc):
|
||||
raw = doc.Strings.GetValue(_PRESETS_KEY)
|
||||
@@ -531,9 +609,131 @@ class EbenenBridge(panel_base.BaseBridge):
|
||||
try: open_layer_combinations_window()
|
||||
except Exception as ex:
|
||||
print("[EBENEN] open layer-combinations:", ex)
|
||||
elif t == "OPEN_PROJECT_SETTINGS":
|
||||
try: self._open_project_settings()
|
||||
except Exception as ex:
|
||||
print("[EBENEN] open project-settings:", ex)
|
||||
elif t == "OPEN_LIBRARY":
|
||||
try: self._open_library()
|
||||
except Exception as ex:
|
||||
print("[EBENEN] open library:", ex)
|
||||
|
||||
# ---- Helpers ----
|
||||
|
||||
def _open_project_settings(self):
|
||||
"""Oeffnet React-Satellite mit Projekt-Voreinstellungen (Geschoss-/
|
||||
Schnitt-Defaults + Material-Library). Werte werden nur als
|
||||
Voreinstellung beim Erstellen neuer Elemente genutzt — pro-Element
|
||||
editierte Werte bleiben davon unberuehrt."""
|
||||
doc = Rhino.RhinoDoc.ActiveDoc
|
||||
current = load_project_settings(doc)
|
||||
# Hardcoded Material-Library aus elemente.py mit-laden damit
|
||||
# Frontend die Defaults zeigt + User sie ggf. anpassen kann.
|
||||
try:
|
||||
import elemente
|
||||
built_in = []
|
||||
for name, m in elemente._MATERIAL_LIBRARY.items():
|
||||
built_in.append({
|
||||
"name": name,
|
||||
"color": m.get("color", "#888888"),
|
||||
"hatch": m.get("hatch", "Solid"),
|
||||
"scale": float(m.get("scale", 1.0)),
|
||||
"source": "builtin",
|
||||
"libraryId": None,
|
||||
"builtin": True, # legacy flag fuer aelteren UI-Code
|
||||
})
|
||||
except Exception:
|
||||
built_in = []
|
||||
params = {
|
||||
"defaults": current.get("defaults", {}),
|
||||
"materials": current.get("materials", []),
|
||||
"builtinMaterials": built_in,
|
||||
"hatchPatterns": _hatch_pattern_names(doc),
|
||||
}
|
||||
def on_save(updated):
|
||||
doc2 = Rhino.RhinoDoc.ActiveDoc
|
||||
if doc2 is None: return
|
||||
new_settings = {
|
||||
"defaults": updated.get("defaults", {}),
|
||||
"materials": updated.get("materials", []),
|
||||
}
|
||||
save_project_settings(doc2, new_settings)
|
||||
_broadcast_state(doc2)
|
||||
try:
|
||||
import scriptcontext as sc
|
||||
eb = sc.sticky.get("elemente_bridge")
|
||||
if eb is not None: eb._send_state()
|
||||
except Exception as ex:
|
||||
print("[PROJECT-SETTINGS] elemente refresh:", ex)
|
||||
panel_base.open_satellite_window(
|
||||
"project_settings",
|
||||
params=params,
|
||||
title="Projekt-Einstellungen",
|
||||
size=(440, 540),
|
||||
on_save=on_save)
|
||||
|
||||
def _open_library(self):
|
||||
"""Oeffnet den Library-Browser als Satellite. Bridge bleibt offen
|
||||
damit User mehrere Items hintereinander importieren kann; nach
|
||||
jedem Import wird der Material-Status zurueckgemeldet damit das
|
||||
UI 'importiert' anzeigt."""
|
||||
import library
|
||||
bridge_holder = {"form": None}
|
||||
class _LibraryBridge(panel_base.BaseBridge):
|
||||
def __init__(self):
|
||||
panel_base.BaseBridge.__init__(self, "library")
|
||||
def _on_ready(self):
|
||||
self._send_state()
|
||||
def _send_state(self):
|
||||
doc = Rhino.RhinoDoc.ActiveDoc
|
||||
manifest = library.load_manifest()
|
||||
imported_ids = set()
|
||||
if doc is not None:
|
||||
settings = load_project_settings(doc)
|
||||
for m in settings.get("materials", []):
|
||||
lid = m.get("libraryId")
|
||||
if lid: imported_ids.add(lid)
|
||||
self.send("LIBRARY_STATE", {
|
||||
"manifest": manifest,
|
||||
"importedIds": list(imported_ids),
|
||||
"libraryRoot": library.library_root(),
|
||||
})
|
||||
def handle(self, data):
|
||||
if not isinstance(data, dict): return
|
||||
t = data.get("type", "")
|
||||
p = data.get("payload") or {}
|
||||
if t == "READY":
|
||||
self._on_ready()
|
||||
elif t == "IMPORT_ITEM":
|
||||
doc = Rhino.RhinoDoc.ActiveDoc
|
||||
ok, msg = library.import_item(doc, p.get("id"))
|
||||
print("[LIBRARY] import {}: {} ({})".format(
|
||||
p.get("id"), ok, msg))
|
||||
if ok:
|
||||
_broadcast_state(doc)
|
||||
# Elemente-Panel-Dropdown muss neu laden (materials)
|
||||
try:
|
||||
import scriptcontext as sc
|
||||
eb = sc.sticky.get("elemente_bridge")
|
||||
if eb is not None: eb._send_state()
|
||||
except Exception as ex:
|
||||
print("[LIBRARY] elemente refresh:", ex)
|
||||
self._send_state()
|
||||
elif t == "RELOAD":
|
||||
self._send_state()
|
||||
elif t == "CLOSE":
|
||||
try:
|
||||
f = bridge_holder.get("form")
|
||||
if f is not None: f.Close()
|
||||
except Exception: pass
|
||||
b = _LibraryBridge()
|
||||
bridge_holder["form"] = panel_base.open_satellite_window(
|
||||
"library",
|
||||
params={},
|
||||
title="Dossier-Library",
|
||||
size=(640, 560),
|
||||
bridge=b)
|
||||
|
||||
def _open_geschoss_settings(self, geschoss):
|
||||
"""Oeffnet ein echtes Rhino-Fenster (Eto.Form mit WebView) mit dem
|
||||
GeschossSettingsDialog. Save updated den Eintrag in doc.Strings +
|
||||
|
||||
+48
-10
@@ -247,8 +247,23 @@ def activate_schnitt(doc, z, skip_view=False):
|
||||
obj = _add_clipping_plane(doc, back_plane, du, dv, vp_ids, "back")
|
||||
if obj is not None: n_planes += 1
|
||||
|
||||
# View setzen: Parallel-Projektion, Kamera senkrecht zur Linie.
|
||||
# Bei skip_view=True (Grip-Drag-Re-Activate) komplett ueberspringen.
|
||||
# Projektion: 'parallel' (klassischer Schnitt) oder 'perspective'
|
||||
# (Schnittperspektive — perspektivische Section mit gleicher Cut-
|
||||
# Logik). Bei perspective wird Kamera leicht naeher geholt + FOV
|
||||
# gesetzt; Cut-Planes sind identisch.
|
||||
projection = (z.get("projection") or "parallel").strip().lower()
|
||||
if projection not in ("parallel", "perspective"): projection = "parallel"
|
||||
|
||||
# Kamera-Z fuer Perspektive: explizit ueber cameraHeight setzbar,
|
||||
# sonst Default = Mitte der Hoehenrange (= plane_z). Bei Parallel
|
||||
# ignoriert weil Kamera-Z in Orthoprojektion das Bild nicht aendert.
|
||||
try:
|
||||
cam_z = float(z.get("cameraHeight")) if z.get("cameraHeight") is not None else plane_z
|
||||
except Exception:
|
||||
cam_z = plane_z
|
||||
|
||||
# View setzen — Kamera senkrecht zur Linie. Bei skip_view=True
|
||||
# (Grip-Drag-Re-Activate) komplett ueberspringen.
|
||||
if not skip_view:
|
||||
try:
|
||||
view = doc.Views.ActiveView
|
||||
@@ -257,18 +272,28 @@ def activate_schnitt(doc, z, skip_view=False):
|
||||
if view is not None:
|
||||
vp = view.ActiveViewport
|
||||
cam_dist = max(50.0, depth_back * 3 + line_len)
|
||||
# Bei Perspektive: Kamera + Target auf cam_z. Bei Parallel:
|
||||
# plane_z (Mitte Hoehenrange) — Z spielt eh keine Rolle
|
||||
# fuers Bild, aber sauber gesetzt fuer konsistente
|
||||
# Kamera-Ausrichtung.
|
||||
view_z = cam_z if projection == "perspective" else plane_z
|
||||
cam_pos = rg.Point3d(
|
||||
mid.X - view_dir.X * cam_dist,
|
||||
mid.Y - view_dir.Y * cam_dist,
|
||||
plane_z)
|
||||
view_z)
|
||||
target = rg.Point3d(
|
||||
mid.X + view_dir.X * (depth_back * 0.5),
|
||||
mid.Y + view_dir.Y * (depth_back * 0.5),
|
||||
plane_z)
|
||||
vp.ChangeToParallelProjection(True)
|
||||
view_z)
|
||||
if projection == "perspective":
|
||||
vp.ChangeToPerspectiveProjection(True, 50.0)
|
||||
else:
|
||||
vp.ChangeToParallelProjection(True)
|
||||
vp.SetCameraLocations(target, cam_pos)
|
||||
vp.CameraUp = rg.Vector3d(0, 0, 1)
|
||||
# Zoom auf Schnitt-BoundingBox + etwas Rand
|
||||
# Zoom auf Schnitt-BoundingBox + etwas Rand. Bei Perspektive
|
||||
# macht ZoomBoundingBox auch Sinn — Rhino passt das FOV-Frame
|
||||
# entsprechend an.
|
||||
bb = rg.BoundingBox(
|
||||
rg.Point3d(min(p1.X, p2.X) - margin, min(p1.Y, p2.Y) - margin,
|
||||
h_min - margin),
|
||||
@@ -564,12 +589,25 @@ def pick_schnitt_interactive(doc, defaults=None):
|
||||
"""Interaktiver Pick: 2 Punkte + Dir-Pfeil + Defaults aus settings.
|
||||
Liefert die neue Schnitt-Id oder None bei Abbruch.
|
||||
|
||||
defaults: {depthBack, heightMin, heightMax, cutAtLine, namePrefix}"""
|
||||
defaults: {depthBack, heightMin, heightMax, cutAtLine, namePrefix}
|
||||
Wenn ein Feld fehlt, wird aus dossier_project_settings nachgeschaut
|
||||
(zentrale Project-Voreinstellung), sonst Hardcoded-Default."""
|
||||
defaults = defaults or {}
|
||||
# Project-Defaults als 2-stufiges Fallback (defaults > project > hardcoded)
|
||||
proj_d = {}
|
||||
try:
|
||||
import rhinopanel
|
||||
ps = rhinopanel.load_project_settings(doc) or {}
|
||||
proj_d = ps.get("defaults", {}) or {}
|
||||
except Exception: pass
|
||||
def _resolve(key, proj_key, fallback):
|
||||
if key in defaults: return defaults[key]
|
||||
if proj_key in proj_d: return proj_d[proj_key]
|
||||
return fallback
|
||||
name_prefix = defaults.get("namePrefix", "S")
|
||||
depth_back = float(defaults.get("depthBack", 8.0))
|
||||
h_min = float(defaults.get("heightMin", -1.0))
|
||||
h_max = float(defaults.get("heightMax", 12.0))
|
||||
depth_back = float(_resolve("depthBack", "schnittDepthBack", 8.0))
|
||||
h_min = float(_resolve("heightMin", "schnittHeightMin", -1.0))
|
||||
h_max = float(_resolve("heightMax", "schnittHeightMax", 12.0))
|
||||
cut_at_line = bool(defaults.get("cutAtLine", True))
|
||||
|
||||
# Pick Punkt 1
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import LibraryBrowser from './components/LibraryBrowser'
|
||||
import { notifyReady, onMessage, send } from './lib/rhinoBridge'
|
||||
|
||||
export default function LibraryApp() {
|
||||
const [manifest, setManifest] = useState({ items: [] })
|
||||
const [importedIds, setImportedIds] = useState([])
|
||||
const [libraryRoot, setLibraryRoot] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
onMessage('LIBRARY_STATE', ({ manifest, importedIds, libraryRoot }) => {
|
||||
if (manifest) setManifest(manifest)
|
||||
if (importedIds) setImportedIds(importedIds)
|
||||
if (libraryRoot) setLibraryRoot(libraryRoot)
|
||||
})
|
||||
notifyReady()
|
||||
const blockContext = (ev) => ev.preventDefault()
|
||||
document.addEventListener('contextmenu', blockContext)
|
||||
return () => document.removeEventListener('contextmenu', blockContext)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<LibraryBrowser
|
||||
manifest={manifest}
|
||||
importedIds={importedIds}
|
||||
libraryRoot={libraryRoot}
|
||||
onImport={(id) => send('IMPORT_ITEM', { id })}
|
||||
onReload={() => send('RELOAD', {})}
|
||||
onClose={() => send('CLOSE', {})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
+114
-1
@@ -17,6 +17,9 @@ import {
|
||||
setDarstellung,
|
||||
arrangeSelection,
|
||||
toggleReferenzlinien,
|
||||
toggleOsnap,
|
||||
setOsnapMode, toggleGridVisible,
|
||||
openProjectSettings,
|
||||
} from './lib/rhinoBridge'
|
||||
|
||||
const PRESETS = [
|
||||
@@ -273,7 +276,7 @@ export default function OberleisteApp() {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openDossierSettings()}
|
||||
title="Dossier-Einstellungen"
|
||||
title="Dossier-Einstellungen (App-Settings, Window-Layout)"
|
||||
style={{
|
||||
background: 'transparent', border: 'none', padding: '2px 4px',
|
||||
cursor: 'pointer', color: 'var(--text-muted)',
|
||||
@@ -284,6 +287,19 @@ export default function OberleisteApp() {
|
||||
>
|
||||
<Icon name="settings" size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openProjectSettings()}
|
||||
title="Projekt-Einstellungen (Voreinstellungen Geschoss/Schnitt + Material-Library)"
|
||||
style={{
|
||||
background: 'transparent', border: 'none', padding: '2px 4px',
|
||||
cursor: 'pointer', color: 'var(--text-muted)',
|
||||
display: 'flex', alignItems: 'center', flexShrink: 0,
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.color = 'var(--text-muted)'}
|
||||
>
|
||||
<Icon name="tune" size={14} />
|
||||
</button>
|
||||
<div style={sep} />
|
||||
{/* ====== VIEW 2x4 Grid ======
|
||||
Reihe 1: TOP / ISO / PERSP / 📷 (Kamera-Settings)
|
||||
@@ -706,6 +722,103 @@ export default function OberleisteApp() {
|
||||
|
||||
<div style={sep} />
|
||||
|
||||
{/* ====== SNAP-BAR (Architektur-Osnaps + Grid) ======
|
||||
Layout: links zwei einzelne Toggle-Buttons (Master-O / Grid)
|
||||
stacked. Rechts zwei 3-Zellen-Pills mit den Osnap-Modi.
|
||||
Master-O und Grid sind globale Toggles → eigene Buttons.
|
||||
Die 6 Modi gehören zusammen → Pill-Gruppe.
|
||||
Ortho + Grid-Snap sind in Rhinos Footer-Bar — hier nicht. */}
|
||||
{(() => {
|
||||
const om = state.osnapModes || {}
|
||||
const osnapDisabled = !state.osnap
|
||||
// Bar-Cell: Teil einer Segment-Pill (kein eigener Border-Radius)
|
||||
const BarCell = ({ icon, active, disabled, onClick, isFirst, title }) => (
|
||||
<button onClick={onClick} disabled={disabled} title={title}
|
||||
onMouseEnter={(e) => {
|
||||
if (disabled || active) return
|
||||
e.currentTarget.style.background = 'var(--bg-item-hover)'
|
||||
e.currentTarget.style.color = 'var(--accent-light)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (active) return
|
||||
e.currentTarget.style.background = 'var(--bg-input)'
|
||||
e.currentTarget.style.color = 'var(--text-primary)'
|
||||
}}
|
||||
style={{
|
||||
height: BAR_H, width: BAR_H, padding: 0,
|
||||
background: active ? 'var(--accent)' : 'var(--bg-input)',
|
||||
color: active ? 'var(--bg-panel)' : 'var(--text-primary)',
|
||||
border: 'none',
|
||||
borderLeft: isFirst ? 'none' : '1px solid var(--border)',
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
opacity: disabled ? 0.35 : 1,
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
appearance: 'none', WebkitAppearance: 'none',
|
||||
lineHeight: 1, boxSizing: 'border-box', flexShrink: 0,
|
||||
transition: 'background 0.15s, color 0.15s',
|
||||
}}>
|
||||
<Icon name={icon} size={12} />
|
||||
</button>
|
||||
)
|
||||
const pillRowStyle = {
|
||||
display: 'inline-flex', width: BAR_H * 3,
|
||||
height: BAR_H + 2, boxSizing: 'border-box',
|
||||
border: '1px solid var(--border)', borderRadius: 999,
|
||||
overflow: 'hidden', flexShrink: 0,
|
||||
}
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'flex-start',
|
||||
flexShrink: 0 }}>
|
||||
{/* Linke Spalte: 2 einzelne Toggle-Buttons stacked */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<BarButton icon="gps_fixed"
|
||||
active={!!state.osnap}
|
||||
onClick={() => toggleOsnap(!state.osnap)}
|
||||
title={state.osnap ? 'Object-Snap an — Klick zum Ausschalten'
|
||||
: 'Object-Snap aus — Klick zum Einschalten'} />
|
||||
<BarButton
|
||||
icon={state.gridVisible === false ? 'grid_off' : 'grid_on'}
|
||||
active={state.gridVisible !== false}
|
||||
onClick={() => toggleGridVisible(state.gridVisible === false)}
|
||||
title="Konstruktions-Raster ein-/ausblenden" />
|
||||
</div>
|
||||
{/* Rechte Spalte: 2 Pills mit den 6 Osnap-Modi */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div style={pillRowStyle}>
|
||||
<BarCell icon="crop_square" isFirst disabled={osnapDisabled}
|
||||
active={!!om.end}
|
||||
onClick={() => setOsnapMode('end', !om.end)}
|
||||
title="Endpunkt (End)" />
|
||||
<BarCell icon="change_history" disabled={osnapDisabled}
|
||||
active={!!om.mid}
|
||||
onClick={() => setOsnapMode('mid', !om.mid)}
|
||||
title="Mittelpunkt (Mid)" />
|
||||
<BarCell icon="close" disabled={osnapDisabled}
|
||||
active={!!om.int}
|
||||
onClick={() => setOsnapMode('int', !om.int)}
|
||||
title="Schnittpunkt (Intersection)" />
|
||||
</div>
|
||||
<div style={pillRowStyle}>
|
||||
<BarCell icon="square_foot" isFirst disabled={osnapDisabled}
|
||||
active={!!om.perp}
|
||||
onClick={() => setOsnapMode('perp', !om.perp)}
|
||||
title="Lotrecht (Perpendicular)" />
|
||||
<BarCell icon="radio_button_unchecked" disabled={osnapDisabled}
|
||||
active={!!om.cen}
|
||||
onClick={() => setOsnapMode('cen', !om.cen)}
|
||||
title="Kreis-/Bogen-Mittelpunkt (Center)" />
|
||||
<BarCell icon="add" disabled={osnapDisabled}
|
||||
active={!!om.near}
|
||||
onClick={() => setOsnapMode('near', !om.near)}
|
||||
title="Naechster Punkt (Near)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
<div style={sep} />
|
||||
|
||||
{/* ====== TEXT-Block (Vectorworks-Stil) ======
|
||||
Reihe 1: Style ▼ | Font ▼ | Size ▼
|
||||
Reihe 2: [B][I][U] | [L][C][R] | [+]
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useEffect } from 'react'
|
||||
import ProjectSettingsDialog from './components/ProjectSettingsDialog'
|
||||
import { notifyReady } from './lib/rhinoBridge'
|
||||
|
||||
function bridgeSend(type, payload = {}) {
|
||||
if (!window.RHINO_MODE) { console.log('[Bridge] →', type, payload); return }
|
||||
const json = JSON.stringify({ type, payload })
|
||||
document.title = 'RHINOMSG::' + json
|
||||
}
|
||||
|
||||
export default function ProjectSettingsApp() {
|
||||
const initial = (typeof window !== 'undefined' && window.PANEL_PARAMS) || {}
|
||||
|
||||
useEffect(() => {
|
||||
notifyReady()
|
||||
const blockContext = (ev) => ev.preventDefault()
|
||||
document.addEventListener('contextmenu', blockContext)
|
||||
return () => document.removeEventListener('contextmenu', blockContext)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ProjectSettingsDialog
|
||||
embedded
|
||||
initial={initial}
|
||||
onSave={(updated) => bridgeSend('SAVE', updated)}
|
||||
onClose={() => bridgeSend('CANCEL', {})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -28,9 +28,11 @@ export default function ZeichnungsebenenApp() {
|
||||
const [activeId, setActiveId] = useState('eg')
|
||||
const [appliedZ, setAppliedZ] = useState(INITIAL_ZEICHNUNGSEBENEN)
|
||||
const [zMode, setZMode] = useState('active')
|
||||
const [projectSettings, setProjectSettings] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
onMessage('STATE_SYNC', ({ zeichnungsebenen: z }) => {
|
||||
onMessage('STATE_SYNC', ({ zeichnungsebenen: z, projectSettings: ps }) => {
|
||||
if (ps) setProjectSettings(ps)
|
||||
if (z) {
|
||||
const r = recalcOkff(z); setZeichnungsebenen(r); setAppliedZ(r)
|
||||
const active = r.find(zz => zz.id === activeId) || r[0]
|
||||
@@ -122,6 +124,7 @@ export default function ZeichnungsebenenApp() {
|
||||
recalcOkff={recalcOkff}
|
||||
mode={zMode}
|
||||
onModeChange={setZMode}
|
||||
projectSettings={projectSettings}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,8 @@ function GeschossBadge({ name }) {
|
||||
function ZeichnungsebeneRow({
|
||||
z, active, mode, onClick, onContextMenu,
|
||||
onToggleVisible, onToggleLock, onToggleClipping, onDelete,
|
||||
onDragStart, onDragOver, onDragLeave, onDrop, onDragEnd,
|
||||
isDragging, dropPos,
|
||||
}) {
|
||||
const isGeschoss = !!z.isGeschoss
|
||||
const isSchnitt = z.type === 'schnitt'
|
||||
@@ -48,6 +50,12 @@ function ZeichnungsebeneRow({
|
||||
}
|
||||
return (
|
||||
<div
|
||||
draggable
|
||||
onDragStart={(ev) => onDragStart && onDragStart(ev, z.id)}
|
||||
onDragOver={(ev) => onDragOver && onDragOver(ev, z.id)}
|
||||
onDragLeave={(ev) => onDragLeave && onDragLeave(ev, z.id)}
|
||||
onDrop={(ev) => onDrop && onDrop(ev, z.id)}
|
||||
onDragEnd={(ev) => onDragEnd && onDragEnd(ev)}
|
||||
onClick={onClick}
|
||||
onContextMenu={onContextMenu}
|
||||
style={{
|
||||
@@ -56,7 +64,16 @@ function ZeichnungsebeneRow({
|
||||
margin: 0,
|
||||
background: active ? 'var(--active-dim)' : 'var(--bg-item)',
|
||||
borderRadius: active ? 999 : 0,
|
||||
borderBottom: active ? '1px solid transparent' : '1px solid var(--border-light)',
|
||||
// Drop-Indikator: 2px accent-Linie an Top oder Bottom — zeigt
|
||||
// wo der gedragte Eintrag nach Loslassen landet. Sonst normale
|
||||
// unten-Trennlinie. isDragging: leichtes Faden des gehobenen
|
||||
// Eintrags fuer visuelles Feedback.
|
||||
borderTop: dropPos === 'top'
|
||||
? '2px solid var(--accent)' : '0 solid transparent',
|
||||
borderBottom: dropPos === 'bottom'
|
||||
? '2px solid var(--accent)'
|
||||
: (active ? '1px solid transparent' : '1px solid var(--border-light)'),
|
||||
opacity: isDragging ? 0.4 : 1,
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
minHeight: 24,
|
||||
@@ -142,10 +159,64 @@ const MODES = [
|
||||
export default function GeschossManager({
|
||||
zeichnungsebenen, activeId, onActiveChange, onChange, recalcOkff,
|
||||
mode, onModeChange,
|
||||
projectSettings,
|
||||
}) {
|
||||
const [ctxMenu, setCtxMenu] = useState(null) // { x, y, id }
|
||||
const [addMenu, setAddMenu] = useState(null) // { x, y } — Picker beim +
|
||||
const [geschossDialog, setGeschossDialog] = useState(null) // { x, y, pos:'above'|'below', name, hoehe, schnitthoehe, anchorId }
|
||||
// Drag-State fuer Reorder
|
||||
const [dragId, setDragId] = useState(null)
|
||||
const [dropOverId, setDropOverId] = useState(null)
|
||||
const [dropPos, setDropPos] = useState(null) // 'top' | 'bottom'
|
||||
|
||||
const handleDragStart = (ev, id) => {
|
||||
setDragId(id)
|
||||
try {
|
||||
ev.dataTransfer.effectAllowed = 'move'
|
||||
ev.dataTransfer.setData('text/plain', id)
|
||||
} catch (e) {}
|
||||
}
|
||||
const handleDragOver = (ev, targetId) => {
|
||||
if (!dragId || dragId === targetId) return
|
||||
ev.preventDefault()
|
||||
try { ev.dataTransfer.dropEffect = 'move' } catch (e) {}
|
||||
const rect = ev.currentTarget.getBoundingClientRect()
|
||||
const pos = (ev.clientY < rect.top + rect.height / 2) ? 'top' : 'bottom'
|
||||
if (targetId !== dropOverId) setDropOverId(targetId)
|
||||
if (pos !== dropPos) setDropPos(pos)
|
||||
}
|
||||
const handleDragLeave = (ev, targetId) => {
|
||||
// Wenn der Cursor die ganze Row verlaesst (nicht in ein Child), reset.
|
||||
// Heuristik: relatedTarget liegt nicht innerhalb der Row.
|
||||
if (!ev.currentTarget.contains(ev.relatedTarget)) {
|
||||
if (dropOverId === targetId) {
|
||||
setDropOverId(null); setDropPos(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
const handleDrop = (ev, targetId) => {
|
||||
ev.preventDefault()
|
||||
const srcId = dragId
|
||||
setDragId(null); setDropOverId(null); setDropPos(null)
|
||||
if (!srcId || srcId === targetId) return
|
||||
// Array-Reihenfolge: bottom-up (recalcOkff stacks von index 0 hoch).
|
||||
// Display ist umgekehrt (top = letztes Array-Element). Klick auf
|
||||
// 'top' eines Display-Eintrags Z heisst: oberhalb Z, d.h. nach Z
|
||||
// im Array (höherer Index). 'bottom' = unterhalb Z in Display =
|
||||
// vor Z im Array.
|
||||
const arr = [...zeichnungsebenen]
|
||||
const srcIdx = arr.findIndex(z => z.id === srcId)
|
||||
if (srcIdx < 0) return
|
||||
const [moved] = arr.splice(srcIdx, 1)
|
||||
const tgtIdx = arr.findIndex(z => z.id === targetId)
|
||||
if (tgtIdx < 0) return
|
||||
const insertIdx = (dropPos === 'top') ? tgtIdx + 1 : tgtIdx
|
||||
arr.splice(insertIdx, 0, moved)
|
||||
onChange(arr) // ZeichnungsebenenApp ruft recalcOkff → OKFFs stimmen wieder
|
||||
}
|
||||
const handleDragEnd = () => {
|
||||
setDragId(null); setDropOverId(null); setDropPos(null)
|
||||
}
|
||||
|
||||
const sorted = [...zeichnungsebenen].reverse()
|
||||
|
||||
@@ -184,16 +255,23 @@ export default function GeschossManager({
|
||||
return 'Neu'
|
||||
}
|
||||
|
||||
// Project-Default-Hoehe: erst aktives Geschoss, dann erstes Geschoss in
|
||||
// der Liste, dann hartcodiert 3.0. So uebernimmt jeder neue Eintrag den
|
||||
// "typischen" Wert des Projekts ohne dass der User irgendwo setzen muss.
|
||||
// Default-Hoehe: Hierarchie
|
||||
// 1. Project-Settings (zentrale Voreinstellung — explizit gesetzt)
|
||||
// 2. Aktives Geschoss (wahrscheinlich gleich vom User gewuenscht)
|
||||
// 3. Erstes Geschoss in der Liste
|
||||
// 4. Hartcodiert 3.0
|
||||
// Project-Settings ueberschreibt alle anderen wenn vorhanden, damit
|
||||
// der User eine zentrale Vorgabe haben kann.
|
||||
const projDefaults = projectSettings?.defaults || {}
|
||||
const defaultGeschossHoehe = () => {
|
||||
if (projDefaults.geschossHoehe != null) return projDefaults.geschossHoehe
|
||||
const act = zeichnungsebenen.find(z => z.id === activeId)
|
||||
if (act?.isGeschoss && act.hoehe != null) return act.hoehe
|
||||
const first = zeichnungsebenen.find(z => z.isGeschoss && z.hoehe != null)
|
||||
return first?.hoehe ?? 3.0
|
||||
}
|
||||
const defaultSchnitthoehe = () => {
|
||||
if (projDefaults.schnitthoehe != null) return projDefaults.schnitthoehe
|
||||
const act = zeichnungsebenen.find(z => z.id === activeId)
|
||||
if (act?.isGeschoss && act.schnitthoehe != null) return act.schnitthoehe
|
||||
const first = zeichnungsebenen.find(z => z.isGeschoss && z.schnitthoehe != null)
|
||||
@@ -320,6 +398,10 @@ export default function GeschossManager({
|
||||
|
||||
const toggleClipping = (id) => {
|
||||
onChange(zeichnungsebenen.map(z => z.id === id ? { ...z, hasClipping: !z.hasClipping } : z))
|
||||
// Backend rendert die Clipping-Plane nur fuer das AKTIVE Geschoss.
|
||||
// Klick auf cut-Icon einer nicht-aktiven Zeile → mit-aktivieren,
|
||||
// sonst kriegt der User keine sichtbare Rueckmeldung.
|
||||
if (id !== activeId) onActiveChange(id)
|
||||
}
|
||||
|
||||
const duplicate = (id) => {
|
||||
@@ -459,6 +541,13 @@ export default function GeschossManager({
|
||||
onToggleLock={() => toggleLock(z.id)}
|
||||
onToggleClipping={() => toggleClipping(z.id)}
|
||||
onDelete={() => remove(z.id)}
|
||||
isDragging={dragId === z.id}
|
||||
dropPos={dropOverId === z.id ? dropPos : null}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onDragEnd={handleDragEnd}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -48,7 +48,12 @@ export default function GeschossSettingsDialog({ geschoss, onSave, onClose, embe
|
||||
const clipZ = (okff + schnitt).toFixed(2)
|
||||
// Schnitt-Felder
|
||||
const cutAtLine = draft.cutAtLine !== false // default true = Schnitt
|
||||
const projection = draft.projection || 'parallel'
|
||||
const depthBack = draft.depthBack ?? 8.0
|
||||
// Kamera-Hoehe (m) — nur bei Perspektive relevant. Default = Mitte
|
||||
// der Hoehenrange (= klassischer "Augenpunkt-in-der-Mitte"-Look).
|
||||
const camHeightDefault = ((draft.heightMin ?? -1.0) + (draft.heightMax ?? 12.0)) / 2
|
||||
const cameraHeight = draft.cameraHeight ?? camHeightDefault
|
||||
const heightMin = draft.heightMin ?? -1.0
|
||||
const heightMax = draft.heightMax ?? 12.0
|
||||
const dirSign = draft.dirSign ?? 1
|
||||
@@ -172,6 +177,30 @@ export default function GeschossSettingsDialog({ geschoss, onSave, onClose, embe
|
||||
onClick={() => set({ dirSign: -1 })}
|
||||
style={{ flex: 1, fontSize: 11 }}>Seite B →</button>
|
||||
</Field>
|
||||
|
||||
<Field label="PROJEKTION"
|
||||
hint={projection === 'perspective'
|
||||
? 'Schnittperspektive — perspektivische Section mit gleichem Clipping. Cutaway-Visualisierung.'
|
||||
: 'Klassischer Schnitt — Parallelprojektion, masstabsgetreu.'}>
|
||||
<button className={projection === 'parallel' ? 'btn-contained' : 'btn-outlined'}
|
||||
onClick={() => set({ projection: 'parallel' })}
|
||||
style={{ flex: 1, fontSize: 11 }}>Parallel</button>
|
||||
<button className={projection === 'perspective' ? 'btn-contained' : 'btn-outlined'}
|
||||
onClick={() => set({ projection: 'perspective' })}
|
||||
style={{ flex: 1, fontSize: 11 }}>Perspektive</button>
|
||||
</Field>
|
||||
|
||||
{projection === 'perspective' && (
|
||||
<Field label="AUGENHÖHE (m)"
|
||||
hint="Z-Höhe von Kamera und Blickziel. 1.60 = Augenhöhe (klassische Schnittperspektive). 0 = Bodennivau. Höher = Bird's-Eye.">
|
||||
<input
|
||||
type="number" step="0.1"
|
||||
value={cameraHeight}
|
||||
onChange={(ev) => set({ cameraHeight: parseFloat(ev.target.value) })}
|
||||
style={{ flex: 1, fontSize: 11, textAlign: 'right', minWidth: 0 }}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -251,6 +280,13 @@ export default function GeschossSettingsDialog({ geschoss, onSave, onClose, embe
|
||||
if (out.heightMax == null) out.heightMax = 12.0
|
||||
if (out.dirSign == null) out.dirSign = 1
|
||||
if (out.cutAtLine == null) out.cutAtLine = true
|
||||
if (out.projection == null) out.projection = 'parallel'
|
||||
// cameraHeight: nur wenn Perspektive aktiv UND noch nicht
|
||||
// gesetzt → Default aus Hoehen-Mitte. Bei Parallel das Feld
|
||||
// nicht ungewollt persistieren (bleibt undefined).
|
||||
if (out.projection === 'perspective' && out.cameraHeight == null) {
|
||||
out.cameraHeight = ((out.heightMin ?? -1.0) + (out.heightMax ?? 12.0)) / 2
|
||||
}
|
||||
}
|
||||
onSave(out)
|
||||
}}>Übernehmen</button>
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import Icon from './Icon'
|
||||
|
||||
/* MaterialCard — Preview-Swatch + Name + Tags + Import-Button. */
|
||||
function MaterialCard({ item, imported, onImport }) {
|
||||
const color = item.data?.color || '#888888'
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column',
|
||||
border: '1px solid var(--border)', borderRadius: 6,
|
||||
background: 'var(--bg-section)',
|
||||
overflow: 'hidden',
|
||||
cursor: imported ? 'default' : 'pointer',
|
||||
opacity: imported ? 0.75 : 1,
|
||||
}}
|
||||
onDoubleClick={() => { if (!imported) onImport(item.id) }}
|
||||
title={imported ? 'Bereits importiert' : 'Doppelklick = importieren'}>
|
||||
<div style={{
|
||||
height: 64,
|
||||
background: color,
|
||||
borderBottom: '1px solid var(--border-light)',
|
||||
}} />
|
||||
<div style={{ padding: '6px 8px', display: 'flex',
|
||||
flexDirection: 'column', gap: 4 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<span style={{ flex: 1, fontSize: 11, fontWeight: 600,
|
||||
color: 'var(--text-primary)',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap' }}>
|
||||
{item.name}
|
||||
</span>
|
||||
{imported && (
|
||||
<Icon name="check" size={12}
|
||||
style={{ color: 'var(--accent)' }} />
|
||||
)}
|
||||
</div>
|
||||
{(item.tags || []).length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 3 }}>
|
||||
{(item.tags || []).slice(0, 4).map(t => (
|
||||
<span key={t} style={{
|
||||
fontSize: 9, color: 'var(--text-muted)',
|
||||
background: 'var(--bg-input)',
|
||||
padding: '1px 5px', borderRadius: 999,
|
||||
}}>{t}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { if (!imported) onImport(item.id) }}
|
||||
disabled={imported}
|
||||
style={{
|
||||
marginTop: 2, padding: '3px 6px', fontSize: 10,
|
||||
background: imported ? 'transparent' : 'var(--accent)',
|
||||
color: imported ? 'var(--text-muted)' : '#fff',
|
||||
border: imported ? '1px solid var(--border)' : 'none',
|
||||
borderRadius: 999, cursor: imported ? 'default' : 'pointer',
|
||||
}}>
|
||||
{imported ? 'Importiert' : 'Importieren'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function LibraryBrowser({
|
||||
manifest, importedIds, libraryRoot,
|
||||
onImport, onReload, onClose,
|
||||
}) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [typeFilter, setTypeFilter] = useState('all')
|
||||
|
||||
const items = manifest?.items || []
|
||||
const importedSet = useMemo(() => new Set(importedIds || []),
|
||||
[importedIds])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase()
|
||||
return items.filter(it => {
|
||||
if (typeFilter !== 'all' && it.type !== typeFilter) return false
|
||||
if (!q) return true
|
||||
if ((it.name || '').toLowerCase().includes(q)) return true
|
||||
if ((it.tags || []).some(t => t.toLowerCase().includes(q))) return true
|
||||
return false
|
||||
})
|
||||
}, [items, search, typeFilter])
|
||||
|
||||
const types = useMemo(() => {
|
||||
const s = new Set(items.map(i => i.type))
|
||||
return ['all', ...Array.from(s)]
|
||||
}, [items])
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: '100%', height: '100%',
|
||||
background: 'var(--bg-dialog)',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
fontFamily: 'var(--font)', color: 'var(--text-primary)', fontSize: 11,
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '10px 12px',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
}}>
|
||||
<Icon name="inventory_2" size={14}
|
||||
style={{ color: 'var(--accent)', flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, fontWeight: 600, fontSize: 11 }}>
|
||||
{manifest?.name || 'Dossier-Library'}
|
||||
</span>
|
||||
<button onClick={onReload}
|
||||
title="Manifest neu laden"
|
||||
style={{ background: 'transparent', border: 'none',
|
||||
cursor: 'pointer', color: 'var(--text-muted)',
|
||||
padding: '0 4px' }}>
|
||||
<Icon name="refresh" size={14} />
|
||||
</button>
|
||||
<button onClick={onClose}
|
||||
style={{ color: 'var(--text-muted)', fontSize: 16,
|
||||
padding: '0 4px', lineHeight: 1 }}>×</button>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '8px 12px',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
}}>
|
||||
<input type="text" value={search}
|
||||
onChange={(ev) => setSearch(ev.target.value)}
|
||||
placeholder="Suchen (Name oder Tag)…"
|
||||
style={{ flex: 1, fontSize: 11, padding: '4px 8px',
|
||||
borderRadius: 999 }} />
|
||||
<div style={{ display: 'flex', gap: 0,
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 999, overflow: 'hidden' }}>
|
||||
{types.map(t => (
|
||||
<button key={t}
|
||||
onClick={() => setTypeFilter(t)}
|
||||
style={{
|
||||
background: typeFilter === t ? 'var(--accent)' : 'transparent',
|
||||
color: typeFilter === t ? '#fff' : 'var(--text-muted)',
|
||||
border: 'none', padding: '4px 10px', fontSize: 10,
|
||||
cursor: 'pointer',
|
||||
}}>
|
||||
{t === 'all' ? 'Alle' : t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto',
|
||||
padding: '10px 12px' }}>
|
||||
{filtered.length === 0 ? (
|
||||
<div style={{ padding: 24, textAlign: 'center',
|
||||
color: 'var(--text-muted)', fontSize: 11 }}>
|
||||
{items.length === 0
|
||||
? 'Library ist leer. Manifest unter:'
|
||||
: 'Nichts gefunden — Filter aendern?'}
|
||||
{items.length === 0 && libraryRoot && (
|
||||
<div style={{ marginTop: 6, fontSize: 9, fontFamily: 'monospace' }}>
|
||||
{libraryRoot}/library.json
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
|
||||
gap: 8,
|
||||
}}>
|
||||
{filtered.map(it => (
|
||||
<MaterialCard key={it.id}
|
||||
item={it}
|
||||
imported={importedSet.has(it.id)}
|
||||
onImport={onImport} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '6px 12px',
|
||||
borderTop: '1px solid var(--border)',
|
||||
background: 'var(--bg-section)',
|
||||
fontSize: 9, color: 'var(--text-muted)',
|
||||
}}>
|
||||
<span>{filtered.length} / {items.length} Items</span>
|
||||
<div style={{ flex: 1 }} />
|
||||
<span title={libraryRoot}
|
||||
style={{ fontFamily: 'monospace',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap', maxWidth: 320 }}>
|
||||
{libraryRoot || ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import { useState } from 'react'
|
||||
import Icon from './Icon'
|
||||
import { openLibrary } from '../lib/rhinoBridge'
|
||||
|
||||
/* Inline Field + Tab helpers fuer kompaktes Layout im Satellite. */
|
||||
function Field({ label, hint, children, style }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3,
|
||||
padding: '5px 0', ...style }}>
|
||||
<span style={{ fontSize: 10, color: 'var(--text-secondary)',
|
||||
fontWeight: 500, letterSpacing: 0.2 }}>{label}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>{children}</div>
|
||||
{hint && (
|
||||
<span style={{ fontSize: 9, color: 'var(--text-muted)', lineHeight: 1.4 }}>
|
||||
{hint}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TabBar({ tabs, active, onChange }) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', gap: 0,
|
||||
borderBottom: '1px solid var(--border)',
|
||||
paddingLeft: 4,
|
||||
}}>
|
||||
{tabs.map(t => (
|
||||
<button key={t.key}
|
||||
onClick={() => onChange(t.key)}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderBottom: active === t.key ? '2px solid var(--accent)' : '2px solid transparent',
|
||||
padding: '8px 14px',
|
||||
fontSize: 11, fontWeight: active === t.key ? 600 : 500,
|
||||
color: active === t.key ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||
cursor: 'pointer',
|
||||
marginBottom: -1,
|
||||
}}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MaterialRow({ mat, hatchPatterns, onChange, onDelete, builtin }) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'grid', gridTemplateColumns: '14px 1fr 90px 60px 18px',
|
||||
alignItems: 'center', gap: 6,
|
||||
padding: '4px 8px',
|
||||
borderBottom: '1px solid var(--border-light)',
|
||||
background: builtin ? 'var(--bg-section)' : 'transparent',
|
||||
}}>
|
||||
<input type="color" value={mat.color || '#888888'}
|
||||
onChange={(ev) => onChange({ ...mat, color: ev.target.value })}
|
||||
title="Farbe"
|
||||
style={{ width: 14, height: 14, padding: 0, border: 'none',
|
||||
background: 'transparent', cursor: 'pointer' }} />
|
||||
<input type="text" value={mat.name || ''}
|
||||
onChange={(ev) => onChange({ ...mat, name: ev.target.value })}
|
||||
disabled={builtin}
|
||||
placeholder="Name"
|
||||
style={{ flex: 1, fontSize: 11, minWidth: 0,
|
||||
opacity: builtin ? 0.7 : 1 }} />
|
||||
<select value={mat.hatch || 'Solid'}
|
||||
onChange={(ev) => onChange({ ...mat, hatch: ev.target.value })}
|
||||
style={{ fontSize: 11, minWidth: 0 }}>
|
||||
{(hatchPatterns || []).map(h => (
|
||||
<option key={h} value={h}>{h}</option>
|
||||
))}
|
||||
</select>
|
||||
<input type="number" step="0.1" min="0.1"
|
||||
value={mat.scale ?? 1.0}
|
||||
onChange={(ev) => onChange({ ...mat, scale: parseFloat(ev.target.value) || 1.0 })}
|
||||
title="Hatch-Skalierung"
|
||||
style={{ fontSize: 11, textAlign: 'right' }} />
|
||||
{mat.source === 'library' ? (
|
||||
<span style={{ fontSize: 9, color: 'var(--accent-light)' }}
|
||||
title="Aus Dossier-Library">L</span>
|
||||
) : builtin || mat.source === 'builtin' ? (
|
||||
<span style={{ fontSize: 9, color: 'var(--text-muted)' }}
|
||||
title="Eingebaut">B</span>
|
||||
) : (
|
||||
<button onClick={onDelete} title="Loeschen"
|
||||
style={{ background: 'transparent', border: 'none',
|
||||
padding: 0, cursor: 'pointer',
|
||||
color: 'var(--text-muted)' }}>
|
||||
<Icon name="close" size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ProjectSettingsDialog({
|
||||
initial, onSave, onClose, embedded = false,
|
||||
}) {
|
||||
const [tab, setTab] = useState('defaults')
|
||||
const [draft, setDraft] = useState(() => ({
|
||||
defaults: { ...(initial.defaults || {}) },
|
||||
materials: [...(initial.materials || [])],
|
||||
}))
|
||||
const builtin = initial.builtinMaterials || []
|
||||
const hatchPatterns = initial.hatchPatterns || ['Solid']
|
||||
|
||||
const setDefault = (k, v) =>
|
||||
setDraft(d => ({ ...d, defaults: { ...d.defaults, [k]: v } }))
|
||||
|
||||
const setMat = (i, newMat) => setDraft(d => ({
|
||||
...d, materials: d.materials.map((m, idx) => idx === i ? newMat : m),
|
||||
}))
|
||||
const delMat = (i) => setDraft(d => ({
|
||||
...d, materials: d.materials.filter((_, idx) => idx !== i),
|
||||
}))
|
||||
const addMat = () => setDraft(d => ({
|
||||
...d,
|
||||
materials: [...d.materials, {
|
||||
name: 'Neues Material', color: '#aaaaaa',
|
||||
hatch: 'Solid', scale: 1.0,
|
||||
// source/libraryId vorbereitet fuer kommendes Library-Feature:
|
||||
// 'local' = manuell erstellt, 'library' = aus Cloud-Repo, libraryId
|
||||
// referenziert dann das Library-Item per UUID.
|
||||
source: 'local', libraryId: null,
|
||||
}],
|
||||
}))
|
||||
|
||||
const wrapperStyle = embedded ? {
|
||||
width: '100%', height: '100%',
|
||||
background: 'var(--bg-dialog)',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
fontFamily: 'var(--font)', color: 'var(--text-primary)', fontSize: 11,
|
||||
} : {
|
||||
position: 'absolute', inset: 0, zIndex: 150,
|
||||
background: 'var(--bg-overlay)',
|
||||
display: 'flex', alignItems: 'flex-start', justifyContent: 'center',
|
||||
paddingTop: 40,
|
||||
}
|
||||
return (
|
||||
<div style={wrapperStyle}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1,
|
||||
minHeight: 0, overflow: 'hidden' }}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '10px 12px',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
}}>
|
||||
<Icon name="tune" size={14}
|
||||
style={{ color: 'var(--text-secondary)', flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, fontWeight: 600, fontSize: 11 }}>
|
||||
Projekt-Einstellungen
|
||||
</span>
|
||||
<button onClick={onClose}
|
||||
style={{ color: 'var(--text-muted)', fontSize: 16,
|
||||
padding: '0 4px', lineHeight: 1 }}>×</button>
|
||||
</div>
|
||||
|
||||
<TabBar tabs={[
|
||||
{ key: 'defaults', label: 'Voreinstellungen' },
|
||||
{ key: 'materials', label: 'Materialien' },
|
||||
]} active={tab} onChange={setTab} />
|
||||
|
||||
{/* Body */}
|
||||
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto',
|
||||
padding: '8px 14px' }}>
|
||||
{tab === 'defaults' && (
|
||||
<>
|
||||
<div style={{ fontSize: 9, color: 'var(--text-muted)',
|
||||
padding: '4px 0 8px', lineHeight: 1.5 }}>
|
||||
Diese Werte werden beim Erstellen neuer Elemente als
|
||||
Voreinstellung genommen. Pro-Element editierte Werte
|
||||
bleiben davon unberuehrt.
|
||||
</div>
|
||||
<Field label="STANDARD-GESCHOSSHÖHE (m)"
|
||||
hint="Vorgabe fuer neue Geschosse — pro Geschoss ueberschreibbar">
|
||||
<input type="number" step="0.05" min="1.0" max="10"
|
||||
value={draft.defaults.geschossHoehe ?? 3.0}
|
||||
onChange={(ev) => setDefault('geschossHoehe', parseFloat(ev.target.value) || 3.0)}
|
||||
style={{ flex: 1, textAlign: 'right' }} />
|
||||
</Field>
|
||||
<Field label="STANDARD-SCHNITTHÖHE (m)"
|
||||
hint="Höhe der horizontalen Schnitt-Plane über OKFF eines Geschosses">
|
||||
<input type="number" step="0.05" min="0.1" max="3"
|
||||
value={draft.defaults.schnitthoehe ?? 1.0}
|
||||
onChange={(ev) => setDefault('schnitthoehe', parseFloat(ev.target.value) || 1.0)}
|
||||
style={{ flex: 1, textAlign: 'right' }} />
|
||||
</Field>
|
||||
<div style={{ height: 1, background: 'var(--border-light)',
|
||||
margin: '8px 0' }} />
|
||||
<Field label="STANDARD-SCHNITT-TIEFE HINTEN (m)"
|
||||
hint="Default-Tiefe für neue Schnitte/Ansichten — wie weit hinter der Schnittlinie sichtbar">
|
||||
<input type="number" step="0.5" min="0.5"
|
||||
value={draft.defaults.schnittDepthBack ?? 8.0}
|
||||
onChange={(ev) => setDefault('schnittDepthBack', parseFloat(ev.target.value) || 8.0)}
|
||||
style={{ flex: 1, textAlign: 'right' }} />
|
||||
</Field>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<Field label="HÖHE UNTEN (m)" style={{ flex: 1 }}>
|
||||
<input type="number" step="0.1"
|
||||
value={draft.defaults.schnittHeightMin ?? -1.0}
|
||||
onChange={(ev) => setDefault('schnittHeightMin', parseFloat(ev.target.value))}
|
||||
style={{ flex: 1, textAlign: 'right' }} />
|
||||
</Field>
|
||||
<Field label="HÖHE OBEN (m)" style={{ flex: 1 }}>
|
||||
<input type="number" step="0.1"
|
||||
value={draft.defaults.schnittHeightMax ?? 12.0}
|
||||
onChange={(ev) => setDefault('schnittHeightMax', parseFloat(ev.target.value))}
|
||||
style={{ flex: 1, textAlign: 'right' }} />
|
||||
</Field>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'materials' && (
|
||||
<>
|
||||
<div style={{ fontSize: 9, color: 'var(--text-muted)',
|
||||
padding: '4px 0 8px', lineHeight: 1.5 }}>
|
||||
Eingebaute Materialien (B) koennen nicht umbenannt werden,
|
||||
aber Farbe + Hatch sind anpassbar. Eigene Materialien
|
||||
koennen frei angelegt werden.
|
||||
</div>
|
||||
{/* Built-in materials (read-only name) */}
|
||||
{builtin.map((m, i) => (
|
||||
<MaterialRow key={'b_' + m.name}
|
||||
mat={m}
|
||||
builtin
|
||||
hatchPatterns={hatchPatterns}
|
||||
onChange={() => {/* read-only fuer Phase 1 */}} />
|
||||
))}
|
||||
{/* User materials */}
|
||||
{draft.materials.map((m, i) => (
|
||||
<MaterialRow key={'u_' + i}
|
||||
mat={m}
|
||||
hatchPatterns={hatchPatterns}
|
||||
onChange={(nm) => setMat(i, nm)}
|
||||
onDelete={() => delMat(i)} />
|
||||
))}
|
||||
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
|
||||
<button onClick={addMat}
|
||||
style={{
|
||||
padding: '4px 10px', fontSize: 11,
|
||||
background: 'var(--bg-input)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 999, cursor: 'pointer',
|
||||
color: 'var(--text-primary)',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
}}>
|
||||
<Icon name="add" size={12} /> Material hinzufuegen
|
||||
</button>
|
||||
<button onClick={openLibrary}
|
||||
title="Material aus Dossier-Library importieren"
|
||||
style={{
|
||||
padding: '4px 10px', fontSize: 11,
|
||||
background: 'transparent',
|
||||
border: '1px solid var(--accent)',
|
||||
borderRadius: 999, cursor: 'pointer',
|
||||
color: 'var(--accent)',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
}}>
|
||||
<Icon name="inventory_2" size={12} /> Library oeffnen
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '8px 12px',
|
||||
borderTop: '1px solid var(--border)',
|
||||
background: 'var(--bg-section)',
|
||||
}}>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="btn-text" onClick={onClose}>Abbrechen</button>
|
||||
<button className="btn-contained" onClick={() => onSave(draft)}>
|
||||
Übernehmen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -181,6 +181,14 @@ export function arrangeSelection(dir) { send('ARRANGE', { dir }) }
|
||||
export function toggleReferenzlinien(visible) {
|
||||
send('TOGGLE_REFERENZLINIEN', { visible: !!visible })
|
||||
}
|
||||
// Snap-Toggles (Architektur-relevante Osnaps + Grid) — neue Helpers.
|
||||
// toggleOrtho/toggleGridSnap/toggleOsnap existieren bereits weiter oben.
|
||||
export function setOsnapMode(key, on) { send('SET_OSNAP_MODE', { key, enabled: !!on }) }
|
||||
export function toggleGridVisible(on) { send('TOGGLE_GRID_VISIBLE', { visible: !!on }) }
|
||||
// Projekt-Einstellungen (Voreinstellungen fuer Geschoss/Schnitt + Material-Library)
|
||||
export function openProjectSettings() { send('OPEN_PROJECT_SETTINGS', {}) }
|
||||
// Dossier-Library (Material-/Symbol-/Object-Templates, Phase A: lokal+material)
|
||||
export function openLibrary() { send('OPEN_LIBRARY', {}) }
|
||||
// Schnitt/Ansicht — interaktiver 2-Punkt-Pick im Rhino-Viewport. Erzeugt
|
||||
// eine neue Zeichnungsebene type=schnitt + 2D-Plan-Symbol + aktiviert sie.
|
||||
// opts: { cutAtLine: bool, depthBack: m, heightMin: m, heightMax: m, namePrefix }
|
||||
|
||||
@@ -4,6 +4,8 @@ import './index.css'
|
||||
import App from './App.jsx'
|
||||
import ZeichnungsebenenApp from './ZeichnungsebenenApp.jsx'
|
||||
import GeschossSettingsApp from './GeschossSettingsApp.jsx'
|
||||
import ProjectSettingsApp from './ProjectSettingsApp.jsx'
|
||||
import LibraryApp from './LibraryApp.jsx'
|
||||
import EbenenSettingsApp from './EbenenSettingsApp.jsx'
|
||||
import GeschossDialogApp from './GeschossDialogApp.jsx'
|
||||
import LayerCombinationsApp from './LayerCombinationsApp.jsx'
|
||||
@@ -39,6 +41,8 @@ const RootApp = mode === 'gestaltung' ? GestaltungApp
|
||||
: mode === 'elemente' ? ElementeApp
|
||||
: mode === 'zeichnungsebenen' ? ZeichnungsebenenApp
|
||||
: mode === 'geschoss_settings' ? GeschossSettingsApp
|
||||
: mode === 'project_settings' ? ProjectSettingsApp
|
||||
: mode === 'library' ? LibraryApp
|
||||
: mode === 'ebenen_settings' ? EbenenSettingsApp
|
||||
: mode === 'geschoss_dialog' ? GeschossDialogApp
|
||||
: mode === 'layer_combinations' ? LayerCombinationsApp
|
||||
|
||||
Reference in New Issue
Block a user