Amerio.Stephane Posted April 19 Posted April 19 Hello, For debugging incoming PDU, I need to manually draw some points and directions when receiving entities PDU. But I can't make correct use of the new API. For example, here I'm just trying to draw the XYZ frame of reference of a processed entity, but the frame drawn is never the correct one. I must be doing something wrong... void WorldLogic::on_process_Entity_State_PDU(KDIS::PDU::Header const* packet) { auto pdu = static_cast<KDIS::PDU::Entity_State_PDU const*>(packet); auto ecef0 = pdu->GetEntityLocation(); auto geo0 = ig->getConverter()->geocentricToGeodetic({ ecef0.GetX(), ecef0.GetY(), ecef0.GetZ() }); auto w0 = ig->getConverter()->geodeticToWorld(geo0); //< works OK auto ecefdir0 = pdu->GetEntityOrientation(); auto euler0 = ig->getConverter()->geocentricEulerToGeodeticEuler(geo0, { ecefdir0.GetPsiInRadians(), ecefdir0.GetThetaInRadians(), ecefdir0.GetPhiInDegrees() }); // from doc of geocentricEulerToGeodeticEuler: //(Psi, Theta, Phi) in radians — yaw about Z, then pitch about Y, then roll about X //Euler angles in the local geodetic (NED) frame as (pitch, roll, heading) in radians. euler0 *= 180 / 3.14159265; // THE API produces radians but consumes degrees.... // NONE of these two lines works, and they don't produce the same result! auto quat0 = ig->getConverter()->geodeticEulerToRotation(geo0, { euler0.x, euler0.y, euler0.z });// from doc: Euler angles in degrees as (pitch, roll, heading) //auto quat0 = ig->getNEDConverter()->eulerNEDToRotation({ euler0.x, euler0.y, euler0.z }); // transform to world (can you confirm it's necessary here?) auto wquat0 = ig->getConverter()->getZeroRotation(geo0) * quat0; auto dir0 = wquat0 * vec3{ 1,0,0 }; auto dir1 = wquat0 * vec3{ 0,1,0 }; auto dir2 = wquat0 * vec3{ 0,0,1 }; Visualizer::renderPoint3D(w0, 0.06f, vec4_blue, true, 0, false); Visualizer::renderDirection(w0, dir0, vec4_red, 0.25f, true, 0, false); Visualizer::renderDirection(w0, dir1, vec4_green, 0.25f, true, 0, false); Visualizer::renderDirection(w0, dir2, vec4_blue, 0.25f, true, 0, false); // I would expect this frame to be the exact same as the frame shown with ig_debug_entity_info 1 (ignoring interpolation) } I have to point that all Euler and various angles API are very misleading: sometimes it's radian, sometimes degree, sometimes the order of rotation is not explicit... It would greatly help if at least the name of input variables was clear and updated in the doc and #include: Math::vec3 geocentricEulerToGeodeticEuler ( const Math::dvec3 & geodetic_coordinate, const Math::vec3 & geocentric_euler ) const => Math::vec3 geocentricEulerToGeodeticEuler ( const Math::dvec3 & geodetic_lat_lon_alt_deg, const Math::vec3 & geocentric_euler_psi_theta_phi_rad ) const (yes, it's very verbose, but at least in the code you know what to expect) Another suggestion: add a lot more code samples in the doc for these API. Thanks!
cash-metall Posted April 20 Posted April 20 Hello! There are few independent bugs in the code — any one of them is enough to break the frame. 1. GetPhiInDegrees (main problem) { ecefdir0.GetPsiInRadians(), ecefdir0.GetThetaInRadians(), ecefdir0.GetPhiInDegrees() } // ^^^^^^^^^ All three DIS orientation angles are in radians 2. geodeticEulerToRotation already returns a world-space quaternion quat Converter::geodeticEulerToRotation(const dvec3 &geo, const vec3 &euler) const { return getZeroRotation(geo) * eulerToRotation(euler); } This line: auto wquat0 = ig->getConverter()->getZeroRotation(geo0) * quat0; rotates it a second time. Direct answer to your question: no, you should not transform into world space again. The commented-out variant using eulerNEDToRotation does NOT apply getZeroRotation, which is why after the extra multiplication the two branches disagree. 3. Order is consistent everywhere: (pitch, roll, heading) == (x, y, z). Your rad→deg conversion is correct. On your API feedback: You're right — mixing rad/deg across adjacent methods is genuinely confusing. It's worth correcting the names of the arguments so that they clearly indicate the units of measurement.
Amerio.Stephane Posted April 21 Author Posted April 21 On 4/20/2026 at 8:13 AM, cash-metall said: GetPhiInDegrees (main problem) Doh! Stupid auto complete and out of screen error. Thanks! On 4/20/2026 at 8:13 AM, cash-metall said: geodeticEulerToRotation already returns a world-space quaternion I would suggest using a more explicit naming convention, like geodeticEulerToWorldRotation, and renaming eulerToRotation to eulerToLocalRotation, and eulerNEDToRotation to eulerNEDToLocalRotation. Otherwise, it is impossible to understand from signature what is the frame of reference (Local, World, ECEF). It's already hard enough to manipulate all this... But now, thanks to your explanation, I have the correct frame of reference! Thanks!
Amerio.Stephane Posted April 24 Author Posted April 24 I thought I had it, but noooo, it doesn't work / I don't understand. // draw the world rotation frame of reference 'wq' at world position 'wpos' static void renderFrame(Vec3 wpos, quat wq, float size = 0.25f, bool screen_space = false, float duration = 0.0f, bool depth_test = true) { auto dir0 = wq * vec3{ 1,0,0 }; auto dir1 = wq * vec3{ 0,1,0 }; auto dir2 = wq * vec3{ 0,0,1 }; Visualizer::renderPoint3D(wpos, 0.06f, vec4_white, screen_space, duration, depth_test); Visualizer::renderDirection(wpos, dir0, vec4_red, size, screen_space, duration, depth_test); Visualizer::renderDirection(wpos, dir1, vec4_green, size, screen_space, duration, depth_test); Visualizer::renderDirection(wpos, dir2, vec4_blue, size, screen_space, duration, depth_test); } // draw CIGI world frame of reference void WorldLogic::on_process_entity_control(IG::CIGI::CigiHostPacket const* p) { auto packet = dynamic_cast<IG::CIGI::CigiEntityControl const*>(p); if (packet->getAttachState() == 1) return; auto geo = packet->getPosition(); auto rpy = packet->getRotation(); // CIGI Roll Pitch Yaw in degrees auto w0 = ig->getConverter()->geodeticToWorld(geo); auto wquat0 = ig->getConverter()->geodeticEulerToRotation(geo, { rpy.y, rpy.x, rpy.z });//Euler angles in degrees as (pitch, roll, heading) renderFrame(w0, wquat0, 0.5f, true, 0, false); } // draw DIS world frame of reference void WorldLogic::on_process_Entity_State_PDU(KDIS::PDU::Header const* packet) { auto pdu = static_cast<KDIS::PDU::Entity_State_PDU const*>(packet); auto ecef0 = pdu->GetEntityLocation(); auto geo0 = ig->getConverter()->geocentricToGeodetic({ ecef0.GetX(), ecef0.GetY(), ecef0.GetZ() }); auto w0 = ig->getConverter()->geodeticToWorld(geo0); auto ecefdir0 = pdu->GetEntityOrientation(); auto euler0 = ig->getConverter()->geocentricEulerToGeodeticEuler(geo0, { ecefdir0.GetPsiInRadians(), ecefdir0.GetThetaInRadians(), ecefdir0.GetPhiInRadians() }); //(Psi, Theta, Phi) in radians — yaw about Z, then pitch about Y, then roll about X //Euler angles in the local geodetic (NED) frame as (pitch, roll, heading) in radians. euler0 *= 180 / 3.14159265; auto wquat0 = ig->getConverter()->geodeticEulerToRotation(geo0, { euler0.x, euler0.y, euler0.z }); //Euler angles in degrees as (pitch, roll, heading) renderFrame(w0, quat0, 0.5f, true, 0, false); } Here I want to draw the frame of reference for CIGI and DIS entities as they are received. None of these works. I'm definitely lost. What am I missing?
cash-metall Posted April 27 Posted April 27 Hello! Main problem that Converter::geodeticEulerToRotation use ENU asix orientation, but IG use NED by default (CIGI-style) for entity IG do: Mat4 transform = Mat4(local_rot, local_pos); transform = Mat4(ltp_rot, ltp_pos) * transform; // world = ltp * local where ltp_rot — this is getZeroRotation(geo), and local_rot calculated by NEDConverter : local_rot = ig->getNEDConverter()->eulerTARGETToRotation(euler); so the final code should be like static void renderFrame(Vec3 wpos, quat wq, float size = 0.25f, bool screen_space = true, float duration = 0.0f, bool depth_test = true) { Visualizer::renderPoint3D (wpos, 0.06f, vec4_white, screen_space, duration, depth_test); Visualizer::renderDirection(wpos, wq * vec3{1,0,0}, vec4_red, size, screen_space, duration, depth_test); Visualizer::renderDirection(wpos, wq * vec3{0,1,0}, vec4_green, size, screen_space, duration, depth_test); Visualizer::renderDirection(wpos, wq * vec3{0,0,1}, vec4_blue, size, screen_space, duration, depth_test); } // CIGI — same path as CIGIConnector::entity_control void WorldLogic::on_process_entity_control(IG::CIGI::CigiHostPacket const* p) { auto packet = dynamic_cast<IG::CIGI::CigiEntityControl const*>(p); if (packet->getAttachState() == 1) return; auto geo = packet->getPosition(); auto rpy = packet->getRotation(); // (roll, pitch, yaw) in deg — pass through as-is auto w = ig->getConverter()->geodeticToWorld(geo); auto loc = ig->getNEDConverter()->eulerTARGETToRotation(rpy); // same op engine uses auto wq = ig->getConverter()->getZeroRotation(geo) * loc; renderFrame(w, wq, 0.5f); } // DIS — same path as DISConnector::Entity_State_PDU void WorldLogic::on_process_Entity_State_PDU(KDIS::PDU::Header const* packet) { auto pdu = static_cast<KDIS::PDU::Entity_State_PDU const*>(packet); auto ecef = pdu->GetEntityLocation(); auto geo = ig->getConverter()->geocentricToGeodetic({ecef.GetX(), ecef.GetY(), ecef.GetZ()}); auto w = ig->getConverter()->geodeticToWorld(geo); auto o = pdu->GetEntityOrientation(); // geocentricEulerToGeodeticEuler returns (pitch, roll, heading) in radians vec3 e_rad = ig->getConverter()->geocentricEulerToGeodeticEuler( geo, { o.GetPsiInRadians(), o.GetThetaInRadians(), o.GetPhiInRadians() }); vec3 e_deg = e_rad * (180.0f / 3.14159265f); // Use NED path (not geodeticEulerToRotation), to match what entity->setRotationEuler does auto loc = ig->getNEDConverter()->eulerTARGETToRotation(e_deg); auto wq = ig->getConverter()->getZeroRotation(geo) * loc; renderFrame(w, wq, 0.5f); } Methods moved into GeodeticConverter operate in the coordinate frame adopted in Unigine: X-right, Y-forward, Z-up. Therefore, for the IG plugin, we separated the axis-swapping logic into NEDConverter to avoid confusing other users. Maybe NEDConverter should have its own analogue of geodeticEulerToRotation 1
Amerio.Stephane Posted April 28 Author Posted April 28 Now it works! Thanks a lot for the explanation. I understand naming angles with all these frames and convention is not an easy task, but I think some better labelling would help, less concise and more explicit, maybe more code samples directly in the doc. The name TARGET for example is really not clear to me in eulerTARGETToRotation, what is it referring to? I understand geocentricEuler angles, but now I'm confused about what is geodeticEuler?
silent Posted April 29 Posted April 29 Hi Stephane, We are currently working on a geodetic converter article, hope it would be available soon 🙂 About target: you can define target by calling setTargetSystem() where you can select which system you need (NED / ENU) and *toTarget* will use it. Thank you! 1 How to submit a good bug report --- FTP server for test scenes and user uploads: ftp://files.unigine.com user: upload password: 6xYkd6vLYWjpW6SN
Amerio.Stephane Posted May 5 Author Posted May 5 @cash-metall One more question: I tried to do the inverse of the previous code and couldn't wrap my head around how to do this: How can you convert from a Unigine node WorldPotision and WorldRotation to an entity GeoPosition and Euler Rotation, so I can manually place an entity exactly over some node? (I always end with some funky rotation in all my tests) Thanks!
cash-metall Posted May 6 Posted May 6 The inverse transform is a mirror: Entity::setGeoPosition + Entity::setRotationEuler. Logic: // Given: a node (or any world-space transform) → get geodetic position + euler rotation // that would produce the same result if passed to entity->setGeoPosition / setRotationEuler static void worldTransformToGeo( IG::Manager* ig, const Mat4& world_transform, // e.g. node->getWorldTransform() dvec3& out_geo, // (lat, lon, alt) degrees vec3& out_euler_deg) // euler in same convention as setRotationEuler { // --- position: world → geodetic --- Vec3 wpos = world_transform.getTranslate(); out_geo = ig->getConverter()->worldToGeodetic(wpos); // --- rotation: world quat → local euler --- // Entity::update_transform does: world = Mat4(ltp_rot, ltp_pos) * Mat4(local_rot, local_pos) // So: local_rot = inverse(ltp_rot) * world_rot // Where ltp_rot ≈ getZeroRotation(geo) quat world_rot = world_transform.getRotate(); quat ltp_rot = ig->getConverter()->getZeroRotation(out_geo); quat local_rot = inverse(ltp_rot) * world_rot; // Inverse of eulerTARGETToRotation — matches NED/ENU config out_euler_deg = ig->getNEDConverter()->rotationToEulerTARGET(local_rot); } Usage: // from an existing node dvec3 geo; vec3 euler_deg; worldTransformToGeo(ig->getManager(), node->getWorldTransform(), geo, euler_deg); // verify round-trip: these should reproduce the original world transform auto w = ig->getConverter()->geodeticToWorld(geo); auto loc = ig->getNEDConverter()->eulerTARGETToRotation(euler_deg); auto wq = ig->getConverter()->getZeroRotation(geo) * loc; renderFrame(w, wq, 0.5f); // should match node's frame exactly To convert back to DIS ECEF euler (Psi/Theta/Phi): // geodetic euler (degrees) → geocentric euler (radians) for DIS PDU vec3 geocentric_euler_rad = ig->getConverter()->geodeticEulerToGeocentricEuler(geo, euler_deg); // geocentric_euler_rad = (psi, theta, phi) in radians — ready for DIS Entity_State_PDU The chain: getZeroRotation и eulerTARGETToRotation / rotationToEulerTARGET — are strictly inverce to each other, so round-trip does not cause any losses (except for floating-point precision). If you have some code snippet that doesn't work for you, please let me know. 1
Amerio.Stephane Posted May 6 Author Posted May 6 I had something very similar, and your code produces exactly the same result for me: the pitch rotation is off by 90°. This code was used in 2.20 and earlier to be able to manually "fly" the entity 0 with the Free camera mode from the master. Here is the code now in 2.21: int IGSystemLogic::postUpdate() { if (ig->isMaster() && ig->isDebugEnabled()) { // Detect activation of the IG debug player if (!Console::isActive()) { if (Input::isKeyDown(Input::KEY_F)) is_debug_player = true; if (Input::isKeyDown(Input::KEY_G)) is_debug_player = false; } if (ig_debug_fly.get() && is_debug_player) { auto e = ig->getEntity(0); Vec3 geo; vec3 euler; worldTransformToGeo(ig, Game::getPlayer()->getWorldTransform(), geo, euler); e->setInterpolation(false); e->setGeoPosition(geo); e->setRotationEuler(euler); } } } When testing it, just activate ig_debug 1 and ig_debug_fly 1 (a custom cvar here) and press F: the entity 0 is supposed to replicate the position & orientation of the Free camera, but it appears with the wrong pitch. What did I do wrong?
silent Posted May 6 Posted May 6 Hi Stephane, More likely that happens because camera axes are different from node: https://developer.unigine.com/en/docs/2.21/code/fundamentals/matrices/index?highlighted=camera,cameralll,camerall#direction_vector Node: Camera: 1 How to submit a good bug report --- FTP server for test scenes and user uploads: ftp://files.unigine.com user: upload password: 6xYkd6vLYWjpW6SN
cash-metall Posted May 6 Posted May 6 yes. for camera it should be worldTransformToGeo(ig, Game::getPlayer()->getWorldTransform() * rotateX(-90.0), geo, euler); 1
Amerio.Stephane Posted May 6 Author Posted May 6 Ooooh of course! Not the first time this bites me, but I keep forgetting! Thank you again for helping on this! 1
Recommended Posts