Source code for Karana.KUtils.kdpy_assets

#!/usr/bin/env python3
# Copyright (c) 2024-2026 Karana Dynamics Pty Ltd. All rights reserved.
#
# NOTICE TO USER:
#
# This source code and/or documentation (the "Licensed Materials") is
# the confidential and proprietary information of Karana Dynamics Inc.
# Use of these Licensed Materials is governed by the terms and conditions
# of a separate software license agreement between Karana Dynamics and the
# Licensee ("License Agreement"). Unless expressly permitted under that
# agreement, any reproduction, modification, distribution, or disclosure
# of the Licensed Materials, in whole or in part, to any third party
# without the prior written consent of Karana Dynamics is strictly prohibited.
#
# THE LICENSED MATERIALS ARE PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND.
# KARANA DYNAMICS DISCLAIMS ALL WARRANTIES, EXPRESS OR IMPLIED, INCLUDING
# BUT NOT LIMITED TO WARRANTIES OF MERCHANTABILITY, NON-INFRINGEMENT, AND
# FITNESS FOR A PARTICULAR PURPOSE.
#
# IN NO EVENT SHALL KARANA DYNAMICS BE LIABLE FOR ANY DAMAGES WHATSOEVER,
# INCLUDING BUT NOT LIMITED TO LOSS OF PROFITS, DATA, OR USE, EVEN IF
# ADVISED OF THE POSSIBILITY OF SUCH DAMAGES, WHETHER IN CONTRACT, TORT,
# OR OTHERWISE ARISING OUT OF OR IN CONNECTION WITH THE LICENSED MATERIALS.
#
# U.S. Government End Users: The Licensed Materials are a "commercial item"
# as defined at 48 C.F.R. 2.101, and are provided to the U.S. Government
# only as a commercial end item under the terms of this license.
#
# Any use of the Licensed Materials in individual or commercial software must
# include, in the user documentation and internal source code comments,
# this Notice, Disclaimer, and U.S. Government Use Provision.

#!/usr/bin/env python3
"""Configure kdpy graphics, meshes, materials, textures, and terrain.

This module converts declarative Kclick option dictionaries into live Scene
objects. Parsing and validation helpers remain separate from construction so
tests can exercise policy without creating a GUI or native graphics data.
"""

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import Any, TYPE_CHECKING, cast
import weakref

import click
import Karana.Core as kc
import Karana.Math as km
import Karana.Scene as ks
import numpy as np
from Karana.KUtils.Sim import Sim

try:
    from Karana.Scene._Terrain_pybind11_Py import (
        TerrainGrid,
        GeoTiffTerrainGrid,
        GeographicTerrainGrid,
    )
except ImportError:
    pass

if TYPE_CHECKING:
    from Karana.Scene._Terrain_pybind11_Py import (
        TerrainGrid,
        GeoTiffTerrainGrid,
        GeographicTerrainGrid,
    )


[docs] @dataclass(frozen=True) class MeshDefinition: """Describe one mesh before type-specific geometry validation. The parameter string remains unparsed until every named mesh option has been indexed, allowing errors to identify the original ``--geometry`` entry. """ name: str geometry_type: str parameters: str
[docs] def parsePointingTarget(value: str) -> str | np.ndarray: """Parse a frame name or a comma/space-delimited three-value target.""" # Treat one undelimited token as a Frame name; vectors contain three values. components = value.replace(",", " ").split() if len(components) == 1: return value if len(components) != 3: raise click.BadParameter( "expected a frame name or three comma/space-delimited floats", param_hint="--pointing", ) try: target = np.asarray([float(component) for component in components]) except ValueError as error: raise click.BadParameter( "target coordinates must be floating-point values", param_hint="--pointing", ) from error return target
[docs] def parseSkyColor(value: str) -> ks.Color: """Convert a CSS color name or three/six-digit hexadecimal string.""" # Accept conventional '#rrggbb' input while passing bare hex to kdFlex. hexadecimal = value.removeprefix("#") if len(hexadecimal) in (3, 6) and all( character in "0123456789abcdefABCDEF" for character in hexadecimal ): return ks.Color.fromHex(hexadecimal) # kdFlex exposes CSS named colors as uppercase Color class attributes. attribute_name = value.replace("-", "").replace("_", "").upper() color = getattr(ks.Color, attribute_name, None) if isinstance(color, ks.Color): return color raise click.BadParameter( f"unknown CSS or hexadecimal color '{value}'", param_hint="--sky-color", )
[docs] def configureGraphics(sim: Sim, options: dict[str, Any]) -> None: """Apply scene-wide graphics settings after the GUI has been created. The function returns when the optional ``graphics`` command was not invoked or GUI creation was disabled. If settings are applied, ``sim.gui.graphics`` must already exist; the launcher enforces that order. """ graphics_options = options.get("graphics") if not isinstance(graphics_options, dict): # Direct API callers may omit the optional graphics command entirely. return if (options.get("gui") or {}).get("off", False): # GUI options are advisory when no GUI exists; silently ignore all of # them without validating values or touching graphical scene objects. return shadows = graphics_options["shadows"] pointing = graphics_options["pointing"] sky_color = graphics_options["sky_color"] stick_scale = graphics_options.get("stick_scale") mesh_trails = getattr(sim, "mesh_trails", ()) if not isinstance(mesh_trails, (list, tuple)): # Mock-like or unrelated dynamic attributes are not pending trail data. mesh_trails = () requested = ( isinstance(shadows, tuple) or isinstance(pointing, str) or isinstance(sky_color, str) or isinstance(stick_scale, float) or bool(mesh_trails) ) if not requested: # An omitted graphics command supplies Kclick's internal sentinels. return # Apply the request to every graphical client; one ProxyScene can serve an # interactive GUI and an offline renderer at the same time. proxy_scene = sim.mb.getScene() for cs in proxy_scene.clientScenes(): # ClientScene has no common graphical capability query, so a default # camera identifies clients that support these visual operations. try: cs.defaultCamera() is_graphics_scene = True except Exception: is_graphics_scene = False if not is_graphics_scene: continue graphical_scene = cast(ks.GraphicalScene, cs) if isinstance(shadows, tuple): enabled, radius, pixels = shadows if radius <= 0.0: raise click.BadParameter( "shadow-map radius must be positive", param_hint="--shadows", ) if pixels <= 0: raise click.BadParameter( "shadow-map pixels must be positive", param_hint="--shadows", ) # Shadows use the global Z-up scene convention; camera up may instead # be relative to the selected pointing frame. direction = np.array([0.0, 0.0, -1.0]) if enabled else np.zeros(3) # BlenderScene and other offline clients may not implement shadows. try: graphical_scene._setShadows(direction, radius, pixels, intensity=0.5) except Exception: pass if isinstance(pointing, str): offset = np.asarray(graphics_options["offset"], dtype=float) at_offset = np.asarray(graphics_options["at_offset"], dtype=float) up = np.asarray(graphics_options["up"], dtype=float) target = parsePointingTarget(pointing) if isinstance(target, str): # Frame names must resolve uniquely before configuring ProxyScene. frames = sim.fc.lookupFrame(target) if len(frames) != 1: raise click.ClickException( f"frame name '{target}' resolved to {len(frames)} frames; expected exactly one" ) if proxy_scene is None or not hasattr(proxy_scene, "viewAroundFrame"): raise click.ClickException("frame pointing requires a ProxyScene-backed model") proxy_scene.viewAroundFrame(frames[0], offset, at_offset, up) else: # Fixed targets use the GUI's retained default camera directly. graphical_scene.defaultCamera().pointCameraAt(offset, target, up) if isinstance(sky_color, str): if sky_color == "starmap": bg_texture = ks.Texture.lookupOrCreateTexture( kc.findResource("textures/starmap/starmap_2020_4k.jpg") ) graphical_scene._setBackgroundTexture(bg_texture) else: graphical_scene._setBackgroundColor(parseSkyColor(sky_color)) if isinstance(stick_scale, float): # Scale all existing multibody stick parts by the requested factor. sim.mb.scaleStickParts(stick_scale) for mesh_node, trail_color in mesh_trails: graphical_node = mesh_node.graphics() if graphical_node is None: raise click.ClickException( f"mesh '{mesh_node.name()}' has no graphical implementation for a trail" ) # Trails are relative to the scene when no reference node is supplied. graphical_node.trail(None, trail_color)
[docs] def parseGeometryParameters( definition: MeshDefinition, ) -> tuple[float, ...] | Path: """Return validated primitive dimensions or an unresolved file path. Primitive dimensions must be positive and match the arity advertised by ``--geometry``. File paths remain opaque until ResourceFinder lookup during scene-object construction. """ if definition.geometry_type == "file": # File geometry retains its path as one opaque parameter string. return Path(definition.parameters) parameter_counts = { "box": 3, "sphere": 1, "cylinder": 2, "capsule": 2, "cone": 2, "roundfrustum": 3, } expected_count = parameter_counts[definition.geometry_type] components = definition.parameters.replace(",", " ").split() if len(components) != expected_count: raise click.BadParameter( f"{definition.geometry_type} mesh '{definition.name}' requires " f"{expected_count} numeric parameter(s)", param_hint="--geometry", ) try: values = tuple(float(component) for component in components) except ValueError as error: raise click.BadParameter( f"mesh '{definition.name}' geometry parameters must be numeric", param_hint="--geometry", ) from error if any(value <= 0.0 for value in values): raise click.BadParameter( f"mesh '{definition.name}' geometry parameters must be positive", param_hint="--geometry", ) return values
[docs] def createPrimitiveGeometry(geometry_type: str, parameters: tuple[float, ...]): """Create one supported non-file kdFlex primitive geometry.""" # Geometry factory methods follow the parameter ordering advertised by help. factories = { "box": ks.BoxGeometry.create, "sphere": ks.SphereGeometry.create, "cylinder": ks.CylinderGeometry.create, "capsule": ks.CapsuleGeometry.create, "cone": ks.ConeGeometry.create, "roundfrustum": ks.RoundFrustumGeometry.create, } return factories[geometry_type](*parameters)
[docs] def geometryTextureExtents( geometry_type: str, parameters: tuple[float, ...], ) -> tuple[float, float]: """Return approximate physical UV extents for synthetic primitive textures.""" if geometry_type == "box": return parameters[0], parameters[1] if geometry_type == "sphere": diameter = 2.0 * parameters[0] return diameter, diameter if geometry_type == "capsule": radius, height = parameters return 2.0 * np.pi * radius, height + 2.0 * radius if geometry_type in ("cylinder", "cone"): radius, height = parameters return 2.0 * np.pi * radius, height bottom_radius, top_radius, height = parameters return 2.0 * np.pi * max(bottom_radius, top_radius), height
[docs] def colorToPixels(color: ks.Color) -> np.ndarray: """Convert a kdFlex color to one uint8 RGB pixel.""" # Clamp after rounding so named and hexadecimal colors remain valid pixels. channels = np.rint(np.asarray([color.r(), color.g(), color.b()], dtype=float) * 255.0) return np.clip(channels, 0, 255).astype(np.uint8)
[docs] def contrastingColor(color: ks.Color) -> np.ndarray: """Choose a black or white grid-line color against a background.""" pixel = colorToPixels(color) # Relative luminance provides legible grid lines for light and dark colors. luminance = 0.2126 * pixel[0] + 0.7152 * pixel[1] + 0.0722 * pixel[2] return np.array([0, 0, 0] if luminance > 127.5 else [255, 255, 255], dtype=np.uint8)
[docs] def createGridTexture( name: str, background: ks.Color, extents: tuple[float, float], spacing: tuple[float, float], ) -> ks.Texture: """Create a square RGB texture containing a physically spaced grid.""" # Use a fixed raster size so generation remains deterministic; physical # extents and requested spacing control how many cells appear. texture_size = 512 extent_x, extent_y = extents spacing_x, spacing_y = spacing x_positions = np.arange(texture_size) * extent_x / texture_size y_positions = np.arange(texture_size) * extent_y / texture_size x_distance = np.minimum( np.mod(x_positions, spacing_x), spacing_x - np.mod(x_positions, spacing_x), ) y_distance = np.minimum( np.mod(y_positions, spacing_y), spacing_y - np.mod(y_positions, spacing_y), ) # Mark pixels within approximately one physical pixel width of either line # family. Broadcasting constructs the full mask without Python loops. line_mask = (y_distance[:, None] <= extent_y / texture_size) | ( x_distance[None, :] <= extent_x / texture_size ) pixels = np.empty((texture_size, texture_size, 3), dtype=np.uint8) pixels[:] = colorToPixels(background) pixels[line_mask] = contrastingColor(background) return ks.Texture.create(f"kdpy_{name}_grid", pixels)
[docs] def createChequeredTexture( name: str, first_color: ks.Color, second_color: ks.Color, extents: tuple[float, float], spacing: tuple[float, float], ) -> ks.Texture: """Create a square RGB texture containing physically spaced checks.""" # Compute cell indices in physical coordinates so primitive dimensions do # not silently change the requested check spacing. texture_size = 512 extent_x, extent_y = extents spacing_x, spacing_y = spacing x_cells = np.floor(np.arange(texture_size) * extent_x / texture_size / spacing_x) y_cells = np.floor(np.arange(texture_size) * extent_y / texture_size / spacing_y) # Alternating parity selects the two colors across the Cartesian cell grid. first_mask = (y_cells[:, None] + x_cells[None, :]) % 2 == 0 pixels = np.empty((texture_size, texture_size, 3), dtype=np.uint8) pixels[first_mask] = colorToPixels(first_color) pixels[~first_mask] = colorToPixels(second_color) return ks.Texture.create(f"kdpy_{name}_chequered", pixels)
[docs] def collectNamedOptions( entries: Any, option_name: str, ) -> dict[str, tuple[Any, ...]]: """Index one repeatable named option and reject duplicate mesh entries.""" if not isinstance(entries, tuple): return {} # Convert parallel Click records into a name-addressable form before native # construction. Reject duplicates instead of choosing arbitrary precedence. collected: dict[str, tuple[Any, ...]] = {} for name, *values in entries: if name in collected: raise click.UsageError(f"{option_name} was specified more than once for mesh '{name}'") collected[name] = tuple(values) return collected
[docs] def resolveResourceFile(path: Path) -> Path: """Resolve a relative mesh or texture file through ResourceFinder.""" if path.is_absolute() or path.exists(): return path.resolve() # Match model-file resource lookup for paths outside the current directory. return kc.ResourceFinder.singleton().resolve(path)
[docs] def createMeshMaterial( definition: MeshDefinition, geometry_parameters: tuple[float, ...] | Path, material_kind: str, material_values: tuple[Any, ...], spacing: tuple[float, float], opacity: float, ) -> ks.PhysicalMaterial: """Create one physical material from the mesh's exclusive color policy. Synthetic textures require primitive dimensions so their check or grid spacing approximates physical units. File textures resolve through the same resource search path used for model-relative assets. """ # Complete the value object before allocating its native material so a # validation failure cannot leave a partially configured scene resource. material_info = ks.PhysicalMaterialInfo() # Physical materials support opacity from zero (transparent) to one (opaque). material_info.opacity = opacity if material_kind == "color": material_info.color = parseSkyColor(str(material_values[0])) elif material_kind == "file_texture": texture_path = resolveResourceFile(Path(material_values[0])) material_info.color = ks.Color.WHITE material_info.color_map = ks.Texture.lookupOrCreateTexture(texture_path) else: # Synthetic patterns derive UV scale from primitive dimensions; imported # files already own their texture-coordinate convention. if isinstance(geometry_parameters, Path): raise click.UsageError( "synthetic grid and chequered textures require primitive geometry" ) extents = geometryTextureExtents( definition.geometry_type, geometry_parameters, ) material_info.color = ks.Color.WHITE if material_kind == "grid_texture": background = parseSkyColor(str(material_values[0])) material_info.color_map = createGridTexture( definition.name, background, extents, spacing, ) else: first_color = parseSkyColor(str(material_values[0])) second_color = parseSkyColor(str(material_values[1])) material_info.color_map = createChequeredTexture( definition.name, first_color, second_color, extents, spacing, ) return ks.PhysicalMaterial.create(material_info)
[docs] def createWireframeMaterial(material: ks.PhysicalMaterial) -> ks.WireframeMaterial: """Create a wireframe material retaining the selected material's base color.""" # WireframeMaterialInfo has no opacity field in the installed kdFlex API. wireframe_info = ks.WireframeMaterialInfo() wireframe_info.color = material.info.color return ks.WireframeMaterial.create(wireframe_info)
[docs] def mergeMeshLayers(layer_names: Any) -> int: """Combine repeated scene-layer names into one bitmask.""" if not isinstance(layer_names, tuple): return int(ks.LAYER_PHYSICAL_GRAPHICS) layers = 0 for layer_name in layer_names: # Click canonicalizes each value against the installed constant names. layers |= int(getattr(ks, layer_name.upper())) return layers
[docs] def applyMaterialRecursively(node: Any, material: Any) -> int: """Apply a material to every ScenePart below an imported file object.""" applied_count = 0 for child in node.childrenNodes(): if child.isScenePart(): child.setMaterial(material) applied_count += 1 applied_count += applyMaterialRecursively(child, material) return applied_count
[docs] def applyIntrinsicScaleRecursively(node: Any, scale: np.ndarray) -> int: """Apply non-uniform intrinsic scale to every imported ScenePart.""" applied_count = 0 for child in node.childrenNodes(): if child.isScenePart(): child.setIntrinsicScale(scale) applied_count += 1 applied_count += applyIntrinsicScaleRecursively(child, scale) return applied_count
[docs] def configureMeshes(sim: Sim, options: dict[str, Any]) -> dict[str, Any]: """Create and configure all named meshes requested by the mesh command. Mesh arguments arrive as parallel, repeatable, name-addressed option sets. Definitions establish valid names before appearance and transform options are joined to them, preventing partial native construction on input errors. Trail requests remain on ``sim`` until GUI setup creates graphical nodes. """ mesh_options = options.get("mesh") or {} geometry_entries = mesh_options.get("geometry", ()) if not isinstance(geometry_entries, tuple): # An omitted mesh command supplies Kclick's internal sentinel. return {} # Phase 1: establish the authoritative set of mesh names and geometries. definitions: dict[str, MeshDefinition] = {} for name, geometry_type, parameters in geometry_entries: if name in definitions: raise click.UsageError(f"mesh '{name}' has multiple --geometry entries") definitions[name] = MeshDefinition(name, geometry_type, parameters) # Phase 2: join every parallel option family by mesh name before touching # ProxyScene. Unknown-name and duplicate-property errors remain transactional. named_options = { option_name: collectNamedOptions( mesh_options.get(option_name, ()), f"--{option_name.replace('_', '-')}" ) for option_name in ( "position", "orientation", "scale", "trail", "color", "file_texture", "grid_texture", "chequered_texture", "texture_spacing", ) } # Every property name must identify one and only one declared geometry. for option_name, entries in named_options.items(): unknown_names = entries.keys() - definitions.keys() if unknown_names: unknown = sorted(unknown_names)[0] raise click.UsageError( f"--{option_name.replace('_', '-')} refers to unknown mesh '{unknown}'" ) # Native allocation starts only after all cross-option relationships pass. proxy_scene = sim.mb.getScene() if proxy_scene is None: raise click.ClickException("mesh creation requires a ProxyScene-backed model") # Phase 3: construct only after all cross-option validation has succeeded. created: dict[str, Any] = {} material_option_names = ( "color", "file_texture", "grid_texture", "chequered_texture", ) layers = mergeMeshLayers(mesh_options.get("layer")) wireframe = mesh_options.get("wireframe") is True opacity = float(mesh_options.get("opacity", 1.0)) for name, definition in definitions.items(): # Exactly one appearance policy may own a mesh material. selected_materials = [ option_name for option_name in material_option_names if name in named_options[option_name] ] if len(selected_materials) > 1: raise click.UsageError(f"mesh '{name}' has mutually exclusive color/texture options") material_kind = selected_materials[0] if selected_materials else "color" material_values = named_options[material_kind].get(name, ("blue",)) spacing_values = named_options["texture_spacing"].get(name, (1.0, 1.0)) spacing = (float(spacing_values[0]), float(spacing_values[1])) if any(value <= 0.0 for value in spacing): raise click.BadParameter( f"mesh '{name}' texture spacing must be positive", param_hint="--texture-spacing", ) if name in named_options["texture_spacing"] and material_kind not in ( "grid_texture", "chequered_texture", ): raise click.UsageError( f"mesh '{name}' uses --texture-spacing without a synthetic texture" ) # Parse dimensions late enough to preserve name-aware diagnostics, but # before allocating geometry or material native objects. geometry_parameters = parseGeometryParameters(definition) material = createMeshMaterial( definition, geometry_parameters, material_kind, material_values, spacing, 1.0 if wireframe else opacity, ) if wireframe: # WireframeMaterial has no opacity setting, so opacity is ignored. material = createWireframeMaterial(material) if isinstance(geometry_parameters, Path): # Imported files may contain a hierarchy, so material and scale # operations recurse after the file object is created. mesh_node = ks.ProxySceneFileObject.create( name, proxy_scene, resolveResourceFile(geometry_parameters), layers, ) if applyMaterialRecursively(mesh_node, material) == 0: raise click.ClickException( f"file mesh '{name}' did not contain any material-bearing parts" ) else: # A primitive is one ScenePart and accepts material at construction. geometry = createPrimitiveGeometry( definition.geometry_type, geometry_parameters, ) mesh_node = ks.ProxyScenePart.create( name, proxy_scene, geometry, material, layers, ) # Attach first so position and orientation are relative to the root Frame. mesh_node.attachTo(sim.fc.root()) position = named_options["position"].get(name, (0.0, 0.0, 0.0)) mesh_node.setTranslation(np.asarray(position, dtype=float)) orientation = named_options["orientation"].get(name, (0.0, 0.0, 0.0, 1.0)) mesh_node.setUnitQuaternion(km.UnitQuaternion(*orientation, renormalize=True)) scale = np.asarray(named_options["scale"].get(name, (1.0, 1.0, 1.0))) if isinstance(geometry_parameters, Path): applyIntrinsicScaleRecursively(mesh_node, scale) else: mesh_node.setIntrinsicScale(scale) if name in named_options["trail"]: # Defer trail creation until setupGui has created graphical proxies. if not isinstance(getattr(sim, "mesh_trails", None), list): sim.mesh_trails = [] trail_color = parseSkyColor(str(named_options["trail"][name][0])) sim.mesh_trails.append((mesh_node, trail_color)) created[name] = mesh_node return created
[docs] def retainTerrainGrid(sim: Sim, terrain_grid: "TerrainGrid") -> None: """Retain a TerrainGrid and register deterministic native cleanup. Scene attachment alone does not express Python ownership. The explicit dictionary keeps the wrapper alive for scripts, while the callback releases its native object during normal kdFlex shutdown. """ # Retain by stable user-facing name so interactive scripts can discover the # grids without depending on native IDs. if not hasattr(sim, "terrain_grids"): # Expose created grids to scripts through ``kdsim.terrain_grids``. sim.terrain_grids = {} terrain_name = terrain_grid.name() if terrain_name in sim.terrain_grids: raise click.UsageError(f"TerrainGrid name '{terrain_name}' is duplicated") sim.terrain_grids[terrain_name] = terrain_grid # TerrainGrid is a LockingBase and must be discarded before process exit. # Weak references prevent the cleanup callback itself from extending either # lifetime. It drops Python retention before native discard. cleanup_key = f"discard_terrain_grid_{terrain_grid.id()}" terrain_ref = kc.CppWeakRef(terrain_grid) sim_ref = weakref.ref(sim) def discardTerrainGrid() -> None: """Release Python retention before discarding the TerrainGrid.""" retained_sim = sim_ref() if retained_sim is not None and hasattr(retained_sim, "terrain_grids"): retained_sim.terrain_grids.pop(terrain_name, None) retained_grid = terrain_ref() if retained_grid is not None: kc.discard(retained_grid) kc.BaseContainer.singleton().at_exit_fns[cleanup_key] = discardTerrainGrid
[docs] def configureCelestialTerrains( sim: Sim, options: dict[str, Any], ) -> dict[str, "GeographicTerrainGrid"]: """Create requested analytic celestial GeographicTerrainGrid instances.""" created: dict[str, GeographicTerrainGrid] = {} celestial_options = options.get("celestial") or {} # Reference ellipsoids and spheres are geographic terrain grids. celestial_factories = ( ("earth", "EarthWGS84", GeographicTerrainGrid.createEarthWGS84), ("moon", "Moon", GeographicTerrainGrid.createMoon), ("mars", "Mars", GeographicTerrainGrid.createMars), ) # Factory order gives scripts and cleanup callbacks deterministic ordering. for option_name, terrain_name, factory in celestial_factories: if celestial_options.get(option_name) is not True: # Ignore Kclick sentinels supplied for an omitted celestial command. continue if terrain_name in created: raise click.UsageError(f"TerrainGrid name '{terrain_name}' is duplicated") terrain_grid = factory() retainTerrainGrid(sim, terrain_grid) created[terrain_name] = terrain_grid return created
[docs] def configureGeotiffTerrains( sim: Sim, options: dict[str, Any], ) -> dict[str, "GeoTiffTerrainGrid"]: """Create requested named GeoTiffTerrainGrid instances.""" created: dict[str, GeoTiffTerrainGrid] = {} terrain_entries = (options.get("geotiff") or {}).get("terrain", ()) # A concrete tuple distinguishes an invoked command from Kclick's omission # sentinel and preserves repeated --terrain order. if isinstance(terrain_entries, tuple): for name, source_path in terrain_entries: if name in created: raise click.UsageError(f"GeoTiffTerrainGrid name '{name}' is duplicated") # Resolve relative GeoTIFF paths through kdFlex resource paths. terrain_grid = GeoTiffTerrainGrid.create( name, resolveResourceFile(source_path), ) retainTerrainGrid(sim, terrain_grid) created[name] = terrain_grid return created
[docs] def configureTerrains( sim: Sim, options: dict[str, Any] ) -> dict[str, GeoTiffTerrainGrid | GeographicTerrainGrid]: """Create celestial grids before GeoTIFF grids for compatibility callers.""" # Preserve the public helper while making the two processing phases explicit. created = configureCelestialTerrains(sim, options) created.update(configureGeotiffTerrains(sim, options)) return created