save startup blend for animation tab & whatnot
This commit is contained in:
2026-04-08 12:10:18 -06:00
parent 57a652524a
commit 692e200ffe
180 changed files with 12336 additions and 3431 deletions
+9 -9
View File
@@ -10,13 +10,13 @@ D:\Work\9 iClone\Amazon\
D:\Amazon\00_external-files\
N:\1. CHARACTERS\remapping\
[Recent]
P:\250827_FestivalTurf\Blends\animations\05 Stretch Cut Nail\
P:\250827_FestivalTurf\Blends\animations\02 Preparing the Base\Watering Ground\
P:\250827_FestivalTurf\Assets\Blends\
P:\250827_FestivalTurf\Blends\animations\01 Develop Your Turf Plan\SPA\
P:\250827_FestivalTurf\Assets\Blends\Char\
P:\260217_Jarvis-Defense\Blends\animations\comp_RR\
C:\Users\Nathan\AppData\Local\Temp\
T:\260217_Jarvis-Defense\Renders\Shot_4a\
F:\renders\Shot_4a_holdout_test\2026-03-25_101242\
F:\renders\
P:\250827_FestivalTurf\Blends\animations\01 Develop Your Turf Plan\
T:\250827_FestivalTurf\Blends\animations\02 Preparing the Base\
P:\250827_FestivalTurf\Blends\animations\06 Infill And Powerbrooming\
A:\1 Amazon_Active_Projects\260127_Pick-and-Stage_2026_edits\Blends\stills\2025_old\img-BG\
A:\1 Amazon_Active_Projects\260127_Pick-and-Stage_2026_edits\Blends\stills\2025_old\
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\animations\
P:\250827_FestivalTurf\Assets\
P:\250827_FestivalTurf\Blends\animations\02 Preparing the Base\bakes\07_compacting\
P:\250827_FestivalTurf\Blends\animations\02 Preparing the Base\
+3 -3
View File
@@ -1,9 +1,9 @@
{
"flamenco_version": {
"version": "3.8.2",
"shortversion": "3.8.2",
"version": "3.8.5",
"shortversion": "3.8.5",
"name": "Flamenco",
"git": "51a41a19"
"git": "690bf6ce"
},
"shared_storage": {
"location": "F:\\jobs",
+171 -171
View File
@@ -1,3 +1,174 @@
P:\250827_FestivalTurf\Blends\animations\02 Preparing the Base\06_Watering.blend
T:\250827_FestivalTurf\Blends\animations\02 Preparing the Base\06_Watering.blend
P:\250827_FestivalTurf\Blends\animations\06 Infill And Powerbrooming\Visual 2B_SPA.blend
P:\250827_FestivalTurf\Blends\animations\FT-lipsync_SPA.blend
P:\250827_FestivalTurf\Blends\animations\06 Infill And Powerbrooming\Visual 2D_SPA.blend
P:\250827_FestivalTurf\Blends\animations\06 Infill And Powerbrooming\Visual 2B.blend
P:\250827_FestivalTurf\Blends\animations\06 Infill And Powerbrooming\Visual 2C.blend
P:\250827_FestivalTurf\Blends\animations\06 Infill And Powerbrooming\Visual 2D.blend
A:\1 Amazon_Active_Projects\260127_Pick-and-Stage_2026_edits\Blends\stills\2025_old\iP&S2025_23.blend
P:\250827_FestivalTurf\Blends\animations\01 Develop Your Turf Plan\SPA\FT-lipsync.blend
P:\250827_FestivalTurf\Blends\animations\01 Develop Your Turf Plan\SPA\01_Blueprint_SPA.blend
P:\250827_FestivalTurf\Blends\animations\04 Securing Your Seam\SPA\06_FT_Hammering_insert_SPA.blend
P:\250827_FestivalTurf\Blends\animations\07 Final Touches And Maintenance\SPA\Visual_0_opening.blend
P:\250827_FestivalTurf\Blends\animations\06 Infill And Powerbrooming\Visual 3B.blend
P:\250827_FestivalTurf\Blends\animations\06 Infill And Powerbrooming\SPA\Visual 3B_SPA.blend
P:\250827_FestivalTurf\Blends\animations\05 Stretch Cut Nail\SPA\04_stretching pattern.blend
P:\250827_FestivalTurf\Blends\animations\05 Stretch Cut Nail\SPA\Visual 6_INSERT.blend
P:\250827_FestivalTurf\Blends\animations\05 Stretch Cut Nail\SPA\Visual 8 insert.blend
P:\250827_FestivalTurf\Blends\animations\05 Stretch Cut Nail\SPA\03_FT Shuffle.blend
P:\250827_FestivalTurf\Blends\animations\05 Stretch Cut Nail\SPA\Visual 8 insert2.blend
P:\250827_FestivalTurf\Blends\animations\05 Stretch Cut Nail\SPA\01_intro_SPA.blend
A:\1 Amazon_Active_Projects\260127_Pick-and-Stage_2026_edits\Blends\animations\BD2\P&S_BD2_animation 12b.blend
A:\1 Amazon_Active_Projects\260127_Pick-and-Stage_2026_edits\Blends\animations\BD2\P&S_BD2_animation 12a.blend
P:\250827_FestivalTurf\Blends\animations\02 Preparing the Base\07_compacting.blend
P:\250827_FestivalTurf\Blends\animations\02 Preparing the Base\03_dirt to gravel.blend
P:\250827_FestivalTurf\Blends\animations\02 Preparing the Base\09_level ground.blend
P:\250827_FestivalTurf\Blends\animations\02 Preparing the Base\05_raking.blend
P:\250827_FestivalTurf\Assets\Blends\Props\Tools.blend
P:\250827_FestivalTurf\Blends\animations\01 Develop Your Turf Plan\01_Blueprint.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\animations\RSR_fluid_unload_animation 6defgh.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\animations\RSR_fluid_unload_animation 6bc.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\animations\RSR_fluid_unload_animation 6a.blend
P:\250827_FestivalTurf\Blends\animations\02 Preparing the Base\04_Rock Yard.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\animations\RSR_fluid_unload_animation 6I.blend
P:\250827_FestivalTurf\Blends\animations\02 Preparing the Base\01_intro.blend
P:\250827_FestivalTurf\Blends\animations\07 Final Touches And Maintenance\Visual_new_final.blend
P:\250827_FestivalTurf\Blends\animations\07 Final Touches And Maintenance\Visual_8.blend
P:\250827_FestivalTurf\Blends\animations\07 Final Touches And Maintenance\Visual_7.blend
P:\250827_FestivalTurf\Blends\animations\07 Final Touches And Maintenance\Visual_7_phone_insert.blend
P:\250827_FestivalTurf\Blends\animations\07 Final Touches And Maintenance\Visual_7_reframe.blend
P:\250827_FestivalTurf\Blends\animations\07 Final Touches And Maintenance\Visual_7_phone_insert_alt.blend
P:\250827_FestivalTurf\Blends\animations\07 Final Touches And Maintenance\Visual_5_heat_damage_insert_1.blend
P:\250827_FestivalTurf\Blends\animations\07 Final Touches And Maintenance\Visual_5.blend
P:\250827_FestivalTurf\Blends\animations\07 Final Touches And Maintenance\Visual_4_leaf_blower_insert.blend
P:\250827_FestivalTurf\Blends\animations\07 Final Touches And Maintenance\Visual_4_leaf_blower.blend
P:\250827_FestivalTurf\Blends\animations\07 Final Touches And Maintenance\Visual_3_PE_spread.blend
P:\250827_FestivalTurf\Blends\animations\07 Final Touches And Maintenance\Visual_2_push_broom.blend
P:\250827_FestivalTurf\Blends\animations\07 Final Touches And Maintenance\Visual_2_power_broom.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\animations\RSR_fluid_unload_SHORT_3b.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\animations\RSR_fluid_unload_SHORT_5b.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\animations\RSR_fluid_unload_SHORT_5aA.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\animations\RSR_fluid_unload_SHORT_4bC.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\animations\RSR_fluid_unload_SHORT_4bB.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\animations\RSR_fluid_unload_SHORT_4bA.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\animations\RSR_fluid_unload_SHORT_4aB.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\animations\RSR_fluid_unload_SHORT_4aA_part2.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\animations\RSR_fluid_unload_SHORT_4aA.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\animations\RSR_fluid_unload_SHORT_3c.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\animations\RSR_fluid_unload_SHORT_3a_insert2.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\animations\RSR_fluid_unload_SHORT_3a_insert1.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\animations\RSR_fluid_unload_SHORT_3a.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\animations\RSR_fluid_unload_SHORT_2c.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\animations\RSR_fluid_unload_SHORT_2b.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\animations\RSR_fluid_unload_SHORT_2a.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\animations\RSR_fluid_unload_animation 4.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\animations\RSR_fluid_unload_animation 3A.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\animations\RSR_fluid_unload_animation 5a.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\animations\RSR_fluid_unload_animation 5d.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\animations\RSR_fluid_unload_animation 5bc.blend
F:\jobs\RSR_fluid_unload_animation 5a\RSR_fluid_unload_animation 5a.flamenco.blend
A:\@GMT-2026.04.02-18.00.00\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\animations\RSR_fluid_unload_animation 5a.blend
A:\@GMT-2026.04.02-17.00.00\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\animations\RSR_fluid_unload_animation 5a.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\animations\RSR_fluid_unload_animation 4b.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\animations\RSR_fluid_unload_animation 3C.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\animations\RSR_fluid_unload_animation 2.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\animations\RSR_fluid_unload_animation 1D.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\animations\RSR_fluid_unload_animation 1c.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\animations\RSR_fluid_unload_animation 1b_c.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\animations\RSR_fluid_unload_animation 1a.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\animations\RSR_fluid_unload_animation 1b_b.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\animations\RSR_fluid_unload_animation 1b_a.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\animations\RSR_fluid_unload_animation 1b.blend
A:\1 Amazon_Active_Projects\260225_Problem-Solve_2026\Blends\animations\PS_2026_animation short 1b.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\stills\iRSR_fluid_unload_7d.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\stills\iRSR_fluid_unload_7c.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\stills\iRSR_fluid_unload_24c.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\stills\iRSR_fluid_unload_25b.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\stills\iRSR_fluid_unload_25a.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\stills\iRSR_fluid_unload_24d.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\stills\iRSR_fluid_unload_24e.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\animations\noncon_OOG_animation 1_CYCLES.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\stills\iRSR_fluid_unload_16e.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\stills\iRSR_fluid_unload_24a.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\stills\iRSR_fluid_unload_4b.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\stills\iRSR_fluid_unload_24b.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\stills\iRSR_fluid_unload_16o.blend
A:\1 Amazon_Active_Projects\1 BlenderAssets\Amazon\Props\Pallete_Broken_Planks.blend
A:\1 Amazon_Active_Projects\1 BlenderAssets\Amazon\Props\Pallete_Broken.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\stills\iRSR_fluid_unload_24b_.blend
C:\Users\Nathan\AppData\Local\Temp\2026-04-02_13-44_iRSR_fluid_unload_24b.blend
T:\1 BlenderAssets\Amazon\Props\Pallete_Broken.blend
A:\1 Amazon_Active_Projects\1 BlenderAssets\Amazon\amazon-3Dworld-assets_v4.0.blend
C:\Users\Nathan\AppData\Local\Temp\2026-04-02_12-09_RSR_fluid_unload_SHORT_3a.blend
C:\Users\Nathan\AppData\Local\Temp\2026-04-02_12-11_RSR_fluid_unload_SHORT_3a.blend
C:\Users\Nathan\AppData\Local\Temp\2026-04-02_12-13_RSR_fluid_unload_SHORT_3a.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\stills\iRSR_fluid_unload_19c.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\stills\iRSR_fluid_unload_23b.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\stills\iRSR_fluid_unload_23a.blend
A:\1 Amazon_Active_Projects\1 BlenderAssets\Amazon\Props\Long-Pallete.blend
A:\1 Amazon_Active_Projects\1 BlenderAssets\Amazon\Props\Shuttle_v2.0.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\stills\iRSR_fluid_unload_19d.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\stills\iRSR_fluid_unload_7.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\stills\i_noncon_OOG_2c.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\stills\iRSR_fluid_unload_19e.blend
A:\1 Amazon_Active_Projects\260206_Dock_Unified\Blends\animations\Dock\DOCK_shrinkwrap_s3.blend
A:\1 Amazon_Active_Projects\260206_Dock_Unified\Blends\animations\Dock\DOCK_shrinkwrap_s4.blend
A:\1 Amazon_Active_Projects\260206_Dock_Unified\Blends\animations\Dock\DOCK_shrinkwrap_s2.blend
A:\1 Amazon_Active_Projects\260206_Dock_Unified\Blends\animations\Dock\DOCK_shrinkwrap_s1.blend
A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Assets\Blends\PAM.blend
P:\250827_FestivalTurf\Blends\animations\07 Final Touches And Maintenance\Visual_2_broom.blend
P:\250827_FestivalTurf\Blends\animations\07 Final Touches And Maintenance\Visual_1_respray_insert.blend
P:\250827_FestivalTurf\Blends\animations\07 Final Touches And Maintenance\Visual_1_respray.blend
P:\250827_FestivalTurf\Blends\animations\07 Final Touches And Maintenance\Visual_0_5_sheering_insert.blend
P:\250827_FestivalTurf\Blends\animations\07 Final Touches And Maintenance\Visual_0_5_talking.blend
P:\250827_FestivalTurf\Blends\animations\06 Infill And Powerbrooming\Visual 5B_insert.blend
P:\250827_FestivalTurf\Blends\animations\06 Infill And Powerbrooming\Visual 5.blend
A:\1 Amazon_Active_Projects\260127_Pick-and-Stage_2026_edits\Blends\animations\2025_old\P&S_safety_refresher_1d_rev2.blend
A:\1 Amazon_Active_Projects\260127_Pick-and-Stage_2026_edits\Blends\animations\2025_old\P&S_safety_refresher_1c_rev2.blend
A:\1 Amazon_Active_Projects\260127_Pick-and-Stage_2026_edits\Blends\animations\2025_old\P&S_safety_refresher_1c.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\animations\SPA_noncon_OOG_short_animation 9a.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\animations\noncon_OOG_short_animation 9a_v3.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\animations\noncon_OOG_short_animation 9a.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\animations\noncon_OOG_short_animation 3a.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Assets\Blends\lipsync.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\animations\noncon_OOG_short_animation 1b.blend
A:\1 Amazon_Active_Projects\260127_Pick-and-Stage_2026_edits\Blends\animations\2026_new\P&S_2026_animation short 1c.blend
A:\1 Amazon_Active_Projects\260127_Pick-and-Stage_2026_edits\Blends\animations\2025_old\P&S2025_anim_2a.blend
A:\1 Amazon_Active_Projects\260127_Pick-and-Stage_2026_edits\Blends\animations\2025_old\P&S2025_anim_2b.blend
A:\1 Amazon_Active_Projects\260127_Pick-and-Stage_2026_edits\Blends\animations\2025_old\P&S2025_anim_2c.blend
A:\1 Amazon_Active_Projects\260127_Pick-and-Stage_2026_edits\Blends\stills\iP&S_2026_2b.blend
A:\1 Amazon_Active_Projects\260127_Pick-and-Stage_2026_edits\Blends\stills\iP&S_2026_2a.blend
A:\1 Amazon_Active_Projects\260127_Pick-and-Stage_2026_edits\Blends\stills\iP&S_2026_1b.blend
T:\260127_Pick-and-Stage_2026_edits\Blends\stills\iP&S_2026_2b.blend
A:\1 Amazon_Active_Projects\260127_Pick-and-Stage_2026_edits\Blends\stills\2026_new\iP&S_2026_2b.blend
A:\1 Amazon_Active_Projects\260127_Pick-and-Stage_2026_edits\Blends\stills\2026_new\iP&S_2026_2a.blend
C:\Users\Nathan\AppData\Local\Temp\2026-03-27_15-23_2026-03-27_15-13_P&S2025_anim_2a.blend
C:\Users\Nathan\AppData\Local\Temp\2026-03-27_15-13_P&S2025_anim_2a.blend
C:\Users\Nathan\AppData\Local\Temp\2026-03-27_15-19_P&S2025_anim_2a.blend
C:\Users\Nathan\AppData\Local\Temp\2026-03-27_15-21_P&S2025_anim_2a.blend
A:\1 Amazon_Active_Projects\260127_Pick-and-Stage_2026_edits\Blends\animations\2025_old\P&S2025_anim_3a.blend
A:\1 Amazon_Active_Projects\260127_Pick-and-Stage_2026_edits\Blends\animations\2025_old\P&S2025_anim_3b.blend
A:\1 Amazon_Active_Projects\260127_Pick-and-Stage_2026_edits\Blends\animations\2025_old\P&S2025_anim_1a.blend
P:\250827_FestivalTurf\Blends\animations\06 Infill And Powerbrooming\Visual 6.blend
P:\250827_FestivalTurf\Blends\animations\05 Stretch Cut Nail\Visual 6.blend
P:\250827_FestivalTurf\Blends\animations\06 Infill And Powerbrooming\Visual 5B_alt_vo.blend
P:\250827_FestivalTurf\Blends\animations\06 Infill And Powerbrooming\Visual 5B.blend
P:\250827_FestivalTurf\Blends\animations\06 Infill And Powerbrooming\Visual 4B.blend
P:\250827_FestivalTurf\Blends\animations\06 Infill And Powerbrooming\Visual 4A.blend
P:\250827_FestivalTurf\Blends\animations\06 Infill And Powerbrooming\Visual 4A_2.blend
P:\250827_FestivalTurf\Blends\animations\06 Infill And Powerbrooming\Visual 3A.blend
E:\250827_FestivalTurf\Blends\animations\06 Infill And Powerbrooming\Visual 3A.blend
P:\250827_FestivalTurf\Blends\animations\06 Infill And Powerbrooming\Visual 3A_.blend
T:\250827_FestivalTurf\Blends\animations\06 Infill And Powerbrooming\Visual 3A.blend
T:\250827_FestivalTurf\Blends\animations\06 Infill And Powerbrooming\Visual 3B.blend
P:\250827_FestivalTurf\Blends\animations\06 Infill And Powerbrooming\01_opening.blend
T:\250827_FestivalTurf\Blends\animations\06 Infill And Powerbrooming\Visual 2D.blend
P:\250827_FestivalTurf\Blends\animations\06 Infill And Powerbrooming\Visual 2A.blend
T:\250827_FestivalTurf\Blends\animations\06 Infill And Powerbrooming\Visual 2C.blend
T:\250827_FestivalTurf\Blends\animations\06 Infill And Powerbrooming\Visual 2B.blend
P:\250827_FestivalTurf\Assets\Blends\Char\FT-rig_moustache.blend
P:\250827_FestivalTurf\Assets\Blends\Char\FT-rig_moustache_fixed.blend
T:\250827_FestivalTurf\Blends\animations\05 Stretch Cut Nail\Visual 11.blend
P:\250827_FestivalTurf\Blends\animations\05 Stretch Cut Nail\Visual 11.blend
P:\250827_FestivalTurf\Blends\animations\05 Stretch Cut Nail\Visual 10.blend
@@ -8,7 +179,6 @@ P:\250827_FestivalTurf\Blends\animations\05 Stretch Cut Nail\Visual 8 insert.ble
P:\250827_FestivalTurf\Blends\animations\05 Stretch Cut Nail\01_intro.blend
P:\250827_FestivalTurf\Blends\animations\05 Stretch Cut Nail\Visual 7.blend
P:\250827_FestivalTurf\Blends\animations\05 Stretch Cut Nail\Visual 6_INSERT.blend
P:\250827_FestivalTurf\Blends\animations\05 Stretch Cut Nail\Visual 6.blend
P:\250827_FestivalTurf\Blends\animations\05 Stretch Cut Nail\04_talking.blend
P:\250827_FestivalTurf\Blends\animations\05 Stretch Cut Nail\04_stretching pattern.blend
P:\250827_FestivalTurf\Blends\animations\05 Stretch Cut Nail\03_FT Shuffle_Intro.blend
@@ -16,8 +186,6 @@ P:\250827_FestivalTurf\Blends\animations\05 Stretch Cut Nail\03_FT Shuffle.blend
P:\250827_FestivalTurf\Blends\animations\05 Stretch Cut Nail\02_talking.blend
P:\250827_FestivalTurf\Blends\animations\05 Stretch Cut Nail\02_kicker insert.blend
P:\260217_Jarvis-Defense\Blends\animations\Shot_Q.blend
P:\250827_FestivalTurf\Blends\animations\01 Develop Your Turf Plan\SPA\01_Blueprint_SPA.blend
P:\250827_FestivalTurf\Blends\animations\01 Develop Your Turf Plan\01_Blueprint.blend
F:\jobs\Shot_5c\Shot_Q.flamenco.blend
F:\jobs\Shot5c-e3zp\Shot_Q.flamenco.blend
P:\260217_Jarvis-Defense\Blends\animations\Shot_4.blend
@@ -30,171 +198,3 @@ P:\260217_Jarvis-Defense\Assets\Blends\3075 Decade Dr.blend
P:\260217_Jarvis-Defense\Blends\animations\comp_RR\Shot_4_holdout.blend
P:\260217_Jarvis-Defense\Blends\animations\Shot_4_redo_fall.blend
P:\250827_FestivalTurf\Blends\animations\01 Develop Your Turf Plan\06_Closing.blend
P:\250827_FestivalTurf\Blends\animations\01 Develop Your Turf Plan\05_Call for Help.blend
P:\250827_FestivalTurf\Blends\animations\01 Develop Your Turf Plan\04_Kid.blend
P:\250827_FestivalTurf\Blends\animations\01 Develop Your Turf Plan\01_Blueprint_insert.blend
P:\250827_FestivalTurf\Blends\animations\01 Develop Your Turf Plan\03_Window.blend
P:\250827_FestivalTurf\Blends\animations\01 Develop Your Turf Plan\02_Roll Sizes.blend
P:\250827_FestivalTurf\Blends\animations\01 Develop Your Turf Plan\04_Dog.blend
P:\250827_FestivalTurf\Blends\animations\01 Develop Your Turf Plan\04_FT.blend
P:\250827_FestivalTurf\Blends\animations\04 Securing Your Seam\06_FT_Hammering_insert.blend
P:\250827_FestivalTurf\Blends\animations\04 Securing Your Seam\SPA\06_FT_Hammering_insert_SPA.blend
P:\250827_FestivalTurf\Blends\animations\05 Stretch Cut Nail\SPA\Visual 7.blend
P:\250827_FestivalTurf\Blends\animations\07 Final Touches And Maintenance\Visual_7.blend
A:\1 Amazon_Active_Projects\260225_Problem-Solve_2026\Blends\animations\PS_2026_animation short 1a.blend
C:\Users\Nathan\Downloads\Shot_5c.blend
C:\Users\Nathan\Downloads\Shot_5b.blend
C:\Users\Nathan\Downloads\Shot_4d_f.blend
P:\260217_Jarvis-Defense\Blends\animations\Shot_2.blend
F:\jobs\Shot_4d_f-4pth\Shot_4_redo_fall.flamenco.blend
F:\jobs\Shot_5b\Shot_Q.flamenco.blend
C:\Users\Nathan\Downloads\Visual_new_final\Visual_new_final.blend
P:\250827_FestivalTurf\Blends\animations\07 Final Touches And Maintenance\Visual_new_final.blend
C:\Users\Nathan\Downloads\Shot_7.blend
C:\Users\Nathan\Downloads\Shot_4.blend
F:\jobs\Shot_7-p1na\Shot_4.flamenco.blend
P:\260217_Jarvis-Defense\Blends\animations\Shot_3_retimed.blend
F:\jobs\Shot_3a-fpm9\Shot_3_retimed.flamenco.blend
C:\Users\Nathan\Downloads\Shot_2a.blend
T:\260217_Jarvis-Defense\Blends\animations\Shot_4_redo_fall.blend
C:\Users\Nathan\Downloads\Shot_1c.blend
C:\Users\Nathan\Downloads\Shot_1b.blend
C:\Users\Nathan\Downloads\Shot_1.flamenco.blend
P:\260217_Jarvis-Defense\Assets\Blends\Char\Priest_v3.0.blend
F:\jobs\shot_2a-ja00\Shot_2.flamenco.blend
C:\Users\Nathan\Downloads\Shot_3_retimed.flamenco.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\animations\noncon_OOG_short_animation 1b.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\animations\noncon_OOG_short_animation 1c.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\stills\i_noncon_OOG_9o.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\stills\i_noncon_OOG_9m.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\stills\i_noncon_OOG_9k.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\stills\i_noncon_OOG_9h.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\animations\noncon_OOG_short_animation 13bJ.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\stills\i_noncon_OOG_9e.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\stills\i_noncon_OOG_9b.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\stills\i_noncon_OOG_9d.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\animations\noncon_OOG_short_animation 10bF.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\stills\i_noncon_OOG_9a.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\animations\noncon_OOG_short_animation 13bC.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\animations\noncon_OOG_short_animation 13bD.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\animations\noncon_OOG_short_animation 9bC.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\animations\noncon_OOG_short_animation 9bG.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\animations\noncon_OOG_short_animation 9bB.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\animations\noncon_OOG_short_animation 9bD_2.blend
T:\1 BlenderAssets\Amazon\Char\Cartoon3\Steve_v1.2.blend
T:\1 BlenderAssets\Amazon\Char\Cartoon2\AS\Sarah_v3.3.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\stills\i_noncon_OOG_4b.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\stills\i_noncon_OOG_4a.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\stills\i_noncon_OOG_2j.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\stills\i_noncon_OOG_2i.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\stills\i_noncon_OOG_1b.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\stills\i_noncon_OOG_2a.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\stills\i_noncon_OOG_1a.blend
A:\1 Amazon_Active_Projects\1 BlenderAssets\Amazon\Scenes\AMZ-warehouse_v6.1_small.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\animations\noncon_OOG_short_animation 10bD.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\stills\i_noncon_OOG_10a.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\stills\i_noncon_OOG_9l.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\stills\i_noncon_OOG_9c.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\stills\i_noncon_OOG_8f.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\stills\i_noncon_OOG_8d.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\stills\i_noncon_OOG_8c.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\stills\i_noncon_OOG_8g.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\stills\i_noncon_OOG_5b.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\animations\noncon_OOG_short_animation 9a_v3.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\animations\noncon_OOG_short_animation 9a_v2.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\stills\i_noncon_OOG_7b.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\stills\i_noncon_OOG_7c.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\stills\i_noncon_OOG_7a.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Assets\Blends\AMZ-warehouse_v6.2_small.blend
T:\260317_NONCON_2026\Blends\animations\noncon_OOG_short_animation 9bB.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\stills\i_noncon_OOG_2f.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\stills\i_noncon_OOG_2c.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\stills\i_noncon_OOG_9i.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\animations\noncon_OOG_short_animation 12C.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\animations\noncon_OOG_short_animation 12A.blend
A:\1 Amazon_Active_Projects\1 BlenderAssets\Amazon\Char\Cartoon3\Liam_v1.2.blend
A:\1 Amazon_Active_Projects\1 BlenderAssets\Amazon\Char\Cartoon3\Tony_v1.2.blend
A:\1 Amazon_Active_Projects\260206_Dock_Unified\Blends\stills\Path 3\iDock_Path4_4d_red.blend
A:\1 Amazon_Active_Projects\260206_Dock_Unified\Blends\stills\Path 3\iDock_Path4_4d_yellow.blend
A:\1 Amazon_Active_Projects\260206_Dock_Unified\Blends\stills\Path 3\iDock_Path3_6_yellow.blend
A:\1 Amazon_Active_Projects\260206_Dock_Unified\Blends\stills\Path 3\iDock_Path3_6_red.blend
A:\1 Amazon_Active_Projects\260206_Dock_Unified\Blends\stills\Waterspider A\WS_A_go-cart loading_f.blend
T:\260206_Dock_Unified\Blends\stills\Waterspider A\WS_A_go-cart loading_f.blend
T:\260206_Dock_Unified\Blends\stills\Waterspider A\WS_A_go-cart loading_d.blend
T:\260206_Dock_Unified\Blends\stills\Waterspider A\WS_A_go-cart loading_b.blend
T:\260206_Dock_Unified\Blends\stills\Waterspider A\iWS_A_6.blend
T:\260206_Dock_Unified\Blends\stills\Waterspider B\MISC_WS-B_swapping.blend
T:\260206_Dock_Unified\Blends\animations\go_cart_loading\WS_A_go-cart loading_a.blend
G:\Amazon\2024\240322_Amazon_Dock-Safety\blends\ANIMATIONS\Path 3\path 3 images\iDock_Path3_6.blend
T:\260206_Dock_Unified\Blends\stills\Waterspider B\WS_A_go-cart loading_e.blend
T:\260206_Dock_Unified\Blends\stills\Waterspider B\WS_A_go-cart loading_f.blend
A:\1 Amazon_Active_Projects\260206_Dock_Unified\Blends\stills\Waterspider A\WS_A_go-cart loading_f_red.blend
A:\1 Amazon_Active_Projects\260206_Dock_Unified\Blends\stills\Waterspider A\WS_A_go-cart loading_e.blend
A:\1 Amazon_Active_Projects\260206_Dock_Unified\Blends\stills\Waterspider A\WS_A_go-cart loading_a.blend
A:\1 Amazon_Active_Projects\260206_Dock_Unified\Blends\stills\Path 3\iDock_path3_4c.blend
A:\1 Amazon_Active_Projects\260206_Dock_Unified\Blends\animations\Path 3\Path 3_Animation 3a.blend
A:\1 Amazon_Active_Projects\260206_Dock_Unified\Blends\animations\Path 3\Path 3_Animation 3_Aa.blend
A:\1 Amazon_Active_Projects\260206_Dock_Unified\Blends\stills\Path 3\iDock_Path3_6.blend
T:\250827_FestivalTurf\Blends\animations\07 Final Touches And Maintenance\Visual_new_final.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\animations\noncon_OOG_short_animation 2e.blend
T:\260206_Dock_Unified\Blends\animations\Path 3\Path 3_Animation 3_Aa.blend
A:\1 Amazon_Active_Projects\260206_Dock_Unified\Blends\animations\Path 3\Path 3_Animation 3e.blend
A:\1 Amazon_Active_Projects\260206_Dock_Unified\Blends\animations\Path 3\Path 3_Animation 3d.blend
A:\1 Amazon_Active_Projects\260206_Dock_Unified\Blends\animations\Path 3\Path 3_Animation 3c.blend
A:\1 Amazon_Active_Projects\260206_Dock_Unified\Blends\animations\Path 3\Path 3_Animation 3b.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\animations\noncon_OOG_short_animation 1a_part2.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\animations\noncon_OOG_short_animation 1a_part1.blend
A:\1 Amazon_Active_Projects\260206_Dock_Unified\Blends\animations\Waterspider A\WS_A_move go-cart from trailer A.blend
A:\1 Amazon_Active_Projects\260206_Dock_Unified\Blends\stills\Waterspider A\WS_A_go-cart loading_e_red.blend
C:\Users\Nathan\Desktop\Untitled.blend
A:\1 Amazon_Active_Projects\1 BlenderAssets\Amazon\Char\Cartoon1\Regina_v4.3.blend
T:\1 BlenderAssets\Amazon\Char\Cartoon2\AS\Paul_v3.4.blend
T:\1 BlenderAssets\Amazon\Char\Cartoon2\AS\Kennedy_v3.3.blend
T:\1 BlenderAssets\Amazon\Char\Cartoon1\Regina_v4.3.blend
T:\1 BlenderAssets\Amazon\Char\Cartoon1\Hailey_v4.3.blend
A:\1 Amazon_Active_Projects\260311_PnS-Beyond-Day-2\Blends\animations\P&S_BD2_animation 10a.blend
A:\1 Amazon_Active_Projects\1 BlenderAssets\Amazon\Char\Cartoon1\Chan_v4.3.blend
A:\1 Amazon_Active_Projects\1 BlenderAssets\Amazon\Char\Cartoon1\Manny_v4.3.blend
A:\1 Amazon_Active_Projects\1 BlenderAssets\Amazon\Char\Cartoon1\Dennis_v4.3.blend
A:\1 Amazon_Active_Projects\1 BlenderAssets\Amazon\Char\Cartoon1\Joe_v4.3.blend
A:\1 Amazon_Active_Projects\1 BlenderAssets\Amazon\Char\Cartoon1\Kirk_v4.4.blend
A:\1 Amazon_Active_Projects\1 BlenderAssets\Amazon\Mat\MAT_Char.blend
A:\1 Amazon_Active_Projects\260311_PnS-Beyond-Day-2\Blends\animations\P&S_BD2_animation 8c_part2.blend
A:\1 Amazon_Active_Projects\260206_PAE_2026\Blends\animations\PAE_Animation 4B.blend
A:\1 Amazon_Active_Projects\260206_PAE_2026\Blends\animations\PAE_Animation 3C.blend
A:\1 Amazon_Active_Projects\260206_PAE_2026\Blends\animations\PAE_Animation 3B.blend
A:\1 Amazon_Active_Projects\260225_Problem-Solve_2026\Blends\animations\PS_2026_animation 5G.blend
A:\1 Amazon_Active_Projects\260225_Problem-Solve_2026\Blends\animations\PS_2026_animation 5E.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\animations\noncon_OOG_short_animation 2f.blend
A:\1 Amazon_Active_Projects\1 BlenderAssets\Amazon\Mat\MATERIALS_BSDF_pallette_v1.0.blend
A:\1 Amazon_Active_Projects\1 BlenderAssets\Amazon\Scenes\AMZ-warehouse_v5.1.blend
A:\1 Amazon_Active_Projects\1 BlenderAssets\Amazon\Scenes\AMZ-warehouse_BSDF_v4.0.blend
C:\Users\Nathan\Downloads\noncon_OOG_short_animation 2d.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\animations\noncon_OOG_short_animation 2d.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\animations\noncon_OOG_short_animation 9a.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\animations\noncon_OOG_short_animation 2a.blend
T:\260317_NONCON_2026\Blends\animations\noncon_OOG_short_animation 9a.blend
A:\1 Amazon_Active_Projects\1 BlenderAssets\Amazon\Props\Dog-Food-Bag.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\animations\noncon_OOG_short_animation 3d.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\animations\noncon_OOG_short_animation 3c.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\animations\noncon_OOG_short_animation 13a.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\animations\noncon_OOG_short_animation 3b.blend
A:\1 Amazon_Active_Projects\260317_NONCON_2026\Blends\animations\noncon_OOG_short_animation 3a.blend
C:\Users\Nathan\AppData\Local\Temp\2026-03-17_11-18_noncon_OOG_short_animation 3b.blend
A:\1 Amazon_Active_Projects\260206_Dock_Unified\Blends\animations\Path 1\Path 1_Animation 1_Scene 3_1.blend
A:\1 Amazon_Active_Projects\260206_Dock_Unified\Blends\animations\New\Dock_safety_new animation 1.blend
A:\1 Amazon_Active_Projects\260311_PnS-Beyond-Day-2\Blends\stills\iP&S_BD2_18h.blend
A:\1 Amazon_Active_Projects\260311_PnS-Beyond-Day-2\Blends\stills\iP&S_BD2_18g.blend
A:\1 Amazon_Active_Projects\260311_PnS-Beyond-Day-2\Blends\stills\iP&S_BD2_3d.blend
A:\1 Amazon_Active_Projects\260311_PnS-Beyond-Day-2\Blends\stills\iP&S_BD2_18f.blend
A:\1 Amazon_Active_Projects\260311_PnS-Beyond-Day-2\Blends\stills\iP&S_BD2_18e.blend
A:\1 Amazon_Active_Projects\260311_PnS-Beyond-Day-2\Blends\stills\iP&S_BD2_18c.blend
A:\1 Amazon_Active_Projects\260311_PnS-Beyond-Day-2\Blends\stills\iP&S_BD2_18d.blend
A:\1 Amazon_Active_Projects\260127_Pick-and-Stage_2026_edits\Blends\stills\iP&S_2026_10.blend
A:\1 Amazon_Active_Projects\260311_PnS-Beyond-Day-2\Blends\stills\iP&S_BD2_18a.blend
A:\1 Amazon_Active_Projects\260311_PnS-Beyond-Day-2\Blends\stills\iP&S_BD2_18b.blend
A:\1 Amazon_Active_Projects\260127_Pick-and-Stage_2026_edits\Blends\stills\iP&S_2026_3a-b.blend
A:\1 Amazon_Active_Projects\260127_Pick-and-Stage_2026_edits\Blends\stills\iP&S_2026_1a.blend
A:\1 Amazon_Active_Projects\260127_Pick-and-Stage_2026_edits\Blends\stills\iP&S_2026_1c.blend
A:\1 Amazon_Active_Projects\260127_Pick-and-Stage_2026_edits\Blends\stills\iP&S_2026_1d.blend
C:\Users\Nathan\Downloads\P&S_BD2_animation 6d.blend
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -31,6 +31,7 @@ if "bpy" in locals():
importlib.reload(add_mesh_menger_sponge)
importlib.reload(add_mesh_vertex)
importlib.reload(add_empty_as_parent)
importlib.reload(add_mesh_equilateral_grid)
importlib.reload(add_mesh_beam_builder)
importlib.reload(Blocks)
importlib.reload(Wallfactory)
@@ -59,6 +60,7 @@ else:
from . import Wallfactory
from . import add_mesh_triangles
from . import preferences
from . import add_mesh_equilateral_grid
from .add_mesh_rocks import __init__
from .add_mesh_rocks import rockgen
@@ -154,6 +156,7 @@ class VIEW3D_MT_mesh_extras_add(Menu):
oper.change = False
oper = layout.operator("mesh.primitive_teapot_add", text="Teapot+")
oper = layout.operator("mesh.menger_sponge_add", text="Menger Sponge")
oper = layout.operator("mesh.add_equilateral_grid", text="Equilateral Grid")
class VIEW3D_MT_mesh_torus_add(Menu):
@@ -209,7 +212,7 @@ def menu_func(self, context):
if prefs.show_single_vert:
layout.menu("VIEW3D_MT_mesh_vert_add", text="Single Vert", icon='DECORATE')
if prefs.show_torus_objects:
layout.menu("VIEW3D_MT_mesh_torus_add", text="Torus Objects", icon='MESH_TORUS')
@@ -218,7 +221,7 @@ def menu_func(self, context):
if prefs.show_gears:
layout.menu("VIEW3D_MT_mesh_gears_add", text="Gears", icon='PREFERENCES')
if prefs.show_pipe_joints:
layout.menu("VIEW3D_MT_mesh_pipe_joints_add", text="Pipe Joints", icon='IPO_CONSTANT')
@@ -409,6 +412,7 @@ classes = [
add_mesh_menger_sponge.AddMengerSponge,
add_mesh_vertex.AddVert,
add_mesh_vertex.AddEmptyVert,
add_mesh_equilateral_grid.MESH_OT_add_equilateral_grid,
add_mesh_vertex.AddSymmetricalEmpty,
add_mesh_vertex.AddSymmetricalVert,
add_empty_as_parent.P2E,
@@ -0,0 +1,270 @@
# SPDX-FileCopyrightText: 2026 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
# Author: Luis-Lerga
import bpy
import bmesh
from math import sqrt
from bpy_extras import object_utils
class MESH_OT_add_equilateral_grid(bpy.types.Operator):
"""Add an equilateral triangular grid with hexagonal pattern"""
bl_idname = "mesh.add_equilateral_grid"
bl_label = "Add Equilateral Grid"
bl_options = {'REGISTER', 'UNDO'}
# Dimensions
width: bpy.props.FloatProperty(
name="Width",
description="Horizontal width of the rectangle",
default=10.0,
min=0.1,
max=100.0,
step=100,
precision=2,
unit='LENGTH'
)
height: bpy.props.FloatProperty(
name="Height",
description="Vertical height of the rectangle",
default=10.0,
min=0.1,
max=100.0,
step=100,
precision=2,
unit='LENGTH'
)
density: bpy.props.IntProperty(
name="Density",
description="Number of horizontal segments",
default=20,
min=4,
max=200,
step=1
)
# UVs
use_uv: bpy.props.BoolProperty(
name="Generate UVs",
description="Generate UV coordinates for the mesh",
default=True
)
# Alignment
align: bpy.props.EnumProperty(
name="Align",
description="Mesh alignment orientation",
items=[
('WORLD', "World", "Align to world origin"),
('VIEW', "View", "Align to current view"),
('CURSOR', "3D Cursor", "Align to 3D cursor position"),
],
default='WORLD'
)
# Transformations
location: bpy.props.FloatVectorProperty(
name="Location",
description="Object location",
subtype='TRANSLATION',
default=(0.0, 0.0, 0.0),
unit='LENGTH'
)
rotation: bpy.props.FloatVectorProperty(
name="Rotation",
description="Object rotation (Euler XYZ)",
subtype='EULER',
default=(0.0, 0.0, 0.0),
unit='ROTATION'
)
def execute(self, context):
# Create geometry & mesh.
verts, faces = self.create_geometry(context)
mesh = bpy.data.meshes.new("Equilateral Grid")
mesh.from_pydata(verts, [], faces)
mesh.update()
# Clean-up.
bm = bmesh.new()
bm.from_mesh(mesh)
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.001)
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
bm.to_mesh(mesh)
bm.free()
# Generate UVs.
if self.use_uv:
self.generate_uvs(mesh)
# Create an object.
obj = object_utils.object_data_add(context, mesh, operator=self)
if context.preferences.edit.use_enter_edit_mode:
bpy.ops.object.mode_set(mode = 'EDIT')
return {'FINISHED'}
def create_geometry(self, context):
"""Generate geometry with equilateral triangles and perfect rectangular borders"""
L = self.width / self.density
tri_height = L * sqrt(3) / 2
# Calculate EVEN number of triangle rows for straight top/bottom borders
target_rows = self.height / tri_height
rows = int(round(target_rows / 2.0)) * 2 # Always even
rows = max(2, rows)
# Actual adjusted dimensions
actual_width = self.density * L
actual_height = rows * tri_height
# === Generate vertices WITHOUT protruding vertices ===
verts = []
row_offsets = [] # Track starting index of each row
for row in range(rows + 1): # rows+1 vertex rows
y = -actual_height / 2 + row * tri_height
row_offsets.append(len(verts))
if row % 2 == 0:
# Even rows: density+1 vertices spanning full width
num_verts = self.density + 1
offset = 0.0
else:
# Odd rows: density vertices (NO protruding vertex)
num_verts = self.density
offset = L * 0.5
for col in range(num_verts):
x = -actual_width / 2 + col * L + offset
verts.append((x, y, 0.0))
# === Generate interior faces (equilateral triangles) ===
faces = []
for row in range(rows):
if row % 2 == 0:
# Even row → next row is odd (fewer vertices)
for col in range(self.density):
i00 = row_offsets[row] + col
i01 = i00 + 1
i10 = row_offsets[row + 1] + col
i11 = i10 + 1 if col < self.density - 1 else None
faces.append((i00, i10, i01))
if i11 is not None:
faces.append((i01, i10, i11))
else:
# Odd row → next row is even (more vertices)
for col in range(self.density):
i00 = row_offsets[row] + col
i01 = i00 + 1 if col < self.density - 1 else None
i10 = row_offsets[row + 1] + col
i11 = i10 + 1
if i01 is not None:
faces.append((i00, i11, i01))
faces.append((i00, i10, i11))
# === Fill LEFT/RIGHT borders for odd rows ===
for row in range(1, rows, 2): # Only odd rows (1, 3, 5...)
y = -actual_height / 2 + row * tri_height
# Add LEFT border vertex at exact x = -width/2
left_idx = len(verts)
verts.append((-actual_width / 2, y, 0.0))
# Add RIGHT border vertex at exact x = +width/2
right_idx = len(verts)
verts.append((actual_width / 2, y, 0.0))
# Connect LEFT border vertex
base_idx = row_offsets[row]
above_idx = row_offsets[row - 1] # Even row above
below_idx = row_offsets[row + 1] # Even row below
# Upper triangle: left border → first interior → vertex above first interior
faces.append((left_idx, base_idx, above_idx))
# Lower triangle: left border → vertex below first interior → first interior
faces.append((left_idx, below_idx, base_idx))
# Connect RIGHT border vertex
last_interior = base_idx + self.density - 1
above_last = above_idx + self.density # Last vertex of even row above
below_last = below_idx + self.density # Last vertex of even row below
# Upper triangle: right border → last interior → vertex above last interior
faces.append((right_idx, last_interior, above_last))
# Lower triangle: right border → vertex below last interior → last interior
faces.append((right_idx, below_last, last_interior))
return verts, faces
def generate_uvs(self, mesh):
"""Generate simple 0-1 UV coordinates based on X,Y coordinates"""
if not mesh.uv_layers:
mesh.uv_layers.new(name="UVMap")
uv_layer = mesh.uv_layers.active.data
# Calculate bounds for normalization
xs = [v.co.x for v in mesh.vertices]
ys = [v.co.y for v in mesh.vertices]
min_x, max_x = min(xs), max(xs)
min_y, max_y = min(ys), max(ys)
width = max_x - min_x or 1.0
height = max_y - min_y or 1.0
# Assign UVs
for poly in mesh.polygons:
for loop_idx in poly.loop_indices:
vert_idx = mesh.loops[loop_idx].vertex_index
v = mesh.vertices[vert_idx].co
uv = (
(v.x - min_x) / width,
(v.y - min_y) / height
)
uv_layer[loop_idx].uv = uv
def draw(self, context):
"""Draw the options panel in the dialog"""
layout = self.layout
layout.use_property_split = True
layout.use_property_decorate = False
# Dimensions
layout.label(text="Dimensions:")
col = layout.column(align=True)
col.prop(self, "width")
col.prop(self, "height")
col.prop(self, "density")
# Additional options
layout.separator()
layout.prop(self, "use_uv")
layout.prop(self, "align")
# Transformations
layout.separator()
col = layout.column(align=True)
col.prop(self, "location", text="Location X", index=0)
col.prop(self, "location", text="Y", index=1)
col.prop(self, "location", text="Z", index=2)
col = layout.column(align=True)
col.prop(self, "rotation", text="Rotation X", index=0)
col.prop(self, "rotation", text="Y", index=1)
col.prop(self, "rotation", text="Z", index=2)
@@ -1,7 +1,7 @@
schema_version = "1.0.0"
id = "extra_mesh_objects"
name = "Extra Mesh Objects"
version = "0.4.0"
version = "0.4.1"
tagline = "Add extra mesh object types"
maintainer = "Community"
type = "add-on"
@@ -4,7 +4,7 @@ Documentation: https://weisl.github.io/renaming_overview/
<h1>Introduction</h1>
<p><b> Simple Renaming Panel </b> is a small, but powerful tool to rename more objects at once. The tool includes basic functionalities of adding suffixes, prefixes, search and replace, add suffixes depending on the object type and much more. Over the time more advanced features like a variable system were added. The tool gives a lot of power to you!
<p><b> Simple Renaming</b> is a small, but powerful tool to rename more objects at once. The tool includes basic functionalities of adding suffixes, prefixes, search and replace, add suffixes depending on the object type and much more. Over the time more advanced features like a variable system were added. The tool gives a lot of power to you!
You decide which kind of objects will be affected by the renaming task. Rename all or just selected objects, specify the affected object types like image textures, materials, objects, object data, bones, or collections. This tool can be a real everyday helper. Renaming multiple objects is often needed and keeping the naming conventions can be tedious. The tool provides you with a clear feedback of what has been renamed. This tool is kept simple to be user-friendly but offers everything you need to stay organized. </p>
Join the discussion at [Blender Artists](https://blenderartists.org/t/simple-renaming-panel/676639 "Blender Artists").
@@ -92,7 +92,8 @@ enumObjectTypesExt = [('EMPTY', "", "Rename empty objects", 'OUTLINER_OB_EMPTY',
('GPENCIL', "", "Rename greace pencil objects", 'OUTLINER_OB_GREASEPENCIL', 512),
('METABALL', "", "Rename metaball objects", 'OUTLINER_OB_META', 2048),
('COLLECTION', "", "Rename collections", 'GROUP', 4096),
('BONE', "", "", 'BONE_DATA', 8192), ]
('BONE', "", "", 'BONE_DATA', 8192),
('POINTCLOUD', "", "Rename point cloud objects", 'OUTLINER_OB_POINTCLOUD', 16384), ]
def register():
@@ -113,7 +114,8 @@ def register():
'MESH',
'ARMATURE', 'LIGHT', 'CAMERA', 'EMPTY',
'GPENCIL',
'TEXT', 'BONE', 'COLLECTION'}
'TEXT', 'BONE', 'COLLECTION',
'POINTCLOUD'}
)
id_store.renaming_suffix_prefix_material = StringProperty(name='Material', default='')
@@ -135,6 +137,7 @@ def register():
id_store.renaming_suffix_prefix_bone = StringProperty(name="Bones", default='')
id_store.renaming_suffix_prefix_speakers = StringProperty(name="Speakers", default='')
id_store.renaming_suffix_prefix_lightprops = StringProperty(name="LightProps", default='')
id_store.renaming_suffix_prefix_pointcloud = StringProperty(name="Point Cloud", default='')
id_store.renaming_inputContext = StringProperty(name="LightProps", default='')
@@ -40,9 +40,6 @@ class VIEW3D_OT_add_type_suf_pre(bpy.types.Operator):
option: StringProperty()
def __init__(self):
self.context = None
def get_selection_all(self):
context = self.context
@@ -266,6 +263,18 @@ class VIEW3D_OT_add_type_suf_pre(bpy.types.Operator):
icon='OUTLINER_OB_META')
return
def pointcloud(self):
context = self.context
wm = context.scene
obj_list = []
for obj in self.get_selection_all():
if obj.type == 'POINTCLOUD':
obj_list.append(obj)
self.rename_suffix_prefix(obj_list, pre_suffix=wm.renaming_suffix_prefix_pointcloud, object_type='POINTCLOUD',
icon='OUTLINER_OB_POINTCLOUD')
return
def collection(self):
context = self.context
wm = context.scene
@@ -303,6 +312,7 @@ class VIEW3D_OT_add_type_suf_pre(bpy.types.Operator):
self.metaball()
self.collection()
self.bone()
self.pointcloud()
self.material()
self.data()
@@ -26,4 +26,5 @@ paths_exclude_pattern = [
"__pycache__/",
"/.git/",
"/*.zip",
"/tests/",
]
@@ -9,6 +9,9 @@ from bpy.props import (
)
from . import add_pre_suffix
from . import case_transform
from . import reload_addon
from .version_check import start_version_check
from . import name_from_data
from . import name_replace
from . import numerate
@@ -30,7 +33,8 @@ enumObjectTypes = [('EMPTY', "", "Rename empty objects", 'OUTLINER_OB_EMPTY', 1)
('META', "", "Rename metaball objects", 'OUTLINER_OB_META', 1024),
('SPEAKER', "", "Rename empty speakers", 'OUTLINER_OB_SPEAKER', 2048),
('LIGHT_PROBE', "", "Rename mesh lightpropes", 'OUTLINER_OB_LIGHTPROBE', 4096),
('VOLUME', "", "Rename mesh volumes", 'OUTLINER_OB_VOLUME', 8192)]
('VOLUME', "", "Rename mesh volumes", 'OUTLINER_OB_VOLUME', 8192),
('POINTCLOUD', "", "Rename point cloud objects", 'OUTLINER_OB_POINTCLOUD', 16384)]
enumObjectTypesAdd = [('SPEAKER', "", "Rename empty speakers", 'OUTLINER_OB_SPEAKER', 1),
('LIGHT_PROBE', "", "Rename mesh lightpropes", 'OUTLINER_OB_LIGHTPROBE', 2)]
@@ -60,6 +64,8 @@ renamingEntitiesItems = [('OBJECT', "Object", "Scene Objects"),
None,
('PARTICLESYSTEM', "Particle Systems", "Rename particle systems"),
('PARTICLESETTINGS', "Particle Settings", "Rename particle settings"),
None,
('NODE_GROUPS', "Node Groups", "Rename node groups"),
]
classes = (
@@ -71,6 +77,13 @@ classes = (
add_pre_suffix.VIEW3D_OT_add_prefix,
numerate.VIEW3D_OT_renaming_numerate,
name_from_data.VIEW3D_OT_use_objectname_for_data,
case_transform.VIEW3D_OT_case_upper,
case_transform.VIEW3D_OT_case_lower,
case_transform.VIEW3D_OT_case_pascal,
case_transform.VIEW3D_OT_case_camel,
case_transform.VIEW3D_OT_case_snake,
case_transform.VIEW3D_OT_case_kebab,
reload_addon.VIEW3D_OT_reload_addon,
)
enum_sort_items = [('X', "X Axis", "Sort the object based on the X axis."),
@@ -120,7 +133,8 @@ def register():
options={'ENUM_FLAG'},
default={'CURVE', 'LATTICE', 'SURFACE', 'MESH',
'ARMATURE', 'LIGHT', 'CAMERA', 'EMPTY', 'GPENCIL',
'FONT', 'SPEAKER', 'LIGHT_PROBE', 'VOLUME'}
'FONT', 'SPEAKER', 'LIGHT_PROBE', 'VOLUME',
'POINTCLOUD'}
)
id_store.renaming_sort_enum = EnumProperty(
@@ -166,12 +180,31 @@ def register():
id_store.renaming_digits_numerate = IntProperty(name="Number Length", default=3)
id_store.renaming_trim_indices = IntVectorProperty(name="Trim Size", default=(0, 0), min=0, soft_min=0, size=2)
id_store.renaming_active_only = BoolProperty(
name="Active Only",
description="Only rename the active layer on each object",
default=False,
)
id_store.renaming_filter_by_index = BoolProperty(
name="By Index",
description="Only rename the layer at the specified index on each object",
default=False,
)
id_store.renaming_index_target = IntProperty(name="Index", default=0, min=0)
id_store.renaming_also_rename_data = BoolProperty(
name="Also Rename Data",
description="Also rename the linked data block (mesh, curve, etc.) to match the object name",
default=False,
)
from bpy.utils import register_class
for cls in classes:
register_class(cls)
bpy.app.handlers.depsgraph_update_post.append(PostChange)
start_version_check()
def unregister():
@@ -192,5 +225,9 @@ def unregister():
del IDStore.renaming_base_numerate
del IDStore.renaming_digits_numerate
del IDStore.renaming_trim_indices
del IDStore.renaming_also_rename_data
del IDStore.renaming_active_only
del IDStore.renaming_filter_by_index
del IDStore.renaming_index_target
bpy.app.handlers.depsgraph_update_post.remove(PostChange)
@@ -1,7 +1,9 @@
import time
import bpy
from .renaming_operators import switch_to_edit_mode
from ..operators.renaming_utilities import get_renaming_list, call_renaming_popup, call_error_popup
from ..operators.renaming_utilities import get_renaming_list, call_renaming_popup, call_error_popup, rename_data_if_enabled, update_bone_drivers, log_timing
from ..variable_replacer.variable_replacer import VariableReplacer
@@ -23,9 +25,11 @@ class VIEW3D_OT_add_suffix(bpy.types.Operator):
call_error_popup(context)
return {'CANCELLED'}
t_start = time.perf_counter()
msg = wm.renaming_messages
VariableReplacer.reset()
VariableReplacer.prepare(context)
if len(renaming_list) > 0:
for entity in renaming_list:
if entity is not None:
@@ -34,11 +38,15 @@ class VIEW3D_OT_add_suffix(bpy.types.Operator):
oldName = entity.name
new_name = entity.name + suffix
entity.name = new_name
rename_data_if_enabled(wm, entity)
if wm.renaming_object_types == 'BONE':
update_bone_drivers(oldName, entity.name)
msg.add_message(oldName, entity.name)
else:
msg.add_message(None, None, "Insert Valid String")
if switch_edit_mode:
switch_to_edit_mode(context)
log_timing(context, "add_suffix", t_start, len(renaming_list))
call_renaming_popup(context)
return {'FINISHED'}
@@ -62,7 +70,9 @@ class VIEW3D_OT_add_prefix(bpy.types.Operator):
call_error_popup(context)
return {'CANCELLED'}
t_start = time.perf_counter()
VariableReplacer.reset()
VariableReplacer.prepare(context)
if len(renaming_list) > 0:
for entity in renaming_list:
@@ -72,8 +82,12 @@ class VIEW3D_OT_add_prefix(bpy.types.Operator):
oldName = entity.name
new_name = pre + entity.name
entity.name = new_name
rename_data_if_enabled(wm, entity)
if wm.renaming_object_types == 'BONE':
update_bone_drivers(oldName, entity.name)
msg.add_message(oldName, entity.name)
log_timing(context, "add_prefix", t_start, len(renaming_list))
call_renaming_popup(context)
if switch_edit_mode:
switch_to_edit_mode(context)
@@ -0,0 +1,159 @@
import re
import bpy
from .renaming_operators import switch_to_edit_mode
from ..operators.renaming_utilities import get_renaming_list, call_renaming_popup, call_error_popup, rename_data_if_enabled, update_bone_drivers
# ---------------------------------------------------------------------------
# Word splitting — handles snake_case, kebab-case, PascalCase, camelCase
# ---------------------------------------------------------------------------
def split_words(name):
"""Split a name into words regardless of input convention."""
# camelCase / PascalCase boundaries: lowercase→Uppercase
name = re.sub(r'([a-z0-9])([A-Z])', r'\1 \2', name)
# Runs of capitals before a capitalised word: XMLParser → XML Parser
name = re.sub(r'([A-Z]+)([A-Z][a-z])', r'\1 \2', name)
# Replace separators with spaces
name = re.sub(r'[-_\s]+', ' ', name)
return [w for w in name.split(' ') if w]
# ---------------------------------------------------------------------------
# Conversion helpers (also imported by search_replace for \u \l \U \L)
# ---------------------------------------------------------------------------
def to_upper(text):
return text.upper()
def to_lower(text):
return text.lower()
def upper_first(text):
"""Uppercase only the first character, leave the rest unchanged."""
return text[:1].upper() + text[1:] if text else text
def lower_first(text):
"""Lowercase only the first character, leave the rest unchanged."""
return text[:1].lower() + text[1:] if text else text
def to_pascal_case(name):
"""hello_world → HelloWorld"""
return ''.join(w.capitalize() for w in split_words(name))
def to_camel_case(name):
"""hello_world → helloWorld"""
words = split_words(name)
if not words:
return name
return words[0].lower() + ''.join(w.capitalize() for w in words[1:])
def to_snake_case(name):
"""HelloWorld → hello_world"""
return '_'.join(w.lower() for w in split_words(name))
def to_kebab_case(name):
"""HelloWorld → hello-world"""
return '-'.join(w.lower() for w in split_words(name))
# ---------------------------------------------------------------------------
# Operator base
# ---------------------------------------------------------------------------
class _CaseOperatorBase(bpy.types.Operator):
bl_options = {'REGISTER', 'UNDO'}
def _transform(self, name):
raise NotImplementedError
def execute(self, context):
scene = context.scene
renaming_list, switch_edit_mode, errMsg = get_renaming_list(context)
if errMsg is not None:
scene.renaming_error_messages.add_message(errMsg)
call_error_popup(context)
return {'CANCELLED'}
msg = scene.renaming_messages
for entity in renaming_list:
if entity is not None:
old_name = entity.name
entity.name = self._transform(entity.name)
rename_data_if_enabled(scene, entity)
if scene.renaming_object_types == 'BONE':
update_bone_drivers(old_name, entity.name)
msg.add_message(old_name, entity.name)
call_renaming_popup(context)
if switch_edit_mode:
switch_to_edit_mode(context)
return {'FINISHED'}
# ---------------------------------------------------------------------------
# Operators
# ---------------------------------------------------------------------------
class VIEW3D_OT_case_upper(_CaseOperatorBase):
bl_idname = "renaming.case_upper"
bl_label = "UPPERCASE"
bl_description = "Convert name to UPPERCASE (hello_world → HELLO_WORLD)"
def _transform(self, name):
return to_upper(name)
class VIEW3D_OT_case_lower(_CaseOperatorBase):
bl_idname = "renaming.case_lower"
bl_label = "lowercase"
bl_description = "Convert name to lowercase (Hello_World → hello_world)"
def _transform(self, name):
return to_lower(name)
class VIEW3D_OT_case_pascal(_CaseOperatorBase):
bl_idname = "renaming.case_pascal"
bl_label = "PascalCase"
bl_description = "Convert name to PascalCase (hello_world → HelloWorld)"
def _transform(self, name):
return to_pascal_case(name)
class VIEW3D_OT_case_camel(_CaseOperatorBase):
bl_idname = "renaming.case_camel"
bl_label = "camelCase"
bl_description = "Convert name to camelCase (hello_world → helloWorld)"
def _transform(self, name):
return to_camel_case(name)
class VIEW3D_OT_case_snake(_CaseOperatorBase):
bl_idname = "renaming.case_snake"
bl_label = "snake_case"
bl_description = "Convert name to snake_case (HelloWorld → hello_world)"
def _transform(self, name):
return to_snake_case(name)
class VIEW3D_OT_case_kebab(_CaseOperatorBase):
bl_idname = "renaming.case_kebab"
bl_label = "kebab-case"
bl_description = "Convert name to kebab-case (HelloWorld → hello-world)"
def _transform(self, name):
return to_kebab_case(name)
@@ -1,9 +1,11 @@
import time
import bpy
from .renaming_operators import getAllVertexGroups, getAllAttributes, getAllBones, getAllModifiers, getAllUvMaps, \
getAllColorAttributes, getAllParticleNames, getAllParticleSettingsNames, getAllDataNames, getAllShapeKeys
from .renaming_operators import getAllModifiers, \
getAllParticleNames, getAllParticleSettingsNames, getAllDataNames
from .renaming_operators import switch_to_edit_mode, numerate_entity_name
from ..operators.renaming_utilities import get_renaming_list, call_renaming_popup, call_error_popup
from ..operators.renaming_utilities import get_renaming_list, call_renaming_popup, call_error_popup, rename_data_if_enabled, update_bone_drivers, log_timing
from ..variable_replacer.variable_replacer import VariableReplacer
@@ -27,57 +29,67 @@ class VIEW3D_OT_replace_name(bpy.types.Operator):
return {'CANCELLED'}
old_mode = context.mode
t_start = time.perf_counter()
# settings for numerating the new name
msg = scene.renaming_messages
vertexGroupNameList = []
particleSettingsList = []
particleList = []
uvmapsList = []
dataList = []
attributeList = []
colorAttributeList = []
shapeKeyNamesList = []
modifierNamesList = []
boneList = []
per_object_types = {'SHAPEKEYS', 'VERTEXGROUPS', 'UVMAPS', 'COLORATTRIBUTES', 'ATTRIBUTES', 'BONE'}
per_obj_owner_items = {
'SHAPEKEYS': lambda o: o.key_blocks,
'VERTEXGROUPS': lambda o: o.vertex_groups,
'UVMAPS': lambda o: o.uv_layers,
'COLORATTRIBUTES': lambda o: o.color_attributes,
'ATTRIBUTES': lambda o: o.attributes,
'BONE': lambda o: o.edit_bones if old_mode == 'EDIT_ARMATURE' else o.bones,
}
particleSettingsList = set()
particleList = set()
dataList = set()
modifierNamesList = set()
if context.scene.renaming_object_types == 'VERTEXGROUPS':
vertexGroupNameList = getAllVertexGroups()
if scene.renaming_object_types == 'PARTICLESYSTEM':
particleList = getAllParticleNames()
particleList = set(getAllParticleNames())
if scene.renaming_object_types == 'PARTICLESETTINGS':
particleSettingsList = getAllParticleSettingsNames()
if context.scene.renaming_object_types == 'UVMAPS':
uvmapsList = getAllUvMaps()
if context.scene.renaming_object_types == 'COLORATTRIBUTES':
colorAttributeList = getAllColorAttributes()
if context.scene.renaming_object_types == 'ATTRIBUTES':
attributeList = getAllAttributes()
if scene.renaming_object_types == 'SHAPEKEYS':
shapeKeyNamesList = getAllShapeKeys()
particleSettingsList = set(getAllParticleSettingsNames())
if scene.renaming_object_types == 'MODIFIERS':
modifierNamesList = getAllModifiers()
if scene.renaming_object_types == 'BONE':
boneList = getAllBones(old_mode)
modifierNamesList = set(getAllModifiers())
if scene.renaming_object_types == 'DATA':
dataList = getAllDataNames()
dataList = set(getAllDataNames())
current_owner = None
per_obj_name_list = set()
VariableReplacer.reset()
VariableReplacer.prepare(context)
if len(str(replaceName)) > 0: # New name != empty
if len(renaming_list) > 0: # List of objects to rename != empty
for entity in renaming_list:
if entity is not None:
if scene.renaming_object_types in per_object_types:
owner = entity.id_data
if owner != current_owner:
current_owner = owner
VariableReplacer.reset()
per_obj_name_list = {item.name for item in per_obj_owner_items[scene.renaming_object_types](owner)}
replaceName = VariableReplacer.replaceInputString(context, scene.renaming_new_name, entity)
oldName = entity.name
new_name = ''
if not scene.renaming_use_enumerate:
entity.name = replaceName
msg.add_message(oldName, entity.name)
try:
entity.name = replaceName
rename_data_if_enabled(scene, entity)
if scene.renaming_object_types == 'BONE':
update_bone_drivers(oldName, entity.name)
msg.add_message(oldName, entity.name)
except AttributeError:
print("Attribute {} is read only".format(replaceName))
else: # if scene.renaming_use_enumerate == True
@@ -94,57 +106,37 @@ class VIEW3D_OT_replace_name(bpy.types.Operator):
new_name, dataList = numerate_entity_name(context, replaceName, dataList, entity.name,
return_type_list=True)
elif scene.renaming_object_types == 'BONE':
new_name, boneList = numerate_entity_name(context, replaceName, boneList, entity.name,
return_type_list=True)
elif scene.renaming_object_types == 'COLLECTION':
new_name = numerate_entity_name(context, replaceName, bpy.data.collections, entity.name)
elif scene.renaming_object_types == 'ACTIONS':
new_name = numerate_entity_name(context, replaceName, bpy.data.actions, entity.name)
elif scene.renaming_object_types == 'SHAPEKEYS':
new_name, shapeKeyNamesList = numerate_entity_name(context, replaceName,
shapeKeyNamesList, entity.name,
elif scene.renaming_object_types in per_object_types:
new_name, per_obj_name_list = numerate_entity_name(context, replaceName,
per_obj_name_list, entity.name,
return_type_list=True)
elif scene.renaming_object_types == 'MODIFIERS':
new_name, modifierNamesList = numerate_entity_name(context, replaceName,
modifierNamesList, entity.name,
return_type_list=True)
elif context.scene.renaming_object_types == 'VERTEXGROUPS':
new_name, vertexGroupNameList = numerate_entity_name(context, replaceName,
vertexGroupNameList, entity.name,
return_type_list=True)
elif context.scene.renaming_object_types == 'PARTICLESYSTEM':
elif scene.renaming_object_types == 'PARTICLESYSTEM':
new_name, particleList = numerate_entity_name(context, replaceName,
particleList, entity.name,
return_type_list=True)
elif context.scene.renaming_object_types == 'PARTICLESETTINGS':
elif scene.renaming_object_types == 'PARTICLESETTINGS':
new_name, particleSettingsList = numerate_entity_name(context, replaceName,
particleSettingsList, entity.name,
return_type_list=True)
elif context.scene.renaming_object_types == 'UVMAPS':
new_name, uvmapsList = numerate_entity_name(context, replaceName,
uvmapsList, entity.name,
return_type_list=True)
elif context.scene.renaming_object_types == 'ATTRIBUTES':
new_name, attributeList = numerate_entity_name(context, replaceName,
attributeList, entity.name,
return_type_list=True)
elif context.scene.renaming_object_types == 'COLORATTRIBUTES':
new_name, colorAttributeList = numerate_entity_name(context, replaceName,
colorAttributeList, entity.name,
return_type_list=True)
try:
entity.name = new_name
rename_data_if_enabled(scene, entity)
if scene.renaming_object_types == 'BONE':
update_bone_drivers(oldName, entity.name)
msg.add_message(oldName, entity.name)
except AttributeError:
print("Attribute {} is read only".format(new_name))
@@ -153,6 +145,7 @@ class VIEW3D_OT_replace_name(bpy.types.Operator):
else: # len(str(replaceName)) <= 0
msg.add_message(None, None, "Insert a valid string to replace names")
log_timing(context, "name_replace", t_start, len(renaming_list))
call_renaming_popup(context)
if switch_edit_mode:
switch_to_edit_mode(context)
@@ -1,8 +1,10 @@
import time
import bpy
from .renaming_operators import switch_to_edit_mode
from .. import __package__ as base_package
from ..operators.renaming_utilities import get_renaming_list, call_renaming_popup, call_error_popup
from ..operators.renaming_utilities import get_renaming_list, call_renaming_popup, call_error_popup, rename_data_if_enabled, update_bone_drivers, log_timing
class VIEW3D_OT_renaming_numerate(bpy.types.Operator):
@@ -32,17 +34,31 @@ class VIEW3D_OT_renaming_numerate(bpy.types.Operator):
call_error_popup(context)
return {'CANCELLED'}
per_object_types = {'SHAPEKEYS', 'VERTEXGROUPS', 'UVMAPS', 'COLORATTRIBUTES', 'ATTRIBUTES', 'BONE'}
obj_type = wm.renaming_object_types
t_start = time.perf_counter()
if len(renaming_list) > 0:
i = 0
current_owner = None
for entity in renaming_list:
if entity is not None:
if obj_type in per_object_types:
owner = entity.id_data
if owner != current_owner:
current_owner = owner
i = 0
oldName = entity.name
new_name = entity.name + separator + (
'{num:{fill}{width}}'.format(num=(i * step) + start_number, fill='0', width=digits))
entity.name = new_name
rename_data_if_enabled(wm, entity)
if obj_type == 'BONE':
update_bone_drivers(oldName, entity.name)
msg.add_message(oldName, entity.name)
i = i + 1
log_timing(context, "numerate", t_start, len(renaming_list))
call_renaming_popup(context)
if switch_edit_mode:
switch_to_edit_mode(context)
@@ -0,0 +1,61 @@
import bpy
from bpy.types import Operator
class VIEW3D_OT_reload_addon(Operator):
"""Reload all Simple Renaming scripts."""
bl_idname = "renaming.reload_addon"
bl_label = "Reload Addon"
bl_description = "Reload all Simple Renaming scripts"
def execute(self, context):
import importlib
import sys
# Derive the addon root package name by stripping the sub-package suffix.
# Works for both legacy addons ("simple_renaming.operators")
# and extensions ("bl_ext.user_default.simple_renaming.operators").
root_pkg = __package__.rsplit(".", 1)[0]
# Snapshot the module names now, before any reload happens.
# Sort key: deeper modules first (so core.* sub-modules reload before
# core.__init__), and alphabetically within the same depth so that
# "core.*" always reloads before "operators.*" before "ui.*".
mod_names = sorted(
[name for name in sys.modules
if name == root_pkg or name.startswith(root_pkg + ".")],
key=lambda n: (-n.count("."), n),
)
# Defer the actual reload to the next event-loop iteration so that this
# operator's own execute() has finished (and its class has been removed
# from the call stack) before we unregister and reload everything.
def _do_reload():
root_mod = sys.modules.get(root_pkg)
if root_mod and hasattr(root_mod, "unregister"):
try:
root_mod.unregister()
except Exception as exc:
print(f"[RENAMING] unregister error: {exc}")
for name in mod_names:
mod = sys.modules.get(name)
if mod is not None:
try:
importlib.reload(mod)
except Exception as exc:
print(f"[RENAMING] reload error for '{name}': {exc}")
# Re-fetch root after in-place reload to pick up any top-level changes.
root_mod = sys.modules.get(root_pkg)
if root_mod and hasattr(root_mod, "register"):
try:
root_mod.register()
except Exception as exc:
print(f"[RENAMING] register error: {exc}")
print(f"[RENAMING] Reloaded {len(mod_names)} modules from '{root_pkg}'")
bpy.app.timers.register(_do_reload, first_interval=0.0)
self.report({'INFO'}, f"Queued reload of {len(mod_names)} modules…")
return {'FINISHED'}
@@ -0,0 +1,62 @@
import time
import bpy
from ..operators.renaming_utilities import call_renaming_popup, call_error_popup, log_timing
INDEXED_TYPES = ('UVMAPS', 'COLORATTRIBUTES', 'ATTRIBUTES', 'VERTEXGROUPS', 'SHAPEKEYS')
_accessor_map = {
'UVMAPS': lambda obj: obj.data.uv_layers,
'COLORATTRIBUTES': lambda obj: obj.data.color_attributes,
'ATTRIBUTES': lambda obj: obj.data.attributes,
'VERTEXGROUPS': lambda obj: obj.vertex_groups,
'SHAPEKEYS': lambda obj: obj.data.shape_keys.key_blocks if obj.data and obj.data.shape_keys else [],
}
class VIEW3D_OT_rename_by_index(bpy.types.Operator):
bl_idname = "renaming.rename_by_index"
bl_label = "Rename Slot"
bl_description = "Rename the item at the specified index on each selected object"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
scene = context.scene
target_index = scene.renaming_index_target
new_name = scene.renaming_index_new_name
entity_type = scene.renaming_object_types
msg = scene.renaming_messages
if not new_name:
error_msg = scene.renaming_error_messages
error_msg.add_message("Name field is empty")
call_error_popup(context)
return {'CANCELLED'}
get_collection = _accessor_map.get(entity_type)
if get_collection is None:
return {'CANCELLED'}
obj_list = context.selected_objects.copy() if scene.renaming_only_selection else list(bpy.data.objects)
t_start = time.perf_counter()
renamed = 0
for obj in obj_list:
if obj.type != 'MESH':
continue
try:
items = list(get_collection(obj))
if target_index < len(items):
item = items[target_index]
old_name = item.name
item.name = new_name
msg.add_message(old_name, item.name)
renamed += 1
except Exception as e:
self.report({'WARNING'}, f"Skipped {obj.name}: {e}")
continue
log_timing(context, "rename_by_index", t_start, renamed)
call_renaming_popup(context)
return {'FINISHED'}
@@ -29,120 +29,64 @@ def numerate_entity_name(context, basename, type_list, active_entity_name, retur
'{num:{fill}{width}}'.format(num=(i * step) + start_number, fill='0', width=digits))
i += 1
if return_type_list: # Manually add new name to custom generated list like all bones and all shape keys
type_list.append(new_name)
if return_type_list: # Manually add new name to custom generated set like all bones and all shape keys
type_list.add(new_name)
return new_name, type_list
return new_name
def getAllBones(mode):
"""Get list of all bones depending on Edit or Pose Mode"""
boneList = []
for arm in bpy.data.armatures:
if mode == 'POSE':
for bone in arm.bones:
boneList.append(bone.name)
else: # mode == 'EDIT':
for bone in arm.edit_bones:
boneList.append(bone.name)
return boneList
"""Get list of all bone names depending on Edit or Pose Mode"""
if mode == 'POSE':
return [bone.name for arm in bpy.data.armatures for bone in arm.bones]
else: # mode == 'EDIT'
return [bone.name for arm in bpy.data.armatures for bone in arm.edit_bones]
def getAllModifiers():
"""get list of all modifiers"""
modifierList = []
for obj in bpy.data.objects:
for mod in obj.modifiers:
modifierList.append(mod.name)
return modifierList
"""get list of all modifier names"""
return [mod.name for obj in bpy.data.objects for mod in obj.modifiers]
def getAllShapeKeys():
"""get list of all shape keys"""
shapeKeyNamesList = []
for key_grp in bpy.data.shape_keys:
for key in key_grp.key_blocks:
shapeKeyNamesList.append(key.name)
return shapeKeyNamesList
"""get list of all shape key names"""
return [key.name for key_grp in bpy.data.shape_keys for key in key_grp.key_blocks]
def getAllVertexGroups():
"""get list of all vertex groups"""
vrtx_grp_names_list = []
for obj in bpy.data.objects:
for vrtGrp in obj.vertex_groups:
vrtx_grp_names_list.append(vrtGrp.name)
return vrtx_grp_names_list
"""get list of all vertex group names"""
return [vg.name for obj in bpy.data.objects for vg in obj.vertex_groups]
def getAllParticleNames():
"""get list of all particle systems"""
particlesNamesList = []
for obj in bpy.data.objects:
for particle_system in obj.particle_systems:
particlesNamesList.append(particle_system.name)
return particlesNamesList
"""get list of all particle system names"""
return [ps.name for obj in bpy.data.objects for ps in obj.particle_systems]
def getAllParticleSettingsNames():
"""get list of all particle settings"""
particlesNamesList = []
for par in bpy.data.particles:
particlesNamesList.append(par.name)
return particlesNamesList
"""get list of all particle settings names"""
return [par.name for par in bpy.data.particles]
def getAllUvMaps():
uvNamesList = []
for obj in bpy.data.objects:
if obj.type != 'MESH':
continue
for uv in obj.data.uv_layers:
uvNamesList.append(uv)
return uvNamesList
"""get list of all UV map names"""
return [uv.name for obj in bpy.data.objects if obj.type == 'MESH'
for uv in obj.data.uv_layers]
def getAllColorAttributes():
colorAttributesList = []
for obj in bpy.data.objects:
if obj.type != 'MESH':
continue
for color_attribute in obj.data.color_attributes:
colorAttributesList.append(color_attribute)
return colorAttributesList
"""get list of all color attribute names"""
return [ca.name for obj in bpy.data.objects if obj.type == 'MESH'
for ca in obj.data.color_attributes]
def getAllAttributes():
attributesList = []
for obj in bpy.data.objects:
if obj.type != 'MESH':
continue
for color_attribute in obj.data.color_attributes:
attributesList.append(color_attribute)
return attributesList
"""get list of all attribute names"""
return [attr.name for obj in bpy.data.objects if obj.type == 'MESH'
for attr in obj.data.attributes]
def getAllDataNames():
"""get list of all data"""
dataList = []
for obj in bpy.data.objects:
if obj.data is not None:
dataList.append(obj.data.name)
return dataList
"""get list of all data names"""
return [obj.data.name for obj in bpy.data.objects if obj.data is not None]
@@ -1,9 +1,22 @@
import time
import bpy
from bpy.types import PoseBone, EditBone
from .. import __package__ as base_package
def log_timing(context, label, t_start, entity_count):
"""Print elapsed time to the console when debug_timing is enabled."""
prefs = context.preferences.addons[base_package].preferences
if not prefs.debug_timing:
return
elapsed_ms = (time.perf_counter() - t_start) * 1000
print(f"[RENAMING] {label}: {elapsed_ms:.1f} ms ({entity_count} entities, "
f"{elapsed_ms / entity_count:.3f} ms/entity)" if entity_count else
f"[RENAMING] {label}: {elapsed_ms:.1f} ms")
def trim_string(string, size):
return string[size[0]:max(0, len(string)-size[1])]
@@ -38,12 +51,14 @@ def get_renaming_list(context):
if scene.renaming_object_types == 'OBJECT':
for obj in obj_list:
if obj in obj_list and obj.type in scene.renaming_object_types_specified:
if obj.type in scene.renaming_object_types_specified:
renaming_list.append(obj)
elif scene.renaming_object_types == 'DATA':
seen_data = set()
for obj in obj_list:
if obj.data not in renaming_list:
if obj.data is not None and id(obj.data) not in seen_data:
seen_data.add(id(obj.data))
renaming_list.append(obj.data)
elif scene.renaming_object_types == 'MATERIAL':
@@ -117,14 +132,25 @@ def get_renaming_list(context):
renaming_list = list(bpy.data.collections)
elif scene.renaming_object_types == 'SHAPEKEYS':
filter_index = scene.renaming_filter_by_index
idx = scene.renaming_index_target
if selection_only:
for obj in context.selected_objects:
for shape in obj.data.shape_keys.key_blocks:
renaming_list.append(shape)
if obj.data and obj.data.shape_keys:
items = list(obj.data.shape_keys.key_blocks)
if filter_index:
if idx < len(items):
renaming_list.append(items[idx])
else:
renaming_list.extend(items)
else: # selection_only == False:
for key_grp in bpy.data.shape_keys:
for key in key_grp.key_blocks:
renaming_list.append(key)
items = list(key_grp.key_blocks)
if filter_index:
if idx < len(items):
renaming_list.append(items[idx])
else:
renaming_list.extend(items)
elif scene.renaming_object_types == 'MODIFIERS':
if selection_only:
@@ -137,14 +163,16 @@ def get_renaming_list(context):
renaming_list.append(mod)
elif context.scene.renaming_object_types == 'VERTEXGROUPS':
if selection_only:
for obj in context.selected_objects:
for vtx in obj.vertex_groups:
renaming_list.append(vtx)
else:
for obj in bpy.data.objects:
for vtx in obj.vertex_groups:
renaming_list.append(vtx)
filter_index = scene.renaming_filter_by_index
idx = scene.renaming_index_target
obj_iter = context.selected_objects if selection_only else bpy.data.objects
for obj in obj_iter:
items = list(obj.vertex_groups)
if filter_index:
if idx < len(items):
renaming_list.append(items[idx])
else:
renaming_list.extend(items)
elif context.scene.renaming_object_types == 'PARTICLESYSTEM':
if selection_only:
@@ -162,27 +190,64 @@ def get_renaming_list(context):
elif context.scene.renaming_object_types == 'UVMAPS':
filter_index = scene.renaming_filter_by_index
active_only = scene.renaming_active_only
idx = scene.renaming_index_target
for obj in obj_list:
if obj.type != 'MESH':
continue
for uv in obj.data.uv_layers:
renaming_list.append(uv)
if filter_index:
items = list(obj.data.uv_layers)
if idx < len(items):
item = items[idx]
if not active_only or obj.data.uv_layers.active == item:
renaming_list.append(item)
elif active_only:
active = obj.data.uv_layers.active
if active is not None:
renaming_list.append(active)
else:
for uv in obj.data.uv_layers:
renaming_list.append(uv)
elif context.scene.renaming_object_types == 'COLORATTRIBUTES':
filter_index = scene.renaming_filter_by_index
active_only = scene.renaming_active_only
idx = scene.renaming_index_target
for obj in obj_list:
if obj.type != 'MESH':
continue
for color_attribute in obj.data.color_attributes:
renaming_list.append(color_attribute)
if filter_index:
items = list(obj.data.color_attributes)
if idx < len(items):
item = items[idx]
if not active_only or obj.data.color_attributes.active_color == item:
renaming_list.append(item)
elif active_only:
active = obj.data.color_attributes.active_color
if active is not None:
renaming_list.append(active)
else:
for color_attribute in obj.data.color_attributes:
renaming_list.append(color_attribute)
elif context.scene.renaming_object_types == 'ATTRIBUTES':
filter_index = scene.renaming_filter_by_index
idx = scene.renaming_index_target
for obj in obj_list:
if obj.type != 'MESH':
continue
for attribute in obj.data.attributes:
renaming_list.append(attribute)
items = list(obj.data.attributes)
if filter_index:
if idx < len(items):
renaming_list.append(items[idx])
else:
renaming_list.extend(items)
elif scene.renaming_object_types == 'NODE_GROUPS':
renaming_list = list(bpy.data.node_groups)
elif scene.renaming_object_types == 'ACTIONS':
if selection_only:
@@ -263,6 +328,46 @@ def get_sorted_objects_z(objects):
return sorted_objects
def rename_data_if_enabled(scene, entity):
if scene.renaming_also_rename_data and \
scene.renaming_object_types in ('OBJECT', 'ADDOBJECTS'):
if hasattr(entity, 'data') and entity.data is not None:
entity.data.name = entity.name
def update_bone_drivers(old_name, new_name):
"""Update all driver paths that reference a renamed bone."""
if old_name == new_name:
return
# Blender may use either double or single quotes in data_path strings.
old_tokens = (f'pose.bones["{old_name}"]', f"pose.bones['{old_name}']")
new_token = f'pose.bones["{new_name}"]'
for datablock in list(bpy.data.objects) + list(bpy.data.scenes):
anim_data = getattr(datablock, 'animation_data', None)
if anim_data is None:
continue
for fcurve in anim_data.drivers:
# Location 1: FCurve data_path
for old_token in old_tokens:
if old_token in fcurve.data_path:
fcurve.data_path = fcurve.data_path.replace(old_token, new_token)
driver = fcurve.driver
if driver is None:
continue
for var in driver.variables:
for target in var.targets:
# Location 2: bone_target field
if target.bone_target == old_name:
target.bone_target = new_name
# Location 3: data_path inside variable target
for old_token in old_tokens:
if old_token in target.data_path:
target.data_path = target.data_path.replace(old_token, new_token)
def clear_order_flag(obj):
try:
del obj["selection_order"]
@@ -271,7 +376,7 @@ def clear_order_flag(obj):
def update_selection_order():
if not bpy.context.selected_objects:
if not (getattr(bpy.context, 'selected_objects', None) or list(bpy.context.view_layer.objects.selected)):
for o in bpy.data.objects:
clear_order_flag(o)
return
@@ -1,10 +1,102 @@
import re
import time
import bpy
from .renaming_operators import switch_to_edit_mode
from ..operators.renaming_utilities import get_renaming_list, call_renaming_popup, call_error_popup
from ..operators.renaming_utilities import get_renaming_list, call_renaming_popup, call_error_popup, rename_data_if_enabled, update_bone_drivers, log_timing
from ..variable_replacer.variable_replacer import VariableReplacer
from .case_transform import to_upper, to_lower, upper_first, lower_first
# ---------------------------------------------------------------------------
# Regex replace with \u \l \U \L case modifier support
# Modifiers apply to the immediately following group reference ($N or \N).
# \u$1 — uppercase first char of group 1
# \l$1 — lowercase first char of group 1
# \U$1 — uppercase all of group 1
# \L$1 — lowercase all of group 1
# Both $1 and \1 are accepted as group references.
# ---------------------------------------------------------------------------
def _read_group_ref(repl, i, match):
"""Read a $N or \\N group reference at position i.
Returns (group_value, chars_consumed)."""
if i >= len(repl):
return '', 0
c = repl[i]
if c in ('$', '\\') and i + 1 < len(repl) and repl[i + 1].isdigit():
group_num = int(repl[i + 1])
try:
return match.group(group_num) or '', 2
except IndexError:
return '', 0
return '', 0
def _expand_replacement(repl, match):
"""Expand a replacement string, handling case modifiers and group refs."""
result = []
i = 0
n = len(repl)
while i < n:
c = repl[i]
if c == '\\' and i + 1 < n:
next_c = repl[i + 1]
if next_c in ('u', 'l', 'U', 'L'):
modifier = next_c
i += 2
group_val, advance = _read_group_ref(repl, i, match)
i += advance
if modifier == 'u':
group_val = upper_first(group_val)
elif modifier == 'l':
group_val = lower_first(group_val)
elif modifier == 'U':
group_val = to_upper(group_val)
elif modifier == 'L':
group_val = to_lower(group_val)
result.append(group_val)
elif next_c.isdigit():
group_num = int(next_c)
try:
result.append(match.group(group_num) or '')
except IndexError:
result.append('\\' + next_c)
i += 2
else:
result.append(c)
i += 1
elif c == '$' and i + 1 < n and repl[i + 1].isdigit():
group_num = int(repl[i + 1])
try:
result.append(match.group(group_num) or '')
except IndexError:
result.append(c)
i += 2
else:
result.append(c)
i += 1
return ''.join(result)
def regex_case_sub(pattern, repl, string):
"""re.sub that additionally supports \\u \\l \\U \\L case modifiers."""
if not re.search(r'\\[uUlL]', repl):
return re.sub(pattern, repl, string)
def replacer(match):
return _expand_replacement(repl, match)
return re.sub(pattern, replacer, string)
class VIEW3D_OT_search_and_replace(bpy.types.Operator):
@@ -25,11 +117,20 @@ class VIEW3D_OT_search_and_replace(bpy.types.Operator):
call_error_popup(context)
return {'CANCELLED'}
t_start = time.perf_counter()
searchName = wm.renaming_search
msg = wm.renaming_messages # variable to save messages
VariableReplacer.reset()
VariableReplacer.prepare(context)
# When the search string contains no @ variables it is the same for
# every entity, so the case-insensitive pattern can be compiled once.
search_has_variables = '@' in searchName
static_pattern = None
if not wm.renaming_useRegex and not wm.renaming_matchcase and not search_has_variables and searchName != '':
static_pattern = re.compile(re.escape(searchName), re.IGNORECASE)
if len(renaming_list) > 0:
for entity in renaming_list: # iterate over all objects that are to be renamed
@@ -41,19 +142,18 @@ class VIEW3D_OT_search_and_replace(bpy.types.Operator):
if not wm.renaming_useRegex:
if wm.renaming_matchcase:
new_name = str(entity.name).replace(searchReplaced, replaceReplaced)
entity.name = new_name
msg.add_message(oldName, entity.name)
else:
replaceSearch = re.compile(re.escape(searchReplaced), re.IGNORECASE)
new_name = replaceSearch.sub(replaceReplaced, entity.name)
entity.name = new_name
msg.add_message(oldName, entity.name)
pattern = static_pattern or re.compile(re.escape(searchReplaced), re.IGNORECASE)
new_name = pattern.sub(replaceReplaced, entity.name)
else: # Use regex
# pattern = re.compile(re.escape(searchName))
new_name = re.sub(searchReplaced, replaceReplaced, str(entity.name))
entity.name = new_name
msg.add_message(oldName, entity.name)
new_name = regex_case_sub(searchReplaced, replaceReplaced, str(entity.name))
entity.name = new_name
rename_data_if_enabled(wm, entity)
if wm.renaming_object_types == 'BONE':
update_bone_drivers(oldName, entity.name)
msg.add_message(oldName, entity.name)
log_timing(context, "search_replace", t_start, len(renaming_list))
call_renaming_popup(context)
if switch_edit_mode:
switch_to_edit_mode(context)
@@ -29,6 +29,7 @@ class VIEW3D_OT_search_and_select(VIEW3D_OT_naming):
def execute(self, context):
super().execute(context)
VariableReplacer.prepare(context)
wm = context.scene
# get list of objects to be selected
@@ -56,8 +57,15 @@ class VIEW3D_OT_search_and_select(VIEW3D_OT_naming):
selectionList.append(entity)
msg.add_message("selected", entityName)
else:
if re.search(searchReplaced, entityName, re.IGNORECASE):
selectionList.append(entity)
try:
if re.search(searchReplaced, entityName, re.IGNORECASE):
selectionList.append(entity)
except re.error as err:
# invalid regex, add message but continue so other names can still be processed
error_msg = f"Invalid regular expression in search: {err}"
wm.renaming_error_messages.add_message(error_msg)
call_error_popup(context)
return {'CANCELLED'}
if str(wm.renaming_object_types) == 'OBJECT':
# set to object mode
@@ -74,7 +82,7 @@ class VIEW3D_OT_search_and_select(VIEW3D_OT_naming):
if bpy.context.mode == 'POSE':
bpy.ops.pose.select_all(action='DESELECT')
for bone in selectionList:
bone.select = True
bone.bone.select = True
elif bpy.context.mode == 'EDIT_ARMATURE':
bpy.ops.armature.select_all(action='DESELECT')
@@ -1,8 +1,11 @@
import time
import bpy
from .renaming_operators import switch_to_edit_mode
from ..operators.renaming_utilities import get_renaming_list, trim_string, call_renaming_popup, call_error_popup
from ..operators.renaming_utilities import get_renaming_list, trim_string, call_renaming_popup, call_error_popup, rename_data_if_enabled, update_bone_drivers, log_timing
class VIEW3D_OT_trim_string(bpy.types.Operator):
bl_idname = "renaming.trim_string"
bl_label = "Trim String"
@@ -19,6 +22,7 @@ class VIEW3D_OT_trim_string(bpy.types.Operator):
call_error_popup(context)
return {'CANCELLED'}
t_start = time.perf_counter()
msg = wm.renaming_messages
if len(renaming_list) > 0:
@@ -27,11 +31,15 @@ class VIEW3D_OT_trim_string(bpy.types.Operator):
old_name = entity.name
new_name = trim_string(entity.name, wm.renaming_trim_indices)
entity.name = new_name
rename_data_if_enabled(wm, entity)
if wm.renaming_object_types == 'BONE':
update_bone_drivers(old_name, entity.name)
msg.add_message(old_name, entity.name)
log_timing(context, "trim_string", t_start, len(renaming_list))
call_renaming_popup(context)
if switch_edit_mode:
switch_to_edit_mode(context)
return {'FINISHED'}
@@ -0,0 +1,62 @@
import threading
import urllib.request
import urllib.error
import json
# Module-level state — read by the panel draw function
update_available = False
latest_version_str = ""
_RELEASES_URL = "https://api.github.com/repos/Weisl/simple_renaming/releases/latest"
def _parse_version(version_str):
"""Convert '2.1.4' or 'v2.1.4' to (2, 1, 4)."""
return tuple(int(x) for x in version_str.lstrip("v").split("."))
def _fetch():
global update_available, latest_version_str
try:
req = urllib.request.Request(
_RELEASES_URL,
headers={"User-Agent": "simple-renaming-addon"},
)
with urllib.request.urlopen(req, timeout=5) as response:
data = json.loads(response.read().decode())
tag = data.get("tag_name", "")
if not tag:
return
latest = _parse_version(tag)
# Read current version from blender_manifest.toml at the addon root
import os
manifest_path = os.path.join(os.path.dirname(__file__), "..", "blender_manifest.toml")
current_str = ""
with open(manifest_path, encoding="utf-8") as f:
for line in f:
if line.startswith("version"):
current_str = line.split("=")[1].strip().strip('"')
break
if not current_str:
return
current = _parse_version(current_str)
if latest > current:
update_available = True
latest_version_str = tag.lstrip("v")
else:
print(f"[RENAMING] Addon is up to date (v{current_str})")
except Exception as exc:
print(f"[RENAMING] version check failed: {exc}")
def start_version_check():
"""Fire a background thread to check for a newer release on GitHub."""
t = threading.Thread(target=_fetch, daemon=True)
t.start()
@@ -26,10 +26,6 @@ class BUTTON_OT_change_key(bpy.types.Operator):
property_prefix: bpy.props.StringProperty()
def __init__(self):
self.prefs = None
self.my_event = ''
def invoke(self, context, event):
prefs = context.preferences.addons[base_package].preferences
self.prefs = prefs
@@ -57,7 +53,7 @@ class BUTTON_OT_change_key(bpy.types.Operator):
def add_keymap():
km = bpy.context.window_manager.keyconfigs.addon.keymaps.new(name="Window")
km = bpy.context.window_manager.keyconfigs.active.keymaps.new(name="Window")
prefs = bpy.context.preferences.addons[base_package].preferences
kmi = km.keymap_items.new(idname='wm.call_panel', type=prefs.renaming_panel_type, value='PRESS',
@@ -80,7 +76,7 @@ def add_key_to_keymap(idname, kmi, km, active=True):
def remove_key(context, idname, properties_name):
"""Removes addon hotkeys from the keymap"""
wm = bpy.context.window_manager
km = wm.keyconfigs.addon.keymaps['Window']
km = wm.keyconfigs.active.keymaps['Window']
for kmi in km.keymap_items:
if kmi.idname == idname and kmi.properties.name == properties_name:
@@ -91,7 +87,7 @@ def remove_keymap():
"""Removes keys from the keymap. Currently, this is only called when unregistering the addon. """
# only works for menus and pie menus
wm = bpy.context.window_manager
km = wm.keyconfigs.addon.keymaps['Window']
km = wm.keyconfigs.active.keymaps['Window']
for kmi in km.keymap_items:
if hasattr(kmi.properties, 'name') and kmi.properties.name in ['VIEW3D_PT_tools_renaming_panel',
@@ -7,7 +7,6 @@ from bpy.props import (
from .renaming_keymap import remove_key
from .. import __package__ as base_package
from ..ui.renaming_panels import VIEW3D_PT_tools_renaming_panel, VIEW3D_PT_tools_type_suffix
def label_multiline(context, text, parent):
@@ -29,7 +28,7 @@ def add_key(km, idname, properties_name, button_assignment_type, button_assignme
def update_key(context, operation, operator_name, property_prefix):
# This functions gets called when the hotkey assignment is updated in the preferences
wm = context.window_manager
km = wm.keyconfigs.addon.keymaps["Window"]
km = wm.keyconfigs.active.keymaps["Window"]
prefs = context.preferences.addons[base_package].preferences
@@ -51,6 +50,7 @@ def update_suf_pre_key(self, context):
def update_panel_category(self, context):
"""Update panel tab for collider tools"""
from ..ui.renaming_panels import VIEW3D_PT_tools_renaming_panel, VIEW3D_PT_tools_type_suffix
panels = [
VIEW3D_PT_tools_renaming_panel,
@@ -70,6 +70,7 @@ def update_panel_category(self, context):
def toggle_suffix_prefix_panel(self, context):
from ..ui.renaming_panels import VIEW3D_PT_tools_type_suffix
if self.renaming_show_suffix_prefix_panel:
bpy.utils.register_class(VIEW3D_PT_tools_type_suffix)
else:
@@ -107,6 +108,12 @@ class VIEW3D_OT_renaming_preferences(bpy.types.AddonPreferences):
default=True,
)
debug_timing: bpy.props.BoolProperty(
name="Debug Timing",
description="Print operator execution time to the console after each rename operation",
default=False,
)
renamingPanel_useObjectOrder: bpy.props.BoolProperty(
name="Use Selection Order",
description="Use the order of selection when renaming objects",
@@ -162,6 +169,25 @@ class VIEW3D_OT_renaming_preferences(bpy.types.AddonPreferences):
default='',
)
date_format: StringProperty(
name="Date Format",
description=(
"strftime format string for the @d variable. "
"Codes: %d=day(03), %m=month(04), %y=year(26), %Y=year(2026), %b=month abbr(Apr). "
"Examples: %d%m%Y → 03042026 (DDMMYYYY), %m%d%y → 040326 (MMDDYY), %d%b%Y → 03Apr2026"
),
default="%y%m%d",
)
time_format: StringProperty(
name="Time Format",
description=(
"strftime format string for the @i variable. "
"Codes: %H=hour 24h(14), %M=minute(30), %S=second(05), %I=hour 12h(02), %p=AM/PM. "
"Example: %H%M → 1430. Avoid colons — invalid in filenames on Windows"
),
default="%H%M",
)
renaming_show_suffix_prefix_panel: bpy.props.BoolProperty(
name="Prefix/Suffix by Type Panel",
description="Enable or disable the Prefix/Suffix by Type Panel",
@@ -185,7 +211,7 @@ class VIEW3D_OT_renaming_preferences(bpy.types.AddonPreferences):
"renamingPanel_showPopup",
"renaming_show_suffix_prefix_panel",
"renamingPanel_useObjectOrder",
"debug_timing",
]
props_naming = [
"renaming_separator",
@@ -205,6 +231,11 @@ class VIEW3D_OT_renaming_preferences(bpy.types.AddonPreferences):
"renaming_user3"
]
props_date_time = [
"date_format",
"time_format",
]
renaming_panel_type: bpy.props.StringProperty(
name="Renaming Popup",
default="F2",
@@ -325,6 +356,13 @@ class VIEW3D_OT_renaming_preferences(bpy.types.AddonPreferences):
row = box.row()
row.prop(self, propName)
box = layout.box()
row = box.row()
row.label(text='Date & Time Variables')
for propName in self.props_date_time:
row = box.row()
row.prop(self, propName)
box = layout.box()
row = box.row()
row.label(text='User Variables')
@@ -2,7 +2,7 @@ import bpy
from .info_messages import RENAMING_MESSAGES, WarningError_MESSAGES, INFO_MESSAGES
from .renaming_panels import VIEW3D_PT_tools_renaming_panel, VIEW3D_PT_tools_type_suffix, VIEW3D_OT_SetVariable, \
VIEW3D_OT_RenamingPopupOperator, OBJECT_MT_suffix_prefix_presets, AddPresetRenamingPresets
VIEW3D_OT_RenamingPopupOperator, OBJECT_MT_suffix_prefix_presets, AddPresetRenamingPresets, RENAMING_MT_caseMenu
from .renaming_panels import panel_func
from .renaming_popup import VIEW3D_PT_renaming_popup, VIEW3D_PT_info_popup, VIEW3D_PT_error_popup
from .renaming_variables import RENAMING_MT_variableMenu, VIEW3D_OT_inputVariables
@@ -10,6 +10,7 @@ from .ui_helpers import PREFERENCES_OT_open_addon
classes = (
RENAMING_MT_variableMenu,
RENAMING_MT_caseMenu,
VIEW3D_OT_inputVariables,
VIEW3D_PT_error_popup,
VIEW3D_PT_info_popup,
@@ -42,6 +43,8 @@ def register():
def unregister():
from bpy.utils import unregister_class
VIEW3D_PT_tools_type_suffix.remove(panel_func)
for cls in reversed(classes):
unregister_class(cls)
@@ -15,6 +15,13 @@ types_of_selected = (
def draw_renaming_panel(layout, context):
from ..operators.version_check import update_available, latest_version_str
if update_available:
row = layout.row(align=True)
row.alert = True
row.label(text=f"Update available: v{latest_version_str}", icon='ERROR')
scene = context.scene
row = layout.row(align=True)
@@ -25,8 +32,18 @@ def draw_renaming_panel(layout, context):
# SELECTED
if str(scene.renaming_object_types) == 'OBJECT':
layout.prop(scene, "renaming_object_types_specified", expand=True)
layout.prop(scene, "renaming_also_rename_data")
if str(scene.renaming_object_types) in types_of_selected:
layout.prop(scene, "renaming_only_selection", text="Only Of Selected Objects")
if str(scene.renaming_object_types) in ('UVMAPS', 'COLORATTRIBUTES', 'ATTRIBUTES', 'VERTEXGROUPS', 'SHAPEKEYS'):
col = layout.column(align=True)
if str(scene.renaming_object_types) in ('UVMAPS', 'COLORATTRIBUTES'):
col.prop(scene, "renaming_active_only")
row = col.row(align=True)
row.prop(scene, "renaming_filter_by_index")
sub = row.row(align=True)
sub.enabled = scene.renaming_filter_by_index
sub.prop(scene, "renaming_index_target", text="")
elif str(scene.renaming_object_types) in types_selected:
layout.prop(scene, "renaming_only_selection", text="Only Selected")
elif str(scene.renaming_object_types) == 'COLLECTION':
@@ -44,7 +61,7 @@ def draw_renaming_panel(layout, context):
box = layout
# Sorting
if str(scene.renaming_object_types) not in ['COLLECTION', 'IMAGE']:
if str(scene.renaming_object_types) not in ['COLLECTION', 'IMAGE', 'NODE_GROUPS']:
col = box.column(align=True)
col.prop(scene, "renaming_sorting")
if scene.renaming_sorting:
@@ -140,7 +157,9 @@ def draw_renaming_panel(layout, context):
layout.label(text="Other")
row = layout.row(align=True)
row.operator("renaming.numerate", icon="LINENUMBERS_ON")
row.operator("renaming.numerate", icon="LINENUMBERS_ON")
row = layout.row(align=True)
row.menu("RENAMING_MT_case_menu", text="Case Transform")
if str(scene.renaming_object_types) in ('DATA', 'OBJECT', 'ADDOBJECTS'):
layout.separator()
@@ -178,6 +197,7 @@ class VIEW3D_PT_tools_renaming_panel(bpy.types.Panel):
op = row.operator("preferences.rename_addon_search", text="", icon='PREFERENCES')
op.addon_name = addon_name
op.prefs_tabs = 'UI'
row.operator("renaming.reload_addon", text="", icon='FILE_REFRESH')
def draw(self, context):
layout = self.layout
@@ -282,10 +302,30 @@ class VIEW3D_PT_tools_type_suffix(bpy.types.Panel):
row.prop(scene, "renaming_suffix_prefix_lightprops", text="")
row.operator('renaming.add_suffix_prefix_by_type', text="Light Probes").option = 'lightprops'
row = col.row()
row.prop(scene, "renaming_suffix_prefix_pointcloud", text="")
row.operator('renaming.add_suffix_prefix_by_type', text="Point Clouds").option = 'pointcloud'
row = col.row()
row.operator('renaming.add_suffix_prefix_by_type', text="Rename All").option = 'all'
class RENAMING_MT_caseMenu(bpy.types.Menu):
bl_label = "Case"
bl_idname = "RENAMING_MT_case_menu"
def draw(self, context):
layout = self.layout
layout.operator("renaming.case_upper", text="UPPERCASE")
layout.operator("renaming.case_lower", text="lowercase")
layout.separator()
layout.operator("renaming.case_pascal", text="PascalCase")
layout.operator("renaming.case_camel", text="camelCase")
layout.separator()
layout.operator("renaming.case_snake", text="snake_case")
layout.operator("renaming.case_kebab", text="kebab-case")
class VIEW3D_OT_SetVariable(bpy.types.Operator):
"""Tooltip"""
bl_idname = "object.renaming_set_variable"
@@ -353,6 +393,7 @@ class AddPresetRenamingPresets(AddPresetBase, Operator):
"scene.renaming_suffix_prefix_bone",
"scene.renaming_suffix_prefix_speakers",
"scene.renaming_suffix_prefix_lightprops",
"scene.renaming_suffix_prefix_pointcloud",
]
# where to store the preset
@@ -31,10 +31,26 @@ class RENAMING_MT_variableMenu(bpy.types.Menu):
layout.operator("object.renaming_multivariables", text="PARENT").renaming_variables = "PARENT"
layout.operator("object.renaming_multivariables", text="DATA").renaming_variables = "DATA"
layout.operator("object.renaming_multivariables", text="ACTIVE").renaming_variables = "ACTIVE"
layout.operator("object.renaming_multivariables", text='FILE').renaming_variables = 'OBJECT'
layout.operator("object.renaming_multivariables", text='OBJECT').renaming_variables = 'OBJECT'
layout.operator("object.renaming_multivariables", text="TYPE").renaming_variables = "TYPE"
layout.operator("object.renaming_multivariables", text="COLLECTION").renaming_variables = "COLLECTION"
if wm.renaming_object_types == 'NODE_GROUPS':
layout.separator()
layout.operator("object.renaming_multivariables", text="TYPE").renaming_variables = "TYPE"
if wm.renaming_object_types in (
'UVMAPS', 'MATERIAL', 'BONE', 'MODIFIERS', 'SHAPEKEYS',
'VERTEXGROUPS', 'PARTICLESYSTEM', 'COLORATTRIBUTES', 'ATTRIBUTES',
):
layout.separator()
layout.operator("object.renaming_multivariables", text="OBJECT").renaming_variables = "OBJECT"
layout.operator("object.renaming_multivariables", text="TYPE").renaming_variables = "TYPE"
layout.operator("object.renaming_multivariables", text="PARENT").renaming_variables = "PARENT"
layout.operator("object.renaming_multivariables", text="DATA").renaming_variables = "DATA"
layout.operator("object.renaming_multivariables", text="ACTIVE").renaming_variables = "ACTIVE"
layout.operator("object.renaming_multivariables", text="COLLECTION").renaming_variables = "COLLECTION"
class VIEW3D_OT_inputVariables(bpy.types.Operator):
"""Tooltip"""
@@ -22,7 +22,7 @@ class PREFERENCES_OT_open_addon(bpy.types.Operator):
prefs.prefs_tabs = self.prefs_tabs
import addon_utils
mod = addon_utils.addons_fake_modules.get('collider_tools')
mod = addon_utils.addons_fake_modules.get('simple_renaming')
# mod is None the first time the operation is called :/
if mod:
@@ -7,6 +7,11 @@ import bpy
from .. import __package__ as base_package
# Single compiled pattern covering all supported variables.
# Multi-char tokens (@u1/@u2/@u3) are listed before the single-char fallback
# so the alternation matches them first.
_VARIABLE_RE = re.compile(r'@(?:u[123]|[fdirhlobantpmc])')
def generate_random_string(string_length=10):
"""Generate a random string of fixed length """
@@ -23,6 +28,12 @@ class VariableReplacer:
step = 1
start_number = 0
# Per-operation lookup caches built by prepare()
_collection_cache = {} # obj_name -> concatenated collection names
_material_to_obj = {} # material_name -> first owner object name
_shape_key_to_obj = {} # id(Key datablock) -> owner object name
_mesh_arm_to_obj = {} # id(obj.data) -> owner object name
@classmethod
def reset(cls):
"""reset all values to initial state"""
@@ -38,44 +49,126 @@ class VariableReplacer:
cls.number = 0
@classmethod
def replaceInputString(cls, context, inputText, entity):
def prepare(cls, context):
"""Build per-operation lookup caches before the rename loop.
Call this once per operator execution after reset(). The caches turn
O(collections × objects) and O(objects) per-entity lookups into O(1).
"""
# Collection reverse-lookup: obj_name -> concatenated collection names
collection_cache = {}
for col in bpy.data.collections:
for obj in col.objects:
if obj.name in collection_cache:
collection_cache[obj.name] += col.name
else:
collection_cache[obj.name] = col.name
cls._collection_cache = collection_cache
# Material -> first owner object name
material_to_obj = {}
for obj in bpy.data.objects:
for slot in obj.material_slots:
if slot.material and slot.material.name not in material_to_obj:
material_to_obj[slot.material.name] = obj.name
cls._material_to_obj = material_to_obj
# Shape key (Key datablock) -> owner object name
shape_key_to_obj = {}
mesh_arm_to_obj = {}
for obj in bpy.data.objects:
if obj.data is None:
continue
data_id = id(obj.data)
if data_id not in mesh_arm_to_obj:
mesh_arm_to_obj[data_id] = obj.name
if hasattr(obj.data, 'shape_keys') and obj.data.shape_keys is not None:
sk_id = id(obj.data.shape_keys)
if sk_id not in shape_key_to_obj:
shape_key_to_obj[sk_id] = obj.name
cls._shape_key_to_obj = shape_key_to_obj
cls._mesh_arm_to_obj = mesh_arm_to_obj
@classmethod
def replaceInputString(cls, context, inputText, entity):
"""Replace custom variables with the according string"""
wm = context.scene
cls.addon_prefs = context.preferences.addons[base_package].preferences
# System and Global Values #
inputText = re.sub(r'@f', cls.getfileName(context), inputText) # file name
inputText = re.sub(r'@d', cls.getDateName(), inputText) # date
inputText = re.sub(r'@i', cls.getTimeName(), inputText) # time
inputText = re.sub(r'@r', cls.getRandomString(), inputText)
if '@' not in inputText:
return inputText
# UserStrings #
inputText = re.sub(r'@h', cls.get_high_variable(), inputText) # high
inputText = re.sub(r'@l', cls.get_low_variable(), inputText) # low
inputText = re.sub(r'@b', cls.get_cage_variable(), inputText) # cage
inputText = re.sub(r'@u1', cls.getuser1(), inputText)
inputText = re.sub(r'@u2', cls.getuser2(), inputText)
inputText = re.sub(r'@u3', cls.getuser3(), inputText)
# Find only the variables present in this template so we skip calling
# getters that are not needed (lazy evaluation).
vars_present = set(_VARIABLE_RE.findall(inputText))
# GetScene #
inputText = re.sub(r'@a', cls.getActive(context), inputText) # active object
inputText = re.sub(r'@n', cls.getNumber(), inputText)
replacements = {}
if '@n' in vars_present:
replacements['@n'] = cls.getNumber()
if '@f' in vars_present:
replacements['@f'] = cls.getfileName(context)
if '@d' in vars_present:
replacements['@d'] = cls.getDateName()
if '@i' in vars_present:
replacements['@i'] = cls.getTimeName()
if '@r' in vars_present:
replacements['@r'] = cls.getRandomString()
if '@h' in vars_present:
replacements['@h'] = cls.get_high_variable()
if '@l' in vars_present:
replacements['@l'] = cls.get_low_variable()
if '@b' in vars_present:
replacements['@b'] = cls.get_cage_variable()
if '@u1' in vars_present:
replacements['@u1'] = cls.getuser1()
if '@u2' in vars_present:
replacements['@u2'] = cls.getuser2()
if '@u3' in vars_present:
replacements['@u3'] = cls.getuser3()
if '@a' in vars_present:
replacements['@a'] = cls.getActive(context)
if wm.renaming_object_types == 'OBJECT':
# Objects
inputText = re.sub(r'@o', cls.getObject(entity), inputText) # object
inputText = re.sub(r'@t', cls.getType(entity), inputText) # type
inputText = re.sub(r'@p', cls.getParent(entity), inputText) # parent
inputText = re.sub(r'@m', cls.getData(entity), inputText) # data
inputText = re.sub(r'@c', cls.getCollection(entity), inputText) # collection
if '@o' in vars_present:
replacements['@o'] = cls.getObject(entity)
if '@t' in vars_present:
replacements['@t'] = cls.getType(entity)
if '@p' in vars_present:
replacements['@p'] = cls.getParent(entity)
if '@m' in vars_present:
replacements['@m'] = cls.getData(entity)
if '@c' in vars_present:
replacements['@c'] = cls.getCollection(entity)
if wm.renaming_object_types in (
'UVMAPS', 'MATERIAL', 'BONE', 'MODIFIERS', 'SHAPEKEYS',
'VERTEXGROUPS', 'PARTICLESYSTEM', 'COLORATTRIBUTES', 'ATTRIBUTES',
):
owner_obj = bpy.data.objects.get(cls.getOwnerObjectName(entity))
if owner_obj is not None:
if '@o' in vars_present:
replacements['@o'] = owner_obj.name
if '@t' in vars_present:
replacements['@t'] = cls.getType(owner_obj)
if '@p' in vars_present:
replacements['@p'] = cls.getParent(owner_obj)
if '@m' in vars_present:
replacements['@m'] = cls.getData(owner_obj)
if '@c' in vars_present:
replacements['@c'] = cls.getCollection(owner_obj)
if wm.renaming_object_types == 'NODE_GROUPS':
if '@t' in vars_present:
replacements['@t'] = cls.getType(entity)
# IMAGES #
if wm.renaming_object_types == 'IMAGE':
inputText = re.sub(r'@r', 'RESOLUTION', inputText)
inputText = re.sub(r'@i', 'FILETYPE', inputText)
if '@r' in vars_present:
replacements['@r'] = 'RESOLUTION'
if '@i' in vars_present:
replacements['@i'] = 'FILETYPE'
return inputText
return _VARIABLE_RE.sub(lambda m: replacements.get(m.group(), m.group()), inputText)
@staticmethod
def getRandomString():
@@ -125,26 +218,24 @@ class VariableReplacer:
@classmethod
def getfileName(cls, context):
scn = context.scene
if bpy.data.is_saved:
filename = bpy.path.display_name(context.blend_data.filepath)
else:
filename = "UNSAVED"
# scn.renaming_messages.add_message(oldName, entity.name)
context.scene.renaming_error_messages.add_message(
"@f variable: file is unsaved, replaced with 'UNSAVED'", isError=False
)
return filename
@classmethod
def getDateName(cls):
t = time.localtime()
t = time.mktime(t)
return time.strftime("%d%b%Y", time.gmtime(t))
date_format = cls.addon_prefs.date_format if cls.addon_prefs else "%d%b%Y"
return time.strftime(date_format, time.localtime())
@classmethod
def getTimeName(cls):
t = time.localtime()
t = time.mktime(t)
return time.strftime("%H:%M", time.gmtime(t))
time_format = cls.addon_prefs.time_format if cls.addon_prefs else "%H%M"
return time.strftime(time_format, time.localtime())
@classmethod
def getActive(cls, context):
@@ -161,29 +252,60 @@ class VariableReplacer:
@classmethod
def getType(cls, entity):
return str(entity.type)
if entity is None:
return "NO_TYPE"
try:
return str(entity.type)
except AttributeError:
return "NO_TYPE"
@classmethod
def getParent(cls, entity):
if entity.parent is not None:
return str(entity.parent.name)
else:
return entity.name
if entity is None:
return "NO_PARENT"
try:
if entity.parent is not None:
return str(entity.parent.name)
else:
return entity.name
except AttributeError:
return "NO_PARENT"
@classmethod
def getData(cls, entity):
if entity.data is not None:
return str(entity.data.name)
else:
return entity.name
if entity is None:
return "NO_DATA"
try:
if entity.data is not None:
return str(entity.data.name)
else:
return entity.name
except AttributeError:
return "NO_DATA"
@classmethod
def getCollection(cls, entity):
"""O(1) lookup using cache built by prepare()."""
return cls._collection_cache.get(entity.name, "")
collectionew_names = ""
for collection in bpy.data.collections:
collection_objects = collection.objects
if entity.name in collection.objects and entity in collection_objects[:]:
collectionew_names += collection.name
@classmethod
def getOwnerObjectName(cls, entity):
"""Find the owner object name using caches built by prepare()."""
id_data = getattr(entity, 'id_data', None)
if id_data is None:
return ""
return collectionew_names
# Modifier, vertex group, particle system, pose bone — id_data is the Object directly
if id_data.bl_rna.identifier == 'Object':
return id_data.name
# Shape key — id_data is a Key datablock
if id_data.bl_rna.identifier == 'Key':
return cls._shape_key_to_obj.get(id(id_data), "")
# Material — search by material name
if id_data.bl_rna.identifier == 'Material':
return cls._material_to_obj.get(id_data.name, "")
# UV layer, bone — id_data is a Mesh or Armature datablock
return cls._mesh_arm_to_obj.get(id(id_data), "")
@@ -58,7 +58,7 @@
"id": "atomic_data_manager",
"name": "Atomic Data Manager",
"tagline": "Smart cleanup and inspection of Blender data-blocks",
"version": "2.5.0",
"version": "2.6.2",
"type": "add-on",
"maintainer": "RaincloudTheDragon",
"license": [
@@ -70,9 +70,9 @@
"management",
"cleanup"
],
"archive_url": "https://github.com/RaincloudTheDragon/atomic-data-manager/releases/download/v2.5.0/Atomic_Data_Manager.v2.5.0.zip",
"archive_size": 114674,
"archive_hash": "sha256:4b4834ed3910a428d4cb01f1891247ad80089b6c5324fc27c6862b09e81ff1c1"
"archive_url": "https://github.com/RaincloudTheDragon/atomic-data-manager/releases/download/v2.6.2/Atomic_Data_Manager.v2.6.2.zip",
"archive_size": 121191,
"archive_hash": "sha256:1f4af882cdf73d3bb0b8cf1badc094b179bf9e982486ee516c45a6a2d478c05d"
},
{
"schema_version": "1.0.0",
@@ -0,0 +1,100 @@
{
"version": "v1",
"blocklist": [],
"data": [
{
"schema_version": "1.0.0",
"id": "basedplayblast",
"name": "BasedPlayblast",
"tagline": "Easily create playblasts from Blender and Flamenco",
"version": "2.6.3",
"type": "add-on",
"maintainer": "RaincloudTheDragon <raincloudthedragon@gmail.com>",
"license": [
"GPL-3.0-or-later"
],
"blender_version_min": "4.2.0",
"website": "https://github.com/RaincloudTheDragon/BasedPlayblast",
"permissions": {
"files": "Import/export files and data"
},
"tags": [
"Animation",
"Render",
"Workflow",
"Video"
],
"archive_url": "https://github.com/RaincloudTheDragon/BasedPlayblast/releases/download/v2.6.3/BasedPlayblast.v2.6.3.zip",
"archive_size": 49732,
"archive_hash": "sha256:078b406105ce6f4802e75233569841e2f73d082e09cd1d954696681ebf72b627"
},
{
"schema_version": "1.0.0",
"id": "rainclouds_bulk_scene_tools",
"name": "Raincloud's Bulk Scene Tools",
"tagline": "Bulk utilities for optimizing scene data",
"version": "0.17.0",
"type": "add-on",
"maintainer": "RaincloudTheDragon <raincloudthedragon@gmail.com>",
"license": [
"GPL-3.0-or-later"
],
"blender_version_min": "4.2.0",
"website": "https://github.com/RaincloudTheDragon/Rainys-Bulk-Scene-Tools",
"permissions": {
"files": "Read and write external resources referenced by scenes"
},
"tags": [
"Scene",
"Workflow",
"Materials"
],
"archive_url": "https://github.com/RaincloudTheDragon/Rainys-Bulk-Scene-Tools/releases/download/v0.17.0/Rainys_Bulk_Scene_Tools.v0.17.0.zip",
"archive_size": 80981,
"archive_hash": "sha256:419433069465b45ea903bd7bb46d89aa28b9c96c541d587d5f3be651a762811f"
},
{
"schema_version": "1.0.0",
"id": "atomic_data_manager",
"name": "Atomic Data Manager",
"tagline": "Smart cleanup and inspection of Blender data-blocks",
"version": "2.6.0",
"type": "add-on",
"maintainer": "RaincloudTheDragon",
"license": [
"GPL-3.0-or-later"
],
"blender_version_min": "4.2.0",
"tags": [
"utility",
"management",
"cleanup"
],
"archive_url": "https://github.com/RaincloudTheDragon/atomic-data-manager/releases/download/v2.6.0/Atomic_Data_Manager.v2.6.0.zip",
"archive_size": 120303,
"archive_hash": "sha256:cf3acc15028c1eb75b99529d10275cafa5454af970d78a2406de48c496df36ac"
},
{
"schema_version": "1.0.0",
"id": "sheepit_project_submitter",
"name": "SheepIt Project Submitter",
"tagline": "Submit projects to SheepIt render farm",
"version": "0.0.8",
"type": "add-on",
"maintainer": "RaincloudTheDragon",
"license": [
"GPL-3.0-or-later"
],
"blender_version_min": "3.0.0",
"tags": [
"render",
"farm",
"submission",
"utility"
],
"archive_url": "https://github.com/RaincloudTheDragon/sheepit_project_submitter/releases/download/v0.0.8/SheepIt_Project_Submitter.v0.0.8.zip",
"archive_size": 47667,
"archive_hash": "sha256:93cd8f18456079130c48c66cfd40235f7fe6414f929f59f90670e7a864821110"
}
]
}
@@ -0,0 +1,100 @@
{
"version": "v1",
"blocklist": [],
"data": [
{
"schema_version": "1.0.0",
"id": "basedplayblast",
"name": "BasedPlayblast",
"tagline": "Easily create playblasts from Blender and Flamenco",
"version": "2.6.3",
"type": "add-on",
"maintainer": "RaincloudTheDragon <raincloudthedragon@gmail.com>",
"license": [
"GPL-3.0-or-later"
],
"blender_version_min": "4.2.0",
"website": "https://github.com/RaincloudTheDragon/BasedPlayblast",
"permissions": {
"files": "Import/export files and data"
},
"tags": [
"Animation",
"Render",
"Workflow",
"Video"
],
"archive_url": "https://github.com/RaincloudTheDragon/BasedPlayblast/releases/download/v2.6.3/BasedPlayblast.v2.6.3.zip",
"archive_size": 49732,
"archive_hash": "sha256:078b406105ce6f4802e75233569841e2f73d082e09cd1d954696681ebf72b627"
},
{
"schema_version": "1.0.0",
"id": "rainclouds_bulk_scene_tools",
"name": "Raincloud's Bulk Scene Tools",
"tagline": "Bulk utilities for optimizing scene data",
"version": "0.17.0",
"type": "add-on",
"maintainer": "RaincloudTheDragon <raincloudthedragon@gmail.com>",
"license": [
"GPL-3.0-or-later"
],
"blender_version_min": "4.2.0",
"website": "https://github.com/RaincloudTheDragon/Rainys-Bulk-Scene-Tools",
"permissions": {
"files": "Read and write external resources referenced by scenes"
},
"tags": [
"Scene",
"Workflow",
"Materials"
],
"archive_url": "https://github.com/RaincloudTheDragon/Rainys-Bulk-Scene-Tools/releases/download/v0.17.0/Rainys_Bulk_Scene_Tools.v0.17.0.zip",
"archive_size": 80981,
"archive_hash": "sha256:419433069465b45ea903bd7bb46d89aa28b9c96c541d587d5f3be651a762811f"
},
{
"schema_version": "1.0.0",
"id": "atomic_data_manager",
"name": "Atomic Data Manager",
"tagline": "Smart cleanup and inspection of Blender data-blocks",
"version": "2.6.1",
"type": "add-on",
"maintainer": "RaincloudTheDragon",
"license": [
"GPL-3.0-or-later"
],
"blender_version_min": "4.2.0",
"tags": [
"utility",
"management",
"cleanup"
],
"archive_url": "https://github.com/RaincloudTheDragon/atomic-data-manager/releases/download/v2.6.1/Atomic_Data_Manager.v2.6.1.zip",
"archive_size": 120812,
"archive_hash": "sha256:fe4cb2f142e40d5c89b0163b165007daa9ad1d5cbf954224886a4bb312fc7017"
},
{
"schema_version": "1.0.0",
"id": "sheepit_project_submitter",
"name": "SheepIt Project Submitter",
"tagline": "Submit projects to SheepIt render farm",
"version": "0.0.8",
"type": "add-on",
"maintainer": "RaincloudTheDragon",
"license": [
"GPL-3.0-or-later"
],
"blender_version_min": "3.0.0",
"tags": [
"render",
"farm",
"submission",
"utility"
],
"archive_url": "https://github.com/RaincloudTheDragon/sheepit_project_submitter/releases/download/v0.0.8/SheepIt_Project_Submitter.v0.0.8.zip",
"archive_size": 47667,
"archive_hash": "sha256:93cd8f18456079130c48c66cfd40235f7fe6414f929f59f90670e7a864821110"
}
]
}
@@ -0,0 +1,100 @@
{
"version": "v1",
"blocklist": [],
"data": [
{
"schema_version": "1.0.0",
"id": "basedplayblast",
"name": "BasedPlayblast",
"tagline": "Easily create playblasts from Blender and Flamenco",
"version": "2.6.3",
"type": "add-on",
"maintainer": "RaincloudTheDragon <raincloudthedragon@gmail.com>",
"license": [
"GPL-3.0-or-later"
],
"blender_version_min": "4.2.0",
"website": "https://github.com/RaincloudTheDragon/BasedPlayblast",
"permissions": {
"files": "Import/export files and data"
},
"tags": [
"Animation",
"Render",
"Workflow",
"Video"
],
"archive_url": "https://github.com/RaincloudTheDragon/BasedPlayblast/releases/download/v2.6.3/BasedPlayblast.v2.6.3.zip",
"archive_size": 49732,
"archive_hash": "sha256:078b406105ce6f4802e75233569841e2f73d082e09cd1d954696681ebf72b627"
},
{
"schema_version": "1.0.0",
"id": "rainclouds_bulk_scene_tools",
"name": "Raincloud's Bulk Scene Tools",
"tagline": "Bulk utilities for optimizing scene data",
"version": "0.17.0",
"type": "add-on",
"maintainer": "RaincloudTheDragon <raincloudthedragon@gmail.com>",
"license": [
"GPL-3.0-or-later"
],
"blender_version_min": "4.2.0",
"website": "https://github.com/RaincloudTheDragon/Rainys-Bulk-Scene-Tools",
"permissions": {
"files": "Read and write external resources referenced by scenes"
},
"tags": [
"Scene",
"Workflow",
"Materials"
],
"archive_url": "https://github.com/RaincloudTheDragon/Rainys-Bulk-Scene-Tools/releases/download/v0.17.0/Rainys_Bulk_Scene_Tools.v0.17.0.zip",
"archive_size": 80981,
"archive_hash": "sha256:419433069465b45ea903bd7bb46d89aa28b9c96c541d587d5f3be651a762811f"
},
{
"schema_version": "1.0.0",
"id": "atomic_data_manager",
"name": "Atomic Data Manager",
"tagline": "Smart cleanup and inspection of Blender data-blocks",
"version": "2.6.0",
"type": "add-on",
"maintainer": "RaincloudTheDragon",
"license": [
"GPL-3.0-or-later"
],
"blender_version_min": "4.2.0",
"tags": [
"utility",
"management",
"cleanup"
],
"archive_url": "https://github.com/RaincloudTheDragon/atomic-data-manager/releases/download/v2.6.0/Atomic_Data_Manager.v2.6.0.zip",
"archive_size": 120303,
"archive_hash": "sha256:cf3acc15028c1eb75b99529d10275cafa5454af970d78a2406de48c496df36ac"
},
{
"schema_version": "1.0.0",
"id": "sheepit_project_submitter",
"name": "SheepIt Project Submitter",
"tagline": "Submit projects to SheepIt render farm",
"version": "0.0.8",
"type": "add-on",
"maintainer": "RaincloudTheDragon",
"license": [
"GPL-3.0-or-later"
],
"blender_version_min": "3.0.0",
"tags": [
"render",
"farm",
"submission",
"utility"
],
"archive_url": "https://github.com/RaincloudTheDragon/sheepit_project_submitter/releases/download/v0.0.8/SheepIt_Project_Submitter.v0.0.8.zip",
"archive_size": 47667,
"archive_hash": "sha256:93cd8f18456079130c48c66cfd40235f7fe6414f929f59f90670e7a864821110"
}
]
}
@@ -1,18 +1,44 @@
## [v2.6.2] - 2026-04-06
### Fixes
- **Clean / unused materials**: if **`Material.users` > 0** (and not only a fake user) but **`material_all()`** finds no references, the material is **no longer** treated as unused—avoids false positives when a **local** material shares a name with a **linked** one or Blender holds other unmodeled refs. The same rule applies to **RNA / Smart Select** scans (the unified scanner uses `analyze_unused_from_graph`, not only `materials_deep`).
## [v2.6.1] - 2026-03-27
### Fixes
- **Stats → Storage**: action byte estimates include **layered/slotted actions** (Blender 4.4+): keyframes in **channel bags** on strips are counted, so values are no longer stuck at the **256 B** minimum when `Action.fcurves` is empty.
## [v2.6.0] - 2026-03-27
### Features
- **Stats for Nerds — Storage**: local blend storage estimates (bytes per datablock type, largest IDs with type + library-override icons and names), including **collections**; packed totals on Overview. Implementation lives in `utils/compat` for reliable `bl_ext` loading.
- **Smart Select / Clean (issue #15)**: materials and images used in Geometry Nodes are detected on **library override** objects (not only pure locals); pure linked objects are still skipped via `is_object_linked_without_override`.
- **Clean**: before removing rig objects, **unparent** scene objects that would be orphaned (keep transform) and **remove Armature modifiers** targeting deleted objects, with info reports.
## [v2.5.0] - 2026-01-28
### Features
- Missing file tools: add “Relink All” and improve replacement workflow.
- Missing file tools:
- Finalized Relinker and Linked Library Replacer features.
- Add “Relink All” button to Relinker
### Fixes
- Missing file UI: fix text field paste + layout/truncation issues; center the detect-missing popup; refine replacement path handling (better dir vs file behavior).
- RNA analysis: expand datablock coverage and refine dependency tracking to reduce false “unused” results.
### Internal
- Maintenance: remove deprecated recovery option; improve ignore rules for hidden dot-directories.
## [v2.4.1] - 2026-01-14
### Fixes
- Fixed RNA analysis crashes when opening new blend files by rebuilding data-block type references dynamically
- Fixed indentation errors that prevented RNA dump from processing most data-blocks
- Fixed compositing nodetree detection by adding scenes as root nodes in dependency graph
@@ -22,6 +48,7 @@
## [v2.4.0] - 2026-01-13
### Features
- **Major Architecture Change: RNA-Based Analysis System**
- Replaced multi-process worker system with faster, more robust RNA-based dependency analysis
- All data types now use unified RNA introspection for dependency tracking
@@ -32,6 +59,7 @@
- Items now display in 4-column grid layout to reduce vertical scrolling
### Fixes
- Fixed node groups used by objects via Geometry Nodes modifiers not being detected as used
- Fixed RigidBodyWorld and other scene-linked data-blocks incorrectly flagged as cleanable
- Fixed area lights and other object data-blocks in scene collections not being marked as used
@@ -39,22 +67,26 @@
- Fixed RNA extraction handling for objects' modifier node groups
### Performance
- Significantly faster scanning across all categories using RNA analysis
- Single-pass dependency graph building shared across all category scans
## [v2.3.1] - 2026-01-13
### Fixes
- Integrate proper UDIM detection
## [v2.3.0] - 2026-01-06
### Features
- Added "Enable Debug Prints" preference to control debug console output
- Debug messages now only print when this preference is enabled (default: off)
- All debug print statements use centralized `config.debug_print()` helper
### Fixes
- Fixed preferences not displaying in Blender 5.0 extensions
- Preferences now correctly match the full module path (`bl_ext.vscode_development.atomic_data_manager`)
- Added safe property setter to handle read-only context errors during file loading
@@ -69,6 +101,7 @@
## [v2.2.0] - 2026-01-05
### Features
- Add loading bars; non-blocking timer-based UI (#10)
- Operations no longer freeze the UI during scanning
- Real-time progress updates with cancel support at any time
@@ -80,6 +113,7 @@
- Added manual cache clear operator for testing and debugging
### Performance
- Optimized deep scan functions with caching and fast-path checks
- Image scanning now uses cached results to avoid redundant scene scans
- Early exit for clearly unused images using Blender's built-in user count
@@ -88,24 +122,28 @@
- Worlds processed one at a time incrementally
### Fixes
- Fixed images used only by unused objects being incorrectly flagged as unused (#5)
- Fixed material detection in brushes and node groups (#6, #7)
- Fixed Clean operator not showing dialog when invoked programmatically (#8)
- Improved material detection in inspection tools (brushes, node groups)
### Internal
- Refactored scanning architecture for maintainability
- Added comprehensive debug output for troubleshooting
## [v2.1.0] - 2025-12-18
### Features
- Added support for detecting unused objects and armatures (#1)
- Objects not present in any scene collections are now detected as unused
- Armatures not used by any objects in scenes (including direct use, modifiers, and constraints like "Child Of") are detected as unused
- Smart Select and Clean operations now support objects and armatures
### Fixes
- Fixed material detection in Geometry Nodes Set Material nodes
- Materials used in Geometry Nodes' "Set Material" nodes are now correctly detected as used
- Fixed legacy issue where materials in node groups (e.g., "outline-highlight" in "box-highlight" node group) were incorrectly flagged as unused
@@ -117,38 +155,45 @@
- Note: Further performance improvements are limited by Blender's Python API being single-threaded and requiring sequential access to `bpy.data` collections, making true parallelization impossible without risking data corruption
### Internal
- Removed incorrect "Remington Creative" copyright notices from newly created files
- Updated repository configuration in manifest
## [v2.0.3] - 2025-12-17
### Fixes
- Fixed missing import error in missing file detection
## [v2.0.2] - 2025-12-17
### Fixes
- Atomic now completely ignores all library-linked and override datablocks across all operations, as originally intended.
## [v2.0.1] - 2025-12-16
### Fixes
- Blender 5.0 compatibility: Fixed `AttributeError` when detecting missing library files (Library objects use `packed_file` singular, Image objects use `packed_files` plural in 5.0)
- Fixed unregistration errors in Blender 4.5 by using safe unregister functions throughout the codebase
## [v2.0.0] - Raincloud's first re-release
### Feature
- Multi-version Blender support (4.2 LTS, 4.5 LTS, and 5.0)
- Version detection utilities in `utils/version.py`
- API compatibility layer in `utils/compat.py` for handling version differences
- Version detection utilities in `utils/version.py`
- API compatibility layer in `utils/compat.py` for handling version differences
### Fixes
- Blender 5.0 compatibility: Fixed `AttributeError` when accessing scene compositor node tree (changed from `scene.node_tree` to `scene.compositing_node_tree`)
- Collections assigned to `rigidbody_world.collection` are now correctly detected as used
### Internal
- GitHub Actions release workflow
- Integrated `rainys_repo_bootstrap` into `__init__.py` so the Rainy's Extensions repository is registered on add-on enable and the bootstrap guard resets on disable.
- Removed "Support Remington Creative" popup and all related functionality
- Removed Support popup preferences
- Removed Support popup preferences
@@ -144,6 +144,13 @@ class ATOMIC_PG_main(bpy.types.PropertyGroup):
'FILE', # icon
0 # number / id
),
(
'STORAGE',
'Storage',
'Local datablocks vs linked libraries',
'DISK_DRIVE',
11
),
(
'COLLECTIONS',
'Collections',
@@ -239,7 +246,14 @@ class ATOMIC_PG_main(bpy.types.PropertyGroup):
def _on_undo_pre(scene):
"""Handler called before undo - invalidate cache."""
from .ops import main_ops
from .utils.compat import invalidate_cache
main_ops._invalidate_cache()
invalidate_cache()
def _load_post_invalidate_storage(_dummy):
from .utils.compat import invalidate_cache
invalidate_cache()
def register():
@@ -252,6 +266,7 @@ def register():
# Register undo handler to invalidate cache
bpy.app.handlers.undo_pre.append(_on_undo_pre)
bpy.app.handlers.load_post.append(_load_post_invalidate_storage)
# bootstrap Rainy's Extensions repository
rainys_repo_bootstrap.register()
@@ -264,6 +279,8 @@ def unregister():
# Remove undo handler
if _on_undo_pre in bpy.app.handlers.undo_pre:
bpy.app.handlers.undo_pre.remove(_on_undo_pre)
if _load_post_invalidate_storage in bpy.app.handlers.load_post:
bpy.app.handlers.load_post.remove(_load_post_invalidate_storage)
# atomic package unregistration
ui.unregister()
@@ -2,7 +2,7 @@ schema_version = "1.0.0"
id = "atomic_data_manager"
name = "Atomic Data Manager"
version = "2.5.0"
version = "2.6.2"
type = "add-on"
author = "RaincloudTheDragon"
maintainer = "RaincloudTheDragon"
@@ -250,7 +250,9 @@ class ATOMIC_OT_clean_all(bpy.types.Operator):
clean.lights()
clean.materials()
clean.node_groups()
clean.objects()
for msg in clean.detach_scene_objects_from_removal_targets(set(self.unused_objects)):
self.report({'INFO'}, msg)
clean.objects(cached_list=self.unused_objects)
clean.particles()
clean.textures()
clean.armatures()
@@ -739,6 +739,14 @@ class ATOMIC_OT_clean(bpy.types.Operator):
bpy.ops.atomic.deselect_all()
return {'FINISHED'}
# Keep in-scene objects that are parented to / deformed by objects we delete
if atom.objects and self.unused_objects:
from .utils import clean as clean_utils
for msg in clean_utils.detach_scene_objects_from_removal_targets(
set(self.unused_objects)
):
self.report({'INFO'}, msg)
# Delete all items synchronously
deleted_count = 0
for category, unused_list in categories_to_clean:
@@ -24,6 +24,52 @@ This file contains functions for cleaning out specific data categories.
import bpy
from ...stats import unused
from ...utils import compat
def detach_scene_objects_from_removal_targets(object_names_to_remove):
"""
Unparent (world transform preserved) and remove Armature modifiers pointing at
objects that are about to be deleted, for objects that are NOT in the removal
set. Prevents in-scene props (e.g. parented to a rig) from being lost when the
rig object is removed.
Returns:
list[str]: Messages suitable for operator.report (one string per change).
"""
if not object_names_to_remove:
return []
remove = set(object_names_to_remove)
reports = []
for obj in bpy.data.objects:
if obj.name in remove:
continue
if compat.is_library_or_override(obj):
continue
parts = []
if obj.parent is not None and obj.parent.name in remove:
mw = obj.matrix_world.copy()
obj.parent = None
obj.matrix_world = mw
parts.append("unparented (kept world transform)")
for mod in list(obj.modifiers):
if mod.type != 'ARMATURE':
continue
target = getattr(mod, "object", None)
if target is not None and target.name in remove:
obj.modifiers.remove(mod)
parts.append("removed Armature modifier targeting deleted object")
if parts:
reports.append(
"Atomic: “%s”: %s" % (obj.name, "; ".join(parts))
)
return reports
def collections(cached_list=None):
@@ -137,7 +183,7 @@ def objects(cached_list=None):
object_keys = cached_list
else:
object_keys = unused.objects_deep()
for object_key in object_keys:
if object_key in bpy.data.objects:
bpy.data.objects.remove(bpy.data.objects[object_key])
@@ -0,0 +1,6 @@
"""
Statistics and analysis helpers for Atomic Data Manager.
Blend-file storage estimates live in ``utils.compat`` (merged there so
``bl_ext`` dev deploy cannot miss a separate ``utils/blend_storage`` file).
"""
@@ -915,6 +915,8 @@ def analyze_unused_from_graph(graph, category, include_fake_users=None):
Returns:
List of unused item names for the specified category
"""
from . import users
if include_fake_users is None:
include_fake_users = config.include_fake_users
@@ -1184,6 +1186,20 @@ def analyze_unused_from_graph(graph, category, include_fake_users=None):
item_name = datablock.name
if (category, item_name) not in used:
if item_name not in category_do_not_flag:
# Objects that appear in a scene collection must stay (traceable to a scene), even
# if the RNA graph missed them (e.g. mesh parented to an out-of-scene armature).
if category == 'objects':
try:
if users.object_all(item_name):
continue
except (AttributeError, KeyError, RuntimeError, ReferenceError):
pass
if category == 'materials':
try:
if datablock.users > 0 and not datablock.use_fake_user:
continue
except (AttributeError, RuntimeError, ReferenceError):
pass
unused.append(item_name)
except (AttributeError, RuntimeError, ReferenceError):
# Datablock may be invalid
@@ -223,6 +223,10 @@ def materials_deep():
# check if material has a fake user or if ignore fake users
# is enabled
if not material.use_fake_user or config.include_fake_users:
# If Blender still counts users but we found none, don't flag (name collisions
# with linked IDs, drivers, or refs we don't traverse). Fake-user purge unchanged.
if material.users > 0 and not material.use_fake_user:
continue
unused.append(material.name)
else:
# Second check: material is used, but check if it's ONLY used by unused objects
@@ -127,6 +127,8 @@ def _has_any_unused_materials():
# First check: standard unused detection
if not users.material_all(material.name):
if not material.use_fake_user or config.include_fake_users:
if material.users > 0 and not material.use_fake_user:
continue
return True
else:
# Second check: material is used, but check if it's ONLY used by unused objects
@@ -416,8 +416,8 @@ def image_geometry_nodes(image_key):
from ..utils import compat
for obj in bpy.data.objects:
# Skip library-linked and override objects
if compat.is_library_or_override(obj):
# Skip purely linked objects; library overrides can have local GN modifiers (#15)
if compat.is_object_linked_without_override(obj):
continue
# Check if object is in any scene collection (reuse object_all logic)
@@ -583,8 +583,8 @@ def material_geometry_nodes(material_key):
from ..utils import compat
for obj in bpy.data.objects:
# Skip library-linked and override objects
if compat.is_library_or_override(obj):
# Skip purely linked objects; library overrides can have local GN modifiers (#15)
if compat.is_object_linked_without_override(obj):
continue
# Check if object is in any scene collection (reuse object_all logic)
@@ -31,6 +31,13 @@ from bpy.utils import register_class
from ..utils import compat
from ..stats import count
from ..stats import misc
from ..utils.compat import (
format_bytes,
format_embedded_total,
get_report,
storage_override_icon,
storage_type_icon,
)
from .utils import ui_layouts
@@ -46,6 +53,8 @@ class ATOMIC_PT_stats_panel(bpy.types.Panel):
def draw(self, context):
layout = self.layout
atom = bpy.context.scene.atomic
# Keep blend storage scan in sync whenever the stats panel is shown
storage_report = get_report()
# categories selector / header
row = layout.row()
@@ -66,6 +75,12 @@ class ATOMIC_PT_stats_panel(bpy.types.Panel):
row = box.row()
row.label(text="Blend File Size: " + misc.blend_size())
row = box.row()
row.label(
text="Packed data (local IDs): "
+ format_embedded_total(storage_report["total_embedded_packed"])
)
# cateogry statistics
split = box.split()
@@ -115,6 +130,52 @@ class ATOMIC_PT_stats_panel(bpy.types.Panel):
# world count
col.label(text=str(count.worlds()))
# local blend storage (excludes linked IDs)
elif atom.stats_mode == 'STORAGE':
row = box.row()
row.label(text="Local blend storage", icon='DISK_DRIVE')
rep = storage_report
col = box.column(align=True)
col.label(
text="IDs linked from another .blend (library) are excluded.",
icon='INFO',
)
col.label(text="Packed = exact bytes stored inside this .blend.")
col.label(text="Other sizes are estimates; disk file may compress.")
col.label(text="Second icon = library override (when applicable).")
row = col.row()
row.label(
text="Packed embedded total: "
+ format_embedded_total(rep["total_embedded_packed"])
)
col.separator()
col.label(text="By datablock type (estimated total)")
for t, nbytes in rep["by_type"][:12]:
col.label(text=" %s: %s" % (t, format_bytes(nbytes)))
col.separator()
col.label(text="Largest local datablocks (top 40)")
for r in rep["rows"][:40]:
split = col.split(factor=0.68)
left = split.row(align=True)
left.label(icon=storage_type_icon(r["type"]), text="")
left.label(
icon=storage_override_icon(r.get("is_lib_override", False)),
text="",
)
left.label(text=r["name"])
split.label(text=format_bytes(r["size_bytes"]))
if len(rep["rows"]) > 40:
col.label(text=" ...")
# collection statistics
elif atom.stats_mode == 'COLLECTIONS':
@@ -4,6 +4,7 @@ between Blender 4.2 LTS, 4.5 LTS, and 5.0.
"""
import os
import bpy
from bpy.utils import register_class, unregister_class
from . import version
@@ -177,3 +178,624 @@ def is_library_or_override(datablock):
return True
return False
def is_object_linked_without_override(obj):
"""
True if obj comes from another .blend file but is not a library override.
Override objects live in the current file and may have local modifiers
(e.g. Geometry Nodes) that reference local materials or images; those
must be scanned. Purely linked objects have no such local stack here.
"""
lib = getattr(obj, "library", None)
ovl = getattr(obj, "override_library", None)
return lib is not None and ovl is None
# --- Blend-file storage (lives here so bl_ext dev sync cannot miss a separate module) ---
_cache_report = None
_cache_light = None
_cache_vert_sum = None
def invalidate_cache():
global _cache_report, _cache_light, _cache_vert_sum
_cache_report = None
_cache_light = None
_cache_vert_sum = None
def _mesh_vertex_sum_sample():
"""Cheap geometry signature so cache invalidates on edit without save."""
s = 0
for m in bpy.data.meshes:
try:
s += len(m.vertices)
except (AttributeError, RuntimeError, ReferenceError):
pass
if s > 50_000_000:
break
return s
def _light_fingerprint():
fp = bpy.data.filepath
try:
mt = os.stat(fp).st_mtime if fp else 0
except OSError:
mt = 0
return (
fp,
mt,
len(bpy.data.meshes),
len(bpy.data.images),
len(bpy.data.materials),
len(bpy.data.node_groups),
len(bpy.data.objects),
len(bpy.data.armatures),
len(bpy.data.collections),
)
def _skip_linked(id_block):
return getattr(id_block, "library", None) is not None
# Set at start of build_report(); object.data IDs whose users are overridden objects
_storage_override_data_ids = frozenset()
def _object_data_override_ids():
"""IDs used as ob.data for at least one object with a library override."""
s = set()
for ob in bpy.data.objects:
if getattr(ob, "override_library", None) and ob.data is not None:
s.add(ob.data)
return frozenset(s)
def is_library_override_storage(id_block):
"""
True if this ID is a library override, including object-data reached only
via an overridden Object (obdata may lack override_library).
"""
if id_block is None:
return False
if getattr(id_block, "override_library", None):
return True
return id_block in _storage_override_data_ids
def _override_weight_factor(id_block):
return 0.08 if is_library_override_storage(id_block) else 1.0
def _mesh_size_bytes(m):
"""Rough serialized footprint estimate (verts/loops/faces), scaled for overrides."""
if _skip_linked(m):
return None
try:
v, l, p = len(m.vertices), len(m.loops), len(m.polygons)
except (AttributeError, RuntimeError, ReferenceError):
return None
ow = _override_weight_factor(m)
base = (v * 28 + l * 6 + p * 10 + 512) * ow
return max(64, int(base))
def _image_entry(img):
if _skip_linked(img):
return None
embedded = 0
pf = getattr(img, "packed_file", None)
if pf:
try:
data = pf.data
if data:
embedded = len(data)
except (AttributeError, TypeError, RuntimeError):
pass
if embedded == 0:
pfs = getattr(img, "packed_files", None)
if pfs:
for p in pfs:
try:
if hasattr(p, "data") and p.data:
embedded += len(p.data)
except (AttributeError, RuntimeError):
pass
ow = _override_weight_factor(img)
if embedded > 0:
size_b = max(1, int(embedded * ow))
return ("images", img.name, embedded, size_b, "packed")
size_b = max(1, int(256 * ow))
return ("images", img.name, 0, size_b, "external")
def _armature_size_bytes(a):
if _skip_linked(a):
return None
try:
n = len(a.bones)
except (AttributeError, RuntimeError, ReferenceError):
n = 0
ow = _override_weight_factor(a)
return int((2048 + n * 320) * ow)
def _curve_size_bytes(c):
if _skip_linked(c):
return None
try:
n = sum(len(s.points) for s in c.splines)
except (AttributeError, RuntimeError, ReferenceError):
n = 0
ow = _override_weight_factor(c)
return int((1024 + n * 24) * ow)
def _node_tree_size_bytes(nt):
if not nt or _skip_linked(nt):
return None
try:
n = len(nt.nodes) + len(nt.links)
except (AttributeError, RuntimeError, ReferenceError):
n = 0
ow = _override_weight_factor(nt)
return int((2048 + n * 96) * ow)
def _action_keyframe_counts(act):
"""
Keyframe point count and F-curve count for storage estimate.
Blender 4.4+ layered actions store curves in ActionChannelBag under
strips; act.fcurves is often empty, so we must walk layers/slots.
"""
kp, fc = 0, 0
layers = getattr(act, "layers", None)
if layers and len(layers) > 0:
for layer in layers:
strips = getattr(layer, "strips", None)
if not strips:
continue
for strip in strips:
if not (hasattr(strip, "channelbags") or hasattr(strip, "channelbag")):
continue
bags = getattr(strip, "channelbags", None)
if bags and len(bags) > 0:
for bag in bags:
for fcurve in bag.fcurves:
fc += 1
try:
kp += len(fcurve.keyframe_points)
except (TypeError, AttributeError, RuntimeError, ReferenceError):
pass
else:
slots = getattr(act, "slots", None)
if not slots:
continue
for slot in slots:
try:
bag = strip.channelbag(slot, ensure=False)
except (TypeError, AttributeError, RuntimeError, ReferenceError):
bag = None
if bag is None:
continue
for fcurve in bag.fcurves:
fc += 1
try:
kp += len(fcurve.keyframe_points)
except (TypeError, AttributeError, RuntimeError, ReferenceError):
pass
return kp, fc
fcurves = getattr(act, "fcurves", None)
if fcurves:
for fcurve in fcurves:
fc += 1
try:
kp += len(fcurve.keyframe_points)
except (TypeError, AttributeError, RuntimeError, ReferenceError):
pass
return kp, fc
def _action_size_bytes(act):
if _skip_linked(act):
return None
kp, fc = _action_keyframe_counts(act)
ow = _override_weight_factor(act)
return max(64, int((256 + kp * 20 + fc * 80) * ow))
def _object_size_bytes(ob):
if _skip_linked(ob):
return None
ow = _override_weight_factor(ob)
return int(192 * ow)
def _texture_size_bytes(tex):
if _skip_linked(tex):
return None
ow = _override_weight_factor(tex)
return int(512 * ow)
def _volume_size_bytes(vol):
if _skip_linked(vol):
return None
ow = _override_weight_factor(vol)
return int(4096 * ow)
def _pointcloud_size_bytes(pc):
if _skip_linked(pc):
return None
try:
n = len(pc.points)
except (AttributeError, RuntimeError, ReferenceError):
n = 0
ow = _override_weight_factor(pc)
return int((512 + n * 16) * ow)
def _sound_entry(snd):
if _skip_linked(snd):
return None
embedded = 0
pf = getattr(snd, "packed_file", None)
if pf:
try:
if hasattr(pf, "data") and pf.data:
embedded = len(pf.data)
except (AttributeError, TypeError, RuntimeError):
pass
ow = _override_weight_factor(snd)
if embedded > 0:
return ("sounds", snd.name, embedded, max(1, int(embedded * ow)), "packed")
return ("sounds", snd.name, 0, max(1, int(256 * ow)), "external")
def _font_entry(font):
if _skip_linked(font):
return None
embedded = 0
pf = getattr(font, "packed_file", None)
if pf and hasattr(pf, "data") and pf.data:
try:
embedded = len(pf.data)
except (TypeError, RuntimeError):
embedded = 0
ow = _override_weight_factor(font)
if embedded > 0:
return ("fonts", font.name, embedded, max(1, int(embedded * ow)), "packed")
return None
def _collection_size_bytes(coll):
if _skip_linked(coll):
return None
try:
no = len(coll.objects)
nc = len(coll.children)
except (AttributeError, RuntimeError, ReferenceError):
return None
ow = _override_weight_factor(coll)
return max(64, int((512 + no * 96 + nc * 256) * ow))
def _fmt_bytes(n):
if n >= 1048576:
return f"{n / 1048576:.2f} MiB"
if n >= 1024:
return f"{n / 1024:.2f} KiB"
return f"{int(n)} B"
def format_bytes(n):
"""Human-readable size for storage estimates."""
return _fmt_bytes(n)
_STORAGE_TYPE_ICONS = {
"Mesh": "MESH_DATA",
"Image": "IMAGE_DATA",
"Armature": "ARMATURE_DATA",
"Material": "MATERIAL",
"Object": "OBJECT_DATA",
"Curve": "CURVE_DATA",
"NodeTree": "NODETREE",
"Action": "ACTION",
"Texture": "TEXTURE",
"Volume": "VOLUME_DATA",
"PointCloud": "POINTCLOUD_DATA",
"Sound": "SOUND",
"Font": "FONT_DATA",
"Collection": "OUTLINER_COLLECTION",
}
def storage_type_icon(type_name):
"""Blender UI icon for a storage row type label."""
return _STORAGE_TYPE_ICONS.get(type_name, "BLANK1")
def storage_override_icon(is_lib_override):
"""Second column: library override emblem vs empty spacer."""
return "LIBRARY_DATA_OVERRIDE" if is_lib_override else "BLANK1"
def build_report():
"""Build storage report dict. Call through get_report() for caching."""
global _storage_override_data_ids
_storage_override_data_ids = _object_data_override_ids()
rows = []
def _ov(id_block):
return is_library_override_storage(id_block)
for m in bpy.data.meshes:
sz = _mesh_size_bytes(m)
if sz is not None:
io = _ov(m)
rows.append(
{
"type": "Mesh",
"name": m.name,
"embedded": 0,
"size_bytes": sz,
"is_lib_override": io,
"kind": "override" if io else "local",
}
)
for img in bpy.data.images:
e = _image_entry(img)
if e is None:
continue
_typ, name, emb, sz, kind = e
io = _ov(img)
rows.append(
{
"type": "Image",
"name": name,
"embedded": emb,
"size_bytes": sz,
"is_lib_override": io,
"kind": kind,
}
)
for a in bpy.data.armatures:
sz = _armature_size_bytes(a)
if sz is not None:
io = _ov(a)
rows.append(
{
"type": "Armature",
"name": a.name,
"embedded": 0,
"size_bytes": sz,
"is_lib_override": io,
"kind": "override" if io else "local",
}
)
for c in getattr(bpy.data, "curves", []):
sz = _curve_size_bytes(c)
if sz is not None:
io = _ov(c)
rows.append(
{
"type": "Curve",
"name": c.name,
"embedded": 0,
"size_bytes": sz,
"is_lib_override": io,
"kind": "override" if io else "local",
}
)
for ng in bpy.data.node_groups:
sz = _node_tree_size_bytes(ng)
if sz is not None:
io = _ov(ng)
rows.append(
{
"type": "NodeTree",
"name": ng.name,
"embedded": 0,
"size_bytes": sz,
"is_lib_override": io,
"kind": "override" if io else "local",
}
)
for mat in bpy.data.materials:
if _skip_linked(mat):
continue
sz = _node_tree_size_bytes(mat.node_tree) if mat.use_nodes and mat.node_tree else 256
if sz is None:
sz = 256
ow = _override_weight_factor(mat)
sz = int(sz * ow)
io = _ov(mat)
rows.append(
{
"type": "Material",
"name": mat.name,
"embedded": 0,
"size_bytes": sz,
"is_lib_override": io,
"kind": "override" if io else "local",
}
)
if hasattr(bpy.data, "actions"):
for act in bpy.data.actions:
sz = _action_size_bytes(act)
if sz is not None:
io = _ov(act)
rows.append(
{
"type": "Action",
"name": act.name,
"embedded": 0,
"size_bytes": sz,
"is_lib_override": io,
"kind": "override" if io else "local",
}
)
for tex in getattr(bpy.data, "textures", []):
sz = _texture_size_bytes(tex)
if sz is not None:
io = _ov(tex)
rows.append(
{
"type": "Texture",
"name": tex.name,
"embedded": 0,
"size_bytes": sz,
"is_lib_override": io,
"kind": "override" if io else "local",
}
)
for ob in bpy.data.objects:
sz = _object_size_bytes(ob)
if sz is not None:
io = _ov(ob)
rows.append(
{
"type": "Object",
"name": ob.name,
"embedded": 0,
"size_bytes": sz,
"is_lib_override": io,
"kind": "override" if io else "local",
}
)
for vol in getattr(bpy.data, "volumes", []):
sz = _volume_size_bytes(vol)
if sz is not None:
io = _ov(vol)
rows.append(
{
"type": "Volume",
"name": vol.name,
"embedded": 0,
"size_bytes": sz,
"is_lib_override": io,
"kind": "override" if io else "local",
}
)
for pc in getattr(bpy.data, "pointclouds", []):
sz = _pointcloud_size_bytes(pc)
if sz is not None:
io = _ov(pc)
rows.append(
{
"type": "PointCloud",
"name": pc.name,
"embedded": 0,
"size_bytes": sz,
"is_lib_override": io,
"kind": "override" if io else "local",
}
)
for snd in getattr(bpy.data, "sounds", []):
e = _sound_entry(snd)
if e is None:
continue
_typ, name, emb, sz, kind = e
io = _ov(snd)
rows.append(
{
"type": "Sound",
"name": name,
"embedded": emb,
"size_bytes": sz,
"is_lib_override": io,
"kind": kind,
}
)
for font in getattr(bpy.data, "fonts", []):
e = _font_entry(font)
if e is None:
continue
_typ, name, emb, sz, kind = e
io = _ov(font)
rows.append(
{
"type": "Font",
"name": name,
"embedded": emb,
"size_bytes": sz,
"is_lib_override": io,
"kind": kind,
}
)
for coll in bpy.data.collections:
sz = _collection_size_bytes(coll)
if sz is not None:
io = _ov(coll)
rows.append(
{
"type": "Collection",
"name": coll.name,
"embedded": 0,
"size_bytes": sz,
"is_lib_override": io,
"kind": "override" if io else "local",
}
)
rows.sort(key=lambda r: r["size_bytes"], reverse=True)
by_type = {}
total_estimated = 0
total_emb = 0
for r in rows:
t = r["type"]
by_type[t] = by_type.get(t, 0) + r["size_bytes"]
total_estimated += r["size_bytes"]
total_emb += r.get("embedded", 0)
type_order = sorted(by_type.keys(), key=lambda t: -by_type[t])
by_type_sizes = [(t, by_type[t]) for t in type_order]
return {
"rows": rows,
"by_type": by_type_sizes,
"total_estimated_bytes": total_estimated,
"total_embedded_packed": total_emb,
}
def get_report():
global _cache_report, _cache_light, _cache_vert_sum
light = _light_fingerprint()
vs = _mesh_vertex_sum_sample()
if (
_cache_report is not None
and _cache_light == light
and _cache_vert_sum == vs
):
return _cache_report
_cache_report = build_report()
_cache_light = light
_cache_vert_sum = vs
return _cache_report
def format_embedded_total(n):
"""Human-readable total packed bytes embedded in the .blend."""
return _fmt_bytes(n)
+221 -32
View File
@@ -19,8 +19,8 @@
bl_info = {
"name": "BlenderKit Online Asset Library",
"author": "Vilem Duha, Petr Dlouhy, A. Gajdosik",
"version": (3, 18, 1, 251219), # X.Y.Z.yymmdd
"author": "Vilem Duha, Petr Dlouhy, A. Gajdosik, Michal Hons",
"version": (3, 19, 1, 260402), # X.Y.Z.yymmdd
"blender": (3, 0, 0),
"location": "View3D > Properties > BlenderKit",
"description": "Boost your workflow with drag&drop assets from the community driven library.",
@@ -28,7 +28,7 @@ bl_info = {
"tracker_url": "https://github.com/BlenderKit/blenderkit/issues",
"category": "3D View",
}
VERSION = (3, 18, 1, 251219)
VERSION = (3, 19, 1, 260402)
import logging
import random
@@ -96,6 +96,7 @@ if "bpy" in locals():
ui = reload(ui)
ui_bgl = reload(ui_bgl)
ui_panels = reload(ui_panels)
keymap_utils = reload(keymap_utils)
upload = reload(upload)
upload_bg = reload(upload_bg)
utils = reload(utils)
@@ -152,6 +153,7 @@ else:
from . import ui
from . import ui_bgl
from . import ui_panels
from . import keymap_utils
from . import upload
from . import upload_bg
from . import utils
@@ -273,7 +275,7 @@ if bpy.app.version >= (4, 5, 0):
EXTRA_PATH_OPTIONS = {"options": {"PATH_SUPPORTS_BLEND_RELATIVE"}}
def udate_down_up(self, context):
def update_down_up(self, context):
"""Perform a search if results are empty."""
props = bpy.context.window_manager.blenderkitUI
if search.get_search_results() is None and props.down_up == "SEARCH":
@@ -308,6 +310,19 @@ def asset_type_callback(self, context):
if bpy.app.version >= (4, 2, 0):
items.append(("ADDON", "Add-ons", "Find add-ons", "PLUGIN", 7))
addon = bpy.context.preferences.addons.get(__package__)
if addon is not None:
preferences = addon.preferences
if preferences.experimental_features and preferences.author_tab:
items.append(
(
"AUTHOR",
"Authors",
"Find authors",
pcoll["asset_type_author"].icon_id,
8,
),
)
else:
items = [
("MODEL", "Model", "Upload a model", "OBJECT_DATAMODE", 0),
@@ -330,6 +345,8 @@ def asset_type_callback(self, context):
if bpy.app.version >= (4, 2, 0):
items.append(("ADDON", "Add-on", "Upload an addon", "PLUGIN", 7))
# Author is search-only, no upload entry needed
return items
@@ -337,6 +354,10 @@ def run_drag_drop_update(self, context):
if self.drag_init_button:
ui_props = bpy.context.window_manager.blenderkitUI
if ui_props.dragging:
self.drag_init_button = False
return
bpy.ops.view3d.close_popup_button("INVOKE_DEFAULT")
bpy.ops.view3d.asset_drag_drop(
"INVOKE_DEFAULT",
@@ -356,7 +377,7 @@ class BlenderKitUIProps(PropertyGroup):
),
description="BlenderKit",
default="SEARCH",
update=udate_down_up,
update=update_down_up,
)
asset_type: EnumProperty(
name=" ",
@@ -505,11 +526,11 @@ class BlenderKitUIProps(PropertyGroup):
ui_scale = 1
thumb_size_def = 96
thumb_size_def = 128
margin_def = 0
thumb_size: IntProperty(
name="Thumbnail Size", default=thumb_size_def, min=-1, max=256
name="Thumbnail Size", default=thumb_size_def, min=48, max=256
)
margin: IntProperty(name="Margin", default=margin_def, min=-1, max=256)
@@ -552,7 +573,6 @@ class BlenderKitUIProps(PropertyGroup):
description="Click or drag into scene for download.\nUse mouse wheel during drag to rotate the asset. Cancel the drag by pressing 'Esc'.",
update=run_drag_drop_update,
)
drag_length: IntProperty(name="Drag length", default=0)
draw_drag_image: BoolProperty(name="Draw Drag Image", default=False)
draw_snapped_bounds: BoolProperty(name="Draw Snapped Bounds", default=False)
@@ -622,6 +642,32 @@ class BlenderKitUIProps(PropertyGroup):
name="Upload HDR", type=bpy.types.Image, description="Pick an image to upload"
)
hdr_use_custom_thumbnail_tone: BoolProperty(
name="Use Custom Thumbnail Tone",
description="Use custom exposure and gamma for HDR thumbnail conversion",
default=False,
)
hdr_thumbnail_exposure: FloatProperty(
name="Thumbnail Exposure",
description="Exposure offset used only for HDR thumbnail conversion",
default=0.0,
min=-5.0,
max=5.0,
soft_min=-2.0,
soft_max=2.0,
)
hdr_thumbnail_gamma: FloatProperty(
name="Thumbnail Gamma",
description="Gamma used only for HDR thumbnail conversion",
default=1.0,
min=0.2,
max=3.0,
soft_min=0.7,
soft_max=1.6,
)
nodegroup_upload: PointerProperty(
name="Upload Tool",
type=bpy.types.GeometryNodeTree,
@@ -785,7 +831,7 @@ def update_free(self, context):
message="Any material uploaded to BlenderKit is free."
" However, it can still earn money for the author,"
" based on our fair share system. "
"Part of subscription is sent to artists based on usage by paying users.\n",
"Part of subscription is sent to authors based on usage by paying users.\n",
)
@@ -868,6 +914,21 @@ class BlenderKitCommonUploadProps(object):
description="License. Please read our help for choosing the right licenses",
)
# verification mainly to retrigger processing
verification_status: EnumProperty(
name="Verification status",
description="Verification status of the asset, set by moderators",
items=(
("UPLOADING", "Uploading", "uploading"),
("UPLOADED", "Uploaded", "uploaded"),
("VALIDATED", "Validated", "validated"),
("ON_HOLD", "On Hold", "on_hold"),
("REJECTED", "Rejected", "rejected"),
("DELETED", "Deleted", "deleted"),
),
default="UPLOADING",
)
is_private: EnumProperty(
name="Thumbnail Style",
items=(("PRIVATE", "Private", ""), ("PUBLIC", "Public", "")),
@@ -1187,13 +1248,17 @@ class BlenderKitAddonSearchProps(PropertyGroup, BlenderKitCommonSearchProps):
description="Show only addons that are already installed in Blender",
default=False,
update=lambda self, context: (
search.refresh_search()
search.search_update(self, context)
if context.window_manager.blenderkitUI.asset_type == "ADDON"
else None
),
)
class BlenderKitAuthorSearchProps(PropertyGroup, BlenderKitCommonSearchProps):
pass
class BlenderKitHDRUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
texture_resolution_max: IntProperty(
name="Texture Resolution Max",
@@ -1207,6 +1272,15 @@ class BlenderKitHDRUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
class BlenderKitBrushUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
thumbnail: StringProperty(
name="Thumbnail",
description="Thumbnail path - at least 1024x1024 .jpg or .png",
subtype="FILE_PATH",
default="",
update=autothumb.update_upload_brush_preview,
**EXTRA_PATH_OPTIONS,
)
mode: EnumProperty(
name="Mode",
items=(
@@ -1526,6 +1600,13 @@ class BlenderKitModelUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
update=autothumb.update_upload_model_preview,
)
is_generating_wire_thumbnail: BoolProperty(
name="Generating Wire Thumbnail",
description="True when background process is running",
default=False,
update=autothumb.update_wire_thumbnail_preview,
)
has_autotags: BoolProperty(
name="Has Autotagging Done",
description="True when autotagging done",
@@ -1546,6 +1627,26 @@ class BlenderKitModelUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
default=False,
)
wire_thumbnail: StringProperty(
name="Wireframe Thumbnail",
description="Wireframe thumbnail (JPG or PNG, preferred size is 1024x1024 or higher)",
subtype="FILE_PATH",
default="",
update=autothumb.update_wire_thumbnail_preview,
**EXTRA_PATH_OPTIONS,
)
wire_thumbnail_will_upload_on_website: BoolProperty(
name="I will upload wireframe thumbnail on website",
description="True if the wireframe thumbnail will upload on the website\n please read upload tutorial for more information",
default=False,
)
wire_thumbnail_generating_state: StringProperty(
name="Wire Thumbnail Generating State",
description="bg process reports for wireframe thumbnail generation",
default="Please add wireframe thumbnail (jpg or png, at least 1024x1024)",
)
class BlenderKitSceneUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
style: EnumProperty(
@@ -1923,6 +2024,14 @@ class BlenderKitHDRSearchProps(PropertyGroup, BlenderKitCommonSearchProps):
)
def our_keymap_draw(self, context):
try:
keymap_utils.draw_keymap(self, context)
except Exception:
bk_logger.exception("Failed to draw keymap in preferences")
return
class BlenderKitSceneSearchProps(PropertyGroup, BlenderKitCommonSearchProps):
search_style: EnumProperty(
name="Style",
@@ -2090,6 +2199,12 @@ class BlenderKitAddonPreferences(AddonPreferences):
default=True,
)
assetbar_follows_cursor: BoolProperty(
name="Assetbar follows active viewport",
description="Make the assetbar follow the cursor across the screen",
default=False,
)
global_dir: StringProperty(
name="Global Directory",
description="Global storage for your assets, will use subdirectories for the contents. Client will place its files in subdirectory 'client'",
@@ -2151,6 +2266,13 @@ class BlenderKitAddonPreferences(AddonPreferences):
update=update_unpack,
)
write_asset_metadata: BoolProperty(
name="Write Asset Metadata",
description="Write BlenderKit metadata into downloaded files so tags, description, and preview show in other scenes",
default=True,
update=utils.save_prefs,
)
# resolution download/import settings
resolution: EnumProperty(
name="Max resolution",
@@ -2317,6 +2439,13 @@ In this case you should also set path to your system CA bundle containing proxy'
update=utils.save_prefs,
)
thumbnail_disable_subdivision: BoolProperty(
name="Disable Subdivision for Thumbnails Rendering (For assets upload)",
description="By default this is off. Disable this for wireframe thumbnails to render faster",
default=False,
update=utils.save_prefs,
)
maximized_assetbar_rows: IntProperty(
name="Maximized Assetbar Rows",
description="Maximum rows of assetbar in the 3D view when expanded",
@@ -2334,8 +2463,8 @@ In this case you should also set path to your system CA bundle containing proxy'
thumb_size: IntProperty(
name="Assetbar Thumbnail Size",
default=96,
min=-1,
default=128,
min=48, # must newer be zero
max=256,
update=utils.save_prefs,
description="Size of thumbnails of the assetbar in 3D view",
@@ -2352,7 +2481,21 @@ In this case you should also set path to your system CA bundle containing proxy'
experimental_features: BoolProperty(
name="Enable experimental features",
description="Enable experimental features of BlenderKit. Note: There are no experimental features in this version.",
description="Enable experimental features of BlenderKit, such as the Authors tab",
default=False,
update=utils.save_prefs,
)
author_tab: BoolProperty(
name="Show Authors tab",
description="Show Authors tab in the assetbar. This tab allows you to see all assets of a specific author and is also used for showing your profile and assets",
default=False,
update=utils.save_prefs,
)
author_asset_type_picker: BoolProperty(
name="Author asset type picker",
description="Enable the Authors tab in the asset type picker. When disabled, clicking an author searches in the current tab",
default=False,
update=utils.save_prefs,
)
@@ -2458,22 +2601,45 @@ In this case you should also set path to your system CA bundle containing proxy'
options={"SKIP_SAVE"},
)
enable_wire_thumbnail_upload: BoolProperty(
name="Enable wire thumbnail upload",
description="If enabled, wireframe thumbnails will be uploaded.",
default=False,
# do not save prefs here, it's experimental
options={"SKIP_SAVE"},
)
def draw(self, context):
layout = self.layout
login_box = layout.box()
login_box.label(text="Login Options")
if self.api_key.strip() == "":
ui_panels.draw_login_buttons(layout)
layout.label(
ui_panels.draw_login_buttons(login_box)
login_box.label(
text="Sign up to bookmark your favorite assets. Get 200 MiB of private storage in Free Plan."
)
else:
layout.operator("wm.blenderkit_logout", text="Logout", icon="URL")
layout.prop(self, "api_key", text="Your API Key")
layout.prop(self, "keep_preferences")
community_row = layout.row()
login_box.operator("wm.blenderkit_logout", text="Logout", icon="URL")
login_box.prop(self, "api_key", text="Your API Key")
login_box.prop(self, "keep_preferences")
community_row = login_box.row()
community_row.prop(self, "experimental_features")
community_row.operator("wm.blenderkit_join_discord", icon="URL")
if utils.profile_is_validator():
layout.prop(self, "categories_fix")
validator_box = layout.box()
validator_box.label(text="Validator Settings")
validator_box.prop(self, "categories_fix")
# REPORT PATHS
report_settings = layout.box()
report_settings.label(text="Report a Bug")
report_settings.label(
text="Create an issue report with version information to help us resolve the issue faster.",
)
report_settings.operator(
"wm.blenderkit_report_bug", text="Submit Full Bug Report", icon="ERROR"
)
# FILE PATHS
locations_settings = layout.box()
@@ -2484,6 +2650,7 @@ In this case you should also set path to your system CA bundle containing proxy'
if self.directory_behaviour in ("BOTH", "LOCAL"):
locations_settings.prop(self, "project_subdir")
locations_settings.prop(self, "unpack_files")
locations_settings.prop(self, "write_asset_metadata")
# GUI SETTINGS
gui_settings = layout.box()
@@ -2498,6 +2665,7 @@ In this case you should also set path to your system CA bundle containing proxy'
gui_settings.prop(self, "show_VIEW3D_MT_blenderkit_model_properties")
gui_settings.prop(self, "tips_on_start")
gui_settings.prop(self, "announcements_on_start")
gui_settings.prop(self, "assetbar_follows_cursor")
gui_settings.prop(self, "use_clipboard_scan")
# NETWORKING SETTINGS
@@ -2516,16 +2684,11 @@ In this case you should also set path to your system CA bundle containing proxy'
# UPDATER SETTINGS
addon_updater_ops.update_settings_ui(self, context)
# EXPERIMENTAL SETTINGS
# only if experimental features enabled
if self.experimental_features:
experimental_settings = layout.box()
experimental_settings.alignment = "EXPAND"
experimental_settings.label(text="Experimental settings")
experimental_settings.prop(self, "ignore_env_for_thumbnails")
# RUNTIME INFO
globdir_op = layout.operator(
directory_box = layout.box()
directory_box.label(text="Directories and Paths")
globdir_op = directory_box.operator(
"wm.blenderkit_open_global_directory",
text=f"Global directory: {self.global_dir}",
icon="FILE_FOLDER",
@@ -2533,7 +2696,7 @@ In this case you should also set path to your system CA bundle containing proxy'
globdir_op.directory = self.global_dir
clientlog_path = client_lib.get_client_log_path()
clientlog_op = layout.operator(
clientlog_op = directory_box.operator(
"wm.blenderkit_open_client_log",
text=f"Client log: {clientlog_path}",
icon="FILE_FOLDER",
@@ -2541,7 +2704,7 @@ In this case you should also set path to your system CA bundle containing proxy'
clientlog_op.directory = clientlog_path
addondir = path.dirname(__file__)
addondir_op = layout.operator(
addondir_op = directory_box.operator(
"wm.blenderkit_open_addon_directory",
text=f"Installed at: {addondir}",
icon="FILE_FOLDER",
@@ -2549,13 +2712,27 @@ In this case you should also set path to your system CA bundle containing proxy'
addondir_op.directory = addondir
tempdir = paths.get_temp_dir()
tempdir_op = layout.operator(
tempdir_op = directory_box.operator(
"wm.blenderkit_open_temp_directory",
text=f"Temp directory: {tempdir}",
icon="FILE_FOLDER",
)
tempdir_op.directory = tempdir
# try to draw also custom keymaps
our_keymap_draw(self, context)
# EXPERIMENTAL SETTINGS
# only if experimental features enabled
if self.experimental_features:
experimental_settings = layout.box()
experimental_settings.alignment = "EXPAND"
experimental_settings.label(text="Experimental settings")
experimental_settings.prop(self, "author_tab")
experimental_settings.prop(self, "author_asset_type_picker")
experimental_settings.prop(self, "ignore_env_for_thumbnails")
# experimental_settings.prop(self, "enable_wire_thumbnail_upload")
# registration
classes = (
@@ -2574,6 +2751,7 @@ classes = (
BlenderKitGeoToolSearchProps,
BlenderKitNodeGroupUploadProps,
BlenderKitAddonSearchProps,
BlenderKitAuthorSearchProps,
)
@@ -2582,6 +2760,9 @@ def register():
global_vars.VERSION = VERSION
bpy.utils.register_class(BlenderKitAddonPreferences)
# Drop any downloads that might have been left running if the add-on was re-enabled mid-transfer.
download.cancel_running_downloads("addon register")
addon_updater_ops.register({"version": VERSION})
for cls in classes:
bpy.utils.register_class(cls)
@@ -2643,9 +2824,13 @@ def register():
bpy.types.WindowManager.blenderkit_addon = PointerProperty(
type=BlenderKitAddonSearchProps
)
bpy.types.WindowManager.blenderkit_author = PointerProperty(
type=BlenderKitAuthorSearchProps
)
if bpy.app.factory_startup is False:
user_preferences = bpy.context.preferences.addons[__package__].preferences
global_vars.PREFS = utils.get_preferences_as_dict()
paths.ensure_asset_library_path(user_preferences.global_dir)
client_lib.reorder_ports(user_preferences.client_port)
timer.update_trusted_CA_certs(user_preferences.trusted_ca_certs)
@@ -2694,6 +2879,8 @@ def register():
def unregister():
bk_logger.info("Unregistering BlenderKit add-on")
# Stop any in-flight downloads to avoid leaving stale UI state when disabling the add-on.
download.cancel_running_downloads("addon unregister")
timer.unregister_timers()
ui_panels.unregister_ui_panels()
ui.unregister_ui()
@@ -2726,6 +2913,8 @@ def unregister():
del bpy.types.WindowManager.blenderkit_brush
del bpy.types.WindowManager.blenderkit_mat
del bpy.types.WindowManager.blenderkit_nodegroup
del bpy.types.WindowManager.blenderkit_addon
del bpy.types.WindowManager.blenderkit_author
del bpy.types.Scene.blenderkit
del bpy.types.Object.blenderkit
@@ -1,12 +0,0 @@
include:
- "**/*.py"
exclude_dirs:
- "tests"
- ".venv"
- "__pycache__"
skips:
- "B404" # https://bandit.readthedocs.io/en/1.7.10/blacklists/blacklist_imports.html#b404-import-subprocess
- "B603" # https://bandit.readthedocs.io/en/1.7.10/plugins/b603_subprocess_without_shell_equals_true.html
- "B608" # https://bandit.readthedocs.io/en/1.7.10/plugins/b608_hardcoded_sql_expressions.html
@@ -29,6 +29,7 @@ import fnmatch
import json
import os
import platform
import logging
import shutil
import ssl
import threading
@@ -43,6 +44,10 @@ import addon_utils
# Blender imports, used in limited cases.
import bpy
from . import tasks_queue
bk_logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
# The main class
@@ -134,15 +139,15 @@ class SingletonUpdater:
self._select_link = select_link_function
def print_trace(self):
"""Print handled exception details when use_print_traces is set"""
"""Logs handled exception details when use_print_traces is set"""
if self._use_print_traces:
traceback.print_exc()
bk_logger.error("%s", traceback.format_exc())
def print_verbose(self, msg):
"""Print out a verbose logging message if verbose is true."""
"""Logs verbose messages if verbose is true."""
if not self._verbose:
return
print("🔄 {}: ".format(self.addon) + msg)
bk_logger.info("%s", msg)
# -------------------------------------------------------------------------
# Getters and setters
@@ -177,7 +182,7 @@ class SingletonUpdater:
def auto_reload_post_update(self, value):
try:
self._auto_reload_post_update = bool(value)
except:
except Exception:
raise ValueError("auto_reload_post_update must be a boolean value")
@property
@@ -227,7 +232,7 @@ class SingletonUpdater:
elif type(tuple_values) is not tuple:
try:
tuple(tuple_values)
except:
except Exception:
raise ValueError("current_version must be a tuple of integers")
for i in tuple_values:
if type(i) is not int:
@@ -277,7 +282,7 @@ class SingletonUpdater:
def include_branch_auto_check(self, value):
try:
self._include_branch_auto_check = bool(value)
except:
except Exception:
raise ValueError("include_branch_autocheck must be a boolean")
@property
@@ -295,7 +300,7 @@ class SingletonUpdater:
)
else:
self._include_branch_list = value
except:
except Exception:
raise ValueError("include_branch_list should be a list of valid branches")
@property
@@ -306,7 +311,7 @@ class SingletonUpdater:
def include_branches(self, value):
try:
self._include_branches = bool(value)
except:
except Exception:
raise ValueError("include_branches must be a boolean value")
@property
@@ -329,7 +334,7 @@ class SingletonUpdater:
def manual_only(self, value):
try:
self._manual_only = bool(value)
except:
except Exception:
raise ValueError("manual_only must be a boolean value")
@property
@@ -377,7 +382,7 @@ class SingletonUpdater:
def repo(self, value):
try:
self._repo = str(value)
except:
except Exception:
raise ValueError("repo must be a string value")
@property
@@ -404,8 +409,8 @@ class SingletonUpdater:
elif value is not None and not os.path.exists(value):
try:
os.makedirs(value)
except:
self.print_verbose("Error trying to staging path")
except Exception:
self.print_verbose("Error trying to create staging path")
self.print_trace()
return
self._updater_path = value
@@ -453,7 +458,7 @@ class SingletonUpdater:
def use_releases(self, value):
try:
self._use_releases = bool(value)
except:
except Exception:
raise ValueError("use_releases must be a boolean value")
@property
@@ -464,7 +469,7 @@ class SingletonUpdater:
def user(self, value):
try:
self._user = str(value)
except:
except Exception:
raise ValueError("User must be a string value")
@property
@@ -476,7 +481,7 @@ class SingletonUpdater:
try:
self._verbose = bool(value)
self.print_verbose("Verbose is enabled")
except:
except Exception:
raise ValueError("Verbose must be a boolean value")
@property
@@ -487,7 +492,7 @@ class SingletonUpdater:
def use_print_traces(self, value):
try:
self._use_print_traces = bool(value)
except:
except Exception:
raise ValueError("use_print_traces must be a boolean value")
@property
@@ -687,7 +692,7 @@ class SingletonUpdater:
request = urllib.request.Request(url)
try:
context = ssl._create_unverified_context()
except:
except Exception:
# Some blender packaged python versions don't have this, largely
# useful for local network setups otherwise minimal impact.
context = None
@@ -712,24 +717,24 @@ class SingletonUpdater:
if str(e.code) == "403":
self._error = "HTTP error (access denied)"
self._error_msg = str(e.code) + " - server error response"
print(self._error, self._error_msg)
bk_logger.error("%s %s", self._error, self._error_msg)
else:
self._error = "HTTP error"
self._error_msg = str(e.code)
print(self._error, self._error_msg)
self.print_trace()
bk_logger.error("%s %s", self._error, self._error_msg)
# self.print_trace()
self._update_ready = None
except urllib.error.URLError as e:
reason = str(e.reason)
if "TLSV1_ALERT" in reason or "SSL" in reason.upper():
self._error = "Connection rejected, download manually"
self._error_msg = reason
print(self._error, self._error_msg)
bk_logger.error("%s %s", self._error, self._error_msg)
else:
self._error = "URL error, check internet connection"
self._error_msg = reason
print(self._error, self._error_msg)
self.print_trace()
bk_logger.error("%s %s", self._error, self._error_msg)
# self.print_trace()
self._update_ready = None
return None
else:
@@ -748,7 +753,7 @@ class SingletonUpdater:
self._error = "API response has invalid JSON format"
self._error_msg = str(e.reason)
self._update_ready = None
print(self._error, self._error_msg)
bk_logger.error("%s %s", self._error, self._error_msg)
self.print_trace()
return None
else:
@@ -766,13 +771,13 @@ class SingletonUpdater:
try:
shutil.rmtree(local)
os.makedirs(local)
except:
except Exception:
error = "failed to remove existing staging directory"
self.print_trace()
else:
try:
os.makedirs(local)
except:
except Exception:
error = "failed to create staging directory"
self.print_trace()
@@ -811,8 +816,8 @@ class SingletonUpdater:
except Exception as e:
self._error = "Error retrieving download, bad link?"
self._error_msg = "Error: {}".format(e)
print("Error retrieving download, bad link?")
print("Error: {}".format(e))
bk_logger.error("Error retrieving download, bad link?")
bk_logger.error("Error: %s", e)
self.print_trace()
return False
@@ -829,7 +834,7 @@ class SingletonUpdater:
if os.path.isdir(local):
try:
shutil.rmtree(local)
except:
except Exception:
self.print_verbose(
"Failed to removed previous backup folder, continuing"
)
@@ -840,7 +845,7 @@ class SingletonUpdater:
if os.path.isdir(tempdest):
try:
shutil.rmtree(tempdest)
except:
except Exception:
self.print_verbose("Failed to remove existing temp folder, continuing")
self.print_trace()
@@ -852,15 +857,15 @@ class SingletonUpdater:
tempdest,
ignore=shutil.ignore_patterns(*self._backup_ignore_patterns),
)
except:
print("Failed to create backup, still attempting update.")
except Exception:
self.print_verbose("Failed to create backup, still attempting update.")
self.print_trace()
return
else:
try:
shutil.copytree(self._addon_root, tempdest)
except:
print("Failed to create backup, still attempting update.")
except Exception:
self.print_verbose("Failed to create backup, still attempting update.")
self.print_trace()
return
shutil.move(tempdest, local)
@@ -906,23 +911,23 @@ class SingletonUpdater:
try:
shutil.rmtree(outdir)
self.print_verbose("Source folder cleared")
except:
except Exception:
self.print_verbose("Error occurred while clearing extract dir")
self.print_trace()
# Create parent directories if needed, would not be relevant unless
# installing addon into another location or via an addon manager.
try:
os.mkdir(outdir)
except Exception as err:
print("Error occurred while making extract dir:")
print(str(err))
except Exception:
self.print_verbose("Error occurred while making extract dir")
self.print_trace()
self._error = "Install failed"
self._error_msg = "Failed to make extract directory"
return -1
if not os.path.isdir(outdir):
print("Failed to create source directory")
bk_logger.error("Failed to create source directory")
self._error = "Install failed"
self._error_msg = "Failed to create extract directory"
return -1
@@ -972,7 +977,7 @@ class SingletonUpdater:
if not os.path.isdir(unpath):
self._error = "Install failed"
self._error_msg = "Extracted path does not exist"
print("Extracted path does not exist: ", unpath)
bk_logger.error("Extracted path does not exist: %s", unpath)
return -1
if self._subfolder_path:
@@ -991,9 +996,8 @@ class SingletonUpdater:
# Smarter check for additional sub folders for a single folder
# containing the __init__.py file.
if not os.path.isfile(os.path.join(unpath, "__init__.py")):
print("Not a valid addon found")
print("Paths:")
print(dirlist)
bk_logger.error("Not a valid addon found")
bk_logger.error("Paths: %s", dirlist)
self._error = "Install failed"
self._error_msg = "No __init__ file found in new source"
return -1
@@ -1052,8 +1056,10 @@ class SingletonUpdater:
self.print_verbose(
"Clean removing file {}".format(os.path.join(base, f))
)
except Exception as e:
print(f"Error removing file {os.path.join(base, f)}: {e}")
except Exception:
bk_logger.exception(
"Error removing file %s:", os.path.join(base, f)
)
for f in folders:
if os.path.join(base, f) is self._updater_path:
continue
@@ -1064,12 +1070,14 @@ class SingletonUpdater:
os.path.join(base, f)
)
)
except Exception as e:
print(f"Error removing folder {os.path.join(base, f)}: {e}")
except Exception:
bk_logger.exception(
"Error removing folder %s:", os.path.join(base, f)
)
except Exception as err:
except Exception:
error = "failed to create clean existing addon folder"
print(error, str(err))
self.print_verbose(error)
self.print_trace()
# Walk through the base addon folder for rules on pre-removing
@@ -1087,7 +1095,7 @@ class SingletonUpdater:
os.remove(fl)
self.print_verbose("Pre-removed file " + file)
except OSError:
print("Failed to pre-remove " + file)
self.print_verbose("Failed to pre-remove " + file)
self.print_trace()
# Walk through the temp addon sub folder for replacements
@@ -1134,13 +1142,13 @@ class SingletonUpdater:
# File did not previously exist, simply move it over.
os.rename(srcFile, dest_file)
self.print_verbose("New file " + os.path.basename(dest_file))
except Exception as e:
print(f"Error replacing file {file}: {e}")
except Exception:
bk_logger.exception("Error replacing file %s:", file)
# now remove the temp staging folder and downloaded zip
try:
shutil.rmtree(staging_path)
except:
except Exception:
error = (
"Error: Failed to remove existing staging directory, "
"consider manually removing "
@@ -1152,7 +1160,7 @@ class SingletonUpdater:
# if post_update false, skip this function
# else, unload/reload addon & trigger popup
if not self._auto_reload_post_update:
print("Restart blender to reload addon and complete update")
bk_logger.info("Restart blender to reload addon and complete update")
return
self.print_verbose("Reloading addon...")
@@ -1165,12 +1173,12 @@ class SingletonUpdater:
bpy.ops.wm.addon_disable(module=self._addon_package)
bpy.ops.wm.addon_refresh()
bpy.ops.wm.addon_enable(module=self._addon_package)
print("2.7 reload complete")
bk_logger.info("2.7 reload complete")
else: # 2.8
bpy.ops.preferences.addon_disable(module=self._addon_package)
bpy.ops.preferences.addon_refresh()
bpy.ops.preferences.addon_enable(module=self._addon_package)
print("2.8 reload complete")
bk_logger.info("2.8 reload complete")
# -------------------------------------------------------------------------
# Other non-api functions and setups
@@ -1190,10 +1198,10 @@ class SingletonUpdater:
while 1:
data = url_file.read(chunk)
if not data:
# print("done.")
# bk_logger.info("done.")
break
f.write(data)
# print("Read %s bytes" % len(data))
# bk_logger.info("Read %s bytes" % len(data))
f.close()
def version_tuple_from_text(self, text):
@@ -1249,7 +1257,9 @@ class SingletonUpdater:
self.print_verbose("Skipping async check, already started")
# already running the bg thread
elif self._update_ready is None:
print("{} updater: Running background check for update".format(self.addon))
bk_logger.info(
"%s updater: Running background check for update", self.addon
)
self.start_async_check_update(False, callback)
def check_for_update_now(self, callback=None):
@@ -1266,7 +1276,7 @@ class SingletonUpdater:
self.start_async_check_update(True, callback)
def check_for_update(self, now=False):
"""Check for update not in a syncrhonous manner.
"""Check for update not in a synchronous manner.
This function is not async, will always return in sequential fashion
but should have a parent which calls it in another thread.
@@ -1449,7 +1459,7 @@ class SingletonUpdater:
res = self.stage_repository(self._update_link)
if not res:
print("Error in staging repository: " + str(res))
bk_logger.error("Error in staging repository: %s", str(res))
if callback is not None:
callback(self._addon_package, self._error_msg)
return self._error_msg
@@ -1467,7 +1477,7 @@ class SingletonUpdater:
res = self.stage_repository(self._update_link)
if not res:
print("Error in staging repository: " + str(res))
bk_logger.error("Error in staging repository: %s", str(res))
if callback:
callback(self._addon_package, self._error_msg)
return self._error_msg
@@ -1525,9 +1535,11 @@ class SingletonUpdater:
os.rename(old_json_path, json_path)
except FileNotFoundError:
pass
except Exception as err:
print("Other OS error occurred while trying to rename old JSON")
print(err)
except Exception:
self.print_verbose(
"Other OS error occurred while trying to rename old JSON"
)
self.print_trace()
return json_path
@@ -1571,7 +1583,7 @@ class SingletonUpdater:
jpath = self.get_json_path()
if not os.path.isdir(os.path.dirname(jpath)):
print(
bk_logger.error(
"State error: Directory does not exist, cannot save json: ",
os.path.basename(jpath),
)
@@ -1580,8 +1592,9 @@ class SingletonUpdater:
with open(jpath, "w") as outf:
data_out = json.dumps(self._json, indent=4)
outf.write(data_out)
except:
print("Failed to open/save data to json: ", jpath)
self.print_verbose(f"Wrote updater JSON settings to file: {jpath}")
except Exception:
self.print_verbose(f"Failed to open/save data to json: {jpath}")
self.print_trace()
self.print_verbose("Wrote out updater JSON settings with content:")
self.print_verbose(str(self._json))
@@ -1630,8 +1643,7 @@ class SingletonUpdater:
try:
self.check_for_update(now=now)
except Exception as exception:
print("Checking for update error:")
print(exception)
self.print_verbose(f"Checking for update error: {exception}")
self.print_trace()
if not self._error:
self._update_ready = False
@@ -1644,8 +1656,11 @@ class SingletonUpdater:
self._check_thread = None
if callback:
self.print_verbose("Finished check update, doing callback")
callback(self._update_ready)
self.print_verbose(
"Finished check update, queueing callback on main thread"
)
# Run the callback on Blender's main thread to avoid bpy access from a background thread.
tasks_queue.add_task((callback, (self._update_ready,)), wait=0)
self.print_verbose("BG thread: Finished check update, no callback")
def stop_async_check_update(self):
@@ -24,11 +24,14 @@ Implements draw calls, popups, and operators that use the addon_updater.
import os
import traceback
import logging
import bpy
from bpy.app.handlers import persistent
from . import client_lib
from . import client_lib, utils
bk_logger = logging.getLogger(__name__)
# Safely import the updater.
@@ -37,9 +40,9 @@ from . import client_lib
try:
from .addon_updater import Updater as updater
except Exception as e:
print("ERROR INITIALIZING UPDATER")
print(str(e))
traceback.print_exc()
bk_logger.error("ERROR INITIALIZING UPDATER")
bk_logger.error(str(e))
bk_logger.error("%s", traceback.format_exc())
class SingletonUpdaterNone(object):
"""Fake, bare minimum fields and functions for the updater object."""
@@ -175,6 +178,10 @@ class AddonUpdaterInstallPopup(bpy.types.Operator):
return True
def invoke(self, context, event):
# Skip opening the popup when we already know there is nothing to show.
if not updater.invalid_updater and updater.update_ready is False:
updater.print_verbose("No update available; skipping popup")
return {"CANCELLED"}
return context.window_manager.invoke_props_dialog(self)
def draw(self, context):
@@ -229,9 +236,9 @@ class AddonUpdaterInstallPopup(bpy.types.Operator):
# Should return 0, if not something happened.
if updater.verbose:
if res == 0:
print("Updater returned successful")
bk_logger.info("Updater returned successful")
else:
print("Updater returned {}, error occurred".format(res))
bk_logger.info("Updater returned {}, error occurred".format(res))
elif updater.update_ready is None:
_ = updater.check_for_update(now=True)
@@ -322,9 +329,9 @@ class AddonUpdaterUpdateNow(bpy.types.Operator):
# Should return 0, if not something happened.
if updater.verbose:
if res == 0:
print("Updater returned successful")
bk_logger.info("Updater returned successful")
else:
print("Updater error response: {}".format(res))
bk_logger.info("Updater error response: {}".format(res))
except Exception as expt:
updater._error = "Error trying to run update"
updater._error_msg = str(expt)
@@ -450,7 +457,7 @@ class AddonUpdaterInstallManually(bpy.types.Operator):
layout.label(text="Updater error")
return
# Display error if a prior autoamted install failed.
# Display error if a prior automated install failed.
if self.error != "":
col = layout.column()
col.scale_y = 0.7
@@ -692,7 +699,7 @@ def updater_run_install_popup_handler(scene):
updater_run_install_popup_handler
)
except Exception as e:
print(e)
bk_logger.error("%s", e)
pass
if "ignore" in updater.json and updater.json["ignore"]:
@@ -833,8 +840,8 @@ def check_for_update_nonthreaded(self, context):
settings = get_user_preferences(bpy.context)
if not settings:
if updater.verbose:
print(
"Could not get {} preferences, update check skipped".format(__package__)
bk_logger.info(
"Could not get %s preferences, update check skipped", __package__
)
return
updater.set_check_interval(
@@ -1128,6 +1135,15 @@ def update_settings_ui(self, context, element=None):
else:
row.label(text="Last update check: Never")
version_row = box.row()
version_row.label(
text=f"BlenderKit v{utils.get_addon_version()} · Blender {bpy.app.version_string}",
icon="INFO",
)
version_row.operator(
"wm.blenderkit_copy_environment_info", text="Copy Info", icon="COPYDOWN"
)
def update_settings_ui_condensed(self, context, element=None):
"""Preferences - Condensed drawing within preferences.
@@ -1359,7 +1375,7 @@ def register(bl_info):
"""Registering the operators in this module"""
# Safer failure in case of issue loading module.
if updater.error:
print("Exiting updater registration, " + updater.error)
bk_logger.error("Exiting updater registration, %s", updater.error)
return
updater.clear_state() # Clear internal vars, avoids reloading oddities.
@@ -299,7 +299,7 @@ def append_material(file_name, matname=None, link=False, fake_user=True):
)
except Exception as e:
bk_logger.error(f"{e} - failed to open the asset file")
bk_logger.error("%s - failed to open the asset file", e)
# we have to find the new material , due to possible name changes
mat = None
for m in bpy.data.materials:
@@ -532,7 +532,7 @@ def append_particle_system(
total_max_threshold = 2000000
# emitting too many parent particles just kills blender now.
# this part tuned child count, we'll leave children to artists only.
# this part tuned child count, we'll leave children to authors only.
# if count > total_max_threshold:
# ratio = round(count / total_max_threshold)
#
@@ -621,14 +621,18 @@ def append_objects(
return_obs = []
to_hidden_collection = []
hidden_objects = []
appended_collection = None
main_object = None
# get first at least one parent for sure
# first get at least one parent for sure
for ob in bpy.context.scene.objects: # type: ignore[union-attr]
if ob.select_get():
if not ob.parent:
main_object = ob
ob.location = location
if ob.select_get() and not ob.parent:
main_object = ob
ob.location = location
if (
ob.hide_viewport or ob.hide_render
): # saved assets only retain hide render state
hidden_objects.append(ob)
# do once again to ensure hidden objects are hidden
for ob in bpy.context.scene.objects: # type: ignore[union-attr]
if ob.select_get():
@@ -654,7 +658,7 @@ def append_objects(
main_object.matrix_world.translation = location
# move objects that should be hidden to a sub collection
if len(to_hidden_collection) > 0 and appended_collection is not None:
if to_hidden_collection and appended_collection is not None:
hidden_collections = []
scene_collection = bpy.context.scene.collection # type: ignore[union-attr]
for ob in to_hidden_collection:
@@ -682,7 +686,7 @@ def append_objects(
if hide_collection in hidden_collections:
continue
# All other collections are moved to be children of the model collection
bk_logger.info(f"{hide_collection}, {appended_collection}")
bk_logger.info("%s, %s", hide_collection, appended_collection)
# If target collection is specified, move collections there instead
if collection and bpy.data.collections.get(collection):
utils.move_collection(
@@ -707,6 +711,12 @@ def append_objects(
if orig_active_collection:
bpy.context.view_layer.active_layer_collection = orig_active_collection # type: ignore[union-attr]
if hidden_objects:
# only unique objects
hidden_objects = list(set(hidden_objects))
for ob in hidden_objects:
ob.hide_set(True)
utils.selection_set(sel)
# let collection also store info that it was created by BlenderKit, for purging reasons
@@ -753,16 +763,18 @@ def append_objects(
obj.select_set(True)
# we need to unhide object so make_local op can use those too.
if link == True:
if obj.hide_viewport:
if (
obj.hide_viewport or obj.hide_render
): # saved assets only retain hide render state
hidden_objects.append(obj)
obj.hide_viewport = False
obj.hide_set(False)
return_obs.append(obj)
# Only after all objects are in scene! Otherwise gets broken relationships
if link == True:
bpy.ops.object.make_local(type="SELECT_OBJECT")
for ob in hidden_objects:
ob.hide_viewport = True
ob.hide_set(True)
if kwargs.get("rotation") is not None:
main_object.rotation_euler = kwargs["rotation"] # type: ignore[union-attr]
File diff suppressed because it is too large Load Diff
@@ -21,6 +21,7 @@ import logging
import math
import os
import random
from typing import Any, Optional, Set, Tuple, Union
import bpy
import mathutils
@@ -28,8 +29,6 @@ from bpy.props import IntProperty, StringProperty
from bpy_extras import view3d_utils
from mathutils import Vector
from typing import Any, Optional, Tuple, Set, Union
from . import (
bg_blender,
colors,
@@ -38,11 +37,12 @@ from . import (
image_utils,
paths,
reports,
search,
ui,
ui_bgl,
ui_panels,
utils,
search,
viewport_utils,
)
from .bl_ui_widgets.bl_ui_button import BL_UI_Button
from .bl_ui_widgets.bl_ui_drag_panel import BL_UI_Drag_Panel
@@ -56,11 +56,8 @@ handler_2d = None
handler_3d = None
DEAD_ZONE = 5 # pixels
"""Number of pixels mouse must move to start drag operation."""
DRAG_THRESHOLD = 10 # pixels
"""Number of pixels mouse must move to consider as a drag (vs click)."""
DEFAULT_DRAG_THRESHOLD = 30 # pixels
"""Pointer travel in pixels needed before we start rendering full drag hints."""
def is_draw_cb_available(self: bpy.types.Operator, context: bpy.types.Context) -> bool:
@@ -106,8 +103,8 @@ def draw_callback_dragging(
Returns:
None
"""
# Only draw 2D elements in the active region where the mouse is. Guard against destroyed operator.
# Only draw 2D elements in the active region where the mouse is. Guard against destroyed operator.
if not is_draw_cb_available(self, context):
return
@@ -356,6 +353,10 @@ def draw_callback_3d_dragging(
if not utils.guard_from_crash():
return
# ignore unless we are dragging
if not self.drag:
return
# Only draw 3D elements in VIEW_3D areas, not in outliner
if context.area.type != "VIEW_3D":
return
@@ -522,19 +523,6 @@ def draw_progress(
ui_bgl.draw_text(text, x, y + 8, 16, color)
def find_and_activate_instancers(
obj: bpy.types.Object,
) -> Optional[bpy.types.Object]:
for ob in bpy.context.visible_objects:
if (
ob.instance_type == "COLLECTION"
and ob.instance_collection
and obj.name in ob.instance_collection.objects
):
utils.activate(ob)
return ob
def mouse_raycast(
region: bpy.types.Region, rv3d: bpy.types.RegionView3D, mx: int, my: int
) -> Tuple[
@@ -741,11 +729,9 @@ def deep_ray_cast(ray_origin: Vector, vec: Vector) -> Tuple[
def object_in_particle_collection(o: bpy.types.Object) -> bool:
"""checks if an object is in a particle system as instance, to not snap to it and not to try to attach material."""
for p in bpy.data.particles:
if p.render_type == "COLLECTION":
if p.instance_collection:
for o1 in p.instance_collection.objects:
if o1 == o:
return True
if p.render_type == "COLLECTION" and p.instance_collection:
if o in p.instance_collection.objects:
return True
if p.render_type == "COLLECTION":
if p.instance_object == o:
return True
@@ -774,22 +760,6 @@ def get_node_tree(context: bpy.types.Context) -> bpy.types.NodeTree:
return context.scene.compositing_node_group
def assign_node_tree(
node_space: bpy.types.SpaceNodeEditor, node_tree: bpy.types.NodeTree
) -> None:
"""Blender version invariant way to assign a node tree to the current node editor."""
if bpy.app.version < (5, 0, 0):
node_space.node_tree = node_tree
return
# blender 5.0+
# recover the node_group from data and assign it
if hasattr(node_space, "node_group"):
node_space.node_group = bpy.data.node_groups[node_tree.name]
elif hasattr(node_space, "node_tree"):
node_space.node_tree = node_tree
class AssetDragOperator(bpy.types.Operator):
"""Drag & drop assets into scene. Operator being drawn when dragging asset."""
@@ -797,9 +767,9 @@ class AssetDragOperator(bpy.types.Operator):
bl_label = "BlenderKit asset drag drop"
asset_search_index: IntProperty(name="Active Index", default=0) # type: ignore
drag_length: IntProperty(name="Drag_length", default=0) # type: ignore
object_name = None
active_operator_id = None
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
@@ -813,32 +783,28 @@ class AssetDragOperator(bpy.types.Operator):
self.hovered_outliner_element: Union[bpy.types.Object, bpy.types.Collection] = (
None
)
self.active_window = None
self.active_area = None
self.active_region = None
self.orig_active_object = None
self.orig_selected_objects = None
self.orig_active_collection = None
self.downloader = None
# Mouse tracking variables
self.start_mouse_x = None
self.start_mouse_y = None
self.start_mouse_x = 0
self.start_mouse_y = 0
self.mouse_x = 0
self.mouse_y = 0
self.mouse_screen_x = 0
self.mouse_screen_y = 0
self.steps = 0
# Store the initial active region pointer
self.active_region_pointer = None
# Initialize outliner tracking variables
self.hovered_outliner_element = None
self.outliner_area = None
self.outliner_region = None
self.orig_selected_objects = None
self.orig_active_object = None
self.orig_active_collection = None
self.prev_area_type = None
# Initialize node editor tracking
@@ -858,6 +824,8 @@ class AssetDragOperator(bpy.types.Operator):
self.iname = ""
self.drag = False
self.steps = 0
self.closed_assetbar = False
def handlers_remove(self) -> None:
"""Remove all draw handlers."""
@@ -873,11 +841,8 @@ class AssetDragOperator(bpy.types.Operator):
self, nodegroup_type: str, editor_type: Optional[str] = None
) -> bool:
"""Check if a nodegroup of a specific type is compatible with the given editor type."""
# Direct matches
if nodegroup_type == editor_type:
return True
# Generic nodegroups can work in any editor
elif nodegroup_type is None:
# Direct matches, or invalid editor
if not nodegroup_type or nodegroup_type == editor_type:
return True
# Otherwise, not compatible
return False
@@ -958,7 +923,7 @@ class AssetDragOperator(bpy.types.Operator):
if obj.type == "MESH":
temp_mesh = object_eval.to_mesh()
mapping = create_material_mapping(obj, temp_mesh)
_mapping = create_material_mapping(obj, temp_mesh)
target_slot = temp_mesh.polygons[self.face_index].material_index
object_eval.to_mesh_clear()
else:
@@ -1073,7 +1038,7 @@ class AssetDragOperator(bpy.types.Operator):
target_collection = ""
# Check what type of element we're dropping on
element_type = type(self.hovered_outliner_element).__name__
_element_type = type(self.hovered_outliner_element).__name__
# If dropping on a collection, set target_collection parameter
if isinstance(self.hovered_outliner_element, bpy.types.Collection):
@@ -1480,10 +1445,10 @@ class AssetDragOperator(bpy.types.Operator):
for window in wins:
# first let's test if it's in this window, so we know we shall continue
window_x = window.x * self.resolution_factor
window_y = window.y * self.resolution_factor
window_width = window.width * self.resolution_factor
window_height = window.height * self.resolution_factor
window_x = window.x
window_y = window.y
window_width = window.width
window_height = window.height
if (
x < window_x
or x > window_x + window_width
@@ -1513,7 +1478,6 @@ class AssetDragOperator(bpy.types.Operator):
return None
context = bpy.context
scene = context.scene
view_layer = context.view_layer
selected_objects = context.selected_objects
active_object = context.active_object
@@ -1630,11 +1594,14 @@ class AssetDragOperator(bpy.types.Operator):
):
"""Get the active object under the mouse cursor during drag."""
region_data = None
for space in active_area.spaces:
if space.type == "VIEW_3D":
region_data = space.region_3d
# precise placement in ortho views, and quad view
region_data = viewport_utils.region_data_for_view(active_area, active_region)
if region_data is None:
for space in active_area.spaces:
if space.type == "VIEW_3D":
region_data = getattr(space, "region_3d", None)
if region_data is not None:
break
# Need to temporarily override context for raycasting
if bpy.app.version < (3, 2, 0): # B3.0, B3.1 - custom context override
@@ -1643,9 +1610,7 @@ class AssetDragOperator(bpy.types.Operator):
"screen": active_window.screen,
"area": active_area,
"region": active_region,
"region_data": active_area.spaces[
0
].region_3d, # Get region_data from space_data
"region_data": region_data,
"scene": context.scene,
"view_layer": context.view_layer,
}
@@ -1757,69 +1722,70 @@ class AssetDragOperator(bpy.types.Operator):
self.in_node_editor = False
self.node_editor_type = None
def _cleanup_drag(self, ui_props, cls, *, reopen_assetbar: bool = False) -> None:
"""Shared teardown: remove handlers, restore cursor, reset drag state."""
self.handlers_remove()
bpy.context.window.cursor_modal_restore()
ui_props.dragging = False
if self.closed_assetbar:
bpy.ops.view3d.run_assetbar_fix_context(keep_running=True, do_search=False)
if reopen_assetbar:
bpy.ops.view3d.blenderkit_asset_bar_widget(
"INVOKE_REGION_WIN", do_search=False
)
cls.active_operator_id = None
def modal(self, context: bpy.types.Context, event: bpy.types.Event) -> Set[str]:
cls = type(self)
ui_props = bpy.context.window_manager.blenderkitUI
self.resolution_factor = (
bpy.context.preferences.system.pixel_size
/ bpy.context.preferences.view.ui_scale
)
self.mouse_screen_x = int(
context.window.x * self.resolution_factor + event.mouse_x
)
self.mouse_screen_y = int(
context.window.y * self.resolution_factor + event.mouse_y
)
self.mouse_screen_x = int(context.window.x + event.mouse_x)
self.mouse_screen_y = int(context.window.y + event.mouse_y)
# Find the active region under the mouse cursor using actual screen coordinates
self.active_window, self.active_area, self.active_region = (
self.find_active_region(self.mouse_screen_x, self.mouse_screen_y)
found_window, found_area, found_region = self.find_active_region(
self.mouse_screen_x, self.mouse_screen_y
)
if (
found_region is not None
and found_area is not None
and found_window is not None
):
self.active_window, self.active_area, self.active_region = (
found_window,
found_area,
found_region,
)
# --- CURSOR VISIBILITY FIX ---
if self.active_region is None or self.active_area is None:
bpy.context.window.cursor_modal_set("STOP")
# bpy.context.window.cursor_modal_set("STOP")
bpy.context.window.cursor_modal_restore()
return {"PASS_THROUGH"}
elif self.drag:
if self.drag:
bpy.context.window.cursor_modal_set("NONE")
# Convert screen coords (bottom-left) to region-local coords
# window.x/y and region.x/y are also in bottom-left coordinate system
self.mouse_x = int(
self.mouse_screen_x
- self.active_window.x * self.resolution_factor
- self.active_region.x
self.mouse_screen_x - self.active_window.x - self.active_region.x
)
self.mouse_y = int(
self.mouse_screen_y
- self.active_window.y * self.resolution_factor
- self.active_region.y
self.mouse_screen_y - self.active_window.y - self.active_region.y
)
if self.start_mouse_x is None or self.start_mouse_y is None:
self.start_mouse_x = self.mouse_x
self.start_mouse_y = self.mouse_y
# --- REDRAW ALL WINDOWS/AREAS FOR MULTI-WINDOW DRAG ---
# redraw all windows to update cursor and other elements
for window in bpy.context.window_manager.windows:
for area in window.screen.areas:
area.tag_redraw()
current_area_type = self.active_area.type if self.active_area else None
current_area_type = self.active_area.type
# Check if we're transitioning out of the outliner
if (
self.prev_area_type
and self.prev_area_type == "OUTLINER"
and current_area_type != "OUTLINER"
):
# If we're leaving the outliner, restore the original selection
if self.prev_area_type == "OUTLINER" and current_area_type != "OUTLINER":
self.restore_original_selection()
# shift pressed
if event.shift:
self.shift_pressed = True
else:
self.shift_pressed = False
self.shift_pressed = event.shift
# Track if we're in a node editor
self._handle_node_editor_type(current_area_type, self.active_area)
@@ -1832,12 +1798,8 @@ class AssetDragOperator(bpy.types.Operator):
# Store the active region pointer for drawing 2D elements only in this region
self.active_region_pointer = self.active_region.as_pointer()
# Make sure all 3D views get redrawn
for area in context.screen.areas:
area.tag_redraw()
# Handle outliner interaction
if self.active_area.type == "OUTLINER":
if current_area_type == "OUTLINER":
self.hovered_outliner_element = self.find_outliner_element_under_mouse()
self.outliner_window = self.active_window
self.outliner_area = self.active_area
@@ -1853,48 +1815,46 @@ class AssetDragOperator(bpy.types.Operator):
self.active_region_pointer = context.region.as_pointer()
# are we dragging already?
delta_x = abs(self.start_mouse_x - self.mouse_screen_x)
delta_y = abs(self.start_mouse_y - self.mouse_screen_y)
if not self.drag and (
abs(self.start_mouse_x - self.mouse_x) > DRAG_THRESHOLD
or abs(self.start_mouse_y - self.mouse_y) > DRAG_THRESHOLD
delta_x > DEFAULT_DRAG_THRESHOLD or delta_y > DEFAULT_DRAG_THRESHOLD
):
self.drag = True
if self.drag and ui_props.assetbar_on:
# turn off asset bar here, shout start again after finishing drag drop.
if self.drag and ui_props.assetbar_on and not self.closed_assetbar:
# turn off asset bar here; reopen after placement when we actually dragged
ui_props.turn_off = True
if (
event.type == "ESC"
or not ui.mouse_in_region(context.region, self.mouse_x, self.mouse_y)
) and (not self.drag or self.steps < DEAD_ZONE):
# this case is for canceling from inside popup card when there's an escape attempt to close the window
return {"PASS_THROUGH"}
self.closed_assetbar = True
if event.type in {"RIGHTMOUSE", "ESC"}:
# Restore original selection if we changed it
self.restore_original_selection()
self.handlers_remove()
bpy.context.window.cursor_modal_restore()
ui_props.dragging = False
bpy.ops.view3d.blenderkit_asset_bar_widget(
"INVOKE_REGION_WIN", do_search=False
)
self._cleanup_drag(ui_props, cls, reopen_assetbar=True)
return {"CANCELLED"}
self.steps += 1
if (
event.type == "ESC"
or not ui.mouse_in_region(context.region, self.mouse_x, self.mouse_y)
) and (not self.drag or self.steps < 5):
# this case is for canceling from inside popup card when there's an escape attempt to close the window
return {"PASS_THROUGH"}
sprops = bpy.context.window_manager.blenderkit_models
if event.type == "WHEELUPMOUSE":
sprops.offset_rotation_amount += sprops.offset_rotation_step
elif event.type == "WHEELDOWNMOUSE":
sprops.offset_rotation_amount -= sprops.offset_rotation_step
if (
event.type == "MOUSEMOVE"
or event.type == "WHEELUPMOUSE"
or event.type == "WHEELDOWNMOUSE"
):
if event.type in {
"MOUSEMOVE",
"INBETWEEN_MOUSEMOVE",
"WHEELUPMOUSE",
"WHEELDOWNMOUSE",
}:
# sometimes active area or region can be None, so we need to check for that
if self.active_area is None or self.active_region is None:
return {"RUNNING_MODAL"}
@@ -1904,11 +1864,7 @@ class AssetDragOperator(bpy.types.Operator):
self.has_hit = False
# Only perform raycasting in 3D view areas
if (
self.active_region
and self.active_area
and self.active_area.type == "VIEW_3D"
):
if current_area_type == "VIEW_3D":
# prefetch the drag active object info
self.drag_raycast_3d_view(
context,
@@ -1921,21 +1877,15 @@ class AssetDragOperator(bpy.types.Operator):
if self.asset_data["assetType"] in ["model", "printable"]:
self.snapped_bbox_min = Vector(self.asset_data["bbox_min"])
self.snapped_bbox_max = Vector(self.asset_data["bbox_max"])
elif self.active_area.type != "VIEW_3D":
elif current_area_type != "VIEW_3D":
# In outliner, don't do raycasting, but keep has_hit to avoid errors
self.has_hit = False
if event.type == "LEFTMOUSE" and event.value == "RELEASE":
self.mouse_release(context) # Pass context here
self.handlers_remove()
bpy.context.window.cursor_modal_restore()
bpy.ops.view3d.run_assetbar_fix_context(keep_running=True, do_search=False)
ui_props.dragging = False
self.mouse_release(context)
self._cleanup_drag(ui_props, cls)
return {"FINISHED"}
self.steps += 1
# pass event to assetbar so it can close itself
if ui_props.assetbar_on and ui_props.turn_off:
return {"PASS_THROUGH"}
@@ -1946,9 +1896,29 @@ class AssetDragOperator(bpy.types.Operator):
# Before registering callbacks, check for canceling situations: login and localdir popups, sculpt popup/switch
sr = search.get_search_results()
ui_props = bpy.context.window_manager.blenderkitUI
if ui_props.dragging:
return {"CANCELLED"}
cls = type(self)
if cls.active_operator_id is not None and cls.active_operator_id != id(self):
return {"CANCELLED"}
# Acquire drag lock immediately so concurrent invoke paths cannot race while this invoke initializes.
ui_props.dragging = True
cls.active_operator_id = id(self)
self.closed_assetbar = False
# Use the asset_search_index parameter passed to the operator, not the global ui_props.active_index
# This is critical for multi-window support where active_index is shared across windows
self.asset_data = dict(sr[self.asset_search_index])
# Initialize drag-start coordinates immediately in invoke. If mouse-move
# events are sparse (or arrive late), we still compute threshold against
# the true click/press origin instead of first modal tick.
self.mouse_screen_x = int(context.window.x + event.mouse_x)
self.mouse_screen_y = int(context.window.y + event.mouse_y)
self.start_mouse_x = self.mouse_screen_x
self.start_mouse_y = self.mouse_screen_y
# Author assets should not be dragged, cancel immediately
if self.asset_data.get("assetType") == "author":
return {"CANCELLED"}
# add-ons
if self.asset_data.get("assetType") == "addon" and not self.asset_data.get(
"canDownload"
@@ -1959,6 +1929,8 @@ class AssetDragOperator(bpy.types.Operator):
bpy.ops.wm.blenderkit_url_dialog(
"INVOKE_REGION_WIN", url=url, message=message, link_text=link_text
)
ui_props.dragging = False
cls.active_operator_id = None
return {"CANCELLED"}
if not self.asset_data.get("canDownload"):
@@ -1969,6 +1941,8 @@ class AssetDragOperator(bpy.types.Operator):
bpy.ops.wm.blenderkit_url_dialog(
"INVOKE_REGION_WIN", url=url, message=message, link_text=link_text
)
ui_props.dragging = False
cls.active_operator_id = None
return {"CANCELLED"}
prefs = bpy.context.preferences.addons[__package__].preferences
@@ -1982,26 +1956,31 @@ class AssetDragOperator(bpy.types.Operator):
bpy.ops.wm.blenderkit_url_dialog(
"INVOKE_REGION_WIN", url=url, message=message, link_text=link_text
)
ui_props.dragging = False
cls.active_operator_id = None
return {"CANCELLED"}
if self.asset_data.get("assetType") == "brush":
if not (context.sculpt_object or context.image_paint_object):
# either switch to sculpt mode and layout automatically or show a popup message
if context.active_object and context.active_object.type == "MESH":
bpy.ops.object.mode_set(mode="SCULPT")
self.mouse_release(context) # does the main job with assets
if self.asset_data.get("assetType") == "brush" and not (
context.sculpt_object or context.image_paint_object
):
# either switch to sculpt mode and layout automatically or show a popup message
if context.active_object and context.active_object.type == "MESH":
bpy.ops.object.mode_set(mode="SCULPT")
self.mouse_release(context) # does the main job with assets
if bpy.data.workspaces.get("Sculpting") is not None:
bpy.context.window.workspace = bpy.data.workspaces["Sculpting"]
reports.add_report(
"Automatically switched to sculpt mode to use brushes."
)
else:
message = "Select a mesh and switch to sculpt or image paint modes to use the brushes."
bpy.ops.wm.blenderkit_popup_dialog(
"INVOKE_REGION_WIN", message=message, width=500
)
return {"CANCELLED"}
if bpy.data.workspaces.get("Sculpting") is not None:
bpy.context.window.workspace = bpy.data.workspaces["Sculpting"]
reports.add_report(
"Automatically switched to sculpt mode to use brushes."
)
else:
message = "Select a mesh and switch to sculpt or image paint modes to use the brushes."
bpy.ops.wm.blenderkit_popup_dialog(
"INVOKE_REGION_WIN", message=message, width=500
)
ui_props.dragging = False
cls.active_operator_id = None
return {"CANCELLED"}
# the arguments we pass the the callback
args = (self, context)
@@ -2044,48 +2023,25 @@ class AssetDragOperator(bpy.types.Operator):
# but if RNA Struct fails we are not longer able to remove it, so we log an error and store None
self._handlers_universal[space_type] = handler
except (AttributeError, TypeError) as e:
bk_logger.error(f"Could not register handler for {space_type}: {e}")
bk_logger.error("Could not register handler for %s: %s", space_type, e)
self._handlers_universal[space_type] = None
self.mouse_x = 0
self.mouse_y = 0
self.mouse_screen_x = 0
self.mouse_screen_y = 0
self.steps = 0
self.mouse_screen_x = self.start_mouse_x
self.mouse_screen_y = self.start_mouse_y
# Store the initial active region pointer
self.active_region_pointer = context.region.as_pointer()
# Initialize outliner tracking variables
self.hovered_outliner_element = None
self.outliner_area = None
self.outliner_region = None
self.orig_selected_objects = None
self.orig_active_object = None
self.orig_active_collection = None
self.prev_area_type = context.area.type # Track previous area type
# Initialize node editor tracking
self.in_node_editor = False
self.node_editor_type = None
self.shift_pressed = False
# Initialize has_hit to False, and set other 3D properties
# We'll only use these in 3D views, not in outliner
self.has_hit = False
self.snapped_location = (0, 0, 0)
self.snapped_normal = (0, 0, 1)
self.snapped_rotation = (0, 0, 0)
self.face_index = 0
self.matrix = None
self.iname = f".{self.asset_data['thumbnail_small']}"
self.iname = (self.iname[:63]) if len(self.iname) > 63 else self.iname
bpy.context.window.cursor_modal_set("NONE")
bpy.context.window.cursor_modal_restore()
ui_props = bpy.context.window_manager.blenderkitUI
ui_props.dragging = True
self.drag = False
self.steps = 0
context.window_manager.modal_handler_add(self)
return {"RUNNING_MODAL"}
@@ -2245,7 +2201,7 @@ class DownloadGizmoOperator(BL_UI_OT_draw_operator):
self.button_close.set_image_size((button_size, button_size))
self.button_close.set_image_position((0, 0))
directory = paths.get_temp_dir("%s_search" % self.asset_data["assetType"])
directory = paths.get_temp_dir(f"{self.asset_data['assetType']}_search")
thumbnail_path = os.path.join(directory, self.asset_data["thumbnail_small"])
self.image.set_image(thumbnail_path)
@@ -2296,7 +2252,10 @@ class DownloadGizmoOperator(BL_UI_OT_draw_operator):
bk_logger.debug("unregistering class %s", cls)
instances_copy = cls.instances.copy()
for instance in instances_copy:
bk_logger.debug("- class instance %s", instance)
try:
bk_logger.debug("- class instance %s", instance)
except ReferenceError:
bk_logger.debug("- class instance <deleted>")
try:
instance.unregister_handlers(instance.context)
except Exception as e:
@@ -2407,37 +2366,6 @@ def create_material_mapping(obj, temp_mesh):
return mapping
def add_set_material_node(tree):
"""Add a Set Material node at the end of the node tree"""
# Find output node
output_node = None
for node in tree.nodes:
if node.type == "GROUP_OUTPUT":
output_node = node
break
if output_node:
# Create Set Material node
set_mat_node = tree.nodes.new("GeometryNodeSetMaterial")
# Position it before output
set_mat_node.location = (output_node.location.x - 200, output_node.location.y)
# Connect nodes
last_geometry_socket = None
for source in output_node.inputs:
if source.type == "GEOMETRY":
if source.is_linked:
last_geometry_socket = source.links[0].from_socket
break
if last_geometry_socket:
tree.links.new(last_geometry_socket, set_mat_node.inputs["Geometry"])
tree.links.new(set_mat_node.outputs["Geometry"], output_node.inputs[0])
return set_mat_node
return None
classes = (
AssetDragOperator,
DownloadGizmoOperator,
@@ -30,6 +30,13 @@ RENDER_OBTYPES = ["MESH", "CURVE", "SURFACE", "METABALL", "TEXT"]
_BLE_5_PLUS = bpy.app.version >= (5, 0, 0)
def _image_tile_count(image) -> int:
"""Return the number of tiles for a UDIM image, 1 for regular images."""
if getattr(image, "source", "") == "TILED" and hasattr(image, "tiles"):
return max(1, len(image.tiles))
return 1
def check_material(props, mat):
e = bpy.context.scene.render.engine
shaders = []
@@ -62,7 +69,10 @@ def check_material(props, mat):
if n.image not in textures:
textures.append(n.image)
props.texture_count += 1
total_pixels += n.image.size[0] * n.image.size[1]
# Multiply per-tile pixel area by tile count so that
# UDIM images (source='TILED') report the correct total.
n_tiles = _image_tile_count(n.image)
total_pixels += n_tiles * n.image.size[0] * n.image.size[1]
maxres = max(n.image.size[0], n.image.size[1])
props.texture_resolution_max = max(
@@ -138,7 +148,10 @@ def check_render_engine(props, obs):
textures.append(n.image)
props.texture_count += 1
total_pixels += n.image.size[0] * n.image.size[1]
# Multiply per-tile pixel area by tile count so that
# UDIM images (source='TILED') report the correct total.
n_tiles = _image_tile_count(n.image)
total_pixels += n_tiles * n.image.size[0] * n.image.size[1]
maxres = max(n.image.size[0], n.image.size[1])
props.texture_resolution_max = max(
@@ -344,7 +357,7 @@ def check_meshprops(props, obs):
props.manifold = manifold
def countObs(props, obs):
def count_objects(props, obs):
ob_types = {}
count = len(obs)
for ob in obs:
@@ -425,7 +438,21 @@ def get_autotags():
check_anim(props, obs)
check_meshprops(props, obs)
check_modifiers(props, obs)
countObs(props, obs)
count_objects(props, obs)
elif ui.asset_type == "SCENE":
scene = bpy.context.scene
props = scene.blenderkit
if props.name == "":
props.name = scene.name
dim, bbox_min, bbox_max = utils.get_scene_dimensions(scene)
props.dimensions = dim
props.bbox_min = bbox_min
props.bbox_max = bbox_max
scene_objects = list(scene.objects)
check_meshprops(props, scene_objects)
count_objects(props, scene_objects)
elif ui.asset_type == "MATERIAL":
# reset some properties here, because they might not get re-filled at all when they aren't needed anymore.
@@ -434,6 +461,7 @@ def get_autotags():
props.texture_resolution_max = 0
props.texture_resolution_min = 0
check_material(props, mat)
elif ui.asset_type == "HDR":
# reset some properties here, because they might not get re-filled at all when they aren't needed anymore.
+218 -17
View File
@@ -32,6 +32,7 @@ from bpy.props import (
FloatProperty,
IntProperty,
FloatVectorProperty,
StringProperty,
)
from . import bg_blender, global_vars, paths, tasks_queue, utils, upload, search
@@ -81,16 +82,31 @@ def get_texture_ui(tpath, iname):
return tex
def check_thumbnail(props, imgpath):
def check_thumbnail(
props,
imgpath,
*,
texture_name="upload_preview",
flag_attr="has_thumbnail",
state_attr="thumbnail_generating_state",
):
"""Reload a thumbnail preview and update status attributes."""
def _set_prop(attr_name, value):
if attr_name and hasattr(props, attr_name):
setattr(props, attr_name, value)
# TODO implement check if the file exists, if size is correct etc. needs some care
if imgpath == "":
props.has_thumbnail = False
_set_prop(flag_attr, False)
return None
img = utils.get_hidden_image(imgpath, "upload_preview", force_reload=True)
img = utils.get_hidden_image(imgpath, texture_name, force_reload=True)
if img is not None: # and img.size[0] == img.size[1] and img.size[0] >= 512 and (
# img.file_format == 'JPEG' or img.file_format == 'PNG'):
props.has_thumbnail = True
props.thumbnail_generating_state = ""
_set_prop(flag_attr, True)
if hasattr(props, "THUMBNAIL_GENERATING_STATE"):
props.THUMBNAIL_GENERATING_STATE = ""
_set_prop(state_attr, "")
utils.get_hidden_texture(img.name)
# pcoll = icons.icon_collections["previews"]
@@ -98,7 +114,7 @@ def check_thumbnail(props, imgpath):
return img
else:
props.has_thumbnail = False
_set_prop(flag_attr, False)
output = ""
if (
img is None
@@ -115,7 +131,7 @@ def check_thumbnail(props, imgpath):
# output += 'image too small, should be at least 512x512\n'
# if img.file_format != 'JPEG' or img.file_format != 'PNG':
# output += 'image has to be a jpeg or png'
props.thumbnail_generating_state = output
_set_prop(state_attr, output)
def update_upload_model_preview(self, context):
@@ -126,6 +142,20 @@ def update_upload_model_preview(self, context):
check_thumbnail(props, imgpath)
def update_wire_thumbnail_preview(self, context):
ob = utils.get_active_model()
if ob is not None:
props = ob.blenderkit
imgpath = props.wire_thumbnail
check_thumbnail(
props,
imgpath,
texture_name=".upload_preview_wire",
flag_attr=None,
state_attr="wire_thumbnail_generating_state",
)
def update_upload_scene_preview(self, context):
s = bpy.context.scene
props = s.blenderkit
@@ -149,7 +179,7 @@ def update_upload_brush_preview(self, context):
brush = utils.get_active_brush()
if brush is not None:
props = brush.blenderkit
imgpath = bpy.path.abspath(brush.icon_filepath)
imgpath = props.thumbnail
check_thumbnail(props, imgpath)
@@ -182,9 +212,26 @@ def start_model_thumbnailer(
):
"""Start Blender in background and render the thumbnail."""
SCRIPT_NAME = "autothumb_model_bg.py"
thumbnail_upload_type = (
json_args.get("thumbnail_upload_type") if json_args else None
)
is_wire_upload = thumbnail_upload_type == "wire_thumbnail"
computing_attr = (
"is_generating_wire_thumbnail" if is_wire_upload else "is_generating_thumbnail"
)
state_attr = (
"wire_thumbnail_generating_state"
if is_wire_upload
else "thumbnail_generating_state"
)
def _set_prop(attr_name, value):
if props and hasattr(props, attr_name):
setattr(props, attr_name, value)
if props:
props.is_generating_thumbnail = True
props.thumbnail_generating_state = "Saving .blend file"
_set_prop(computing_attr, True)
_set_prop(state_attr, "Saving .blend file")
datafile = os.path.join(json_args["tempdir"], BLENDERKIT_EXPORT_DATA_FILE)
user_preferences = bpy.context.preferences.addons[__package__].preferences
@@ -194,6 +241,10 @@ def start_model_thumbnailer(
"cycles"
].preferences.compute_device_type
json_args["thumbnail_disable_subdivision"] = (
user_preferences.thumbnail_disable_subdivision
)
try:
with open(datafile, "w", encoding="utf-8") as s:
json.dump(json_args, s, ensure_ascii=False, indent=4)
@@ -228,9 +279,10 @@ def start_model_thumbnailer(
env=env,
)
bk_logger.info("Started Blender executing %s on file %s", SCRIPT_NAME, datafile)
eval_path_computing = f"bpy.data.objects['{json_args['asset_name']}'].blenderkit.is_generating_thumbnail"
eval_path_state = f"bpy.data.objects['{json_args['asset_name']}'].blenderkit.thumbnail_generating_state"
eval_path = f"bpy.data.objects['{json_args['asset_name']}']"
eval_path_base = f"bpy.data.objects['{json_args['asset_name']}']"
eval_path = eval_path_base
eval_path_computing = f"{eval_path_base}.blenderkit.{computing_attr}"
eval_path_state = f"{eval_path_base}.blenderkit.{state_attr}"
name = f"{json_args['asset_name']} thumbnailer"
bg_blender.add_bg_process(
name=name,
@@ -241,7 +293,7 @@ def start_model_thumbnailer(
process=proc,
)
if props:
props.thumbnail_generating_state = "Started Blender instance"
_set_prop(state_attr, "Started Blender instance")
if wait:
while proc.poll() is None:
@@ -449,6 +501,132 @@ class GenerateThumbnailOperator(bpy.types.Operator):
return wm.invoke_props_dialog(self, width=400)
class GenerateWireframeThumbnailOperator(bpy.types.Operator):
"""Generate Cycles wireframe thumbnail for model assets"""
bl_idname = "object.blenderkit_generate_wireframe_thumbnail"
bl_label = "BlenderKit Wireframe Thumbnail Generator"
bl_options = {"REGISTER", "INTERNAL"}
@classmethod
def poll(cls, context):
return bpy.context.view_layer.objects.active is not None
def draw(self, context):
ui_props = bpy.context.window_manager.blenderkitUI
asset_type = ui_props.asset_type
ob = utils.get_active_model()
props = ob.blenderkit
layout = self.layout
layout.label(text="thumbnailer settings")
layout.prop(props, "thumbnail_background_lightness")
# for printable models
if asset_type == "PRINTABLE":
layout.prop(props, "thumbnail_material_color")
layout.prop(props, "thumbnail_angle")
layout.prop(props, "thumbnail_snap_to")
layout.prop(props, "thumbnail_samples")
layout.prop(props, "thumbnail_resolution")
layout.prop(props, "thumbnail_denoising")
preferences = bpy.context.preferences.addons[__package__].preferences
layout.prop(preferences, "thumbnail_use_gpu")
# TODO: wireframe
# layout.prop(preferences, "thumbnail_disable_subdivision")
def execute(self, context):
if not upload.wire_thumbnail_upload_enabled():
self.report({"ERROR"}, "Wireframe thumbnail uploads are disabled.")
return {"CANCELLED"}
asset = utils.get_active_model()
asset.blenderkit.is_generating_wire_thumbnail = True
asset.blenderkit.wire_thumbnail_generating_state = "starting blender instance"
tempdir = tempfile.mkdtemp()
ext = ".blend"
filepath = os.path.join(tempdir, "thumbnailer_wf_blenderkit" + ext)
path_can_be_relative = True
thumb_dir = os.path.dirname(bpy.data.filepath)
if thumb_dir == "":
thumb_dir = tempdir
path_can_be_relative = False
an_slug = paths.slugify(asset.name)
# add suffix to distinguish from regular thumbnail
an_slug += "_wf"
thumb_path = os.path.join(thumb_dir, an_slug)
if path_can_be_relative:
rel_thumb_path = f"//{an_slug}"
else:
rel_thumb_path = thumb_path
i = 0
while os.path.isfile(thumb_path + ".jpg"):
thumb_name = f"{an_slug}_{str(i).zfill(4)}"
thumb_path = os.path.join(thumb_dir, thumb_name)
if path_can_be_relative:
rel_thumb_path = f"//{thumb_name}"
i += 1
bkit = asset.blenderkit
bkit.wire_thumbnail = rel_thumb_path + ".jpg"
bkit.wire_thumbnail_generating_state = "Saving .blend file"
# if this isn't here, blender crashes.
if bpy.app.version >= (3, 0, 0):
bpy.context.preferences.filepaths.file_preview_type = "NONE"
# save a copy of actual scene but don't interfere with the users models
bpy.ops.wm.save_as_mainfile(filepath=filepath, compress=False, copy=True)
# get all included objects
obs = utils.get_hierarchy(asset)
obnames = []
for ob in obs:
obnames.append(ob.name)
# asset type can be model or printable
ui_props = bpy.context.window_manager.blenderkitUI
asset_type = ui_props.asset_type
args_dict = {
"type": asset_type,
"asset_name": asset.name,
"filepath": filepath,
"thumbnail_path": thumb_path,
"tempdir": tempdir,
"thumbnail_render_type": "WIREFRAME",
"thumbnail_upload_type": "wire_thumbnail",
}
thumbnail_args = {
"type": asset_type,
"models": str(obnames),
"thumbnail_angle": bkit.thumbnail_angle,
"thumbnail_snap_to": bkit.thumbnail_snap_to,
"thumbnail_background_lightness": 0.2,
"thumbnail_material_color": (
bkit.thumbnail_material_color[0],
bkit.thumbnail_material_color[1],
bkit.thumbnail_material_color[2],
),
"thumbnail_resolution": bkit.thumbnail_resolution,
"thumbnail_samples": bkit.thumbnail_samples,
"thumbnail_denoising": bkit.thumbnail_denoising,
}
args_dict.update(thumbnail_args)
start_model_thumbnailer(
self, json_args=args_dict, props=asset.blenderkit, wait=False
)
return {"FINISHED"}
def invoke(self, context, event):
wm = context.window_manager
return wm.invoke_props_dialog(self, width=400)
class ReGenerateThumbnailOperator(bpy.types.Operator):
"""
Generate default thumbnail with Cycles renderer and upload it.
@@ -464,6 +642,12 @@ class ReGenerateThumbnailOperator(bpy.types.Operator):
name="Asset Index", description="asset index in search results", default=-1
)
asset_type: StringProperty( # type: ignore[valid-type]
name="Asset Type",
description="Asset type used for thumbnail generation",
default="",
)
render_locally: BoolProperty( # type: ignore[valid-type]
name="Render Locally",
description="Render thumbnail locally instead of using server-side rendering",
@@ -529,7 +713,12 @@ class ReGenerateThumbnailOperator(bpy.types.Operator):
layout.label(text="thumbnailer settings")
layout.prop(props, "thumbnail_background_lightness")
# for printable models
if self.asset_type == "PRINTABLE":
asset_type = (
getattr(self, "asset_type", "")
or getattr(self, "asset_data", {}).get("assetType", "")
or bpy.context.window_manager.blenderkitUI.asset_type
).upper()
if asset_type == "PRINTABLE":
layout.prop(props, "thumbnail_material_color")
layout.prop(props, "thumbnail_angle")
layout.prop(props, "thumbnail_snap_to")
@@ -545,6 +734,11 @@ class ReGenerateThumbnailOperator(bpy.types.Operator):
preferences = bpy.context.preferences.addons[__package__].preferences
# Ensure asset_type is set when execution is triggered directly.
ui_props = bpy.context.window_manager.blenderkitUI
if not getattr(self, "asset_type", ""):
self.asset_type = ui_props.asset_type
if not self.render_locally:
# Use server-side thumbnail regeneration
success = upload.mark_for_thumbnail(
@@ -575,8 +769,7 @@ class ReGenerateThumbnailOperator(bpy.types.Operator):
thumb_path = os.path.join(tempdir, an_slug)
# asset type can be model or printable
ui_props = bpy.context.window_manager.blenderkitUI
self.asset_type = ui_props.asset_type
self.asset_type = self.asset_type or ui_props.asset_type
args_dict = {
"type": self.asset_type,
"asset_name": self.asset_data["name"],
@@ -607,6 +800,12 @@ class ReGenerateThumbnailOperator(bpy.types.Operator):
history_step = search.get_active_history_step()
sr = history_step.get("search_results", [])
self.asset_data = sr[self.asset_index]
# Prepopulate asset_type so draw() can safely access it.
self.asset_type = (
self.asset_data.get("assetType", "")
if isinstance(self.asset_data, dict)
else ""
).upper() or bpy.context.window_manager.blenderkitUI.asset_type
return wm.invoke_props_dialog(self, width=400)
@@ -891,6 +1090,7 @@ class ReGenerateMaterialThumbnailOperator(bpy.types.Operator):
def register_thumbnailer():
bpy.utils.register_class(GenerateThumbnailOperator)
bpy.utils.register_class(ReGenerateThumbnailOperator)
bpy.utils.register_class(GenerateWireframeThumbnailOperator)
bpy.utils.register_class(GenerateMaterialThumbnailOperator)
bpy.utils.register_class(ReGenerateMaterialThumbnailOperator)
@@ -898,5 +1098,6 @@ def register_thumbnailer():
def unregister_thumbnailer():
bpy.utils.unregister_class(GenerateThumbnailOperator)
bpy.utils.unregister_class(ReGenerateThumbnailOperator)
bpy.utils.unregister_class(GenerateWireframeThumbnailOperator)
bpy.utils.unregister_class(GenerateMaterialThumbnailOperator)
bpy.utils.unregister_class(ReGenerateMaterialThumbnailOperator)
@@ -17,14 +17,14 @@
# ##### END GPL LICENSE BLOCK #####
# type: ignore
from __future__ import annotations
import json
import math
import os
import random
import colorsys
import sys
from traceback import print_exc
from typing import Any, Union
import bpy
@@ -36,47 +36,66 @@ def get_obnames(BLENDERKIT_EXPORT_DATA: str):
return obnames
def center_obs_for_thumbnail(obs):
s = bpy.context.scene
# obs = bpy.context.selected_objects
def center_objs_for_thumbnail(obs: list[Any]) -> None:
"""Center and scale objects for optimal thumbnail framing.
Steps:
1. Center objects in world space (handles parent-child hierarchy)
2. Adjust camera distance based on object bounds
3. Scale helper objects to fit the model in frame
Args:
obs: List of Blender objects to center and frame.
"""
scene = bpy.context.scene
parent = obs[0]
# Handle instanced collections (linked objects)
if parent.type == "EMPTY" and parent.instance_collection is not None:
obs = parent.instance_collection.objects[:]
# Get top-level parent
while parent.parent is not None:
parent = parent.parent
# reset parent rotation, so we see how it really snaps.
# Reset parent rotation for accurate snapping
parent.rotation_euler = (0, 0, 0)
parent.location = (0, 0, 0)
bpy.context.view_layer.update()
# Calculate bounding box in world space
minx, miny, minz, maxx, maxy, maxz = utils.get_bounds_worldspace(obs)
# Center object at world origin
cx = (maxx - minx) / 2 + minx
cy = (maxy - miny) / 2 + miny
for ob in s.collection.objects:
for ob in scene.collection.objects:
ob.select_set(False)
bpy.context.view_layer.objects.active = parent
# parent.location += mathutils.Vector((-cx, -cy, -minz))
parent.location = (-cx, -cy, 0)
camZ = s.camera.parent.parent
# camZ.location.z = (maxz - minz) / 2
camZ.location.z = (maxz) / 2
# Adjust camera position and scale based on object size
cam_z = scene.camera.parent.parent
cam_z.location.z = maxz / 2
# Calculate diagonal size of object for scaling
dx = maxx - minx
dy = maxy - miny
dz = maxz - minz
r = math.sqrt(dx * dx + dy * dy + dz * dz)
# Scale scene elements to fit object
scaler = bpy.context.view_layer.objects["scaler"]
scaler.scale = (r, r, r)
coef = 0.7
coef = 0.7 # Camera distance coefficient
r *= coef
camZ.scale = (r, r, r)
cam_z.scale = (r, r, r)
bpy.context.view_layer.update()
def render_thumbnails():
def render_thumbnails() -> None:
"""Render the current scene to a still image (no animation)."""
bpy.ops.render.render(write_still=True, animation=False)
@@ -112,17 +131,23 @@ def patch_imports(addon_module_name: str):
print(f"- Local repository {parts[1]} added")
def replace_materials(obs, material_name):
"""Replace all materials on objects with the specified material
def replace_materials(
obs: list[Any], material_name: str
) -> Union[bpy.types.Material, None]:
"""Replace all materials on the given objects with a wireframe material.
Args:
obs: List of objects to process
material_name: Name of the material to apply to all objects
obs: List of Blender objects to modify.
material_name: Name of the wireframe material to use.
"""
material = bpy.data.materials.get(material_name)
if not material:
# Create or get the wireframe material
if material_name in bpy.data.materials:
material = bpy.data.materials[material_name]
else:
bg_blender.progress(f"Material {material_name} not found")
return
# Assign the wireframe material to all objects
for ob in obs:
if ob.type == "MESH":
# Clear all material slots and add the specified material
@@ -131,6 +156,57 @@ def replace_materials(obs, material_name):
return material
def disable_modifier(obs: list[Any], modifier_type: str) -> None:
"""Disable a specific type of modifier on all given objects.
Args:
obs: List of Blender objects to modify.
modifier_type: Type of the modifier to disable (e.g., 'SUBSURF').
"""
for ob in obs:
if ob.type == "MESH":
for mod in ob.modifiers:
if mod.type == modifier_type:
mod.show_viewport = False
mod.show_render = False
# disable only first found
break
def _str_to_color(s: str) -> Union[tuple[float, float, float], None]:
"""Convert a color string to an RGB tuple.
Args:
s: Color string in the format "#RRGGBB" or "R,G,B".
Returns:
A tuple of (R, G, B) values as floats in the range [0.0, 1.0], or None.
"""
hex_size = 7 # e.g. "#RRGGBB"
rgb_size = 5 # e.g. "R,G,B"
rgb_count = 3
s = s.strip()
if s.startswith("#") and len(s) == hex_size:
r = int(s[1:3], 16) / 255.0
g = int(s[3:5], 16) / 255.0
b = int(s[5:7], 16) / 255.0
return (r, g, b)
if len(s) == rgb_size:
parts = s.split(",")
if len(parts) == rgb_count:
try:
r = float(parts[0].strip())
g = float(parts[1].strip())
b = float(parts[2].strip())
except ValueError:
pass
else:
return (r, g, b)
# Default to None
return None
if __name__ == "__main__":
try:
# args order must match the order in blenderkit/autothumb.py:get_thumbnailer_args()!
@@ -144,6 +220,7 @@ if __name__ == "__main__":
with open(BLENDERKIT_EXPORT_DATA, "r", encoding="utf-8") as s:
data = json.load(s)
thumbnail_use_gpu = data.get("thumbnail_use_gpu")
thumbnail_disable_subdivision = data.get("thumbnail_disable_subdivision", False)
if data.get("do_download"):
# if this isn't here, blender crashes.
@@ -195,7 +272,7 @@ if __name__ == "__main__":
}
bpy.context.scene.camera = bpy.data.objects[camdict[data["thumbnail_snap_to"]]]
center_obs_for_thumbnail(allobs)
center_objs_for_thumbnail(allobs)
bpy.context.scene.render.filepath = data["thumbnail_path"]
if thumbnail_use_gpu is True:
bpy.context.scene.cycles.device = "GPU"
@@ -214,8 +291,8 @@ if __name__ == "__main__":
"SIDE": 4,
"TOP": 5,
}
s = bpy.context.scene
s.frame_set(fdict[data["thumbnail_angle"]])
scene = bpy.context.scene
scene.frame_set(fdict[data["thumbnail_angle"]])
snapdict = {
"GROUND": "Ground",
@@ -262,12 +339,19 @@ if __name__ == "__main__":
1 - random_color[2],
1,
)
# disable subdivision for thumbnail rendering if needed
if thumbnail_disable_subdivision:
disable_modifier(allobs, "SUBSURF")
# replace material if we need to render wireframe thumbnail
if data.get("thumbnail_render_type") == "WIREFRAME":
replace_materials(allobs, "bkit wireframe")
bpy.data.materials["bkit background"].node_tree.nodes["Value"].outputs[
"Value"
].default_value = data["thumbnail_background_lightness"]
s.cycles.samples = data["thumbnail_samples"]
scene.cycles.samples = data["thumbnail_samples"]
bpy.context.view_layer.cycles.use_denoising = data["thumbnail_denoising"]
bpy.context.view_layer.update()
@@ -297,14 +381,17 @@ if __name__ == "__main__":
)
sys.exit(0)
# get sub type if we are not generating for main beauty thumbnail
filetype = "thumbnail"
if data.get("thumbnail_upload_type"):
filetype = data["thumbnail_upload_type"].lower()
bg_blender.progress("uploading thumbnail")
fpath = data["thumbnail_path"] + ".jpg"
ok = client_lib.complete_upload_file_blocking(
api_key=BLENDERKIT_EXPORT_API_KEY,
asset_id=data["asset_data"]["id"],
filepath=fpath,
filetype=f"thumbnail",
filetype=filetype,
fileindex=0,
)
if not ok:
@@ -25,7 +25,15 @@ import threading
import bpy
from bpy.props import EnumProperty
from . import utils
from . import utils, reports
def _safe_eval_target(eval_path: str, name: str):
"""Try resolving eval_path; fallback to bpy.data.objects by name; return None if missing."""
try:
return eval(eval_path)
except Exception:
return bpy.data.objects.get(name) if name else None
bk_logger = logging.getLogger(__name__)
@@ -62,8 +70,9 @@ class ThreadCom: # object passed to threads to read background process stdout i
def threadread(tcom: ThreadCom):
"""reads stdout of background process.
this threads basically waits for a stdout line to come in,
"""Reads stdout of background process.
This thread basically waits for a stdout line to come in,
fills the data, dies."""
found = False
while not found:
@@ -74,7 +83,12 @@ def threadread(tcom: ThreadCom):
# ignore empty lines
if inline.strip() == "":
continue
bk_logger.info(inline.strip())
# Background Blender already formats logs; strip a leading emoji/prefix to avoid double branding.
line = inline.strip()
line = re.sub(
r"^(?:[🐞ℹ️⚠️❌🔥]\s*)?blenderkit:\s*", "", line, flags=re.IGNORECASE
)
bk_logger.info(line)
progress = re.findall(r"progress\{(.*?)\}", inline)
if len(progress) > 0:
if type(progress[0]) == int or type(progress[0]) == float:
@@ -116,8 +130,10 @@ def progress(text, n=None):
sys.stdout.write(output)
sys.stdout.flush()
except Exception as e:
print("background progress reporting race condition")
print(e)
bk_logger.exception(
"background progress reporting race condition", exc_info=False
)
bk_logger.error(f"Error details: {e}")
# @bpy.app.handlers.persistent
@@ -140,10 +156,15 @@ def bg_update():
for p in remove_processes:
bk_logger.info(str(p[1].outtext))
estring = p[1].eval_path_computing + " = False"
try:
exec(estring)
except Exception as e:
bk_logger.error(f"Exception executing eval_path_computing: {e}")
target = _safe_eval_target(p[1].eval_path, p[1].name)
if target is not None:
try:
exec(estring)
except Exception as e:
bk_logger.exception(
"Exception executing eval_path_computing.", exc_info=False
)
bk_logger.error(f"Error details: {e}")
bg_processes.remove(p)
# Parse process output
@@ -162,7 +183,7 @@ def bg_update():
tcom.outtext = ""
text = tcom.lasttext.replace("'", "") # noqa: F841 needed in exec()
estring = tcom.eval_path_state + " = text"
# print(tcom.lasttext)
if "finished successfully" in tcom.lasttext:
bk_logger.info(str(tcom.lasttext))
bg_processes.remove(p)
@@ -174,10 +195,20 @@ def bg_update():
readthread.start()
p[0] = readthread
if estring:
try:
exec(estring)
except Exception as e:
print(f"Exception while reading from background process: {e}")
target = _safe_eval_target(tcom.eval_path, tcom.name)
if target is not None:
try:
exec(estring)
except Exception as e:
bk_logger.exception(
"Exception while reading from background process.",
exc_info=False,
)
bk_logger.error(f"Error details: {e}")
else:
bk_logger.debug(
"Skipping state update; target missing for %s", tcom.name
)
# if len(bg_processes) == 0:
# bpy.app.timers.unregister(bg_update)
@@ -235,6 +266,8 @@ class KillBgProcess(bpy.types.Operator):
props.uploading = False
if self.process_type == "THUMBNAILER":
props.is_generating_thumbnail = False
if hasattr(props, "is_generating_wire_thumbnail"):
props.is_generating_wire_thumbnail = False
# print('killing', self.process_source, self.process_type)
# then go kill the process. this wasn't working for unsetting props and that was the reason for changing to the method above.
@@ -244,36 +277,43 @@ class KillBgProcess(bpy.types.Operator):
tcom = p[1]
# print(tcom.process_type, self.process_type)
if tcom.process_type == self.process_type:
source = eval(tcom.eval_path)
source = _safe_eval_target(tcom.eval_path, tcom.name)
kill = False
# TODO HDR - add killing of process
if source.bl_rna.name == "Object" and self.process_source == "MODEL":
if source.name == bpy.context.active_object.name:
kill = True
if source.bl_rna.name == "Scene" and self.process_source == "SCENE":
if source.name == bpy.context.scene.name:
kill = True
if source.bl_rna.name == "Image" and self.process_source == "HDR":
ui_props = bpy.context.window_manager.blenderkitUI
if source.name == ui_props.hdr_upload_image.name:
kill = False
if source is not None:
if (
source.bl_rna.name == "Object"
and self.process_source == "MODEL"
):
if source.name == bpy.context.active_object.name:
kill = True
if source.bl_rna.name == "Scene" and self.process_source == "SCENE":
if source.name == bpy.context.scene.name:
kill = True
if source.bl_rna.name == "Image" and self.process_source == "HDR":
ui_props = bpy.context.window_manager.blenderkitUI
if source.name == ui_props.hdr_upload_image.name:
kill = False
if (
source.bl_rna.name == "Material"
and self.process_source == "MATERIAL"
):
if source.name == bpy.context.active_object.active_material.name:
kill = True
if source.bl_rna.name == "Brush" and self.process_source == "BRUSH":
brush = utils.get_active_brush()
if brush is not None and source.name == brush.name:
kill = True
if (
source.bl_rna.name == "Object"
and self.process_source == "PRINTABLE"
):
if source.name == bpy.context.active_object.name:
kill = True
if (
source.bl_rna.name == "Material"
and self.process_source == "MATERIAL"
):
if (
source.name
== bpy.context.active_object.active_material.name
):
kill = True
if source.bl_rna.name == "Brush" and self.process_source == "BRUSH":
brush = utils.get_active_brush()
if brush is not None and source.name == brush.name:
kill = True
if (
source.bl_rna.name == "Object"
and self.process_source == "PRINTABLE"
):
if source.name == bpy.context.active_object.name:
kill = True
if kill:
estring = tcom.eval_path_computing + " = False"
exec(estring)
@@ -60,7 +60,7 @@ def delete_unfinished_file(file_name):
try:
os.remove(file_name)
except Exception as e:
bk_logger.error(f"{e}")
bk_logger.error("Could not delete unfinished file %s: %s", file_name, e)
asset_dir = os.path.dirname(file_name)
if len(os.listdir(asset_dir)) == 0:
os.rmdir(asset_dir)
@@ -23,13 +23,24 @@ import random
import secrets
import string
import time
import uuid
from urllib.parse import quote as urlquote
from webbrowser import open_new_tab
import bpy
from bpy.props import BoolProperty
from . import client_lib, client_tasks, datas, global_vars, reports, tasks_queue, utils
from . import (
client_lib,
client_tasks,
datas,
global_vars,
reports,
search_price,
tasks_queue,
utils,
)
if bpy.app.version >= (4, 2, 0):
from . import override_extension_draw
@@ -104,6 +115,7 @@ def clean_login_data():
preferences.api_key = ""
preferences.api_key_timeout = 0
global_vars.BKIT_PROFILE = datas.MineProfile()
search_price.clear_price_cache()
# Cleanup also the api key in the extensions repository setting and clean the cache
if bpy.app.version >= (4, 2, 0):
override_extension_draw.ensure_repository(api_key="")
@@ -128,14 +140,19 @@ def login(signup: bool) -> None:
code_verifier, code_challenge = generate_pkce_pair()
state = secrets.token_urlsafe()
client_lib.send_oauth_verification_data(code_verifier, state)
authorize_url = f"/o/authorize?client_id={CLIENT_ID}&response_type=code&state={state}&redirect_uri={redirect_URI}&code_challenge={code_challenge}&code_challenge_method=S256"
system_id = get_system_id()
authorize_url = (
f"/o/authorize?client_id={CLIENT_ID}&response_type=code&state={state}"
f"&redirect_uri={redirect_URI}&code_challenge={code_challenge}"
f"&code_challenge_method=S256&system_id={system_id}"
)
if signup:
authorize_url = urlquote(authorize_url)
authorize_url = f"{global_vars.SERVER}/accounts/register/?next={authorize_url}"
else:
authorize_url = f"{global_vars.SERVER}{authorize_url}"
ok = open_new_tab(authorize_url)
bk_logger.info(f"Login page in browser opened ({ok})")
bk_logger.info("Login page in browser opened (%s)", ok)
def generate_pkce_pair() -> tuple[str, str]:
@@ -151,12 +168,17 @@ def generate_pkce_pair() -> tuple[str, str]:
return code_verifier, code_challenge
def get_system_id() -> str:
return f"{uuid.getnode():015d}"
def write_tokens(auth_token, refresh_token, oauth_response):
preferences = bpy.context.preferences.addons[__package__].preferences
preferences.api_key_timeout = int(time.time() + oauth_response["expires_in"])
preferences.login_attempt = False
preferences.api_key_refresh = refresh_token
preferences.api_key = auth_token # triggers api_key update function
search_price.clear_price_cache()
# write token also to extensions repository setting and clear the cache
if bpy.app.version >= (4, 2, 0):
override_extension_draw.ensure_repository(api_key=auth_token)
@@ -20,7 +20,7 @@
bl_info = {
"name": "BlenderKit Online Asset Library",
"author": "Vilem Duha, Petr Dlouhy, A. Gajdosik",
"version": (3, 18, 0, 251121), # X.Y.Z.yymmdd
"version": (3, 18, 1, 251219), # X.Y.Z.yymmdd
"blender": (3, 0, 0),
"location": "View3D > Properties > BlenderKit",
"description": "Boost your workflow with drag&drop assets from the community driven library.",
@@ -28,7 +28,7 @@ bl_info = {
"tracker_url": "https://github.com/BlenderKit/blenderkit/issues",
"category": "3D View",
}
VERSION = (3, 18, 0, 251121)
VERSION = (3, 18, 1, 251219)
import logging
import random
@@ -242,7 +242,7 @@ engines = (
("CYCLES", "Cycles", "Blender Cycles"),
("EEVEE", "Eevee", "Blender eevee renderer"),
("EEEVE_NEXT", "Eevee Next", "Blender eevee renderer (new)"),
("OCTANE", "Octane", "Octane render enginge"),
("OCTANE", "Octane", "Octane render engine"),
("ARNOLD", "Arnold", "Arnold render engine"),
("V-RAY", "V-Ray", "V-Ray renderer"),
("UNREAL", "Unreal", "Unreal engine"),
@@ -267,6 +267,12 @@ mesh_poly_types = (
)
EXTRA_PATH_OPTIONS = {}
if bpy.app.version >= (4, 5, 0):
EXTRA_PATH_OPTIONS = {"options": {"PATH_SUPPORTS_BLEND_RELATIVE"}}
def udate_down_up(self, context):
"""Perform a search if results are empty."""
props = bpy.context.window_manager.blenderkitUI
@@ -461,12 +467,12 @@ class BlenderKitUIProps(PropertyGroup):
search_blender_version: BoolProperty(
name="Asset Blender Version",
description="Limit the assets by version of Blender (minimum, maximum) in which they were created. "
+ "Use maximum version limit to exclude incompatible assets from newer Blender versions than yours. Or set the minumum version to exclude assets created in quite old Blender versions",
+ "Use maximum version limit to exclude incompatible assets from newer Blender versions than yours. Or set the minimum version to exclude assets created in quite old Blender versions",
)
search_blender_version_min: StringProperty(
name="Minimal version (including, higher than or equal)",
default="0.0",
description="Limit the assets by minimum version of Blender in which they were created, including also the specified version and exluding all older versions from the search results. "
description="Limit the assets by minimum version of Blender in which they were created, including also the specified version and excluding all older versions from the search results. "
+ "Only assets created in HIGHER THAN OR EQUAL (>= min) minimum version will be shown. Use semantic versioning format: X.Y.Z.\n\n"
+ "E.g.: exclude all Blender 2 assets by specifying 3, 3.0, or 3.0.0. Assets created in 3.0 or higher will be shown",
update=search.search_update,
@@ -474,7 +480,7 @@ class BlenderKitUIProps(PropertyGroup):
search_blender_version_max: StringProperty(
name="Maximum version (excluding, lower than)",
default="5.99",
description="Limit the assets by maximum version of Blender in which they were created, exluding the specified version and all newer versions from the search results. "
description="Limit the assets by maximum version of Blender in which they were created, excluding the specified version and all newer versions from the search results. "
+ "Only assets created in LOWER THAN (< max) maximum version will be shown. Use semantic versioning format: X.Y.Z.\n\n"
+ "E.g.: exclude all Blender 4 assets by specifying 4, 4.0, or 4.0.0. Assets created in 3.6 and lower will be shown",
update=search.search_update,
@@ -580,7 +586,7 @@ class BlenderKitUIProps(PropertyGroup):
rating_ui_width: IntProperty(name="Rating UI Width", default=rating_ui_scale * 600)
rating_ui_height: IntProperty(
name="Rating UI Heightt", default=rating_ui_scale * 256
name="Rating UI Height", default=rating_ui_scale * 256
)
quality_stars_x: IntProperty(name="Rating UI Stars X", default=rating_ui_scale * 90)
@@ -1130,6 +1136,7 @@ class BlenderKitMaterialUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
subtype="FILE_PATH",
default="",
update=autothumb.update_upload_material_preview,
**EXTRA_PATH_OPTIONS,
)
is_generating_thumbnail: BoolProperty(
@@ -1213,13 +1220,14 @@ class BlenderKitBrushUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
)
class BlenderKitNodeGroulUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
class BlenderKitNodeGroupUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
thumbnail: StringProperty(
name="Thumbnail",
description="Thumbnail path - minimum 1024x1024 square .jpg\n"
"And make it beautiful!",
subtype="FILE_PATH",
default="",
**EXTRA_PATH_OPTIONS,
# update=autothumb.update_upload_model_preview,
)
# mode: EnumProperty(
@@ -1326,6 +1334,7 @@ class BlenderKitModelUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
subtype="FILE_PATH",
default="",
update=autothumb.update_upload_model_preview,
**EXTRA_PATH_OPTIONS,
)
thumbnail_background_lightness: FloatProperty(
@@ -1529,6 +1538,7 @@ class BlenderKitModelUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
description="Photo of the 3D printed object (JPG or PNG, preferred size is 1024x1024 or higher)",
subtype="FILE_PATH",
default="",
**EXTRA_PATH_OPTIONS,
)
photo_thumbnail_will_upload_on_website: BoolProperty(
name="I will upload photo on website",
@@ -1603,6 +1613,7 @@ class BlenderKitSceneUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
subtype="FILE_PATH",
default="",
update=autothumb.update_upload_scene_preview,
**EXTRA_PATH_OPTIONS,
)
use_design_year: BoolProperty(
@@ -1766,7 +1777,7 @@ class BlenderKitModelSearchProps(PropertyGroup, BlenderKitCommonSearchProps):
update=search.search_update,
)
search_design_year: BoolProperty(
name="Sesigned in Year",
name="Designed in Year",
description="When the object was approximately designed. \n"
"Useful for search of historical or future objects",
default=False,
@@ -1966,7 +1977,7 @@ def fix_subdir(self, context):
ui_panels.ui_message(
title="Fixed to relative path",
message="This path should be always realative.\n"
message="This path should be always relative.\n"
" It's a directory BlenderKit creates where your .blend is \n "
"and uses it for storing assets.",
)
@@ -1992,7 +2003,7 @@ class BlenderKitAddonPreferences(AddonPreferences):
preferences_lock: BoolProperty(
name="Preferences Locked",
description="When this is on, preferences will not be saved. Used for programatical changes of preferences",
description="When this is on, preferences will not be saved. Used for programmatic changes of preferences",
default=False,
)
@@ -2120,6 +2131,14 @@ class BlenderKitAddonPreferences(AddonPreferences):
update=utils.save_prefs,
)
# USE OF CLIPBOARD SCAN
use_clipboard_scan: BoolProperty(
name="Use Clipboard Scan",
description="Use the info from BlenderKit website clipboard for visual search",
default=True,
update=utils.save_prefs,
)
unpack_files: BoolProperty(
name="Unpack Files",
description="Unpack assets after download \n "
@@ -2233,8 +2252,8 @@ class BlenderKitAddonPreferences(AddonPreferences):
proxy_address: StringProperty(
name="Custom proxy address",
description="""Set custom HTTP proxy for HTTPS requests of add-on. This setting preceeds any system wide proxy settings. If left empty custom proxy will not be set.
description="""Set custom HTTP proxy for HTTPS requests of add-on. This setting precedes any system wide proxy settings. If left empty custom proxy will not be set.
If you use simple HTTP proxy, set in format http://ip:port, or http://username:password@ip:port if your HTTP proxy requires authentication (make sure to escape special characters like #$%:^&*() etc. in username and password). You have to specify the address with http:// prefix.
HTTPS proxies are not supported! We wait for support in Python 3.11 and in aiohttp module. You can specify the HTTPS proxy with https:// prefix for hacking around and development purposes, but functionality cannot be guaranteed.
@@ -2430,12 +2449,21 @@ In this case you should also set path to your system CA bundle containing proxy'
default="[]",
)
# EXPERIMENTAL AND DEBUG FEATURES CAN GO BELOW
ignore_env_for_thumbnails: BoolProperty(
name="Ignore ENVIRONMENT variables for thumbnails",
description="If enabled, we will not modify the system environment variables for background thumbnail rendering.",
default=False,
# do not save prefs here, it's experimental
options={"SKIP_SAVE"},
)
def draw(self, context):
layout = self.layout
if self.api_key.strip() == "":
ui_panels.draw_login_buttons(layout)
layout.label(
text="Sign up to bookmark your favourite assets. Get 200 MiB of private storage in Free Plan."
text="Sign up to bookmark your favorite assets. Get 200 MiB of private storage in Free Plan."
)
else:
layout.operator("wm.blenderkit_logout", text="Logout", icon="URL")
@@ -2470,8 +2498,9 @@ In this case you should also set path to your system CA bundle containing proxy'
gui_settings.prop(self, "show_VIEW3D_MT_blenderkit_model_properties")
gui_settings.prop(self, "tips_on_start")
gui_settings.prop(self, "announcements_on_start")
gui_settings.prop(self, "use_clipboard_scan")
# NETWORKING SETINGS
# NETWORKING SETTINGS
network_settings = layout.box()
network_settings.alignment = "EXPAND"
network_settings.label(text="Networking settings")
@@ -2487,6 +2516,14 @@ In this case you should also set path to your system CA bundle containing proxy'
# UPDATER SETTINGS
addon_updater_ops.update_settings_ui(self, context)
# EXPERIMENTAL SETTINGS
# only if experimental features enabled
if self.experimental_features:
experimental_settings = layout.box()
experimental_settings.alignment = "EXPAND"
experimental_settings.label(text="Experimental settings")
experimental_settings.prop(self, "ignore_env_for_thumbnails")
# RUNTIME INFO
globdir_op = layout.operator(
"wm.blenderkit_open_global_directory",
@@ -2535,7 +2572,7 @@ classes = (
BlenderKitBrushSearchProps,
BlenderKitBrushUploadProps,
BlenderKitGeoToolSearchProps,
BlenderKitNodeGroulUploadProps,
BlenderKitNodeGroupUploadProps,
BlenderKitAddonSearchProps,
)
@@ -2598,10 +2635,10 @@ def register():
type=BlenderKitGeoToolSearchProps
)
bpy.types.NodeGroup.blenderkit = PointerProperty( # for uploads, not now...
type=BlenderKitNodeGroulUploadProps
type=BlenderKitNodeGroupUploadProps
)
bpy.types.NodeTree.blenderkit = PointerProperty( # for uploads, not now...
type=BlenderKitNodeGroulUploadProps
type=BlenderKitNodeGroupUploadProps
)
bpy.types.WindowManager.blenderkit_addon = PointerProperty(
type=BlenderKitAddonSearchProps
@@ -97,7 +97,13 @@ def make_annotations(cls):
if bl_props:
if "__annotations__" not in cls.__dict__:
setattr(cls, "__annotations__", {})
annotations = cls.__dict__["__annotations__"]
try:
annotations = cls.__dict__["__annotations__"]
except KeyError:
# Fedora 43 bug workaround #1823
annotations = getattr(cls, "__annotations__")
for k, v in bl_props.items():
annotations[k] = v
delattr(cls, k)
@@ -42,15 +42,19 @@ def find_layer_collection(layer_collection, collection_name):
def append_brush(file_name, brushname=None, link=False, fake_user=True):
"""append a brush"""
brushes_before = bpy.data.brushes[:]
with bpy.data.libraries.load(file_name, link=link, relative=True) as (
data_from,
data_to,
):
for m in data_from.brushes:
if m == brushname or brushname is None:
if brushname is None or m.strip() == brushname.strip():
data_to.brushes = [m]
brushname = m
brush = bpy.data.brushes[brushname]
for b in bpy.data.brushes:
if b not in brushes_before:
brush = b
break
brush.use_fake_user = fake_user
return brush
@@ -93,8 +97,7 @@ def append_nodegroup(
data_to,
):
for g in data_from.node_groups:
print(g)
if g == nodegroupname or nodegroupname is None:
if nodegroupname is None or g.strip() == nodegroupname.strip():
data_to.node_groups = [g]
nodegroupname = g
nodegroup = bpy.data.node_groups[nodegroupname]
@@ -281,7 +284,7 @@ def append_material(file_name, matname=None, link=False, fake_user=True):
):
found = False
for m in data_from.materials:
if m == matname or matname is None:
if matname is None or m.strip() == matname.strip():
data_to.materials = [m]
matname = m
found = True
@@ -319,7 +322,7 @@ def append_scene(file_name, scenename=None, link=False, fake_user=False):
data_to,
):
for s in data_from.scenes:
if s == scenename or scenename is None:
if scenename is None or s.strip() == scenename.strip():
data_to.scenes = [s]
scenename = s
scene = bpy.data.scenes[scenename]
@@ -448,7 +451,7 @@ def link_collection(
data_to,
):
for col in data_from.collections:
if col == kwargs["name"]:
if col.strip() == kwargs["name"].strip():
data_to.collections = [col]
rotation = (0, 0, 0)
@@ -21,7 +21,7 @@ import math
import os
import re
import time
from typing import Any, Dict
from typing import Any, Dict, Union
import bpy
from bpy.props import BoolProperty, StringProperty
@@ -286,6 +286,12 @@ def modal_inside(self, context, event):
if self.check_ui_resized(context) or self.check_new_search_results(context):
self.update_assetbar_sizes(context)
self.update_assetbar_layout(context)
# also update tooltip visibility
# if there's less results and active button is not visible, hide tooltip
# happened only when e.g. running new search from web browser (copying assetbaseid to clipboard)
# fixes issue #1766
if self.active_index >= len(search.get_search_results()):
self.hide_tooltip()
self.scroll_update(
always=True
) # one extra update for scroll for correct redraw, updates all buttons
@@ -395,6 +401,17 @@ def get_tooltip_data(asset_data):
# Add pricing information
price_text = ""
price_color = colors.WHITE
price_background = (0, 0, 0, 0)
def format_price(value):
if value is None:
return ""
value_str = str(value).strip()
if not value_str:
return ""
if value_str.startswith("$"):
return value_str
return f"${value_str}"
# Check if asset is free or paid (works for all asset types)
is_free = asset_data.get("isFree", True)
@@ -403,23 +420,38 @@ def get_tooltip_data(asset_data):
if asset_data.get("assetType") == "addon":
# Get pricing info from extensions cache.
# Pricing info is shown only for add-ons.
base_price = asset_data.get("basePrice")
base_price = format_price(asset_data.get("basePrice"))
user_price = format_price(asset_data.get("userPrice"))
is_for_sale = asset_data.get("isForSale")
if is_for_sale and not can_download and base_price:
price_text = f"${base_price}"
price_color = colors.PURPLE
if utils.profile_is_validator():
segments = []
if user_price:
segments.append(f"User {user_price}")
if base_price:
segments.append(f"Base {base_price}")
price_text = " | ".join(segments)
price_background = colors.PURPLE_PRICE
elif is_for_sale and not can_download and user_price and base_price:
price_text = f"{user_price} (was {base_price})"
price_background = colors.PURPLE_PRICE
elif is_for_sale and not can_download and base_price:
price_text = base_price
price_background = colors.PURPLE_PRICE
elif not is_free and not is_for_sale:
price_text = "Full Plan"
price_color = colors.PURPLE
elif (
is_for_sale and can_download
): # purchased, but not yet downloaded, so we can't show price
price_text = f"Purchased (${base_price})"
price_color = colors.PURPLE
price_background = colors.ORANGE_FULL
elif is_for_sale and can_download:
price_text = "Purchased"
price_background = colors.PURPLE_PRICE
else:
price_text = "Free"
price_color = colors.GREEN_FREE
price_background = colors.GREEN_PRICE
tooltip_data = {
"aname": aname,
@@ -427,12 +459,15 @@ def get_tooltip_data(asset_data):
"quality": quality,
"price_text": price_text,
"price_color": price_color,
"price_background": price_background,
}
asset_data["tooltip_data"] = tooltip_data
def set_thumb_check(
element: BL_UI_Button, asset: Dict[str, Any], thumb_type: str = "thumbnail_small"
element: Union[BL_UI_Button, BL_UI_Image],
asset: Dict[str, Any],
thumb_type: str = "thumbnail_small",
) -> None:
"""Set image in case it is loaded in search results. Checks global_vars.DATA["images available"].
- if image download failed, it will be set to 'thumbnail_not_available.jpg'
@@ -457,6 +492,8 @@ def set_thumb_check(
class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
"""BlenderKit Asset Bar Operator."""
bl_idname = "view3d.blenderkit_asset_bar_widget"
bl_label = "BlenderKit asset bar refresh"
bl_description = "BlenderKit asset bar refresh"
@@ -508,8 +545,23 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
"""Initialize the tooltip panel and its widgets."""
self.tooltip_widgets = []
self.tooltip_scale = 1.0
self.tooltip_height = self.tooltip_size
self.tooltip_width = self.tooltip_size
# Fallbacks in case update_tooltip_size was not called yet
self.tooltip_width = getattr(self, "tooltip_width", self.tooltip_size)
image_height = getattr(self, "tooltip_image_height", self.tooltip_size)
info_height = getattr(
self,
"tooltip_info_height",
max(
int(image_height * self.bottom_panel_fraction),
self.asset_name_text_size * 3,
),
)
self.tooltip_image_height = image_height
self.tooltip_info_height = info_height
self.tooltip_height = self.tooltip_image_height + self.tooltip_info_height
self.labels_start = self.tooltip_image_height
# total_size = tooltip# + 2 * self.margin
self.tooltip_panel = BL_UI_Drag_Panel(
0, 0, self.tooltip_width, self.tooltip_height
@@ -520,20 +572,16 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
tooltip_image = BL_UI_Image(0, 0, 1, 1)
img_path = paths.get_addon_thumbnail_path("thumbnail_notready.jpg")
tooltip_image.set_image(img_path)
tooltip_image.set_image_size((self.tooltip_width, self.tooltip_height))
tooltip_image.set_image_size((self.tooltip_width, self.tooltip_image_height))
tooltip_image.set_image_position((0, 0))
tooltip_image.set_image_colorspace("")
self.tooltip_image = tooltip_image
self.tooltip_widgets.append(tooltip_image)
self.bottom_panel_fraction = 0.15
self.labels_start = self.tooltip_height * (1 - self.bottom_panel_fraction)
dark_panel = BL_UI_Widget(
0,
self.labels_start,
self.tooltip_width,
self.tooltip_height * self.bottom_panel_fraction,
self.tooltip_info_height,
)
dark_panel.bg_color = (0.0, 0.0, 0.0, 0.7)
self.tooltip_dark_panel = dark_panel
@@ -549,8 +597,9 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
self.asset_name = name_label
self.tooltip_widgets.append(name_label)
self.gravatar_size = int(
self.tooltip_height * self.bottom_panel_fraction - self.tooltip_margin
self.gravatar_size = max(
int(self.tooltip_info_height - 2 * self.tooltip_margin),
self.asset_name_text_size,
)
authors_name = self.new_text(
@@ -566,8 +615,8 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
self.tooltip_widgets.append(authors_name)
gravatar_image = BL_UI_Image(
self.tooltip_width - self.gravatar_size,
self.tooltip_height - self.gravatar_size,
self.tooltip_width - self.gravatar_size - self.tooltip_margin,
self.tooltip_height - self.gravatar_size - self.tooltip_margin,
1,
1,
)
@@ -575,8 +624,8 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
gravatar_image.set_image(img_path)
gravatar_image.set_image_size(
(
self.gravatar_size - 1 * self.tooltip_margin,
self.gravatar_size - 1 * self.tooltip_margin,
self.gravatar_size,
self.gravatar_size,
)
)
gravatar_image.set_image_position((0, 0))
@@ -617,7 +666,14 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
height=self.asset_name_text_size,
text_size=self.asset_name_text_size,
)
price_label.text_color = (1.0, 0.8, 0.2, 1.0) # Golden color for price
price_label.background = True
price_label.padding = (3, 4)
price_label.text_color = (
1.0,
0.8,
0.2,
1.0,
) # Golden color for price
self.tooltip_widgets.append(price_label)
self.price_label = price_label
@@ -728,14 +784,30 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
"""Calculate all important sizes for the tooltip"""
region = context.region
ui_props = bpy.context.window_manager.blenderkitUI
ui_scale = bpy.context.preferences.view.ui_scale
ui_scale = self.get_ui_scale()
base_panel_height = self.tooltip_base_size_pixels * (
1 + self.bottom_panel_fraction
)
if hasattr(self, "tooltip_panel"):
tooltip_y_offset = abs(region.height - self.tooltip_panel.y_screen)
tooltip_y_available_height = abs(
region.height - self.tooltip_panel.y_screen
)
# if tooltip is above, we need to reduce it's size if its y is out of region height
if self.tooltip_panel.y_screen <= 0:
tooltip_y_available_height = (
base_panel_height * ui_scale + self.tooltip_panel.y_screen
)
self.tooltip_panel.set_location(self.tooltip_panel.x, 0)
else:
tooltip_y_offset = abs(region.height - (self.bar_height + self.bar_y))
tooltip_y_available_height = abs(
region.height - (self.bar_height + self.bar_y)
)
self.tooltip_scale = min(
1.0, tooltip_y_offset / (self.tooltip_base_size_pixels * ui_scale)
1.0, tooltip_y_available_height / (base_panel_height * ui_scale)
)
self.asset_name_text_size = int(
0.039 * self.tooltip_base_size_pixels * ui_scale * self.tooltip_scale
@@ -750,14 +822,33 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
if ui_props.asset_type == "HDR":
self.tooltip_width = self.tooltip_size * 2
self.tooltip_height = self.tooltip_size
self.tooltip_image_height = self.tooltip_size
else:
self.tooltip_width = self.tooltip_size
self.tooltip_height = self.tooltip_size
self.tooltip_image_height = self.tooltip_size
self.gravatar_size = int(
self.tooltip_height * self.bottom_panel_fraction - self.tooltip_margin
self.tooltip_info_height = max(
int(self.tooltip_image_height * self.bottom_panel_fraction),
self.asset_name_text_size * 3,
)
self.labels_start = self.tooltip_image_height
self.tooltip_height = self.tooltip_image_height + self.tooltip_info_height
self.gravatar_size = max(
int(self.tooltip_info_height - 2 * self.tooltip_margin),
self.asset_name_text_size,
)
def get_ui_scale(self):
"""Get the UI scale"""
ui_scale = bpy.context.preferences.view.ui_scale
pixel_size = bpy.context.preferences.system.pixel_size
if pixel_size > 1:
# for a reason unknown,
# the pixel size is modified only on mac
# where pixel size is 2.0
ui_scale = pixel_size
return ui_scale
def update_assetbar_sizes(self, context):
"""Calculate all important sizes for the asset bar"""
@@ -766,8 +857,7 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
ui_props = bpy.context.window_manager.blenderkitUI
user_preferences = bpy.context.preferences.addons[__package__].preferences
ui_scale = bpy.context.preferences.view.ui_scale
ui_scale = self.get_ui_scale()
# assetbar scaling
self.button_margin = int(0 * ui_scale)
self.assetbar_margin = int(2 * ui_scale)
@@ -793,6 +883,10 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
self.bar_x = int(
tools_width + self.button_margin + ui_props.bar_x_offset * ui_scale
)
# self.bar_y = region.height - ui_props.bar_y_offset * ui_scale
self.bar_y = int(ui_props.bar_y_offset * ui_scale)
self.bar_end = int(ui_width + 180 * ui_scale + self.other_button_size)
self.bar_width = int(region.width - self.bar_x - self.bar_end)
@@ -810,6 +904,16 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
if search_results is not None and self.wcount > 0:
if user_preferences.assetbar_expanded:
max_rows = user_preferences.maximized_assetbar_rows
available_height = (
region.height
- self.bar_y
- 2 * self.assetbar_margin
- self.other_button_size
)
max_rows_by_height = math.floor(available_height / self.button_size)
max_rows = (
min(max_rows, max_rows_by_height) if max_rows_by_height > 0 else 1
)
else:
max_rows = 1
self.hcount = min(
@@ -821,8 +925,6 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
self.hcount = 1
self.bar_height = (self.button_size) * self.hcount + 2 * self.assetbar_margin
# self.bar_y = region.height - ui_props.bar_y_offset * ui_scale
self.bar_y = int(ui_props.bar_y_offset * ui_scale)
if ui_props.down_up == "UPLOAD":
self.reports_y = region.height - self.bar_y - 600
ui_props.reports_y = region.height - self.bar_y - 600
@@ -886,26 +988,28 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
self.tooltip_panel.width = self.tooltip_width
self.tooltip_panel.height = self.tooltip_height
self.tooltip_image.width = self.tooltip_width
self.tooltip_image.height = self.tooltip_height
self.tooltip_image.height = self.tooltip_image_height
self.labels_start = self.tooltip_height * (1 - self.bottom_panel_fraction)
self.labels_start = self.tooltip_image_height
self.tooltip_image.set_image_size((self.tooltip_width, self.tooltip_height))
self.tooltip_image.set_image_size(
(self.tooltip_width, self.tooltip_image_height)
)
self.tooltip_image.set_location(0, 0)
self.gravatar_image.set_location(
self.tooltip_width - self.gravatar_size,
self.tooltip_height - self.gravatar_size,
self.tooltip_width - self.gravatar_size - self.tooltip_margin,
self.tooltip_height - self.gravatar_size - self.tooltip_margin,
)
self.gravatar_image.set_image_size(
(
self.gravatar_size - 1 * self.tooltip_margin,
self.gravatar_size - 1 * self.tooltip_margin,
self.gravatar_size,
self.gravatar_size,
)
)
self.authors_name.set_location(
self.tooltip_width - self.gravatar_size - self.tooltip_margin,
self.tooltip_width - self.gravatar_size - (self.tooltip_margin * 2),
self.tooltip_height - self.author_text_size - self.tooltip_margin,
)
self.authors_name.text_size = self.author_text_size
@@ -922,9 +1026,7 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
0,
self.labels_start,
)
self.tooltip_dark_panel.height = (
self.tooltip_height * self.bottom_panel_fraction
)
self.tooltip_dark_panel.height = self.tooltip_info_height
self.tooltip_dark_panel.width = self.tooltip_width
self.quality_label.set_location(
@@ -942,6 +1044,15 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
(self.asset_name_text_size, self.asset_name_text_size)
)
# right after the asset name
self.price_label.set_location(
self.tooltip_margin,
self.labels_start + (self.tooltip_margin * 3) + self.asset_name.height,
)
self.price_label.width = self.tooltip_width - 2 * self.tooltip_margin
self.price_label.height = self.asset_name_text_size
self.price_label.text_size = self.asset_name_text_size
def update_layout(self, context, event):
"""update UI sizes after their recalculation"""
self.update_assetbar_layout(context)
@@ -1044,6 +1155,7 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
self.button_bg_color = (0.2, 0.2, 0.2, 1.0)
self.button_hover_color = (0.8, 0.8, 0.8, 1.0)
self.button_selected_color = (0.5, 0.5, 0.5, 1.0)
self.button_selected_color_dim = (0.3, 0.3, 0.3, 1.0)
self.buttons = []
self.asset_buttons = []
@@ -1072,7 +1184,7 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
self.other_button_size, # Same height as tab buttons
)
# dark blue
self.tab_area_bg.bg_color = (0.2, 0.25, 0.4, 1.0)
self.tab_area_bg.bg_color = colors.TOP_BAR_BLUE
# Add widgets to panel - add tab background first so it's behind everything
self.widgets_panel.append(self.tab_area_bg)
@@ -1162,8 +1274,11 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
# Add tab navigation elements
button_size = self.other_button_size
margin = int(button_size * 0.05)
space = int(button_size * 0.4)
tab_icon_size = int(button_size * 0.7) # Size for the asset type icon
tab_width = button_size * 4 # Wider tabs to accommodate icon
tab_width = (
button_size * 4 + tab_icon_size
) # Widen the tabs to accommodate type icon
# Back/Forward history buttons
self.history_back_button = BL_UI_Button(
@@ -1199,10 +1314,14 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
tabs = global_vars.TABS["tabs"]
tab_x_start = margin * 4 + button_size * 3 # Starting x position of first tab
tabs_end_x = 0
for i, tab in enumerate(tabs):
is_active = i == global_vars.TABS["active_tab"]
# Calculate positions
tab_x = tab_x_start + i * (
tab_width + button_size + margin
tab_width + button_size + margin + space
) # Space for tab and close button
# Tab button
@@ -1212,13 +1331,15 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
tab_width, # Width of tab
button_size,
)
tab_button.bg_color = self.button_bg_color
if i == global_vars.TABS["active_tab"]:
tab_button.bg_color = self.button_selected_color
tab_button.hover_bg_color = self.button_hover_color
tab_button.text = tab["name"]
tab_button.text_size = button_size * 0.5
tab_button.text_color = self.text_color
tab_button.bg_color = self.button_bg_color
if is_active:
tab_button.bg_color = self.button_selected_color
tab_button.tab_index = i # Store tab index
tab_button.set_mouse_down(self.switch_tab) # Add click handler
self.tab_buttons.append(tab_button)
@@ -1226,7 +1347,7 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
# Set asset type icon as tab button image
tab_button.set_image_size((tab_icon_size, tab_icon_size))
tab_button.set_image_position(
(margin, (button_size - tab_icon_size) / 2)
(margin * 2, (button_size - tab_icon_size) / 2)
) # Center vertically
# Only create close button if there's more than one tab
@@ -1243,22 +1364,24 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
close_tab.text = "×" # Set text after creation
close_tab.text_size = button_size * 0.8
close_tab.text_color = self.text_color
if is_active:
close_tab.bg_color = self.button_selected_color_dim
close_tab.tab_index = i # Store tab index
# if there's only one tab, the button closes asset bar instead of closing tab
if len(tabs) > 1:
close_tab.set_mouse_down(self.remove_tab) # Add click handler
else:
close_tab.set_mouse_down(self.cancel_press)
self.close_tab_buttons.append(close_tab)
tabs_end_x = close_x + button_size
# New tab button - position after all tabs and close buttons
if len(tabs) > 0:
last_tab_index = len(tabs) - 1
last_tab_x = tab_x_start + last_tab_index * (
tab_width + button_size + margin
)
new_tab_x = (
last_tab_x + tab_width + button_size + margin * 2
space + tabs_end_x + margin * 2
) # After last tab and its close button
else:
new_tab_x = tab_x_start # If no tabs, start at the beginning
@@ -1302,8 +1425,6 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
active_tab["history_index"] < len(active_tab["history"]) - 1
)
# self.update_buttons()
def set_element_images(self):
"""set ui elements images, has to be done after init of UI."""
# img_fp = paths.get_addon_thumbnail_path("vs_rejected.png")
@@ -1345,7 +1466,7 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
icon_path = paths.get_addon_thumbnail_path(
f"asset_type_{asset_type}.png"
)
if not os.path.exists(icon_path):
if not paths.icon_path_exists(icon_path):
icon_path = paths.get_addon_thumbnail_path(
"asset_type_model.png"
)
@@ -1444,7 +1565,7 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
"""Initialize the asset bar operator."""
self.tooltip_base_size_pixels = 512
self.tooltip_scale = 1.0
self.bottom_panel_fraction = 0.15
self.bottom_panel_fraction = 0.18
self.needs_tooltip_update = False
self.update_ui_size(bpy.context)
@@ -1679,9 +1800,13 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
price_color = asset_data["tooltip_data"].get(
"price_color", (1.0, 0.8, 0.2, 1.0)
)
price_background = asset_data["tooltip_data"].get(
"price_background", (0.2, 0.2, 0.2, 0.0)
)
self.price_label.text = price_text
self.price_label.text_color = price_color
self.price_label.visible = bool(price_text)
self.price_label.bg_color = price_background
# preview comments for validators
self.update_comments_for_validators(asset_data)
@@ -1721,7 +1846,22 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
- properties_width
),
)
tooltip_y = int(widget.y_screen + widget.height)
# Calculate space above and below the button
ui_scale = self.get_ui_scale()
full_tooltip_height = self.tooltip_panel.height
space_above = widget.y_screen
space_below = bpy.context.region.height - (widget.y_screen + widget.height)
# If space below is insufficient (would make tooltip < 70% size), position above
if (
space_below < full_tooltip_height
and space_below < full_tooltip_height * 0.7
and space_below < space_above
):
tooltip_y = int(widget.y_screen - full_tooltip_height)
else:
tooltip_y = int(widget.y_screen + widget.height)
# need to set image here because of context issues.
img_path = paths.get_addon_thumbnail_path("star_grey.png")
self.quality_star.set_image(img_path)
@@ -1730,7 +1870,7 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
self.tooltip_panel.set_location(tooltip_x, tooltip_y)
self.update_tooltip_size(bpy.context)
self.update_tooltip_layout(bpy.context)
self.tooltip_panel.set_location(tooltip_x, tooltip_y)
self.tooltip_panel.set_location(self.tooltip_panel.x, self.tooltip_panel.y)
self.tooltip_panel.layout_widgets()
# show bookmark button - always on mouse enter
if widget.bookmark_button:
@@ -2317,6 +2457,10 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
return # Already on this tab and history step
# make original tab original background color
self.tab_buttons[global_vars.TABS["active_tab"]].bg_color = self.button_bg_color
# make also tab close button original background color
self.close_tab_buttons[global_vars.TABS["active_tab"]].bg_color = (
self.button_bg_color
)
global_vars.TABS["active_tab"] = tab_index
global_vars.TABS["tabs"][tab_index]["history_index"] = history_index
@@ -2354,14 +2498,24 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
# Update history button visibility
active_tab = global_vars.TABS["tabs"][tab_index]
self.history_back_button.visible = active_tab["history_index"] > 0
self.history_forward_button.visible = (
active_tab["history_index"] < len(active_tab["history"]) - 1
)
# make active tab a bit darker
if len(self.tab_buttons) > tab_index:
self.tab_buttons[tab_index].bg_color = self.button_selected_color
# update tab colors
for tab_button in self.tab_buttons:
c_tab_index = tab_button.tab_index
if c_tab_index == tab_index:
tab_button.bg_color = self.button_selected_color
self.close_tab_buttons[tab_index].bg_color = (
self.button_selected_color_dim
)
else:
tab_button.bg_color = self.button_bg_color
self.close_tab_buttons[c_tab_index].bg_color = self.button_bg_color
# update filters
search.update_filters()
@@ -1467,8 +1467,18 @@ class AssetDragOperator(bpy.types.Operator):
Tuple[None, None, None],
]:
"""Find the window, region and area under the mouse cursor."""
# Iterate windows backwards, so we go from the top-most window to the bottommost window
for window in reversed(bpy.context.window_manager.windows):
wins = bpy.context.window_manager.windows[:]
# reverse the list, seemed to work well at least on windows.
wins.reverse()
context_win = bpy.context.window
# let's prioritize the context window
if context_win is not None:
wins.remove(context_win)
wins.insert(0, context_win)
for window in wins:
# first let's test if it's in this window, so we know we shall continue
window_x = window.x * self.resolution_factor
window_y = window.y * self.resolution_factor
@@ -27,6 +27,8 @@ from . import utils
RENDER_OBTYPES = ["MESH", "CURVE", "SURFACE", "METABALL", "TEXT"]
_BLE_5_PLUS = bpy.app.version >= (5, 0, 0)
def check_material(props, mat):
e = bpy.context.scene.render.engine
@@ -217,17 +219,39 @@ def check_rig(props, obs):
props.rig = True
def has_keyframes(obj):
"""Checks if object has animation data with keyframes.
This function only checks for keyframes,
may return false negatives for objects animated with constraints, drivers, etc.
"""
if obj.animation_data is None:
return False
a = obj.animation_data.action
if a is None:
return False
# should work from at least Blender4.2+
if _BLE_5_PLUS:
# combined fcurves ranges
# check if start and end frames are different
if a.curve_frame_range[0] != a.curve_frame_range[1]:
return True
else:
# older Blender versions
for c in a.fcurves:
if len(c.keyframe_points) > 1:
return True
return False
def check_anim(props, obs):
animated = False
for ob in obs:
if ob.animation_data is not None:
a = ob.animation_data.action
if a is not None:
for c in a.fcurves:
if len(c.keyframe_points) > 1:
animated = True
# c.keyframe_points.remove(c.keyframe_points[0])
if has_keyframes(ob):
animated = True
break
if animated:
props.animated = True
@@ -209,8 +209,17 @@ def start_model_thumbnailer(
blender_user_scripts_dir = (
Path(__file__).resolve().parents[2]
) # scripts/addons/blenderkit/autothumb.py
env = {"BLENDER_USER_SCRIPTS": str(blender_user_scripts_dir)}
env.update(os.environ)
# both must be enabled
if (
user_preferences.experimental_features
and user_preferences.ignore_env_for_thumbnails
):
env = None
proc = subprocess.Popen(
args,
stdout=subprocess.PIPE,
@@ -218,7 +227,7 @@ def start_model_thumbnailer(
creationflags=utils.get_process_flags(),
env=env,
)
bk_logger.info(f"Started Blender executing {SCRIPT_NAME} on file {datafile}")
bk_logger.info("Started Blender executing %s on file %s", SCRIPT_NAME, datafile)
eval_path_computing = f"bpy.data.objects['{json_args['asset_name']}'].blenderkit.is_generating_thumbnail"
eval_path_state = f"bpy.data.objects['{json_args['asset_name']}'].blenderkit.thumbnail_generating_state"
eval_path = f"bpy.data.objects['{json_args['asset_name']}']"
@@ -284,8 +293,16 @@ def start_material_thumbnailer(
blender_user_scripts_dir = (
Path(__file__).resolve().parents[2]
) # scripts/addons/blenderkit/autothumb.py
env = {"BLENDER_USER_SCRIPTS": str(blender_user_scripts_dir)}
env.update(os.environ)
if (
user_preferences.experimental_features
and user_preferences.ignore_env_for_thumbnails
):
env = None
proc = subprocess.Popen(
args,
stdout=subprocess.PIPE,
@@ -293,7 +310,7 @@ def start_material_thumbnailer(
creationflags=utils.get_process_flags(),
env=env,
)
bk_logger.info(f"Started Blender executing {SCRIPT_NAME} on file {datafile}")
bk_logger.info("Started Blender executing %s on file %s", SCRIPT_NAME, datafile)
eval_path_computing = f"bpy.data.materials['{json_args['asset_name']}'].blenderkit.is_generating_thumbnail"
eval_path_state = f"bpy.data.materials['{json_args['asset_name']}'].blenderkit.thumbnail_generating_state"
@@ -164,11 +164,20 @@ if __name__ == "__main__":
ob.data.texspace_size.x = 1 # / tscale
ob.data.texspace_size.y = 1 # / tscale
ob.data.texspace_size.z = 1 # / tscale
if data["adaptive_subdivision"] == True:
ob.cycles.use_adaptive_subdivision = True
# this option was moved in Blender 5.0 from cycles directly to modifier
if bpy.app.version >= (5, 0, 0):
for mod in ob.modifiers:
if mod.type == "SUBSURF":
if data["adaptive_subdivision"] == True:
mod.use_adaptive_subdivision = True
else:
mod.use_adaptive_subdivision = False
else:
ob.cycles.use_adaptive_subdivision = False
if data["adaptive_subdivision"] == True:
ob.cycles.use_adaptive_subdivision = True
else:
ob.cycles.use_adaptive_subdivision = False
ts = data["texture_size_meters"]
if data["thumbnail_type"] in ["BALL", "BALL_COMPLEX", "CLOTH"]:
utils.automap(
@@ -179,7 +188,13 @@ if __name__ == "__main__":
)
bpy.context.view_layer.update()
s.cycles.volume_step_size = tscale * 0.1
# this option was removed in Blender 5.0
# but we have option to set biased volumes
if bpy.app.version >= (5, 0, 0):
# usually small speedup with little quality loss
s.cycles.volume_biased = True
else:
s.cycles.volume_step_size = tscale * 0.1
if thumbnail_use_gpu is True:
bpy.context.scene.cycles.device = "GPU"
@@ -71,6 +71,9 @@ def threadread(tcom: ThreadCom):
return # process terminated
inline = tcom.proc.stdout.readline()
inline = inline.decode("utf-8")
# ignore empty lines
if inline.strip() == "":
continue
bk_logger.info(inline.strip())
progress = re.findall(r"progress\{(.*?)\}", inline)
if len(progress) > 0:
@@ -139,8 +139,8 @@ def login(signup: bool) -> None:
def generate_pkce_pair() -> tuple[str, str]:
"""Generate PKCE pair - a code verifier and code challange.
The challange should be sent first to the server, the verifier is used in next steps to verify identity (handles Client).
"""Generate PKCE pair - a code verifier and code challenge.
The challenge should be sent first to the server, the verifier is used in next steps to verify identity (handles Client).
"""
rand = random.SystemRandom()
code_verifier = "".join(rand.choices(string.ascii_letters + string.digits, k=128))
@@ -162,8 +162,6 @@ def write_tokens(auth_token, refresh_token, oauth_response):
override_extension_draw.ensure_repository(api_key=auth_token)
override_extension_draw.clear_repo_cache()
#
def ensure_token_refresh() -> bool:
"""Check if API token needs refresh, call refresh and return True if so.
@@ -1,17 +1,17 @@
{
"last_check": "2026-01-12 10:24:10.400844",
"backup_date": "December-1-2025",
"last_check": "2026-04-02 12:41:20.491300",
"backup_date": "January-12-2026",
"update_ready": true,
"ignore": false,
"just_restored": false,
"just_updated": false,
"version_text": {
"link": "https://github.com/BlenderKit/BlenderKit/releases/download/v3.18.1.251219/blenderkit-v3.18.1.251219.zip",
"link": "https://github.com/BlenderKit/BlenderKit/releases/download/v3.19.1.260402/blenderkit-v3.19.1.260402.zip",
"version": [
3,
18,
19,
1,
251219
260402
]
}
}
@@ -1,5 +1,10 @@
import blf
import bpy
import gpu
from typing import Tuple, Union
from gpu_extras.batch import batch_for_shader
from .bl_ui_widget import BL_UI_Widget
@@ -17,6 +22,9 @@ class BL_UI_Label(BL_UI_Widget):
self.multiline = False
self.row_height = 20
self.padding: Union[Tuple[float, float], float] = 0
self.background = False
@property
def text_color(self):
return self._text_color
@@ -61,6 +69,30 @@ class BL_UI_Label(BL_UI_Widget):
blf.size(font_id, self._text_size, 72)
else:
blf.size(font_id, self._text_size)
lines = self._text.split("\n") if self.multiline else [self._text]
if not lines:
return
default_line_height = self.row_height if self.multiline else self._text_size
line_metrics = []
max_line_width = 0.0
total_height = 0.0
for line in lines:
width, height = blf.dimensions(font_id, line)
if height == 0:
height = default_line_height
line_height = (
self.row_height if self.multiline else max(height, self._text_size)
)
if line_height == 0:
line_height = default_line_height
line_metrics.append((line, width, line_height))
max_line_width = max(max_line_width, width)
total_height += line_height
if not line_metrics:
return
textpos_y = area_height - self.y_screen - self.height
@@ -76,16 +108,55 @@ class BL_UI_Label(BL_UI_Widget):
if self._valign == "CENTER":
y -= height // 2
# bottom could be here but there's no reason for it
first_line_height = line_metrics[0][2]
if self.background and (max_line_width > 0 or total_height > 0):
pad_x, pad_y = self._padding_tuple()
text_top = y + first_line_height
text_bottom = text_top - total_height
left = x - pad_x
right = x + max_line_width + pad_x
top = text_top + pad_y
bottom = text_bottom - pad_y
self._draw_background_rect(left, right, bottom, top)
current_y = y
if not self.multiline:
blf.position(font_id, x, y, 0)
blf.position(font_id, x, current_y, 0)
blf.color(font_id, r, g, b, a)
blf.draw(font_id, self._text)
else:
lines = self._text.split("\n")
for line in lines:
blf.position(font_id, x, y, 0)
for line, _, line_height in line_metrics:
blf.position(font_id, x, current_y, 0)
blf.color(font_id, r, g, b, a)
blf.draw(font_id, line)
y -= self.row_height
current_y -= line_height
def _padding_tuple(self) -> Tuple[float, float]:
pad = self.padding
if isinstance(pad, (list, tuple)):
if len(pad) == 0:
return (0.0, 0.0)
if len(pad) == 1:
value = float(pad[0])
return (value, value)
return (float(pad[0]), float(pad[1]))
value = float(pad)
return (value, value)
def _draw_background_rect(self, left, right, bottom, top):
vertices = (
(left, top),
(left, bottom),
(right, bottom),
(right, top),
)
indices = ((0, 1, 2), (0, 2, 3))
gpu.state.blend_set("ALPHA")
self.shader.bind()
self.shader.uniform_float("color", self._bg_color)
batch = batch_for_shader(
self.shader, "TRIS", {"pos": vertices}, indices=indices
)
batch.draw(self.shader)
@@ -2,7 +2,7 @@ schema_version = "1.0.0"
id = "blenderkit"
type = "add-on"
version = "3.18.0-251121" # X.Y.Z-YYYYMMDD, must have a dash instead of a dot
version = "3.18.1-251219" # X.Y.Z-YYYYMMDD, must have a dash instead of a dot
name = "BlenderKit Online Asset Library"
tagline = "Drag & drop of assets from the community driven library"
@@ -91,7 +91,7 @@ def ensure_minimal_data(data: Optional[dict] = None) -> dict:
return data
def ensure_minimal_data_class(data_class):
def ensure_minimal_data_class(data_class: datas.SearchData) -> datas.SearchData:
"""Ensure that the data send to the BlenderKit-Client contains:
- app_id is the process ID of the Blender instance, so BlenderKit-client can return reports to the correct instance.
- api_key is the authentication token for the BlenderKit server, so BlenderKit-Client can authenticate the user.
@@ -19,16 +19,35 @@
Module colors defines color palette for BlenderKit UI.
"""
# UI Colors
TOP_BAR_BLUE = (0.2, 0.25, 0.4, 1.0)
"""TOP_BAR_BLUE Color for BlenderKit UI top bar."""
WHITE = (1, 1, 1, 0.9)
TEXT = (0.9, 0.9, 0.9, 0.6)
GREEN = (0.9, 1, 0.9, 0.6)
RED = (1, 0.5, 0.5, 0.8)
BLUE = (0.8, 0.8, 1, 0.8)
TEXT = (0.9, 0.9, 0.9, 0.9)
"""TEXT Color for BlenderKit UI text."""
PURPLE = (0.8, 0.4, 1.0, 1.0) # Full Plan purple
GREEN_FREE = (0.4, 0.8, 0.4, 1.0) # Green for free addons
"""Color for validator reports."""
TEXT_DIM = (0.8, 0.8, 0.8, 0.9)
GREEN = (0.9, 1, 0.9, 0.6)
"""GREEN Color for validator reports."""
RED = (1, 0.5, 0.5, 0.8)
"""RED Color for validator reports."""
BLUE = (0.8, 0.8, 1, 0.8)
"""BLUE Color for validator reports."""
GREEN_PRICE = (0.42, 0.49, 0.19, 1.0)
"""Emerald Green to be used on "discounted" add-ons."""
PURPLE_PRICE = (0.59, 0.05, 0.82, 1.0)
"""Lavender Purple to be used on "for sale" add-ons."""
ORANGE_FULL = (0.702, 0.349, 0.208, 1.0)
"""Burnt Orange associated with full plan assets and add-ons."""
GRAY = (0.7, 0.7, 0.7, 0.6)
"""Default color for debug reports."""
@@ -109,8 +109,10 @@ def get_addon_installation_status(asset_data):
if not is_enabled:
extension_module_name = f"bl_ext.www_blenderkit_com.{extension_id}"
is_enabled = extension_module_name in enabled_addons
bk_logger.info(
f"Checking extension format: {extension_module_name} -> enabled: {is_enabled}"
bk_logger.debug(
"Checking extension format: %s -> enabled: %s",
extension_module_name,
is_enabled,
)
# Also try other possible repository name formats
@@ -210,10 +212,15 @@ def get_addon_installation_status(asset_data):
if "blenderkit" in addon.lower() or addon.endswith(extension_id)
]
if blenderkit_addons:
bk_logger.info(f"Found BlenderKit-related enabled addons: {blenderkit_addons}")
bk_logger.debug(
"Found BlenderKit-related enabled addons: %s", blenderkit_addons
)
bk_logger.info(
f"Addon status check for '{extension_id}': installed={is_installed}, enabled={is_enabled}"
bk_logger.debug(
"Addon status check for '%s': installed=%s, enabled=%s",
extension_id,
is_installed,
is_enabled,
)
return {
@@ -877,8 +884,13 @@ def append_asset(asset_data, **kwargs): # downloaders=[], location=None,
if asset_blender_version < (4, 3, 0) and bpy.app.version >= (4, 3, 0):
brush.asset_clear()
brush.asset_mark()
brush.icon_filepath = asset_thumb_path
if bpy.app.version <= (4, 5, 0):
brush.icon_filepath = asset_thumb_path
else:
# load asset thumbnail into brush if it's not already present
if brush.preview is None:
with bpy.context.temp_override(id=brush):
bpy.ops.ed.lib_id_load_custom_preview(filepath=asset_thumb_path)
# set the brush active
if bpy.context.view_layer.objects.active.mode == "SCULPT":
if bpy.app.version < (4, 3, 0):
@@ -897,7 +909,6 @@ def append_asset(asset_data, **kwargs): # downloaders=[], location=None,
relative_asset_identifier=f"Brush{os.sep}{brush.name}"
)
# TODO add grease pencil brushes!
# bpy.context.tool_settings.image_paint.brush = brush
asset_main = brush
@@ -16,7 +16,7 @@
#
# ##### END GPL LICENSE BLOCK #####
from logging import INFO, WARN
from logging import DEBUG, INFO, WARN
from os import environ
from subprocess import Popen
from typing import Any, Optional
@@ -59,6 +59,11 @@ BKIT_AUTHORS: dict[int, datas.UserProfile] = {}
"""All loaded profiles of other users. Current user is also present in stripped down version. Key is the UserProfile.id."""
LOGGING_LEVEL_BLENDERKIT = INFO
# read special DEBUG env var to set logging level to DEBUG
if environ.get("BLENDERKIT_DEBUG", "0") == "1":
LOGGING_LEVEL_BLENDERKIT = DEBUG
LOGGING_LEVEL_IMPORTED = WARN
PREFS = {}
@@ -27,8 +27,10 @@ import bpy
icon_collections = {}
icons_read = {
"fp.png": "free",
"flp.png": "full",
"free_plan.png": "free",
"full_plan.png": "full",
"promo_sale_symbol.png": "promo_sale_symbol",
"sale_purple.png": "for_sale",
"trophy.png": "trophy",
"dumbbell.png": "dumbbell",
"cc0.png": "cc0",
@@ -24,6 +24,7 @@ import shutil
import subprocess
import sys
import tempfile
from functools import lru_cache
import bpy
@@ -39,6 +40,9 @@ BLENDERKIT_REPORT_URL = f"{global_vars.SERVER}/usage_report"
BLENDERKIT_USER_ASSETS_URL = f"{global_vars.SERVER}/my-assets"
BLENDERKIT_MANUAL_URL = "https://youtu.be/0P8ZjfbUjeA"
BLENDERKIT_MODEL_UPLOAD_INSTRUCTIONS_URL = f"{global_vars.SERVER}/docs/upload/"
BLENDERKIT_PRINTABLE_UPLOAD_INSTRUCTIONS_URL = (
f"{global_vars.SERVER}/docs/upload-printables/"
)
BLENDERKIT_MATERIAL_UPLOAD_INSTRUCTIONS_URL = (
f"{global_vars.SERVER}/docs/uploading-material/"
)
@@ -463,6 +467,8 @@ def get_addon_file(subpath=""):
script_path = os.path.dirname(os.path.realpath(__file__))
# cache this for minor performance boost
@lru_cache(maxsize=128)
def get_addon_thumbnail_path(name):
global script_path
# fpath = os.path.join(p, subpath)
@@ -474,6 +480,13 @@ def get_addon_thumbnail_path(name):
return os.path.join(script_path, subpath)
# cache this for minor performance boost
@lru_cache(maxsize=128)
def icon_path_exists(path: str) -> bool:
"""Cached version of os.path.exists"""
return os.path.exists(path)
def get_config_dir_path() -> str:
"""Get the path to the config directory in global_dir."""
global_dir = bpy.context.preferences.addons[__package__].preferences.global_dir # type: ignore
@@ -203,7 +203,7 @@ class SetBookmark(bpy.types.Operator):
"""Add or remove bookmarking of the asset.\nShortcut: hover over asset in the asset bar and press 'B'."""
bl_idname = "wm.blenderkit_bookmark_asset"
bl_label = "BlenderKit bookmark assest"
bl_label = "BlenderKit bookmark assets"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
asset_id: StringProperty( # type: ignore[valid-type]
@@ -233,28 +233,29 @@ class SetBookmark(bpy.types.Operator):
ratings_utils.store_rating_local(
self.asset_id, rating_type="bookmarks", value=bookmark_value
)
client_lib.send_rating(self.asset_id, "bookmarks", bookmark_value)
client_lib.send_rating(self.asset_id, "bookmarks", str(bookmark_value))
return {"FINISHED"}
def rating_menu_draw(self, context):
layout = self.layout
## NOT USED ANYMORE
# def rating_menu_draw(self, context):
# layout = self.layout
ui_props = context.window_manager.blenderkitUI
sr = search.get_search_results()
# ui_props = context.window_manager.blenderkitUI
# sr = search.get_search_results()
asset_search_index = ui_props.active_index
if asset_search_index > -1:
asset_data = dict(sr["results"][asset_search_index])
# asset_search_index = ui_props.active_index
# if asset_search_index > -1:
# asset_data = dict(sr["results"][asset_search_index])
col = layout.column()
layout.label(text="Admin rating Tools:")
col.operator_context = "INVOKE_DEFAULT"
# col = layout.column()
# layout.label(text="Admin rating Tools:")
# col.operator_context = "INVOKE_DEFAULT"
op = col.operator("wm.blenderkit_menu_rating_upload", text="Add Rating")
op.asset_id = asset_data["id"]
op.asset_name = asset_data["name"]
op.asset_type = asset_data["assetType"]
# op = col.operator("wm.blenderkit_menu_rating_upload", text="Add Rating")
# op.asset_id = asset_data["id"]
# op.asset_name = asset_data["name"]
# op.asset_type = asset_data["assetType"]
# Coordinates (each one is a triangle).
@@ -20,6 +20,7 @@ import copy
import json
import logging
import math
from functools import lru_cache
import os
import re
import unicodedata
@@ -43,6 +44,7 @@ from . import (
image_utils,
paths,
reports,
search_price,
resolutions,
tasks_queue,
utils,
@@ -53,6 +55,51 @@ bk_logger = logging.getLogger(__name__)
search_tasks = {}
def _inject_user_price_data(assets: list[dict]) -> None:
"""Augment search results with per-user pricing info when available."""
if not assets:
bk_logger.debug("User price lookup skipped: empty assets list.")
return
version_uuids: list[str] = [ass["id"] for ass in assets]
if not version_uuids:
bk_logger.debug("User price lookup skipped: empty version UUIDs list.")
return
try:
price_response = search_price.query_user_price(
version_uuids=version_uuids,
page_size=len(version_uuids),
)
except Exception as exc:
bk_logger.warning("Failed to fetch user prices: %s", exc)
return
if not price_response:
bk_logger.debug(
"User price lookup skipped: %s",
price_response,
)
return
price_by_uuid: dict[str, dict] = {}
for entry in price_response:
version_uuid = entry.get("versionUuid") # maybe assetUuid ?
if not version_uuid:
continue
price_by_uuid[version_uuid] = entry
if not price_by_uuid:
return
for asset in assets:
version_uuid = asset["id"]
price_info = price_by_uuid.get(version_uuid)
if not price_info:
continue
asset["userPrice"] = price_info["discountedPrice"]
def update_ad(ad):
if not ad.get("assetBaseId"):
try:
@@ -136,22 +183,23 @@ def check_clipboard():
"""
global last_clipboard
try: # could be problematic on Linux
current_clipboard = bpy.context.window_manager.clipboard
current_clipboard = str(bpy.context.window_manager.clipboard)
except Exception as e:
bk_logger.warning(f"Failed to get clipboard: {e}")
return
if current_clipboard == last_clipboard:
return
last_clipboard = current_clipboard
asset_type_index = last_clipboard.find("asset_type:")
asset_type_index = current_clipboard.find("asset_type:")
if asset_type_index == -1:
return
if not last_clipboard.startswith("asset_base_id:"):
if not current_clipboard.startswith("asset_base_id:"):
return
last_clipboard = current_clipboard
asset_type_string = current_clipboard[asset_type_index:].lower()
if asset_type_string.find("model") > -1:
target_asset_type = "MODEL"
@@ -169,6 +217,10 @@ def check_clipboard():
target_asset_type = "NODEGROUP"
elif asset_type_string.find("addon") > -1 or asset_type_string.find("add-on") > -1:
target_asset_type = "ADDON"
else:
bk_logger.debug("Clipboard does not contain valid asset type.")
return
ui_props = bpy.context.window_manager.blenderkitUI
if ui_props.asset_type != target_asset_type:
ui_props.asset_type = target_asset_type # switch asset type before placing keywords, so it does not search under wrong asset type
@@ -341,7 +393,7 @@ def handle_search_task(task: client_tasks.Task) -> bool:
return True
# don't do anything while dragging - this could switch asset during drag, and make results list length different,
# causing a lot of throuble.
# causing a lot of trouble.
if bpy.context.window_manager.blenderkitUI.dragging: # type: ignore[attr-defined]
return False
@@ -403,6 +455,10 @@ def handle_search_task(task: client_tasks.Task) -> bool:
asset for asset in result_field if asset.get("downloaded", 0) > 0
]
# TODO: if ever needed, implement for other future types
if result_field:
_inject_user_price_data(result_field)
# Store results in history step
history_step["search_results"] = result_field
history_step["search_results_orig"] = task.result
@@ -710,7 +766,7 @@ def query_to_url(
scene_uuid: str = "",
page_size: int = 15,
) -> str:
"""Build a new search request by parsing query dictionaty into appropriate URL.
"""Build a new search request by parsing query dictionary into appropriate URL.
Also modifies query and adds some stuff in there which is very misleading anti-pattern.
TODO: just convert to URL here and move the sorting and adding of params to separate function.
https://www.blenderkit.com/api/v1/search/
@@ -1012,6 +1068,7 @@ def filter_addon_search_results(search_results, filter_installed_only=False):
def add_search_process(
query, get_next: bool, page_size: int, next_url: str, history_id: str
):
"""Initialize search task and add it to the task queue."""
global search_tasks
addon_version = utils.get_addon_version()
blender_version = utils.get_blender_version()
@@ -1232,7 +1289,7 @@ def search(get_next=False, query=None, author_id=""):
def clean_filters():
"""Cleanup filters in case search needs to be reset, typicaly when asset id is copy pasted."""
"""Cleanup filters in case search needs to be reset, typically when asset id is copy pasted."""
sprops = utils.get_search_props()
ui_props = bpy.context.window_manager.blenderkitUI
ui_props.property_unset("own_only")
@@ -1551,6 +1608,13 @@ class SearchOperator(Operator):
default="Runs search and displays the asset bar at the same time"
)
force_clear: BoolProperty( # type: ignore[valid-type]
name="Force clear keywords, before programmatic search",
description="Force clear keywords before search",
default=True,
options={"SKIP_SAVE"},
)
@classmethod
def description(cls, context, properties):
return properties.tooltip
@@ -1564,16 +1628,25 @@ class SearchOperator(Operator):
if self.esc:
bpy.ops.view3d.close_popup_button("INVOKE_DEFAULT")
ui_props = bpy.context.window_manager.blenderkitUI
search_keywords = str(ui_props.search_keywords)
if self.keywords != "":
search_keywords = self.keywords
# remove all search keywords if force_clear is set
if self.force_clear:
# self.force_clear = False # reset the force clear
search_keywords = ""
if self.author_id != "":
bk_logger.info(f"Author ID: {self.author_id}")
# if there is already an author id in the search keywords, remove it first, the author_id can be any so
# use regex to find it
ui_props.search_keywords = re.sub(
r"\+author_id:\d+", "", ui_props.search_keywords
)
ui_props.search_keywords += f"+author_id:{self.author_id}"
if self.keywords != "":
ui_props.search_keywords = self.keywords
search_keywords = re.sub(r"\+author_id:\d+", "", search_keywords)
search_keywords += f"+author_id:{self.author_id}"
ui_props.search_keywords = search_keywords
search(get_next=self.get_next)
@@ -0,0 +1,57 @@
from typing import Iterable, List, Optional, Tuple
from . import client_lib, paths, utils
def _normalize_version_uuid_list(values: Optional[Iterable[str]]) -> List[str]:
if values is None:
return []
normalized: List[str] = []
for value in values:
if not value:
continue
as_str = str(value)
if as_str not in normalized:
normalized.append(as_str)
return normalized
def query_user_price(
version_uuids: list[str] = [],
page_size: int = 15,
timeout: Tuple[float, float] = (1, 30),
) -> dict:
"""Return results for price lookup of multiple asset versions.
The server endpoint now expects a POST body with `version_uuids`, so we keep
the helper focused on returning the correct URL alongside the JSON payload
that should be sent in the request.
"""
if isinstance(version_uuids, str):
version_uuids = [version_uuids]
version_uuid_list = _normalize_version_uuid_list(version_uuids)
if page_size > 0:
version_uuid_list = version_uuid_list[:page_size]
payload: dict = {"version_uuids": version_uuid_list}
url = f"{paths.BLENDERKIT_API}/cart/request-price-bulk/"
if not payload["version_uuids"]:
raise ValueError("No version UUIDs provided for price lookup.")
headers = utils.get_simple_headers()
headers.setdefault("Content-Type", "application/json")
response = client_lib.blocking_request(
url,
"POST",
headers,
json_data=payload,
timeout=timeout,
)
search_results = response.json()
return search_results
@@ -120,12 +120,14 @@ def handle_failed_reports(exception: Exception) -> float:
@bpy.app.handlers.persistent
def client_communication_timer():
"""Recieve all responses from Client and run according followup commands.
"""Receive all responses from Client and run according followup commands.
This function is the only one responsible for keeping the Client up and running.
"""
global pending_tasks
bk_logger.debug("Getting tasks from Client")
search.check_clipboard()
bk_logger.log(5, "Getting tasks from Client")
user_preferences = bpy.context.preferences.addons[__package__].preferences
if user_preferences.use_clipboard_scan:
search.check_clipboard()
results = list()
try:
results = client_lib.get_reports(os.getpid())
@@ -141,7 +143,7 @@ def client_communication_timer():
wm = bpy.context.window_manager
wm.blenderkitUI.logo_status = "logo"
bk_logger.debug("Handling tasks")
bk_logger.log(5, "Handling tasks")
results_converted_tasks = []
# convert to task type
@@ -166,8 +168,8 @@ def client_communication_timer():
for task in results_converted_tasks:
handle_task(task)
bk_logger.debug("Task handling finished")
delay = bpy.context.preferences.addons[__package__].preferences.client_polling
bk_logger.log(5, "Task handling finished")
delay = user_preferences.client_polling
if len(download.download_tasks) > 0:
return min(0.2, delay)
return delay
@@ -1,50 +0,0 @@
.updater_install_popup
.updater_check_now
.updater_update_now
.updater_update_target
.updater_install_manually
.updater_update_successful
.updater_restore_backup
.updater_ignore
.end_background_check
view3d.asset_drag_drop
object.blenderkit_auto_tags
object.blenderkit_generate_thumbnail
object.blenderkit_regenerate_thumbnail
object.blenderkit_generate_material_thumbnail
object.blenderkit_regenerate_material_thumbnail
object.kill_bg_process
wm.blenderkit_login
wm.blenderkit_logout
wm.blenderkit_login_cancel
scene.blenderkit_addon_manager
scene.blenderkit_addon_choice
scene.blenderkit_download_kill
scene.blenderkit_download
wm.blenderkit_bookmark_asset
wm.blenderkit_mark_notification_read
wm.blenderkit_mark_notifications_read_all
wm.blenderkit_open_notification_target
wm.blenderkit_upvote_comment
wm.blenderkit_is_private_comment
wm.blenderkit_post_comment
wm.logo_status
wm.show_notifications
wm.blenderkit_join_discord
wm.blenderkit_welcome
wm.blenderkit_open_system_directory
wm.blenderkit_asset_popup
view3d.blenderkit_set_comment_reply_id
view3d.blenderkit_set_category_origin
view3d.blenderkit_clear_search_keywords
view3d.close_popup_button
wm.blenderkit_popup_dialog
wm.blenderkit_url_dialog
wm.blenderkit_login_dialog
wm.blenderkit_nodegroup_drop_dialog
object.blenderkit_particles_drop
object.blenderkit_data_trasnfer
wm.modal_timer_operator
view3d.run_assetbar_start_modal
view3d.run_assetbar_fix_context
wm.blenderkit_fast_metadata
@@ -83,7 +83,7 @@ def draw_upload_common(layout, props, asset_type, context):
url = "" # paths.BLENDERKIT_NODEGROUP_UPLOAD_INSTRUCTIONS_URL
if asset_type == "PRINTABLE":
url = (
paths.BLENDERKIT_MODEL_UPLOAD_INSTRUCTIONS_URL
paths.BLENDERKIT_PRINTABLE_UPLOAD_INSTRUCTIONS_URL
) # Reuse model instructions since prints are similar
if asset_type == "ADDON":
asset_type_text = asset_type
@@ -1721,6 +1721,10 @@ class VIEW3D_PT_blenderkit_import_settings(Panel):
layout.prop(preferences, "resolution")
# layout.prop(props, 'unpack_files')
# general settings
# show toggle for clipboard scan
layout.prop(preferences, "use_clipboard_scan")
def deferred_set_name(props, expected_obj_name):
"""Deferred timer to set empty name of uploaded asset to active Object's name.
@@ -2725,15 +2729,26 @@ class AssetPopupCard(bpy.types.Operator, ratings_utils.RatingProperties):
is_free = self.asset_data.get("isFree")
# Get pricing info from extensions cache
user_price = self.asset_data.get("userPrice")
base_price = self.asset_data.get("basePrice")
is_for_sale = self.asset_data.get("isForSale")
if self.asset_data["isPrivate"]:
text = "Private"
self.draw_property(box, "Access", text, icon="LOCKED")
elif is_for_sale and not can_download and user_price and base_price:
text = f"${user_price} (Not purchased)"
icon = pcoll["for_sale"]
self.draw_property(
box,
"Price",
text,
icon_value=icon.icon_id,
tooltip="This addon is for sale but you haven't purchased it yet.\nPrice shown is your price / base price",
)
elif is_for_sale and not can_download and base_price:
text = f"${base_price} (Not purchased)"
icon = pcoll["full"]
icon = pcoll["for_sale"]
self.draw_property(
box,
"Price",
@@ -2742,8 +2757,8 @@ class AssetPopupCard(bpy.types.Operator, ratings_utils.RatingProperties):
tooltip="This addon is for sale but you haven't purchased it yet",
)
elif is_for_sale and can_download and base_price:
text = f"${base_price} (Purchased)"
icon = pcoll["full"]
text = f"Purchased"
icon = pcoll["for_sale"]
self.draw_property(
box,
"Price",
@@ -2752,7 +2767,7 @@ class AssetPopupCard(bpy.types.Operator, ratings_utils.RatingProperties):
tooltip="You have purchased this addon",
)
elif not is_free and not is_for_sale:
text = "Full plan required"
text = "Full plan"
icon = pcoll["full"]
self.draw_property(
box,
@@ -2991,7 +3006,7 @@ class AssetPopupCard(bpy.types.Operator, ratings_utils.RatingProperties):
)
op.tooltip = "Search all assets by this author.\nShortcut: Hover over the asset in the asset bar and press 'A'." # type: ignore[attr-defined]
op.esc = True # type: ignore[attr-defined]
op.keywords = "" # type: ignore[attr-defined]
op.keywords = "" # type: ignore[attr-defined] # must not be empty otherwise search will use previous keywords
op.author_id = str(author_id) # type: ignore[attr-defined]
button_row = button_row.row(align=True)
@@ -3222,7 +3237,7 @@ class AssetPopupCard(bpy.types.Operator, ratings_utils.RatingProperties):
# name_row.label(text='>')
name_row.label(text=aname)
push_op_left(name_row, strength=3)
push_op_left(name_row, strength=1)
op = name_row.operator("view3d.close_popup_button", text="", icon="CANCEL")
def draw_comment_response(self, context, layout, comment_id):
@@ -3568,6 +3583,18 @@ class SetCategoryOperatorLastInPopupCard(SetCategoryOperatorOrigin):
bl_idname = "view3d.blenderkit_set_category_in_popup_card_last"
class ToggleClipboardScan(bpy.types.Operator):
"""Toggle whether asset links are set from clipboard when copied."""
bl_idname = "wm.blenderkit_toggle_clipboard_scan"
bl_label = "Toggle Clipboard Scan"
def execute(self, context):
user_preferences = bpy.context.preferences.addons[__package__].preferences
user_preferences.use_clipboard_scan = not user_preferences.use_clipboard_scan
return {"FINISHED"}
class ClearSearchKeywords(bpy.types.Operator):
"""Clear search keywords"""
@@ -339,6 +339,25 @@ def get_active_asset_by_type(asset_type="model"):
return None
def get_equivalent_datablock(asset_type, name):
"""Get the datablock that blocks us from renaming the asset, and rename it to something a bit else."""
if asset_type == "MATERIAL":
return bpy.data.materials.get(name)
elif asset_type == "OBJECT":
return bpy.data.objects.get(name)
elif asset_type == "SCENE":
return bpy.data.scenes.get(name)
elif asset_type == "HDR":
return bpy.data.images.get(name)
elif asset_type == "BRUSH":
return bpy.data.brushes.get(name)
elif asset_type == "NODEGROUP":
return bpy.data.node_groups.get(name)
elif asset_type == "ADDON":
return bpy.data.addons.get(name)
return None
def get_active_asset():
scene = bpy.context.scene
ui_props = bpy.context.window_manager.blenderkitUI
@@ -970,12 +989,17 @@ def get_dimensions(obs):
return dim, bbmin, bbmax
def get_headers(api_key: str = "") -> dict[str, str]:
def get_simple_headers() -> dict[str, str]:
headers = {
"accept": "application/json",
"Platform-Version": platform.platform(),
"addon-version": f"{global_vars.VERSION[0]}.{global_vars.VERSION[1]}.{global_vars.VERSION[2]}.{global_vars.VERSION[3]}",
}
return headers
def get_headers(api_key: str = "") -> dict[str, str]:
headers = get_simple_headers()
if api_key != "":
headers["Authorization"] = f"Bearer {api_key}"
@@ -1129,6 +1153,18 @@ def name_update(props, context=None):
asset = get_active_asset()
if asset.name != fname: # Here we actually rename assets datablocks
asset.name = fname # change name of active object to upload Name
# we need to set the name back for proper appending later
if asset.name != fname and re.search(r"\.\d+$", asset.name) is not None:
# - because assets end up with .001, .002, etc. names sometimes.
# first, let's get the datablock that blocks us from renaming the asset, and rename it to something a bit else:
# we need to ge the equivalent datablock ,
# then we can swap those names around.
datablock = get_equivalent_datablock(ui_props.asset_type, fname)
if datablock is not None:
datablock.name = fname + "_temprename"
replace_name = asset.name
asset.name = fname
datablock.name = replace_name
def fmt_dimensions(p):
@@ -1,6 +1,6 @@
{
"last_check": "2026-03-16 11:19:55.639825",
"backup_date": "January-12-2026",
"last_check": "2026-04-02 12:41:20.491300",
"backup_date": "April-2-2026",
"update_ready": false,
"ignore": false,
"just_restored": false,
@@ -1,14 +1,12 @@
import os
import logging
from typing import Optional
import blf
import bpy
import gpu
from .. import image_utils, ui_bgl
from .bl_ui_widget import BL_UI_Widget
from .bl_ui_image import BL_UI_Image
from .bl_ui_widget import BL_UI_Widget, region_redraw
bk_logger = logging.getLogger(__name__)
@@ -18,6 +16,8 @@ class BL_UI_Button(BL_UI_Widget):
def __init__(self, x, y, width, height):
super().__init__(x, y, width, height)
self.background = True
self.background_padding = (0, 0)
self._text_color = (1.0, 1.0, 1.0, 1.0)
self._hover_bg_color = (0.5, 0.5, 0.5, 1.0)
self._select_bg_color = (0.7, 0.7, 0.7, 1.0)
@@ -30,6 +30,8 @@ class BL_UI_Button(BL_UI_Widget):
self.__image = None
self.__image_size = (24, 24)
self.__image_position = (4, 2)
self.__image_padding = 0.0
self.image_corner_radius = None
@property
def text_color(self):
@@ -38,7 +40,7 @@ class BL_UI_Button(BL_UI_Widget):
@text_color.setter
def text_color(self, value):
if value != self._text_color:
bpy.context.region.tag_redraw()
region_redraw()
self._text_color = value
@property
@@ -48,7 +50,7 @@ class BL_UI_Button(BL_UI_Widget):
@text.setter
def text(self, value):
if value != self._text:
bpy.context.region.tag_redraw()
region_redraw()
self._text = value
@property
@@ -58,7 +60,7 @@ class BL_UI_Button(BL_UI_Widget):
@text_size.setter
def text_size(self, value):
if value != self._text_size:
bpy.context.region.tag_redraw()
region_redraw()
self._text_size = value
@property
@@ -68,7 +70,7 @@ class BL_UI_Button(BL_UI_Widget):
@hover_bg_color.setter
def hover_bg_color(self, value):
if value != self._hover_bg_color:
bpy.context.region.tag_redraw()
region_redraw()
self._hover_bg_color = value
@property
@@ -78,7 +80,7 @@ class BL_UI_Button(BL_UI_Widget):
@select_bg_color.setter
def select_bg_color(self, value):
if value != self._select_bg_color:
bpy.context.region.tag_redraw()
region_redraw()
self._select_bg_color = value
def set_image_size(self, image_size):
@@ -93,13 +95,16 @@ class BL_UI_Button(BL_UI_Widget):
self.__image
self.__image.filepath
# self.__image.pixels
except Exception as e:
except Exception:
self.__image = None
def set_image_colorspace(self, colorspace: str = ""):
image_utils.set_colorspace(self.__image, colorspace)
def set_image(self, rel_filepath):
if rel_filepath is None:
self.__image = None
return
# first try to access the image, for cases where it can get removed
self.check_image_exists()
try:
@@ -117,6 +122,17 @@ class BL_UI_Button(BL_UI_Widget):
return None
return self.__image.filepath
@property
def image_padding(self):
return self.__image_padding
@image_padding.setter
def image_padding(self, padding: float):
self.__image_padding = padding
def get_image_padding(self):
return self.__image_padding
def update(self, x, y):
super().update(x, y)
self._textpos = [x, y]
@@ -127,19 +143,30 @@ class BL_UI_Button(BL_UI_Widget):
area_height = self.get_area_height()
gpu.state.blend_set("ALPHA")
fill_color = self._resolve_panel_color()
self.shader.bind()
self.set_colors()
self.batch_panel.draw(self.shader)
if self.use_rounded_background:
rect_y = area_height - self.y_screen - self.height
self.draw_background_rect(
self.x_screen,
rect_y,
self.width,
self.height,
fill_color,
force=True,
fill_color_override=fill_color,
)
else:
self.shader.bind()
self.shader.uniform_float("color", fill_color)
self.batch_panel.draw(self.shader)
self.draw_image()
# Draw text
self.draw_text(area_height)
def set_colors(self):
def _resolve_panel_color(self):
color = self._bg_color
# pressed
@@ -150,7 +177,7 @@ class BL_UI_Button(BL_UI_Widget):
elif self.__state == 2:
color = self._hover_bg_color
self.shader.uniform_float("color", color)
return color
def draw_text(self, area_height):
font_id = 1
@@ -165,9 +192,19 @@ class BL_UI_Button(BL_UI_Widget):
size = blf.dimensions(font_id, self._text)
# When an image is present, center text in the remaining space after the image
image_offset = 0
if self.__image is not None:
image_offset = self.__image_position[0] + self.__image_size[0]
textpos_y = area_height - self._textpos[1] - (self.height + size[1]) / 2.0
blf.position(
font_id, self._textpos[0] + (self.width - size[0]) / 2.0, textpos_y + 1, 0
font_id,
self._textpos[0]
+ image_offset
+ (self.width - image_offset - size[0]) / 2.0,
textpos_y + 1,
0,
)
r, g, b, a = self._text_color
@@ -180,15 +217,17 @@ class BL_UI_Button(BL_UI_Widget):
y_screen_flip = self.get_area_height() - self.y_screen
off_x, off_y = self.__image_position
sx, sy = self.__image_size
pad = self.__image_padding
ui_bgl.draw_image_runtime(
self.x_screen + off_x,
y_screen_flip - off_y - sy,
sx,
sy,
self.x_screen + off_x + pad,
y_screen_flip - off_y - sy + pad,
sx - 2 * pad,
sy - 2 * pad,
self.__image,
1.0,
crop=(0, 0, 1, 1),
batch=None,
corner_radius=self.image_corner_radius,
)
return True
return False
@@ -203,9 +242,7 @@ class BL_UI_Button(BL_UI_Widget):
self.mouse_down_func(self)
except Exception:
bk_logger.exception("BL_UI_BUTTON mouse_down() error:")
return True
return False
def set_mouse_down_right(self, mouse_down_right_func):
@@ -213,10 +250,11 @@ class BL_UI_Button(BL_UI_Widget):
def mouse_down_right(self, x, y):
if self.is_in_rect(x, y):
try:
self.mouse_down_right_func(self)
except Exception:
bk_logger.exception("BL_UI_BUTTON mouse_down_right() error:")
if hasattr(self, "mouse_down_right_func"):
try:
self.mouse_down_right_func(self)
except Exception:
bk_logger.exception("BL_UI_BUTTON mouse_down_right() error:")
return True

Some files were not shown because too many files have changed in this diff Show More