From 24ac481994ac231626655479aeaee1aa6c6db213 Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Fri, 27 Feb 2026 13:10:50 -0800 Subject: [PATCH] Flood overlay water rendering, UI refinements, Trinidad coastal resilience demo - Add overlay_as_water flag to render pipeline: flood overlay pixels (value > 0.5) trigger the ocean water shader (procedural waves, Fresnel reflections, specular) instead of colormap blending, creating a visual rising-flood animation when cycling layers with G key. - Engine detects flood_ prefix layers and sets overlay_as_water automatically. - Add info_text parameter to explore(): appended as a MODEL INFO section in the paginated help menu. Help item rendering supports key-less entries that span the full column width. - Trinidad & Tobago Coastal Resilience demo: storm surge analysis across both islands using Copernicus 30m DEM, Overture buildings/roads, and OSM. Flood masks use NaN for non-flooded pixels (fixes black mask over satellite tiles). Removed slope/aspect layers to focus on surge analysis. --- .../trinidad_tobago_coastal_resilience.py | 430 +++++++++++++ rtxpy/accessor.py | 9 +- rtxpy/analysis/render.py | 68 +- rtxpy/engine.py | 587 +++++++++++------- 4 files changed, 843 insertions(+), 251 deletions(-) create mode 100644 examples/trinidad_tobago_coastal_resilience.py diff --git a/examples/trinidad_tobago_coastal_resilience.py b/examples/trinidad_tobago_coastal_resilience.py new file mode 100644 index 0000000..71e57a1 --- /dev/null +++ b/examples/trinidad_tobago_coastal_resilience.py @@ -0,0 +1,430 @@ +"""Trinidad & Tobago Coastal Resilience — Storm Surge Impact Analysis. + +Model storm-surge and sea-level-rise scenarios across Trinidad & Tobago. +Copernicus 30 m elevation data is used to identify which OSM buildings and +roads would be inundated at progressively higher water levels — highlighting +low-water crossings and coastal infrastructure most at risk. + +Storm-surge scenarios: 1 m, 2 m, 3 m, 5 m, 10 m above present sea level. +Infrastructure is colour-coded by the lowest surge level that would flood it: + + dark purple → 1 m (extreme – low-water crossings, immediate flood zone) + vermillion → 2 m (very high) + orange → 3 m (high) + sky blue → 5 m (moderate) + bluish grn → 10 m (low) + grey → safe (above 10 m) + +Dataset layers (press G to cycle): + elevation – terrain height (scaled) + surge_risk – graduated: lowest surge level to flood each pixel + flood_1m … flood_10m – water rendering per scenario (rising flood) + +Flood model: simple bathtub inundation — each pixel with Copernicus DEM +elevation <= surge level is marked as flooded. No hydrodynamic connectivity +or wave run-up; assumes static uniform water surface. Conservative for open +coast (ignores wave setup) but optimistic for inland areas (ignores drainage +blockage). Buildings classified by centroid elevation, roads by their lowest +vertex (low-water crossing analysis). + +Press U for satellite overlay, Shift+W for wind animation, O/V for viewshed. + +Requirements: + pip install rtxpy[all] matplotlib xarray rioxarray requests pyproj Pillow +""" + +import warnings + +import numpy as np +import xarray as xr +from pathlib import Path +from pyproj import Transformer + +from rtxpy import fetch_dem, fetch_buildings, fetch_roads, fetch_water + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- +BOUNDS = (-61.95, 10.04, -60.44, 11.40) # both islands (WGS 84) +CRS = 'EPSG:32620' # UTM zone 20N +CACHE = Path(__file__).parent +ZARR = CACHE / "tt_coastal_dem.zarr" +SURGE_LEVELS = [1, 2, 3, 5, 10] # metres above sea level + +RISK_COLORS = { + 1: (0.50, 0.00, 0.50), # dark purple – extreme + 2: (0.84, 0.19, 0.12), # vermillion – very high + 3: (0.90, 0.60, 0.00), # orange – high + 5: (0.34, 0.71, 0.91), # sky blue – moderate + 10: (0.00, 0.62, 0.45), # bluish green – low +} + +BLDG_HEIGHT = 8 # default 8 m building height + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +def _sample_dem(raw_elev, lons, lats): + """Return ground elevation (m) at each (lon, lat) via nearest-pixel lookup.""" + transformer = Transformer.from_crs("EPSG:4326", CRS, always_xy=True) + pxs, pys = transformer.transform( + np.asarray(lons, dtype=np.float64), + np.asarray(lats, dtype=np.float64), + ) + pxs, pys = np.asarray(pxs), np.asarray(pys) + + xs = raw_elev.x.values.copy() + ys = raw_elev.y.values.copy() + elev = raw_elev.values + + # Ensure ascending order for searchsorted + if xs[-1] < xs[0]: + xs = xs[::-1] + elev = elev[:, ::-1] + if ys[-1] < ys[0]: + ys = ys[::-1] + elev = elev[::-1, :] + + def _nearest(arr, vals): + idx = np.searchsorted(arr, vals).clip(0, len(arr) - 1) + left = np.maximum(idx - 1, 0) + return np.where(np.abs(arr[left] - vals) < np.abs(arr[idx] - vals), left, idx) + + return elev[_nearest(ys, pys), _nearest(xs, pxs)] + + +def _fc(features): + """Wrap features in a GeoJSON FeatureCollection.""" + return {"type": "FeatureCollection", "features": features} + + +# --------------------------------------------------------------------------- +# Data loading +# --------------------------------------------------------------------------- +def load_terrain(): + """Fetch Copernicus DEM; return (scaled GPU DataArray, raw CPU DataArray).""" + raw = fetch_dem( + bounds=BOUNDS, + output_path=ZARR, + source='copernicus', + crs=CRS, + ) + raw_elev = raw.copy(deep=True) + + terrain = raw.copy(deep=True) + terrain.data = np.ascontiguousarray(terrain.data) + emin, emax = float(np.nanmin(raw_elev.data)), float(np.nanmax(raw_elev.data)) + print(f"Terrain: {terrain.shape}, elevation {emin:.1f} m to {emax:.1f} m") + + terrain = terrain.rtx.to_cupy() + return terrain, raw_elev + + +# --------------------------------------------------------------------------- +# Surge analysis — raster layers +# --------------------------------------------------------------------------- +def build_surge_layers(raw_elev): + """Create raster flood-zone layers from unscaled elevation. + + Returns a dict of DataArrays ready for the Dataset. + """ + elev = raw_elev.values + land = ~np.isnan(elev) # True where we have elevation data + layers = {} + + # Graduated layer: lowest surge level that floods each pixel + risk = np.full_like(elev, np.nan) + for level in reversed(SURGE_LEVELS): + risk = np.where(land & (elev <= level), float(level), risk) + layers['surge_risk'] = xr.DataArray( + np.ascontiguousarray(risk), coords=raw_elev.coords, dims=raw_elev.dims, + ) + + # Per-scenario flood masks (1 = flooded, NaN = everything else) + pixel_km2 = 30 * 30 / 1e6 + print("\nStorm-surge inundation area:") + for level in SURGE_LEVELS: + flooded = land & (elev <= level) + area = float(flooded.sum()) * pixel_km2 + print(f" {level:>2d} m surge: {area:>8.1f} km² inundated") + mask = np.where(flooded, 1.0, np.nan) + layers[f'flood_{level}m'] = xr.DataArray( + np.ascontiguousarray(mask), coords=raw_elev.coords, dims=raw_elev.dims, + ) + + return layers + + +# --------------------------------------------------------------------------- +# Infrastructure classification +# --------------------------------------------------------------------------- +def classify_buildings(bldg_data, raw_elev): + """Split buildings into risk bins by centroid ground elevation.""" + features = bldg_data.get('features', []) + if not features: + return {} + + # Compute centroids from exterior ring + lons, lats = [], [] + for f in features: + coords = f['geometry']['coordinates'] + ring = coords[0][0] if f['geometry']['type'] == 'MultiPolygon' else coords[0] + lons.append(sum(c[0] for c in ring) / len(ring)) + lats.append(sum(c[1] for c in ring) / len(ring)) + + elevs = _sample_dem(raw_elev, lons, lats) + + bins = {lv: [] for lv in SURGE_LEVELS} + bins['safe'] = [] + for i, f in enumerate(features): + e = elevs[i] + if np.isnan(e): + bins['safe'].append(f) + continue + placed = False + for lv in SURGE_LEVELS: + if e <= lv: + bins[lv].append(f) + placed = True + break + if not placed: + bins['safe'].append(f) + + print("\nBuildings by storm-surge risk:") + for lv in SURGE_LEVELS: + n = len(bins[lv]) + if n: + print(f" ≤ {lv:>2d} m: {n:>7,d} buildings") + print(f" safe: {len(bins['safe']):>7,d} buildings") + return bins + + +def classify_roads(road_data, raw_elev): + """Split roads into risk bins by minimum vertex elevation (low-water crossings).""" + features = road_data.get('features', []) + if not features: + return {} + + # Gather every vertex with its parent feature index + all_lons, all_lats, feat_idx = [], [], [] + for i, f in enumerate(features): + geom = f['geometry'] + if geom['type'] == 'LineString': + lines = [geom['coordinates']] + elif geom['type'] == 'MultiLineString': + lines = geom['coordinates'] + else: + continue # skip Point, Polygon, etc. + for line in lines: + for coord in line: + all_lons.append(coord[0]) + all_lats.append(coord[1]) + feat_idx.append(i) + + if not all_lons: + return {} + + all_elevs = _sample_dem(raw_elev, all_lons, all_lats) + feat_idx = np.asarray(feat_idx) + + # Minimum elevation per road segment (the low-water crossing point) + min_elev = np.full(len(features), np.inf) + valid = ~np.isnan(all_elevs) + np.minimum.at(min_elev, feat_idx[valid], all_elevs[valid]) + + bins = {lv: [] for lv in SURGE_LEVELS} + bins['safe'] = [] + for i, f in enumerate(features): + e = min_elev[i] + if np.isinf(e): + bins['safe'].append(f) + continue + placed = False + for lv in SURGE_LEVELS: + if e <= lv: + bins[lv].append(f) + placed = True + break + if not placed: + bins['safe'].append(f) + + print("\nRoads by storm-surge risk (low-water crossing analysis):") + for lv in SURGE_LEVELS: + n = len(bins[lv]) + if n: + print(f" ≤ {lv:>2d} m: {n:>7,d} road segments") + print(f" safe: {len(bins['safe']):>7,d} road segments") + return bins + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +if __name__ == "__main__": + terrain, raw_elev = load_terrain() + + # --- Derived raster layers --------------------------------------------- + print("Computing terrain analysis layers...") + surge_layers = build_surge_layers(raw_elev) + + ds = xr.Dataset({ + 'elevation': terrain.rename(None), + **{k: v.rtx.to_cupy() for k, v in surge_layers.items()}, + }) + print(ds) + + # --- Satellite tiles --------------------------------------------------- + print("\nLoading satellite tiles...") + ds.rtx.place_tiles('satellite', z='elevation') + + # --- Load meshes from zarr cache, or build from GeoJSON ----------------- + import zarr as _zarr + _has_mesh_cache = False + try: + _store = _zarr.open(str(ZARR), mode='r', use_consolidated=False) + _has_mesh_cache = 'meshes' in _store and len(list(_store['meshes'])) > 0 + _store = None + except Exception: + pass + + if _has_mesh_cache: + ds.rtx.load_meshes(ZARR) + else: + # --- Buildings colour-coded by risk -------------------------------- + try: + bldg_data = fetch_buildings( + bounds=BOUNDS, source='overture', + cache_path=CACHE / "tt_coastal_buildings.geojson", + ) + bldg_bins = classify_buildings(bldg_data, raw_elev) + + for lv in SURGE_LEVELS: + if bldg_bins.get(lv): + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", message="place_geojson called before") + info = ds.rtx.place_geojson( + _fc(bldg_bins[lv]), z='elevation', + height=BLDG_HEIGHT, extrude=True, merge=True, + geometry_id=f'bldg_{lv}m', + color=RISK_COLORS[lv], + ) + print(f" Placed {info['geometries']} buildings at ≤{lv} m risk") + + if bldg_bins.get('safe'): + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", message="place_geojson called before") + info = ds.rtx.place_geojson( + _fc(bldg_bins['safe']), z='elevation', + height=BLDG_HEIGHT, extrude=True, merge=True, + geometry_id='bldg_safe', + color=(0.65, 0.65, 0.65), + ) + print(f" Placed {info['geometries']} safe buildings") + except Exception as e: + print(f"Skipping buildings: {e}") + + # --- Roads colour-coded by risk ------------------------------------ + try: + road_data = fetch_roads( + bounds=BOUNDS, road_type='all', source='overture', + cache_path=CACHE / "tt_coastal_roads.geojson", + ) + road_bins = classify_roads(road_data, raw_elev) + + for lv in SURGE_LEVELS: + if road_bins.get(lv): + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", message="place_geojson called before") + info = ds.rtx.place_roads( + _fc(road_bins[lv]), z='elevation', + geometry_id=f'road_{lv}m', + color=RISK_COLORS[lv], + ) + print(f" Placed {info['geometries']} road segments at ≤{lv} m risk") + + if road_bins.get('safe'): + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", message="place_geojson called before") + info = ds.rtx.place_roads( + _fc(road_bins['safe']), z='elevation', + geometry_id='road_safe', + color=(0.30, 0.30, 0.30), + ) + print(f" Placed {info['geometries']} safe road segments") + except Exception as e: + print(f"Skipping roads: {e}") + + # --- Water features (coastline/rivers for context) ----------------- + try: + water_data = fetch_water( + bounds=BOUNDS, water_type='all', + cache_path=CACHE / "tt_coastal_water.geojson", + ) + results = ds.rtx.place_water(water_data, z='elevation') + for cat, info in results.items(): + print(f" Placed {info['geometries']} {cat} water features") + except Exception as e: + print(f"Skipping water: {e}") + + # --- Save all meshes to zarr for next run -------------------------- + try: + ds.rtx.save_meshes(ZARR) + except Exception as e: + print(f"Could not save mesh cache: {e}") + + # --- Wind (storm context) ---------------------------------------------- + wind = None + try: + from rtxpy import fetch_wind + wind = fetch_wind(BOUNDS, grid_size=15) + except Exception as e: + print(f"Skipping wind: {e}") + + # --- Launch explorer --------------------------------------------------- + print("\n" + "=" * 60) + print("COASTAL RESILIENCE EXPLORER") + print("=" * 60) + print(" G cycle layers (elevation → slope → surge_risk → flood maps)") + print(" U toggle satellite overlay") + print(" Shift+W toggle wind particles (storm simulation)") + print(" O / V set observer / toggle viewshed") + print(" M minimap") + print(" H full help overlay") + print("=" * 60) + + ds.rtx.explore( + z='elevation', + scene_zarr=ZARR, + title='Trinidad & Tobago: Storm Surge', + subtitle='Copernicus 30m DEM \u00b7 Overture Maps \u00b7 OSM', + legend={ + 'entries': [ + ('Extreme (\u2264 1 m)', (0.50, 0.00, 0.50)), + ('Very high (\u2264 2 m)', (0.84, 0.19, 0.12)), + ('High (\u2264 3 m)', (0.90, 0.60, 0.00)), + ('Moderate (\u2264 5 m)', (0.34, 0.71, 0.91)), + ('Low (\u2264 10 m)', (0.00, 0.62, 0.45)), + ('Safe', (0.65, 0.65, 0.65)), + ], + }, + width=2048, + height=1600, + render_scale=0.5, + color_stretch='cbrt', + subsample=1, + wind_data=wind, + minimap_style='cyberpunk', + minimap_layer='surge_risk', + minimap_colors=RISK_COLORS, + info_text=( + "Bathtub inundation model\n" + "Copernicus 30m DEM \u2264 surge level = flooded\n" + "No hydrodynamic connectivity or wave run-up\n" + "Buildings classified by centroid elevation\n" + "Roads by lowest vertex (low-water crossing)" + ), + repl=True, + ) + + print("Done") diff --git a/rtxpy/accessor.py b/rtxpy/accessor.py index c667bd4..92360db 100644 --- a/rtxpy/accessor.py +++ b/rtxpy/accessor.py @@ -3009,7 +3009,10 @@ def explore(self, z, width=800, height=600, render_scale=0.5, subtitle=None, legend=None, subsample=1, wind_data=None, gtfs_data=None, scene_zarr=None, - ao_samples=0, gi_bounces=1, denoise=False, repl=False, tour=None): + ao_samples=0, gi_bounces=1, denoise=False, + minimap_style=None, minimap_layer=None, + minimap_colors=None, info_text=None, + repl=False, tour=None): """Launch an interactive terrain viewer with Dataset variables as overlay layers cycled with the G key. @@ -3116,6 +3119,10 @@ def explore(self, z, width=800, height=600, render_scale=0.5, ao_samples=ao_samples, gi_bounces=gi_bounces, denoise=denoise, + minimap_style=minimap_style, + minimap_layer=minimap_layer, + minimap_colors=minimap_colors, + info_text=info_text, repl=repl, tour=tour, ) diff --git a/rtxpy/analysis/render.py b/rtxpy/analysis/render.py index c1c23d3..04f8397 100644 --- a/rtxpy/analysis/render.py +++ b/rtxpy/analysis/render.py @@ -823,6 +823,7 @@ def _shade_terrain_kernel( color_stretch, rgb_texture, overlay_data, overlay_alpha, overlay_min, overlay_range, + overlay_as_water, instance_ids, geometry_colors, primitive_ids, point_colors, point_color_offsets, ao_factor, gi_color, gi_intensity, @@ -986,33 +987,44 @@ def _shade_terrain_kernel( if elev_y >= 0 and elev_y < ov_h and elev_x >= 0 and elev_x < ov_w: ov_val = overlay_data[elev_y, elev_x] if not math.isnan(ov_val): - if overlay_range > 0: - ov_norm = (ov_val - overlay_min) / overlay_range + if overlay_as_water and ov_val > 0.5: + # Flood water shader — same look as ocean + is_water = True + water_specular = 0.12 + base_r = 0.06 + base_g = 0.12 + base_b = 0.22 + nx = 0.0 + ny = 0.0 + nz = 1.0 else: - ov_norm = 0.5 - if ov_norm < 0: - ov_norm = 0.0 - elif ov_norm > 1: - ov_norm = 1.0 - # Apply same color stretch - if color_stretch == 1: - ov_norm = math.pow(ov_norm, 1.0 / 3.0) - elif color_stretch == 2: - ov_norm = math.log(1.0 + ov_norm * 9.0) / math.log(10.0) - elif color_stretch == 3: - ov_norm = math.sqrt(ov_norm) - ov_idx = int(ov_norm * 255) - if ov_idx > 255: - ov_idx = 255 - if ov_idx < 0: - ov_idx = 0 - ov_r = color_lut[ov_idx, 0] - ov_g = color_lut[ov_idx, 1] - ov_b = color_lut[ov_idx, 2] - a = overlay_alpha - base_r = base_r * (1.0 - a) + ov_r * a - base_g = base_g * (1.0 - a) + ov_g * a - base_b = base_b * (1.0 - a) + ov_b * a + if overlay_range > 0: + ov_norm = (ov_val - overlay_min) / overlay_range + else: + ov_norm = 0.5 + if ov_norm < 0: + ov_norm = 0.0 + elif ov_norm > 1: + ov_norm = 1.0 + # Apply same color stretch + if color_stretch == 1: + ov_norm = math.pow(ov_norm, 1.0 / 3.0) + elif color_stretch == 2: + ov_norm = math.log(1.0 + ov_norm * 9.0) / math.log(10.0) + elif color_stretch == 3: + ov_norm = math.sqrt(ov_norm) + ov_idx = int(ov_norm * 255) + if ov_idx > 255: + ov_idx = 255 + if ov_idx < 0: + ov_idx = 0 + ov_r = color_lut[ov_idx, 0] + ov_g = color_lut[ov_idx, 1] + ov_b = color_lut[ov_idx, 2] + a = overlay_alpha + base_r = base_r * (1.0 - a) + ov_r * a + base_g = base_g * (1.0 - a) + ov_g * a + base_b = base_b * (1.0 - a) + ov_b * a # Write albedo (material color before lighting) for denoiser guide if albedo_out.shape[0] > 1: @@ -1539,6 +1551,7 @@ def _shade_terrain( rgb_texture=None, overlay_data=None, overlay_alpha=0.5, overlay_min=0.0, overlay_range=1.0, + overlay_as_water=False, instance_ids=None, geometry_colors=None, primitive_ids=None, point_colors=None, point_color_offsets=None, ao_factor=None, gi_color=None, gi_intensity=2.0, @@ -1642,6 +1655,7 @@ def _shade_terrain( color_stretch, rgb_texture, overlay_data, overlay_alpha, overlay_min, overlay_range, + overlay_as_water, instance_ids, geometry_colors, primitive_ids, point_colors, point_color_offsets, ao_factor, gi_color, np.float32(gi_intensity), @@ -1773,6 +1787,7 @@ def render( rgb_texture=None, overlay_data=None, overlay_alpha: float = 0.5, + overlay_as_water: bool = False, geometry_colors=None, ao_samples: int = 0, ao_radius: Optional[float] = None, @@ -2150,6 +2165,7 @@ def render( rgb_texture=rgb_texture, overlay_data=d_overlay, overlay_alpha=overlay_alpha, overlay_min=ov_min, overlay_range=ov_range, + overlay_as_water=overlay_as_water, instance_ids=d_instance_ids, geometry_colors=geometry_colors, primitive_ids=d_primitive_ids, point_colors=d_point_colors, diff --git a/rtxpy/engine.py b/rtxpy/engine.py index 1baa27d..d96e232 100644 --- a/rtxpy/engine.py +++ b/rtxpy/engine.py @@ -776,6 +776,7 @@ def fn(v): if v._terrain_layer_idx >= len(v._terrain_layer_order): v._terrain_layer_idx = 0 v._active_overlay_data = None + v._overlay_as_water = False v._update_frame() print(f"Removed layer: {name}") self._submit(fn) @@ -786,6 +787,7 @@ def fn(v): if name == 'elevation': v._active_color_data = None v._active_overlay_data = None + v._overlay_as_water = False v._terrain_layer_idx = 0 v._update_frame() print("Terrain: elevation") @@ -798,6 +800,7 @@ def fn(v): v._terrain_layer_idx = idx v._active_color_data = None v._active_overlay_data = v._overlay_layers[name] + v._overlay_as_water = name.startswith('flood_') v._update_frame() print(f"Terrain: {name}") self._submit(fn) @@ -1064,6 +1067,7 @@ def _add_overlay(viewer, name, data): viewer._terrain_layer_idx = idx viewer._active_color_data = None viewer._active_overlay_data = data + viewer._overlay_as_water = name.startswith('flood_') viewer._update_frame() print(f"Terrain: {name}") @@ -1229,6 +1233,7 @@ def __init__(self, raster, width: int = 800, height: int = 600, self._active_color_data = None # None = use elevation_data self._active_overlay_data = None # Transparent overlay on top of base self._overlay_alpha = 0.7 # Overlay blending alpha (0=base only, 1=overlay only) + self._overlay_as_water = False # True when flood layer renders as water # Independent terrain color cycling (G key): elevation + overlay names self._terrain_layer_order = ['elevation'] + list(self._overlay_names) @@ -1362,7 +1367,7 @@ def __init__(self, raster, width: int = 800, height: int = 600, # State self.running = False - self.show_help = True + self._help_page_idx = 0 # -1 = off, 0..N-1 = page index self.show_minimap = True self.frame_count = 0 self._last_title = None @@ -1374,14 +1379,18 @@ def __init__(self, raster, width: int = 800, height: int = 600, self._minimap_scale_y = 1.0 self._minimap_has_tiles = False self._minimap_rect = None # (x0, y0, w, h) in frame coords + self._minimap_style = None # 'cyberpunk' for neon edge outline + self._minimap_layer = None # overlay layer name for minimap coloring + self._minimap_colors = None # {value: (r,g,b)} categorical color map self._drone_glow = False - # Help text cache (pre-rendered RGBA numpy array via PIL) - self._help_text_rgba = None + # Help text cache (list of pre-rendered RGBA page arrays via PIL) + self._help_pages = [] # Title / subtitle overlay (pre-rendered RGBA numpy array via PIL) self._subtitle = subtitle self._legend_config = legend + self._info_text = None # Optional info blurb (appended to help pages) self._title_overlay_rgba = None self._legend_rgba = None @@ -1773,17 +1782,35 @@ def _compute_minimap_background(self): # Build RGBA image rgba = np.zeros((new_h, new_w, 4), dtype=np.float32) - # Land: smoky warm tones — blend hillshade with elevation tint - # Low elevation → dark olive/brown, high → pale sand/cream - lo = np.array([0.18, 0.20, 0.14]) # dark olive - hi = np.array([0.85, 0.80, 0.70]) # warm cream - for c in range(3): - tint = lo[c] + (hi[c] - lo[c]) * elev_norm - # Mix 60 % hillshade + 40 % elevation tint for a smoky look - rgba[:, :, c] = shaded * 0.6 * tint + tint * 0.4 - rgba[:, :, 3] = 1.0 # fully opaque land + # Check for categorical overlay layer coloring + _layer_data = None + if (self._minimap_layer and self._minimap_colors + and self._minimap_layer in self._base_overlay_layers): + ld = self._base_overlay_layers[self._minimap_layer] + if hasattr(ld, 'get'): + ld = ld.get() + ld = np.asarray(ld, dtype=np.float64) + if longest > max_dim: + _layer_data = ld[np.ix_(y_idx, x_idx)] + else: + _layer_data = ld.copy() - # Water: dark blue-black, semi-transparent + # Grayscale hillshade base for all land + grey = shaded * 0.5 + elev_norm * 0.3 + 0.1 + grey = np.clip(grey, 0, 1) + for c in range(3): + rgba[:, :, c] = grey + rgba[:, :, 3] = 1.0 + + if _layer_data is not None: + # Overlay risk colours on matched pixels; unmatched stays grey + for val, (r, g, b) in self._minimap_colors.items(): + mask = np.isclose(_layer_data, float(val), atol=0.1) + for c, cv in enumerate((r, g, b)): + rgba[:, :, c] = np.where( + mask, cv * (shaded * 0.5 + 0.5), rgba[:, :, c]) + + # Water: dark blue-black rgba[water, 0] = 0.08 rgba[water, 1] = 0.10 rgba[water, 2] = 0.18 @@ -1808,10 +1835,97 @@ def _compute_minimap_background(self): rgba[:, :, c] = np.where(has_coverage, blended[:, :, c], rgba[:, :, c]) self._minimap_has_tiles = True + # Apply minimap style filter + if self._minimap_style == 'cyberpunk': + rgba = self._apply_cyberpunk_minimap(rgba, water) + self._minimap_background = rgba self._minimap_scale_x = new_w / W self._minimap_scale_y = new_h / H + def _apply_cyberpunk_minimap(self, rgba, water): + """Apply a neon-edge cyberpunk filter to the minimap RGBA image. + + Detects edges via Sobel, colours them with a cyan/magenta neon + palette, darkens the base, and adds faint scan-lines. + """ + h, w = rgba.shape[:2] + + # Convert to luminance for edge detection + lum = rgba[:, :, 0] * 0.299 + rgba[:, :, 1] * 0.587 + rgba[:, :, 2] * 0.114 + + # Sobel edge detection + # Horizontal kernel [-1 0 1; -2 0 2; -1 0 1] + sx = np.zeros_like(lum) + sy = np.zeros_like(lum) + if h > 2 and w > 2: + sx[1:-1, 1:-1] = ( + -lum[:-2, :-2] + lum[:-2, 2:] + - 2 * lum[1:-1, :-2] + 2 * lum[1:-1, 2:] + - lum[2:, :-2] + lum[2:, 2:] + ) + sy[1:-1, 1:-1] = ( + -lum[:-2, :-2] - 2 * lum[:-2, 1:-1] - lum[:-2, 2:] + + lum[2:, :-2] + 2 * lum[2:, 1:-1] + lum[2:, 2:] + ) + + edges = np.sqrt(sx ** 2 + sy ** 2) + edges = np.clip(edges / (edges.max() + 1e-8), 0, 1) + + # Boost contrast — power curve to sharpen edges + edges = edges ** 0.6 + + # Neon colour: cyan for terrain edges, magenta for strong edges + cyan = np.array([0.0, 1.0, 1.0]) + magenta = np.array([1.0, 0.0, 0.8]) + # Blend cyan→magenta based on edge intensity + neon = np.zeros((h, w, 3), dtype=np.float32) + for c in range(3): + neon[:, :, c] = cyan[c] + (magenta[c] - cyan[c]) * edges + + # Dark base: heavily darken the original image + dark = np.zeros_like(rgba) + for c in range(3): + dark[:, :, c] = rgba[:, :, c] * 0.12 + dark[:, :, 3] = rgba[:, :, 3] + + # Water gets a deep dark blue-purple + dark[water, 0] = 0.03 + dark[water, 1] = 0.02 + dark[water, 2] = 0.08 + dark[water, 3] = 0.85 + + # Composite neon edges onto dark base (additive blend) + edge_alpha = edges * 0.9 # edge glow strength + result = dark.copy() + for c in range(3): + result[:, :, c] = np.clip( + dark[:, :, c] + neon[:, :, c] * edge_alpha, 0, 1) + + # Add faint simulated glow: dilate edges slightly via max-filter + if h > 4 and w > 4: + glow = edges.copy() + # Simple 3x3 max pooling for glow spread + padded = np.pad(glow, 1, mode='edge') + glow = np.maximum.reduce([ + padded[:-2, :-2], padded[:-2, 1:-1], padded[:-2, 2:], + padded[1:-1, :-2], padded[1:-1, 1:-1], padded[1:-1, 2:], + padded[2:, :-2], padded[2:, 1:-1], padded[2:, 2:], + ]) + glow_extra = np.clip(glow - edges, 0, 1) * 0.3 + for c in range(3): + result[:, :, c] = np.clip( + result[:, :, c] + neon[:, :, c] * glow_extra, 0, 1) + + # Scan-lines: darken every other row slightly + scanline = np.ones(h, dtype=np.float32) + scanline[1::2] = 0.82 + for c in range(3): + result[:, :, c] *= scanline[:, np.newaxis] + + result[:, :, :3] = np.clip(result[:, :, :3], 0, 1) + return result + def _rebuild_at_resolution(self, factor): """Rebuild terrain mesh at a different subsample factor. @@ -1954,6 +2068,7 @@ def _rebuild_at_resolution(self, factor): terrain_name = self._terrain_layer_order[self._terrain_layer_idx] if terrain_name != 'elevation' and terrain_name in self._overlay_layers: self._active_overlay_data = self._overlay_layers[terrain_name] + self._overlay_as_water = terrain_name.startswith('flood_') # 6. Invalidate chunk manager cache (meshes need new Z coords) if self._chunk_manager is not None: @@ -2354,68 +2469,27 @@ def _blit_minimap_on_frame(self, img): mm_h, mm_w = mm_bg.shape[:2] fh, fw = img.shape[:2] - # Size the minimap to ~20% of frame width - target_w = max(40, int(fw * 0.2)) - scale = target_w / mm_w - target_h = max(20, int(mm_h * scale)) - target_w = min(target_w, fw - 8) - target_h = min(target_h, fh - 8) + # Size minimap: match legend height if available, else ~20% of frame + if self._legend_rgba is not None: + target_h = self._legend_rgba.shape[0] + else: + target_w = max(40, int(fw * 0.2)) + scale = target_w / mm_w + target_h = max(20, int(mm_h * scale)) + # Derive width from height to preserve aspect ratio + aspect = mm_w / mm_h + target_w = max(20, int(target_h * aspect)) + target_w = min(target_w, fw) + target_h = min(target_h, fh) # Nearest-neighbour resize y_idx = np.linspace(0, mm_h - 1, target_h).astype(int) x_idx = np.linspace(0, mm_w - 1, target_w).astype(int) bg_resized = mm_bg[np.ix_(y_idx, x_idx)].copy() # (th, tw, 4) - # --- Rounded corner mask --- - corner_radius = min(8, target_h // 4, target_w // 4) - if corner_radius > 1: - mask = np.ones((target_h, target_w), dtype=np.float32) - yy = np.arange(target_h)[:, None] - xx = np.arange(target_w)[None, :] - # Four corners: (cy, cx) of the inscribed circle center - corners = [ - (corner_radius, corner_radius), # top-left - (corner_radius, target_w - 1 - corner_radius), # top-right - (target_h - 1 - corner_radius, corner_radius), # bottom-left - (target_h - 1 - corner_radius, target_w - 1 - corner_radius), # bottom-right - ] - for cy, cx in corners: - # Select the corner quadrant - if cy <= corner_radius: - row_sel = yy < corner_radius - else: - row_sel = yy > target_h - 1 - corner_radius - if cx <= corner_radius: - col_sel = xx < corner_radius - else: - col_sel = xx > target_w - 1 - corner_radius - in_corner = row_sel & col_sel - dist_sq = (yy - cy) ** 2 + (xx - cx) ** 2 - outside_circle = dist_sq > corner_radius ** 2 - mask = np.where(in_corner & outside_circle, 0.0, mask) - bg_resized[:, :, 3] *= mask - - # Placement: bottom-right with 6px margin - margin = 6 - y0 = fh - target_h - margin - x0 = fw - target_w - margin - - # --- Drop shadow (dark rounded rect offset by 2px) --- - shadow_off = 2 - sy0 = y0 + shadow_off - sx0 = x0 + shadow_off - sy1 = min(sy0 + target_h, fh) - sx1 = min(sx0 + target_w, fw) - sh = sy1 - sy0 - sw = sx1 - sx0 - if sh > 0 and sw > 0: - shadow_alpha = 0.35 - if corner_radius > 1: - shadow_mask = mask[:sh, :sw] * shadow_alpha - else: - shadow_mask = np.full((sh, sw), shadow_alpha, dtype=np.float32) - shadow_region = img[sy0:sy1, sx0:sx1] - shadow_region[:] = shadow_region * (1 - shadow_mask[:, :, None]) + # Placement: flush bottom-right + y0 = fh - target_h + x0 = fw - target_w # Alpha-composite background onto frame alpha = bg_resized[:, :, 3:4] @@ -3409,10 +3483,12 @@ def _handle_key_press(self, raw_key, key): self._update_frame() return - # Eye Dome Lighting toggle: Shift+H + # Previous help page: Shift+H if raw_key == 'H': - self.edl_enabled = not self.edl_enabled - print(f"Eye Dome Lighting: {'ON' if self.edl_enabled else 'OFF'}") + if self._help_pages: + self._help_page_idx -= 1 + if self._help_page_idx < -1: + self._help_page_idx = len(self._help_pages) - 1 self._update_frame() return @@ -3476,7 +3552,12 @@ def _handle_key_press(self, raw_key, key): elif key == 'p': self._jump_to_geometry(-1) # Previous geometry in current group elif key == 'h': - self.show_help = not self.show_help + if self._help_pages: + self._help_page_idx += 1 + if self._help_page_idx >= len(self._help_pages): + self._help_page_idx = -1 # off + else: + self._help_page_idx = -1 self._update_frame() elif key == 'm': self.show_minimap = not self.show_minimap @@ -3980,12 +4061,17 @@ def _cycle_terrain_layer(self): if layer_name == 'elevation': self._active_color_data = None self._active_overlay_data = None + self._overlay_as_water = False print(f"Terrain: elevation") else: self._active_color_data = None self._active_overlay_data = self._overlay_layers[layer_name] - alpha_pct = int(self._overlay_alpha * 100) - print(f"Terrain: {layer_name} (alpha {alpha_pct}%, ,/. to adjust)") + self._overlay_as_water = layer_name.startswith('flood_') + if self._overlay_as_water: + print(f"Terrain: {layer_name} (water)") + else: + alpha_pct = int(self._overlay_alpha * 100) + print(f"Terrain: {layer_name} (alpha {alpha_pct}%, ,/. to adjust)") self._update_frame() @@ -4943,6 +5029,7 @@ def _save_screenshot(self): rgb_texture=rgb_texture, overlay_data=self._active_overlay_data, overlay_alpha=self._overlay_alpha, + overlay_as_water=self._overlay_as_water, geometry_colors=geometry_colors, ) @@ -5091,6 +5178,7 @@ def _render_frame(self): rgb_texture=rgb_texture, overlay_data=self._active_overlay_data, overlay_alpha=self._overlay_alpha, + overlay_as_water=self._overlay_as_water, geometry_colors=geometry_colors, ao_samples=ao_samples, ao_radius=self.ao_radius, @@ -5283,12 +5371,13 @@ def _composite_overlays(self): glfw.set_window_title(self._glfw_window, combined) # Build display frame (copy if we need overlays, else use pinned directly) + _help_visible = (self._help_page_idx >= 0 and self._help_pages) needs_overlay = ( (self._gtfs_rt_enabled and self._gtfs_rt_vehicles is not None) or self.show_minimap or self._title_overlay_rgba is not None or self._legend_rgba is not None - or self.show_help + or _help_visible ) if needs_overlay: img = self._pinned_frame.copy() @@ -5302,15 +5391,14 @@ def _composite_overlays(self): # Minimap overlay self._blit_minimap_on_frame(img) - # Title overlay (hidden when help is shown — they overlap top-left) - if not self.show_help: - self._blit_title_on_frame(img) + # Title overlay + self._blit_title_on_frame(img) # Legend overlay (always visible) self._blit_legend_on_frame(img) - # Help text overlay - if self.show_help and self._help_text_rgba is not None: + # Help page overlay + if _help_visible: self._blit_help_on_frame(img) self._display_frame = img @@ -5429,28 +5517,31 @@ def _handle_mouse_motion(self, xpos, ypos): # ------------------------------------------------------------------ def _render_title_overlay(self): - """Pre-render title + subtitle to an RGBA numpy array using PIL. + """Pre-render title + subtitle with drop-shadow to an RGBA array. - Called once at startup; cached in ``self._title_overlay_rgba``. - Skipped when no title is set. + Rendered at 2x resolution for antialiased text, then downsampled. + No background — text floats over the scene with a strong shadow. """ if not self._title or self._title == 'rtxpy': return try: - from PIL import Image, ImageDraw, ImageFont + from PIL import Image, ImageDraw, ImageFont, ImageFilter bold_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" sans_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf" + + # Render at 2x for antialiasing via supersample + ss = 2 try: - font_title = ImageFont.truetype(bold_path, 22) - font_sub = ImageFont.truetype(sans_path, 11) + font_title = ImageFont.truetype(bold_path, 22 * ss) + font_sub = ImageFont.truetype(sans_path, 11 * ss) except (OSError, IOError): font_title = ImageFont.load_default() font_sub = font_title + ss = 1 # fall back to 1x with default font - pad_x, pad_y = 14, 10 - corner_r = 10 - bg_color = (15, 18, 24, 200) + pad_x, pad_y = 14 * ss, 10 * ss + shadow_spread = 4 * ss # extra margin for shadow blur # Measure title title_bbox = font_title.getbbox(self._title) @@ -5463,32 +5554,57 @@ def _render_title_overlay(self): if self._subtitle: sub_bbox = font_sub.getbbox(self._subtitle) sub_w = sub_bbox[2] - sub_bbox[0] - sub_h = sub_bbox[3] - sub_bbox[1] + 4 # 4px gap - - img_w = pad_x * 2 + max(title_w, sub_w) - img_h = pad_y * 2 + title_h + sub_h - - img = Image.new('RGBA', (img_w, img_h), (0, 0, 0, 0)) - draw = ImageDraw.Draw(img) - draw.rounded_rectangle( - [0, 0, img_w - 1, img_h - 1], - radius=corner_r, fill=bg_color, - ) - draw.rounded_rectangle( - [0, 0, img_w - 1, img_h - 1], - radius=corner_r, outline=(60, 70, 90, 140), width=1, - ) + sub_h = sub_bbox[3] - sub_bbox[1] + 4 * ss + + img_w = pad_x * 2 + max(title_w, sub_w) + shadow_spread * 2 + img_h = pad_y * 2 + title_h + sub_h + shadow_spread * 2 + + # --- Shadow layer: black text, then blur --- + shadow_img = Image.new('RGBA', (img_w, img_h), (0, 0, 0, 0)) + shadow_draw = ImageDraw.Draw(shadow_img) + sx = pad_x + shadow_spread + sy = pad_y + shadow_spread + + # Draw shadow text with slight offset + shadow_off = 2 * ss + shadow_draw.text((sx + shadow_off, sy + shadow_off), + self._title, fill=(0, 0, 0, 255), + font=font_title) + if self._subtitle: + shadow_draw.text( + (sx + shadow_off, sy + title_h + 4 * ss + shadow_off), + self._subtitle, fill=(0, 0, 0, 255), font=font_sub) + + # Multi-pass blur for a deep, soft shadow + for _ in range(3): + shadow_img = shadow_img.filter( + ImageFilter.GaussianBlur(radius=3 * ss)) + + # Boost shadow opacity + shadow_arr = np.array(shadow_img, dtype=np.float32) + shadow_arr[:, :, 3] = np.clip(shadow_arr[:, :, 3] * 2.5, 0, 255) + shadow_img = Image.fromarray(shadow_arr.astype(np.uint8)) + + # --- Foreground layer: crisp white text --- + fg_img = Image.new('RGBA', (img_w, img_h), (0, 0, 0, 0)) + fg_draw = ImageDraw.Draw(fg_img) + fg_draw.text((sx, sy), self._title, + fill=(255, 255, 255, 255), font=font_title) + if self._subtitle: + fg_draw.text((sx, sy + title_h + 4 * ss), self._subtitle, + fill=(200, 210, 225, 230), font=font_sub) - # Title - draw.text((pad_x, pad_y), self._title, - fill=(255, 255, 255, 255), font=font_title) + # Composite: shadow behind, text on top + result = Image.alpha_composite(shadow_img, fg_img) - # Subtitle - if self._subtitle: - draw.text((pad_x, pad_y + title_h + 4), self._subtitle, - fill=(170, 180, 195, 220), font=font_sub) + # Downsample from 2x to 1x with antialiasing + if ss > 1: + final_w = img_w // ss + final_h = img_h // ss + result = result.resize((final_w, final_h), Image.LANCZOS) - self._title_overlay_rgba = np.array(img, dtype=np.float32) / 255.0 + self._title_overlay_rgba = ( + np.array(result, dtype=np.float32) / 255.0) except ImportError: pass @@ -5517,7 +5633,6 @@ def _render_legend_overlay(self): font_label = font_header pad_x, pad_y = 14, 10 - corner_r = 10 swatch_size = 12 swatch_gap = 8 # gap between swatch and label line_h = 18 @@ -5546,13 +5661,13 @@ def _render_legend_overlay(self): img = Image.new('RGBA', (img_w, img_h), (0, 0, 0, 0)) draw = ImageDraw.Draw(img) - draw.rounded_rectangle( + draw.rectangle( [0, 0, img_w - 1, img_h - 1], - radius=corner_r, fill=bg_color, + fill=bg_color, ) - draw.rounded_rectangle( + draw.rectangle( [0, 0, img_w - 1, img_h - 1], - radius=corner_r, outline=(60, 70, 90, 140), width=1, + outline=(60, 70, 90, 140), width=1, ) y = pad_y @@ -5584,6 +5699,8 @@ def _render_legend_overlay(self): def _blit_title_on_frame(self, img): """Alpha-composite cached title overlay onto the rendered frame. + Placed flush in the top-left corner (no margin). + Parameters ---------- img : ndarray, shape (H, W, 3), float32 0-1 @@ -5594,19 +5711,20 @@ def _blit_title_on_frame(self, img): ov = self._title_overlay_rgba oh, ow = ov.shape[:2] fh, fw = img.shape[:2] - margin = 12 - bh = min(oh, fh - margin) - bw = min(ow, fw - margin) + bh = min(oh, fh) + bw = min(ow, fw) if bh <= 0 or bw <= 0: return alpha = ov[:bh, :bw, 3:4] rgb = ov[:bh, :bw, :3] - region = img[margin:margin+bh, margin:margin+bw] + region = img[:bh, :bw] region[:] = region * (1 - alpha) + rgb * alpha def _blit_legend_on_frame(self, img): """Alpha-composite cached legend overlay onto the rendered frame. + Placed flush in the bottom-left corner (no margin). + Parameters ---------- img : ndarray, shape (H, W, 3), float32 0-1 @@ -5617,27 +5735,25 @@ def _blit_legend_on_frame(self, img): ov = self._legend_rgba oh, ow = ov.shape[:2] fh, fw = img.shape[:2] - margin = 12 - y0 = fh - margin - oh + y0 = fh - oh if y0 < 0: y0 = 0 bh = min(oh, fh - y0) - bw = min(ow, fw - margin) + bw = min(ow, fw) if bh <= 0 or bw <= 0: return alpha = ov[:bh, :bw, 3:4] rgb = ov[:bh, :bw, :3] - region = img[y0:y0+bh, margin:margin+bw] + region = img[y0:y0+bh, :bw] region[:] = region * (1 - alpha) + rgb * alpha def _render_help_text(self): - """Pre-render help text to an RGBA numpy array using PIL. + """Pre-render paginated help overlays cached in ``_help_pages``. - Called once at startup; the result is cached in self._help_text_rgba. - Two-column layout with styled section headers and key highlighting. + Each page matches the legend/minimap height and is positioned + at the bottom of the frame. Press H to cycle pages, then off. """ - # Two columns of (section_title, [(key, description), ...]) - col_left = [ + all_sections = [ ("MOVEMENT", [ ("W/S/A/D", "Move fwd / back / left / right"), ("Arrows", "Move fwd / back / left / right"), @@ -5664,11 +5780,9 @@ def _render_help_text(self): ("Shift+F", "FIRMS fire (7d)"), ("Shift+W", "Toggle wind"), ]), - ] - col_right = [ ("RENDERING", [ ("0", "Toggle ambient occlusion"), - ("Shift+H", "Toggle EDL lighting"), + ("Shift+H", "Prev help page"), ("Shift+G", "Cycle GI bounces (1-3)"), ("Shift+D", "Toggle AI denoiser"), ("9", "Toggle depth of field"), @@ -5693,18 +5807,27 @@ def _render_help_text(self): ("OTHER", [ ("F", "Screenshot"), ("M", "Toggle minimap"), - ("H", "Toggle this help"), + ("H", "Cycle help pages"), ("X / Esc", "Exit"), ]), ] + # Append info text as a section if provided + if self._info_text: + info_items = [] + for line in self._info_text.strip().splitlines(): + line = line.strip() + if line: + info_items.append(("", line)) + if info_items: + all_sections.append(("MODEL INFO", info_items)) + try: from PIL import Image, ImageDraw, ImageFont font_size = 12 header_size = 13 mono_path = "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf" - bold_path = "/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf" sans_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf" sans_bold_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" try: @@ -5717,125 +5840,133 @@ def _render_help_text(self): font_header = font line_h = font_size + 5 - header_h = header_size + 8 + hdr_h = header_size + 8 section_gap = 6 - key_col_w = 105 # width reserved for keys - desc_col_w = 195 # width for descriptions + key_col_w = 105 + desc_col_w = 195 col_w = key_col_w + desc_col_w - col_gap = 20 pad_x = 14 - pad_y = 12 - corner_r = 10 + pad_y = 10 # Colors - bg_color = (15, 18, 24, 210) # dark blue-black, 82% opaque - header_color = (180, 210, 255, 255) # light blue - key_color = (255, 200, 100, 245) # warm amber - desc_color = (210, 215, 225, 220) # soft white - separator_color = (80, 90, 110, 120) # subtle line - accent_color = (90, 140, 220, 180) # blue accent for header underline - - def _column_height(sections): - h = 0 - for i, (title, items) in enumerate(sections): - if i > 0: - h += section_gap - h += header_h + 3 # header + underline space - h += len(items) * line_h - return h - - left_h = _column_height(col_left) - right_h = _column_height(col_right) - content_h = max(left_h, right_h) - footer_h = header_h + section_gap # space for "Press H to close" - img_w = pad_x * 2 + col_w * 2 + col_gap - img_h = pad_y * 2 + content_h + footer_h - - # Create with transparent background, then draw rounded rect - img = Image.new('RGBA', (img_w, img_h), (0, 0, 0, 0)) - draw = ImageDraw.Draw(img) - - # Rounded rectangle background - draw.rounded_rectangle( - [0, 0, img_w - 1, img_h - 1], - radius=corner_r, fill=bg_color, - ) - - # Subtle border - draw.rounded_rectangle( - [0, 0, img_w - 1, img_h - 1], - radius=corner_r, outline=(60, 70, 90, 140), width=1, - ) + bg_color = (15, 18, 24, 210) + header_color = (180, 210, 255, 255) + key_color = (255, 200, 100, 245) + desc_color = (210, 215, 225, 220) + accent_color = (90, 140, 220, 180) + page_color = (120, 140, 170, 180) + + # Target height: match legend if available + if self._legend_rgba is not None: + target_h = self._legend_rgba.shape[0] + else: + target_h = 200 + usable_h = target_h - pad_y * 2 + + def _section_h(section): + _, items = section + return hdr_h + 3 + len(items) * line_h + + # Pack sections into pages greedily + pages = [] + current_page = [] + current_h = 0 + for sec in all_sections: + sh = _section_h(sec) + needed = sh + (section_gap if current_page else 0) + if current_page and current_h + needed > usable_h: + pages.append(current_page) + current_page = [sec] + current_h = sh + else: + current_h += needed + current_page.append(sec) + if current_page: + pages.append(current_page) + + n_pages = len(pages) + img_w = pad_x * 2 + col_w + + self._help_pages = [] + for pi, page_sections in enumerate(pages): + img = Image.new('RGBA', (img_w, target_h), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + draw.rectangle( + [0, 0, img_w - 1, target_h - 1], fill=bg_color) + draw.rectangle( + [0, 0, img_w - 1, target_h - 1], + outline=(60, 70, 90, 140), width=1) - def _draw_column(sections, x_start, y_start): - y = y_start - for si, (title, items) in enumerate(sections): + y = pad_y + for si, (title, items) in enumerate(page_sections): if si > 0: y += section_gap - # Section header - draw.text((x_start, y), title, fill=header_color, + draw.text((pad_x, y), title, fill=header_color, font=font_header) - # Accent underline underline_y = y + header_size + 2 draw.line( - [(x_start, underline_y), - (x_start + col_w - 10, underline_y)], + [(pad_x, underline_y), + (pad_x + col_w - 10, underline_y)], fill=accent_color, width=1) y = underline_y + 3 - - # Key-description rows for key_text, desc_text in items: - draw.text((x_start, y), key_text, - fill=key_color, font=font_key) - draw.text((x_start + key_col_w, y), desc_text, - fill=desc_color, font=font) + if key_text: + draw.text((pad_x, y), key_text, + fill=key_color, font=font_key) + draw.text((pad_x + key_col_w, y), desc_text, + fill=desc_color, font=font) + else: + draw.text((pad_x, y), desc_text, + fill=desc_color, font=font) y += line_h - _draw_column(col_left, pad_x, pad_y) - _draw_column(col_right, pad_x + col_w + col_gap, pad_y) - - # Vertical separator between columns - sep_x = pad_x + col_w + col_gap // 2 - draw.line( - [(sep_x, pad_y + 4), (sep_x, pad_y + content_h - 4)], - fill=separator_color, width=1) - - # Bold "Press H to close" footer, centered - footer_text = "Press H to close" - bbox = font_header.getbbox(footer_text) - fw = bbox[2] - bbox[0] - footer_x = (img_w - fw) // 2 - footer_y = pad_y + content_h + section_gap - draw.text((footer_x, footer_y), footer_text, - fill=header_color, font=font_header) - - self._help_text_rgba = np.array(img, dtype=np.float32) / 255.0 + # Page indicator (top-right) + page_text = f"{pi + 1}/{n_pages}" + bbox = font_header.getbbox(page_text) + ptw = bbox[2] - bbox[0] + draw.text((img_w - pad_x - ptw, pad_y), page_text, + fill=page_color, font=font_header) + + self._help_pages.append( + np.array(img, dtype=np.float32) / 255.0) except ImportError: - self._help_text_rgba = None + self._help_pages = [] def _blit_help_on_frame(self, img): - """Alpha-composite cached help text onto the rendered frame. + """Alpha-composite the current help page onto the rendered frame. + + Positioned flush at the bottom, right after the legend. Parameters ---------- img : ndarray, shape (H, W, 3), float32 0-1 Rendered frame. Modified in-place. """ - if self._help_text_rgba is None: + idx = self._help_page_idx + if idx < 0 or idx >= len(self._help_pages): return - ht = self._help_text_rgba + ht = self._help_pages[idx] hh, hw = ht.shape[:2] fh, fw = img.shape[:2] - # Top-left with small margin - margin = 8 - # Clamp to frame size - bh = min(hh, fh - margin) - bw = min(hw, fw - margin) + + # X: right after legend, or flush left + if self._legend_rgba is not None: + x0 = self._legend_rgba.shape[1] + else: + x0 = 0 + + # Flush bottom + y0 = fh - hh + if y0 < 0: + y0 = 0 + + bh = min(hh, fh - y0) + bw = min(hw, fw - x0) if bh <= 0 or bw <= 0: return alpha = ht[:bh, :bw, 3:4] rgb = ht[:bh, :bw, :3] - region = img[margin:margin+bh, margin:margin+bw] + region = img[y0:y0+bh, x0:x0+bw] region[:] = region * (1 - alpha) + rgb * alpha def run(self, start_position: Optional[Tuple[float, float, float]] = None, @@ -5949,9 +6080,9 @@ def run(self, start_position: Optional[Tuple[float, float, float]] = None, frame_tex.filter = (moderngl.LINEAR, moderngl.LINEAR) # --- Pre-render help text overlay --- - self._render_help_text() self._render_title_overlay() self._render_legend_overlay() + self._render_help_text() # --- Initialize minimap --- self._compute_minimap_background() @@ -6162,6 +6293,10 @@ def explore(raster, width: int = 800, height: int = 600, ao_samples: int = 0, gi_bounces: int = 1, denoise: bool = False, + minimap_style: str = None, + minimap_layer: str = None, + minimap_colors: dict = None, + info_text: str = None, repl: bool = False, tour=None): """ @@ -6303,6 +6438,10 @@ def explore(raster, width: int = 800, height: int = 600, ) viewer._geometry_colors_builder = geometry_colors_builder viewer._baked_meshes = baked_meshes or {} + viewer._minimap_style = minimap_style + viewer._minimap_layer = minimap_layer + viewer._minimap_colors = minimap_colors + viewer._info_text = info_text viewer._accessor = accessor viewer._terrain_loader = terrain_loader if scene_zarr is not None: