Fix carrier starts
Rewrite the position-init code for carrier starts, to precisely wait on the carrier model being loaded, before proceeding with FDM init. This allows the FDM to see the correct carrier model in the ground cache, and hence avoids starting in the water. To implement this, the CheckSceneryVisitor is used to force the carrier model to be loaded while the splash-screen is visible.
This commit is contained in:
parent
91d3229514
commit
bf2c363e50
8 changed files with 196 additions and 99 deletions
|
@ -939,3 +939,14 @@ bool FGAIBase::isValid() {
|
||||||
//Either no flightplan or it is valid
|
//Either no flightplan or it is valid
|
||||||
return !fp || fp->isValidPlan();
|
return !fp || fp->isValidPlan();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
osg::PagedLOD* FGAIBase::getSceneBranch() const
|
||||||
|
{
|
||||||
|
return _model;
|
||||||
|
}
|
||||||
|
|
||||||
|
SGGeod FGAIBase::getGeodPos() const
|
||||||
|
{
|
||||||
|
return pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
@ -109,6 +109,8 @@ public:
|
||||||
|
|
||||||
bool getDie();
|
bool getDie();
|
||||||
bool isValid();
|
bool isValid();
|
||||||
|
|
||||||
|
SGGeod getGeodPos() const;
|
||||||
|
|
||||||
SGVec3d getCartPosAt(const SGVec3d& off) const;
|
SGVec3d getCartPosAt(const SGVec3d& off) const;
|
||||||
SGVec3d getCartPos() const;
|
SGVec3d getCartPos() const;
|
||||||
|
@ -121,6 +123,7 @@ public:
|
||||||
double _getCartPosY() const;
|
double _getCartPosY() const;
|
||||||
double _getCartPosZ() const;
|
double _getCartPosZ() const;
|
||||||
|
|
||||||
|
osg::PagedLOD* getSceneBranch() const;
|
||||||
protected:
|
protected:
|
||||||
double _elevation_m;
|
double _elevation_m;
|
||||||
|
|
||||||
|
|
|
@ -590,3 +590,53 @@ void FGAICarrier::UpdateJBD(double dt, double jbd_transition_time) {
|
||||||
return;
|
return;
|
||||||
|
|
||||||
} // end UpdateJBD
|
} // end UpdateJBD
|
||||||
|
|
||||||
|
std::pair<bool, SGGeod> FGAICarrier::initialPositionForCarrier(const std::string& namePennant)
|
||||||
|
{
|
||||||
|
SGPropertyNode_ptr aiRoot = fgGetNode("/sim/ai", true);
|
||||||
|
for (const auto scenarioFileNode : aiRoot->getChildren("scenario")) {
|
||||||
|
SGPropertyNode_ptr s = FGAIManager::loadScenarioFile(scenarioFileNode->getStringValue());
|
||||||
|
if (!s || !s->hasChild("scenario")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string carrierType("carrier");
|
||||||
|
SGPropertyNode_ptr scenario = s->getChild("scenario");
|
||||||
|
for (int i = 0; i < scenario->nChildren(); i++) {
|
||||||
|
SGPropertyNode* c = scenario->getChild(i);
|
||||||
|
if (c->getStringValue("type") != carrierType) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto pennant = c->getStringValue("pennant-number");
|
||||||
|
const auto name = c->getStringValue("name");
|
||||||
|
if (pennant == namePennant || name == namePennant) {
|
||||||
|
SGSharedPtr<FGAICarrier> carrier = new FGAICarrier;
|
||||||
|
carrier->readFromScenario(c);
|
||||||
|
return std::make_pair(true, carrier->getGeodPos());
|
||||||
|
}
|
||||||
|
} // of objects in scenario iteration
|
||||||
|
} // of scenario files iteration
|
||||||
|
|
||||||
|
return std::make_pair(false, SGGeod());
|
||||||
|
}
|
||||||
|
|
||||||
|
SGSharedPtr<FGAICarrier> FGAICarrier::findCarrierByNameOrPennant(const std::string& namePennant)
|
||||||
|
{
|
||||||
|
const FGAIManager* aiManager = globals->get_subsystem<FGAIManager>();
|
||||||
|
if (!aiManager) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const auto aiObject : aiManager->get_ai_list()) {
|
||||||
|
if (aiObject->isa(FGAIBase::otCarrier)) {
|
||||||
|
SGSharedPtr<FGAICarrier> c = static_cast<FGAICarrier*>(aiObject.get());
|
||||||
|
if ((c->sign == namePennant) || (c->_getName() == namePennant)) {
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} // of all objects iteration
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
@ -70,7 +70,13 @@ public:
|
||||||
|
|
||||||
bool getParkPosition(const string& id, SGGeod& geodPos,
|
bool getParkPosition(const string& id, SGGeod& geodPos,
|
||||||
double& hdng, SGVec3d& uvw);
|
double& hdng, SGVec3d& uvw);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief type-safe wrapper around AIManager::getObjectFromProperty
|
||||||
|
*/
|
||||||
|
static SGSharedPtr<FGAICarrier> findCarrierByNameOrPennant(const std::string& namePennant);
|
||||||
|
|
||||||
|
static std::pair<bool, SGGeod> initialPositionForCarrier(const std::string& namePennant);
|
||||||
private:
|
private:
|
||||||
/// Is sufficient to be private, stores a possible position to place an
|
/// Is sufficient to be private, stores a possible position to place an
|
||||||
/// aircraft on start
|
/// aircraft on start
|
||||||
|
|
|
@ -519,45 +519,6 @@ FGAIManager::loadScenarioFile(const std::string& filename)
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool
|
|
||||||
FGAIManager::getStartPosition(const string& id, const string& pid,
|
|
||||||
SGGeod& geodPos, double& hdng, SGVec3d& uvw)
|
|
||||||
{
|
|
||||||
bool found = false;
|
|
||||||
SGPropertyNode* root = fgGetNode("sim/ai", true);
|
|
||||||
if (!root->getNode("enabled", true)->getBoolValue())
|
|
||||||
return found;
|
|
||||||
|
|
||||||
for (int i = 0 ; (!found) && i < root->nChildren() ; i++) {
|
|
||||||
SGPropertyNode *aiEntry = root->getChild( i );
|
|
||||||
if ( !strcmp( aiEntry->getName(), "scenario" ) ) {
|
|
||||||
const string& filename = aiEntry->getStringValue();
|
|
||||||
SGPropertyNode_ptr scenarioTop = loadScenarioFile(filename);
|
|
||||||
if (scenarioTop) {
|
|
||||||
SGPropertyNode* scenarios = scenarioTop->getChild("scenario");
|
|
||||||
if (scenarios) {
|
|
||||||
for (int i = 0; i < scenarios->nChildren(); i++) {
|
|
||||||
SGPropertyNode* scEntry = scenarios->getChild(i);
|
|
||||||
const std::string& type = scEntry->getStringValue("type");
|
|
||||||
const std::string& pnumber = scEntry->getStringValue("pennant-number");
|
|
||||||
const std::string& name = scEntry->getStringValue("name");
|
|
||||||
if (type == "carrier" && (pnumber == id || name == id)) {
|
|
||||||
SGSharedPtr<FGAICarrier> carrier = new FGAICarrier;
|
|
||||||
carrier->readFromScenario(scEntry);
|
|
||||||
|
|
||||||
if (carrier->getParkPosition(pid, geodPos, hdng, uvw)) {
|
|
||||||
found = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return found;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FGAIBase *
|
const FGAIBase *
|
||||||
FGAIManager::calcCollision(double alt, double lat, double lon, double fuse_range)
|
FGAIManager::calcCollision(double alt, double lat, double lon, double fuse_range)
|
||||||
{
|
{
|
||||||
|
|
|
@ -62,12 +62,9 @@ public:
|
||||||
inline double get_user_agl() const { return user_altitude_agl; }
|
inline double get_user_agl() const { return user_altitude_agl; }
|
||||||
|
|
||||||
bool loadScenario( const std::string &filename );
|
bool loadScenario( const std::string &filename );
|
||||||
|
|
||||||
static SGPropertyNode_ptr loadScenarioFile(const std::string& filename);
|
static SGPropertyNode_ptr loadScenarioFile(const std::string& filename);
|
||||||
|
|
||||||
static bool getStartPosition(const std::string& id, const std::string& pid,
|
|
||||||
SGGeod& geodPos, double& hdng, SGVec3d& uvw);
|
|
||||||
|
|
||||||
FGAIBasePtr addObject(const SGPropertyNode* definition);
|
FGAIBasePtr addObject(const SGPropertyNode* definition);
|
||||||
bool isVisible(const SGGeod& pos) const;
|
bool isVisible(const SGGeod& pos) const;
|
||||||
|
|
||||||
|
|
|
@ -179,7 +179,8 @@ void FDMShell::update(double dt)
|
||||||
|
|
||||||
double range = 1000.0; // in meters
|
double range = 1000.0; // in meters
|
||||||
SGGeod geod = SGGeod::fromDeg(lon, lat);
|
SGGeod geod = SGGeod::fromDeg(lon, lat);
|
||||||
if (globals->get_scenery()->scenery_available(geod, range)) {
|
const auto startUpPositionFialized = fgGetBool("/sim/position-finalized", false);
|
||||||
|
if (startUpPositionFialized && globals->get_scenery()->scenery_available(geod, range)) {
|
||||||
SG_LOG(SG_FLIGHT, SG_INFO, "Scenery loaded, will init FDM");
|
SG_LOG(SG_FLIGHT, SG_INFO, "Scenery loaded, will init FDM");
|
||||||
_impl->init();
|
_impl->init();
|
||||||
if (_impl->get_bound()) {
|
if (_impl->get_bound()) {
|
||||||
|
|
|
@ -23,11 +23,15 @@
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#include <boost/tuple/tuple.hpp>
|
#include <boost/tuple/tuple.hpp>
|
||||||
|
#include <osgViewer/Viewer>
|
||||||
|
#include <osg/PagedLOD>
|
||||||
|
|
||||||
// simgear
|
// simgear
|
||||||
#include <simgear/props/props_io.hxx>
|
#include <simgear/props/props_io.hxx>
|
||||||
#include <simgear/structure/exception.hxx>
|
#include <simgear/structure/exception.hxx>
|
||||||
#include <simgear/structure/event_mgr.hxx>
|
#include <simgear/structure/event_mgr.hxx>
|
||||||
|
#include <simgear/scene/model/CheckSceneryVisitor.hxx>
|
||||||
|
#include <simgear/scene/util/OsgMath.hxx>
|
||||||
|
|
||||||
#include "globals.hxx"
|
#include "globals.hxx"
|
||||||
#include "fg_props.hxx"
|
#include "fg_props.hxx"
|
||||||
|
@ -38,14 +42,25 @@
|
||||||
#include <Airports/dynamics.hxx>
|
#include <Airports/dynamics.hxx>
|
||||||
#include <Airports/groundnetwork.hxx>
|
#include <Airports/groundnetwork.hxx>
|
||||||
#include <AIModel/AIManager.hxx>
|
#include <AIModel/AIManager.hxx>
|
||||||
#include <GUI/MessageBox.hxx>
|
#include <AIModel/AICarrier.hxx>
|
||||||
|
|
||||||
|
#include <Scenery/scenery.hxx>
|
||||||
|
#include <GUI/MessageBox.hxx>
|
||||||
|
#include <Viewer/renderer.hxx>
|
||||||
|
|
||||||
using std::endl;
|
using std::endl;
|
||||||
using std::string;
|
using std::string;
|
||||||
|
|
||||||
namespace flightgear
|
namespace flightgear
|
||||||
{
|
{
|
||||||
|
|
||||||
|
|
||||||
|
enum InitPosResult {
|
||||||
|
ExactPosition,
|
||||||
|
VicinityPosition,
|
||||||
|
ContinueWaiting,
|
||||||
|
Failure
|
||||||
|
};
|
||||||
|
|
||||||
/// to avoid blocking when metar-fetch is enabled, but the network is
|
/// to avoid blocking when metar-fetch is enabled, but the network is
|
||||||
/// unresponsive, we need a timeout value. This value is reset on initPosition,
|
/// unresponsive, we need a timeout value. This value is reset on initPosition,
|
||||||
|
@ -446,53 +461,95 @@ static bool fgSetPosFromNAV( const string& id,
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static InitPosResult setInitialPosFromCarrier( const string& carrier )
|
||||||
|
{
|
||||||
|
#if !defined(FG_TESTLIB)
|
||||||
|
const auto initialPos = FGAICarrier::initialPositionForCarrier(carrier);
|
||||||
|
if (initialPos.first) {
|
||||||
|
// set these so scenery system has a vicinity to work with, and
|
||||||
|
// so our PagedLOD is loaded
|
||||||
|
fgSetDouble("/sim/presets/longitude-deg", initialPos.second.getLongitudeDeg());
|
||||||
|
fgSetDouble("/sim/presets/latitude-deg", initialPos.second.getLatitudeDeg());
|
||||||
|
SG_LOG( SG_GENERAL, SG_INFO, "Initial carrier pos = " << initialPos.second );
|
||||||
|
return VicinityPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
SG_LOG( SG_GENERAL, SG_ALERT, "Failed to locate aircraft carrier = " << carrier );
|
||||||
|
#endif
|
||||||
|
return Failure;
|
||||||
|
}
|
||||||
|
|
||||||
// Set current_options lon/lat given an aircraft carrier id
|
// Set current_options lon/lat given an aircraft carrier id
|
||||||
static bool fgSetPosFromCarrier( const string& carrier, const string& posid )
|
static InitPosResult setFinalPosFromCarrier( const string& carrier, const string& posid )
|
||||||
{
|
{
|
||||||
|
|
||||||
#ifndef FG_TESTLIB
|
#if !defined(FG_TESTLIB)
|
||||||
|
SGSharedPtr<FGAICarrier> carrierRef = FGAICarrier::findCarrierByNameOrPennant(carrier);
|
||||||
|
if (!carrierRef) {
|
||||||
|
SG_LOG( SG_GENERAL, SG_ALERT, "Failed to locate aircraft carrier = "
|
||||||
|
<< carrier );
|
||||||
|
return Failure;
|
||||||
|
}
|
||||||
|
|
||||||
|
SGVec3d cartPos = carrierRef->getCartPos();
|
||||||
|
auto framestamp = globals->get_renderer()->getViewer()->getFrameStamp();
|
||||||
|
simgear::CheckSceneryVisitor csnv(globals->get_scenery()->getPager(),
|
||||||
|
toOsg(cartPos),
|
||||||
|
100.0 /* range in metres */,
|
||||||
|
framestamp);
|
||||||
|
|
||||||
// set initial position from runway and heading
|
// currently the PagedLODs will not be loaded by the DatabasePager
|
||||||
SGGeod geodPos;
|
// while the splashscreen is there, so CheckSceneryVisitor force-loads
|
||||||
double heading;
|
// missing objects in the main thread
|
||||||
SGVec3d uvw;
|
carrierRef->getSceneBranch()->accept(csnv);
|
||||||
|
if (!csnv.isLoaded()) {
|
||||||
|
return ContinueWaiting;
|
||||||
|
}
|
||||||
|
|
||||||
|
// and then wait for the load to actually be synced to the main thread
|
||||||
|
if (carrierRef->getSceneBranch()->getNumChildren() < 1) {
|
||||||
|
return ContinueWaiting;
|
||||||
|
}
|
||||||
|
|
||||||
|
SGGeod geodPos;
|
||||||
|
double heading;
|
||||||
|
SGVec3d uvw;
|
||||||
|
if (carrierRef->getParkPosition(posid, geodPos, heading, uvw)) {
|
||||||
|
|
||||||
|
////////
|
||||||
|
double lon = geodPos.getLongitudeDeg();
|
||||||
|
double lat = geodPos.getLatitudeDeg();
|
||||||
|
double alt = geodPos.getElevationFt() + 2.0;
|
||||||
|
|
||||||
|
SG_LOG( SG_GENERAL, SG_INFO, "Attempting to set starting position for "
|
||||||
|
<< carrier << " at lat = " << lat << ", lon = " << lon
|
||||||
|
<< ", alt = " << alt << ", heading = " << heading);
|
||||||
|
|
||||||
|
fgSetDouble("/sim/presets/longitude-deg", lon);
|
||||||
|
fgSetDouble("/sim/presets/latitude-deg", lat);
|
||||||
|
fgSetDouble("/sim/presets/altitude-ft", alt);
|
||||||
|
fgSetDouble("/sim/presets/heading-deg", heading);
|
||||||
|
fgSetDouble("/position/longitude-deg", lon);
|
||||||
|
fgSetDouble("/position/latitude-deg", lat);
|
||||||
|
fgSetDouble("/position/altitude-ft", alt);
|
||||||
|
fgSetDouble("/orientation/heading-deg", heading);
|
||||||
|
|
||||||
|
fgSetString("/sim/presets/speed-set", "UVW");
|
||||||
|
fgSetDouble("/velocities/uBody-fps", uvw(0));
|
||||||
|
fgSetDouble("/velocities/vBody-fps", uvw(1));
|
||||||
|
fgSetDouble("/velocities/wBody-fps", uvw(2));
|
||||||
|
fgSetDouble("/sim/presets/uBody-fps", uvw(0));
|
||||||
|
fgSetDouble("/sim/presets/vBody-fps", uvw(1));
|
||||||
|
fgSetDouble("/sim/presets/wBody-fps", uvw(2));
|
||||||
|
|
||||||
|
fgSetBool("/sim/presets/onground", true);
|
||||||
|
|
||||||
if (FGAIManager::getStartPosition(carrier, posid, geodPos, heading, uvw)) {
|
/////////
|
||||||
double lon = geodPos.getLongitudeDeg();
|
return ExactPosition;
|
||||||
double lat = geodPos.getLatitudeDeg();
|
}
|
||||||
double alt = geodPos.getElevationFt();
|
|
||||||
|
|
||||||
SG_LOG( SG_GENERAL, SG_INFO, "Attempting to set starting position for "
|
|
||||||
<< carrier << " at lat = " << lat << ", lon = " << lon
|
|
||||||
<< ", alt = " << alt << ", heading = " << heading);
|
|
||||||
|
|
||||||
fgSetDouble("/sim/presets/longitude-deg", lon);
|
|
||||||
fgSetDouble("/sim/presets/latitude-deg", lat);
|
|
||||||
fgSetDouble("/sim/presets/altitude-ft", alt);
|
|
||||||
fgSetDouble("/sim/presets/heading-deg", heading);
|
|
||||||
fgSetDouble("/position/longitude-deg", lon);
|
|
||||||
fgSetDouble("/position/latitude-deg", lat);
|
|
||||||
fgSetDouble("/position/altitude-ft", alt);
|
|
||||||
fgSetDouble("/orientation/heading-deg", heading);
|
|
||||||
|
|
||||||
fgSetString("/sim/presets/speed-set", "UVW");
|
|
||||||
fgSetDouble("/velocities/uBody-fps", uvw(0));
|
|
||||||
fgSetDouble("/velocities/vBody-fps", uvw(1));
|
|
||||||
fgSetDouble("/velocities/wBody-fps", uvw(2));
|
|
||||||
fgSetDouble("/sim/presets/uBody-fps", uvw(0));
|
|
||||||
fgSetDouble("/sim/presets/vBody-fps", uvw(1));
|
|
||||||
fgSetDouble("/sim/presets/wBody-fps", uvw(2));
|
|
||||||
|
|
||||||
fgSetBool("/sim/presets/onground", true);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} else
|
|
||||||
#endif
|
#endif
|
||||||
{
|
SG_LOG( SG_GENERAL, SG_ALERT, "Failed to locate aircraft carrier = " << carrier );
|
||||||
SG_LOG( SG_GENERAL, SG_ALERT, "Failed to locate aircraft carrier = "
|
return Failure;
|
||||||
<< carrier );
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set current_options lon/lat given a fix ident and GUID
|
// Set current_options lon/lat given a fix ident and GUID
|
||||||
|
@ -588,6 +645,15 @@ bool initPosition()
|
||||||
if (hdg > 9990.0)
|
if (hdg > 9990.0)
|
||||||
hdg = fgGetDouble("/environment/config/boundary/entry/wind-from-heading-deg", 270);
|
hdg = fgGetDouble("/environment/config/boundary/entry/wind-from-heading-deg", 270);
|
||||||
|
|
||||||
|
if ( !set_pos && !carrier.empty() ) {
|
||||||
|
// an aircraft carrier is requested
|
||||||
|
const auto result = setInitialPosFromCarrier( carrier );
|
||||||
|
if (result != Failure) {
|
||||||
|
// we at least found the carrier
|
||||||
|
set_pos = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (apt_req && !rwy_req) {
|
if (apt_req && !rwy_req) {
|
||||||
// ensure that if the users asks for a specific airport, but not a runway,
|
// ensure that if the users asks for a specific airport, but not a runway,
|
||||||
// presumably because they want automatic selection, we do not look
|
// presumably because they want automatic selection, we do not look
|
||||||
|
@ -648,13 +714,6 @@ bool initPosition()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ( !set_pos && !carrier.empty() ) {
|
|
||||||
// an aircraft carrier is requested
|
|
||||||
if ( fgSetPosFromCarrier( carrier, parkpos ) ) {
|
|
||||||
set_pos = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( !set_pos && !fix.empty() ) {
|
if ( !set_pos && !fix.empty() ) {
|
||||||
// a Fix is requested
|
// a Fix is requested
|
||||||
if ( fgSetPosFromFix( fix, navaidId ) ) {
|
if ( fgSetPosFromFix( fix, navaidId ) ) {
|
||||||
|
@ -766,11 +825,20 @@ void finalizePosition()
|
||||||
|
|
||||||
if (!carrier.empty())
|
if (!carrier.empty())
|
||||||
{
|
{
|
||||||
SG_LOG(SG_GENERAL, SG_INFO, "finalizePositioned: re-init-ing position on carrier");
|
const auto res = setFinalPosFromCarrier(carrier, parkpos);
|
||||||
// clear preset location and re-trigger position setup
|
if (res == ExactPosition) {
|
||||||
fgSetDouble("/sim/presets/longitude-deg", 9999);
|
done = true;
|
||||||
fgSetDouble("/sim/presets/latitude-deg", 9999);
|
} else if (res == Failure) {
|
||||||
initPosition();
|
SG_LOG(SG_GENERAL, SG_ALERT, "secondary carrier init failed");
|
||||||
|
done = true;
|
||||||
|
} else {
|
||||||
|
done = false;
|
||||||
|
// 60 second timeout on waiting for the carrier to load
|
||||||
|
if (global_finalizeTime.elapsedMSec() > 60000) {
|
||||||
|
done = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
} else if (!apt.empty() && !parkpos.empty()) {
|
} else if (!apt.empty() && !parkpos.empty()) {
|
||||||
// parking position depends on ATC / dynamics code to assign spaces,
|
// parking position depends on ATC / dynamics code to assign spaces,
|
||||||
// so we wait until this point to initialise
|
// so we wait until this point to initialise
|
||||||
|
|
Loading…
Add table
Reference in a new issue