Ortho-Foto sichtbar (PictureFrame) + Oberleiste-Polish

Swisstopo Ortho
- AddPictureFrame statt Mesh+Material — Rhinos eigener Picture-Pfad mit
  embedBitmap=True + selfIllumination=True macht die Textur in allen
  Display-Modi sichtbar (Wireframe / Shaded / Rendered / Raytraced)
- asMesh=False (Brep-Variante) — asMesh=True ist auf Mac Rhino 8 broken
  (alle Pictures landen am gleichen Punkt unabhaengig von der Plane)
- Per-Tile Sub-Ebenen unter 80_swisstopo (z.B. 80_swisstopo/2763-1254_Ortho)
  via dossier_ebenen JSON registriert → erscheinen im Dossier-Ebenen-Manager
  mit eigener Visibility
- target_layer_idx wird vor AddPictureFrame als Active-Layer gesetzt,
  Picture landet direkt auf richtigem Sub-Layer (Move-danach broeselte
  das Material)
- Regex-Fix in _parse_swisstopo_tile_bbox: Separator zwischen den beiden
  Coords MUSS Hyphen sein, sonst matcht es faelschlich auf `_YEAR_EAST_`
  Patterns wie `_2025_2763_`

Oberleiste
- DOSSIER. Logo (Krungthep + Petrol-Punkt) + Launcher-Version
  (via __LAUNCHER_VERSION__ Vite-Define aus launcher/package.json)
- Overrides + Kombi vertikal gestapelt, gleiche Label-Spalte + Dropdown-
  Breite → Dropdowns auf gleicher X-Linie
- View-Icons neu zugeordnet:
    Top=view_quilt (Raster), Front=north (Pfeil), Persp=view_in_ar (3D)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-20 00:44:19 +02:00
parent afb59b6626
commit 85f09390bc
4 changed files with 405 additions and 161 deletions
+198 -23
View File
@@ -378,16 +378,16 @@ def _parse_swisstopo_tile_bbox(filename):
Filename-Pattern:
swissimage-dop10_2025_2763-1254_0.1_2056.tif (1km x 1km Tile)
swissimage-dop10_2025_2763-1254-12_0.5_2056.tif (250m Sub-Tile)
SWISSALTI3D_..._2763_1254.xyz (LV95-1km)
SWISSALTI3D_..._2763-1254.xyz (LV95-1km)
Wichtig: Separator zwischen den beiden Coords MUSS Hyphen sein
(`2763-1254`), sonst matcht der Regex faelschlich auf `_YEAR_EAST_`
Strukturen wie `_2025_2763_` und liefert Tile-Koords vom Jahr 2025.
Tile-Coords sind in 100m-Einheiten (E/N x 100). 2763-1254 = LV95
E=2'763'000, N=1'254'000 bbox = (2763000, 1254000, 2764000, 1255000).
Liefert (e_min, n_min, e_max, n_max) in Metern oder None."""
import re as _re
if not filename: return None
# Erst per-1km-Tile probieren: _NNNN-NNNN_ oder _NNNN_NNNN_
m = _re.search(r"[_-](\d{4})[-_](\d{4})(?:[-_]|\.)", filename)
m = _re.search(r"[_-](\d{4})-(\d{2,4})(?:[-_]|\.)", filename)
if not m: return None
e_k = int(m.group(1)); n_k = int(m.group(2))
e_min = e_k * 1000.0
@@ -7026,36 +7026,115 @@ class ElementeBridge(panel_base.BaseBridge):
plane_z = terr_max_z + z_offset
self._push_log("{} Ortho-Tile(s), platziere Plane bei Z={:.3f}".format(
len(ortho_paths), plane_z))
# Tile-IDs vorab extrahieren + Sub-Ebenen unter
# 80_swisstopo registrieren BEVOR die Pictures
# erzeugt werden. Damit kann jede _-Picture direkt
# auf dem richtigen Sub-Layer landen statt nach-
# traeglich verschoben zu werden (das brach die
# Textur).
import re as _re_o
tile_id_per_path = {}
for op in ortho_paths:
m = _re_o.search(r"(\d{3,4}-\d{2,4})",
os.path.basename(op))
if m: tile_id_per_path[op] = m.group(1)
if z_id and tile_id_per_path:
_find_ebene_sublayer_name(
d, ["swisstopo", "gelaende_topo"],
"80", "swisstopo",
default_color="#909090", default_lw=0.18)
self._ensure_ortho_tile_ebenen(
d, list(tile_id_per_path.values()))
# Target-Sub-Layer-Index pro Tile holen
import layer_builder as _lb_o
tile_layer_idx = {}
if z_id:
parent_idx = _lb_o._find_top_by_id(d, z_id)
if parent_idx >= 0:
parent_id_ = d.Layers[parent_idx].Id
base_idx = _lb_o._find_sublayer_by_code(
d, parent_id_, "80")
if base_idx >= 0:
base_id_ = d.Layers[base_idx].Id
for op, tid in tile_id_per_path.items():
idx = _lb_o._find_sublayer_by_code(
d, base_id_, tid)
if idx >= 0: tile_layer_idx[op] = idx
ortho_objs = []
for ortho_path in ortho_paths:
# tile_bbox aus Filename ableiten — swissimage
# tile_id = "1076-33" o.ae. → LV95 Tile-Origin
tile_bbox = _parse_swisstopo_tile_bbox(
os.path.basename(ortho_path))
if tile_bbox is None:
self._push_log(" → Tile-bbox nicht ableitbar aus {}".format(
os.path.basename(ortho_path)))
continue
tgt_idx = tile_layer_idx.get(ortho_path, -1)
try:
obj = swisstopo.add_ortho_plane(
d, ortho_path, tile_bbox,
origin_shift, m_to_unit, z_doc=plane_z)
if obj: ortho_objs.append(obj)
origin_shift, m_to_unit, z_doc=plane_z,
target_layer_idx=tgt_idx)
if obj:
ortho_objs.append(obj)
# Tag fuer Replace-Detection bei naechstem Import
try:
at = obj.Attributes.Duplicate()
at.SetUserString(
"dossier_swisstopo_kind", "ortho")
d.Objects.ModifyAttributes(obj, at, True)
except Exception: pass
except Exception as ex:
self._push_log("Ortho-Apply: {}".format(ex))
self._push_log("{} Ortho-Plane(s) erstellt".format(len(ortho_objs)))
# Layer (gleicher Geschoss-Sublayer 80_swisstopo wie Terrain)
if z_id and ortho_objs:
sub_name = _find_ebene_sublayer_name(
d, ["swisstopo", "gelaende_topo"],
"80", "swisstopo",
default_color="#909090", default_lw=0.18)
self._move_to_sublayer(d, ortho_objs, z_id,
sub_name.split("_", 1)[0], tag="ortho",
fallback_name=sub_name,
fallback_color="#909090")
elif ortho_objs:
self._tag_objects(d, ortho_objs, "ortho")
self._push_log("{} Ortho-Plane(s) auf eigene Sub-Layer".format(
len(ortho_objs)))
# End-Diagnose mit BBox-Koords damit wir sehen
# wo die Pictures tatsaechlich gelandet sind.
try:
diag = []
for o in d.Objects:
if o is None or o.IsDeleted: continue
tag = o.Attributes.GetUserString("dossier_swisstopo_kind")
if tag != "ortho": continue
li = o.Attributes.LayerIndex
lay = d.Layers[li]
try: bb = o.Geometry.GetBoundingBox(True)
except Exception: bb = None
diag.append({
"id": str(o.Id)[:8],
"lay": lay.FullPath,
"vis": lay.IsVisible,
"lck": lay.IsLocked,
"hid": o.IsHidden,
"typ": type(o.Geometry).__name__,
"bb": bb,
})
self._push_log("DIAG: {} Ortho-Objekte im Doc".format(len(diag)))
for s in diag[:4]:
bb = s["bb"]
bbstr = "bb=({:.0f},{:.0f},{:.0f})→({:.0f},{:.0f},{:.0f})".format(
bb.Min.X, bb.Min.Y, bb.Min.Z,
bb.Max.X, bb.Max.Y, bb.Max.Z) if bb else "bb=?"
self._push_log(" {} typ={} {} vis={} hid={} lay='{}'".format(
s["id"], s["typ"], bbstr,
s["vis"], s["hid"], s["lay"]))
# Building-bbox zum Vergleich
bb_b = rg.BoundingBox.Empty
n_b = 0
for o in d.Objects:
if o is None or o.IsDeleted: continue
tag = o.Attributes.GetUserString("dossier_swisstopo_kind")
if tag != "buildings": continue
try:
bb_b.Union(o.Geometry.GetBoundingBox(True))
n_b += 1
except Exception: pass
if bb_b.IsValid:
self._push_log("DIAG: Buildings ({} Obj) bb=({:.0f},{:.0f},{:.0f})→({:.0f},{:.0f},{:.0f})".format(
n_b,
bb_b.Min.X, bb_b.Min.Y, bb_b.Min.Z,
bb_b.Max.X, bb_b.Max.Y, bb_b.Max.Z))
except Exception as ex:
self._push_log("DIAG fail: {}".format(ex))
new_obj_ids.extend(o.Id for o in ortho_objs)
new_obj_ids.extend(o.Id for o, _ in mesh_objects)
@@ -7150,6 +7229,102 @@ class ElementeBridge(panel_base.BaseBridge):
doc.Objects.ModifyAttributes(o, attrs, True)
except Exception: pass
def _move_orthos_to_per_tile_layers(self, doc, objs_with_paths,
z_id):
"""Jedes Ortho-Tile bekommt eine eigene Sub-Ebene unter
80_swisstopo (Code=Tile-ID, z.B. '2763-1254'). Die Sub-Ebene
wird via dossier_ebenen JSON registriert erscheint sowohl
im Dossier-Ebenen-Manager als auch im Rhino-Layer-Panel.
User kann jedes Tile einzeln togglen."""
import re as _re
try:
# Schritt 1: alle tile_ids ermitteln
tiles = []
for obj, path in objs_with_paths:
m = _re.search(r"(\d{3,4}-\d{2,4})",
os.path.basename(path))
tile_id = m.group(1) if m else None
if tile_id: tiles.append((obj, tile_id))
if not tiles: return
# Schritt 2: alle als Children von 80_swisstopo registrieren
self._ensure_ortho_tile_ebenen(
doc, [t for _, t in tiles])
# Schritt 3: Objekte auf die jetzt existierenden Sublayer
import layer_builder
parent_idx = layer_builder._find_top_by_id(doc, z_id)
if parent_idx < 0: return
parent_id = doc.Layers[parent_idx].Id
base_idx = layer_builder._find_sublayer_by_code(
doc, parent_id, "80")
if base_idx < 0:
self._push_log(" 80_swisstopo nicht gefunden")
return
base_id = doc.Layers[base_idx].Id
moved = 0
for obj, tile_id in tiles:
sub_idx = layer_builder._find_sublayer_by_code(
doc, base_id, tile_id)
if sub_idx < 0:
self._push_log(" Sub-Layer fuer {} nicht gefunden".format(tile_id))
continue
try:
attrs = obj.Attributes.Duplicate()
attrs.LayerIndex = sub_idx
attrs.SetUserString("dossier_swisstopo_kind", "ortho")
doc.Objects.ModifyAttributes(obj, attrs, True)
moved += 1
except Exception as ex:
self._push_log(" ortho-move {}: {}".format(tile_id, ex))
self._push_log("{} Ortho-Tile(s) auf eigene Sub-Ebene".format(moved))
except Exception as ex:
self._push_log(" ortho-per-tile: {}".format(ex))
def _ensure_ortho_tile_ebenen(self, doc, tile_ids):
"""Registriert jeden Tile als Child unter '80_swisstopo' in
dossier_ebenen JSON, baut Layer einmal synchron, broadcastet
an die UI. Duplikate werden uebersprungen."""
if not tile_ids: return
raw = doc.Strings.GetValue("dossier_ebenen")
try: ebenen = json.loads(raw) if raw else []
except Exception: ebenen = []
if not isinstance(ebenen, list): ebenen = []
parent = next((e for e in ebenen if isinstance(e, dict)
and e.get("code") == "80"), None)
if parent is None:
parent = {
"code": "80", "name": "swisstopo",
"color": "#909090", "lw": 0.18,
"visible": True, "locked": False,
"children": [],
}
ebenen.append(parent)
if not isinstance(parent.get("children"), list):
parent["children"] = []
have = {c.get("code") for c in parent["children"]
if isinstance(c, dict)}
changed = False
for tile_id in set(tile_ids):
if tile_id in have: continue
parent["children"].append({
"code": tile_id, "name": "Ortho",
"color": "#909090", "lw": 0.13,
"visible": True, "locked": False,
})
changed = True
if not changed: return
try:
doc.Strings.SetString("dossier_ebenen",
json.dumps(ebenen, ensure_ascii=False))
import layer_builder
z_raw = doc.Strings.GetValue("dossier_zeichnungsebenen")
zlist = json.loads(z_raw) if z_raw else []
if zlist:
layer_builder.build_layers(doc, zlist, ebenen)
import rhinopanel
rhinopanel._broadcast_state(doc)
except Exception as ex:
self._push_log(" ortho-ebenen build: {}".format(ex))
def _ensure_sub_sublayer(self, doc, parent_id, name,
color_hex="#888888", lw=0.25):
"""Findet oder erstellt einen Sub-Layer mit Name <name> direkt
+71 -34
View File
@@ -18,6 +18,7 @@ import urllib.request
import urllib.parse
import Rhino
import Rhino.Geometry as rg
import System
CACHE_DIR = os.path.expanduser("~/Library/Caches/Dossier/swisstopo")
STAC_BASE = "https://data.geo.admin.ch/api/stac/v1"
@@ -737,7 +738,7 @@ def _geotiff_to_png(tif_path, max_dim=2048):
def add_ortho_plane(doc, ortho_path, tile_bbox_lv95, shift_lv95, m_to_unit,
z_doc=0.0):
z_doc=0.0, target_layer_idx=-1):
"""Erzeugt eine planare Brep-Flaeche mit dem SWISSIMAGE-Foto als Material,
direkt sichtbar in Top/Shaded/Rendered Display-Mode.
@@ -760,41 +761,77 @@ def add_ortho_plane(doc, ortho_path, tile_bbox_lv95, shift_lv95, m_to_unit,
x_max = (e_max - sx) * m_to_unit
y_min = (n_min - sy) * m_to_unit
y_max = (n_max - sy) * m_to_unit
# Mesh-Quad mit expliziten Per-Vertex-UV-Koordinaten — bombensicher
# fuer Cycles/Raytraced. Eine Brep-Plane braucht erst Render-Mesh-
# Erzeugung + TextureMapping, was diverse Fallstricke hat.
mesh = rg.Mesh()
mesh.Vertices.Add(x_min, y_min, z_doc) # 0 → UV (0,0)
mesh.Vertices.Add(x_max, y_min, z_doc) # 1 → UV (1,0)
mesh.Vertices.Add(x_max, y_max, z_doc) # 2 → UV (1,1)
mesh.Vertices.Add(x_min, y_max, z_doc) # 3 → UV (0,1)
mesh.Faces.AddFace(0, 1, 2, 3)
mesh.TextureCoordinates.Add(0.0, 0.0)
mesh.TextureCoordinates.Add(1.0, 0.0)
mesh.TextureCoordinates.Add(1.0, 1.0)
mesh.TextureCoordinates.Add(0.0, 1.0)
mesh.Normals.ComputeNormals()
mesh.Compact()
gid = doc.Objects.AddMesh(mesh)
# Centered plane, mesh-based PictureFrame mit embedded Bitmap.
# asMesh=True ist anders gerendert als Brep-Variante; bei Brep zeigte
# Mac Rhino 8 die Textur in keinem Modus.
cx = (x_min + x_max) / 2.0
cy = (y_min + y_max) / 2.0
width = abs(x_max - x_min)
height = abs(y_max - y_min)
plane = rg.Plane(rg.Point3d(cx, cy, z_doc),
rg.Vector3d.XAxis, rg.Vector3d.YAxis)
try:
size_mb = os.path.getsize(ortho_path) / 1e6
print("[SWISSTOPO] PictureFrame src: {} ({:.1f} MB)".format(
os.path.basename(ortho_path), size_mb))
except Exception:
print("[SWISSTOPO] file nicht lesbar:", ortho_path)
return None
try:
gid = doc.Objects.AddPictureFrame(
plane, ortho_path,
False, # asMesh=False (Brep) — Mac Rhino 8 ignoriert die
# Plane bei asMesh=True, alle Pictures landen
# uebereinander
width, height,
True, # selfIllumination=True — Textur unabhaengig von
# Lighting sichtbar (sonst evtl. dunkel in modes
# ohne Lichtquellen)
True) # embedBitmap=True (Pfad-Probleme umgehen)
if gid == System.Guid.Empty:
print("[SWISSTOPO] AddPictureFrame: Empty-GUID")
return None
except Exception as ex:
print("[SWISSTOPO] AddPictureFrame exception:", ex)
return None
obj = doc.Objects.Find(gid)
if obj is None: return None
# Material: Legacy + ToPhysicallyBased + PBR_BaseColor-Texture.
# Bekannt instabil unter Mac Rhino 8 für Raytraced (Cycles greift den
# Shim nicht zuverlaessig); zumindest Shaded zeigt die Textur.
# Auf Ziel-Layer schieben (nachträglich; Material bleibt auf Object).
if target_layer_idx >= 0:
try:
at = obj.Attributes.Duplicate()
at.LayerIndex = target_layer_idx
doc.Objects.ModifyAttributes(obj, at, True)
except Exception as ex:
print("[SWISSTOPO] Layer-Move fail:", ex)
# Diagnose: hat das Material tatsaechlich eine Bitmap-Textur drin?
try:
mat = Rhino.DocObjects.Material()
mat.Name = "swisstopo_ortho"
mat.SetBitmapTexture(ortho_path)
mat.ToPhysicallyBased()
tex = Rhino.DocObjects.Texture()
tex.FileName = ortho_path
tex.Enabled = True
mat.SetTexture(tex, Rhino.DocObjects.TextureType.PBR_BaseColor)
midx = doc.Materials.Add(mat)
attrs = obj.Attributes.Duplicate()
attrs.MaterialSource = Rhino.DocObjects.ObjectMaterialSource.MaterialFromObject
attrs.MaterialIndex = midx
doc.Objects.ModifyAttributes(obj, attrs, True)
o2 = doc.Objects.Find(gid)
a = o2.Attributes
print("[SWISSTOPO] PictureFrame OK id={} layer='{}' MatSrc={} MatIdx={} hidden={}".format(
gid, doc.Layers[a.LayerIndex].FullPath,
a.MaterialSource, a.MaterialIndex, o2.IsHidden))
# Material-Inspect
mat = None
try:
if a.MaterialIndex >= 0 and a.MaterialIndex < doc.Materials.Count:
mat = doc.Materials[a.MaterialIndex]
except Exception: pass
if mat is not None:
try: bmp_fn = mat.GetBitmapTexture().FileName if mat.GetBitmapTexture() else None
except Exception: bmp_fn = None
try: tex = mat.GetTexture(Rhino.DocObjects.TextureType.Bitmap)
except Exception: tex = None
print("[SWISSTOPO] material[{}].Name='{}' bitmap='{}' tex={} textures={}".format(
a.MaterialIndex, mat.Name, bmp_fn, tex,
mat.GetTextures().Length if hasattr(mat, "GetTextures") else "?"))
# RenderMaterial-Inspect
try:
rm = a.RenderMaterial
print("[SWISSTOPO] RenderMaterial: {}".format(
rm.Name if rm else "None"))
except Exception as ex:
print("[SWISSTOPO] RenderMaterial-check fail:", ex)
except Exception as ex:
print("[SWISSTOPO] ortho-material:", ex)
print("[SWISSTOPO] diag fail:", ex)
return obj
+133 -103
View File
@@ -21,10 +21,10 @@ const PRESETS = [
]
const VIEWS = [
{ value: 'Top', icon: 'north', label: 'Top' },
{ value: 'Front', icon: 'view_in_ar', label: 'Front' },
{ value: 'Top', icon: 'view_quilt', label: 'Top' },
{ value: 'Front', icon: 'north', label: 'Front' },
{ value: 'Right', icon: 'east', label: 'Right' },
{ value: 'Perspective', icon: 'view_quilt', label: 'Persp' },
{ value: 'Perspective', icon: 'view_in_ar', label: 'Persp' },
]
function fmtScale(s) {
@@ -207,21 +207,37 @@ export default function OberleisteApp() {
{/* === Toolbar (View, Display, Massstab, Snap, Overrides) === */}
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '6px 12px',
padding: '4px 12px 8px',
overflowX: 'auto', overflowY: 'hidden',
flexShrink: 0,
}}>
<span
{/* Logo: DOSSIER. (Petrol-Punkt) + Launcher-Version */}
<div
style={{
fontSize: 11, fontWeight: 600, letterSpacing: '0.08em',
color: 'var(--text-muted)',
fontFamily: 'DM Mono, monospace',
display: 'flex', alignItems: 'baseline', gap: 8,
flexShrink: 0, userSelect: 'none',
}}
title={`Dossier ${__APP_VERSION__} — Teil von OpenStudio`}
title={`Dossier ${__LAUNCHER_VERSION__} (Plugin ${__APP_VERSION__}) — Teil von OpenStudio`}
>
DOSSIER <span style={{ opacity: 0.55 }}>{__APP_VERSION__}</span>
</span>
<span style={{
fontFamily: "Krungthep, 'Archivo Black', sans-serif",
fontSize: 18,
letterSpacing: '-0.02em',
color: 'var(--text-primary)',
lineHeight: 1,
}}>
DOSSIER<span style={{ color: 'var(--accent)' }}>.</span>
</span>
<span style={{
fontFamily: 'DM Mono, monospace',
fontSize: 9,
letterSpacing: '0.14em',
color: 'var(--text-muted)',
textTransform: 'uppercase',
}}>
v{__LAUNCHER_VERSION__}
</span>
</div>
<button
onClick={() => openDossierSettings()}
title="Dossier-Einstellungen"
@@ -344,98 +360,112 @@ export default function OberleisteApp() {
{/* Snap-Toggles (Ortho/Grid/OSnap) sind in Rhinos eigener Footer-Bar
schon vorhanden — hier rausgenommen um Doppelung zu vermeiden. */}
{/* ====== GRUPPE: OVERRIDES ====== */}
<span style={groupLabel}>Overrides</span>
<ToolButton
onClick={() => toggleOverrides(!state.overridesEnabled)}
active={state.overridesEnabled}
icon="auto_fix_high"
label={state.overridesEnabled ? 'AN' : 'AUS'}
title={state.overridesEnabled
? `Grafische Overrides aktiv — klick zum Ausschalten`
: `Grafische Overrides ausgeschaltet`}
/>
{/* Preset-Dropdown: aktive Kombination waehlen. "—" = keine Kombination
(Doc-Rules sind frei editiert oder leer). "Konfigurieren…" oeffnet
den grossen Regel-Editor (OVERRIDES-Panel). */}
<select
value={state.overridesActivePreset || '__none__'}
onChange={(e) => {
const v = e.target.value
if (v === '__configure__') { openOverridesPanel(); return }
setOverridesPreset(v === '__none__' ? null : v)
}}
style={{ ...pillSelect, width: 140 }}
title={state.overridesActivePreset
? `Aktives Preset: ${state.overridesActivePreset} (${state.overridesCount} Regeln)`
: `Kein Preset aktiv (${state.overridesCount} Regeln, frei editiert)`}
>
<option value="__none__">{state.overridesCount > 0 ? `— (${state.overridesCount} Regeln)` : '—'}</option>
{(state.overridesPresets || []).map(name => (
<option key={name} value={name}>{name}</option>
))}
<option disabled></option>
<option value="__configure__">Konfigurieren</option>
</select>
<ToolButton
onClick={openOverridesPanel}
icon="settings"
title="Overrides-Regel-Editor öffnen"
/>
<div style={sep} />
{/* ====== GRUPPE: EBENENKOMBINATION ====== */}
<span style={groupLabel}>Kombi</span>
<select
value={state.layerCombinationActive || '__none__'}
onChange={(e) => {
const v = e.target.value
if (v === '__configure__') { openLayerCombinationsDialog(); return }
if (v === '__delete__') {
if (state.layerCombinationActive &&
window.confirm(`Kombination "${state.layerCombinationActive}" löschen?`))
deleteLayerCombination(state.layerCombinationActive)
return
}
pickLayerCombination(v === '__none__' ? null : v)
}}
style={{ ...pillSelect, width: 140 }}
title={state.layerCombinationActive
? `Aktive Kombi: ${state.layerCombinationActive}`
: 'Keine Kombination — manuelle Sichtbarkeit'}
>
<option value="__none__"> Eigene </option>
{(state.layerCombinations || []).map(name => (
<option key={name} value={name}>{name}</option>
))}
{state.layerCombinationActive && (
<>
<option disabled></option>
<option value="__delete__">🗑 Aktuelle löschen</option>
</>
)}
<option disabled></option>
<option value="__configure__">Bearbeiten</option>
</select>
<ToolButton
onClick={() => {
const suggested = state.layerCombinationActive
|| `Kombi ${(state.layerCombinations || []).length + 1}`
const name = (window.prompt('Name für Ebenenkombination:', suggested) || '').trim()
if (!name) return
if ((state.layerCombinations || []).includes(name) &&
!window.confirm(`"${name}" überschreiben?`)) return
saveLayerCombination(name)
}}
icon="add"
title="Aktuelle Sichtbarkeit als neue Kombination speichern"
/>
<ToolButton
onClick={openLayerCombinationsDialog}
icon="edit"
title="Ebenenkombinationen bearbeiten"
/>
{/* ====== STACK: Overrides + Kombi uebereinander ======
Beide Zeilen haben identisches Spalten-Layout (Label-Spalte fix,
Dropdown gleich breit), damit Dropdowns vertikal aligned sind. */}
{(() => {
const STACK_LABEL_W = 60 // gleich breit fuer beide Zeilen
const STACK_DROPDOWN_W = 150
const stackLabel = { ...groupLabel, width: STACK_LABEL_W,
padding: 0, textAlign: 'left' }
return (
<div style={{
display: 'flex', flexDirection: 'column', gap: 4,
flexShrink: 0,
}}>
{/* Overrides */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={stackLabel}>Overrides</span>
<ToolButton
onClick={() => toggleOverrides(!state.overridesEnabled)}
active={state.overridesEnabled}
icon="auto_fix_high"
label={state.overridesEnabled ? 'AN' : 'AUS'}
title={state.overridesEnabled
? `Grafische Overrides aktiv — klick zum Ausschalten`
: `Grafische Overrides ausgeschaltet`}
/>
<select
value={state.overridesActivePreset || '__none__'}
onChange={(e) => {
const v = e.target.value
if (v === '__configure__') { openOverridesPanel(); return }
setOverridesPreset(v === '__none__' ? null : v)
}}
style={{ ...pillSelect, width: STACK_DROPDOWN_W }}
title={state.overridesActivePreset
? `Aktives Preset: ${state.overridesActivePreset} (${state.overridesCount} Regeln)`
: `Kein Preset aktiv (${state.overridesCount} Regeln, frei editiert)`}
>
<option value="__none__">{state.overridesCount > 0 ? `— (${state.overridesCount} Regeln)` : '—'}</option>
{(state.overridesPresets || []).map(name => (
<option key={name} value={name}>{name}</option>
))}
<option disabled></option>
<option value="__configure__">Konfigurieren</option>
</select>
<ToolButton
onClick={openOverridesPanel}
icon="settings"
title="Overrides-Regel-Editor öffnen"
/>
</div>
{/* Kombi */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={stackLabel}>Kombi</span>
<ToolButton
onClick={() => {
const suggested = state.layerCombinationActive
|| `Kombi ${(state.layerCombinations || []).length + 1}`
const name = (window.prompt('Name für Ebenenkombination:', suggested) || '').trim()
if (!name) return
if ((state.layerCombinations || []).includes(name) &&
!window.confirm(`"${name}" überschreiben?`)) return
saveLayerCombination(name)
}}
icon="add"
title="Aktuelle Sichtbarkeit als neue Kombination speichern"
/>
<select
value={state.layerCombinationActive || '__none__'}
onChange={(e) => {
const v = e.target.value
if (v === '__configure__') { openLayerCombinationsDialog(); return }
if (v === '__delete__') {
if (state.layerCombinationActive &&
window.confirm(`Kombination "${state.layerCombinationActive}" löschen?`))
deleteLayerCombination(state.layerCombinationActive)
return
}
pickLayerCombination(v === '__none__' ? null : v)
}}
style={{ ...pillSelect, width: STACK_DROPDOWN_W }}
title={state.layerCombinationActive
? `Aktive Kombi: ${state.layerCombinationActive}`
: 'Keine Kombination — manuelle Sichtbarkeit'}
>
<option value="__none__"> Eigene </option>
{(state.layerCombinations || []).map(name => (
<option key={name} value={name}>{name}</option>
))}
{state.layerCombinationActive && (
<>
<option disabled></option>
<option value="__delete__">🗑 Aktuelle löschen</option>
</>
)}
<option disabled></option>
<option value="__configure__">Bearbeiten</option>
</select>
<ToolButton
onClick={openLayerCombinationsDialog}
icon="edit"
title="Ebenenkombinationen bearbeiten"
/>
</div>
</div>
)
})()}
{/* Spacer am rechten Rand */}
<div style={{ flex: 1 }} />
+3 -1
View File
@@ -1,12 +1,14 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import pkg from './package.json' with { type: 'json' }
import launcherPkg from './launcher/package.json' with { type: 'json' }
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
base: './', // relative paths so file:// URLs in WKWebView funktionieren
define: {
__APP_VERSION__: JSON.stringify(pkg.version),
__APP_VERSION__: JSON.stringify(pkg.version),
__LAUNCHER_VERSION__: JSON.stringify(launcherPkg.version),
},
})