diff --git a/scripts/startup/bl_ui/space_topbar.py b/scripts/startup/bl_ui/space_topbar.py index 1872b3cbd51..e235e592403 100644 --- a/scripts/startup/bl_ui/space_topbar.py +++ b/scripts/startup/bl_ui/space_topbar.py @@ -491,7 +491,7 @@ class TOPBAR_MT_file_export(Menu): self.layout.operator("wm.alembic_export", text="Alembic (.abc)") if bpy.app.build_options.usd: self.layout.operator( - "wm.usd_export", text="Universal Scene Description (.usd, .usdc, .usda)") + "wm.usd_export", text="Universal Scene Description (.usd*)") if bpy.app.build_options.io_gpencil: # Pugixml lib dependency diff --git a/source/blender/blenlib/BLI_fileops.h b/source/blender/blenlib/BLI_fileops.h index 9a10358dcea..2f6393cb154 100644 --- a/source/blender/blenlib/BLI_fileops.h +++ b/source/blender/blenlib/BLI_fileops.h @@ -57,8 +57,8 @@ int BLI_delete(const char *file, bool dir, bool recursive) ATTR_NONNULL(); * \return zero on success (matching 'remove' behavior). */ int BLI_delete_soft(const char *file, const char **error_message) ATTR_NONNULL(); +int BLI_path_move(const char *path, const char *to) ATTR_NONNULL(); #if 0 /* Unused */ -int BLI_move(const char *path, const char *to) ATTR_NONNULL(); int BLI_create_symlink(const char *path, const char *to) ATTR_NONNULL(); #endif diff --git a/source/blender/blenlib/intern/fileops.c b/source/blender/blenlib/intern/fileops.c index d7ab08d8bc8..edc408d4a4f 100644 --- a/source/blender/blenlib/intern/fileops.c +++ b/source/blender/blenlib/intern/fileops.c @@ -458,9 +458,7 @@ int BLI_delete_soft(const char *file, const char **error_message) return err; } -/* Not used anywhere! */ -# if 0 -int BLI_move(const char *file, const char *to) +int BLI_path_move(const char *file, const char *to) { char str[MAXPATHLEN + 12]; int err; @@ -490,7 +488,6 @@ int BLI_move(const char *file, const char *to) return err; } -# endif int BLI_copy(const char *file, const char *to) { @@ -822,8 +819,8 @@ static int delete_soft(const char *file, const char **error_message) Class NSStringClass = objc_getClass("NSString"); SEL stringWithUTF8StringSel = sel_registerName("stringWithUTF8String:"); - id pathString = (( - id(*)(Class, SEL, const char *))objc_msgSend)(NSStringClass, stringWithUTF8StringSel, file); + id pathString = ((id(*)(Class, SEL, const char *))objc_msgSend)( + NSStringClass, stringWithUTF8StringSel, file); Class NSFileManagerClass = objc_getClass("NSFileManager"); SEL defaultManagerSel = sel_registerName("defaultManager"); @@ -834,8 +831,8 @@ static int delete_soft(const char *file, const char **error_message) id nsurl = ((id(*)(Class, SEL, id))objc_msgSend)(NSURLClass, fileURLWithPathSel, pathString); SEL trashItemAtURLSel = sel_registerName("trashItemAtURL:resultingItemURL:error:"); - BOOL deleteSuccessful = (( - BOOL(*)(id, SEL, id, id, id))objc_msgSend)(fileManager, trashItemAtURLSel, nsurl, nil, nil); + BOOL deleteSuccessful = ((BOOL(*)(id, SEL, id, id, id))objc_msgSend)( + fileManager, trashItemAtURLSel, nsurl, nil, nil); if (deleteSuccessful) { ret = 0; @@ -1123,8 +1120,6 @@ static int copy_single_file(const char *from, const char *to) return RecursiveOp_Callback_OK; } -/* Not used anywhere! */ -# if 0 static int move_callback_pre(const char *from, const char *to) { int ret = rename(from, to); @@ -1149,7 +1144,7 @@ static int move_single_file(const char *from, const char *to) /* if *file represents a directory, moves all its contents into *to, else renames * file itself to *to. */ -int BLI_move(const char *file, const char *to) +int BLI_path_move(const char *file, const char *to) { int ret = recursive_operation(file, to, move_callback_pre, move_single_file, NULL); @@ -1159,7 +1154,6 @@ int BLI_move(const char *file, const char *to) return ret; } -# endif static const char *check_destination(const char *file, const char *to) { diff --git a/source/blender/editors/io/io_usd.c b/source/blender/editors/io/io_usd.c index b3cce1eabcf..2b2798986f9 100644 --- a/source/blender/editors/io/io_usd.c +++ b/source/blender/editors/io/io_usd.c @@ -226,7 +226,7 @@ static bool wm_usd_export_check(bContext *UNUSED(C), wmOperator *op) char filepath[FILE_MAX]; RNA_string_get(op->ptr, "filepath", filepath); - if (!BLI_path_extension_check_n(filepath, ".usd", ".usda", ".usdc", NULL)) { + if (!BLI_path_extension_check_n(filepath, ".usd", ".usda", ".usdc", ".usdz", NULL)) { BLI_path_extension_ensure(filepath, FILE_MAX, ".usdc"); RNA_string_set(op->ptr, "filepath", filepath); return true; diff --git a/source/blender/io/usd/CMakeLists.txt b/source/blender/io/usd/CMakeLists.txt index e7a884087b1..21017d1be88 100644 --- a/source/blender/io/usd/CMakeLists.txt +++ b/source/blender/io/usd/CMakeLists.txt @@ -151,7 +151,7 @@ if(WITH_GTESTS) tests/usd_stage_creation_test.cc tests/usd_tests_common.cc tests/usd_tests_common.h - + tests/usd_usdz_export_test.cc intern/usd_writer_material.h ) if(USD_IMAGING_HEADERS) diff --git a/source/blender/io/usd/intern/usd_capi_export.cc b/source/blender/io/usd/intern/usd_capi_export.cc index 7ab244f18f0..9258934030b 100644 --- a/source/blender/io/usd/intern/usd_capi_export.cc +++ b/source/blender/io/usd/intern/usd_capi_export.cc @@ -11,6 +11,7 @@ #include #include #include +#include #include "MEM_guardedalloc.h" @@ -41,21 +42,88 @@ struct ExportJobData { Depsgraph *depsgraph; wmWindowManager *wm; - char filepath[FILE_MAX]; + char unarchived_filepath[FILE_MAX]; /* unarchived_filepath is used for usda/usdc/usd export. */ + char usdz_filepath[FILE_MAX]; USDExportParams params; bool export_ok; timeit::TimePoint start_time; + + bool targets_usdz() const + { + return usdz_filepath[0] != '\0'; + } + + const char *export_filepath() const + { + if (targets_usdz()) { + return usdz_filepath; + } + return unarchived_filepath; + } }; static void report_job_duration(const ExportJobData *data) { timeit::Nanoseconds duration = timeit::Clock::now() - data->start_time; - std::cout << "USD export of '" << data->filepath << "' took "; + const char *export_filepath = data->export_filepath(); + std::cout << "USD export of '" << export_filepath << "' took "; timeit::print_duration(duration); std::cout << '\n'; } +/** + * For usdz export, we must first create a usd/a/c file and then covert it to usdz. In Blender's + * case, we first create a usdc file in Blender's temporary working directory, and store the path + * to the usdc file in `unarchived_filepath`. This function then does the conversion of that usdc + * file into usdz. + * + * \return true when the conversion from usdc to usdz is successful. + */ +static bool perform_usdz_conversion(const ExportJobData *data) +{ + char usdc_temp_dir[FILE_MAX], usdc_file[FILE_MAX]; + BLI_split_dirfile(data->unarchived_filepath, usdc_temp_dir, usdc_file, FILE_MAX, FILE_MAX); + + char usdz_file[FILE_MAX]; + BLI_split_file_part(data->usdz_filepath, usdz_file, FILE_MAX); + + char original_working_dir_buff[FILE_MAX]; + char *original_working_dir = BLI_current_working_dir(original_working_dir_buff, + sizeof(original_working_dir_buff)); + /* Buffer is expected to be returned by #BLI_current_working_dir, although in theory other + * returns are possible on some platforms, this is not handled by this code. */ + BLI_assert(original_working_dir == original_working_dir_buff); + + BLI_change_working_dir(usdc_temp_dir); + + pxr::UsdUtilsCreateNewUsdzPackage(pxr::SdfAssetPath(usdc_file), usdz_file); + BLI_change_working_dir(original_working_dir); + + char usdz_temp_full_path[FILE_MAX]; + BLI_path_join(usdz_temp_full_path, FILE_MAX, usdc_temp_dir, usdz_file); + + int result = 0; + if (BLI_exists(data->usdz_filepath)) { + result = BLI_delete(data->usdz_filepath, false, false); + if (result != 0) { + WM_reportf( + RPT_ERROR, "USD Export: Unable to delete existing usdz file %s", data->usdz_filepath); + return false; + } + } + result = BLI_path_move(usdz_temp_full_path, data->usdz_filepath); + if (result != 0) { + WM_reportf(RPT_ERROR, + "USD Export: Couldn't move new usdz file from temporary location %s to %s", + usdz_temp_full_path, + data->usdz_filepath); + return false; + } + + return true; +} + static void export_startjob(void *customdata, /* Cannot be const, this function implements wm_jobs_start_callback. * NOLINTNEXTLINE: readability-non-const-parameter. */ @@ -89,13 +157,14 @@ static void export_startjob(void *customdata, /* For restoring the current frame after exporting animation is done. */ const int orig_frame = scene->r.cfra; - pxr::UsdStageRefPtr usd_stage = pxr::UsdStage::CreateNew(data->filepath); + pxr::UsdStageRefPtr usd_stage = pxr::UsdStage::CreateNew(data->unarchived_filepath); if (!usd_stage) { /* This happens when the USD JSON files cannot be found. When that happens, * the USD library doesn't know it has the functionality to write USDA and * USDC files, and creating a new UsdStage fails. */ - WM_reportf( - RPT_ERROR, "USD Export: unable to find suitable USD plugin to write %s", data->filepath); + WM_reportf(RPT_ERROR, + "USD Export: unable to find suitable USD plugin to write %s", + data->unarchived_filepath); return; } @@ -159,19 +228,51 @@ static void export_startjob(void *customdata, BKE_scene_graph_update_for_newframe(data->depsgraph); } + if (data->targets_usdz()) { + bool usd_conversion_success = perform_usdz_conversion(data); + if (!usd_conversion_success) { + data->export_ok = false; + *progress = 1.0f; + *do_update = true; + return; + } + } + data->export_ok = true; *progress = 1.0f; *do_update = true; } +static void export_endjob_usdz_cleanup(const ExportJobData *data) +{ + if (!BLI_exists(data->unarchived_filepath)) { + return; + } + + char dir[FILE_MAX]; + BLI_split_dir_part(data->unarchived_filepath, dir, FILE_MAX); + + char usdc_temp_dir[FILE_MAX]; + BLI_path_join(usdc_temp_dir, FILE_MAX, BKE_tempdir_session(), "USDZ", SEP_STR); + + BLI_assert_msg(BLI_strcasecmp(dir, usdc_temp_dir) == 0, + "USD Export: Attempting to delete directory that doesn't match the expected " + "temporary directory for usdz export."); + BLI_delete(usdc_temp_dir, true, true); +} + static void export_endjob(void *customdata) { ExportJobData *data = static_cast(customdata); DEG_graph_free(data->depsgraph); - if (!data->export_ok && BLI_exists(data->filepath)) { - BLI_delete(data->filepath, false, false); + if (data->targets_usdz()) { + export_endjob_usdz_cleanup(data); + } + + if (!data->export_ok && BLI_exists(data->unarchived_filepath)) { + BLI_delete(data->unarchived_filepath, false, false); } G.is_rendering = false; @@ -183,6 +284,36 @@ static void export_endjob(void *customdata) } // namespace blender::io::usd +/* To create a usdz file, we must first create a .usd/a/c file and then covert it to .usdz. The + * temporary files will be created in Blender's temporary session storage. The .usdz file will then + * be moved to job->usdz_filepath. */ +static void create_temp_path_for_usdz_export(const char *filepath, + blender::io::usd::ExportJobData *job) +{ + char file[FILE_MAX]; + BLI_split_file_part(filepath, file, FILE_MAX); + char *usdc_file = BLI_str_replaceN(file, ".usdz", ".usdc"); + + char usdc_temp_filepath[FILE_MAX]; + BLI_path_join(usdc_temp_filepath, FILE_MAX, BKE_tempdir_session(), "USDZ", usdc_file); + + BLI_strncpy(job->unarchived_filepath, usdc_temp_filepath, strlen(usdc_temp_filepath) + 1); + BLI_strncpy(job->usdz_filepath, filepath, strlen(filepath) + 1); + + MEM_freeN(usdc_file); +} + +static void set_job_filepath(blender::io::usd::ExportJobData *job, const char *filepath) +{ + if (BLI_path_extension_check_n(filepath, ".usdz", NULL)) { + create_temp_path_for_usdz_export(filepath, job); + return; + } + + BLI_strncpy(job->unarchived_filepath, filepath, sizeof(job->unarchived_filepath)); + job->usdz_filepath[0] = '\0'; +} + bool USD_export(bContext *C, const char *filepath, const USDExportParams *params, @@ -197,7 +328,7 @@ bool USD_export(bContext *C, job->bmain = CTX_data_main(C); job->wm = CTX_wm_manager(C); job->export_ok = false; - BLI_strncpy(job->filepath, filepath, sizeof(job->filepath)); + set_job_filepath(job, filepath); job->depsgraph = DEG_graph_new(job->bmain, scene, view_layer, params->evaluation_mode); job->params = *params; diff --git a/source/blender/io/usd/tests/usd_usdz_export_test.cc b/source/blender/io/usd/tests/usd_usdz_export_test.cc new file mode 100644 index 00000000000..0de6ea0d8eb --- /dev/null +++ b/source/blender/io/usd/tests/usd_usdz_export_test.cc @@ -0,0 +1,134 @@ +/* SPDX-License-Identifier: GPL-2.0-or-later */ + +#include "testing/testing.h" +#include "tests/blendfile_loading_base_test.h" + +#include +#include + +#include "BKE_appdir.h" +#include "BKE_context.h" +#include "BKE_main.h" +#include "BLI_fileops.h" +#include "BLI_path_util.h" +#include "BLO_readfile.h" + +#include "DEG_depsgraph.h" + +#include "WM_api.h" + +#include "usd.h" +#include "usd_tests_common.h" + +namespace blender::io::usd { + +const StringRefNull usdz_export_test_filename = "usd/usdz_export_test.blend"; +char temp_dir[FILE_MAX]; +char temp_output_dir[FILE_MAX]; +char output_filename[FILE_MAX]; + +class UsdUsdzExportTest : public BlendfileLoadingBaseTest { + protected: + struct bContext *context = nullptr; + + public: + bool load_file_and_depsgraph(const StringRefNull &filepath, + const eEvaluationMode eval_mode = DAG_EVAL_VIEWPORT) + { + if (!blendfile_load(filepath.c_str())) { + return false; + } + depsgraph_create(eval_mode); + + context = CTX_create(); + CTX_data_main_set(context, bfile->main); + CTX_data_scene_set(context, bfile->curscene); + + return true; + } + + virtual void SetUp() override + { + BlendfileLoadingBaseTest::SetUp(); + std::string usd_plugin_path = register_usd_plugins_for_tests(); + if (usd_plugin_path.empty()) { + FAIL(); + } + + BKE_tempdir_init(nullptr); + const char *temp_base_dir = BKE_tempdir_base(); + + BLI_path_join(temp_dir, FILE_MAX, temp_base_dir, "usdz_test_temp_dir"); + BLI_dir_create_recursive(temp_dir); + + BLI_path_join(temp_output_dir, FILE_MAX, temp_base_dir, "usdz_test_output_dir"); + BLI_dir_create_recursive(temp_output_dir); + + BLI_path_join(output_filename, FILE_MAX, temp_output_dir, "output_новый.usdz"); + } + + virtual void TearDown() override + { + BlendfileLoadingBaseTest::TearDown(); + CTX_free(context); + context = nullptr; + + BLI_delete(temp_dir, true, true); + BLI_delete(temp_output_dir, true, true); + } +}; + +TEST_F(UsdUsdzExportTest, usdz_export) +{ + if (!load_file_and_depsgraph(usdz_export_test_filename)) { + ADD_FAILURE(); + return; + } + + /* File sanity check. */ + ASSERT_EQ(BLI_listbase_count(&bfile->main->objects), 4) + << "Blender scene should have 4 objects."; + + char original_cwd_buff[FILE_MAX]; + char *original_cwd = BLI_current_working_dir(original_cwd_buff, sizeof(original_cwd_buff)); + /* Buffer is expected to be returned by #BLI_current_working_dir, although in theory other + * returns are possible on some platforms, this is not handled by this code. */ + ASSERT_EQ(original_cwd, original_cwd_buff) + << "BLI_current_working_dir is not expected to return a different value than the given char " + "buffer."; + + USDExportParams params{}; + + bool result = USD_export(context, output_filename, ¶ms, false); + ASSERT_TRUE(result) << "usd export to " << output_filename << " failed."; + + pxr::UsdStageRefPtr stage = pxr::UsdStage::Open(output_filename); + ASSERT_TRUE(bool(stage)) << "unable to open stage for the exported usdz file."; + + std::string prim_name = pxr::TfMakeValidIdentifier("Cube"); + pxr::UsdPrim test_prim = stage->GetPrimAtPath(pxr::SdfPath("/Cube/" + prim_name)); + EXPECT_TRUE(bool(test_prim)) << "Cube prim should exist in exported usdz file."; + + prim_name = pxr::TfMakeValidIdentifier("Cylinder"); + test_prim = stage->GetPrimAtPath(pxr::SdfPath("/Cylinder/" + prim_name)); + EXPECT_TRUE(bool(test_prim)) << "Cylinder prim should exist in exported usdz file."; + + prim_name = pxr::TfMakeValidIdentifier("Icosphere"); + test_prim = stage->GetPrimAtPath(pxr::SdfPath("/Icosphere/" + prim_name)); + EXPECT_TRUE(bool(test_prim)) << "Icosphere prim should exist in exported usdz file."; + + prim_name = pxr::TfMakeValidIdentifier("Sphere"); + test_prim = stage->GetPrimAtPath(pxr::SdfPath("/Sphere/" + prim_name)); + EXPECT_TRUE(bool(test_prim)) << "Sphere prim should exist in exported usdz file."; + + char final_cwd_buff[FILE_MAX]; + char *final_cwd = BLI_current_working_dir(final_cwd_buff, sizeof(final_cwd_buff)); + /* Buffer is expected to be returned by #BLI_current_working_dir, although in theory other + * returns are possible on some platforms, this is not handled by this code. */ + ASSERT_EQ(final_cwd, final_cwd_buff) << "BLI_current_working_dir is not expected to return " + "a different value than the given char buffer."; + EXPECT_TRUE(STREQ(original_cwd, final_cwd)) + << "Final CWD should be the same as the original one."; +} + +} // namespace blender::io::usd