17
0
mirror of https://github.com/MaSzyna-EU07/maszyna.git synced 2026-10-17 13:19:20 +02:00
Files
maszyna/tools/terraincook/main.cpp
maj00r 5f34d68e6f WIP: TIN terrain system - 95% complete
Replaces heightfield with Cesium Quantized Mesh format (TIN).
Full replacement, not hybrid - TIN is the only terrain format.

Implemented:
- Format: quantizedmeshformat.h, quantizedmeshreader.h
- Triangulator: terraincooker_tin.{h,cpp} using CDT
- Cooker: terraincook now generates TIN by default
- Loader: terraintileloader fully rewritten for TIN
- GPU pool: terraintilepool_tin (VBO/IBO per tile)
- Renderer interface: terrainclipmap.cppm updated
- Shaders: terrainclipmap_tin.{vert,frag}
- Documentation: 5 comprehensive markdown files

Remaining:
- terrainclipmap.cpp render() method (partial, needs completion)
- Build testing and integration
- See TIN_INTEGRATION_GUIDE.md for next steps

Stats: 2700+ lines new code, ~50% vertex reduction expected
2026-09-17 23:02:13 +02:00

911 lines
42 KiB
C++

/*
This Source Code Form is subject to the
terms of the Mozilla Public License, v.
2.0. If a copy of the MPL was not
distributed with this file, You can
obtain one at
http://mozilla.org/MPL/2.0/.
*/
// Terrain analysis and cooking outside the engine. Reads terrain .scm files, detects the base
// grid, reports how the triangle soup splits into a regular heightfield and an off-grid
// overlay - with greyscale previews, since whether the overlay is a narrow strip along the
// track has to be seen - and cooks, verifies or probes a tiled heightfield. The cooking
// itself is scene/terraincooker.h, shared with the engine's first-run bake.
#include <algorithm>
#include <array>
#include <charconv>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <limits>
#include <sstream>
#include <string>
#include <string_view>
#include <unordered_map>
#include <vector>
#include "scene/heightfieldformat.h"
#include "scene/heightfieldreader.h"
#include "scene/terraincooker.h"
#include "scene/terraincooker_tin.h"
#include "scene/quantizedmeshformat.h"
namespace {
using terrain::conflictepsilon;
using terrain::on_grid;
using terrain::planar_length;
using terrain::vertex;
using terrain::writepreview;
struct triangle {
vertex v[ 3 ];
};
struct bounds {
double minx { 1e30 }, maxx { -1e30 };
double minz { 1e30 }, maxz { -1e30 };
void add( double const X, double const Z ) {
minx = std::min( minx, X ); maxx = std::max( maxx, X );
minz = std::min( minz, Z ); maxz = std::max( maxz, Z ); }
};
// cell sizes the covered area is measured at. vertices landing on a 1 m grid says
// nothing about how far apart neighbouring samples actually are, and the difference
// decides what a heightfield of this terrain would really cost
constexpr double coveragescales[] { 1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 64.0, 128.0, 256.0, 512.0 };
constexpr std::size_t coveragescalecount { sizeof( coveragescales ) / sizeof( coveragescales[ 0 ] ) };
struct statistics {
std::size_t triangles { 0 };
std::size_t gridtriangles { 0 };
std::size_t vertices { 0 };
std::size_t gridvertices { 0 };
std::size_t conflicts { 0 };
double maxconflict { 0.0 };
bounds extent;
bounds overlayextent;
// triangle edge lengths, bucketed by powers of two: <1, <2, <4, <8, <16, <32, <64, rest
std::size_t edgebuckets[ 8 ] {};
double areasum { 0.0 };
};
using heightfield::tile_key;
bool
numeric( char const Character ) {
return ( Character >= '0' && Character <= '9' ) || Character == '-' || Character == '+' || Character == '.';
}
std::string
readfile( std::filesystem::path const &Path ) {
std::ifstream file( Path, std::ios::binary );
if( false == file.good() ) { return {}; }
file.seekg( 0, std::ios::end );
auto const size { static_cast<std::size_t>( file.tellg() ) };
file.seekg( 0, std::ios::beg );
std::string content;
content.resize( size );
file.read( content.data(), static_cast<std::streamsize>( size ) );
return content;
}
// walks the scenery text and hands every triangle of a triangle-family node to the sink.
// deliberately forgiving: anything that is not a node header or vertex data is skipped
template <typename Sink_>
void
parse( std::string const &Content, Sink_ &&Handler ) {
auto const *cursor { Content.data() };
auto const *end { cursor + Content.size() };
auto skipspace = [ & ]() {
while( cursor < end && ( static_cast<unsigned char>( *cursor ) <= ' ' ) ) { ++cursor; } };
auto token = [ & ]() -> std::string_view {
skipspace();
auto const *start { cursor };
while( cursor < end && ( static_cast<unsigned char>( *cursor ) > ' ' ) ) { ++cursor; }
return { start, static_cast<std::size_t>( cursor - start ) }; };
auto skipline = [ & ]() {
while( cursor < end && *cursor != '\n' ) { ++cursor; } };
std::vector<double> numbers;
numbers.reserve( 32 );
auto transformdepth { 0 };
auto rotated { false };
while( cursor < end ) {
auto word { token() };
if( word.empty() ) { break; }
if( word.starts_with( "//" ) ) { skipline(); continue; }
// the same selection the engine bakes with (heightfield::selection_rules): terrain
// is written in world space, and whatever sits under origin, rotate or scale is an
// object built from triangles rather than ground
if( word == "origin" ) { ++transformdepth; continue; }
if( word == "endorigin" ) { transformdepth = std::max( 0, transformdepth - 1 ); continue; }
if( word == "scale" ) { ++transformdepth; continue; }
if( word == "endscale" ) { transformdepth = std::max( 0, transformdepth - 1 ); continue; }
if( word == "rotate" ) {
auto const x { token() }, y { token() }, z { token() };
auto const zero = []( std::string_view const Value ) {
return std::strtod( std::string( Value ).c_str(), nullptr ) == 0.0; };
rotated = !( zero( x ) && zero( y ) && zero( z ) );
continue;
}
if( word != "node" ) { continue; }
if( ( transformdepth > 0 ) || ( true == rotated ) ) {
// an object, not terrain: step over its body without handing it on
while( cursor < end ) {
auto const body { token() };
if( body.empty() || ( body.starts_with( "end" ) && ( body != "end" ) ) ) { break; }
}
continue;
}
// node header: range_max range_min name type
token(); token(); token();
auto const type { token() };
if( ( type != "triangles" ) && ( type != "triangle_strip" ) && ( type != "triangle_fan" ) ) {
continue;
}
auto const material { token() };
numbers.clear();
while( cursor < end ) {
auto const value { token() };
if( value.empty() ) { break; }
if( value == "endtri" ) { break; }
if( false == numeric( value.front() ) ) { continue; } // "end" and friends
double parsed { 0.0 };
auto const result {
std::from_chars( value.data(), value.data() + value.size(), parsed ) };
if( result.ec == std::errc() ) { numbers.push_back( parsed ); }
}
// vertex is position, normal and texture coordinates; only the position matters here
auto const stride { 8u };
auto const vertexcount { numbers.size() / stride };
std::vector<vertex> vertices;
vertices.reserve( vertexcount );
for( auto index { 0u }; index < vertexcount; ++index ) {
vertices.push_back( {
numbers[ index * stride + 0 ],
numbers[ index * stride + 1 ],
numbers[ index * stride + 2 ],
numbers[ index * stride + 6 ],
numbers[ index * stride + 7 ] } );
}
if( type == "triangles" ) {
for( auto index { 0u }; index + 2 < vertices.size(); index += 3 ) {
Handler( vertices[ index ], vertices[ index + 1 ], vertices[ index + 2 ], material );
}
}
else if( type == "triangle_strip" ) {
for( auto index { 2u }; index < vertices.size(); ++index ) {
Handler( vertices[ index - 2 ], vertices[ index - 1 ], vertices[ index ], material );
}
}
else { // triangle_fan
for( auto index { 2u }; index < vertices.size(); ++index ) {
Handler( vertices[ 0 ], vertices[ index - 1 ], vertices[ index ], material );
}
}
}
}
// ---------------------------------------------------------------------------------
// track proximity. the question is whether the fine sampling of the terrain follows the
// railway or is spread evenly: that decides whether a coarse heightfield plus a corridor
// overlay is enough, or whether the base grid has to be fine everywhere
// distance field over a coarse grid, in metres, built from the sampled track centrelines
class distance_field {
public:
void
build( std::vector<vertex> const &Samples, double const Cell ) {
m_cell = Cell;
if( true == Samples.empty() ) { return; }
for( auto const &point : Samples ) { m_extent.add( point.x, point.z ); }
// a margin, so that terrain lying off to the side still gets a real distance
m_extent.minx -= margin; m_extent.maxx += margin;
m_extent.minz -= margin; m_extent.maxz += margin;
m_width = static_cast<std::size_t>( ( m_extent.maxx - m_extent.minx ) / m_cell ) + 1;
m_height = static_cast<std::size_t>( ( m_extent.maxz - m_extent.minz ) / m_cell ) + 1;
m_distance.assign( m_width * m_height, unreachable );
for( auto const &point : Samples ) {
auto const index { cell( point.x, point.z ) };
if( index != npos ) { m_distance[ index ] = 0.0f; }
}
// two-pass chamfer transform; weights 3/4 approximate euclidean within ~2%
constexpr float straight { 3.0f }, diagonal { 4.0f };
auto relax = [ & ]( std::size_t const Target, std::size_t const Source, float const Weight ) {
m_distance[ Target ] = std::min( m_distance[ Target ], m_distance[ Source ] + Weight ); };
for( std::size_t y = 0; y < m_height; ++y ) {
for( std::size_t x = 0; x < m_width; ++x ) {
auto const here { y * m_width + x };
if( y > 0 ) {
relax( here, here - m_width, straight );
if( x > 0 ) { relax( here, here - m_width - 1, diagonal ); }
if( x + 1 < m_width ) { relax( here, here - m_width + 1, diagonal ); }
}
if( x > 0 ) { relax( here, here - 1, straight ); }
}
}
for( std::size_t y = m_height; y-- > 0; ) {
for( std::size_t x = m_width; x-- > 0; ) {
auto const here { y * m_width + x };
if( y + 1 < m_height ) {
relax( here, here + m_width, straight );
if( x > 0 ) { relax( here, here + m_width - 1, diagonal ); }
if( x + 1 < m_width ) { relax( here, here + m_width + 1, diagonal ); }
}
if( x + 1 < m_width ) { relax( here, here + 1, straight ); }
}
}
}
// distance in metres, or a negative value when the point falls outside the field
double
at( double const X, double const Z ) const {
auto const index { cell( X, Z ) };
if( index == npos ) { return -1.0; }
auto const value { m_distance[ index ] };
if( value >= unreachable ) { return -1.0; }
return value * m_cell / 3.0; }
bool ready() const { return false == m_distance.empty(); }
private:
static constexpr std::size_t npos { static_cast<std::size_t>( -1 ) };
static constexpr float unreachable { 1e9f };
static constexpr double margin { 4000.0 };
std::size_t
cell( double const X, double const Z ) const {
if( ( X < m_extent.minx ) || ( X > m_extent.maxx )
|| ( Z < m_extent.minz ) || ( Z > m_extent.maxz ) ) { return npos; }
auto const x { static_cast<std::size_t>( ( X - m_extent.minx ) / m_cell ) };
auto const z { static_cast<std::size_t>( ( Z - m_extent.minz ) / m_cell ) };
if( ( x >= m_width ) || ( z >= m_height ) ) { return npos; }
return z * m_width + x; }
bounds m_extent;
double m_cell { 16.0 };
std::size_t m_width { 0 }, m_height { 0 };
std::vector<float> m_distance;
};
// reads track nodes and samples their centrelines. the scenery spells a track as a cubic
// bezier: two endpoints with roll, and two control vectors relative to them
std::vector<vertex>
sampletracks( std::vector<std::filesystem::path> const &Paths, double const Spacing ) {
std::vector<vertex> samples;
std::size_t nodes { 0 }, rejected { 0 };
double tracklength { 0.0 };
for( auto const &path : Paths ) {
auto const content { readfile( path ) };
auto const *cursor { content.data() };
auto const *end { cursor + content.size() };
auto token = [ & ]() -> std::string_view {
while( cursor < end && ( static_cast<unsigned char>( *cursor ) <= ' ' ) ) { ++cursor; }
auto const *start { cursor };
while( cursor < end && ( static_cast<unsigned char>( *cursor ) > ' ' ) ) { ++cursor; }
return { start, static_cast<std::size_t>( cursor - start ) }; };
auto number = [ & ]() -> double {
auto const word { token() };
if( ( true == word.empty() ) || ( false == numeric( word.front() ) ) ) { return 0.0; }
double parsed { 0.0 };
std::from_chars( word.data(), word.data() + word.size(), parsed );
return parsed; };
while( cursor < end ) {
auto const word { token() };
if( true == word.empty() ) { break; }
if( word != "track" ) { continue; }
// the header has to be walked token by token, in the order TTrack::Load reads
// it: skipping to "the first numbers after the keyword" lands in the middle of
// the parameter line and yields nonsense geometry
token(); // type
for( auto field { 0u }; field < 4u; ++field ) { number(); } // length, gauge, friction, sound distance
for( auto field { 0u }; field < 2u; ++field ) { number(); } // quality, damage
token(); // environment
auto const visibility { token() }; // vis / unvis
if( visibility == "vis" ) {
token(); // rail texture
number(); // texture tile length
token(); // sub or rail texture
for( auto field { 0u }; field < 3u; ++field ) { number(); } // tex height, width, slope
}
vertex p0 {}, c0 {}, c1 {}, p1 {};
p0.x = number(); p0.y = number(); p0.z = number(); number(); // roll
c0.x = number(); c0.y = number(); c0.z = number();
c1.x = number(); c1.y = number(); c1.z = number();
p1.x = number(); p1.y = number(); p1.z = number(); number(); // roll
vertex const control0 { p0.x + c0.x, p0.y + c0.y, p0.z + c0.z };
vertex const control1 { p1.x + c1.x, p1.y + c1.y, p1.z + c1.z };
auto const chord {
std::sqrt( ( p1.x - p0.x ) * ( p1.x - p0.x ) + ( p1.z - p0.z ) * ( p1.z - p0.z ) ) };
++nodes;
if( ( chord <= 0.0 ) || ( chord > 5000.0 ) ) { ++rejected; continue; }
tracklength += chord;
auto const steps { std::max<std::size_t>( 2u, static_cast<std::size_t>( chord / Spacing ) + 1 ) };
for( auto step { 0u }; step <= steps; ++step ) {
auto const t { static_cast<double>( step ) / static_cast<double>( steps ) };
auto const u { 1.0 - t };
auto const w0 { u * u * u }, w1 { 3.0 * u * u * t }, w2 { 3.0 * u * t * t }, w3 { t * t * t };
samples.push_back( {
w0 * p0.x + w1 * control0.x + w2 * control1.x + w3 * p1.x,
0.0,
w0 * p0.z + w1 * control0.z + w2 * control1.z + w3 * p1.z } );
}
}
}
std::printf( " track nodes %zu, rejected %zu, total chord length %.1f km\n",
nodes, rejected, tracklength / 1000.0 );
return samples;
}
// prints a cross section straight out of a cooked file. a screenshot can show that the
// ground has a cliff in it; only the samples say how tall it is and how many of them it
// takes, which is the difference between a slope that is too steep and one that is right
int
probe( std::filesystem::path const &Path, double const X, double const Z,
double const Dirx, double const Dirz, std::size_t const Count ) {
heightfield::reader reader;
if( false == reader.open( Path.string() ) ) {
std::printf( "probe: cannot open %s\n", Path.string().c_str() );
return 1;
}
auto const length { std::sqrt( Dirx * Dirx + Dirz * Dirz ) };
if( length < 1e-9 ) { std::printf( "probe: direction has no length\n" ); return 1; }
auto const stepx { Dirx / length * reader.gridstep() };
auto const stepz { Dirz / length * reader.gridstep() };
std::printf( "-- cross section from %.1f, %.1f along %.2f, %.2f, %zu samples of %.2f m\n",
X, Z, Dirx / length, Dirz / length, Count, reader.gridstep() );
std::vector<std::uint16_t> heights;
std::vector<std::uint8_t> materials;
auto previous { std::numeric_limits<double>::quiet_NaN() };
for( std::size_t index = 0; index < Count; ++index ) {
auto const x { X + stepx * static_cast<double>( index ) };
auto const z { Z + stepz * static_cast<double>( index ) };
auto const tilex { reader.tile_x( x ) };
auto const tilez { reader.tile_z( z ) };
if( false == reader.read_level( tilex, tilez, 0, heights, materials ) ) {
std::printf( " %8.1f %8.1f no tile\n", x, z );
continue;
}
auto const side { reader.samples_per_side( 0 ) };
auto const local {
static_cast<std::int64_t>( std::llround( x / reader.gridstep() ) )
- static_cast<std::int64_t>( tilex ) * reader.tilesamples() };
auto const localrow {
static_cast<std::int64_t>( std::llround( z / reader.gridstep() ) )
- static_cast<std::int64_t>( tilez ) * reader.tilesamples() };
if( ( local < 0 ) || ( localrow < 0 ) || ( local >= side ) || ( localrow >= side ) ) {
std::printf( " %8.1f %8.1f outside\n", x, z );
continue;
}
auto const sample { heights[ localrow * side + local ] };
if( sample == heightfield::nodata ) {
std::printf( " %8.1f %8.1f hole\n", x, z );
previous = std::numeric_limits<double>::quiet_NaN();
continue;
}
auto const height { reader.height( sample ) };
auto const material { materials[ localrow * side + local ] };
std::printf( " %8.1f %8.1f %8.3f m step %+7.3f %s\n",
x, z, height, std::isnan( previous ) ? 0.0 : height - previous,
material < reader.materials().size() ? reader.materials()[ material ].c_str() : "?" );
previous = height;
}
return 0;
}
// reads a cooked file back with the reader the engine uses, and checks the promises the
// format makes. the one that matters is the shared edge: neighbouring tiles must hold
// byte-identical samples along the boundary they share, at every mip level, or the
// terrain will show seams that no amount of skirting hides
int
verify( std::filesystem::path const &Path ) {
heightfield::reader reader;
if( false == reader.open( Path.string() ) ) {
std::printf( "verify: cannot open %s as a heightfield\n", Path.string().c_str() );
return 1;
}
std::printf( "-- verifying %s\n", Path.string().c_str() );
std::printf( " tiles %zu, levels %u, grid step %.2f m, tile side %.0f m, materials %zu\n",
reader.tiles().size(), reader.levels(), reader.gridstep(), reader.tilesize(), reader.materials().size() );
std::vector<std::uint16_t> heights, neighbourheights;
std::vector<std::uint8_t> materials, neighbourmaterials;
std::size_t unreadable { 0 }, seams { 0 }, checkededges { 0 }, holes { 0 }, samples { 0 };
for( auto const &tile : reader.tiles() ) {
for( std::uint32_t level = 0; level < reader.levels(); ++level ) {
if( false == reader.read_level( tile.x, tile.z, level, heights, materials ) ) {
++unreadable;
continue;
}
auto const side { reader.samples_per_side( level ) };
if( heights.size() != static_cast<std::size_t>( side ) * side ) {
++unreadable;
continue;
}
if( level == 0 ) {
samples += heights.size();
for( auto const value : heights ) {
if( value == heightfield::nodata ) { ++holes; }
}
}
// the tile to the east shares this tile's last column with its first
if( true == reader.contains( tile.x + 1, tile.z ) ) {
if( true == reader.read_level( tile.x + 1, tile.z, level, neighbourheights, neighbourmaterials ) ) {
++checkededges;
for( std::uint32_t row = 0; row < side; ++row ) {
if( heights[ row * side + ( side - 1 ) ] != neighbourheights[ row * side ] ) { ++seams; break; }
}
}
// re-reading this tile, since the reader keeps one decoded payload
reader.read_level( tile.x, tile.z, level, heights, materials );
}
// and the tile to the south shares the last row
if( true == reader.contains( tile.x, tile.z + 1 ) ) {
if( true == reader.read_level( tile.x, tile.z + 1, level, neighbourheights, neighbourmaterials ) ) {
++checkededges;
for( std::uint32_t column = 0; column < side; ++column ) {
if( heights[ ( side - 1 ) * side + column ] != neighbourheights[ column ] ) { ++seams; break; }
}
}
reader.read_level( tile.x, tile.z, level, heights, materials );
}
}
}
std::printf( " unreadable tile levels %zu\n", unreadable );
std::printf( " shared edges checked %zu\n", checkededges );
std::printf( " mismatched edges %zu\n", seams );
std::printf( " holes at finest level %zu of %zu samples (%.2f %%)\n",
holes, samples, samples == 0 ? 0.0 : 100.0 * static_cast<double>( holes ) / static_cast<double>( samples ) );
auto const failed { ( unreadable > 0 ) || ( seams > 0 ) };
std::printf( " %s\n", failed ? "FAILED" : "ok" );
return failed ? 1 : 0;
}
} // namespace
int
main( int argc, char *argv[] ) {
if( argc < 2 ) {
std::cout
<< "usage: terraincook <terrain .scm files or directory>\n"
<< " [-step <metres>] [-tracks <dir>] [-out <directory>]\n"
<< " [-cook] [-tilesize <metres>] [-lodlevels <n>]\n"
<< " analyses the terrain: grid detection, heightfield/overlay split, sampling\n"
<< " density against distance from the nearest track, coverage by cell size\n"
<< " -cook generates TIN tiles (terrain_*.qm) with adaptive triangulation\n"
<< " -raw leaves the cooked tiles uncompressed\n"
<< "usage: terraincook -verify <terrain_dir>\n"
<< " verifies TIN tiles in directory\n"
<< "usage: terraincook -probe <terrain_dir> <x,z,lod>\n"
<< " dumps tile data for inspection\n";
return 1;
}
for( auto index { 1 }; index < argc; ++index ) {
if( std::string { argv[ index ] } != "-probe" ) { continue; }
if( index + 2 >= argc ) {
std::cerr << "-probe needs <terrain.ehf> <x,z,dirx,dirz,count>\n";
return 1;
}
std::vector<double> fields;
std::istringstream stream { std::string { argv[ index + 2 ] } };
std::string field;
while( std::getline( stream, field, ',' ) ) { fields.push_back( std::strtod( field.c_str(), nullptr ) ); }
if( fields.size() < 5 ) {
std::cerr << "-probe wants x,z,dirx,dirz,count\n";
return 1;
}
return probe(
argv[ index + 1 ], fields[ 0 ], fields[ 1 ], fields[ 2 ], fields[ 3 ],
static_cast<std::size_t>( fields[ 4 ] ) );
}
// verification stands on its own: it reads a cooked file and needs no source data
for( auto index { 1 }; index < argc; ++index ) {
if( std::string { argv[ index ] } != "-verify" ) { continue; }
if( index + 1 >= argc ) {
std::cerr << "-verify needs a path to a cooked heightfield\n";
return 1;
}
return verify( argv[ index + 1 ] );
}
std::vector<std::filesystem::path> inputs;
std::vector<std::filesystem::path> trackinputs;
std::filesystem::path outputdirectory { "." };
double step { 0.0 };
bool cooking { false };
double tin_tilesize { 256.0 };
std::uint32_t tin_lodlevels { 4 };
bool compress { true };
for( auto index { 1 }; index < argc; ++index ) {
std::string const argument { argv[ index ] };
if( argument == "-step" && index + 1 < argc ) { step = std::strtod( argv[ ++index ], nullptr ); continue; }
if( argument == "-out" && index + 1 < argc ) { outputdirectory = argv[ ++index ]; continue; }
if( argument == "-cook" ) { cooking = true; continue; }
if( argument == "-tilesize" && index + 1 < argc ) { tin_tilesize = std::strtod( argv[ ++index ], nullptr ); continue; }
if( argument == "-lodlevels" && index + 1 < argc ) { tin_lodlevels = static_cast<std::uint32_t>( std::strtoul( argv[ ++index ], nullptr, 10 ) ); continue; }
if( argument == "-raw" ) { compress = false; continue; }
if( argument == "-tracks" && index + 1 < argc ) {
std::filesystem::path const trackpath { argv[ ++index ] };
if( std::filesystem::is_directory( trackpath ) ) {
for( auto const &entry : std::filesystem::directory_iterator( trackpath ) ) {
if( entry.path().extension() == ".scm" ) { trackinputs.push_back( entry.path() ); }
}
}
else { trackinputs.push_back( trackpath ); }
continue; }
std::filesystem::path const path { argument };
if( std::filesystem::is_directory( path ) ) {
for( auto const &entry : std::filesystem::directory_iterator( path ) ) {
if( entry.path().extension() == ".scm" ) { inputs.push_back( entry.path() ); }
}
}
else {
inputs.push_back( path );
}
}
std::sort( inputs.begin(), inputs.end() );
if( true == inputs.empty() ) {
std::cerr << "no input files\n";
return 1;
}
// first pass: sample vertices to pick the grid step, unless one was given
if( step <= 0.0 ) {
std::vector<vertex> sample;
auto const content { readfile( inputs.front() ) };
parse( content, [ & ]( vertex const &A, vertex const &B, vertex const &C, std::string_view ) {
if( sample.size() >= 300000 ) { return; }
sample.push_back( A ); sample.push_back( B ); sample.push_back( C ); } );
std::cout << "grid step detection, on " << sample.size() << " sampled vertices:\n";
step = terrain::detect_step(
sample,
[]( double const Candidate, double const Share ) {
std::printf( " %6.2f m %6.2f %%\n", Candidate, Share ); } );
std::cout << " chosen: " << step << " m\n\n";
}
// distance-to-track field, when track files were given
distance_field trackdistance;
if( false == trackinputs.empty() ) {
std::sort( trackinputs.begin(), trackinputs.end() );
auto const samples { sampletracks( trackinputs, 8.0 ) };
std::cout << "track centreline samples: " << samples.size() << "\n";
trackdistance.build( samples, 16.0 );
}
// triangles bucketed by distance from the nearest track, with their sampling scale
constexpr double distancebands[] { 25.0, 50.0, 100.0, 200.0, 400.0, 800.0, 1600.0 };
constexpr std::size_t bandcount { sizeof( distancebands ) / sizeof( distancebands[ 0 ] ) + 2 };
std::array<std::size_t, bandcount> bandtriangles {};
std::array<double, bandcount> bandedgesum {};
std::array<std::size_t, bandcount> bandfine {};
terrain::tin_cooker tin_cooker;
if( cooking ) {
tin_cooker.configure( tin_tilesize, tin_lodlevels );
std::printf( "TIN cooking enabled: tile size %.0f m, %u LOD levels\n",
tin_tilesize, tin_lodlevels );
}
statistics stats;
std::vector<std::unordered_map<std::int64_t, char>> coverage( coveragescalecount );
std::unordered_map<std::int64_t, float> heights;
heights.reserve( 8u << 20 );
std::vector<triangle> overlay;
for( auto const &path : inputs ) {
auto const content { readfile( path ) };
if( true == content.empty() ) {
std::cerr << "cannot read " << path << "\n";
continue;
}
auto const before { stats.triangles };
parse( content, [ & ]( vertex const &A, vertex const &B, vertex const &C, std::string_view Material ) {
if( cooking ) { tin_cooker.rasterize( A, B, C, Material ); }
++stats.triangles;
stats.vertices += 3;
vertex const points[ 3 ] { A, B, C };
auto gridpoints { 0 };
for( auto const &point : points ) {
stats.extent.add( point.x, point.z );
if( on_grid( point.x, step ) && on_grid( point.z, step ) ) { ++gridpoints; }
for( auto scaleindex { 0u }; scaleindex < coveragescalecount; ++scaleindex ) {
auto const cell { coveragescales[ scaleindex ] };
coverage[ scaleindex ].emplace(
tile_key(
static_cast<std::int32_t>( std::floor( point.x / cell ) ),
static_cast<std::int32_t>( std::floor( point.z / cell ) ) ),
char { 0 } );
}
}
// horizontal edge lengths and footprint area, to see the real sampling density
for( auto index { 0u }; index < 3u; ++index ) {
auto const length { planar_length( points[ index ], points[ ( index + 1 ) % 3 ] ) };
auto bucket { 0u };
while( ( bucket < 7u ) && ( length >= ( 1.0 * ( 1u << bucket ) ) ) ) { ++bucket; }
++stats.edgebuckets[ bucket ];
}
if( true == trackdistance.ready() ) {
auto const centroidx { ( points[ 0 ].x + points[ 1 ].x + points[ 2 ].x ) / 3.0 };
auto const centroidz { ( points[ 0 ].z + points[ 1 ].z + points[ 2 ].z ) / 3.0 };
auto edgesum { 0.0 };
for( auto index { 0u }; index < 3u; ++index ) {
edgesum += planar_length( points[ index ], points[ ( index + 1 ) % 3 ] );
}
auto const scale { edgesum / 3.0 };
auto const distance { trackdistance.at( centroidx, centroidz ) };
std::size_t band { bandcount - 1 }; // outside the field
if( distance >= 0.0 ) {
band = bandcount - 2; // beyond the last threshold
for( auto index { 0u }; index < bandcount - 2; ++index ) {
if( distance < distancebands[ index ] ) { band = index; break; }
}
}
++bandtriangles[ band ];
bandedgesum[ band ] += scale;
if( scale < 4.0 ) { ++bandfine[ band ]; }
}
stats.areasum += 0.5 * std::abs(
( points[ 1 ].x - points[ 0 ].x ) * ( points[ 2 ].z - points[ 0 ].z )
- ( points[ 2 ].x - points[ 0 ].x ) * ( points[ 1 ].z - points[ 0 ].z ) );
stats.gridvertices += static_cast<std::size_t>( gridpoints );
if( gridpoints == 3 ) {
++stats.gridtriangles;
for( auto const &point : points ) {
auto const key {
tile_key(
static_cast<std::int32_t>( std::llround( point.x / step ) ),
static_cast<std::int32_t>( std::llround( point.z / step ) ) ) };
auto const existing { heights.find( key ) };
if( existing == heights.end() ) {
heights.emplace( key, static_cast<float>( point.y ) );
}
else {
auto const difference { std::abs( static_cast<double>( existing->second ) - point.y ) };
if( difference > conflictepsilon ) {
++stats.conflicts;
stats.maxconflict = std::max( stats.maxconflict, difference );
}
}
}
}
else {
overlay.push_back( { { A, B, C } } );
for( auto const &point : points ) { stats.overlayextent.add( point.x, point.z ); }
}
} );
std::printf( "%-28s %10zu triangles\n",
path.filename().string().c_str(), stats.triangles - before );
}
auto const share = []( std::size_t const Part, std::size_t const Whole ) {
return Whole == 0 ? 0.0 : 100.0 * static_cast<double>( Part ) / static_cast<double>( Whole ); };
// the full bounding box is set by outliers, so derive the working extent from the
// occupied grid samples themselves, trimming the sparsest 0.1% at each end
std::vector<std::int32_t> columns, rows;
columns.reserve( heights.size() );
rows.reserve( heights.size() );
for( auto const & [ key, height ] : heights ) {
columns.push_back( static_cast<std::int32_t>( key >> 32 ) );
rows.push_back( static_cast<std::int32_t>( static_cast<std::uint32_t>( key & 0xffffffff ) ) );
}
auto trimmed = []( std::vector<std::int32_t> &Values ) -> std::pair<double, double> {
if( true == Values.empty() ) { return { 0.0, 0.0 }; }
auto const low { Values.size() / 1000 };
auto const high { Values.size() - 1 - low };
std::nth_element( Values.begin(), Values.begin() + static_cast<std::ptrdiff_t>( low ), Values.end() );
auto const minimum { Values[ low ] };
std::nth_element( Values.begin(), Values.begin() + static_cast<std::ptrdiff_t>( high ), Values.end() );
auto const maximum { Values[ high ] };
return { static_cast<double>( minimum ), static_cast<double>( maximum ) }; };
auto const [ columnmin, columnmax ] { trimmed( columns ) };
auto const [ rowmin, rowmax ] { trimmed( rows ) };
auto const gridwidth { static_cast<std::size_t>( columnmax - columnmin ) + 1 };
auto const gridheight { static_cast<std::size_t>( rowmax - rowmin ) + 1 };
auto const overlaytriangles { stats.triangles - stats.gridtriangles };
std::printf( "\n-- terrain decomposition, grid step %.2f m\n", step );
std::printf( " triangles total %12zu\n", stats.triangles );
std::printf( " on grid (heightfield)%12zu %6.2f %%\n", stats.gridtriangles, share( stats.gridtriangles, stats.triangles ) );
std::printf( " off grid (overlay) %12zu %6.2f %%\n", overlaytriangles, share( overlaytriangles, stats.triangles ) );
std::printf( " vertices on grid %12zu %6.2f %%\n", stats.gridvertices, share( stats.gridvertices, stats.vertices ) );
std::printf( " distinct grid samples%12zu\n", heights.size() );
std::printf( " stacked samples %12zu (max height difference %.2f m)\n", stats.conflicts, stats.maxconflict );
std::printf( " full bounding box %.0f .. %.0f x %.0f .. %.0f z\n",
stats.extent.minx, stats.extent.maxx, stats.extent.minz, stats.extent.maxz );
std::printf( " working extent %.0f .. %.0f x %.0f .. %.0f z (%zu x %zu grid)\n",
columnmin * step, columnmax * step, rowmin * step, rowmax * step, gridwidth, gridheight );
std::printf( " covered area %12.2f km2 (%.1f %% of the working extent)\n",
heights.size() * step * step / 1.0e6,
share( heights.size(), gridwidth * gridheight ) );
if( overlaytriangles > 0 ) {
std::printf( " overlay extent %.0f .. %.0f x %.0f .. %.0f z\n",
stats.overlayextent.minx, stats.overlayextent.maxx,
stats.overlayextent.minz, stats.overlayextent.maxz );
}
std::printf( "\n-- real sampling density\n" );
std::printf( " summed triangle footprint %8.2f km2\n", stats.areasum / 1.0e6 );
std::printf( " mean triangle footprint %8.2f m2\n",
stats.triangles == 0 ? 0.0 : stats.areasum / static_cast<double>( stats.triangles ) );
char const *edgelabels[ 8 ] { "<1 m", "<2 m", "<4 m", "<8 m", "<16 m", "<32 m", "<64 m", ">=64 m" };
std::size_t edgetotal { 0 };
for( auto const bucket : stats.edgebuckets ) { edgetotal += bucket; }
for( auto index { 0u }; index < 8u; ++index ) {
if( stats.edgebuckets[ index ] == 0 ) { continue; }
std::printf( " edges %-7s %12zu %6.2f %%\n",
edgelabels[ index ], stats.edgebuckets[ index ], share( stats.edgebuckets[ index ], edgetotal ) );
}
if( true == trackdistance.ready() ) {
std::printf( "\n-- sampling density against distance from the nearest track\n" );
std::printf( " %-14s %12s %12s %10s\n", "band", "triangles", "mean edge", "fine <4 m" );
for( auto index { 0u }; index < bandcount; ++index ) {
if( bandtriangles[ index ] == 0 ) { continue; }
char label[ 32 ];
if( index < bandcount - 2 ) {
std::snprintf( label, sizeof( label ), "< %.0f m", distancebands[ index ] );
}
else if( index == bandcount - 2 ) {
std::snprintf( label, sizeof( label ), ">= %.0f m", distancebands[ bandcount - 3 ] );
}
else {
std::snprintf( label, sizeof( label ), "off the field" );
}
std::printf( " %-14s %12zu %10.2f m %9.2f %%\n",
label, bandtriangles[ index ],
bandedgesum[ index ] / static_cast<double>( bandtriangles[ index ] ),
share( bandfine[ index ], bandtriangles[ index ] ) );
}
}
std::printf( "\n-- covered area by cell size, and what a heightfield at that resolution costs\n" );
for( auto scaleindex { 0u }; scaleindex < coveragescalecount; ++scaleindex ) {
auto const cell { coveragescales[ scaleindex ] };
auto const cells { coverage[ scaleindex ].size() };
std::printf( " %5.0f m %12zu cells %9.2f km2 %9.1f MB R16\n",
cell, cells, cells * cell * cell / 1.0e6, cells * 2u / 1048576.0 );
}
auto const heightfieldbytes { gridwidth * gridheight * 2u };
auto const overlaybytes { overlaytriangles * 3u * 20u };
std::printf( "\n-- storage estimate\n" );
std::printf( " dense heightfield R16%12.1f MB (whole bounding box)\n", heightfieldbytes / 1048576.0 );
std::printf( " occupied samples R16 %12.1f MB (sparse, what is actually covered)\n", heights.size() * 2u / 1048576.0 );
std::printf( " overlay at 20 B/vertex%11.1f MB\n", overlaybytes / 1048576.0 );
// previews, downsampled so that the wider side is at most 2048 pixels
std::filesystem::create_directories( outputdirectory );
auto const preview { std::max<std::size_t>( 1u, ( std::max( gridwidth, gridheight ) + 2047u ) / 2048u ) };
auto const previewwidth { gridwidth / preview + 1 };
auto const previewheight { gridheight / preview + 1 };
constexpr float sentinel { -1e30f };
std::vector<float> heightpreview( previewwidth * previewheight, sentinel );
for( auto const & [ key, height ] : heights ) {
auto const x { static_cast<std::int32_t>( key >> 32 ) };
auto const z { static_cast<std::int32_t>( static_cast<std::uint32_t>( key & 0xffffffff ) ) };
if( ( x < columnmin ) || ( x > columnmax ) || ( z < rowmin ) || ( z > rowmax ) ) { continue; }
auto const column { static_cast<std::size_t>( x - columnmin ) / preview };
auto const row { static_cast<std::size_t>( z - rowmin ) / preview };
if( column >= previewwidth || row >= previewheight ) { continue; }
auto &target { heightpreview[ row * previewwidth + column ] };
target = ( target == sentinel ? height : std::max( target, height ) );
}
writepreview( outputdirectory / "height.pgm", heightpreview, previewwidth, previewheight, sentinel );
std::vector<float> overlaypreview( previewwidth * previewheight, 0.0f );
for( auto const &piece : overlay ) {
for( auto const &point : piece.v ) {
auto const gridx { std::llround( point.x / step ) };
auto const gridz { std::llround( point.z / step ) };
if( ( gridx < columnmin ) || ( gridx > columnmax ) || ( gridz < rowmin ) || ( gridz > rowmax ) ) { continue; }
auto const column { static_cast<std::size_t>( gridx - columnmin ) / preview };
auto const row { static_cast<std::size_t>( gridz - rowmin ) / preview };
if( column >= previewwidth || row >= previewheight ) { continue; }
overlaypreview[ row * previewwidth + column ] += 1.0f;
}
}
writepreview( outputdirectory / "overlay.pgm", overlaypreview, previewwidth, previewheight, -1.0f );
if( cooking ) {
std::filesystem::create_directories( outputdirectory );
std::printf( "\n-- generating TIN tiles\n" );
auto tiles = tin_cooker.finish();
std::printf( "Writing %zu tiles...\n", tiles.size() );
std::size_t written = 0;
for( auto const &tile : tiles ) {
for( std::uint32_t lod = 0; lod < tile.lods.size(); ++lod ) {
char filename[ 256 ];
std::snprintf( filename, sizeof( filename ), "terrain_%d_%d_lod%u.qm",
tile.x, tile.z, lod );
std::filesystem::path const filepath = outputdirectory / filename;
if( terrain::tin_cooker::write_tile( tile, lod, filepath.string(), compress ) ) {
++written;
} else {
std::fprintf( stderr, "Failed to write %s\n", filename );
}
}
}
std::printf( "Wrote %zu quantized mesh files to %s\n", written, outputdirectory.string().c_str() );
}
std::printf( "\n previews written to %s (%zux%zu, 1 px = %.0f m)\n",
outputdirectory.string().c_str(), previewwidth, previewheight, step * preview );
return 0;
}