python/src/pipeline.cpp
| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | #include "bindings.h" | ||
| 2 | #include "common.h" | ||
| 3 | #include "objects.h" | ||
| 4 | #include "tables.h" | ||
| 5 | |||
| 6 | #include "terraingen/config/build.h" | ||
| 7 | #include "terraingen/config/generate.h" | ||
| 8 | #include "terraingen/config/json.h" | ||
| 9 | #include "terraingen/config/validate.h" | ||
| 10 | #include "terraingen/pipeline/body_mesh.h" | ||
| 11 | #include "terraingen/pipeline/features.h" | ||
| 12 | #include "terraingen/pipeline/height_mesh.h" | ||
| 13 | #include "terraingen/pipeline/terrain.h" | ||
| 14 | #include "terraingen/pipeline/tiles.h" | ||
| 15 | |||
| 16 | #include <nanobind/operators.h> | ||
| 17 | |||
| 18 | #include <filesystem> | ||
| 19 | #include <optional> | ||
| 20 | #include <stdexcept> | ||
| 21 | #include <string> | ||
| 22 | #include <utility> | ||
| 23 | #include <vector> | ||
| 24 | |||
| 25 | namespace terraingen::python { | ||
| 26 | |||
| 27 | namespace { | ||
| 28 | |||
| 29 | using OptionalPath = std::optional<std::filesystem::path>; | ||
| 30 | |||
| 31 | 43 | std::filesystem::path Directory(const OptionalPath& directory) { | |
| 32 | 43 | return directory.value_or(std::filesystem::path{}); | |
| 33 | } | ||
| 34 | |||
| 35 | using PointArray = nb::ndarray<const double, nb::ndim<2>, nb::device::cpu, nb::c_contig>; | ||
| 36 | |||
| 37 | /// `EvaluatePoints` over the rows of an (n, 2) array, as a float32 array. | ||
| 38 | 2 | nb::object EvaluatePointArray(const PyTerrain& terrain, const PointArray& points, double spacing, | |
| 39 | unsigned threads) { | ||
| 40 | 2 | if (points.shape(1) != 2) { | |
| 41 | 1 | throw std::invalid_argument("Points must be an array of shape (n, 2)"); | |
| 42 | } | ||
| 43 | 1 | std::vector<Vec2d> positions(points.shape(0)); | |
| 44 | 4 | for (std::size_t i = 0; i < positions.size(); ++i) { | |
| 45 | 3 | positions[i] = {points.data()[2 * i], points.data()[2 * i + 1]}; | |
| 46 | } | ||
| 47 | 1 | std::vector<float> heights; | |
| 48 | { | ||
| 49 | 1 | nb::gil_scoped_release release; | |
| 50 | 1 | heights = EvaluatePoints(terrain.Get(), positions, spacing, threads); | |
| 51 | 1 | } | |
| 52 | 2 | return Column(std::move(heights)); | |
| 53 | 1 | } | |
| 54 | |||
| 55 | /// @brief `text` parsed by Python's json module. | ||
| 56 | 22 | nb::object FromJson(const std::string& text) { | |
| 57 | 22 | return nb::module_::import_("json").attr("loads")(text); | |
| 58 | } | ||
| 59 | |||
| 60 | /// @brief `grid`, or else the grid of `terrain`'s source. | ||
| 61 | /// @throws std::invalid_argument when there is neither. | ||
| 62 | 186 | RasterGrid GridOr(const Terrain& terrain, const std::optional<RasterGrid>& grid) { | |
| 63 | 186 | if (grid) { | |
| 64 | 20 | return *grid; | |
| 65 | } | ||
| 66 | 166 | if (!terrain.source) { | |
| 67 | 2 | throw std::invalid_argument("A terrain without a source needs a grid"); | |
| 68 | } | ||
| 69 | 164 | return terrain.source->Grid(); | |
| 70 | } | ||
| 71 | |||
| 72 | /// @brief The list format a file's extension asks for: JSON for .json, CSV otherwise. | ||
| 73 | 6 | ListFormat FormatOf(const std::filesystem::path& path) { | |
| 74 | 6 | return path.extension() == ".json" ? ListFormat::Json : ListFormat::Csv; | |
| 75 | } | ||
| 76 | |||
| 77 | 44 | void BindTerrainClass(nb::module_& m) { | |
| 78 | ✗ | nb::class_<PyTerrain>(m, "Terrain", | |
| 79 | "A terrain: heights from `source` (the datum, 0 m, when None), plus " | ||
| 80 | "each of `layers` in order.") | ||
| 81 | 44 | .def( | |
| 82 | "__init__", | ||
| 83 | 202 | [](PyTerrain* self, std::shared_ptr<Source> source, | |
| 84 | std::vector<std::shared_ptr<Layer>> layers, const Surface& surface) { | ||
| 85 | 158 | new (self) PyTerrain{std::move(source), std::move(layers), surface}; | |
| 86 | 158 | }, | |
| 87 | 132 | "source"_a = nb::none(), "layers"_a = std::vector<std::shared_ptr<Layer>>{}, | |
| 88 | 88 | "surface"_a = Surface{}, "`surface` must be the one the layers were built for.") | |
| 89 | 44 | .def_rw("surface", &PyTerrain::surface) | |
| 90 | 44 | .def_rw("source", &PyTerrain::source) | |
| 91 | .def_rw("layers", &PyTerrain::layers); | ||
| 92 | 44 | } | |
| 93 | |||
| 94 | 44 | void BindFeatures(nb::module_& m) { | |
| 95 | 88 | nb::class_<CraterFeatures>(m, "CraterFeatures", "Craters and their attributes, in step.") | |
| 96 | 44 | .def_ro("craters", &CraterFeatures::craters, "The craters, as a CraterList.") | |
| 97 | 44 | .def_prop_ro( | |
| 98 | 29 | "table", [](const CraterFeatures& f) { return CraterTable(f.craters, f.attributes); }, | |
| 99 | "The craters as NumPy columns 'x', 'y', 'diameter', 'age_gyr', 'id' and 'manual' (1 " | ||
| 100 | "for manual craters), in the surface's coordinates, and their attributes " | ||
| 101 | "'floor_depth' and 'rim_height' (m), 'depth_ratio', 'freshness' and 'complex'.") | ||
| 102 | .def( | ||
| 103 | "write", | ||
| 104 | 47 | [](const CraterFeatures& f, const std::filesystem::path& path) { | |
| 105 | 3 | WriteCraterList(f.craters, path, FormatOf(path), f.attributes); | |
| 106 | 3 | }, | |
| 107 | 88 | "path"_a, "Writes the craters as CSV, or JSON for a .json path."); | |
| 108 | 44 | nb::class_<BoulderFeatures>(m, "BoulderFeatures", | |
| 109 | "Boulders, and those of them placed as their own 3D models.") | ||
| 110 | 44 | .def_ro("boulders", &BoulderFeatures::boulders, "The boulders, as Boulder objects.") | |
| 111 | 44 | .def_ro("models", &BoulderFeatures::models, | |
| 112 | "The boulder models, with their shapes and placements.") | ||
| 113 | 44 | .def_prop_ro( | |
| 114 | 20 | "table", [](const BoulderFeatures& f) { return BoulderTable(f.boulders); }, | |
| 115 | "The boulders as NumPy columns 'x', 'y', 'ground' (NaN when unknown), 'diameter', " | ||
| 116 | "'height', 'burial', 'yaw', 'lean', 'id' and 'model' (1 for boulder models).") | ||
| 117 | 44 | .def( | |
| 118 | "write_list", | ||
| 119 | ✗ | [](const BoulderFeatures& f, const std::filesystem::path& path) { | |
| 120 | 3 | WriteBoulderList(f.boulders, path, FormatOf(path)); | |
| 121 | 3 | }, | |
| 122 | 44 | "path"_a, "Writes the boulders as CSV, or JSON for a .json path.") | |
| 123 | .def( | ||
| 124 | "write_models", | ||
| 125 | 47 | [](const BoulderFeatures& f, const std::filesystem::path& path, double creaseAngle, | |
| 126 | bool instanced) { | ||
| 127 | 3 | WriteBoulderModels(f.models, path, creaseAngle, {}, {}, instanced); | |
| 128 | 3 | }, | |
| 129 | 176 | "path"_a, "crease_angle"_a = 60.0, "instanced"_a = true, | |
| 130 | "Writes the boulder models as a glTF scene (.glb or .gltf), OBJ (.obj) or STL (.stl): " | ||
| 131 | "each shared shape once, drawn per boulder, or with `instanced` false every boulder as " | ||
| 132 | "its own placed mesh."); | ||
| 133 | 44 | m.def( | |
| 134 | "placed_craters", | ||
| 135 | ✗ | [](const PyTerrain& terrain, std::optional<RasterGrid> grid, double spacing) { | |
| 136 | 32 | const Terrain built = terrain.Get(); | |
| 137 | 63 | return PlacedCraters(built, GridOr(built, grid), spacing); | |
| 138 | 32 | }, | |
| 139 | 176 | "terrain"_a, "grid"_a = nb::none(), "spacing"_a = 0.0, | |
| 140 | ✗ | nb::call_guard<nb::gil_scoped_release>(), | |
| 141 | "The craters the terrain's crater and secondary crater layers apply over `grid` (by " | ||
| 142 | "default its source's) at `spacing` metres (0 for the grid's), in layer order."); | ||
| 143 | 44 | m.def( | |
| 144 | "placed_boulders", | ||
| 145 | ✗ | [](const PyTerrain& terrain, std::optional<RasterGrid> grid, double spacing) { | |
| 146 | 25 | const Terrain built = terrain.Get(); | |
| 147 | 50 | return PlacedBoulders(built, GridOr(built, grid), spacing); | |
| 148 | 25 | }, | |
| 149 | 176 | "terrain"_a, "grid"_a = nb::none(), "spacing"_a = 0.0, | |
| 150 | 44 | nb::call_guard<nb::gil_scoped_release>(), | |
| 151 | "The boulders the terrain's boulder layers place over `grid` (by default its source's) at " | ||
| 152 | "`spacing` metres (0 for the grid's), and the boulder models among them."); | ||
| 153 | 44 | } | |
| 154 | |||
| 155 | 44 | void BindEvaluation(nb::module_& m) { | |
| 156 | 44 | m.def( | |
| 157 | "evaluate", | ||
| 158 | ✗ | [](const PyTerrain& terrain, double spacing, unsigned threads, | |
| 159 | std::optional<RasterGrid> grid, std::optional<Backend> backend) { | ||
| 160 | 129 | const Terrain built = terrain.Get(); | |
| 161 | 130 | return Evaluate(built, GridOr(built, grid), | |
| 162 | 515 | EvaluationOptions{spacing, threads, backend}); | |
| 163 | 129 | }, | |
| 164 | 220 | "terrain"_a, "spacing"_a = 0.0, "threads"_a = 0, "grid"_a = nb::none(), | |
| 165 | 88 | "backend"_a = nb::none(), nb::call_guard<nb::gil_scoped_release>(), | |
| 166 | "Evaluates `terrain` over `grid` (by default its source's) at a sample spacing of " | ||
| 167 | "`spacing` metres (0 for the grid's), on up to `threads` threads (0 for every hardware " | ||
| 168 | "thread; the result does not depend on it), on `backend` (None for default_backend()). " | ||
| 169 | "On Vulkan the heights match the CPU to within float rounding."); | ||
| 170 | 44 | m.def( | |
| 171 | "evaluate_at", | ||
| 172 | ✗ | [](const PyTerrain& terrain, double x, double y, double spacing) { | |
| 173 | 4015 | return EvaluateAt(terrain.Get(), x, y, spacing); | |
| 174 | }, | ||
| 175 | 44 | "terrain"_a, "x"_a, "y"_a, "spacing"_a, nb::call_guard<nb::gil_scoped_release>(), | |
| 176 | "The terrain's height at (x, y) sampled every `spacing` metres; equal to the cell centred " | ||
| 177 | "there of `evaluate`."); | ||
| 178 | 44 | m.def("evaluate_points", &EvaluatePointArray, "terrain"_a, "points"_a, "spacing"_a, | |
| 179 | 88 | "threads"_a = 0, | |
| 180 | "The terrain's heights at `points`, an array of shape (n, 2) in surface coordinates, " | ||
| 181 | "sampled every `spacing` metres, as a float32 array; each equals `evaluate_at` there. " | ||
| 182 | "Batches of points are much faster than one call per point."); | ||
| 183 | 88 | m.def("height_mesh", &HeightMesh, "heights"_a, "surface"_a = Surface{}, | |
| 184 | 88 | "site"_a = Vec2d{0.0, 0.0}, | |
| 185 | "The heights as a mesh, a vertex per cell with data, in the frame boulder models are " | ||
| 186 | "written in: world coordinates on a plane, east-north-up metres of `site` on a sphere."); | ||
| 187 | 44 | m.def( | |
| 188 | "body_mesh", | ||
| 189 | ✗ | [](const PyTerrain& terrain, std::size_t cellsPerFace, double spacing, unsigned threads) { | |
| 190 | 5 | return BodyMesh(terrain.Get(), cellsPerFace, spacing, threads); | |
| 191 | }, | ||
| 192 | 176 | "terrain"_a, "cells_per_face"_a, "spacing"_a = 0.0, "threads"_a = 0, | |
| 193 | ✗ | nb::call_guard<nb::gil_scoped_release>(), | |
| 194 | "A mesh of the whole body a terrain on a sphere or ellipsoid lies on: six cube faces of " | ||
| 195 | "`cells_per_face` squared quads, each vertex at the radius plus the terrain's height " | ||
| 196 | "there."); | ||
| 197 | 44 | m.def( | |
| 198 | "body_icq", | ||
| 199 | ✗ | [](const PyTerrain& terrain, std::size_t q, double spacing, unsigned threads) { | |
| 200 | 1 | return BodyIcq(terrain.Get(), q, spacing, threads); | |
| 201 | }, | ||
| 202 | 176 | "terrain"_a, "q"_a, "spacing"_a = 0.0, "threads"_a = 0, | |
| 203 | 44 | nb::call_guard<nb::gil_scoped_release>(), | |
| 204 | "The whole body a terrain on a sphere or ellipsoid lies on as an ICQ shape model with " | ||
| 205 | "`q` intervals per face edge, each label at the radius plus the terrain's height there."); | ||
| 206 | 44 | } | |
| 207 | |||
| 208 | 44 | void BindTileKey(nb::module_& m) { | |
| 209 | ✗ | nb::class_<TileKey>(m, "TileKey", "Tile (x, y) of a tile scheme's level `level`.") | |
| 210 | 44 | .def( | |
| 211 | "__init__", | ||
| 212 | ✗ | [](TileKey* self, int level, std::int64_t x, std::int64_t y) { | |
| 213 | 34 | new (self) TileKey{level, x, y}; | |
| 214 | 34 | }, | |
| 215 | 44 | "level"_a, "x"_a, "y"_a) | |
| 216 | 44 | .def_rw("level", &TileKey::level) | |
| 217 | 44 | .def_rw("x", &TileKey::x) | |
| 218 | 44 | .def_rw("y", &TileKey::y) | |
| 219 | 44 | .def(nb::self == nb::self) | |
| 220 | 44 | .def("__repr__", | |
| 221 | 47 | [](const TileKey& k) { | |
| 222 | 6 | return "TileKey(" + std::to_string(k.level) + ", " + std::to_string(k.x) + ", " + | |
| 223 | 9 | std::to_string(k.y) + ")"; | |
| 224 | }) | ||
| 225 | 88 | .def_prop_ro("parent", &TileScheme::Parent) | |
| 226 | 44 | .def_prop_ro("children", [](const TileKey& key) { | |
| 227 | 1 | const auto c = TileScheme::Children(key); | |
| 228 | 2 | return std::vector<TileKey>(c.begin(), c.end()); | |
| 229 | }); | ||
| 230 | 44 | } | |
| 231 | |||
| 232 | 44 | void BindTileScheme(nb::module_& m) { | |
| 233 | ✗ | nb::class_<TileScheme>( | |
| 234 | m, "TileScheme", | ||
| 235 | "A quadtree of north-up tiles: level 0 has tiles `root_size` across from `origin` (the " | ||
| 236 | "top-left corner of tile (0, 0)), each level halves them, and every tile has `cells` x " | ||
| 237 | "`cells` samples.") | ||
| 238 | 44 | .def( | |
| 239 | "__init__", | ||
| 240 | ✗ | [](TileScheme* self, const Vec2d& origin, double rootSize, std::size_t cells) { | |
| 241 | 2 | new (self) TileScheme{origin, rootSize, cells}; | |
| 242 | 2 | self->Validate(); | |
| 243 | 1 | }, | |
| 244 | 132 | "origin"_a, "root_size"_a, "cells"_a = 256) | |
| 245 | 132 | .def_static("covering", &TileScheme::Covering, "grid"_a, "cells"_a = 256, | |
| 246 | "A scheme one of whose levels has the cells of `grid`.") | ||
| 247 | 132 | .def_static("whole_body", &TileScheme::WholeBody, "cells"_a = 256, | |
| 248 | "A longitude-latitude pyramid over a whole sphere.") | ||
| 249 | 44 | .def_static("body_tiles", &TileScheme::BodyTiles, "level"_a, | |
| 250 | "Every tile of a level of a whole-body scheme.") | ||
| 251 | 88 | .def_static("wrap_body_tile", &TileScheme::WrapBodyTile, "key"_a, | |
| 252 | "The whole-body tile a neighbour across the antimeridian is.") | ||
| 253 | 44 | .def_ro("origin", &TileScheme::origin) | |
| 254 | 44 | .def_ro("root_size", &TileScheme::rootSize) | |
| 255 | 44 | .def_ro("cells", &TileScheme::cells) | |
| 256 | 44 | .def("tile_size", &TileScheme::TileSize, "level"_a) | |
| 257 | 44 | .def("spacing", &TileScheme::Spacing, "level"_a) | |
| 258 | 44 | .def("level_for", &TileScheme::LevelFor, "spacing"_a, | |
| 259 | "The coarsest level whose spacing is at most `spacing`.") | ||
| 260 | 44 | .def("tile_at", &TileScheme::TileAt, "x"_a, "y"_a, "level"_a) | |
| 261 | 44 | .def("tiles_covering", &TileScheme::TilesCovering, "bounds"_a, "level"_a) | |
| 262 | 88 | .def("grid", &TileScheme::Grid, "key"_a, "The tile's grid of samples."); | |
| 263 | 44 | } | |
| 264 | |||
| 265 | 44 | void BindTiles(nb::module_& m) { | |
| 266 | 44 | BindTileKey(m); | |
| 267 | 44 | BindTileScheme(m); | |
| 268 | 44 | m.def( | |
| 269 | "evaluate_tile", | ||
| 270 | ✗ | [](const PyTerrain& terrain, const TileScheme& scheme, const TileKey& key, | |
| 271 | 5 | unsigned threads) { return EvaluateTile(terrain.Get(), scheme, key, threads); }, | |
| 272 | 132 | "terrain"_a, "scheme"_a, "key"_a, "threads"_a = 1, nb::call_guard<nb::gil_scoped_release>(), | |
| 273 | "One tile of `scheme` at its level's spacing, equal to the same cells of any larger grid."); | ||
| 274 | 44 | m.def( | |
| 275 | "evaluate_tiles", | ||
| 276 | ✗ | [](const PyTerrain& terrain, const TileScheme& scheme, const std::vector<TileKey>& keys, | |
| 277 | 3 | unsigned threads) { return EvaluateTiles(terrain.Get(), scheme, keys, threads); }, | |
| 278 | 132 | "terrain"_a, "scheme"_a, "keys"_a, "threads"_a = 0, | |
| 279 | 44 | nb::call_guard<nb::gil_scoped_release>(), | |
| 280 | "`evaluate_tile` for each of `keys`, split over threads."); | ||
| 281 | ✗ | nb::class_<TileCache>( | |
| 282 | m, "TileCache", | ||
| 283 | "Tiles of one terrain and scheme, evaluated on demand and kept up to about " | ||
| 284 | "`max_bytes`, least recently used first out; thread-safe. The budget is " | ||
| 285 | "split over 16 shards that each keep at least one tile, so a budget " | ||
| 286 | "smaller than 16 tiles is exceeded.") | ||
| 287 | 44 | .def( | |
| 288 | "__init__", | ||
| 289 | ✗ | [](TileCache* self, const PyTerrain& terrain, const TileScheme& scheme, | |
| 290 | 4 | std::size_t maxBytes) { new (self) TileCache(terrain.Get(), scheme, maxBytes); }, | |
| 291 | 132 | "terrain"_a, "scheme"_a, "max_bytes"_a = std::size_t{1} << 30) | |
| 292 | 44 | .def( | |
| 293 | "get", | ||
| 294 | 589 | [](TileCache& cache, const TileKey& key) { return Raster<float>(*cache.Get(key)); }, | |
| 295 | 44 | "key"_a, nb::call_guard<nb::gil_scoped_release>(), | |
| 296 | "The tile at `key`, evaluated if it is not held.") | ||
| 297 | 44 | .def_prop_ro("size", &TileCache::Size, "Bytes held.") | |
| 298 | 44 | .def_prop_ro("max_bytes", &TileCache::MaxBytes) | |
| 299 | 88 | .def_prop_ro("evictions", &TileCache::Evictions, "Tiles evicted so far."); | |
| 300 | 44 | } | |
| 301 | |||
| 302 | /// @brief Registers ConfigError, a ValueError with the JSON `path` of the offending value. | ||
| 303 | 44 | void BindConfigError(nb::module_& m) { | |
| 304 | // Named within the extension module, as nanobind names its own types, so its stubs list it. | ||
| 305 | 88 | const std::string name = nb::cast<std::string>(m.attr("__name__")) + ".ConfigError"; | |
| 306 | static nb::object configError = | ||
| 307 | 44 | nb::steal(PyErr_NewExceptionWithDoc(name.c_str(), | |
| 308 | "An invalid configuration; `path` is the JSON path of " | ||
| 309 | "the offending value, empty for the whole document.", | ||
| 310 | 88 | PyExc_ValueError, nullptr)); | |
| 311 | 44 | m.attr("ConfigError") = configError; | |
| 312 | 44 | nb::register_exception_translator([](const std::exception_ptr& error, void*) { | |
| 313 | try { | ||
| 314 | 144 | std::rethrow_exception(error); | |
| 315 | 72 | } catch (const ConfigError& config) { | |
| 316 | 10 | nb::object instance = configError(config.what()); | |
| 317 | 20 | instance.attr("path") = config.Path(); | |
| 318 | 20 | PyErr_SetObject(configError.ptr(), instance.ptr()); | |
| 319 | 10 | } | |
| 320 | 10 | }); | |
| 321 | 44 | } | |
| 322 | |||
| 323 | 44 | void BindConfigIo(nb::class_<TerrainConfig>& config) { | |
| 324 | ✗ | config.def(nb::init<>(), "The default configuration.") | |
| 325 | 44 | .def_static("from_json", &ParseConfig, "text"_a, | |
| 326 | "Parses a configuration from JSON text. Raises ConfigError if it does not fit " | ||
| 327 | "the format.") | ||
| 328 | 44 | .def_static( | |
| 329 | "from_dict", | ||
| 330 | 61 | [](nb::handle data) { | |
| 331 | 61 | const nb::object json = nb::module_::import_("json"); | |
| 332 | 244 | return ParseConfig(nb::cast<std::string>(json.attr("dumps")(data))); | |
| 333 | }, | ||
| 334 | 44 | "data"_a, "Parses a configuration from a dict of JSON values.") | |
| 335 | 44 | .def_static("load", &LoadConfig, "path"_a, | |
| 336 | "Reads the configuration in the JSON file at `path`.") | ||
| 337 | 176 | .def("to_json", &ConfigToJson, "indent"_a = 2, | |
| 338 | "The configuration as JSON with every value written out.") | ||
| 339 | .def( | ||
| 340 | 61 | "to_dict", [](const TerrainConfig& c) { return FromJson(ConfigToJson(c, -1)); }, | |
| 341 | "The configuration as a dict of JSON values, every value written out."); | ||
| 342 | 44 | } | |
| 343 | |||
| 344 | 44 | void BindConfigQueries(nb::class_<TerrainConfig>& config) { | |
| 345 | config | ||
| 346 | 44 | .def( | |
| 347 | "validate", | ||
| 348 | ✗ | [](const TerrainConfig& c, const OptionalPath& directory) { | |
| 349 | 25 | ValidateConfig(c, Directory(directory)); | |
| 350 | 13 | }, | |
| 351 | 88 | "directory"_a = nb::none(), | |
| 352 | "Checks everything parsing cannot (grid limits, raster files, model parameters, " | ||
| 353 | "expected crater counts); relative input paths resolve against `directory`. Raises " | ||
| 354 | "ConfigError naming the first invalid value.") | ||
| 355 | 55 | .def("__eq__", [](const TerrainConfig& a, const TerrainConfig& b) { return a == b; }) | |
| 356 | 44 | .def( | |
| 357 | "body", | ||
| 358 | ✗ | [](const TerrainConfig& c, const OptionalPath& directory) { | |
| 359 | 1 | return BuildSurface(c.body, Directory(directory)); | |
| 360 | }, | ||
| 361 | 88 | "directory"_a = nb::none(), | |
| 362 | "The Surface the configuration's terrain lies on; a shape model's path resolves " | ||
| 363 | "against `directory`.") | ||
| 364 | 44 | .def( | |
| 365 | "base_grid", | ||
| 366 | ✗ | [](const TerrainConfig& c, const OptionalPath& directory) -> std::optional<RasterGrid> { | |
| 367 | 1 | if (!c.base) { | |
| 368 | 1 | return std::nullopt; | |
| 369 | } | ||
| 370 | ✗ | return DescribeSource(*c.base, Directory(directory), | |
| 371 | ✗ | BuildSurface(c.body, Directory(directory))); | |
| 372 | }, | ||
| 373 | 88 | "directory"_a = nb::none(), | |
| 374 | "The grid the configuration's base covers, reading only a raster's header; None " | ||
| 375 | "without a base.") | ||
| 376 | 44 | .def( | |
| 377 | "output_grids", | ||
| 378 | 49 | [](const TerrainConfig& c, const OptionalPath& directory) { | |
| 379 | 5 | return ValidateConfig(c, Directory(directory)).outputGrids; | |
| 380 | }, | ||
| 381 | 88 | "directory"_a = nb::none(), | |
| 382 | "The grid each output covers, in order (None for a mesh), after validating the " | ||
| 383 | "configuration.") | ||
| 384 | 44 | .def("__repr__", [](const TerrainConfig& c) { | |
| 385 | 2 | return "Config(" + std::to_string(c.layers.size()) + " layers, " + | |
| 386 | 3 | std::to_string(c.outputs.size()) + " outputs)"; | |
| 387 | }); | ||
| 388 | 44 | } | |
| 389 | |||
| 390 | 44 | void BindConfig(nb::module_& m) { | |
| 391 | 44 | BindConfigError(m); | |
| 392 | nb::class_<TerrainConfig> config(m, "Config", | ||
| 393 | "A terrain configuration: a body, optionally a base, layers, " | ||
| 394 | "outputs and optionally an albedo, as the JSON configs of the " | ||
| 395 | 44 | "terraingen command describe."); | |
| 396 | 44 | BindConfigIo(config); | |
| 397 | 44 | BindConfigQueries(config); | |
| 398 | 44 | m.def( | |
| 399 | 4 | "config_catalog", [] { return FromJson(ConfigCatalog(-1)); }, | |
| 400 | "Every configuration type with its defaults, as a dict: the lists 'body', 'base', " | ||
| 401 | "'layers' and 'outputs', the 'albedo', and the 'models' (densities, ages, " | ||
| 402 | "degradation, profiles, boulder sizes, region shapes and albedo sources and layers)."); | ||
| 403 | 44 | m.def( | |
| 404 | 1 | "config_schema", [] { return FromJson(ConfigSchema(-1)); }, | |
| 405 | "The structure of a configuration, for editors, as a dict: 'root' (the configuration " | ||
| 406 | "object) and 'definitions' (each struct and choice of types by id, referred to as " | ||
| 407 | "{'kind': 'ref', 'ref': id}). Every field has a 'name', 'description', 'unit', 'schema' " | ||
| 408 | "and 'default'; schema kinds are number, integer, boolean, string, enum (with 'choices' " | ||
| 409 | "and their 'descriptions'), optional, array, object and choice (with 'alternatives')."); | ||
| 410 | 44 | } | |
| 411 | |||
| 412 | /// @brief `grid`, or else the first grid `terrain` generated. | ||
| 413 | /// @throws std::invalid_argument when there is neither. | ||
| 414 | 8 | RasterGrid GeneratedGridOr(const GeneratedTerrain& terrain, const std::optional<RasterGrid>& grid) { | |
| 415 | 8 | if (grid) { | |
| 416 | 3 | return *grid; | |
| 417 | } | ||
| 418 | 5 | if (terrain.grids.empty()) { | |
| 419 | ✗ | throw std::invalid_argument("The terrain generated no grid; give one"); | |
| 420 | } | ||
| 421 | 5 | return RasterGrid::FromRaster(terrain.grids.front().heights); | |
| 422 | } | ||
| 423 | |||
| 424 | 44 | void BindGeneratedTerrain(nb::module_& m) { | |
| 425 | 88 | nb::class_<GeneratedGrid>(m, "GeneratedGrid", | |
| 426 | "A terrain's heights, and its albedo bands, over one grid.") | ||
| 427 | 44 | .def_prop_ro( | |
| 428 | 8 | "heights", [](GeneratedGrid& g) -> Raster<float>& { return g.heights; }, | |
| 429 | nb::rv_policy::reference_internal, "Heights in metres over the grid.") | ||
| 430 | 44 | .def_prop_ro( | |
| 431 | "albedo", | ||
| 432 | 46 | [](nb::handle self) { | |
| 433 | 2 | nb::list bands; | |
| 434 | 3 | for (Raster<float>& band : nb::cast<GeneratedGrid&>(self).albedo) { | |
| 435 | 2 | bands.append(nb::cast(&band, nb::rv_policy::reference_internal, self)); | |
| 436 | } | ||
| 437 | 2 | return bands; | |
| 438 | ✗ | }, | |
| 439 | "Albedo bands, empty unless an albedo output covers the grid.") | ||
| 440 | 44 | .def_prop_ro( | |
| 441 | "materials", | ||
| 442 | 44 | [](nb::handle self) { | |
| 443 | ✗ | nb::list bands; | |
| 444 | ✗ | for (Raster<float>& band : nb::cast<GeneratedGrid&>(self).materials) { | |
| 445 | ✗ | bands.append(nb::cast(&band, nb::rv_policy::reference_internal, self)); | |
| 446 | } | ||
| 447 | ✗ | return bands; | |
| 448 | ✗ | }, | |
| 449 | "The share of each MaterialClass, one raster per class; empty unless a materials or " | ||
| 450 | "photometry output covers the grid.") | ||
| 451 | .def_prop_ro( | ||
| 452 | 45 | "grid", [](const GeneratedGrid& g) { return RasterGrid::FromRaster(g.heights); }, | |
| 453 | "The grid the heights cover."); | ||
| 454 | nb::class_<GeneratedTerrain> terrain(m, "GeneratedTerrain", | ||
| 455 | "A generated terrain: the heights and albedo over each " | ||
| 456 | "grid its outputs cover, and the craters and boulders it " | ||
| 457 | 44 | "places on request."); | |
| 458 | terrain.def_prop_ro( | ||
| 459 | "grids", | ||
| 460 | 57 | [](nb::handle self) { | |
| 461 | 13 | nb::list grids; | |
| 462 | 26 | for (GeneratedGrid& grid : nb::cast<GeneratedTerrain&>(self).grids) { | |
| 463 | 26 | grids.append(nb::cast(&grid, nb::rv_policy::reference_internal, self)); | |
| 464 | } | ||
| 465 | 13 | return grids; | |
| 466 | ✗ | }, | |
| 467 | "The GeneratedGrid of each distinct grid a heightmap, albedo or terrain mesh output " | ||
| 468 | "covers, in output order."); | ||
| 469 | terrain | ||
| 470 | 44 | .def( | |
| 471 | "placed_craters", | ||
| 472 | ✗ | [](const GeneratedTerrain& t, std::optional<RasterGrid> grid, double spacing) { | |
| 473 | 6 | return PlacedCraters(t.terrain, GeneratedGridOr(t, grid), spacing); | |
| 474 | }, | ||
| 475 | 132 | "grid"_a = nb::none(), "spacing"_a = 0.0, | |
| 476 | "The craters the terrain's layers apply over `grid` (by default its first generated " | ||
| 477 | "grid) at `spacing` metres (0 for the grid's).") | ||
| 478 | 44 | .def( | |
| 479 | "placed_boulders", | ||
| 480 | 46 | [](const GeneratedTerrain& t, std::optional<RasterGrid> grid, double spacing) { | |
| 481 | 2 | return PlacedBoulders(t.terrain, GeneratedGridOr(t, grid), spacing); | |
| 482 | }, | ||
| 483 | 132 | "grid"_a = nb::none(), "spacing"_a = 0.0, | |
| 484 | "The boulders the terrain's layers place over `grid` (by default its first generated " | ||
| 485 | "grid) at `spacing` metres (0 for the grid's), and the models among them.") | ||
| 486 | 44 | .def_ro("seconds", &GeneratedTerrain::seconds, | |
| 487 | "Wall-clock time the heights and albedo took to evaluate, in seconds.") | ||
| 488 | 44 | .def_ro("backend", &GeneratedTerrain::backend, | |
| 489 | "Where the heights were evaluated: CPU or a GPU backend.") | ||
| 490 | 88 | .def("summary", &Summary, "One line describing the terrain: its size and time."); | |
| 491 | 44 | } | |
| 492 | |||
| 493 | 44 | void BindRun(nb::module_& m) { | |
| 494 | 44 | m.def( | |
| 495 | "generate_terrain", | ||
| 496 | ✗ | [](const TerrainConfig& config, const OptionalPath& directory, unsigned threads, | |
| 497 | std::optional<Backend> backend) { | ||
| 498 | 12 | return GenerateTerrain(config, Directory(directory), threads, backend); | |
| 499 | }, | ||
| 500 | 220 | "config"_a, "directory"_a = nb::none(), "threads"_a = 0, "backend"_a = nb::none(), | |
| 501 | 44 | nb::call_guard<nb::gil_scoped_release>(), | |
| 502 | "Generates the terrain `config` describes over each grid its outputs cover, on up to " | ||
| 503 | "`threads` threads (0 for every hardware thread; the result does not depend on it), " | ||
| 504 | "evaluating the heights on `backend` (None for default_backend()). " | ||
| 505 | "Relative input " | ||
| 506 | "paths resolve against `directory`. Raises ConfigError for an invalid configuration."); | ||
| 507 | 44 | m.def( | |
| 508 | "build_terrain", | ||
| 509 | ✗ | [](const TerrainConfig& config, const OptionalPath& directory) { | |
| 510 | 5 | return FromTerrain(BuildTerrain(config, Directory(directory))); | |
| 511 | }, | ||
| 512 | 132 | "config"_a, "directory"_a = nb::none(), | |
| 513 | "The Terrain a configuration describes, as library objects to evaluate, tile, query at " | ||
| 514 | "points or change; relative input paths resolve against `directory`."); | ||
| 515 | 44 | m.def( | |
| 516 | "build_albedo", | ||
| 517 | ✗ | [](const TerrainConfig& config, const PyTerrain& terrain, const OptionalPath& directory) { | |
| 518 | 1 | return FromAlbedo(BuildAlbedo(config, terrain.Get(), Directory(directory))); | |
| 519 | }, | ||
| 520 | 132 | "config"_a, "terrain"_a, "directory"_a = nb::none(), | |
| 521 | "The Albedo a configuration describes for `terrain`, built from the same configuration " | ||
| 522 | "by `build_terrain`."); | ||
| 523 | 44 | m.def( | |
| 524 | "write_outputs", | ||
| 525 | ✗ | [](const TerrainConfig& config, const GeneratedTerrain& terrain, | |
| 526 | const std::filesystem::path& directory) { | ||
| 527 | 2 | std::vector<std::string> paths; | |
| 528 | 7 | for (const auto& path : WriteOutputs(config, terrain, directory)) { | |
| 529 | 5 | paths.push_back(path.string()); | |
| 530 | 2 | } | |
| 531 | 2 | return paths; | |
| 532 | ✗ | }, | |
| 533 | 44 | "config"_a, "terrain"_a, "directory"_a, nb::call_guard<nb::gil_scoped_release>(), | |
| 534 | "Writes `config`'s outputs for `terrain`, resolving relative paths against `directory`, " | ||
| 535 | "and returns the paths written."); | ||
| 536 | 44 | } | |
| 537 | |||
| 538 | } // namespace | ||
| 539 | |||
| 540 | 44 | void BindPipeline(nb::module_& m) { | |
| 541 | 44 | BindTerrainClass(m); | |
| 542 | 44 | BindFeatures(m); | |
| 543 | 44 | BindEvaluation(m); | |
| 544 | 44 | BindTiles(m); | |
| 545 | 44 | BindConfig(m); | |
| 546 | 44 | BindGeneratedTerrain(m); | |
| 547 | 44 | BindRun(m); | |
| 548 | 44 | } | |
| 549 | |||
| 550 | } // namespace terraingen::python | ||
| 551 |