From b820c2d6be263fa5e086a49d7da83fbbf673e9ea Mon Sep 17 00:00:00 2001 From: Zack Middleton Date: Sat, 30 Dec 2023 02:44:57 -0600 Subject: [PATCH] iqefilter: Add Quake 3 player export for IQE --- doc/html/olh_iqeprompt.htm | 23 ++ doc/html/olh_quakemd3prompt.htm | 4 +- src/implui/iqeprompt.cc | 6 + src/libmm3d/iqefilter.cc | 521 +++++++++++++++++++++++++++++++- src/libmm3d/iqefilter.h | 39 +++ src/libmm3d/md3filter.cc | 2 +- src/qtui/iqeprompt.ui | 22 ++ src/qtui/md3prompt.ui | 4 +- 8 files changed, 615 insertions(+), 6 deletions(-) diff --git a/doc/html/olh_iqeprompt.htm b/doc/html/olh_iqeprompt.htm index 41241fcc..3a361beb 100644 --- a/doc/html/olh_iqeprompt.htm +++ b/doc/html/olh_iqeprompt.htm @@ -7,6 +7,29 @@

IQE Export Options

which animations to save.

+

+ The Save as a Quake 3 Player checkbox allows you indicate + if you want to export as a Quake 3 composite player model + (head.iqe, upper.iqe, and lower.iqe models). + If it is grayed out, see the list of requirements for export on the MD3 page. +

+ +

+ For exporting as a Quake 3 player, there must be a root joint with a desendent torso joint and desendent (of torso joint) head joint. There must be a "tag_torso" point at the exact location of the torso joint and attached to the torso joint. Same for "tag_head" at exact location of head joint and attached to it. The point names must be "tag_torso" and "tag_head", the joint names can be anything. Do not make the root joint be the torso joint; everything will be a desendent of it and there cannot be leg joints. +

+ +

+ Joints/points that are not a desendents of torso joint are exported in lower.iqe. Torso joint and desendent joints/points to (and including) head joint and "tag_head" point are exported to upper.iqe. Head joint and desendent joints/points are exported to head.iqe. +

+ +

+ The Save Quake 3 animation.cfg checkbox allows you to indicate + if you want to write "animation.cfg" file along side the model. + This file contains a list of animations with looping and frames + per-second information and can contain other arbitry data from + MD3_CFG_* meta data. See the MD3 page for details. +

+

The Save Meshes checkbox allows you indicate if you want the meshes saved in the file or not. diff --git a/doc/html/olh_quakemd3prompt.htm b/doc/html/olh_quakemd3prompt.htm index aedd3728..769c0cd5 100644 --- a/doc/html/olh_quakemd3prompt.htm +++ b/doc/html/olh_quakemd3prompt.htm @@ -7,14 +7,14 @@

MD3 Export Options

- The Save as a Player Model checkbox allows you indicate + The Save as a Quake 3 Player checkbox allows you indicate if you want to export as a Quake 3 composite player model (head.md3, upper.md3, and lower.md3 models). If it is grayed out, see the list of requirements for export on the MD3 page.

- The Save animation.cfg checkbox allows you to indicate + The Save Quake 3 animation.cfg checkbox allows you to indicate if you want to write "animation.cfg" file along side the model. This file contains a list of animations with looping and frames per-second information and can contain other arbitry data from diff --git a/src/implui/iqeprompt.cc b/src/implui/iqeprompt.cc index d92a3885..8f3b6dda 100644 --- a/src/implui/iqeprompt.cc +++ b/src/implui/iqeprompt.cc @@ -47,6 +47,9 @@ IqePrompt::~IqePrompt() void IqePrompt::setOptions( IqeFilter::IqeOptions * opts, Model * model ) { + m_saveAsPlayer->setEnabled( opts->m_playerSupported ); + m_saveAsPlayer->setChecked( opts->m_saveAsPlayer ); + m_saveAnimationCfg->setChecked( opts->m_saveAnimationCfg ); m_saveMeshes->setChecked( opts->m_saveMeshes ); m_savePointsJoint->setChecked( opts->m_savePointsJoint ); m_saveSkeleton->setChecked( opts->m_saveSkeleton ); @@ -63,6 +66,8 @@ void IqePrompt::setOptions( IqeFilter::IqeOptions * opts, Model * model ) void IqePrompt::getOptions( IqeFilter::IqeOptions * opts ) { + opts->m_saveAsPlayer = m_saveAsPlayer->isChecked(); + opts->m_saveAnimationCfg = m_saveAnimationCfg->isChecked(); opts->m_saveMeshes = m_saveMeshes->isChecked(); opts->m_savePointsJoint= m_savePointsJoint->isChecked(); opts->m_saveSkeleton = m_saveSkeleton->isChecked(); @@ -93,6 +98,7 @@ bool iqeprompt_show( Model * model, const char * const filename, ModelFilter::Op IqeFilter::IqeOptions * opts = dynamic_cast< IqeFilter::IqeOptions * >( o ); if ( opts ) { + opts->setOptionsFromModel( model, filename ); p.setOptions( opts, model ); if ( p.exec() ) diff --git a/src/libmm3d/iqefilter.cc b/src/libmm3d/iqefilter.cc index 9d3edc05..08e1de8d 100644 --- a/src/libmm3d/iqefilter.cc +++ b/src/libmm3d/iqefilter.cc @@ -47,10 +47,22 @@ using std::list; using std::string; +static const char *s_animSyncWarning[] = +{ + "torso_attack", + "torso_attack2", + "torso_drop", + "torso_raise", + NULL +}; + static const char *IQE_HEADER = "# Inter-Quake Export"; IqeFilter::IqeOptions::IqeOptions() - : m_saveMeshes( true ), + : m_playerSupported( false ), + m_saveAsPlayer( false ), + m_saveAnimationCfg( false ), + m_saveMeshes( true ), m_savePointsJoint( true ), m_saveSkeleton( true ), m_saveAnimations( true ), @@ -62,6 +74,77 @@ IqeFilter::IqeOptions::~IqeOptions() { } +void IqeFilter::IqeOptions::setOptionsFromModel( Model * model, const char * const filename ) +{ + m_playerSupported = false; + + string modelPath = ""; + string modelBaseName = ""; + string modelFullName = ""; + + normalizePath( filename, modelFullName, modelPath, modelBaseName ); + + if ( strncasecmp( modelBaseName.c_str(), "lower.", 6 ) == 0 + || strncasecmp( modelBaseName.c_str(), "upper.", 6 ) == 0 + || strncasecmp( modelBaseName.c_str(), "head.", 5 ) == 0 ) + { + bool haveUpper = false; + bool haveLower = false; + bool haveUnknown = false; + + unsigned gcount = model->getGroupCount(); + for ( unsigned g = 0; g < gcount; g++ ) + { + std::string name = model->getGroupName( g ); + if ( name[0] != '\0' && name[1] == '_' ) + { + switch ( toupper( name[0] ) ) + { + case 'U': + haveUpper = true; + break; + case 'L': + haveLower = true; + break; + case 'H': + break; + default: + haveUnknown = true; + break; + } + } else { + haveUnknown = true; + } + } + + if ( !haveUnknown && haveUpper && haveLower + && model->getPointByName( "tag_torso" ) >= 0 + && model->getPointByName( "tag_head" ) >= 0 ) + { + // have filename, groups, and tags required for player model + m_playerSupported = true; + } + } + + m_saveAsPlayer = m_playerSupported; + m_saveAnimationCfg = m_playerSupported; + + char value[20]; + if ( model->getMetaData( "MD3_composite", value, sizeof( value ) ) ) + { + if ( atoi( value ) == 0 ) + { + m_saveAsPlayer = false; + m_saveAnimationCfg = false; + } + } + + if ( model->getMetaData( "MD3_animationcfg", value, sizeof( value ) ) ) + { + m_saveAnimationCfg = !!atoi( value ); + } +} + IqeFilter::IqeFilter() { } @@ -123,6 +206,101 @@ Model::ModelErrorE IqeFilter::writeFile( Model * model, const char * const filen } } + m_model = model; + + string modelPath = ""; + string modelBaseName = ""; + string modelFullName = ""; + + normalizePath( filename, modelFullName, modelPath, modelBaseName ); + + m_modelPath = modelPath; + + if ( m_options->m_saveAsPlayer ) + { + log_debug( "saving as a player model\n" ); + + Model * section; + std::string playerFile; + std::string path = modelPath + "/"; + + section = m_model->copyQuake3PlayerSection( Model::MS_Lower, Model::ANIMMODE_SKELETAL ); + if ( section ) + { + playerFile = path + fixFileCase( m_modelPath.c_str(), "lower.iqe" ); + writeSectionFile( section, playerFile.c_str() ); + delete section; + } + + section = m_model->copyQuake3PlayerSection( Model::MS_Upper, Model::ANIMMODE_SKELETAL ); + if ( section ) + { + playerFile = path + fixFileCase( m_modelPath.c_str(), "upper.iqe" ); + writeSectionFile( section, playerFile.c_str() ); + delete section; + } + + section = m_model->copyQuake3PlayerSection( Model::MS_Head, Model::ANIMMODE_SKELETAL ); + if ( section ) + { + playerFile = path + fixFileCase( m_modelPath.c_str(), "head.iqe" ); + writeSectionFile( section, playerFile.c_str() ); + delete section; + } + + if ( m_options->m_saveAnimationCfg ) + { + writeAnimations( true, NULL ); + } + else + { + model->addMetaData( "MD3_animationcfg", "0" ); + } + + model->addMetaData( "MD3_composite", "1" ); + model->operationComplete( transll( QT_TRANSLATE_NOOP( "LowLevel", "Set meta data for IQE export" ) ).c_str() ); + + return Model::ERROR_NONE; + } + else + { + log_debug( "saving as a single model\n" ); + + writeSectionFile( model, filename ); + + if ( m_options->m_saveAnimationCfg ) + { + writeAnimations( false, filename ); + } + + if ( m_options->m_saveAnimationCfg || m_options->m_playerSupported ) + { + if ( m_options->m_playerSupported ) + { + model->addMetaData( "MD3_composite", "0" ); + } + if ( m_options->m_saveAnimationCfg ) + { + model->addMetaData( "MD3_animationcfg", "1" ); + } + + model->operationComplete( transll( QT_TRANSLATE_NOOP( "LowLevel", "Set meta data for IQE export" ) ).c_str() ); + } + + return Model::ERROR_NONE; + } + } + else + { + log_error( "no filename supplied for model filter\n" ); + return Model::ERROR_NO_FILE; + } +} + +Model::ModelErrorE IqeFilter::writeSectionFile( Model *model, const char * filename ) +{ + if ( model && filename && filename[0] ) + { Model::ModelErrorE err = Model::ERROR_NONE; DataDest * dst = openOutput( filename, err ); DestCloser fc( dst ); @@ -256,7 +434,12 @@ Model::ModelErrorE IqeFilter::writeFile( Model * model, const char * const filen // coordinate or normal. MM3D does. The mesh_create_list function will // break the model up into meshes where vertices meet the MD3 criteria. // See mesh.h for details. +#if 0 + // TODO: Split up meshes to meet Quake 3 per-mesh triangle and vertex limits. Need an export option dialog and/or meta data. + mesh_create_list( meshes, model, Mesh::MO_All, 2000 - 1, 1000 - 1 ); +#else mesh_create_list( meshes, model ); +#endif vector & modelMaterials = getMaterialList( model ); @@ -525,6 +708,342 @@ bool IqeFilter::writeLine( DataDest *dst, const char * line, ... ) return true; } +// writeAnimations() and it's util functions are copied from Md3Filter. +// Changed to use ANIMMODE_SKELETAL and m_animations. +bool IqeFilter::writeAnimations( bool playerModel, const char * modelName ) +{ + string animFile = m_modelPath + "/animation.cfg"; + bool eliteLoop = false; + bool animKeyword = false; + + Model::ModelErrorE err = Model::ERROR_NONE; + DataDest *dst = openOutput( animFile.c_str(), err ); + DestCloser fc( dst ); + + if ( err != Model::ERROR_NONE ) + { + return false; + } + else + { + log_debug( "writing animation.cfg\n" ); + + if ( modelName ) + { + dst->writePrintf( "// animation config file for %s\r\n", PORT_basename( modelName ) ); + } + else + { + dst->writeString( "// animation config file\r\n" ); + } + + bool hadKeyword = false; + char keyword[1024], value[1024]; + + for (unsigned int i = 0; i < m_model->getMetaDataCount(); i++) + { + if (!m_model->getMetaData(i, keyword, sizeof (keyword), value, sizeof (value))) + continue; + + if (strncasecmp(keyword, "MD3_CFG_", 8) == 0) + { + if (!hadKeyword) + { + hadKeyword = true; + dst->writeString( "\r\n" ); + } + if (strlen(value) > 0) + dst->writePrintf( "%s %s\r\n", &keyword[8], value ); + else + dst->writePrintf( "%s\r\n", &keyword[8] ); + } + // Support old keywords + else if (strncasecmp(keyword, "MD3_sex", 7) == 0 + || strncasecmp(keyword, "MD3_footsteps", 13) == 0 + || strncasecmp(keyword, "MD3_headoffset", 14) == 0 + || strncasecmp(keyword, "MD3_fixedtorso", 14) == 0 + || strncasecmp(keyword, "MD3_fixedlegs", 13) == 0) + { + if (!hadKeyword) + { + hadKeyword = true; + dst->writeString( "\r\n" ); + } + if (strlen(value) > 0) + dst->writePrintf( "%s %s\r\n", &keyword[4], value ); + else + dst->writePrintf( "%s\r\n", &keyword[4] ); + } + // animations.cfg format settings + else if (strncasecmp(keyword, "MD3_EliteLoop", 13) == 0) + { + eliteLoop = (atoi(value) > 0); + } + else if (strncasecmp(keyword, "MD3_AnimKeyword", 15) == 0) + { + animKeyword = (atoi(value) > 0); + } + } + + dst->writeString( "\r\n" ); + + dst->writeString( "// frame data:\r\n" ); + dst->writeString( "// first count looping fps\r\n\r\n" ); + + char warning[] = " (MUST NOT CHANGE -- hand animation is synced to this)"; + + size_t longestName = 16; // minimum name length for spacing + std::vector::iterator it; + for (it = m_options->m_animations.begin(); it != m_options->m_animations.end(); it++) + { + unsigned anim = *it; + std::string name = getSafeName( anim ); + size_t len = name.length(); + if (len > longestName) { + longestName = len; + } + } + + for (it = m_options->m_animations.begin(); it != m_options->m_animations.end(); it++) + { + unsigned anim = *it; + int animFrame = 0; + int count = 1; + int fps = 15; + if (!getExportAnimData( playerModel, (int)anim, animFrame, count, fps )) + { + continue; + } + + int loop = count; // loop by default + + std::string name = getSafeName( anim ); + size_t len = name.length(); + for ( size_t n = 0; n < len; n++ ) + { + name[n] = std::toupper(name[n]); + } + + // disable looping on non-looping anims + if ( !m_model->getAnimLooping( Model::ANIMMODE_SKELETAL, anim ) ) + { + loop = 0; + } + + // Convert to Elite Force Single Player Style + if (eliteLoop) + { + if (loop == 0) + loop = -1; // No loop + else + loop = 0; // Loop + } + + if (animKeyword) + { + // Align animFrame + const size_t MAX_SPACES = 40; + char spaces[MAX_SPACES+2] = {' ',' ',' ',' ',' ',' ',' ',' ',' ',' ',' ',' ',' ',' ',' ',' ',' ',' ',' ',' ', + ' ',' ',' ',' ',' ',' ',' ',' ',' ',' ',' ',' ',' ',' ',' ',' ',' ',' ',' ',' ','\0'}; + size_t maxSpaces = (longestName+6 > MAX_SPACES) ? MAX_SPACES : longestName+6; + + spaces[((name.length() < maxSpaces) ? (maxSpaces - name.length()) : 0)] = '\0'; + + if (animSyncWarning(name)) + dst->writePrintf( "%s%s\t%d\t%d\t%d\t%d\t\t// %s\r\n", + name.c_str(), spaces, animFrame, count, loop, fps, warning ); + else + dst->writePrintf( "%s%s\t%d\t%d\t%d\t%d\r\n", + name.c_str(), spaces, animFrame, count, loop, fps ); + } + else + { + dst->writePrintf( "%d\t%d\t%d\t%d\t\t// %s%s\r\n", + animFrame, count, loop, fps, name.c_str(), + (animSyncWarning(name) ? warning : "") ); + } + } + return true; + } +} + +std::string IqeFilter::getSafeName( unsigned int anim ) +{ + std::string animName = "none"; + + if ( anim < m_model->getAnimCount( Model::ANIMMODE_SKELETAL ) ) + { + animName = m_model->getAnimName( Model::ANIMMODE_SKELETAL, anim ); + } + + return animName; +} + +bool IqeFilter::animSyncWarning(std::string name) +{ + char value[20]; + + if ( m_model->getMetaData( "MD3_NoSyncWarning", value, sizeof(value) ) ) + { + if ( atoi( value ) == 1 ) + return false; + } + + for (unsigned i = 0; s_animSyncWarning[i] != NULL; i++) + { + if (strncasecmp(s_animSyncWarning[i], name.c_str(), name.length()) == 0) + { + return true; + } + } + return false; +} + +IqeFilter::MeshAnimationTypeE IqeFilter::getAnimationType( bool playerModel, const std::string & animName ) +{ + MeshAnimationTypeE animType = MA_All; + + if ( !playerModel ) + { + return MA_All; + } + + if (strncasecmp(animName.c_str(), "both_", 5) == 0) + { + animType = MA_Both; + } + else if (strncasecmp(animName.c_str(), "torso_", 6) == 0) + { + animType = MA_Torso; + } + else if (strncasecmp(animName.c_str(), "legs_", 5) == 0) + { + animType = MA_Legs; + } + else if (strncasecmp(animName.c_str(), "head_", 5) == 0) + { + animType = MA_Head; + } + + return animType; +} + +bool IqeFilter::animInSection( std::string animName, MeshSectionE section ) +{ + if ( strncasecmp( animName.c_str(), "torso_", 6 ) == 0 ) + { + if ( section == MS_Upper ) + return true; + else + return false; + } + if ( strncasecmp( animName.c_str(), "legs_", 5 ) == 0 ) + { + if ( section == MS_Lower ) + return true; + else + return false; + } + + if ( strncasecmp( animName.c_str(), "head_", 5 ) == 0 ) + { + if ( section == MS_Head ) + return true; + else + return false; + } + + // It's a "both_" animation, or something weird + if ( strncasecmp( animName.c_str(), "both_", 5 ) == 0 ) + { + if ( section == MS_Lower || section == MS_Upper ) + return true; + else + return false; + } + + if ( strncasecmp( animName.c_str(), "all_", 4 ) == 0 ) + { + // Animation for torso, legs, and head! + return true; + } + + return false; +} + +bool IqeFilter::getExportAnimData( bool playerModel, int modelAnim, + int & fileFrame, int & frameCount, int & fps ) +{ + fileFrame = 0; + frameCount = 0; + + std::string animName = getSafeName( modelAnim ); + MeshAnimationTypeE animType = getAnimationType( playerModel, animName ); + + // If this is a "dead" animation and its after a "death" animation + // and it has 0 frames, use the last frame of the death animation. + if (modelAnim > 0 && ((strncasecmp(animName.c_str(), "all_dead", 8) == 0 + && strncasecmp(getSafeName( modelAnim - 1 ).c_str(), "all_death", 9) == 0) + || (strncasecmp(animName.c_str(), "both_dead", 9) == 0 + && strncasecmp(getSafeName( modelAnim - 1 ).c_str(), "both_death", 10) == 0)) + && m_model->getAnimFrameCount( Model::ANIMMODE_SKELETAL, modelAnim ) == 0 ) + { + if ( getExportAnimData( playerModel, modelAnim - 1, fileFrame, frameCount, fps ) ) + { + fileFrame += frameCount - 1; + frameCount = 1; + return true; + } + } + + std::vector::iterator it; + for (it = m_options->m_animations.begin(); it != m_options->m_animations.end(); it++) + { + size_t a = *it; + std::string name = getSafeName( a ); + if ( !playerModel + || animInSection( name, MS_Upper ) + || animInSection( name, MS_Lower ) + || animInSection( name, MS_Head ) ) + { + MeshAnimationTypeE type = getAnimationType( playerModel, name ); + if ( (int)a == modelAnim ) + { + frameCount = m_model->getAnimFrameCount( Model::ANIMMODE_SKELETAL, a ); + fps = (int) m_model->getAnimFPS( Model::ANIMMODE_SKELETAL, a ); + + if ( fps <= 0 ) // just being paranoid + { + fps = 15; + } + + if (animType > MA_Torso) + { + // Must still count torso animations after this for fileFrame + // and in the case of 'head' we must also count legs animations + } + else + { + return true; + } + } + // All torso frames go before leg frames, all legs go after torso + else if (((int)a < modelAnim && animType >= type) || ((int)a > modelAnim && animType > type)) + { + fileFrame += m_model->getAnimFrameCount( Model::ANIMMODE_SKELETAL, a ); + } + } + } + + if (animType > MA_Torso && frameCount) + { + // Finished adding up fileFrame for legs or head animations + return true; + } + + return false; +} + bool IqeFilter::canRead( const char * filename ) { return false; diff --git a/src/libmm3d/iqefilter.h b/src/libmm3d/iqefilter.h index bac7b4e9..85107bda 100644 --- a/src/libmm3d/iqefilter.h +++ b/src/libmm3d/iqefilter.h @@ -40,12 +40,19 @@ class IqeFilter : public ModelFilter virtual void release() { delete this; }; + bool m_playerSupported; + + bool m_saveAsPlayer; + bool m_saveAnimationCfg; + bool m_saveMeshes; bool m_savePointsJoint; bool m_saveSkeleton; bool m_saveAnimations; std::vector m_animations; + void setOptionsFromModel( Model * m, const char * const filename ); + protected: virtual ~IqeOptions(); // Use release() instead }; @@ -69,9 +76,41 @@ class IqeFilter : public ModelFilter protected: + typedef enum _MeshSection_e + { + MS_Lower = 0, + MS_Upper, + MS_Head, + MS_MAX + } MeshSectionE; + + // the order is important, used for writing continue frames by type + typedef enum _MeshAnimationType_e + { + MA_All, + MA_Both, + MA_Torso, + MA_Legs, + // NOTE: Team Arena has extra torso animations after legs + MA_Head, + MA_MAX + } MeshAnimationTypeE; + IqeOptions * m_options; + Model * m_model; + std::string m_modelPath; bool writeLine( DataDest *dst, const char * line, ... ) __attribute__ ((format (printf, 3, 4))); + + Model::ModelErrorE writeSectionFile( Model *model, const char * filename ); + + bool writeAnimations( bool playerModel, const char * filename ); + std::string getSafeName( unsigned int anim ); + bool animSyncWarning(std::string name); + MeshAnimationTypeE getAnimationType( bool playerModel, const std::string & animName ); + bool animInSection( std::string animName, MeshSectionE section ); + bool getExportAnimData( bool playerModel, int modelAnim, + int & fileFrame, int & frameCount, int & fps ); }; #endif // __IQEFILTER_H diff --git a/src/libmm3d/md3filter.cc b/src/libmm3d/md3filter.cc index 09222b35..775c37e3 100644 --- a/src/libmm3d/md3filter.cc +++ b/src/libmm3d/md3filter.cc @@ -100,7 +100,7 @@ const char s_animNames[ MD3_ANIMATIONS ][16] = "legs_turn", }; -const char *s_animSyncWarning[] = +static const char *s_animSyncWarning[] = { "torso_attack", "torso_attack2", diff --git a/src/qtui/iqeprompt.ui b/src/qtui/iqeprompt.ui index 30404f1a..c02b3bec 100644 --- a/src/qtui/iqeprompt.ui +++ b/src/qtui/iqeprompt.ui @@ -14,6 +14,26 @@ IQE Filter Options + + + + Save as a Quake 3 Player (head.iqe, upper.iqe, lower.iqe) + + + true + + + + + + + Save Quake 3 animation.cfg + + + true + + + @@ -124,6 +144,8 @@ + m_saveAsPlayer + m_saveAnimationCfg m_saveMeshes m_savePointsJoint m_saveSkeleton diff --git a/src/qtui/md3prompt.ui b/src/qtui/md3prompt.ui index 181b43bc..48cb7983 100644 --- a/src/qtui/md3prompt.ui +++ b/src/qtui/md3prompt.ui @@ -17,7 +17,7 @@ - Save as a Player Model (head.md3, upper.md3, lower.md3) + Save as a Quake 3 Player (head.md3, upper.md3, lower.md3) true @@ -27,7 +27,7 @@ - Save animation.cfg + Save Quake 3 animation.cfg true