[terrafit] source code format only
This commit is contained in:
parent
53cb8086b3
commit
93440e4acd
1 changed files with 163 additions and 160 deletions
|
@ -24,31 +24,31 @@
|
|||
*/
|
||||
|
||||
#include <chrono>
|
||||
#include <string>
|
||||
#include <stdio.h>
|
||||
#include <errno.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string>
|
||||
|
||||
#ifndef _MSC_VER
|
||||
# include <unistd.h>
|
||||
# ifdef __APPLE__
|
||||
# include <Prep/Terra/getopt.h>
|
||||
# else
|
||||
# include <getopt.h>
|
||||
# endif
|
||||
#include <unistd.h>
|
||||
#ifdef __APPLE__
|
||||
#include <Prep/Terra/getopt.h>
|
||||
#else
|
||||
# define S_ISDIR(a) ((a)&_S_IFDIR)
|
||||
# include <windows.h>
|
||||
# include <Prep/Terra/getopt.h>
|
||||
# define sleep(x) Sleep(x*1000)
|
||||
#include <getopt.h>
|
||||
#endif
|
||||
#else
|
||||
#define S_ISDIR(a) ((a)&_S_IFDIR)
|
||||
#include <Prep/Terra/getopt.h>
|
||||
#include <windows.h>
|
||||
#define sleep(x) Sleep(x * 1000)
|
||||
#endif
|
||||
|
||||
#include <zlib.h>
|
||||
|
||||
#include <simgear/debug/logstream.hxx>
|
||||
#include <simgear/bucket/newbucket.hxx>
|
||||
#include <simgear/misc/sg_path.hxx>
|
||||
#include <simgear/debug/logstream.hxx>
|
||||
#include <simgear/misc/sg_dir.hxx>
|
||||
#include <simgear/misc/sg_path.hxx>
|
||||
#include <simgear/structure/exception.hxx>
|
||||
#include <simgear/threads/SGQueue.hxx>
|
||||
#include <simgear/threads/SGThread.hxx>
|
||||
|
@ -59,13 +59,12 @@
|
|||
#include <Prep/Terra/Map.h>
|
||||
#include <Prep/Terra/Mask.h>
|
||||
|
||||
using std::istream;
|
||||
using simgear::Dir;
|
||||
using simgear::PathList;
|
||||
using std::istream;
|
||||
|
||||
SGLockedQueue<SGPath> global_workQueue;
|
||||
|
||||
|
||||
/*
|
||||
* Benchmark: Processing 800 individual buckets:
|
||||
* terrafit.cc: 52s 48s 48s
|
||||
|
@ -73,38 +72,44 @@ SGLockedQueue<SGPath> global_workQueue;
|
|||
*
|
||||
* terrafit.cc takes on 20% of the time that terrafit.py took!
|
||||
*/
|
||||
class ArrayMap: public Terra::Map {
|
||||
class ArrayMap : public Terra::Map {
|
||||
public:
|
||||
explicit ArrayMap(TGArray& array): array(array) {
|
||||
width=array.get_cols();
|
||||
height=array.get_rows();
|
||||
min=30000;
|
||||
max=-30000;
|
||||
for (int i=0;i<width;i++) {
|
||||
for (int j=0;j<height;j++) {
|
||||
Terra::real v=eval(i,j);
|
||||
if (v<min)
|
||||
min=v;
|
||||
if (v>max)
|
||||
max=v;
|
||||
}
|
||||
}
|
||||
depth=32;
|
||||
explicit ArrayMap(TGArray& array)
|
||||
: array(array)
|
||||
{
|
||||
width = array.get_cols();
|
||||
height = array.get_rows();
|
||||
min = 30000;
|
||||
max = -30000;
|
||||
for (int i = 0; i < width; i++) {
|
||||
for (int j = 0; j < height; j++) {
|
||||
Terra::real v = eval(i, j);
|
||||
if (v < min)
|
||||
min = v;
|
||||
if (v > max)
|
||||
max = v;
|
||||
}
|
||||
}
|
||||
depth = 32;
|
||||
}
|
||||
|
||||
virtual ~ArrayMap() {}
|
||||
virtual ~ArrayMap() {}
|
||||
|
||||
virtual Terra::real eval(int i, int j) override {
|
||||
return (Terra::real)array.get_array_elev(i,j);
|
||||
}
|
||||
virtual Terra::real eval(int i, int j) override
|
||||
{
|
||||
return (Terra::real)array.get_array_elev(i, j);
|
||||
}
|
||||
|
||||
/* No direct reading of .arr.gz files */
|
||||
virtual void rawRead(istream&)
|
||||
{
|
||||
}
|
||||
virtual void textRead(istream&)
|
||||
{
|
||||
}
|
||||
|
||||
/* No direct reading of .arr.gz files */
|
||||
virtual void rawRead(istream&) {
|
||||
}
|
||||
virtual void textRead(istream&) {
|
||||
}
|
||||
protected:
|
||||
TGArray& array;
|
||||
TGArray& array;
|
||||
};
|
||||
|
||||
static Terra::ImportMask default_mask;
|
||||
|
@ -113,61 +118,57 @@ namespace Terra {
|
|||
* don't need one...
|
||||
*/
|
||||
static Terra::ImportMask default_mask;
|
||||
Terra::ImportMask *MASK=&default_mask;
|
||||
Terra::ImportMask* MASK = &default_mask;
|
||||
}; // namespace Terra
|
||||
|
||||
Terra::real error_threshold=40.0;
|
||||
unsigned int min_points=50;
|
||||
unsigned int point_limit=1000;
|
||||
bool force=false;
|
||||
Terra::real error_threshold = 40.0;
|
||||
unsigned int min_points = 50;
|
||||
unsigned int point_limit = 1000;
|
||||
bool force = false;
|
||||
unsigned int num_threads = 1;
|
||||
|
||||
inline int goal_not_met(Terra::GreedySubdivision* mesh)
|
||||
{
|
||||
return
|
||||
( mesh->maxError() > error_threshold &&
|
||||
mesh->pointCount() < point_limit ) ||
|
||||
mesh->pointCount() < min_points;
|
||||
|
||||
return (mesh->maxError() > error_threshold && mesh->pointCount() < point_limit) || mesh->pointCount() < min_points;
|
||||
}
|
||||
|
||||
static void announce_goal(Terra::GreedySubdivision* mesh)
|
||||
{
|
||||
SG_LOG(SG_GENERAL, SG_INFO, "Goal conditions met:");
|
||||
SG_LOG(SG_GENERAL, SG_INFO, " error=" << mesh->maxError() << " [thresh="<< error_threshold << "]");
|
||||
SG_LOG(SG_GENERAL, SG_INFO, " error=" << mesh->maxError() << " [thresh=" << error_threshold << "]");
|
||||
SG_LOG(SG_GENERAL, SG_INFO, " points=" << mesh->pointCount() << " [limit=" << point_limit << "]");
|
||||
}
|
||||
|
||||
void greedy_insertion(Terra::GreedySubdivision* mesh)
|
||||
{
|
||||
|
||||
while( goal_not_met(mesh) )
|
||||
{
|
||||
if( !mesh->greedyInsert() )
|
||||
while (goal_not_met(mesh)) {
|
||||
if (!mesh->greedyInsert())
|
||||
break;
|
||||
}
|
||||
|
||||
announce_goal(mesh);
|
||||
}
|
||||
|
||||
bool endswith(const std::string& s1, const std::string& suffix) {
|
||||
size_t s1len=s1.size();
|
||||
size_t sufflen=suffix.size();
|
||||
bool endswith(const std::string& s1, const std::string& suffix)
|
||||
{
|
||||
size_t s1len = s1.size();
|
||||
size_t sufflen = suffix.size();
|
||||
|
||||
if (s1len<sufflen) {
|
||||
if (s1len < sufflen) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return s1.compare(s1len-sufflen,sufflen,suffix)==0;
|
||||
return s1.compare(s1len - sufflen, sufflen, suffix) == 0;
|
||||
}
|
||||
|
||||
void fit_file(const SGPath& path) {
|
||||
SG_LOG(SG_GENERAL, SG_INFO,"Working on file '" << path << "'");
|
||||
void fit_file(const SGPath& path)
|
||||
{
|
||||
SG_LOG(SG_GENERAL, SG_INFO, "Working on file '" << path << "'");
|
||||
|
||||
SGPath outPath(path.dir());
|
||||
outPath.append(path.file_base() + ".fit.gz");
|
||||
if ( outPath.exists() ) {
|
||||
unlink( outPath.c_str() );
|
||||
if (outPath.exists()) {
|
||||
unlink(outPath.c_str());
|
||||
}
|
||||
|
||||
SGBucket bucket; // dummy bucket
|
||||
|
@ -182,22 +183,22 @@ void fit_file(const SGPath& path) {
|
|||
greedy_insertion(&mesh);
|
||||
|
||||
gzFile fp;
|
||||
if ( (fp = gzopen( outPath.c_str(), "wb9" )) == NULL ) {
|
||||
if ((fp = gzopen(outPath.c_str(), "wb9")) == NULL) {
|
||||
SG_LOG(SG_GENERAL, SG_ALERT, "ERROR: opening " << outPath << " for writing!");
|
||||
return;
|
||||
}
|
||||
|
||||
gzprintf(fp,"%d\n",mesh.pointCount());
|
||||
gzprintf(fp, "%d\n", mesh.pointCount());
|
||||
|
||||
for (int x=0;x<DEM.width;x++) {
|
||||
for (int y=0;y<DEM.height;y++) {
|
||||
if (mesh.is_used(x,y) != DATA_POINT_USED)
|
||||
for (int x = 0; x < DEM.width; x++) {
|
||||
for (int y = 0; y < DEM.height; y++) {
|
||||
if (mesh.is_used(x, y) != DATA_POINT_USED)
|
||||
continue;
|
||||
double vx,vy,vz;
|
||||
vx=(inarray.get_originx()+x*inarray.get_col_step())/3600.0;
|
||||
vy=(inarray.get_originy()+y*inarray.get_row_step())/3600.0;
|
||||
vz=DEM.eval(x,y);
|
||||
gzprintf(fp,"%+03.8f %+02.8f %0.2f\n",vx,vy,vz);
|
||||
double vx, vy, vz;
|
||||
vx = (inarray.get_originx() + x * inarray.get_col_step()) / 3600.0;
|
||||
vy = (inarray.get_originy() + y * inarray.get_row_step()) / 3600.0;
|
||||
vz = DEM.eval(x, y);
|
||||
gzprintf(fp, "%+03.8f %+02.8f %0.2f\n", vx, vy, vz);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -211,7 +212,7 @@ void queue_fit_file(const SGPath& path)
|
|||
|
||||
if (!force) {
|
||||
if (outPath.exists() && (path.modTime() < outPath.modTime())) {
|
||||
SG_LOG(SG_GENERAL, SG_INFO ,"Skipping " << outPath << ", source " << path << " is older");
|
||||
SG_LOG(SG_GENERAL, SG_INFO, "Skipping " << outPath << ", source " << path << " is older");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
@ -219,8 +220,7 @@ void queue_fit_file(const SGPath& path)
|
|||
global_workQueue.push(path);
|
||||
}
|
||||
|
||||
class FitThread : public SGThread
|
||||
{
|
||||
class FitThread : public SGThread {
|
||||
public:
|
||||
virtual void run()
|
||||
{
|
||||
|
@ -233,15 +233,14 @@ public:
|
|||
}
|
||||
};
|
||||
|
||||
void walk_path(const SGPath& path) {
|
||||
|
||||
void walk_path(const SGPath& path)
|
||||
{
|
||||
if (!path.exists()) {
|
||||
SG_LOG(SG_GENERAL, SG_ALERT ,"ERROR: Unable to stat '" << path.str() << "':" << strerror(errno));
|
||||
SG_LOG(SG_GENERAL, SG_ALERT, "ERROR: Unable to stat '" << path.str() << "':" << strerror(errno));
|
||||
return;
|
||||
}
|
||||
|
||||
if ((path.lower_extension() == "arr") || (path.complete_lower_extension() == "arr.rectified.gz") ||
|
||||
(path.complete_lower_extension() == "arr.gz")) {
|
||||
if ((path.lower_extension() == "arr") || (path.complete_lower_extension() == "arr.rectified.gz") || (path.complete_lower_extension() == "arr.gz")) {
|
||||
SG_LOG(SG_GENERAL, SG_DEBUG, "will queue " << path);
|
||||
queue_fit_file(path);
|
||||
} else if (path.isDir()) {
|
||||
|
@ -253,86 +252,88 @@ void walk_path(const SGPath& path) {
|
|||
}
|
||||
}
|
||||
|
||||
void usage(char* progname, const std::string& msg) {
|
||||
if (msg.size()!=0) {
|
||||
SG_LOG(SG_GENERAL,SG_ALERT, msg);
|
||||
void usage(char* progname, const std::string& msg)
|
||||
{
|
||||
if (msg.size() != 0) {
|
||||
SG_LOG(SG_GENERAL, SG_ALERT, msg);
|
||||
}
|
||||
|
||||
SG_LOG(SG_GENERAL,SG_INFO, "Usage: " << progname << " [options] <file | path to walk>");
|
||||
SG_LOG(SG_GENERAL,SG_INFO, "\t -h | --help");
|
||||
SG_LOG(SG_GENERAL,SG_INFO, "\t -m | --minnodes 50");
|
||||
SG_LOG(SG_GENERAL,SG_INFO, "\t -x | --maxnodes 1000");
|
||||
SG_LOG(SG_GENERAL,SG_INFO, "\t -e | --maxerror 40");
|
||||
SG_LOG(SG_GENERAL,SG_INFO, "\t -f | --force");
|
||||
SG_LOG(SG_GENERAL,SG_INFO, "\t -j | --threads <number>");
|
||||
SG_LOG(SG_GENERAL,SG_INFO, "\t -v | --version");
|
||||
SG_LOG(SG_GENERAL,SG_INFO, "");
|
||||
SG_LOG(SG_GENERAL,SG_INFO, "Algorithm will produce at least <minnodes> fitted nodes, but no");
|
||||
SG_LOG(SG_GENERAL,SG_INFO, "more than <maxnodes>. Within that range, the algorithm will stop");
|
||||
SG_LOG(SG_GENERAL,SG_INFO, "if the maximum elevation error for any remaining point");
|
||||
SG_LOG(SG_GENERAL,SG_INFO, "drops below <maxerror> meters.");
|
||||
SG_LOG(SG_GENERAL,SG_INFO, "");
|
||||
SG_LOG(SG_GENERAL,SG_INFO, "Increasing the maxnodes value and/or decreasing maxerror");
|
||||
SG_LOG(SG_GENERAL,SG_INFO, "will produce a better surface approximation.");
|
||||
SG_LOG(SG_GENERAL,SG_INFO, "");
|
||||
SG_LOG(SG_GENERAL,SG_INFO, "The input file must be a .arr.gz file such as that produced");
|
||||
SG_LOG(SG_GENERAL,SG_INFO, "by the hgtchop utility.");
|
||||
SG_LOG(SG_GENERAL,SG_INFO, "");
|
||||
SG_LOG(SG_GENERAL,SG_INFO, "Force will overwrite existing .arr.gz files, even if the input is older");
|
||||
SG_LOG(SG_GENERAL,SG_INFO, "");
|
||||
SG_LOG(SG_GENERAL,SG_INFO, "**** NOTE ****:");
|
||||
SG_LOG(SG_GENERAL,SG_INFO, "If a directory is input all .arr.gz files in directory will be");
|
||||
SG_LOG(SG_GENERAL,SG_INFO, "processed recursively.");
|
||||
SG_LOG(SG_GENERAL,SG_INFO, "");
|
||||
SG_LOG(SG_GENERAL,SG_INFO, "The output file(s) is/are called .fit.gz and is simply a list of");
|
||||
SG_LOG(SG_GENERAL,SG_INFO, "from the resulting fitted surface nodes. The user of the");
|
||||
SG_LOG(SG_GENERAL,SG_INFO, ".fit.gz file will need to retriangulate the surface.");
|
||||
SG_LOG(SG_GENERAL, SG_INFO, "Usage: " << progname << " [options] <file | path to walk>");
|
||||
SG_LOG(SG_GENERAL, SG_INFO, "\t -h | --help");
|
||||
SG_LOG(SG_GENERAL, SG_INFO, "\t -m | --minnodes 50");
|
||||
SG_LOG(SG_GENERAL, SG_INFO, "\t -x | --maxnodes 1000");
|
||||
SG_LOG(SG_GENERAL, SG_INFO, "\t -e | --maxerror 40");
|
||||
SG_LOG(SG_GENERAL, SG_INFO, "\t -f | --force");
|
||||
SG_LOG(SG_GENERAL, SG_INFO, "\t -j | --threads <number>");
|
||||
SG_LOG(SG_GENERAL, SG_INFO, "\t -v | --version");
|
||||
SG_LOG(SG_GENERAL, SG_INFO, "");
|
||||
SG_LOG(SG_GENERAL, SG_INFO, "Algorithm will produce at least <minnodes> fitted nodes, but no");
|
||||
SG_LOG(SG_GENERAL, SG_INFO, "more than <maxnodes>. Within that range, the algorithm will stop");
|
||||
SG_LOG(SG_GENERAL, SG_INFO, "if the maximum elevation error for any remaining point");
|
||||
SG_LOG(SG_GENERAL, SG_INFO, "drops below <maxerror> meters.");
|
||||
SG_LOG(SG_GENERAL, SG_INFO, "");
|
||||
SG_LOG(SG_GENERAL, SG_INFO, "Increasing the maxnodes value and/or decreasing maxerror");
|
||||
SG_LOG(SG_GENERAL, SG_INFO, "will produce a better surface approximation.");
|
||||
SG_LOG(SG_GENERAL, SG_INFO, "");
|
||||
SG_LOG(SG_GENERAL, SG_INFO, "The input file must be a .arr.gz file such as that produced");
|
||||
SG_LOG(SG_GENERAL, SG_INFO, "by the hgtchop utility.");
|
||||
SG_LOG(SG_GENERAL, SG_INFO, "");
|
||||
SG_LOG(SG_GENERAL, SG_INFO, "Force will overwrite existing .arr.gz files, even if the input is older");
|
||||
SG_LOG(SG_GENERAL, SG_INFO, "");
|
||||
SG_LOG(SG_GENERAL, SG_INFO, "**** NOTE ****:");
|
||||
SG_LOG(SG_GENERAL, SG_INFO, "If a directory is input all .arr.gz files in directory will be");
|
||||
SG_LOG(SG_GENERAL, SG_INFO, "processed recursively.");
|
||||
SG_LOG(SG_GENERAL, SG_INFO, "");
|
||||
SG_LOG(SG_GENERAL, SG_INFO, "The output file(s) is/are called .fit.gz and is simply a list of");
|
||||
SG_LOG(SG_GENERAL, SG_INFO, "from the resulting fitted surface nodes. The user of the");
|
||||
SG_LOG(SG_GENERAL, SG_INFO, ".fit.gz file will need to retriangulate the surface.");
|
||||
}
|
||||
|
||||
struct option options[]={
|
||||
{"help",no_argument,NULL,'h'},
|
||||
{"minnodes",required_argument,NULL,'m'},
|
||||
{"maxnodes",required_argument,NULL,'x'},
|
||||
{"maxerror",required_argument,NULL,'e'},
|
||||
{"force",no_argument,NULL,'f'},
|
||||
{"version",no_argument,NULL,'v'},
|
||||
{"threads",required_argument,NULL,'j'},
|
||||
{NULL,0,NULL,0}
|
||||
struct option options[] = {
|
||||
{ "help", no_argument, NULL, 'h' },
|
||||
{ "minnodes", required_argument, NULL, 'm' },
|
||||
{ "maxnodes", required_argument, NULL, 'x' },
|
||||
{ "maxerror", required_argument, NULL, 'e' },
|
||||
{ "force", no_argument, NULL, 'f' },
|
||||
{ "version", no_argument, NULL, 'v' },
|
||||
{ "threads", required_argument, NULL, 'j' },
|
||||
{ NULL, 0, NULL, 0 }
|
||||
};
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
sglog().setLogLevels( SG_ALL, SG_INFO );
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
sglog().setLogLevels(SG_ALL, SG_INFO);
|
||||
int option;
|
||||
|
||||
auto start_time = std::chrono::high_resolution_clock::now();
|
||||
|
||||
while ((option=getopt_long(argc,argv,"hm:x:e:fvj:",options,NULL))!=-1) {
|
||||
while ((option = getopt_long(argc, argv, "hm:x:e:fvj:", options, NULL)) != -1) {
|
||||
switch (option) {
|
||||
case 'h':
|
||||
usage(argv[0],"");
|
||||
break;
|
||||
case 'm':
|
||||
min_points=atoi(optarg);
|
||||
break;
|
||||
case 'x':
|
||||
point_limit=atoi(optarg);
|
||||
break;
|
||||
case 'e':
|
||||
error_threshold=atof(optarg);
|
||||
break;
|
||||
case 'f':
|
||||
force=true;
|
||||
break;
|
||||
case 'v':
|
||||
SG_LOG(SG_GENERAL,SG_INFO,argv[0] << " version " << getTGVersion());
|
||||
exit(0);
|
||||
break;
|
||||
case 'j':
|
||||
num_threads = atoi(optarg);
|
||||
break;
|
||||
case '?':
|
||||
usage(argv[0],std::string("Unknown option:")+(char)optopt);
|
||||
exit(1);
|
||||
case 'h':
|
||||
usage(argv[0], "");
|
||||
break;
|
||||
case 'm':
|
||||
min_points = atoi(optarg);
|
||||
break;
|
||||
case 'x':
|
||||
point_limit = atoi(optarg);
|
||||
break;
|
||||
case 'e':
|
||||
error_threshold = atof(optarg);
|
||||
break;
|
||||
case 'f':
|
||||
force = true;
|
||||
break;
|
||||
case 'v':
|
||||
SG_LOG(SG_GENERAL, SG_INFO, argv[0] << " version " << getTGVersion());
|
||||
exit(0);
|
||||
break;
|
||||
case 'j':
|
||||
num_threads = atoi(optarg);
|
||||
break;
|
||||
case '?':
|
||||
usage(argv[0], std::string("Unknown option:") + (char)optopt);
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -341,8 +342,8 @@ int main(int argc, char** argv) {
|
|||
SG_LOG(SG_GENERAL, SG_INFO, "Max points = " << point_limit);
|
||||
SG_LOG(SG_GENERAL, SG_INFO, "Max error = " << error_threshold);
|
||||
|
||||
if (optind<argc) {
|
||||
while (optind<argc) {
|
||||
if (optind < argc) {
|
||||
while (optind < argc) {
|
||||
SG_LOG(SG_GENERAL, SG_INFO, "walking " << SGPath(argv[optind]));
|
||||
walk_path(SGPath(argv[optind++]));
|
||||
}
|
||||
|
@ -351,8 +352,8 @@ int main(int argc, char** argv) {
|
|||
exit(1);
|
||||
}
|
||||
|
||||
std::vector<std::shared_ptr<FitThread> > threads;
|
||||
for (unsigned int t=0; t<num_threads; ++t) {
|
||||
std::vector<std::shared_ptr<FitThread>> threads;
|
||||
for (unsigned int t = 0; t < num_threads; ++t) {
|
||||
std::shared_ptr<FitThread> thread(new FitThread);
|
||||
thread->start();
|
||||
threads.push_back(thread);
|
||||
|
@ -362,7 +363,7 @@ int main(int argc, char** argv) {
|
|||
sleep(1);
|
||||
}
|
||||
|
||||
for (unsigned int t=0; t<num_threads; ++t) {
|
||||
for (unsigned int t = 0; t < num_threads; ++t) {
|
||||
threads[t]->join();
|
||||
}
|
||||
|
||||
|
@ -370,7 +371,9 @@ int main(int argc, char** argv) {
|
|||
|
||||
auto finish_time = std::chrono::high_resolution_clock::now();
|
||||
std::chrono::duration<double> elapsed = finish_time - start_time;
|
||||
std::cout << std::endl << "Elapsed time: " << elapsed.count() << " seconds" << std::endl << std::endl;
|
||||
std::cout << std::endl
|
||||
<< "Elapsed time: " << elapsed.count() << " seconds" << std::endl
|
||||
<< std::endl;
|
||||
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue