diff --git a/rhino/elemente.py b/rhino/elemente.py index b683a1d..7403945 100644 --- a/rhino/elemente.py +++ b/rhino/elemente.py @@ -2038,6 +2038,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 +2096,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) @@ -5127,8 +5151,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 @@ -5838,7 +5863,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) diff --git a/rhino/library.py b/rhino/library.py new file mode 100644 index 0000000..973d4f4 --- /dev/null +++ b/rhino/library.py @@ -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) diff --git a/rhino/rhinopanel.py b/rhino/rhinopanel.py index a73961c..535a45f 100644 --- a/rhino/rhinopanel.py +++ b/rhino/rhinopanel.py @@ -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 + diff --git a/rhino/schnitte.py b/rhino/schnitte.py index 22f943e..1bdec7a 100644 --- a/rhino/schnitte.py +++ b/rhino/schnitte.py @@ -589,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 diff --git a/src/LibraryApp.jsx b/src/LibraryApp.jsx new file mode 100644 index 0000000..85e436c --- /dev/null +++ b/src/LibraryApp.jsx @@ -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 ( + send('IMPORT_ITEM', { id })} + onReload={() => send('RELOAD', {})} + onClose={() => send('CLOSE', {})} + /> + ) +} diff --git a/src/OberleisteApp.jsx b/src/OberleisteApp.jsx index 9a62a60..05c01fd 100644 --- a/src/OberleisteApp.jsx +++ b/src/OberleisteApp.jsx @@ -19,6 +19,7 @@ import { toggleReferenzlinien, toggleOsnap, setOsnapMode, toggleGridVisible, + openProjectSettings, } from './lib/rhinoBridge' const PRESETS = [ @@ -275,7 +276,7 @@ export default function OberleisteApp() { +
{/* ====== VIEW 2x4 Grid ====== Reihe 1: TOP / ISO / PERSP / 📷 (Kamera-Settings) @@ -709,16 +723,16 @@ export default function OberleisteApp() {
{/* ====== SNAP-BAR (Architektur-Osnaps + Grid) ====== - 4×2 Grid, nur Icons, schlank. Symbol-Wahl orientiert sich an - Rhinos eigenen Snap-Markern (End=Quadrat, Mid=Dreieck, Cen= - Kreis, Int=X, Perp=Winkel, Near=Plus). Ortho + Grid-Snap sind - in Rhinos Footer-Bar — hier nur was dort fehlt. - Reihe 1: Master-O | End | Mid | Int - Reihe 2: Perp | Cen | Near | 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 - const IconBtn = ({ icon, active, disabled, onClick, isFirst, title }) => ( + // Bar-Cell: Teil einer Segment-Pill (kein eigener Border-Radius) + const BarCell = ({ icon, active, disabled, onClick, isFirst, title }) => ( ) - const rowStyle = { - display: 'inline-flex', width: BAR_H * 4, + 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 ( -
-
- + {/* Linke Spalte: 2 einzelne Toggle-Buttons stacked */} +
+ toggleOsnap(!state.osnap)} - title={state.osnap ? 'Object-Snap an' : 'Object-Snap aus'} /> - setOsnapMode('end', !om.end)} - title="Endpunkt (End)" /> - setOsnapMode('mid', !om.mid)} - title="Mittelpunkt (Mid)" /> - setOsnapMode('int', !om.int)} - title="Schnittpunkt (Intersection)" /> -
-
- setOsnapMode('perp', !om.perp)} - title="Lotrecht (Perpendicular)" /> - setOsnapMode('cen', !om.cen)} - title="Kreis-/Bogen-Mittelpunkt (Center)" /> - setOsnapMode('near', !om.near)} - title="Naechster Punkt (Near)" /> - + toggleGridVisible(state.gridVisible === false)} title="Konstruktions-Raster ein-/ausblenden" />
+ {/* Rechte Spalte: 2 Pills mit den 6 Osnap-Modi */} +
+
+ setOsnapMode('end', !om.end)} + title="Endpunkt (End)" /> + setOsnapMode('mid', !om.mid)} + title="Mittelpunkt (Mid)" /> + setOsnapMode('int', !om.int)} + title="Schnittpunkt (Intersection)" /> +
+
+ setOsnapMode('perp', !om.perp)} + title="Lotrecht (Perpendicular)" /> + setOsnapMode('cen', !om.cen)} + title="Kreis-/Bogen-Mittelpunkt (Center)" /> + setOsnapMode('near', !om.near)} + title="Naechster Punkt (Near)" /> +
+
) })()} diff --git a/src/ProjectSettingsApp.jsx b/src/ProjectSettingsApp.jsx new file mode 100644 index 0000000..33396d8 --- /dev/null +++ b/src/ProjectSettingsApp.jsx @@ -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 ( + bridgeSend('SAVE', updated)} + onClose={() => bridgeSend('CANCEL', {})} + /> + ) +} diff --git a/src/ZeichnungsebenenApp.jsx b/src/ZeichnungsebenenApp.jsx index e3d2065..1007d1b 100644 --- a/src/ZeichnungsebenenApp.jsx +++ b/src/ZeichnungsebenenApp.jsx @@ -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} />
diff --git a/src/components/GeschossManager.jsx b/src/components/GeschossManager.jsx index df41673..fb8b0f0 100644 --- a/src/components/GeschossManager.jsx +++ b/src/components/GeschossManager.jsx @@ -159,6 +159,7 @@ 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 + @@ -254,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) diff --git a/src/components/LibraryBrowser.jsx b/src/components/LibraryBrowser.jsx new file mode 100644 index 0000000..b8e9ea2 --- /dev/null +++ b/src/components/LibraryBrowser.jsx @@ -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 ( +
{ if (!imported) onImport(item.id) }} + title={imported ? 'Bereits importiert' : 'Doppelklick = importieren'}> +
+
+
+ + {item.name} + + {imported && ( + + )} +
+ {(item.tags || []).length > 0 && ( +
+ {(item.tags || []).slice(0, 4).map(t => ( + {t} + ))} +
+ )} + +
+
+ ) +} + +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 ( +
+ {/* Header */} +
+ + + {manifest?.name || 'Dossier-Library'} + + + +
+ + {/* Toolbar */} +
+ setSearch(ev.target.value)} + placeholder="Suchen (Name oder Tag)…" + style={{ flex: 1, fontSize: 11, padding: '4px 8px', + borderRadius: 999 }} /> +
+ {types.map(t => ( + + ))} +
+
+ + {/* Grid */} +
+ {filtered.length === 0 ? ( +
+ {items.length === 0 + ? 'Library ist leer. Manifest unter:' + : 'Nichts gefunden — Filter aendern?'} + {items.length === 0 && libraryRoot && ( +
+ {libraryRoot}/library.json +
+ )} +
+ ) : ( +
+ {filtered.map(it => ( + + ))} +
+ )} +
+ + {/* Footer */} +
+ {filtered.length} / {items.length} Items +
+ + {libraryRoot || ''} + +
+
+ ) +} diff --git a/src/components/ProjectSettingsDialog.jsx b/src/components/ProjectSettingsDialog.jsx new file mode 100644 index 0000000..bc2c1d3 --- /dev/null +++ b/src/components/ProjectSettingsDialog.jsx @@ -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 ( +
+ {label} +
{children}
+ {hint && ( + + {hint} + + )} +
+ ) +} + +function TabBar({ tabs, active, onChange }) { + return ( +
+ {tabs.map(t => ( + + ))} +
+ ) +} + +function MaterialRow({ mat, hatchPatterns, onChange, onDelete, builtin }) { + return ( +
+ onChange({ ...mat, color: ev.target.value })} + title="Farbe" + style={{ width: 14, height: 14, padding: 0, border: 'none', + background: 'transparent', cursor: 'pointer' }} /> + onChange({ ...mat, name: ev.target.value })} + disabled={builtin} + placeholder="Name" + style={{ flex: 1, fontSize: 11, minWidth: 0, + opacity: builtin ? 0.7 : 1 }} /> + + onChange({ ...mat, scale: parseFloat(ev.target.value) || 1.0 })} + title="Hatch-Skalierung" + style={{ fontSize: 11, textAlign: 'right' }} /> + {mat.source === 'library' ? ( + L + ) : builtin || mat.source === 'builtin' ? ( + B + ) : ( + + )} +
+ ) +} + +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 ( +
+
+ {/* Header */} +
+ + + Projekt-Einstellungen + + +
+ + + + {/* Body */} +
+ {tab === 'defaults' && ( + <> +
+ Diese Werte werden beim Erstellen neuer Elemente als + Voreinstellung genommen. Pro-Element editierte Werte + bleiben davon unberuehrt. +
+ + setDefault('geschossHoehe', parseFloat(ev.target.value) || 3.0)} + style={{ flex: 1, textAlign: 'right' }} /> + + + setDefault('schnitthoehe', parseFloat(ev.target.value) || 1.0)} + style={{ flex: 1, textAlign: 'right' }} /> + +
+ + setDefault('schnittDepthBack', parseFloat(ev.target.value) || 8.0)} + style={{ flex: 1, textAlign: 'right' }} /> + +
+ + setDefault('schnittHeightMin', parseFloat(ev.target.value))} + style={{ flex: 1, textAlign: 'right' }} /> + + + setDefault('schnittHeightMax', parseFloat(ev.target.value))} + style={{ flex: 1, textAlign: 'right' }} /> + +
+ + )} + + {tab === 'materials' && ( + <> +
+ Eingebaute Materialien (B) koennen nicht umbenannt werden, + aber Farbe + Hatch sind anpassbar. Eigene Materialien + koennen frei angelegt werden. +
+ {/* Built-in materials (read-only name) */} + {builtin.map((m, i) => ( + {/* read-only fuer Phase 1 */}} /> + ))} + {/* User materials */} + {draft.materials.map((m, i) => ( + setMat(i, nm)} + onDelete={() => delMat(i)} /> + ))} +
+ + +
+ + )} +
+ + {/* Footer */} +
+
+ + +
+
+
+ ) +} diff --git a/src/lib/rhinoBridge.js b/src/lib/rhinoBridge.js index d30f24a..85af60d 100644 --- a/src/lib/rhinoBridge.js +++ b/src/lib/rhinoBridge.js @@ -185,6 +185,10 @@ export function toggleReferenzlinien(visible) { // 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 } diff --git a/src/main.jsx b/src/main.jsx index 096cdfc..afc4813 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -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