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\ D:\Amazon\00_external-files\
N:\1. CHARACTERS\remapping\ N:\1. CHARACTERS\remapping\
[Recent] [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\Assets\Blends\
P:\250827_FestivalTurf\Blends\animations\01 Develop Your Turf Plan\SPA\ T:\250827_FestivalTurf\Blends\animations\02 Preparing the Base\
P:\250827_FestivalTurf\Assets\Blends\Char\ P:\250827_FestivalTurf\Blends\animations\06 Infill And Powerbrooming\
P:\260217_Jarvis-Defense\Blends\animations\comp_RR\ A:\1 Amazon_Active_Projects\260127_Pick-and-Stage_2026_edits\Blends\stills\2025_old\img-BG\
C:\Users\Nathan\AppData\Local\Temp\ A:\1 Amazon_Active_Projects\260127_Pick-and-Stage_2026_edits\Blends\stills\2025_old\
T:\260217_Jarvis-Defense\Renders\Shot_4a\ A:\1 Amazon_Active_Projects\260326_RSR-Fluid-Unload\Blends\animations\
F:\renders\Shot_4a_holdout_test\2026-03-25_101242\ P:\250827_FestivalTurf\Assets\
F:\renders\ P:\250827_FestivalTurf\Blends\animations\02 Preparing the Base\bakes\07_compacting\
P:\250827_FestivalTurf\Blends\animations\01 Develop Your Turf Plan\ P:\250827_FestivalTurf\Blends\animations\02 Preparing the Base\
+3 -3
View File
@@ -1,9 +1,9 @@
{ {
"flamenco_version": { "flamenco_version": {
"version": "3.8.2", "version": "3.8.5",
"shortversion": "3.8.2", "shortversion": "3.8.5",
"name": "Flamenco", "name": "Flamenco",
"git": "51a41a19" "git": "690bf6ce"
}, },
"shared_storage": { "shared_storage": {
"location": "F:\\jobs", "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 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 11.blend
P:\250827_FestivalTurf\Blends\animations\05 Stretch Cut Nail\Visual 10.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\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 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_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_talking.blend
P:\250827_FestivalTurf\Blends\animations\05 Stretch Cut Nail\04_stretching pattern.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 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_talking.blend
P:\250827_FestivalTurf\Blends\animations\05 Stretch Cut Nail\02_kicker insert.blend P:\250827_FestivalTurf\Blends\animations\05 Stretch Cut Nail\02_kicker insert.blend
P:\260217_Jarvis-Defense\Blends\animations\Shot_Q.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\Shot_5c\Shot_Q.flamenco.blend
F:\jobs\Shot5c-e3zp\Shot_Q.flamenco.blend F:\jobs\Shot5c-e3zp\Shot_Q.flamenco.blend
P:\260217_Jarvis-Defense\Blends\animations\Shot_4.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\comp_RR\Shot_4_holdout.blend
P:\260217_Jarvis-Defense\Blends\animations\Shot_4_redo_fall.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\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_menger_sponge)
importlib.reload(add_mesh_vertex) importlib.reload(add_mesh_vertex)
importlib.reload(add_empty_as_parent) importlib.reload(add_empty_as_parent)
importlib.reload(add_mesh_equilateral_grid)
importlib.reload(add_mesh_beam_builder) importlib.reload(add_mesh_beam_builder)
importlib.reload(Blocks) importlib.reload(Blocks)
importlib.reload(Wallfactory) importlib.reload(Wallfactory)
@@ -59,6 +60,7 @@ else:
from . import Wallfactory from . import Wallfactory
from . import add_mesh_triangles from . import add_mesh_triangles
from . import preferences from . import preferences
from . import add_mesh_equilateral_grid
from .add_mesh_rocks import __init__ from .add_mesh_rocks import __init__
from .add_mesh_rocks import rockgen from .add_mesh_rocks import rockgen
@@ -154,6 +156,7 @@ class VIEW3D_MT_mesh_extras_add(Menu):
oper.change = False oper.change = False
oper = layout.operator("mesh.primitive_teapot_add", text="Teapot+") oper = layout.operator("mesh.primitive_teapot_add", text="Teapot+")
oper = layout.operator("mesh.menger_sponge_add", text="Menger Sponge") 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): class VIEW3D_MT_mesh_torus_add(Menu):
@@ -209,7 +212,7 @@ def menu_func(self, context):
if prefs.show_single_vert: if prefs.show_single_vert:
layout.menu("VIEW3D_MT_mesh_vert_add", text="Single Vert", icon='DECORATE') layout.menu("VIEW3D_MT_mesh_vert_add", text="Single Vert", icon='DECORATE')
if prefs.show_torus_objects: if prefs.show_torus_objects:
layout.menu("VIEW3D_MT_mesh_torus_add", text="Torus Objects", icon='MESH_TORUS') 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: if prefs.show_gears:
layout.menu("VIEW3D_MT_mesh_gears_add", text="Gears", icon='PREFERENCES') layout.menu("VIEW3D_MT_mesh_gears_add", text="Gears", icon='PREFERENCES')
if prefs.show_pipe_joints: if prefs.show_pipe_joints:
layout.menu("VIEW3D_MT_mesh_pipe_joints_add", text="Pipe Joints", icon='IPO_CONSTANT') 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_menger_sponge.AddMengerSponge,
add_mesh_vertex.AddVert, add_mesh_vertex.AddVert,
add_mesh_vertex.AddEmptyVert, add_mesh_vertex.AddEmptyVert,
add_mesh_equilateral_grid.MESH_OT_add_equilateral_grid,
add_mesh_vertex.AddSymmetricalEmpty, add_mesh_vertex.AddSymmetricalEmpty,
add_mesh_vertex.AddSymmetricalVert, add_mesh_vertex.AddSymmetricalVert,
add_empty_as_parent.P2E, 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" schema_version = "1.0.0"
id = "extra_mesh_objects" id = "extra_mesh_objects"
name = "Extra Mesh Objects" name = "Extra Mesh Objects"
version = "0.4.0" version = "0.4.1"
tagline = "Add extra mesh object types" tagline = "Add extra mesh object types"
maintainer = "Community" maintainer = "Community"
type = "add-on" type = "add-on"
@@ -4,7 +4,7 @@ Documentation: https://weisl.github.io/renaming_overview/
<h1>Introduction</h1> <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> 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"). 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), ('GPENCIL', "", "Rename greace pencil objects", 'OUTLINER_OB_GREASEPENCIL', 512),
('METABALL', "", "Rename metaball objects", 'OUTLINER_OB_META', 2048), ('METABALL', "", "Rename metaball objects", 'OUTLINER_OB_META', 2048),
('COLLECTION', "", "Rename collections", 'GROUP', 4096), ('COLLECTION', "", "Rename collections", 'GROUP', 4096),
('BONE', "", "", 'BONE_DATA', 8192), ] ('BONE', "", "", 'BONE_DATA', 8192),
('POINTCLOUD', "", "Rename point cloud objects", 'OUTLINER_OB_POINTCLOUD', 16384), ]
def register(): def register():
@@ -113,7 +114,8 @@ def register():
'MESH', 'MESH',
'ARMATURE', 'LIGHT', 'CAMERA', 'EMPTY', 'ARMATURE', 'LIGHT', 'CAMERA', 'EMPTY',
'GPENCIL', 'GPENCIL',
'TEXT', 'BONE', 'COLLECTION'} 'TEXT', 'BONE', 'COLLECTION',
'POINTCLOUD'}
) )
id_store.renaming_suffix_prefix_material = StringProperty(name='Material', default='') 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_bone = StringProperty(name="Bones", default='')
id_store.renaming_suffix_prefix_speakers = StringProperty(name="Speakers", 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_lightprops = StringProperty(name="LightProps", default='')
id_store.renaming_suffix_prefix_pointcloud = StringProperty(name="Point Cloud", default='')
id_store.renaming_inputContext = StringProperty(name="LightProps", 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() option: StringProperty()
def __init__(self):
self.context = None
def get_selection_all(self): def get_selection_all(self):
context = self.context context = self.context
@@ -266,6 +263,18 @@ class VIEW3D_OT_add_type_suf_pre(bpy.types.Operator):
icon='OUTLINER_OB_META') icon='OUTLINER_OB_META')
return 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): def collection(self):
context = self.context context = self.context
wm = context.scene wm = context.scene
@@ -303,6 +312,7 @@ class VIEW3D_OT_add_type_suf_pre(bpy.types.Operator):
self.metaball() self.metaball()
self.collection() self.collection()
self.bone() self.bone()
self.pointcloud()
self.material() self.material()
self.data() self.data()
@@ -26,4 +26,5 @@ paths_exclude_pattern = [
"__pycache__/", "__pycache__/",
"/.git/", "/.git/",
"/*.zip", "/*.zip",
"/tests/",
] ]
@@ -9,6 +9,9 @@ from bpy.props import (
) )
from . import add_pre_suffix 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_from_data
from . import name_replace from . import name_replace
from . import numerate from . import numerate
@@ -30,7 +33,8 @@ enumObjectTypes = [('EMPTY', "", "Rename empty objects", 'OUTLINER_OB_EMPTY', 1)
('META', "", "Rename metaball objects", 'OUTLINER_OB_META', 1024), ('META', "", "Rename metaball objects", 'OUTLINER_OB_META', 1024),
('SPEAKER', "", "Rename empty speakers", 'OUTLINER_OB_SPEAKER', 2048), ('SPEAKER', "", "Rename empty speakers", 'OUTLINER_OB_SPEAKER', 2048),
('LIGHT_PROBE', "", "Rename mesh lightpropes", 'OUTLINER_OB_LIGHTPROBE', 4096), ('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), enumObjectTypesAdd = [('SPEAKER', "", "Rename empty speakers", 'OUTLINER_OB_SPEAKER', 1),
('LIGHT_PROBE', "", "Rename mesh lightpropes", 'OUTLINER_OB_LIGHTPROBE', 2)] ('LIGHT_PROBE', "", "Rename mesh lightpropes", 'OUTLINER_OB_LIGHTPROBE', 2)]
@@ -60,6 +64,8 @@ renamingEntitiesItems = [('OBJECT', "Object", "Scene Objects"),
None, None,
('PARTICLESYSTEM', "Particle Systems", "Rename particle systems"), ('PARTICLESYSTEM', "Particle Systems", "Rename particle systems"),
('PARTICLESETTINGS', "Particle Settings", "Rename particle settings"), ('PARTICLESETTINGS', "Particle Settings", "Rename particle settings"),
None,
('NODE_GROUPS', "Node Groups", "Rename node groups"),
] ]
classes = ( classes = (
@@ -71,6 +77,13 @@ classes = (
add_pre_suffix.VIEW3D_OT_add_prefix, add_pre_suffix.VIEW3D_OT_add_prefix,
numerate.VIEW3D_OT_renaming_numerate, numerate.VIEW3D_OT_renaming_numerate,
name_from_data.VIEW3D_OT_use_objectname_for_data, 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."), enum_sort_items = [('X', "X Axis", "Sort the object based on the X axis."),
@@ -120,7 +133,8 @@ def register():
options={'ENUM_FLAG'}, options={'ENUM_FLAG'},
default={'CURVE', 'LATTICE', 'SURFACE', 'MESH', default={'CURVE', 'LATTICE', 'SURFACE', 'MESH',
'ARMATURE', 'LIGHT', 'CAMERA', 'EMPTY', 'GPENCIL', 'ARMATURE', 'LIGHT', 'CAMERA', 'EMPTY', 'GPENCIL',
'FONT', 'SPEAKER', 'LIGHT_PROBE', 'VOLUME'} 'FONT', 'SPEAKER', 'LIGHT_PROBE', 'VOLUME',
'POINTCLOUD'}
) )
id_store.renaming_sort_enum = EnumProperty( 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_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_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 from bpy.utils import register_class
for cls in classes: for cls in classes:
register_class(cls) register_class(cls)
bpy.app.handlers.depsgraph_update_post.append(PostChange) bpy.app.handlers.depsgraph_update_post.append(PostChange)
start_version_check()
def unregister(): def unregister():
@@ -192,5 +225,9 @@ def unregister():
del IDStore.renaming_base_numerate del IDStore.renaming_base_numerate
del IDStore.renaming_digits_numerate del IDStore.renaming_digits_numerate
del IDStore.renaming_trim_indices 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) bpy.app.handlers.depsgraph_update_post.remove(PostChange)
@@ -1,7 +1,9 @@
import time
import bpy import bpy
from .renaming_operators import switch_to_edit_mode 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 ..variable_replacer.variable_replacer import VariableReplacer
@@ -23,9 +25,11 @@ class VIEW3D_OT_add_suffix(bpy.types.Operator):
call_error_popup(context) call_error_popup(context)
return {'CANCELLED'} return {'CANCELLED'}
t_start = time.perf_counter()
msg = wm.renaming_messages msg = wm.renaming_messages
VariableReplacer.reset() VariableReplacer.reset()
VariableReplacer.prepare(context)
if len(renaming_list) > 0: if len(renaming_list) > 0:
for entity in renaming_list: for entity in renaming_list:
if entity is not None: if entity is not None:
@@ -34,11 +38,15 @@ class VIEW3D_OT_add_suffix(bpy.types.Operator):
oldName = entity.name oldName = entity.name
new_name = entity.name + suffix new_name = entity.name + suffix
entity.name = new_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) msg.add_message(oldName, entity.name)
else: else:
msg.add_message(None, None, "Insert Valid String") msg.add_message(None, None, "Insert Valid String")
if switch_edit_mode: if switch_edit_mode:
switch_to_edit_mode(context) switch_to_edit_mode(context)
log_timing(context, "add_suffix", t_start, len(renaming_list))
call_renaming_popup(context) call_renaming_popup(context)
return {'FINISHED'} return {'FINISHED'}
@@ -62,7 +70,9 @@ class VIEW3D_OT_add_prefix(bpy.types.Operator):
call_error_popup(context) call_error_popup(context)
return {'CANCELLED'} return {'CANCELLED'}
t_start = time.perf_counter()
VariableReplacer.reset() VariableReplacer.reset()
VariableReplacer.prepare(context)
if len(renaming_list) > 0: if len(renaming_list) > 0:
for entity in renaming_list: for entity in renaming_list:
@@ -72,8 +82,12 @@ class VIEW3D_OT_add_prefix(bpy.types.Operator):
oldName = entity.name oldName = entity.name
new_name = pre + entity.name new_name = pre + entity.name
entity.name = new_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) msg.add_message(oldName, entity.name)
log_timing(context, "add_prefix", t_start, len(renaming_list))
call_renaming_popup(context) call_renaming_popup(context)
if switch_edit_mode: if switch_edit_mode:
switch_to_edit_mode(context) 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 import bpy
from .renaming_operators import getAllVertexGroups, getAllAttributes, getAllBones, getAllModifiers, getAllUvMaps, \ from .renaming_operators import getAllModifiers, \
getAllColorAttributes, getAllParticleNames, getAllParticleSettingsNames, getAllDataNames, getAllShapeKeys getAllParticleNames, getAllParticleSettingsNames, getAllDataNames
from .renaming_operators import switch_to_edit_mode, numerate_entity_name 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 from ..variable_replacer.variable_replacer import VariableReplacer
@@ -27,57 +29,67 @@ class VIEW3D_OT_replace_name(bpy.types.Operator):
return {'CANCELLED'} return {'CANCELLED'}
old_mode = context.mode old_mode = context.mode
t_start = time.perf_counter()
# settings for numerating the new name # settings for numerating the new name
msg = scene.renaming_messages msg = scene.renaming_messages
vertexGroupNameList = [] per_object_types = {'SHAPEKEYS', 'VERTEXGROUPS', 'UVMAPS', 'COLORATTRIBUTES', 'ATTRIBUTES', 'BONE'}
particleSettingsList = [] per_obj_owner_items = {
particleList = [] 'SHAPEKEYS': lambda o: o.key_blocks,
uvmapsList = [] 'VERTEXGROUPS': lambda o: o.vertex_groups,
dataList = [] 'UVMAPS': lambda o: o.uv_layers,
attributeList = [] 'COLORATTRIBUTES': lambda o: o.color_attributes,
colorAttributeList = [] 'ATTRIBUTES': lambda o: o.attributes,
shapeKeyNamesList = [] 'BONE': lambda o: o.edit_bones if old_mode == 'EDIT_ARMATURE' else o.bones,
modifierNamesList = [] }
boneList = []
particleSettingsList = set()
particleList = set()
dataList = set()
modifierNamesList = set()
if context.scene.renaming_object_types == 'VERTEXGROUPS':
vertexGroupNameList = getAllVertexGroups()
if scene.renaming_object_types == 'PARTICLESYSTEM': if scene.renaming_object_types == 'PARTICLESYSTEM':
particleList = getAllParticleNames() particleList = set(getAllParticleNames())
if scene.renaming_object_types == 'PARTICLESETTINGS': if scene.renaming_object_types == 'PARTICLESETTINGS':
particleSettingsList = getAllParticleSettingsNames() particleSettingsList = set(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()
if scene.renaming_object_types == 'MODIFIERS': if scene.renaming_object_types == 'MODIFIERS':
modifierNamesList = getAllModifiers() modifierNamesList = set(getAllModifiers())
if scene.renaming_object_types == 'BONE':
boneList = getAllBones(old_mode)
if scene.renaming_object_types == 'DATA': if scene.renaming_object_types == 'DATA':
dataList = getAllDataNames() dataList = set(getAllDataNames())
current_owner = None
per_obj_name_list = set()
VariableReplacer.reset() VariableReplacer.reset()
VariableReplacer.prepare(context)
if len(str(replaceName)) > 0: # New name != empty if len(str(replaceName)) > 0: # New name != empty
if len(renaming_list) > 0: # List of objects to rename != empty if len(renaming_list) > 0: # List of objects to rename != empty
for entity in renaming_list: for entity in renaming_list:
if entity is not None: 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) replaceName = VariableReplacer.replaceInputString(context, scene.renaming_new_name, entity)
oldName = entity.name oldName = entity.name
new_name = '' new_name = ''
if not scene.renaming_use_enumerate: if not scene.renaming_use_enumerate:
entity.name = replaceName try:
msg.add_message(oldName, entity.name) 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 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, new_name, dataList = numerate_entity_name(context, replaceName, dataList, entity.name,
return_type_list=True) 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': elif scene.renaming_object_types == 'COLLECTION':
new_name = numerate_entity_name(context, replaceName, bpy.data.collections, entity.name) new_name = numerate_entity_name(context, replaceName, bpy.data.collections, entity.name)
elif scene.renaming_object_types == 'ACTIONS': elif scene.renaming_object_types == 'ACTIONS':
new_name = numerate_entity_name(context, replaceName, bpy.data.actions, entity.name) new_name = numerate_entity_name(context, replaceName, bpy.data.actions, entity.name)
elif scene.renaming_object_types == 'SHAPEKEYS': elif scene.renaming_object_types in per_object_types:
new_name, shapeKeyNamesList = numerate_entity_name(context, replaceName, new_name, per_obj_name_list = numerate_entity_name(context, replaceName,
shapeKeyNamesList, entity.name, per_obj_name_list, entity.name,
return_type_list=True) return_type_list=True)
elif scene.renaming_object_types == 'MODIFIERS': elif scene.renaming_object_types == 'MODIFIERS':
new_name, modifierNamesList = numerate_entity_name(context, replaceName, new_name, modifierNamesList = numerate_entity_name(context, replaceName,
modifierNamesList, entity.name, modifierNamesList, entity.name,
return_type_list=True) 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, new_name, particleList = numerate_entity_name(context, replaceName,
particleList, entity.name, particleList, entity.name,
return_type_list=True) 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, new_name, particleSettingsList = numerate_entity_name(context, replaceName,
particleSettingsList, entity.name, particleSettingsList, entity.name,
return_type_list=True) 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: try:
entity.name = new_name 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) msg.add_message(oldName, entity.name)
except AttributeError: except AttributeError:
print("Attribute {} is read only".format(new_name)) 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 else: # len(str(replaceName)) <= 0
msg.add_message(None, None, "Insert a valid string to replace names") 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) call_renaming_popup(context)
if switch_edit_mode: if switch_edit_mode:
switch_to_edit_mode(context) switch_to_edit_mode(context)
@@ -1,8 +1,10 @@
import time
import bpy import bpy
from .renaming_operators import switch_to_edit_mode from .renaming_operators import switch_to_edit_mode
from .. import __package__ as base_package 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): class VIEW3D_OT_renaming_numerate(bpy.types.Operator):
@@ -32,17 +34,31 @@ class VIEW3D_OT_renaming_numerate(bpy.types.Operator):
call_error_popup(context) call_error_popup(context)
return {'CANCELLED'} 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: if len(renaming_list) > 0:
i = 0 i = 0
current_owner = None
for entity in renaming_list: for entity in renaming_list:
if entity is not None: 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 oldName = entity.name
new_name = entity.name + separator + ( new_name = entity.name + separator + (
'{num:{fill}{width}}'.format(num=(i * step) + start_number, fill='0', width=digits)) '{num:{fill}{width}}'.format(num=(i * step) + start_number, fill='0', width=digits))
entity.name = new_name 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) msg.add_message(oldName, entity.name)
i = i + 1 i = i + 1
log_timing(context, "numerate", t_start, len(renaming_list))
call_renaming_popup(context) call_renaming_popup(context)
if switch_edit_mode: if switch_edit_mode:
switch_to_edit_mode(context) 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)) '{num:{fill}{width}}'.format(num=(i * step) + start_number, fill='0', width=digits))
i += 1 i += 1
if return_type_list: # Manually add new name to custom generated list like all bones and all shape keys if return_type_list: # Manually add new name to custom generated set like all bones and all shape keys
type_list.append(new_name) type_list.add(new_name)
return new_name, type_list return new_name, type_list
return new_name return new_name
def getAllBones(mode): def getAllBones(mode):
"""Get list of all bones depending on Edit or Pose Mode""" """Get list of all bone names depending on Edit or Pose Mode"""
boneList = [] if mode == 'POSE':
return [bone.name for arm in bpy.data.armatures for bone in arm.bones]
for arm in bpy.data.armatures: else: # mode == 'EDIT'
if mode == 'POSE': return [bone.name for arm in bpy.data.armatures for bone in arm.edit_bones]
for bone in arm.bones:
boneList.append(bone.name)
else: # mode == 'EDIT':
for bone in arm.edit_bones:
boneList.append(bone.name)
return boneList
def getAllModifiers(): def getAllModifiers():
"""get list of all modifiers""" """get list of all modifier names"""
modifierList = [] return [mod.name for obj in bpy.data.objects for mod in obj.modifiers]
for obj in bpy.data.objects:
for mod in obj.modifiers:
modifierList.append(mod.name)
return modifierList
def getAllShapeKeys(): def getAllShapeKeys():
"""get list of all shape keys""" """get list of all shape key names"""
shapeKeyNamesList = [] return [key.name for key_grp in bpy.data.shape_keys for key in key_grp.key_blocks]
for key_grp in bpy.data.shape_keys:
for key in key_grp.key_blocks:
shapeKeyNamesList.append(key.name)
return shapeKeyNamesList
def getAllVertexGroups(): def getAllVertexGroups():
"""get list of all vertex groups""" """get list of all vertex group names"""
vrtx_grp_names_list = [] return [vg.name for obj in bpy.data.objects for vg in obj.vertex_groups]
for obj in bpy.data.objects:
for vrtGrp in obj.vertex_groups:
vrtx_grp_names_list.append(vrtGrp.name)
return vrtx_grp_names_list
def getAllParticleNames(): def getAllParticleNames():
"""get list of all particle systems""" """get list of all particle system names"""
particlesNamesList = [] return [ps.name for obj in bpy.data.objects for ps in obj.particle_systems]
for obj in bpy.data.objects:
for particle_system in obj.particle_systems:
particlesNamesList.append(particle_system.name)
return particlesNamesList
def getAllParticleSettingsNames(): def getAllParticleSettingsNames():
"""get list of all particle settings""" """get list of all particle settings names"""
particlesNamesList = [] return [par.name for par in bpy.data.particles]
for par in bpy.data.particles:
particlesNamesList.append(par.name)
return particlesNamesList
def getAllUvMaps(): def getAllUvMaps():
uvNamesList = [] """get list of all UV map names"""
for obj in bpy.data.objects: return [uv.name for obj in bpy.data.objects if obj.type == 'MESH'
if obj.type != 'MESH': for uv in obj.data.uv_layers]
continue
for uv in obj.data.uv_layers:
uvNamesList.append(uv)
return uvNamesList
def getAllColorAttributes(): def getAllColorAttributes():
colorAttributesList = [] """get list of all color attribute names"""
return [ca.name for obj in bpy.data.objects if obj.type == 'MESH'
for obj in bpy.data.objects: for ca in obj.data.color_attributes]
if obj.type != 'MESH':
continue
for color_attribute in obj.data.color_attributes:
colorAttributesList.append(color_attribute)
return colorAttributesList
def getAllAttributes(): def getAllAttributes():
attributesList = [] """get list of all attribute names"""
return [attr.name for obj in bpy.data.objects if obj.type == 'MESH'
for obj in bpy.data.objects: for attr in obj.data.attributes]
if obj.type != 'MESH':
continue
for color_attribute in obj.data.color_attributes:
attributesList.append(color_attribute)
return attributesList
def getAllDataNames(): def getAllDataNames():
"""get list of all data""" """get list of all data names"""
dataList = [] return [obj.data.name for obj in bpy.data.objects if obj.data is not None]
for obj in bpy.data.objects:
if obj.data is not None:
dataList.append(obj.data.name)
return dataList
@@ -1,9 +1,22 @@
import time
import bpy import bpy
from bpy.types import PoseBone, EditBone from bpy.types import PoseBone, EditBone
from .. import __package__ as base_package 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): def trim_string(string, size):
return string[size[0]:max(0, len(string)-size[1])] 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': if scene.renaming_object_types == 'OBJECT':
for obj in obj_list: 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) renaming_list.append(obj)
elif scene.renaming_object_types == 'DATA': elif scene.renaming_object_types == 'DATA':
seen_data = set()
for obj in obj_list: 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) renaming_list.append(obj.data)
elif scene.renaming_object_types == 'MATERIAL': elif scene.renaming_object_types == 'MATERIAL':
@@ -117,14 +132,25 @@ def get_renaming_list(context):
renaming_list = list(bpy.data.collections) renaming_list = list(bpy.data.collections)
elif scene.renaming_object_types == 'SHAPEKEYS': elif scene.renaming_object_types == 'SHAPEKEYS':
filter_index = scene.renaming_filter_by_index
idx = scene.renaming_index_target
if selection_only: if selection_only:
for obj in context.selected_objects: for obj in context.selected_objects:
for shape in obj.data.shape_keys.key_blocks: if obj.data and obj.data.shape_keys:
renaming_list.append(shape) 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: else: # selection_only == False:
for key_grp in bpy.data.shape_keys: for key_grp in bpy.data.shape_keys:
for key in key_grp.key_blocks: items = list(key_grp.key_blocks)
renaming_list.append(key) if filter_index:
if idx < len(items):
renaming_list.append(items[idx])
else:
renaming_list.extend(items)
elif scene.renaming_object_types == 'MODIFIERS': elif scene.renaming_object_types == 'MODIFIERS':
if selection_only: if selection_only:
@@ -137,14 +163,16 @@ def get_renaming_list(context):
renaming_list.append(mod) renaming_list.append(mod)
elif context.scene.renaming_object_types == 'VERTEXGROUPS': elif context.scene.renaming_object_types == 'VERTEXGROUPS':
if selection_only: filter_index = scene.renaming_filter_by_index
for obj in context.selected_objects: idx = scene.renaming_index_target
for vtx in obj.vertex_groups: obj_iter = context.selected_objects if selection_only else bpy.data.objects
renaming_list.append(vtx) for obj in obj_iter:
else: items = list(obj.vertex_groups)
for obj in bpy.data.objects: if filter_index:
for vtx in obj.vertex_groups: if idx < len(items):
renaming_list.append(vtx) renaming_list.append(items[idx])
else:
renaming_list.extend(items)
elif context.scene.renaming_object_types == 'PARTICLESYSTEM': elif context.scene.renaming_object_types == 'PARTICLESYSTEM':
if selection_only: if selection_only:
@@ -162,27 +190,64 @@ def get_renaming_list(context):
elif context.scene.renaming_object_types == 'UVMAPS': 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: for obj in obj_list:
if obj.type != 'MESH': if obj.type != 'MESH':
continue continue
for uv in obj.data.uv_layers: if filter_index:
renaming_list.append(uv) 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': 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: for obj in obj_list:
if obj.type != 'MESH': if obj.type != 'MESH':
continue continue
for color_attribute in obj.data.color_attributes: if filter_index:
renaming_list.append(color_attribute) 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': elif context.scene.renaming_object_types == 'ATTRIBUTES':
filter_index = scene.renaming_filter_by_index
idx = scene.renaming_index_target
for obj in obj_list: for obj in obj_list:
if obj.type != 'MESH': if obj.type != 'MESH':
continue continue
for attribute in obj.data.attributes: items = list(obj.data.attributes)
renaming_list.append(attribute) 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': elif scene.renaming_object_types == 'ACTIONS':
if selection_only: if selection_only:
@@ -263,6 +328,46 @@ def get_sorted_objects_z(objects):
return sorted_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): def clear_order_flag(obj):
try: try:
del obj["selection_order"] del obj["selection_order"]
@@ -271,7 +376,7 @@ def clear_order_flag(obj):
def update_selection_order(): 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: for o in bpy.data.objects:
clear_order_flag(o) clear_order_flag(o)
return return
@@ -1,10 +1,102 @@
import re import re
import time
import bpy import bpy
from .renaming_operators import switch_to_edit_mode 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 ..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): 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) call_error_popup(context)
return {'CANCELLED'} return {'CANCELLED'}
t_start = time.perf_counter()
searchName = wm.renaming_search searchName = wm.renaming_search
msg = wm.renaming_messages # variable to save messages msg = wm.renaming_messages # variable to save messages
VariableReplacer.reset() 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: if len(renaming_list) > 0:
for entity in renaming_list: # iterate over all objects that are to be renamed 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 not wm.renaming_useRegex:
if wm.renaming_matchcase: if wm.renaming_matchcase:
new_name = str(entity.name).replace(searchReplaced, replaceReplaced) new_name = str(entity.name).replace(searchReplaced, replaceReplaced)
entity.name = new_name
msg.add_message(oldName, entity.name)
else: else:
replaceSearch = re.compile(re.escape(searchReplaced), re.IGNORECASE) pattern = static_pattern or re.compile(re.escape(searchReplaced), re.IGNORECASE)
new_name = replaceSearch.sub(replaceReplaced, entity.name) new_name = pattern.sub(replaceReplaced, entity.name)
entity.name = new_name
msg.add_message(oldName, entity.name)
else: # Use regex else: # Use regex
# pattern = re.compile(re.escape(searchName)) new_name = regex_case_sub(searchReplaced, replaceReplaced, str(entity.name))
new_name = re.sub(searchReplaced, replaceReplaced, str(entity.name)) entity.name = new_name
entity.name = new_name rename_data_if_enabled(wm, entity)
msg.add_message(oldName, entity.name) 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) call_renaming_popup(context)
if switch_edit_mode: if switch_edit_mode:
switch_to_edit_mode(context) switch_to_edit_mode(context)
@@ -29,6 +29,7 @@ class VIEW3D_OT_search_and_select(VIEW3D_OT_naming):
def execute(self, context): def execute(self, context):
super().execute(context) super().execute(context)
VariableReplacer.prepare(context)
wm = context.scene wm = context.scene
# get list of objects to be selected # get list of objects to be selected
@@ -56,8 +57,15 @@ class VIEW3D_OT_search_and_select(VIEW3D_OT_naming):
selectionList.append(entity) selectionList.append(entity)
msg.add_message("selected", entityName) msg.add_message("selected", entityName)
else: else:
if re.search(searchReplaced, entityName, re.IGNORECASE): try:
selectionList.append(entity) 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': if str(wm.renaming_object_types) == 'OBJECT':
# set to object mode # set to object mode
@@ -74,7 +82,7 @@ class VIEW3D_OT_search_and_select(VIEW3D_OT_naming):
if bpy.context.mode == 'POSE': if bpy.context.mode == 'POSE':
bpy.ops.pose.select_all(action='DESELECT') bpy.ops.pose.select_all(action='DESELECT')
for bone in selectionList: for bone in selectionList:
bone.select = True bone.bone.select = True
elif bpy.context.mode == 'EDIT_ARMATURE': elif bpy.context.mode == 'EDIT_ARMATURE':
bpy.ops.armature.select_all(action='DESELECT') bpy.ops.armature.select_all(action='DESELECT')
@@ -1,8 +1,11 @@
import time
import bpy import bpy
from .renaming_operators import switch_to_edit_mode 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): class VIEW3D_OT_trim_string(bpy.types.Operator):
bl_idname = "renaming.trim_string" bl_idname = "renaming.trim_string"
bl_label = "Trim String" bl_label = "Trim String"
@@ -19,6 +22,7 @@ class VIEW3D_OT_trim_string(bpy.types.Operator):
call_error_popup(context) call_error_popup(context)
return {'CANCELLED'} return {'CANCELLED'}
t_start = time.perf_counter()
msg = wm.renaming_messages msg = wm.renaming_messages
if len(renaming_list) > 0: if len(renaming_list) > 0:
@@ -27,11 +31,15 @@ class VIEW3D_OT_trim_string(bpy.types.Operator):
old_name = entity.name old_name = entity.name
new_name = trim_string(entity.name, wm.renaming_trim_indices) new_name = trim_string(entity.name, wm.renaming_trim_indices)
entity.name = new_name 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) msg.add_message(old_name, entity.name)
log_timing(context, "trim_string", t_start, len(renaming_list))
call_renaming_popup(context) call_renaming_popup(context)
if switch_edit_mode: if switch_edit_mode:
switch_to_edit_mode(context) switch_to_edit_mode(context)
return {'FINISHED'} 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() property_prefix: bpy.props.StringProperty()
def __init__(self):
self.prefs = None
self.my_event = ''
def invoke(self, context, event): def invoke(self, context, event):
prefs = context.preferences.addons[base_package].preferences prefs = context.preferences.addons[base_package].preferences
self.prefs = prefs self.prefs = prefs
@@ -57,7 +53,7 @@ class BUTTON_OT_change_key(bpy.types.Operator):
def add_keymap(): 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 prefs = bpy.context.preferences.addons[base_package].preferences
kmi = km.keymap_items.new(idname='wm.call_panel', type=prefs.renaming_panel_type, value='PRESS', 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): def remove_key(context, idname, properties_name):
"""Removes addon hotkeys from the keymap""" """Removes addon hotkeys from the keymap"""
wm = bpy.context.window_manager wm = bpy.context.window_manager
km = wm.keyconfigs.addon.keymaps['Window'] km = wm.keyconfigs.active.keymaps['Window']
for kmi in km.keymap_items: for kmi in km.keymap_items:
if kmi.idname == idname and kmi.properties.name == properties_name: 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. """ """Removes keys from the keymap. Currently, this is only called when unregistering the addon. """
# only works for menus and pie menus # only works for menus and pie menus
wm = bpy.context.window_manager wm = bpy.context.window_manager
km = wm.keyconfigs.addon.keymaps['Window'] km = wm.keyconfigs.active.keymaps['Window']
for kmi in km.keymap_items: for kmi in km.keymap_items:
if hasattr(kmi.properties, 'name') and kmi.properties.name in ['VIEW3D_PT_tools_renaming_panel', 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 .renaming_keymap import remove_key
from .. import __package__ as base_package 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): 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): def update_key(context, operation, operator_name, property_prefix):
# This functions gets called when the hotkey assignment is updated in the preferences # This functions gets called when the hotkey assignment is updated in the preferences
wm = context.window_manager wm = context.window_manager
km = wm.keyconfigs.addon.keymaps["Window"] km = wm.keyconfigs.active.keymaps["Window"]
prefs = context.preferences.addons[base_package].preferences prefs = context.preferences.addons[base_package].preferences
@@ -51,6 +50,7 @@ def update_suf_pre_key(self, context):
def update_panel_category(self, context): def update_panel_category(self, context):
"""Update panel tab for collider tools""" """Update panel tab for collider tools"""
from ..ui.renaming_panels import VIEW3D_PT_tools_renaming_panel, VIEW3D_PT_tools_type_suffix
panels = [ panels = [
VIEW3D_PT_tools_renaming_panel, VIEW3D_PT_tools_renaming_panel,
@@ -70,6 +70,7 @@ def update_panel_category(self, context):
def toggle_suffix_prefix_panel(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: if self.renaming_show_suffix_prefix_panel:
bpy.utils.register_class(VIEW3D_PT_tools_type_suffix) bpy.utils.register_class(VIEW3D_PT_tools_type_suffix)
else: else:
@@ -107,6 +108,12 @@ class VIEW3D_OT_renaming_preferences(bpy.types.AddonPreferences):
default=True, 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( renamingPanel_useObjectOrder: bpy.props.BoolProperty(
name="Use Selection Order", name="Use Selection Order",
description="Use the order of selection when renaming objects", description="Use the order of selection when renaming objects",
@@ -162,6 +169,25 @@ class VIEW3D_OT_renaming_preferences(bpy.types.AddonPreferences):
default='', 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( renaming_show_suffix_prefix_panel: bpy.props.BoolProperty(
name="Prefix/Suffix by Type Panel", name="Prefix/Suffix by Type Panel",
description="Enable or disable the 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", "renamingPanel_showPopup",
"renaming_show_suffix_prefix_panel", "renaming_show_suffix_prefix_panel",
"renamingPanel_useObjectOrder", "renamingPanel_useObjectOrder",
"debug_timing",
] ]
props_naming = [ props_naming = [
"renaming_separator", "renaming_separator",
@@ -205,6 +231,11 @@ class VIEW3D_OT_renaming_preferences(bpy.types.AddonPreferences):
"renaming_user3" "renaming_user3"
] ]
props_date_time = [
"date_format",
"time_format",
]
renaming_panel_type: bpy.props.StringProperty( renaming_panel_type: bpy.props.StringProperty(
name="Renaming Popup", name="Renaming Popup",
default="F2", default="F2",
@@ -325,6 +356,13 @@ class VIEW3D_OT_renaming_preferences(bpy.types.AddonPreferences):
row = box.row() row = box.row()
row.prop(self, propName) 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() box = layout.box()
row = box.row() row = box.row()
row.label(text='User Variables') row.label(text='User Variables')
@@ -2,7 +2,7 @@ import bpy
from .info_messages import RENAMING_MESSAGES, WarningError_MESSAGES, INFO_MESSAGES 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, \ 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_panels import panel_func
from .renaming_popup import VIEW3D_PT_renaming_popup, VIEW3D_PT_info_popup, VIEW3D_PT_error_popup 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 from .renaming_variables import RENAMING_MT_variableMenu, VIEW3D_OT_inputVariables
@@ -10,6 +10,7 @@ from .ui_helpers import PREFERENCES_OT_open_addon
classes = ( classes = (
RENAMING_MT_variableMenu, RENAMING_MT_variableMenu,
RENAMING_MT_caseMenu,
VIEW3D_OT_inputVariables, VIEW3D_OT_inputVariables,
VIEW3D_PT_error_popup, VIEW3D_PT_error_popup,
VIEW3D_PT_info_popup, VIEW3D_PT_info_popup,
@@ -42,6 +43,8 @@ def register():
def unregister(): def unregister():
from bpy.utils import unregister_class from bpy.utils import unregister_class
VIEW3D_PT_tools_type_suffix.remove(panel_func)
for cls in reversed(classes): for cls in reversed(classes):
unregister_class(cls) unregister_class(cls)
@@ -15,6 +15,13 @@ types_of_selected = (
def draw_renaming_panel(layout, context): 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 scene = context.scene
row = layout.row(align=True) row = layout.row(align=True)
@@ -25,8 +32,18 @@ def draw_renaming_panel(layout, context):
# SELECTED # SELECTED
if str(scene.renaming_object_types) == 'OBJECT': if str(scene.renaming_object_types) == 'OBJECT':
layout.prop(scene, "renaming_object_types_specified", expand=True) 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: if str(scene.renaming_object_types) in types_of_selected:
layout.prop(scene, "renaming_only_selection", text="Only Of Selected Objects") 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: elif str(scene.renaming_object_types) in types_selected:
layout.prop(scene, "renaming_only_selection", text="Only Selected") layout.prop(scene, "renaming_only_selection", text="Only Selected")
elif str(scene.renaming_object_types) == 'COLLECTION': elif str(scene.renaming_object_types) == 'COLLECTION':
@@ -44,7 +61,7 @@ def draw_renaming_panel(layout, context):
box = layout box = layout
# Sorting # 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 = box.column(align=True)
col.prop(scene, "renaming_sorting") col.prop(scene, "renaming_sorting")
if scene.renaming_sorting: if scene.renaming_sorting:
@@ -140,7 +157,9 @@ def draw_renaming_panel(layout, context):
layout.label(text="Other") layout.label(text="Other")
row = layout.row(align=True) 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'): if str(scene.renaming_object_types) in ('DATA', 'OBJECT', 'ADDOBJECTS'):
layout.separator() 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 = row.operator("preferences.rename_addon_search", text="", icon='PREFERENCES')
op.addon_name = addon_name op.addon_name = addon_name
op.prefs_tabs = 'UI' op.prefs_tabs = 'UI'
row.operator("renaming.reload_addon", text="", icon='FILE_REFRESH')
def draw(self, context): def draw(self, context):
layout = self.layout 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.prop(scene, "renaming_suffix_prefix_lightprops", text="")
row.operator('renaming.add_suffix_prefix_by_type', text="Light Probes").option = 'lightprops' 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 = col.row()
row.operator('renaming.add_suffix_prefix_by_type', text="Rename All").option = 'all' 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): class VIEW3D_OT_SetVariable(bpy.types.Operator):
"""Tooltip""" """Tooltip"""
bl_idname = "object.renaming_set_variable" bl_idname = "object.renaming_set_variable"
@@ -353,6 +393,7 @@ class AddPresetRenamingPresets(AddPresetBase, Operator):
"scene.renaming_suffix_prefix_bone", "scene.renaming_suffix_prefix_bone",
"scene.renaming_suffix_prefix_speakers", "scene.renaming_suffix_prefix_speakers",
"scene.renaming_suffix_prefix_lightprops", "scene.renaming_suffix_prefix_lightprops",
"scene.renaming_suffix_prefix_pointcloud",
] ]
# where to store the preset # 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="PARENT").renaming_variables = "PARENT"
layout.operator("object.renaming_multivariables", text="DATA").renaming_variables = "DATA" 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="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="TYPE").renaming_variables = "TYPE"
layout.operator("object.renaming_multivariables", text="COLLECTION").renaming_variables = "COLLECTION" 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): class VIEW3D_OT_inputVariables(bpy.types.Operator):
"""Tooltip""" """Tooltip"""
@@ -22,7 +22,7 @@ class PREFERENCES_OT_open_addon(bpy.types.Operator):
prefs.prefs_tabs = self.prefs_tabs prefs.prefs_tabs = self.prefs_tabs
import addon_utils 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 :/ # mod is None the first time the operation is called :/
if mod: if mod:
@@ -7,6 +7,11 @@ import bpy
from .. import __package__ as base_package 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): def generate_random_string(string_length=10):
"""Generate a random string of fixed length """ """Generate a random string of fixed length """
@@ -23,6 +28,12 @@ class VariableReplacer:
step = 1 step = 1
start_number = 0 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 @classmethod
def reset(cls): def reset(cls):
"""reset all values to initial state""" """reset all values to initial state"""
@@ -38,44 +49,126 @@ class VariableReplacer:
cls.number = 0 cls.number = 0
@classmethod @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""" """Replace custom variables with the according string"""
wm = context.scene wm = context.scene
cls.addon_prefs = context.preferences.addons[base_package].preferences cls.addon_prefs = context.preferences.addons[base_package].preferences
# System and Global Values # if '@' not in inputText:
inputText = re.sub(r'@f', cls.getfileName(context), inputText) # file name return inputText
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)
# UserStrings # # Find only the variables present in this template so we skip calling
inputText = re.sub(r'@h', cls.get_high_variable(), inputText) # high # getters that are not needed (lazy evaluation).
inputText = re.sub(r'@l', cls.get_low_variable(), inputText) # low vars_present = set(_VARIABLE_RE.findall(inputText))
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)
# GetScene # replacements = {}
inputText = re.sub(r'@a', cls.getActive(context), inputText) # active object
inputText = re.sub(r'@n', cls.getNumber(), inputText) 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': if wm.renaming_object_types == 'OBJECT':
# Objects if '@o' in vars_present:
inputText = re.sub(r'@o', cls.getObject(entity), inputText) # object replacements['@o'] = cls.getObject(entity)
inputText = re.sub(r'@t', cls.getType(entity), inputText) # type if '@t' in vars_present:
inputText = re.sub(r'@p', cls.getParent(entity), inputText) # parent replacements['@t'] = cls.getType(entity)
inputText = re.sub(r'@m', cls.getData(entity), inputText) # data if '@p' in vars_present:
inputText = re.sub(r'@c', cls.getCollection(entity), inputText) # collection 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': if wm.renaming_object_types == 'IMAGE':
inputText = re.sub(r'@r', 'RESOLUTION', inputText) if '@r' in vars_present:
inputText = re.sub(r'@i', 'FILETYPE', inputText) 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 @staticmethod
def getRandomString(): def getRandomString():
@@ -125,26 +218,24 @@ class VariableReplacer:
@classmethod @classmethod
def getfileName(cls, context): def getfileName(cls, context):
scn = context.scene
if bpy.data.is_saved: if bpy.data.is_saved:
filename = bpy.path.display_name(context.blend_data.filepath) filename = bpy.path.display_name(context.blend_data.filepath)
else: else:
filename = "UNSAVED" 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 return filename
@classmethod @classmethod
def getDateName(cls): def getDateName(cls):
t = time.localtime() date_format = cls.addon_prefs.date_format if cls.addon_prefs else "%d%b%Y"
t = time.mktime(t) return time.strftime(date_format, time.localtime())
return time.strftime("%d%b%Y", time.gmtime(t))
@classmethod @classmethod
def getTimeName(cls): def getTimeName(cls):
t = time.localtime() time_format = cls.addon_prefs.time_format if cls.addon_prefs else "%H%M"
t = time.mktime(t) return time.strftime(time_format, time.localtime())
return time.strftime("%H:%M", time.gmtime(t))
@classmethod @classmethod
def getActive(cls, context): def getActive(cls, context):
@@ -161,29 +252,60 @@ class VariableReplacer:
@classmethod @classmethod
def getType(cls, entity): 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 @classmethod
def getParent(cls, entity): def getParent(cls, entity):
if entity.parent is not None: if entity is None:
return str(entity.parent.name) return "NO_PARENT"
else: try:
return entity.name if entity.parent is not None:
return str(entity.parent.name)
else:
return entity.name
except AttributeError:
return "NO_PARENT"
@classmethod @classmethod
def getData(cls, entity): def getData(cls, entity):
if entity.data is not None: if entity is None:
return str(entity.data.name) return "NO_DATA"
else: try:
return entity.name if entity.data is not None:
return str(entity.data.name)
else:
return entity.name
except AttributeError:
return "NO_DATA"
@classmethod @classmethod
def getCollection(cls, entity): def getCollection(cls, entity):
"""O(1) lookup using cache built by prepare()."""
return cls._collection_cache.get(entity.name, "")
collectionew_names = "" @classmethod
for collection in bpy.data.collections: def getOwnerObjectName(cls, entity):
collection_objects = collection.objects """Find the owner object name using caches built by prepare()."""
if entity.name in collection.objects and entity in collection_objects[:]: id_data = getattr(entity, 'id_data', None)
collectionew_names += collection.name 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", "id": "atomic_data_manager",
"name": "Atomic Data Manager", "name": "Atomic Data Manager",
"tagline": "Smart cleanup and inspection of Blender data-blocks", "tagline": "Smart cleanup and inspection of Blender data-blocks",
"version": "2.5.0", "version": "2.6.2",
"type": "add-on", "type": "add-on",
"maintainer": "RaincloudTheDragon", "maintainer": "RaincloudTheDragon",
"license": [ "license": [
@@ -70,9 +70,9 @@
"management", "management",
"cleanup" "cleanup"
], ],
"archive_url": "https://github.com/RaincloudTheDragon/atomic-data-manager/releases/download/v2.5.0/Atomic_Data_Manager.v2.5.0.zip", "archive_url": "https://github.com/RaincloudTheDragon/atomic-data-manager/releases/download/v2.6.2/Atomic_Data_Manager.v2.6.2.zip",
"archive_size": 114674, "archive_size": 121191,
"archive_hash": "sha256:4b4834ed3910a428d4cb01f1891247ad80089b6c5324fc27c6862b09e81ff1c1" "archive_hash": "sha256:1f4af882cdf73d3bb0b8cf1badc094b179bf9e982486ee516c45a6a2d478c05d"
}, },
{ {
"schema_version": "1.0.0", "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 ## [v2.5.0] - 2026-01-28
### Features ### 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 ### 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). - 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. - RNA analysis: expand datablock coverage and refine dependency tracking to reduce false “unused” results.
### Internal ### Internal
- Maintenance: remove deprecated recovery option; improve ignore rules for hidden dot-directories. - Maintenance: remove deprecated recovery option; improve ignore rules for hidden dot-directories.
## [v2.4.1] - 2026-01-14 ## [v2.4.1] - 2026-01-14
### Fixes ### Fixes
- Fixed RNA analysis crashes when opening new blend files by rebuilding data-block type references dynamically - 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 indentation errors that prevented RNA dump from processing most data-blocks
- Fixed compositing nodetree detection by adding scenes as root nodes in dependency graph - Fixed compositing nodetree detection by adding scenes as root nodes in dependency graph
@@ -22,6 +48,7 @@
## [v2.4.0] - 2026-01-13 ## [v2.4.0] - 2026-01-13
### Features ### Features
- **Major Architecture Change: RNA-Based Analysis System** - **Major Architecture Change: RNA-Based Analysis System**
- Replaced multi-process worker system with faster, more robust RNA-based dependency analysis - Replaced multi-process worker system with faster, more robust RNA-based dependency analysis
- All data types now use unified RNA introspection for dependency tracking - 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 - Items now display in 4-column grid layout to reduce vertical scrolling
### Fixes ### Fixes
- Fixed node groups used by objects via Geometry Nodes modifiers not being detected as used - 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 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 - 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 - Fixed RNA extraction handling for objects' modifier node groups
### Performance ### Performance
- Significantly faster scanning across all categories using RNA analysis - Significantly faster scanning across all categories using RNA analysis
- Single-pass dependency graph building shared across all category scans - Single-pass dependency graph building shared across all category scans
## [v2.3.1] - 2026-01-13 ## [v2.3.1] - 2026-01-13
### Fixes ### Fixes
- Integrate proper UDIM detection - Integrate proper UDIM detection
## [v2.3.0] - 2026-01-06 ## [v2.3.0] - 2026-01-06
### Features ### Features
- Added "Enable Debug Prints" preference to control debug console output - Added "Enable Debug Prints" preference to control debug console output
- Debug messages now only print when this preference is enabled (default: off) - Debug messages now only print when this preference is enabled (default: off)
- All debug print statements use centralized `config.debug_print()` helper - All debug print statements use centralized `config.debug_print()` helper
### Fixes ### Fixes
- Fixed preferences not displaying in Blender 5.0 extensions - Fixed preferences not displaying in Blender 5.0 extensions
- Preferences now correctly match the full module path (`bl_ext.vscode_development.atomic_data_manager`) - 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 - Added safe property setter to handle read-only context errors during file loading
@@ -69,6 +101,7 @@
## [v2.2.0] - 2026-01-05 ## [v2.2.0] - 2026-01-05
### Features ### Features
- Add loading bars; non-blocking timer-based UI (#10) - Add loading bars; non-blocking timer-based UI (#10)
- Operations no longer freeze the UI during scanning - Operations no longer freeze the UI during scanning
- Real-time progress updates with cancel support at any time - Real-time progress updates with cancel support at any time
@@ -80,6 +113,7 @@
- Added manual cache clear operator for testing and debugging - Added manual cache clear operator for testing and debugging
### Performance ### Performance
- Optimized deep scan functions with caching and fast-path checks - Optimized deep scan functions with caching and fast-path checks
- Image scanning now uses cached results to avoid redundant scene scans - Image scanning now uses cached results to avoid redundant scene scans
- Early exit for clearly unused images using Blender's built-in user count - Early exit for clearly unused images using Blender's built-in user count
@@ -88,24 +122,28 @@
- Worlds processed one at a time incrementally - Worlds processed one at a time incrementally
### Fixes ### Fixes
- Fixed images used only by unused objects being incorrectly flagged as unused (#5) - Fixed images used only by unused objects being incorrectly flagged as unused (#5)
- Fixed material detection in brushes and node groups (#6, #7) - Fixed material detection in brushes and node groups (#6, #7)
- Fixed Clean operator not showing dialog when invoked programmatically (#8) - Fixed Clean operator not showing dialog when invoked programmatically (#8)
- Improved material detection in inspection tools (brushes, node groups) - Improved material detection in inspection tools (brushes, node groups)
### Internal ### Internal
- Refactored scanning architecture for maintainability - Refactored scanning architecture for maintainability
- Added comprehensive debug output for troubleshooting - Added comprehensive debug output for troubleshooting
## [v2.1.0] - 2025-12-18 ## [v2.1.0] - 2025-12-18
### Features ### Features
- Added support for detecting unused objects and armatures (#1) - Added support for detecting unused objects and armatures (#1)
- Objects not present in any scene collections are now detected as unused - 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 - 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 - Smart Select and Clean operations now support objects and armatures
### Fixes ### Fixes
- Fixed material detection in Geometry Nodes Set Material nodes - Fixed material detection in Geometry Nodes Set Material nodes
- Materials used in Geometry Nodes' "Set Material" nodes are now correctly detected as used - 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 - 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 - 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 ### Internal
- Removed incorrect "Remington Creative" copyright notices from newly created files - Removed incorrect "Remington Creative" copyright notices from newly created files
- Updated repository configuration in manifest - Updated repository configuration in manifest
## [v2.0.3] - 2025-12-17 ## [v2.0.3] - 2025-12-17
### Fixes ### Fixes
- Fixed missing import error in missing file detection - Fixed missing import error in missing file detection
## [v2.0.2] - 2025-12-17 ## [v2.0.2] - 2025-12-17
### Fixes ### Fixes
- Atomic now completely ignores all library-linked and override datablocks across all operations, as originally intended. - Atomic now completely ignores all library-linked and override datablocks across all operations, as originally intended.
## [v2.0.1] - 2025-12-16 ## [v2.0.1] - 2025-12-16
### Fixes ### 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) - 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 - Fixed unregistration errors in Blender 4.5 by using safe unregister functions throughout the codebase
## [v2.0.0] - Raincloud's first re-release ## [v2.0.0] - Raincloud's first re-release
### Feature ### Feature
- Multi-version Blender support (4.2 LTS, 4.5 LTS, and 5.0) - Multi-version Blender support (4.2 LTS, 4.5 LTS, and 5.0)
- Version detection utilities in `utils/version.py` - Version detection utilities in `utils/version.py`
- API compatibility layer in `utils/compat.py` for handling version differences - API compatibility layer in `utils/compat.py` for handling version differences
### Fixes ### Fixes
- Blender 5.0 compatibility: Fixed `AttributeError` when accessing scene compositor node tree (changed from `scene.node_tree` to `scene.compositing_node_tree`) - 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 - Collections assigned to `rigidbody_world.collection` are now correctly detected as used
### Internal ### Internal
- GitHub Actions release workflow - 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. - 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 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 'FILE', # icon
0 # number / id 0 # number / id
), ),
(
'STORAGE',
'Storage',
'Local datablocks vs linked libraries',
'DISK_DRIVE',
11
),
( (
'COLLECTIONS', 'COLLECTIONS',
'Collections', 'Collections',
@@ -239,7 +246,14 @@ class ATOMIC_PG_main(bpy.types.PropertyGroup):
def _on_undo_pre(scene): def _on_undo_pre(scene):
"""Handler called before undo - invalidate cache.""" """Handler called before undo - invalidate cache."""
from .ops import main_ops from .ops import main_ops
from .utils.compat import invalidate_cache
main_ops._invalidate_cache() main_ops._invalidate_cache()
invalidate_cache()
def _load_post_invalidate_storage(_dummy):
from .utils.compat import invalidate_cache
invalidate_cache()
def register(): def register():
@@ -252,6 +266,7 @@ def register():
# Register undo handler to invalidate cache # Register undo handler to invalidate cache
bpy.app.handlers.undo_pre.append(_on_undo_pre) bpy.app.handlers.undo_pre.append(_on_undo_pre)
bpy.app.handlers.load_post.append(_load_post_invalidate_storage)
# bootstrap Rainy's Extensions repository # bootstrap Rainy's Extensions repository
rainys_repo_bootstrap.register() rainys_repo_bootstrap.register()
@@ -264,6 +279,8 @@ def unregister():
# Remove undo handler # Remove undo handler
if _on_undo_pre in bpy.app.handlers.undo_pre: if _on_undo_pre in bpy.app.handlers.undo_pre:
bpy.app.handlers.undo_pre.remove(_on_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 # atomic package unregistration
ui.unregister() ui.unregister()
@@ -2,7 +2,7 @@ schema_version = "1.0.0"
id = "atomic_data_manager" id = "atomic_data_manager"
name = "Atomic Data Manager" name = "Atomic Data Manager"
version = "2.5.0" version = "2.6.2"
type = "add-on" type = "add-on"
author = "RaincloudTheDragon" author = "RaincloudTheDragon"
maintainer = "RaincloudTheDragon" maintainer = "RaincloudTheDragon"
@@ -250,7 +250,9 @@ class ATOMIC_OT_clean_all(bpy.types.Operator):
clean.lights() clean.lights()
clean.materials() clean.materials()
clean.node_groups() 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.particles()
clean.textures() clean.textures()
clean.armatures() clean.armatures()
@@ -739,6 +739,14 @@ class ATOMIC_OT_clean(bpy.types.Operator):
bpy.ops.atomic.deselect_all() bpy.ops.atomic.deselect_all()
return {'FINISHED'} 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 # Delete all items synchronously
deleted_count = 0 deleted_count = 0
for category, unused_list in categories_to_clean: for category, unused_list in categories_to_clean:
@@ -24,6 +24,52 @@ This file contains functions for cleaning out specific data categories.
import bpy import bpy
from ...stats import unused 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): def collections(cached_list=None):
@@ -137,7 +183,7 @@ def objects(cached_list=None):
object_keys = cached_list object_keys = cached_list
else: else:
object_keys = unused.objects_deep() object_keys = unused.objects_deep()
for object_key in object_keys: for object_key in object_keys:
if object_key in bpy.data.objects: if object_key in bpy.data.objects:
bpy.data.objects.remove(bpy.data.objects[object_key]) 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: Returns:
List of unused item names for the specified category List of unused item names for the specified category
""" """
from . import users
if include_fake_users is None: if include_fake_users is None:
include_fake_users = config.include_fake_users 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 item_name = datablock.name
if (category, item_name) not in used: if (category, item_name) not in used:
if item_name not in category_do_not_flag: 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) unused.append(item_name)
except (AttributeError, RuntimeError, ReferenceError): except (AttributeError, RuntimeError, ReferenceError):
# Datablock may be invalid # Datablock may be invalid
@@ -223,6 +223,10 @@ def materials_deep():
# check if material has a fake user or if ignore fake users # check if material has a fake user or if ignore fake users
# is enabled # is enabled
if not material.use_fake_user or config.include_fake_users: 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) unused.append(material.name)
else: else:
# Second check: material is used, but check if it's ONLY used by unused objects # 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 # First check: standard unused detection
if not users.material_all(material.name): if not users.material_all(material.name):
if not material.use_fake_user or config.include_fake_users: if not material.use_fake_user or config.include_fake_users:
if material.users > 0 and not material.use_fake_user:
continue
return True return True
else: else:
# Second check: material is used, but check if it's ONLY used by unused objects # 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 from ..utils import compat
for obj in bpy.data.objects: for obj in bpy.data.objects:
# Skip library-linked and override objects # Skip purely linked objects; library overrides can have local GN modifiers (#15)
if compat.is_library_or_override(obj): if compat.is_object_linked_without_override(obj):
continue continue
# Check if object is in any scene collection (reuse object_all logic) # 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 from ..utils import compat
for obj in bpy.data.objects: for obj in bpy.data.objects:
# Skip library-linked and override objects # Skip purely linked objects; library overrides can have local GN modifiers (#15)
if compat.is_library_or_override(obj): if compat.is_object_linked_without_override(obj):
continue continue
# Check if object is in any scene collection (reuse object_all logic) # 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 ..utils import compat
from ..stats import count from ..stats import count
from ..stats import misc 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 from .utils import ui_layouts
@@ -46,6 +53,8 @@ class ATOMIC_PT_stats_panel(bpy.types.Panel):
def draw(self, context): def draw(self, context):
layout = self.layout layout = self.layout
atom = bpy.context.scene.atomic atom = bpy.context.scene.atomic
# Keep blend storage scan in sync whenever the stats panel is shown
storage_report = get_report()
# categories selector / header # categories selector / header
row = layout.row() row = layout.row()
@@ -66,6 +75,12 @@ class ATOMIC_PT_stats_panel(bpy.types.Panel):
row = box.row() row = box.row()
row.label(text="Blend File Size: " + misc.blend_size()) 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 # cateogry statistics
split = box.split() split = box.split()
@@ -115,6 +130,52 @@ class ATOMIC_PT_stats_panel(bpy.types.Panel):
# world count # world count
col.label(text=str(count.worlds())) 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 # collection statistics
elif atom.stats_mode == 'COLLECTIONS': elif atom.stats_mode == 'COLLECTIONS':
@@ -4,6 +4,7 @@ between Blender 4.2 LTS, 4.5 LTS, and 5.0.
""" """
import os
import bpy import bpy
from bpy.utils import register_class, unregister_class from bpy.utils import register_class, unregister_class
from . import version from . import version
@@ -177,3 +178,624 @@ def is_library_or_override(datablock):
return True return True
return False 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 = { bl_info = {
"name": "BlenderKit Online Asset Library", "name": "BlenderKit Online Asset Library",
"author": "Vilem Duha, Petr Dlouhy, A. Gajdosik", "author": "Vilem Duha, Petr Dlouhy, A. Gajdosik, Michal Hons",
"version": (3, 18, 1, 251219), # X.Y.Z.yymmdd "version": (3, 19, 1, 260402), # X.Y.Z.yymmdd
"blender": (3, 0, 0), "blender": (3, 0, 0),
"location": "View3D > Properties > BlenderKit", "location": "View3D > Properties > BlenderKit",
"description": "Boost your workflow with drag&drop assets from the community driven library.", "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", "tracker_url": "https://github.com/BlenderKit/blenderkit/issues",
"category": "3D View", "category": "3D View",
} }
VERSION = (3, 18, 1, 251219) VERSION = (3, 19, 1, 260402)
import logging import logging
import random import random
@@ -96,6 +96,7 @@ if "bpy" in locals():
ui = reload(ui) ui = reload(ui)
ui_bgl = reload(ui_bgl) ui_bgl = reload(ui_bgl)
ui_panels = reload(ui_panels) ui_panels = reload(ui_panels)
keymap_utils = reload(keymap_utils)
upload = reload(upload) upload = reload(upload)
upload_bg = reload(upload_bg) upload_bg = reload(upload_bg)
utils = reload(utils) utils = reload(utils)
@@ -152,6 +153,7 @@ else:
from . import ui from . import ui
from . import ui_bgl from . import ui_bgl
from . import ui_panels from . import ui_panels
from . import keymap_utils
from . import upload from . import upload
from . import upload_bg from . import upload_bg
from . import utils from . import utils
@@ -273,7 +275,7 @@ if bpy.app.version >= (4, 5, 0):
EXTRA_PATH_OPTIONS = {"options": {"PATH_SUPPORTS_BLEND_RELATIVE"}} 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.""" """Perform a search if results are empty."""
props = bpy.context.window_manager.blenderkitUI props = bpy.context.window_manager.blenderkitUI
if search.get_search_results() is None and props.down_up == "SEARCH": 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): if bpy.app.version >= (4, 2, 0):
items.append(("ADDON", "Add-ons", "Find add-ons", "PLUGIN", 7)) 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: else:
items = [ items = [
("MODEL", "Model", "Upload a model", "OBJECT_DATAMODE", 0), ("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): if bpy.app.version >= (4, 2, 0):
items.append(("ADDON", "Add-on", "Upload an addon", "PLUGIN", 7)) items.append(("ADDON", "Add-on", "Upload an addon", "PLUGIN", 7))
# Author is search-only, no upload entry needed
return items return items
@@ -337,6 +354,10 @@ def run_drag_drop_update(self, context):
if self.drag_init_button: if self.drag_init_button:
ui_props = bpy.context.window_manager.blenderkitUI 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.close_popup_button("INVOKE_DEFAULT")
bpy.ops.view3d.asset_drag_drop( bpy.ops.view3d.asset_drag_drop(
"INVOKE_DEFAULT", "INVOKE_DEFAULT",
@@ -356,7 +377,7 @@ class BlenderKitUIProps(PropertyGroup):
), ),
description="BlenderKit", description="BlenderKit",
default="SEARCH", default="SEARCH",
update=udate_down_up, update=update_down_up,
) )
asset_type: EnumProperty( asset_type: EnumProperty(
name=" ", name=" ",
@@ -505,11 +526,11 @@ class BlenderKitUIProps(PropertyGroup):
ui_scale = 1 ui_scale = 1
thumb_size_def = 96 thumb_size_def = 128
margin_def = 0 margin_def = 0
thumb_size: IntProperty( 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) 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'.", 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, update=run_drag_drop_update,
) )
drag_length: IntProperty(name="Drag length", default=0)
draw_drag_image: BoolProperty(name="Draw Drag Image", default=False) draw_drag_image: BoolProperty(name="Draw Drag Image", default=False)
draw_snapped_bounds: BoolProperty(name="Draw Snapped Bounds", 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" 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( nodegroup_upload: PointerProperty(
name="Upload Tool", name="Upload Tool",
type=bpy.types.GeometryNodeTree, type=bpy.types.GeometryNodeTree,
@@ -785,7 +831,7 @@ def update_free(self, context):
message="Any material uploaded to BlenderKit is free." message="Any material uploaded to BlenderKit is free."
" However, it can still earn money for the author," " However, it can still earn money for the author,"
" based on our fair share system. " " 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", 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( is_private: EnumProperty(
name="Thumbnail Style", name="Thumbnail Style",
items=(("PRIVATE", "Private", ""), ("PUBLIC", "Public", "")), items=(("PRIVATE", "Private", ""), ("PUBLIC", "Public", "")),
@@ -1187,13 +1248,17 @@ class BlenderKitAddonSearchProps(PropertyGroup, BlenderKitCommonSearchProps):
description="Show only addons that are already installed in Blender", description="Show only addons that are already installed in Blender",
default=False, default=False,
update=lambda self, context: ( update=lambda self, context: (
search.refresh_search() search.search_update(self, context)
if context.window_manager.blenderkitUI.asset_type == "ADDON" if context.window_manager.blenderkitUI.asset_type == "ADDON"
else None else None
), ),
) )
class BlenderKitAuthorSearchProps(PropertyGroup, BlenderKitCommonSearchProps):
pass
class BlenderKitHDRUploadProps(PropertyGroup, BlenderKitCommonUploadProps): class BlenderKitHDRUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
texture_resolution_max: IntProperty( texture_resolution_max: IntProperty(
name="Texture Resolution Max", name="Texture Resolution Max",
@@ -1207,6 +1272,15 @@ class BlenderKitHDRUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
class BlenderKitBrushUploadProps(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( mode: EnumProperty(
name="Mode", name="Mode",
items=( items=(
@@ -1526,6 +1600,13 @@ class BlenderKitModelUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
update=autothumb.update_upload_model_preview, 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( has_autotags: BoolProperty(
name="Has Autotagging Done", name="Has Autotagging Done",
description="True when autotagging done", description="True when autotagging done",
@@ -1546,6 +1627,26 @@ class BlenderKitModelUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
default=False, 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): class BlenderKitSceneUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
style: EnumProperty( 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): class BlenderKitSceneSearchProps(PropertyGroup, BlenderKitCommonSearchProps):
search_style: EnumProperty( search_style: EnumProperty(
name="Style", name="Style",
@@ -2090,6 +2199,12 @@ class BlenderKitAddonPreferences(AddonPreferences):
default=True, 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( global_dir: StringProperty(
name="Global Directory", name="Global Directory",
description="Global storage for your assets, will use subdirectories for the contents. Client will place its files in subdirectory 'client'", 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, 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 download/import settings
resolution: EnumProperty( resolution: EnumProperty(
name="Max resolution", 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, 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( maximized_assetbar_rows: IntProperty(
name="Maximized Assetbar Rows", name="Maximized Assetbar Rows",
description="Maximum rows of assetbar in the 3D view when expanded", 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( thumb_size: IntProperty(
name="Assetbar Thumbnail Size", name="Assetbar Thumbnail Size",
default=96, default=128,
min=-1, min=48, # must newer be zero
max=256, max=256,
update=utils.save_prefs, update=utils.save_prefs,
description="Size of thumbnails of the assetbar in 3D view", 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( experimental_features: BoolProperty(
name="Enable experimental features", 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, default=False,
update=utils.save_prefs, 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"}, 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): def draw(self, context):
layout = self.layout layout = self.layout
login_box = layout.box()
login_box.label(text="Login Options")
if self.api_key.strip() == "": if self.api_key.strip() == "":
ui_panels.draw_login_buttons(layout) ui_panels.draw_login_buttons(login_box)
layout.label( login_box.label(
text="Sign up to bookmark your favorite 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: else:
layout.operator("wm.blenderkit_logout", text="Logout", icon="URL") login_box.operator("wm.blenderkit_logout", text="Logout", icon="URL")
layout.prop(self, "api_key", text="Your API Key") login_box.prop(self, "api_key", text="Your API Key")
layout.prop(self, "keep_preferences") login_box.prop(self, "keep_preferences")
community_row = layout.row() community_row = login_box.row()
community_row.prop(self, "experimental_features") community_row.prop(self, "experimental_features")
community_row.operator("wm.blenderkit_join_discord", icon="URL") community_row.operator("wm.blenderkit_join_discord", icon="URL")
if utils.profile_is_validator(): 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 # FILE PATHS
locations_settings = layout.box() 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"): if self.directory_behaviour in ("BOTH", "LOCAL"):
locations_settings.prop(self, "project_subdir") locations_settings.prop(self, "project_subdir")
locations_settings.prop(self, "unpack_files") locations_settings.prop(self, "unpack_files")
locations_settings.prop(self, "write_asset_metadata")
# GUI SETTINGS # GUI SETTINGS
gui_settings = layout.box() 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, "show_VIEW3D_MT_blenderkit_model_properties")
gui_settings.prop(self, "tips_on_start") gui_settings.prop(self, "tips_on_start")
gui_settings.prop(self, "announcements_on_start") gui_settings.prop(self, "announcements_on_start")
gui_settings.prop(self, "assetbar_follows_cursor")
gui_settings.prop(self, "use_clipboard_scan") gui_settings.prop(self, "use_clipboard_scan")
# NETWORKING SETTINGS # NETWORKING SETTINGS
@@ -2516,16 +2684,11 @@ In this case you should also set path to your system CA bundle containing proxy'
# UPDATER SETTINGS # UPDATER SETTINGS
addon_updater_ops.update_settings_ui(self, context) 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 # 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", "wm.blenderkit_open_global_directory",
text=f"Global directory: {self.global_dir}", text=f"Global directory: {self.global_dir}",
icon="FILE_FOLDER", 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 globdir_op.directory = self.global_dir
clientlog_path = client_lib.get_client_log_path() clientlog_path = client_lib.get_client_log_path()
clientlog_op = layout.operator( clientlog_op = directory_box.operator(
"wm.blenderkit_open_client_log", "wm.blenderkit_open_client_log",
text=f"Client log: {clientlog_path}", text=f"Client log: {clientlog_path}",
icon="FILE_FOLDER", 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 clientlog_op.directory = clientlog_path
addondir = path.dirname(__file__) addondir = path.dirname(__file__)
addondir_op = layout.operator( addondir_op = directory_box.operator(
"wm.blenderkit_open_addon_directory", "wm.blenderkit_open_addon_directory",
text=f"Installed at: {addondir}", text=f"Installed at: {addondir}",
icon="FILE_FOLDER", 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 addondir_op.directory = addondir
tempdir = paths.get_temp_dir() tempdir = paths.get_temp_dir()
tempdir_op = layout.operator( tempdir_op = directory_box.operator(
"wm.blenderkit_open_temp_directory", "wm.blenderkit_open_temp_directory",
text=f"Temp directory: {tempdir}", text=f"Temp directory: {tempdir}",
icon="FILE_FOLDER", icon="FILE_FOLDER",
) )
tempdir_op.directory = tempdir 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 # registration
classes = ( classes = (
@@ -2574,6 +2751,7 @@ classes = (
BlenderKitGeoToolSearchProps, BlenderKitGeoToolSearchProps,
BlenderKitNodeGroupUploadProps, BlenderKitNodeGroupUploadProps,
BlenderKitAddonSearchProps, BlenderKitAddonSearchProps,
BlenderKitAuthorSearchProps,
) )
@@ -2582,6 +2760,9 @@ def register():
global_vars.VERSION = VERSION global_vars.VERSION = VERSION
bpy.utils.register_class(BlenderKitAddonPreferences) 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}) addon_updater_ops.register({"version": VERSION})
for cls in classes: for cls in classes:
bpy.utils.register_class(cls) bpy.utils.register_class(cls)
@@ -2643,9 +2824,13 @@ def register():
bpy.types.WindowManager.blenderkit_addon = PointerProperty( bpy.types.WindowManager.blenderkit_addon = PointerProperty(
type=BlenderKitAddonSearchProps type=BlenderKitAddonSearchProps
) )
bpy.types.WindowManager.blenderkit_author = PointerProperty(
type=BlenderKitAuthorSearchProps
)
if bpy.app.factory_startup is False: if bpy.app.factory_startup is False:
user_preferences = bpy.context.preferences.addons[__package__].preferences user_preferences = bpy.context.preferences.addons[__package__].preferences
global_vars.PREFS = utils.get_preferences_as_dict() 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) client_lib.reorder_ports(user_preferences.client_port)
timer.update_trusted_CA_certs(user_preferences.trusted_ca_certs) timer.update_trusted_CA_certs(user_preferences.trusted_ca_certs)
@@ -2694,6 +2879,8 @@ def register():
def unregister(): def unregister():
bk_logger.info("Unregistering BlenderKit add-on") 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() timer.unregister_timers()
ui_panels.unregister_ui_panels() ui_panels.unregister_ui_panels()
ui.unregister_ui() ui.unregister_ui()
@@ -2726,6 +2913,8 @@ def unregister():
del bpy.types.WindowManager.blenderkit_brush del bpy.types.WindowManager.blenderkit_brush
del bpy.types.WindowManager.blenderkit_mat del bpy.types.WindowManager.blenderkit_mat
del bpy.types.WindowManager.blenderkit_nodegroup 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.Scene.blenderkit
del bpy.types.Object.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 json
import os import os
import platform import platform
import logging
import shutil import shutil
import ssl import ssl
import threading import threading
@@ -43,6 +44,10 @@ import addon_utils
# Blender imports, used in limited cases. # Blender imports, used in limited cases.
import bpy import bpy
from . import tasks_queue
bk_logger = logging.getLogger(__name__)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# The main class # The main class
@@ -134,15 +139,15 @@ class SingletonUpdater:
self._select_link = select_link_function self._select_link = select_link_function
def print_trace(self): 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: if self._use_print_traces:
traceback.print_exc() bk_logger.error("%s", traceback.format_exc())
def print_verbose(self, msg): 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: if not self._verbose:
return return
print("🔄 {}: ".format(self.addon) + msg) bk_logger.info("%s", msg)
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Getters and setters # Getters and setters
@@ -177,7 +182,7 @@ class SingletonUpdater:
def auto_reload_post_update(self, value): def auto_reload_post_update(self, value):
try: try:
self._auto_reload_post_update = bool(value) self._auto_reload_post_update = bool(value)
except: except Exception:
raise ValueError("auto_reload_post_update must be a boolean value") raise ValueError("auto_reload_post_update must be a boolean value")
@property @property
@@ -227,7 +232,7 @@ class SingletonUpdater:
elif type(tuple_values) is not tuple: elif type(tuple_values) is not tuple:
try: try:
tuple(tuple_values) tuple(tuple_values)
except: except Exception:
raise ValueError("current_version must be a tuple of integers") raise ValueError("current_version must be a tuple of integers")
for i in tuple_values: for i in tuple_values:
if type(i) is not int: if type(i) is not int:
@@ -277,7 +282,7 @@ class SingletonUpdater:
def include_branch_auto_check(self, value): def include_branch_auto_check(self, value):
try: try:
self._include_branch_auto_check = bool(value) self._include_branch_auto_check = bool(value)
except: except Exception:
raise ValueError("include_branch_autocheck must be a boolean") raise ValueError("include_branch_autocheck must be a boolean")
@property @property
@@ -295,7 +300,7 @@ class SingletonUpdater:
) )
else: else:
self._include_branch_list = value self._include_branch_list = value
except: except Exception:
raise ValueError("include_branch_list should be a list of valid branches") raise ValueError("include_branch_list should be a list of valid branches")
@property @property
@@ -306,7 +311,7 @@ class SingletonUpdater:
def include_branches(self, value): def include_branches(self, value):
try: try:
self._include_branches = bool(value) self._include_branches = bool(value)
except: except Exception:
raise ValueError("include_branches must be a boolean value") raise ValueError("include_branches must be a boolean value")
@property @property
@@ -329,7 +334,7 @@ class SingletonUpdater:
def manual_only(self, value): def manual_only(self, value):
try: try:
self._manual_only = bool(value) self._manual_only = bool(value)
except: except Exception:
raise ValueError("manual_only must be a boolean value") raise ValueError("manual_only must be a boolean value")
@property @property
@@ -377,7 +382,7 @@ class SingletonUpdater:
def repo(self, value): def repo(self, value):
try: try:
self._repo = str(value) self._repo = str(value)
except: except Exception:
raise ValueError("repo must be a string value") raise ValueError("repo must be a string value")
@property @property
@@ -404,8 +409,8 @@ class SingletonUpdater:
elif value is not None and not os.path.exists(value): elif value is not None and not os.path.exists(value):
try: try:
os.makedirs(value) os.makedirs(value)
except: except Exception:
self.print_verbose("Error trying to staging path") self.print_verbose("Error trying to create staging path")
self.print_trace() self.print_trace()
return return
self._updater_path = value self._updater_path = value
@@ -453,7 +458,7 @@ class SingletonUpdater:
def use_releases(self, value): def use_releases(self, value):
try: try:
self._use_releases = bool(value) self._use_releases = bool(value)
except: except Exception:
raise ValueError("use_releases must be a boolean value") raise ValueError("use_releases must be a boolean value")
@property @property
@@ -464,7 +469,7 @@ class SingletonUpdater:
def user(self, value): def user(self, value):
try: try:
self._user = str(value) self._user = str(value)
except: except Exception:
raise ValueError("User must be a string value") raise ValueError("User must be a string value")
@property @property
@@ -476,7 +481,7 @@ class SingletonUpdater:
try: try:
self._verbose = bool(value) self._verbose = bool(value)
self.print_verbose("Verbose is enabled") self.print_verbose("Verbose is enabled")
except: except Exception:
raise ValueError("Verbose must be a boolean value") raise ValueError("Verbose must be a boolean value")
@property @property
@@ -487,7 +492,7 @@ class SingletonUpdater:
def use_print_traces(self, value): def use_print_traces(self, value):
try: try:
self._use_print_traces = bool(value) self._use_print_traces = bool(value)
except: except Exception:
raise ValueError("use_print_traces must be a boolean value") raise ValueError("use_print_traces must be a boolean value")
@property @property
@@ -687,7 +692,7 @@ class SingletonUpdater:
request = urllib.request.Request(url) request = urllib.request.Request(url)
try: try:
context = ssl._create_unverified_context() context = ssl._create_unverified_context()
except: except Exception:
# Some blender packaged python versions don't have this, largely # Some blender packaged python versions don't have this, largely
# useful for local network setups otherwise minimal impact. # useful for local network setups otherwise minimal impact.
context = None context = None
@@ -712,24 +717,24 @@ class SingletonUpdater:
if str(e.code) == "403": if str(e.code) == "403":
self._error = "HTTP error (access denied)" self._error = "HTTP error (access denied)"
self._error_msg = str(e.code) + " - server error response" 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: else:
self._error = "HTTP error" self._error = "HTTP error"
self._error_msg = str(e.code) self._error_msg = str(e.code)
print(self._error, self._error_msg) bk_logger.error("%s %s", self._error, self._error_msg)
self.print_trace() # self.print_trace()
self._update_ready = None self._update_ready = None
except urllib.error.URLError as e: except urllib.error.URLError as e:
reason = str(e.reason) reason = str(e.reason)
if "TLSV1_ALERT" in reason or "SSL" in reason.upper(): if "TLSV1_ALERT" in reason or "SSL" in reason.upper():
self._error = "Connection rejected, download manually" self._error = "Connection rejected, download manually"
self._error_msg = reason self._error_msg = reason
print(self._error, self._error_msg) bk_logger.error("%s %s", self._error, self._error_msg)
else: else:
self._error = "URL error, check internet connection" self._error = "URL error, check internet connection"
self._error_msg = reason self._error_msg = reason
print(self._error, self._error_msg) bk_logger.error("%s %s", self._error, self._error_msg)
self.print_trace() # self.print_trace()
self._update_ready = None self._update_ready = None
return None return None
else: else:
@@ -748,7 +753,7 @@ class SingletonUpdater:
self._error = "API response has invalid JSON format" self._error = "API response has invalid JSON format"
self._error_msg = str(e.reason) self._error_msg = str(e.reason)
self._update_ready = None self._update_ready = None
print(self._error, self._error_msg) bk_logger.error("%s %s", self._error, self._error_msg)
self.print_trace() self.print_trace()
return None return None
else: else:
@@ -766,13 +771,13 @@ class SingletonUpdater:
try: try:
shutil.rmtree(local) shutil.rmtree(local)
os.makedirs(local) os.makedirs(local)
except: except Exception:
error = "failed to remove existing staging directory" error = "failed to remove existing staging directory"
self.print_trace() self.print_trace()
else: else:
try: try:
os.makedirs(local) os.makedirs(local)
except: except Exception:
error = "failed to create staging directory" error = "failed to create staging directory"
self.print_trace() self.print_trace()
@@ -811,8 +816,8 @@ class SingletonUpdater:
except Exception as e: except Exception as e:
self._error = "Error retrieving download, bad link?" self._error = "Error retrieving download, bad link?"
self._error_msg = "Error: {}".format(e) self._error_msg = "Error: {}".format(e)
print("Error retrieving download, bad link?") bk_logger.error("Error retrieving download, bad link?")
print("Error: {}".format(e)) bk_logger.error("Error: %s", e)
self.print_trace() self.print_trace()
return False return False
@@ -829,7 +834,7 @@ class SingletonUpdater:
if os.path.isdir(local): if os.path.isdir(local):
try: try:
shutil.rmtree(local) shutil.rmtree(local)
except: except Exception:
self.print_verbose( self.print_verbose(
"Failed to removed previous backup folder, continuing" "Failed to removed previous backup folder, continuing"
) )
@@ -840,7 +845,7 @@ class SingletonUpdater:
if os.path.isdir(tempdest): if os.path.isdir(tempdest):
try: try:
shutil.rmtree(tempdest) shutil.rmtree(tempdest)
except: except Exception:
self.print_verbose("Failed to remove existing temp folder, continuing") self.print_verbose("Failed to remove existing temp folder, continuing")
self.print_trace() self.print_trace()
@@ -852,15 +857,15 @@ class SingletonUpdater:
tempdest, tempdest,
ignore=shutil.ignore_patterns(*self._backup_ignore_patterns), ignore=shutil.ignore_patterns(*self._backup_ignore_patterns),
) )
except: except Exception:
print("Failed to create backup, still attempting update.") self.print_verbose("Failed to create backup, still attempting update.")
self.print_trace() self.print_trace()
return return
else: else:
try: try:
shutil.copytree(self._addon_root, tempdest) shutil.copytree(self._addon_root, tempdest)
except: except Exception:
print("Failed to create backup, still attempting update.") self.print_verbose("Failed to create backup, still attempting update.")
self.print_trace() self.print_trace()
return return
shutil.move(tempdest, local) shutil.move(tempdest, local)
@@ -906,23 +911,23 @@ class SingletonUpdater:
try: try:
shutil.rmtree(outdir) shutil.rmtree(outdir)
self.print_verbose("Source folder cleared") self.print_verbose("Source folder cleared")
except: except Exception:
self.print_verbose("Error occurred while clearing extract dir")
self.print_trace() self.print_trace()
# Create parent directories if needed, would not be relevant unless # Create parent directories if needed, would not be relevant unless
# installing addon into another location or via an addon manager. # installing addon into another location or via an addon manager.
try: try:
os.mkdir(outdir) os.mkdir(outdir)
except Exception as err: except Exception:
print("Error occurred while making extract dir:") self.print_verbose("Error occurred while making extract dir")
print(str(err))
self.print_trace() self.print_trace()
self._error = "Install failed" self._error = "Install failed"
self._error_msg = "Failed to make extract directory" self._error_msg = "Failed to make extract directory"
return -1 return -1
if not os.path.isdir(outdir): 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 = "Install failed"
self._error_msg = "Failed to create extract directory" self._error_msg = "Failed to create extract directory"
return -1 return -1
@@ -972,7 +977,7 @@ class SingletonUpdater:
if not os.path.isdir(unpath): if not os.path.isdir(unpath):
self._error = "Install failed" self._error = "Install failed"
self._error_msg = "Extracted path does not exist" 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 return -1
if self._subfolder_path: if self._subfolder_path:
@@ -991,9 +996,8 @@ class SingletonUpdater:
# Smarter check for additional sub folders for a single folder # Smarter check for additional sub folders for a single folder
# containing the __init__.py file. # containing the __init__.py file.
if not os.path.isfile(os.path.join(unpath, "__init__.py")): if not os.path.isfile(os.path.join(unpath, "__init__.py")):
print("Not a valid addon found") bk_logger.error("Not a valid addon found")
print("Paths:") bk_logger.error("Paths: %s", dirlist)
print(dirlist)
self._error = "Install failed" self._error = "Install failed"
self._error_msg = "No __init__ file found in new source" self._error_msg = "No __init__ file found in new source"
return -1 return -1
@@ -1052,8 +1056,10 @@ class SingletonUpdater:
self.print_verbose( self.print_verbose(
"Clean removing file {}".format(os.path.join(base, f)) "Clean removing file {}".format(os.path.join(base, f))
) )
except Exception as e: except Exception:
print(f"Error removing file {os.path.join(base, f)}: {e}") bk_logger.exception(
"Error removing file %s:", os.path.join(base, f)
)
for f in folders: for f in folders:
if os.path.join(base, f) is self._updater_path: if os.path.join(base, f) is self._updater_path:
continue continue
@@ -1064,12 +1070,14 @@ class SingletonUpdater:
os.path.join(base, f) os.path.join(base, f)
) )
) )
except Exception as e: except Exception:
print(f"Error removing folder {os.path.join(base, f)}: {e}") 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" error = "failed to create clean existing addon folder"
print(error, str(err)) self.print_verbose(error)
self.print_trace() self.print_trace()
# Walk through the base addon folder for rules on pre-removing # Walk through the base addon folder for rules on pre-removing
@@ -1087,7 +1095,7 @@ class SingletonUpdater:
os.remove(fl) os.remove(fl)
self.print_verbose("Pre-removed file " + file) self.print_verbose("Pre-removed file " + file)
except OSError: except OSError:
print("Failed to pre-remove " + file) self.print_verbose("Failed to pre-remove " + file)
self.print_trace() self.print_trace()
# Walk through the temp addon sub folder for replacements # Walk through the temp addon sub folder for replacements
@@ -1134,13 +1142,13 @@ class SingletonUpdater:
# File did not previously exist, simply move it over. # File did not previously exist, simply move it over.
os.rename(srcFile, dest_file) os.rename(srcFile, dest_file)
self.print_verbose("New file " + os.path.basename(dest_file)) self.print_verbose("New file " + os.path.basename(dest_file))
except Exception as e: except Exception:
print(f"Error replacing file {file}: {e}") bk_logger.exception("Error replacing file %s:", file)
# now remove the temp staging folder and downloaded zip # now remove the temp staging folder and downloaded zip
try: try:
shutil.rmtree(staging_path) shutil.rmtree(staging_path)
except: except Exception:
error = ( error = (
"Error: Failed to remove existing staging directory, " "Error: Failed to remove existing staging directory, "
"consider manually removing " "consider manually removing "
@@ -1152,7 +1160,7 @@ class SingletonUpdater:
# if post_update false, skip this function # if post_update false, skip this function
# else, unload/reload addon & trigger popup # else, unload/reload addon & trigger popup
if not self._auto_reload_post_update: 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 return
self.print_verbose("Reloading addon...") self.print_verbose("Reloading addon...")
@@ -1165,12 +1173,12 @@ class SingletonUpdater:
bpy.ops.wm.addon_disable(module=self._addon_package) bpy.ops.wm.addon_disable(module=self._addon_package)
bpy.ops.wm.addon_refresh() bpy.ops.wm.addon_refresh()
bpy.ops.wm.addon_enable(module=self._addon_package) bpy.ops.wm.addon_enable(module=self._addon_package)
print("2.7 reload complete") bk_logger.info("2.7 reload complete")
else: # 2.8 else: # 2.8
bpy.ops.preferences.addon_disable(module=self._addon_package) bpy.ops.preferences.addon_disable(module=self._addon_package)
bpy.ops.preferences.addon_refresh() bpy.ops.preferences.addon_refresh()
bpy.ops.preferences.addon_enable(module=self._addon_package) 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 # Other non-api functions and setups
@@ -1190,10 +1198,10 @@ class SingletonUpdater:
while 1: while 1:
data = url_file.read(chunk) data = url_file.read(chunk)
if not data: if not data:
# print("done.") # bk_logger.info("done.")
break break
f.write(data) f.write(data)
# print("Read %s bytes" % len(data)) # bk_logger.info("Read %s bytes" % len(data))
f.close() f.close()
def version_tuple_from_text(self, text): def version_tuple_from_text(self, text):
@@ -1249,7 +1257,9 @@ class SingletonUpdater:
self.print_verbose("Skipping async check, already started") self.print_verbose("Skipping async check, already started")
# already running the bg thread # already running the bg thread
elif self._update_ready is None: 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) self.start_async_check_update(False, callback)
def check_for_update_now(self, callback=None): def check_for_update_now(self, callback=None):
@@ -1266,7 +1276,7 @@ class SingletonUpdater:
self.start_async_check_update(True, callback) self.start_async_check_update(True, callback)
def check_for_update(self, now=False): 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 This function is not async, will always return in sequential fashion
but should have a parent which calls it in another thread. but should have a parent which calls it in another thread.
@@ -1449,7 +1459,7 @@ class SingletonUpdater:
res = self.stage_repository(self._update_link) res = self.stage_repository(self._update_link)
if not res: 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: if callback is not None:
callback(self._addon_package, self._error_msg) callback(self._addon_package, self._error_msg)
return self._error_msg return self._error_msg
@@ -1467,7 +1477,7 @@ class SingletonUpdater:
res = self.stage_repository(self._update_link) res = self.stage_repository(self._update_link)
if not res: if not res:
print("Error in staging repository: " + str(res)) bk_logger.error("Error in staging repository: %s", str(res))
if callback: if callback:
callback(self._addon_package, self._error_msg) callback(self._addon_package, self._error_msg)
return self._error_msg return self._error_msg
@@ -1525,9 +1535,11 @@ class SingletonUpdater:
os.rename(old_json_path, json_path) os.rename(old_json_path, json_path)
except FileNotFoundError: except FileNotFoundError:
pass pass
except Exception as err: except Exception:
print("Other OS error occurred while trying to rename old JSON") self.print_verbose(
print(err) "Other OS error occurred while trying to rename old JSON"
)
self.print_trace() self.print_trace()
return json_path return json_path
@@ -1571,7 +1583,7 @@ class SingletonUpdater:
jpath = self.get_json_path() jpath = self.get_json_path()
if not os.path.isdir(os.path.dirname(jpath)): if not os.path.isdir(os.path.dirname(jpath)):
print( bk_logger.error(
"State error: Directory does not exist, cannot save json: ", "State error: Directory does not exist, cannot save json: ",
os.path.basename(jpath), os.path.basename(jpath),
) )
@@ -1580,8 +1592,9 @@ class SingletonUpdater:
with open(jpath, "w") as outf: with open(jpath, "w") as outf:
data_out = json.dumps(self._json, indent=4) data_out = json.dumps(self._json, indent=4)
outf.write(data_out) outf.write(data_out)
except: self.print_verbose(f"Wrote updater JSON settings to file: {jpath}")
print("Failed to open/save data to json: ", jpath) except Exception:
self.print_verbose(f"Failed to open/save data to json: {jpath}")
self.print_trace() self.print_trace()
self.print_verbose("Wrote out updater JSON settings with content:") self.print_verbose("Wrote out updater JSON settings with content:")
self.print_verbose(str(self._json)) self.print_verbose(str(self._json))
@@ -1630,8 +1643,7 @@ class SingletonUpdater:
try: try:
self.check_for_update(now=now) self.check_for_update(now=now)
except Exception as exception: except Exception as exception:
print("Checking for update error:") self.print_verbose(f"Checking for update error: {exception}")
print(exception)
self.print_trace() self.print_trace()
if not self._error: if not self._error:
self._update_ready = False self._update_ready = False
@@ -1644,8 +1656,11 @@ class SingletonUpdater:
self._check_thread = None self._check_thread = None
if callback: if callback:
self.print_verbose("Finished check update, doing callback") self.print_verbose(
callback(self._update_ready) "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") self.print_verbose("BG thread: Finished check update, no callback")
def stop_async_check_update(self): def stop_async_check_update(self):
@@ -24,11 +24,14 @@ Implements draw calls, popups, and operators that use the addon_updater.
import os import os
import traceback import traceback
import logging
import bpy import bpy
from bpy.app.handlers import persistent from bpy.app.handlers import persistent
from . import client_lib from . import client_lib, utils
bk_logger = logging.getLogger(__name__)
# Safely import the updater. # Safely import the updater.
@@ -37,9 +40,9 @@ from . import client_lib
try: try:
from .addon_updater import Updater as updater from .addon_updater import Updater as updater
except Exception as e: except Exception as e:
print("ERROR INITIALIZING UPDATER") bk_logger.error("ERROR INITIALIZING UPDATER")
print(str(e)) bk_logger.error(str(e))
traceback.print_exc() bk_logger.error("%s", traceback.format_exc())
class SingletonUpdaterNone(object): class SingletonUpdaterNone(object):
"""Fake, bare minimum fields and functions for the updater object.""" """Fake, bare minimum fields and functions for the updater object."""
@@ -175,6 +178,10 @@ class AddonUpdaterInstallPopup(bpy.types.Operator):
return True return True
def invoke(self, context, event): 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) return context.window_manager.invoke_props_dialog(self)
def draw(self, context): def draw(self, context):
@@ -229,9 +236,9 @@ class AddonUpdaterInstallPopup(bpy.types.Operator):
# Should return 0, if not something happened. # Should return 0, if not something happened.
if updater.verbose: if updater.verbose:
if res == 0: if res == 0:
print("Updater returned successful") bk_logger.info("Updater returned successful")
else: else:
print("Updater returned {}, error occurred".format(res)) bk_logger.info("Updater returned {}, error occurred".format(res))
elif updater.update_ready is None: elif updater.update_ready is None:
_ = updater.check_for_update(now=True) _ = updater.check_for_update(now=True)
@@ -322,9 +329,9 @@ class AddonUpdaterUpdateNow(bpy.types.Operator):
# Should return 0, if not something happened. # Should return 0, if not something happened.
if updater.verbose: if updater.verbose:
if res == 0: if res == 0:
print("Updater returned successful") bk_logger.info("Updater returned successful")
else: else:
print("Updater error response: {}".format(res)) bk_logger.info("Updater error response: {}".format(res))
except Exception as expt: except Exception as expt:
updater._error = "Error trying to run update" updater._error = "Error trying to run update"
updater._error_msg = str(expt) updater._error_msg = str(expt)
@@ -450,7 +457,7 @@ class AddonUpdaterInstallManually(bpy.types.Operator):
layout.label(text="Updater error") layout.label(text="Updater error")
return return
# Display error if a prior autoamted install failed. # Display error if a prior automated install failed.
if self.error != "": if self.error != "":
col = layout.column() col = layout.column()
col.scale_y = 0.7 col.scale_y = 0.7
@@ -692,7 +699,7 @@ def updater_run_install_popup_handler(scene):
updater_run_install_popup_handler updater_run_install_popup_handler
) )
except Exception as e: except Exception as e:
print(e) bk_logger.error("%s", e)
pass pass
if "ignore" in updater.json and updater.json["ignore"]: 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) settings = get_user_preferences(bpy.context)
if not settings: if not settings:
if updater.verbose: if updater.verbose:
print( bk_logger.info(
"Could not get {} preferences, update check skipped".format(__package__) "Could not get %s preferences, update check skipped", __package__
) )
return return
updater.set_check_interval( updater.set_check_interval(
@@ -1128,6 +1135,15 @@ def update_settings_ui(self, context, element=None):
else: else:
row.label(text="Last update check: Never") 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): def update_settings_ui_condensed(self, context, element=None):
"""Preferences - Condensed drawing within preferences. """Preferences - Condensed drawing within preferences.
@@ -1359,7 +1375,7 @@ def register(bl_info):
"""Registering the operators in this module""" """Registering the operators in this module"""
# Safer failure in case of issue loading module. # Safer failure in case of issue loading module.
if updater.error: if updater.error:
print("Exiting updater registration, " + updater.error) bk_logger.error("Exiting updater registration, %s", updater.error)
return return
updater.clear_state() # Clear internal vars, avoids reloading oddities. 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: 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 # we have to find the new material , due to possible name changes
mat = None mat = None
for m in bpy.data.materials: for m in bpy.data.materials:
@@ -532,7 +532,7 @@ def append_particle_system(
total_max_threshold = 2000000 total_max_threshold = 2000000
# emitting too many parent particles just kills blender now. # 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: # if count > total_max_threshold:
# ratio = round(count / total_max_threshold) # ratio = round(count / total_max_threshold)
# #
@@ -621,14 +621,18 @@ def append_objects(
return_obs = [] return_obs = []
to_hidden_collection = [] to_hidden_collection = []
hidden_objects = []
appended_collection = None appended_collection = None
main_object = 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] for ob in bpy.context.scene.objects: # type: ignore[union-attr]
if ob.select_get(): if ob.select_get() and not ob.parent:
if not ob.parent: main_object = ob
main_object = ob ob.location = location
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 # do once again to ensure hidden objects are hidden
for ob in bpy.context.scene.objects: # type: ignore[union-attr] for ob in bpy.context.scene.objects: # type: ignore[union-attr]
if ob.select_get(): if ob.select_get():
@@ -654,7 +658,7 @@ def append_objects(
main_object.matrix_world.translation = location main_object.matrix_world.translation = location
# move objects that should be hidden to a sub collection # 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 = [] hidden_collections = []
scene_collection = bpy.context.scene.collection # type: ignore[union-attr] scene_collection = bpy.context.scene.collection # type: ignore[union-attr]
for ob in to_hidden_collection: for ob in to_hidden_collection:
@@ -682,7 +686,7 @@ def append_objects(
if hide_collection in hidden_collections: if hide_collection in hidden_collections:
continue continue
# All other collections are moved to be children of the model collection # 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 target collection is specified, move collections there instead
if collection and bpy.data.collections.get(collection): if collection and bpy.data.collections.get(collection):
utils.move_collection( utils.move_collection(
@@ -707,6 +711,12 @@ def append_objects(
if orig_active_collection: if orig_active_collection:
bpy.context.view_layer.active_layer_collection = orig_active_collection # type: ignore[union-attr] 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) utils.selection_set(sel)
# let collection also store info that it was created by BlenderKit, for purging reasons # 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) obj.select_set(True)
# we need to unhide object so make_local op can use those too. # we need to unhide object so make_local op can use those too.
if link == True: 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) hidden_objects.append(obj)
obj.hide_viewport = False obj.hide_set(False)
return_obs.append(obj) return_obs.append(obj)
# Only after all objects are in scene! Otherwise gets broken relationships # Only after all objects are in scene! Otherwise gets broken relationships
if link == True: if link == True:
bpy.ops.object.make_local(type="SELECT_OBJECT") bpy.ops.object.make_local(type="SELECT_OBJECT")
for ob in hidden_objects: for ob in hidden_objects:
ob.hide_viewport = True ob.hide_set(True)
if kwargs.get("rotation") is not None: if kwargs.get("rotation") is not None:
main_object.rotation_euler = kwargs["rotation"] # type: ignore[union-attr] 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 math
import os import os
import random import random
from typing import Any, Optional, Set, Tuple, Union
import bpy import bpy
import mathutils import mathutils
@@ -28,8 +29,6 @@ from bpy.props import IntProperty, StringProperty
from bpy_extras import view3d_utils from bpy_extras import view3d_utils
from mathutils import Vector from mathutils import Vector
from typing import Any, Optional, Tuple, Set, Union
from . import ( from . import (
bg_blender, bg_blender,
colors, colors,
@@ -38,11 +37,12 @@ from . import (
image_utils, image_utils,
paths, paths,
reports, reports,
search,
ui, ui,
ui_bgl, ui_bgl,
ui_panels, ui_panels,
utils, utils,
search, viewport_utils,
) )
from .bl_ui_widgets.bl_ui_button import BL_UI_Button from .bl_ui_widgets.bl_ui_button import BL_UI_Button
from .bl_ui_widgets.bl_ui_drag_panel import BL_UI_Drag_Panel from .bl_ui_widgets.bl_ui_drag_panel import BL_UI_Drag_Panel
@@ -56,11 +56,8 @@ handler_2d = None
handler_3d = None handler_3d = None
DEAD_ZONE = 5 # pixels DEFAULT_DRAG_THRESHOLD = 30 # pixels
"""Number of pixels mouse must move to start drag operation.""" """Pointer travel in pixels needed before we start rendering full drag hints."""
DRAG_THRESHOLD = 10 # pixels
"""Number of pixels mouse must move to consider as a drag (vs click)."""
def is_draw_cb_available(self: bpy.types.Operator, context: bpy.types.Context) -> bool: def is_draw_cb_available(self: bpy.types.Operator, context: bpy.types.Context) -> bool:
@@ -106,8 +103,8 @@ def draw_callback_dragging(
Returns: Returns:
None 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): if not is_draw_cb_available(self, context):
return return
@@ -356,6 +353,10 @@ def draw_callback_3d_dragging(
if not utils.guard_from_crash(): if not utils.guard_from_crash():
return return
# ignore unless we are dragging
if not self.drag:
return
# Only draw 3D elements in VIEW_3D areas, not in outliner # Only draw 3D elements in VIEW_3D areas, not in outliner
if context.area.type != "VIEW_3D": if context.area.type != "VIEW_3D":
return return
@@ -522,19 +523,6 @@ def draw_progress(
ui_bgl.draw_text(text, x, y + 8, 16, color) 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( def mouse_raycast(
region: bpy.types.Region, rv3d: bpy.types.RegionView3D, mx: int, my: int region: bpy.types.Region, rv3d: bpy.types.RegionView3D, mx: int, my: int
) -> Tuple[ ) -> 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: 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.""" """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: for p in bpy.data.particles:
if p.render_type == "COLLECTION": if p.render_type == "COLLECTION" and p.instance_collection:
if p.instance_collection: if o in p.instance_collection.objects:
for o1 in p.instance_collection.objects: return True
if o1 == o:
return True
if p.render_type == "COLLECTION": if p.render_type == "COLLECTION":
if p.instance_object == o: if p.instance_object == o:
return True return True
@@ -774,22 +760,6 @@ def get_node_tree(context: bpy.types.Context) -> bpy.types.NodeTree:
return context.scene.compositing_node_group 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): class AssetDragOperator(bpy.types.Operator):
"""Drag & drop assets into scene. Operator being drawn when dragging asset.""" """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" bl_label = "BlenderKit asset drag drop"
asset_search_index: IntProperty(name="Active Index", default=0) # type: ignore asset_search_index: IntProperty(name="Active Index", default=0) # type: ignore
drag_length: IntProperty(name="Drag_length", default=0) # type: ignore
object_name = None object_name = None
active_operator_id = None
def __init__(self, *args, **kwargs) -> None: def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -813,32 +783,28 @@ class AssetDragOperator(bpy.types.Operator):
self.hovered_outliner_element: Union[bpy.types.Object, bpy.types.Collection] = ( self.hovered_outliner_element: Union[bpy.types.Object, bpy.types.Collection] = (
None None
) )
self.active_window = None
self.active_area = None
self.active_region = None
self.orig_active_object = None self.orig_active_object = None
self.orig_selected_objects = None self.orig_selected_objects = None
self.orig_active_collection = None
self.downloader = None self.downloader = None
# Mouse tracking variables # Mouse tracking variables
self.start_mouse_x = None self.start_mouse_x = 0
self.start_mouse_y = None self.start_mouse_y = 0
self.mouse_x = 0 self.mouse_x = 0
self.mouse_y = 0 self.mouse_y = 0
self.mouse_screen_x = 0 self.mouse_screen_x = 0
self.mouse_screen_y = 0 self.mouse_screen_y = 0
self.steps = 0
# Store the initial active region pointer
self.active_region_pointer = None
# Initialize outliner tracking variables # Initialize outliner tracking variables
self.hovered_outliner_element = None
self.outliner_area = None self.outliner_area = None
self.outliner_region = 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 self.prev_area_type = None
# Initialize node editor tracking # Initialize node editor tracking
@@ -858,6 +824,8 @@ class AssetDragOperator(bpy.types.Operator):
self.iname = "" self.iname = ""
self.drag = False self.drag = False
self.steps = 0
self.closed_assetbar = False
def handlers_remove(self) -> None: def handlers_remove(self) -> None:
"""Remove all draw handlers.""" """Remove all draw handlers."""
@@ -873,11 +841,8 @@ class AssetDragOperator(bpy.types.Operator):
self, nodegroup_type: str, editor_type: Optional[str] = None self, nodegroup_type: str, editor_type: Optional[str] = None
) -> bool: ) -> bool:
"""Check if a nodegroup of a specific type is compatible with the given editor type.""" """Check if a nodegroup of a specific type is compatible with the given editor type."""
# Direct matches # Direct matches, or invalid editor
if nodegroup_type == editor_type: if not nodegroup_type or nodegroup_type == editor_type:
return True
# Generic nodegroups can work in any editor
elif nodegroup_type is None:
return True return True
# Otherwise, not compatible # Otherwise, not compatible
return False return False
@@ -958,7 +923,7 @@ class AssetDragOperator(bpy.types.Operator):
if obj.type == "MESH": if obj.type == "MESH":
temp_mesh = object_eval.to_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 target_slot = temp_mesh.polygons[self.face_index].material_index
object_eval.to_mesh_clear() object_eval.to_mesh_clear()
else: else:
@@ -1073,7 +1038,7 @@ class AssetDragOperator(bpy.types.Operator):
target_collection = "" target_collection = ""
# Check what type of element we're dropping on # 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 dropping on a collection, set target_collection parameter
if isinstance(self.hovered_outliner_element, bpy.types.Collection): if isinstance(self.hovered_outliner_element, bpy.types.Collection):
@@ -1480,10 +1445,10 @@ class AssetDragOperator(bpy.types.Operator):
for window in wins: for window in wins:
# first let's test if it's in this window, so we know we shall continue # first let's test if it's in this window, so we know we shall continue
window_x = window.x * self.resolution_factor window_x = window.x
window_y = window.y * self.resolution_factor window_y = window.y
window_width = window.width * self.resolution_factor window_width = window.width
window_height = window.height * self.resolution_factor window_height = window.height
if ( if (
x < window_x x < window_x
or x > window_x + window_width or x > window_x + window_width
@@ -1513,7 +1478,6 @@ class AssetDragOperator(bpy.types.Operator):
return None return None
context = bpy.context context = bpy.context
scene = context.scene
view_layer = context.view_layer view_layer = context.view_layer
selected_objects = context.selected_objects selected_objects = context.selected_objects
active_object = context.active_object active_object = context.active_object
@@ -1630,11 +1594,14 @@ class AssetDragOperator(bpy.types.Operator):
): ):
"""Get the active object under the mouse cursor during drag.""" """Get the active object under the mouse cursor during drag."""
region_data = None # precise placement in ortho views, and quad view
region_data = viewport_utils.region_data_for_view(active_area, active_region)
for space in active_area.spaces: if region_data is None:
if space.type == "VIEW_3D": for space in active_area.spaces:
region_data = space.region_3d 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 # Need to temporarily override context for raycasting
if bpy.app.version < (3, 2, 0): # B3.0, B3.1 - custom context override 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, "screen": active_window.screen,
"area": active_area, "area": active_area,
"region": active_region, "region": active_region,
"region_data": active_area.spaces[ "region_data": region_data,
0
].region_3d, # Get region_data from space_data
"scene": context.scene, "scene": context.scene,
"view_layer": context.view_layer, "view_layer": context.view_layer,
} }
@@ -1757,69 +1722,70 @@ class AssetDragOperator(bpy.types.Operator):
self.in_node_editor = False self.in_node_editor = False
self.node_editor_type = None 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]: def modal(self, context: bpy.types.Context, event: bpy.types.Event) -> Set[str]:
cls = type(self)
ui_props = bpy.context.window_manager.blenderkitUI ui_props = bpy.context.window_manager.blenderkitUI
self.resolution_factor = ( self.mouse_screen_x = int(context.window.x + event.mouse_x)
bpy.context.preferences.system.pixel_size self.mouse_screen_y = int(context.window.y + event.mouse_y)
/ 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
)
# Find the active region under the mouse cursor using actual screen coordinates # Find the active region under the mouse cursor using actual screen coordinates
self.active_window, self.active_area, self.active_region = ( found_window, found_area, found_region = self.find_active_region(
self.find_active_region(self.mouse_screen_x, self.mouse_screen_y) 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 --- # --- CURSOR VISIBILITY FIX ---
if self.active_region is None or self.active_area is None: 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"} return {"PASS_THROUGH"}
elif self.drag:
if self.drag:
bpy.context.window.cursor_modal_set("NONE") bpy.context.window.cursor_modal_set("NONE")
# Convert screen coords (bottom-left) to region-local coords # Convert screen coords (bottom-left) to region-local coords
# window.x/y and region.x/y are also in bottom-left coordinate system # window.x/y and region.x/y are also in bottom-left coordinate system
self.mouse_x = int( self.mouse_x = int(
self.mouse_screen_x self.mouse_screen_x - self.active_window.x - self.active_region.x
- self.active_window.x * self.resolution_factor
- self.active_region.x
) )
self.mouse_y = int( self.mouse_y = int(
self.mouse_screen_y self.mouse_screen_y - self.active_window.y - self.active_region.y
- self.active_window.y * self.resolution_factor
- self.active_region.y
) )
if self.start_mouse_x is None or self.start_mouse_y is None: # redraw all windows to update cursor and other elements
self.start_mouse_x = self.mouse_x
self.start_mouse_y = self.mouse_y
# --- REDRAW ALL WINDOWS/AREAS FOR MULTI-WINDOW DRAG ---
for window in bpy.context.window_manager.windows: for window in bpy.context.window_manager.windows:
for area in window.screen.areas: for area in window.screen.areas:
area.tag_redraw() 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 # Check if we're transitioning out of the outliner
if ( if self.prev_area_type == "OUTLINER" and current_area_type != "OUTLINER":
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
self.restore_original_selection() self.restore_original_selection()
# shift pressed self.shift_pressed = event.shift
if event.shift:
self.shift_pressed = True
else:
self.shift_pressed = False
# Track if we're in a node editor # Track if we're in a node editor
self._handle_node_editor_type(current_area_type, self.active_area) 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 # Store the active region pointer for drawing 2D elements only in this region
self.active_region_pointer = self.active_region.as_pointer() 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 # 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.hovered_outliner_element = self.find_outliner_element_under_mouse()
self.outliner_window = self.active_window self.outliner_window = self.active_window
self.outliner_area = self.active_area self.outliner_area = self.active_area
@@ -1853,48 +1815,46 @@ class AssetDragOperator(bpy.types.Operator):
self.active_region_pointer = context.region.as_pointer() self.active_region_pointer = context.region.as_pointer()
# are we dragging already? # 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 ( if not self.drag and (
abs(self.start_mouse_x - self.mouse_x) > DRAG_THRESHOLD delta_x > DEFAULT_DRAG_THRESHOLD or delta_y > DEFAULT_DRAG_THRESHOLD
or abs(self.start_mouse_y - self.mouse_y) > DRAG_THRESHOLD
): ):
self.drag = True self.drag = True
if self.drag and ui_props.assetbar_on: if self.drag and ui_props.assetbar_on and not self.closed_assetbar:
# turn off asset bar here, shout start again after finishing drag drop. # turn off asset bar here; reopen after placement when we actually dragged
ui_props.turn_off = True ui_props.turn_off = True
self.closed_assetbar = 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"}
if event.type in {"RIGHTMOUSE", "ESC"}: if event.type in {"RIGHTMOUSE", "ESC"}:
# Restore original selection if we changed it # Restore original selection if we changed it
self.restore_original_selection() self.restore_original_selection()
self.handlers_remove() self._cleanup_drag(ui_props, cls, reopen_assetbar=True)
bpy.context.window.cursor_modal_restore()
ui_props.dragging = False
bpy.ops.view3d.blenderkit_asset_bar_widget(
"INVOKE_REGION_WIN", do_search=False
)
return {"CANCELLED"} 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 sprops = bpy.context.window_manager.blenderkit_models
if event.type == "WHEELUPMOUSE": if event.type == "WHEELUPMOUSE":
sprops.offset_rotation_amount += sprops.offset_rotation_step sprops.offset_rotation_amount += sprops.offset_rotation_step
elif event.type == "WHEELDOWNMOUSE": elif event.type == "WHEELDOWNMOUSE":
sprops.offset_rotation_amount -= sprops.offset_rotation_step sprops.offset_rotation_amount -= sprops.offset_rotation_step
if ( if event.type in {
event.type == "MOUSEMOVE" "MOUSEMOVE",
or event.type == "WHEELUPMOUSE" "INBETWEEN_MOUSEMOVE",
or event.type == "WHEELDOWNMOUSE" "WHEELUPMOUSE",
): "WHEELDOWNMOUSE",
}:
# sometimes active area or region can be None, so we need to check for that # 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: if self.active_area is None or self.active_region is None:
return {"RUNNING_MODAL"} return {"RUNNING_MODAL"}
@@ -1904,11 +1864,7 @@ class AssetDragOperator(bpy.types.Operator):
self.has_hit = False self.has_hit = False
# Only perform raycasting in 3D view areas # Only perform raycasting in 3D view areas
if ( if current_area_type == "VIEW_3D":
self.active_region
and self.active_area
and self.active_area.type == "VIEW_3D"
):
# prefetch the drag active object info # prefetch the drag active object info
self.drag_raycast_3d_view( self.drag_raycast_3d_view(
context, context,
@@ -1921,21 +1877,15 @@ class AssetDragOperator(bpy.types.Operator):
if self.asset_data["assetType"] in ["model", "printable"]: if self.asset_data["assetType"] in ["model", "printable"]:
self.snapped_bbox_min = Vector(self.asset_data["bbox_min"]) self.snapped_bbox_min = Vector(self.asset_data["bbox_min"])
self.snapped_bbox_max = Vector(self.asset_data["bbox_max"]) 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 # In outliner, don't do raycasting, but keep has_hit to avoid errors
self.has_hit = False self.has_hit = False
if event.type == "LEFTMOUSE" and event.value == "RELEASE": if event.type == "LEFTMOUSE" and event.value == "RELEASE":
self.mouse_release(context) # Pass context here self.mouse_release(context)
self.handlers_remove() self._cleanup_drag(ui_props, cls)
bpy.context.window.cursor_modal_restore()
bpy.ops.view3d.run_assetbar_fix_context(keep_running=True, do_search=False)
ui_props.dragging = False
return {"FINISHED"} return {"FINISHED"}
self.steps += 1
# pass event to assetbar so it can close itself # pass event to assetbar so it can close itself
if ui_props.assetbar_on and ui_props.turn_off: if ui_props.assetbar_on and ui_props.turn_off:
return {"PASS_THROUGH"} 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 # Before registering callbacks, check for canceling situations: login and localdir popups, sculpt popup/switch
sr = search.get_search_results() sr = search.get_search_results()
ui_props = bpy.context.window_manager.blenderkitUI 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 # 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 # This is critical for multi-window support where active_index is shared across windows
self.asset_data = dict(sr[self.asset_search_index]) 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 # add-ons
if self.asset_data.get("assetType") == "addon" and not self.asset_data.get( if self.asset_data.get("assetType") == "addon" and not self.asset_data.get(
"canDownload" "canDownload"
@@ -1959,6 +1929,8 @@ class AssetDragOperator(bpy.types.Operator):
bpy.ops.wm.blenderkit_url_dialog( bpy.ops.wm.blenderkit_url_dialog(
"INVOKE_REGION_WIN", url=url, message=message, link_text=link_text "INVOKE_REGION_WIN", url=url, message=message, link_text=link_text
) )
ui_props.dragging = False
cls.active_operator_id = None
return {"CANCELLED"} return {"CANCELLED"}
if not self.asset_data.get("canDownload"): if not self.asset_data.get("canDownload"):
@@ -1969,6 +1941,8 @@ class AssetDragOperator(bpy.types.Operator):
bpy.ops.wm.blenderkit_url_dialog( bpy.ops.wm.blenderkit_url_dialog(
"INVOKE_REGION_WIN", url=url, message=message, link_text=link_text "INVOKE_REGION_WIN", url=url, message=message, link_text=link_text
) )
ui_props.dragging = False
cls.active_operator_id = None
return {"CANCELLED"} return {"CANCELLED"}
prefs = bpy.context.preferences.addons[__package__].preferences prefs = bpy.context.preferences.addons[__package__].preferences
@@ -1982,26 +1956,31 @@ class AssetDragOperator(bpy.types.Operator):
bpy.ops.wm.blenderkit_url_dialog( bpy.ops.wm.blenderkit_url_dialog(
"INVOKE_REGION_WIN", url=url, message=message, link_text=link_text "INVOKE_REGION_WIN", url=url, message=message, link_text=link_text
) )
ui_props.dragging = False
cls.active_operator_id = None
return {"CANCELLED"} return {"CANCELLED"}
if self.asset_data.get("assetType") == "brush": if self.asset_data.get("assetType") == "brush" and not (
if not (context.sculpt_object or context.image_paint_object): 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": # either switch to sculpt mode and layout automatically or show a popup message
bpy.ops.object.mode_set(mode="SCULPT") if context.active_object and context.active_object.type == "MESH":
self.mouse_release(context) # does the main job with assets 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: if bpy.data.workspaces.get("Sculpting") is not None:
bpy.context.window.workspace = bpy.data.workspaces["Sculpting"] bpy.context.window.workspace = bpy.data.workspaces["Sculpting"]
reports.add_report( reports.add_report(
"Automatically switched to sculpt mode to use brushes." "Automatically switched to sculpt mode to use brushes."
) )
else: else:
message = "Select a mesh and switch to sculpt or image paint modes to use the brushes." message = "Select a mesh and switch to sculpt or image paint modes to use the brushes."
bpy.ops.wm.blenderkit_popup_dialog( bpy.ops.wm.blenderkit_popup_dialog(
"INVOKE_REGION_WIN", message=message, width=500 "INVOKE_REGION_WIN", message=message, width=500
) )
return {"CANCELLED"} ui_props.dragging = False
cls.active_operator_id = None
return {"CANCELLED"}
# the arguments we pass the the callback # the arguments we pass the the callback
args = (self, context) 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 # 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 self._handlers_universal[space_type] = handler
except (AttributeError, TypeError) as e: 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._handlers_universal[space_type] = None
self.mouse_x = 0 self.mouse_x = 0
self.mouse_y = 0 self.mouse_y = 0
self.mouse_screen_x = 0 self.mouse_screen_x = self.start_mouse_x
self.mouse_screen_y = 0 self.mouse_screen_y = self.start_mouse_y
self.steps = 0
# Store the initial active region pointer # Store the initial active region pointer
self.active_region_pointer = context.region.as_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 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 = f".{self.asset_data['thumbnail_small']}"
self.iname = (self.iname[:63]) if len(self.iname) > 63 else self.iname 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 = bpy.context.window_manager.blenderkitUI
ui_props.dragging = True
self.drag = False self.drag = False
self.steps = 0
context.window_manager.modal_handler_add(self) context.window_manager.modal_handler_add(self)
return {"RUNNING_MODAL"} 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_size((button_size, button_size))
self.button_close.set_image_position((0, 0)) 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"]) thumbnail_path = os.path.join(directory, self.asset_data["thumbnail_small"])
self.image.set_image(thumbnail_path) self.image.set_image(thumbnail_path)
@@ -2296,7 +2252,10 @@ class DownloadGizmoOperator(BL_UI_OT_draw_operator):
bk_logger.debug("unregistering class %s", cls) bk_logger.debug("unregistering class %s", cls)
instances_copy = cls.instances.copy() instances_copy = cls.instances.copy()
for instance in 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: try:
instance.unregister_handlers(instance.context) instance.unregister_handlers(instance.context)
except Exception as e: except Exception as e:
@@ -2407,37 +2366,6 @@ def create_material_mapping(obj, temp_mesh):
return mapping 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 = ( classes = (
AssetDragOperator, AssetDragOperator,
DownloadGizmoOperator, DownloadGizmoOperator,
@@ -30,6 +30,13 @@ RENDER_OBTYPES = ["MESH", "CURVE", "SURFACE", "METABALL", "TEXT"]
_BLE_5_PLUS = bpy.app.version >= (5, 0, 0) _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): def check_material(props, mat):
e = bpy.context.scene.render.engine e = bpy.context.scene.render.engine
shaders = [] shaders = []
@@ -62,7 +69,10 @@ def check_material(props, mat):
if n.image not in textures: if n.image not in textures:
textures.append(n.image) textures.append(n.image)
props.texture_count += 1 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]) maxres = max(n.image.size[0], n.image.size[1])
props.texture_resolution_max = max( props.texture_resolution_max = max(
@@ -138,7 +148,10 @@ def check_render_engine(props, obs):
textures.append(n.image) textures.append(n.image)
props.texture_count += 1 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]) maxres = max(n.image.size[0], n.image.size[1])
props.texture_resolution_max = max( props.texture_resolution_max = max(
@@ -344,7 +357,7 @@ def check_meshprops(props, obs):
props.manifold = manifold props.manifold = manifold
def countObs(props, obs): def count_objects(props, obs):
ob_types = {} ob_types = {}
count = len(obs) count = len(obs)
for ob in obs: for ob in obs:
@@ -425,7 +438,21 @@ def get_autotags():
check_anim(props, obs) check_anim(props, obs)
check_meshprops(props, obs) check_meshprops(props, obs)
check_modifiers(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": elif ui.asset_type == "MATERIAL":
# reset some properties here, because they might not get re-filled at all when they aren't needed anymore. # 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_max = 0
props.texture_resolution_min = 0 props.texture_resolution_min = 0
check_material(props, mat) check_material(props, mat)
elif ui.asset_type == "HDR": elif ui.asset_type == "HDR":
# reset some properties here, because they might not get re-filled at all when they aren't needed anymore. # 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, FloatProperty,
IntProperty, IntProperty,
FloatVectorProperty, FloatVectorProperty,
StringProperty,
) )
from . import bg_blender, global_vars, paths, tasks_queue, utils, upload, search from . import bg_blender, global_vars, paths, tasks_queue, utils, upload, search
@@ -81,16 +82,31 @@ def get_texture_ui(tpath, iname):
return tex 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 # TODO implement check if the file exists, if size is correct etc. needs some care
if imgpath == "": if imgpath == "":
props.has_thumbnail = False _set_prop(flag_attr, False)
return None 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 ( 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'): # img.file_format == 'JPEG' or img.file_format == 'PNG'):
props.has_thumbnail = True _set_prop(flag_attr, True)
props.thumbnail_generating_state = "" if hasattr(props, "THUMBNAIL_GENERATING_STATE"):
props.THUMBNAIL_GENERATING_STATE = ""
_set_prop(state_attr, "")
utils.get_hidden_texture(img.name) utils.get_hidden_texture(img.name)
# pcoll = icons.icon_collections["previews"] # pcoll = icons.icon_collections["previews"]
@@ -98,7 +114,7 @@ def check_thumbnail(props, imgpath):
return img return img
else: else:
props.has_thumbnail = False _set_prop(flag_attr, False)
output = "" output = ""
if ( if (
img is None img is None
@@ -115,7 +131,7 @@ def check_thumbnail(props, imgpath):
# output += 'image too small, should be at least 512x512\n' # output += 'image too small, should be at least 512x512\n'
# if img.file_format != 'JPEG' or img.file_format != 'PNG': # if img.file_format != 'JPEG' or img.file_format != 'PNG':
# output += 'image has to be a jpeg or 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): def update_upload_model_preview(self, context):
@@ -126,6 +142,20 @@ def update_upload_model_preview(self, context):
check_thumbnail(props, imgpath) 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): def update_upload_scene_preview(self, context):
s = bpy.context.scene s = bpy.context.scene
props = s.blenderkit props = s.blenderkit
@@ -149,7 +179,7 @@ def update_upload_brush_preview(self, context):
brush = utils.get_active_brush() brush = utils.get_active_brush()
if brush is not None: if brush is not None:
props = brush.blenderkit props = brush.blenderkit
imgpath = bpy.path.abspath(brush.icon_filepath) imgpath = props.thumbnail
check_thumbnail(props, imgpath) check_thumbnail(props, imgpath)
@@ -182,9 +212,26 @@ def start_model_thumbnailer(
): ):
"""Start Blender in background and render the thumbnail.""" """Start Blender in background and render the thumbnail."""
SCRIPT_NAME = "autothumb_model_bg.py" 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: if props:
props.is_generating_thumbnail = True _set_prop(computing_attr, True)
props.thumbnail_generating_state = "Saving .blend file" _set_prop(state_attr, "Saving .blend file")
datafile = os.path.join(json_args["tempdir"], BLENDERKIT_EXPORT_DATA_FILE) datafile = os.path.join(json_args["tempdir"], BLENDERKIT_EXPORT_DATA_FILE)
user_preferences = bpy.context.preferences.addons[__package__].preferences user_preferences = bpy.context.preferences.addons[__package__].preferences
@@ -194,6 +241,10 @@ def start_model_thumbnailer(
"cycles" "cycles"
].preferences.compute_device_type ].preferences.compute_device_type
json_args["thumbnail_disable_subdivision"] = (
user_preferences.thumbnail_disable_subdivision
)
try: try:
with open(datafile, "w", encoding="utf-8") as s: with open(datafile, "w", encoding="utf-8") as s:
json.dump(json_args, s, ensure_ascii=False, indent=4) json.dump(json_args, s, ensure_ascii=False, indent=4)
@@ -228,9 +279,10 @@ def start_model_thumbnailer(
env=env, env=env,
) )
bk_logger.info("Started Blender executing %s on file %s", SCRIPT_NAME, 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_base = f"bpy.data.objects['{json_args['asset_name']}']"
eval_path_state = f"bpy.data.objects['{json_args['asset_name']}'].blenderkit.thumbnail_generating_state" eval_path = eval_path_base
eval_path = f"bpy.data.objects['{json_args['asset_name']}']" 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" name = f"{json_args['asset_name']} thumbnailer"
bg_blender.add_bg_process( bg_blender.add_bg_process(
name=name, name=name,
@@ -241,7 +293,7 @@ def start_model_thumbnailer(
process=proc, process=proc,
) )
if props: if props:
props.thumbnail_generating_state = "Started Blender instance" _set_prop(state_attr, "Started Blender instance")
if wait: if wait:
while proc.poll() is None: while proc.poll() is None:
@@ -449,6 +501,132 @@ class GenerateThumbnailOperator(bpy.types.Operator):
return wm.invoke_props_dialog(self, width=400) 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): class ReGenerateThumbnailOperator(bpy.types.Operator):
""" """
Generate default thumbnail with Cycles renderer and upload it. 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 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] render_locally: BoolProperty( # type: ignore[valid-type]
name="Render Locally", name="Render Locally",
description="Render thumbnail locally instead of using server-side rendering", 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.label(text="thumbnailer settings")
layout.prop(props, "thumbnail_background_lightness") layout.prop(props, "thumbnail_background_lightness")
# for printable models # 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_material_color")
layout.prop(props, "thumbnail_angle") layout.prop(props, "thumbnail_angle")
layout.prop(props, "thumbnail_snap_to") layout.prop(props, "thumbnail_snap_to")
@@ -545,6 +734,11 @@ class ReGenerateThumbnailOperator(bpy.types.Operator):
preferences = bpy.context.preferences.addons[__package__].preferences 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: if not self.render_locally:
# Use server-side thumbnail regeneration # Use server-side thumbnail regeneration
success = upload.mark_for_thumbnail( success = upload.mark_for_thumbnail(
@@ -575,8 +769,7 @@ class ReGenerateThumbnailOperator(bpy.types.Operator):
thumb_path = os.path.join(tempdir, an_slug) thumb_path = os.path.join(tempdir, an_slug)
# asset type can be model or printable # asset type can be model or printable
ui_props = bpy.context.window_manager.blenderkitUI self.asset_type = self.asset_type or ui_props.asset_type
self.asset_type = ui_props.asset_type
args_dict = { args_dict = {
"type": self.asset_type, "type": self.asset_type,
"asset_name": self.asset_data["name"], "asset_name": self.asset_data["name"],
@@ -607,6 +800,12 @@ class ReGenerateThumbnailOperator(bpy.types.Operator):
history_step = search.get_active_history_step() history_step = search.get_active_history_step()
sr = history_step.get("search_results", []) sr = history_step.get("search_results", [])
self.asset_data = sr[self.asset_index] 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) return wm.invoke_props_dialog(self, width=400)
@@ -891,6 +1090,7 @@ class ReGenerateMaterialThumbnailOperator(bpy.types.Operator):
def register_thumbnailer(): def register_thumbnailer():
bpy.utils.register_class(GenerateThumbnailOperator) bpy.utils.register_class(GenerateThumbnailOperator)
bpy.utils.register_class(ReGenerateThumbnailOperator) bpy.utils.register_class(ReGenerateThumbnailOperator)
bpy.utils.register_class(GenerateWireframeThumbnailOperator)
bpy.utils.register_class(GenerateMaterialThumbnailOperator) bpy.utils.register_class(GenerateMaterialThumbnailOperator)
bpy.utils.register_class(ReGenerateMaterialThumbnailOperator) bpy.utils.register_class(ReGenerateMaterialThumbnailOperator)
@@ -898,5 +1098,6 @@ def register_thumbnailer():
def unregister_thumbnailer(): def unregister_thumbnailer():
bpy.utils.unregister_class(GenerateThumbnailOperator) bpy.utils.unregister_class(GenerateThumbnailOperator)
bpy.utils.unregister_class(ReGenerateThumbnailOperator) bpy.utils.unregister_class(ReGenerateThumbnailOperator)
bpy.utils.unregister_class(GenerateWireframeThumbnailOperator)
bpy.utils.unregister_class(GenerateMaterialThumbnailOperator) bpy.utils.unregister_class(GenerateMaterialThumbnailOperator)
bpy.utils.unregister_class(ReGenerateMaterialThumbnailOperator) bpy.utils.unregister_class(ReGenerateMaterialThumbnailOperator)
@@ -17,14 +17,14 @@
# ##### END GPL LICENSE BLOCK ##### # ##### END GPL LICENSE BLOCK #####
# type: ignore # type: ignore
from __future__ import annotations
import json import json
import math import math
import os import os
import random
import colorsys
import sys import sys
from traceback import print_exc from traceback import print_exc
from typing import Any, Union
import bpy import bpy
@@ -36,47 +36,66 @@ def get_obnames(BLENDERKIT_EXPORT_DATA: str):
return obnames return obnames
def center_obs_for_thumbnail(obs): def center_objs_for_thumbnail(obs: list[Any]) -> None:
s = bpy.context.scene """Center and scale objects for optimal thumbnail framing.
# obs = bpy.context.selected_objects
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] parent = obs[0]
# Handle instanced collections (linked objects)
if parent.type == "EMPTY" and parent.instance_collection is not None: if parent.type == "EMPTY" and parent.instance_collection is not None:
obs = parent.instance_collection.objects[:] obs = parent.instance_collection.objects[:]
# Get top-level parent
while parent.parent is not None: while parent.parent is not None:
parent = parent.parent 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.rotation_euler = (0, 0, 0)
parent.location = (0, 0, 0) parent.location = (0, 0, 0)
bpy.context.view_layer.update() bpy.context.view_layer.update()
# Calculate bounding box in world space
minx, miny, minz, maxx, maxy, maxz = utils.get_bounds_worldspace(obs) minx, miny, minz, maxx, maxy, maxz = utils.get_bounds_worldspace(obs)
# Center object at world origin
cx = (maxx - minx) / 2 + minx cx = (maxx - minx) / 2 + minx
cy = (maxy - miny) / 2 + miny cy = (maxy - miny) / 2 + miny
for ob in s.collection.objects: for ob in scene.collection.objects:
ob.select_set(False) ob.select_set(False)
bpy.context.view_layer.objects.active = parent bpy.context.view_layer.objects.active = parent
# parent.location += mathutils.Vector((-cx, -cy, -minz))
parent.location = (-cx, -cy, 0) parent.location = (-cx, -cy, 0)
camZ = s.camera.parent.parent # Adjust camera position and scale based on object size
# camZ.location.z = (maxz - minz) / 2 cam_z = scene.camera.parent.parent
camZ.location.z = (maxz) / 2 cam_z.location.z = maxz / 2
# Calculate diagonal size of object for scaling
dx = maxx - minx dx = maxx - minx
dy = maxy - miny dy = maxy - miny
dz = maxz - minz dz = maxz - minz
r = math.sqrt(dx * dx + dy * dy + dz * dz) r = math.sqrt(dx * dx + dy * dy + dz * dz)
# Scale scene elements to fit object
scaler = bpy.context.view_layer.objects["scaler"] scaler = bpy.context.view_layer.objects["scaler"]
scaler.scale = (r, r, r) scaler.scale = (r, r, r)
coef = 0.7 coef = 0.7 # Camera distance coefficient
r *= coef r *= coef
camZ.scale = (r, r, r) cam_z.scale = (r, r, r)
bpy.context.view_layer.update() 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) 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") print(f"- Local repository {parts[1]} added")
def replace_materials(obs, material_name): def replace_materials(
"""Replace all materials on objects with the specified material obs: list[Any], material_name: str
) -> Union[bpy.types.Material, None]:
"""Replace all materials on the given objects with a wireframe material.
Args: Args:
obs: List of objects to process obs: List of Blender objects to modify.
material_name: Name of the material to apply to all objects material_name: Name of the wireframe material to use.
""" """
material = bpy.data.materials.get(material_name) # Create or get the wireframe material
if not material: if material_name in bpy.data.materials:
material = bpy.data.materials[material_name]
else:
bg_blender.progress(f"Material {material_name} not found") bg_blender.progress(f"Material {material_name} not found")
return return
# Assign the wireframe material to all objects
for ob in obs: for ob in obs:
if ob.type == "MESH": if ob.type == "MESH":
# Clear all material slots and add the specified material # Clear all material slots and add the specified material
@@ -131,6 +156,57 @@ def replace_materials(obs, material_name):
return material 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__": if __name__ == "__main__":
try: try:
# args order must match the order in blenderkit/autothumb.py:get_thumbnailer_args()! # 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: with open(BLENDERKIT_EXPORT_DATA, "r", encoding="utf-8") as s:
data = json.load(s) data = json.load(s)
thumbnail_use_gpu = data.get("thumbnail_use_gpu") thumbnail_use_gpu = data.get("thumbnail_use_gpu")
thumbnail_disable_subdivision = data.get("thumbnail_disable_subdivision", False)
if data.get("do_download"): if data.get("do_download"):
# if this isn't here, blender crashes. # 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"]]] 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"] bpy.context.scene.render.filepath = data["thumbnail_path"]
if thumbnail_use_gpu is True: if thumbnail_use_gpu is True:
bpy.context.scene.cycles.device = "GPU" bpy.context.scene.cycles.device = "GPU"
@@ -214,8 +291,8 @@ if __name__ == "__main__":
"SIDE": 4, "SIDE": 4,
"TOP": 5, "TOP": 5,
} }
s = bpy.context.scene scene = bpy.context.scene
s.frame_set(fdict[data["thumbnail_angle"]]) scene.frame_set(fdict[data["thumbnail_angle"]])
snapdict = { snapdict = {
"GROUND": "Ground", "GROUND": "Ground",
@@ -262,12 +339,19 @@ if __name__ == "__main__":
1 - random_color[2], 1 - random_color[2],
1, 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[ bpy.data.materials["bkit background"].node_tree.nodes["Value"].outputs[
"Value" "Value"
].default_value = data["thumbnail_background_lightness"] ].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.cycles.use_denoising = data["thumbnail_denoising"]
bpy.context.view_layer.update() bpy.context.view_layer.update()
@@ -297,14 +381,17 @@ if __name__ == "__main__":
) )
sys.exit(0) 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") bg_blender.progress("uploading thumbnail")
fpath = data["thumbnail_path"] + ".jpg" fpath = data["thumbnail_path"] + ".jpg"
ok = client_lib.complete_upload_file_blocking( ok = client_lib.complete_upload_file_blocking(
api_key=BLENDERKIT_EXPORT_API_KEY, api_key=BLENDERKIT_EXPORT_API_KEY,
asset_id=data["asset_data"]["id"], asset_id=data["asset_data"]["id"],
filepath=fpath, filepath=fpath,
filetype=f"thumbnail", filetype=filetype,
fileindex=0, fileindex=0,
) )
if not ok: if not ok:
@@ -25,7 +25,15 @@ import threading
import bpy import bpy
from bpy.props import EnumProperty 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__) 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): def threadread(tcom: ThreadCom):
"""reads stdout of background process. """Reads stdout of background process.
this threads basically waits for a stdout line to come in,
This thread basically waits for a stdout line to come in,
fills the data, dies.""" fills the data, dies."""
found = False found = False
while not found: while not found:
@@ -74,7 +83,12 @@ def threadread(tcom: ThreadCom):
# ignore empty lines # ignore empty lines
if inline.strip() == "": if inline.strip() == "":
continue 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) progress = re.findall(r"progress\{(.*?)\}", inline)
if len(progress) > 0: if len(progress) > 0:
if type(progress[0]) == int or type(progress[0]) == float: 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.write(output)
sys.stdout.flush() sys.stdout.flush()
except Exception as e: except Exception as e:
print("background progress reporting race condition") bk_logger.exception(
print(e) "background progress reporting race condition", exc_info=False
)
bk_logger.error(f"Error details: {e}")
# @bpy.app.handlers.persistent # @bpy.app.handlers.persistent
@@ -140,10 +156,15 @@ def bg_update():
for p in remove_processes: for p in remove_processes:
bk_logger.info(str(p[1].outtext)) bk_logger.info(str(p[1].outtext))
estring = p[1].eval_path_computing + " = False" estring = p[1].eval_path_computing + " = False"
try: target = _safe_eval_target(p[1].eval_path, p[1].name)
exec(estring) if target is not None:
except Exception as e: try:
bk_logger.error(f"Exception executing eval_path_computing: {e}") 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) bg_processes.remove(p)
# Parse process output # Parse process output
@@ -162,7 +183,7 @@ def bg_update():
tcom.outtext = "" tcom.outtext = ""
text = tcom.lasttext.replace("'", "") # noqa: F841 needed in exec() text = tcom.lasttext.replace("'", "") # noqa: F841 needed in exec()
estring = tcom.eval_path_state + " = text" estring = tcom.eval_path_state + " = text"
# print(tcom.lasttext)
if "finished successfully" in tcom.lasttext: if "finished successfully" in tcom.lasttext:
bk_logger.info(str(tcom.lasttext)) bk_logger.info(str(tcom.lasttext))
bg_processes.remove(p) bg_processes.remove(p)
@@ -174,10 +195,20 @@ def bg_update():
readthread.start() readthread.start()
p[0] = readthread p[0] = readthread
if estring: if estring:
try: target = _safe_eval_target(tcom.eval_path, tcom.name)
exec(estring) if target is not None:
except Exception as e: try:
print(f"Exception while reading from background process: {e}") 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: # if len(bg_processes) == 0:
# bpy.app.timers.unregister(bg_update) # bpy.app.timers.unregister(bg_update)
@@ -235,6 +266,8 @@ class KillBgProcess(bpy.types.Operator):
props.uploading = False props.uploading = False
if self.process_type == "THUMBNAILER": if self.process_type == "THUMBNAILER":
props.is_generating_thumbnail = False 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) # 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. # 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] tcom = p[1]
# print(tcom.process_type, self.process_type) # print(tcom.process_type, self.process_type)
if 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 kill = False
# TODO HDR - add killing of process # TODO HDR - add killing of process
if source.bl_rna.name == "Object" and self.process_source == "MODEL": if source is not None:
if source.name == bpy.context.active_object.name: if (
kill = True source.bl_rna.name == "Object"
if source.bl_rna.name == "Scene" and self.process_source == "SCENE": and self.process_source == "MODEL"
if source.name == bpy.context.scene.name: ):
kill = True if source.name == bpy.context.active_object.name:
if source.bl_rna.name == "Image" and self.process_source == "HDR": kill = True
ui_props = bpy.context.window_manager.blenderkitUI if source.bl_rna.name == "Scene" and self.process_source == "SCENE":
if source.name == ui_props.hdr_upload_image.name: if source.name == bpy.context.scene.name:
kill = False 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 ( if (
source.bl_rna.name == "Material" source.bl_rna.name == "Material"
and self.process_source == "MATERIAL" and self.process_source == "MATERIAL"
): ):
if source.name == bpy.context.active_object.active_material.name: if (
kill = True source.name
if source.bl_rna.name == "Brush" and self.process_source == "BRUSH": == bpy.context.active_object.active_material.name
brush = utils.get_active_brush() ):
if brush is not None and source.name == brush.name: kill = True
kill = True if source.bl_rna.name == "Brush" and self.process_source == "BRUSH":
if ( brush = utils.get_active_brush()
source.bl_rna.name == "Object" if brush is not None and source.name == brush.name:
and self.process_source == "PRINTABLE" kill = True
): if (
if source.name == bpy.context.active_object.name: source.bl_rna.name == "Object"
kill = True and self.process_source == "PRINTABLE"
):
if source.name == bpy.context.active_object.name:
kill = True
if kill: if kill:
estring = tcom.eval_path_computing + " = False" estring = tcom.eval_path_computing + " = False"
exec(estring) exec(estring)
@@ -60,7 +60,7 @@ def delete_unfinished_file(file_name):
try: try:
os.remove(file_name) os.remove(file_name)
except Exception as e: 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) asset_dir = os.path.dirname(file_name)
if len(os.listdir(asset_dir)) == 0: if len(os.listdir(asset_dir)) == 0:
os.rmdir(asset_dir) os.rmdir(asset_dir)
@@ -23,13 +23,24 @@ import random
import secrets import secrets
import string import string
import time import time
import uuid
from urllib.parse import quote as urlquote from urllib.parse import quote as urlquote
from webbrowser import open_new_tab from webbrowser import open_new_tab
import bpy import bpy
from bpy.props import BoolProperty 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): if bpy.app.version >= (4, 2, 0):
from . import override_extension_draw from . import override_extension_draw
@@ -104,6 +115,7 @@ def clean_login_data():
preferences.api_key = "" preferences.api_key = ""
preferences.api_key_timeout = 0 preferences.api_key_timeout = 0
global_vars.BKIT_PROFILE = datas.MineProfile() 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 # Cleanup also the api key in the extensions repository setting and clean the cache
if bpy.app.version >= (4, 2, 0): if bpy.app.version >= (4, 2, 0):
override_extension_draw.ensure_repository(api_key="") override_extension_draw.ensure_repository(api_key="")
@@ -128,14 +140,19 @@ def login(signup: bool) -> None:
code_verifier, code_challenge = generate_pkce_pair() code_verifier, code_challenge = generate_pkce_pair()
state = secrets.token_urlsafe() state = secrets.token_urlsafe()
client_lib.send_oauth_verification_data(code_verifier, state) 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: if signup:
authorize_url = urlquote(authorize_url) authorize_url = urlquote(authorize_url)
authorize_url = f"{global_vars.SERVER}/accounts/register/?next={authorize_url}" authorize_url = f"{global_vars.SERVER}/accounts/register/?next={authorize_url}"
else: else:
authorize_url = f"{global_vars.SERVER}{authorize_url}" authorize_url = f"{global_vars.SERVER}{authorize_url}"
ok = open_new_tab(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]: def generate_pkce_pair() -> tuple[str, str]:
@@ -151,12 +168,17 @@ def generate_pkce_pair() -> tuple[str, str]:
return code_verifier, code_challenge return code_verifier, code_challenge
def get_system_id() -> str:
return f"{uuid.getnode():015d}"
def write_tokens(auth_token, refresh_token, oauth_response): def write_tokens(auth_token, refresh_token, oauth_response):
preferences = bpy.context.preferences.addons[__package__].preferences preferences = bpy.context.preferences.addons[__package__].preferences
preferences.api_key_timeout = int(time.time() + oauth_response["expires_in"]) preferences.api_key_timeout = int(time.time() + oauth_response["expires_in"])
preferences.login_attempt = False preferences.login_attempt = False
preferences.api_key_refresh = refresh_token preferences.api_key_refresh = refresh_token
preferences.api_key = auth_token # triggers api_key update function 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 # write token also to extensions repository setting and clear the cache
if bpy.app.version >= (4, 2, 0): if bpy.app.version >= (4, 2, 0):
override_extension_draw.ensure_repository(api_key=auth_token) override_extension_draw.ensure_repository(api_key=auth_token)
@@ -20,7 +20,7 @@
bl_info = { bl_info = {
"name": "BlenderKit Online Asset Library", "name": "BlenderKit Online Asset Library",
"author": "Vilem Duha, Petr Dlouhy, A. Gajdosik", "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), "blender": (3, 0, 0),
"location": "View3D > Properties > BlenderKit", "location": "View3D > Properties > BlenderKit",
"description": "Boost your workflow with drag&drop assets from the community driven library.", "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", "tracker_url": "https://github.com/BlenderKit/blenderkit/issues",
"category": "3D View", "category": "3D View",
} }
VERSION = (3, 18, 0, 251121) VERSION = (3, 18, 1, 251219)
import logging import logging
import random import random
@@ -242,7 +242,7 @@ engines = (
("CYCLES", "Cycles", "Blender Cycles"), ("CYCLES", "Cycles", "Blender Cycles"),
("EEVEE", "Eevee", "Blender eevee renderer"), ("EEVEE", "Eevee", "Blender eevee renderer"),
("EEEVE_NEXT", "Eevee Next", "Blender eevee renderer (new)"), ("EEEVE_NEXT", "Eevee Next", "Blender eevee renderer (new)"),
("OCTANE", "Octane", "Octane render enginge"), ("OCTANE", "Octane", "Octane render engine"),
("ARNOLD", "Arnold", "Arnold render engine"), ("ARNOLD", "Arnold", "Arnold render engine"),
("V-RAY", "V-Ray", "V-Ray renderer"), ("V-RAY", "V-Ray", "V-Ray renderer"),
("UNREAL", "Unreal", "Unreal engine"), ("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): def udate_down_up(self, context):
"""Perform a search if results are empty.""" """Perform a search if results are empty."""
props = bpy.context.window_manager.blenderkitUI props = bpy.context.window_manager.blenderkitUI
@@ -461,12 +467,12 @@ class BlenderKitUIProps(PropertyGroup):
search_blender_version: BoolProperty( search_blender_version: BoolProperty(
name="Asset Blender Version", name="Asset Blender Version",
description="Limit the assets by version of Blender (minimum, maximum) in which they were created. " 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( search_blender_version_min: StringProperty(
name="Minimal version (including, higher than or equal)", name="Minimal version (including, higher than or equal)",
default="0.0", 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" + "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", + "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, update=search.search_update,
@@ -474,7 +480,7 @@ class BlenderKitUIProps(PropertyGroup):
search_blender_version_max: StringProperty( search_blender_version_max: StringProperty(
name="Maximum version (excluding, lower than)", name="Maximum version (excluding, lower than)",
default="5.99", 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" + "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", + "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, 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_width: IntProperty(name="Rating UI Width", default=rating_ui_scale * 600)
rating_ui_height: IntProperty( 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) 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", subtype="FILE_PATH",
default="", default="",
update=autothumb.update_upload_material_preview, update=autothumb.update_upload_material_preview,
**EXTRA_PATH_OPTIONS,
) )
is_generating_thumbnail: BoolProperty( is_generating_thumbnail: BoolProperty(
@@ -1213,13 +1220,14 @@ class BlenderKitBrushUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
) )
class BlenderKitNodeGroulUploadProps(PropertyGroup, BlenderKitCommonUploadProps): class BlenderKitNodeGroupUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
thumbnail: StringProperty( thumbnail: StringProperty(
name="Thumbnail", name="Thumbnail",
description="Thumbnail path - minimum 1024x1024 square .jpg\n" description="Thumbnail path - minimum 1024x1024 square .jpg\n"
"And make it beautiful!", "And make it beautiful!",
subtype="FILE_PATH", subtype="FILE_PATH",
default="", default="",
**EXTRA_PATH_OPTIONS,
# update=autothumb.update_upload_model_preview, # update=autothumb.update_upload_model_preview,
) )
# mode: EnumProperty( # mode: EnumProperty(
@@ -1326,6 +1334,7 @@ class BlenderKitModelUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
subtype="FILE_PATH", subtype="FILE_PATH",
default="", default="",
update=autothumb.update_upload_model_preview, update=autothumb.update_upload_model_preview,
**EXTRA_PATH_OPTIONS,
) )
thumbnail_background_lightness: FloatProperty( 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)", description="Photo of the 3D printed object (JPG or PNG, preferred size is 1024x1024 or higher)",
subtype="FILE_PATH", subtype="FILE_PATH",
default="", default="",
**EXTRA_PATH_OPTIONS,
) )
photo_thumbnail_will_upload_on_website: BoolProperty( photo_thumbnail_will_upload_on_website: BoolProperty(
name="I will upload photo on website", name="I will upload photo on website",
@@ -1603,6 +1613,7 @@ class BlenderKitSceneUploadProps(PropertyGroup, BlenderKitCommonUploadProps):
subtype="FILE_PATH", subtype="FILE_PATH",
default="", default="",
update=autothumb.update_upload_scene_preview, update=autothumb.update_upload_scene_preview,
**EXTRA_PATH_OPTIONS,
) )
use_design_year: BoolProperty( use_design_year: BoolProperty(
@@ -1766,7 +1777,7 @@ class BlenderKitModelSearchProps(PropertyGroup, BlenderKitCommonSearchProps):
update=search.search_update, update=search.search_update,
) )
search_design_year: BoolProperty( search_design_year: BoolProperty(
name="Sesigned in Year", name="Designed in Year",
description="When the object was approximately designed. \n" description="When the object was approximately designed. \n"
"Useful for search of historical or future objects", "Useful for search of historical or future objects",
default=False, default=False,
@@ -1966,7 +1977,7 @@ def fix_subdir(self, context):
ui_panels.ui_message( ui_panels.ui_message(
title="Fixed to relative path", 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 " " It's a directory BlenderKit creates where your .blend is \n "
"and uses it for storing assets.", "and uses it for storing assets.",
) )
@@ -1992,7 +2003,7 @@ class BlenderKitAddonPreferences(AddonPreferences):
preferences_lock: BoolProperty( preferences_lock: BoolProperty(
name="Preferences Locked", 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, default=False,
) )
@@ -2120,6 +2131,14 @@ class BlenderKitAddonPreferences(AddonPreferences):
update=utils.save_prefs, 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( unpack_files: BoolProperty(
name="Unpack Files", name="Unpack Files",
description="Unpack assets after download \n " description="Unpack assets after download \n "
@@ -2233,8 +2252,8 @@ class BlenderKitAddonPreferences(AddonPreferences):
proxy_address: StringProperty( proxy_address: StringProperty(
name="Custom proxy address", 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. 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. 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="[]", 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): def draw(self, context):
layout = self.layout layout = self.layout
if self.api_key.strip() == "": if self.api_key.strip() == "":
ui_panels.draw_login_buttons(layout) ui_panels.draw_login_buttons(layout)
layout.label( 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: else:
layout.operator("wm.blenderkit_logout", text="Logout", icon="URL") 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, "show_VIEW3D_MT_blenderkit_model_properties")
gui_settings.prop(self, "tips_on_start") gui_settings.prop(self, "tips_on_start")
gui_settings.prop(self, "announcements_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 = layout.box()
network_settings.alignment = "EXPAND" network_settings.alignment = "EXPAND"
network_settings.label(text="Networking settings") 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 # UPDATER SETTINGS
addon_updater_ops.update_settings_ui(self, context) 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 # RUNTIME INFO
globdir_op = layout.operator( globdir_op = layout.operator(
"wm.blenderkit_open_global_directory", "wm.blenderkit_open_global_directory",
@@ -2535,7 +2572,7 @@ classes = (
BlenderKitBrushSearchProps, BlenderKitBrushSearchProps,
BlenderKitBrushUploadProps, BlenderKitBrushUploadProps,
BlenderKitGeoToolSearchProps, BlenderKitGeoToolSearchProps,
BlenderKitNodeGroulUploadProps, BlenderKitNodeGroupUploadProps,
BlenderKitAddonSearchProps, BlenderKitAddonSearchProps,
) )
@@ -2598,10 +2635,10 @@ def register():
type=BlenderKitGeoToolSearchProps type=BlenderKitGeoToolSearchProps
) )
bpy.types.NodeGroup.blenderkit = PointerProperty( # for uploads, not now... bpy.types.NodeGroup.blenderkit = PointerProperty( # for uploads, not now...
type=BlenderKitNodeGroulUploadProps type=BlenderKitNodeGroupUploadProps
) )
bpy.types.NodeTree.blenderkit = PointerProperty( # for uploads, not now... bpy.types.NodeTree.blenderkit = PointerProperty( # for uploads, not now...
type=BlenderKitNodeGroulUploadProps type=BlenderKitNodeGroupUploadProps
) )
bpy.types.WindowManager.blenderkit_addon = PointerProperty( bpy.types.WindowManager.blenderkit_addon = PointerProperty(
type=BlenderKitAddonSearchProps type=BlenderKitAddonSearchProps
@@ -97,7 +97,13 @@ def make_annotations(cls):
if bl_props: if bl_props:
if "__annotations__" not in cls.__dict__: if "__annotations__" not in cls.__dict__:
setattr(cls, "__annotations__", {}) 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(): for k, v in bl_props.items():
annotations[k] = v annotations[k] = v
delattr(cls, k) 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): def append_brush(file_name, brushname=None, link=False, fake_user=True):
"""append a brush""" """append a brush"""
brushes_before = bpy.data.brushes[:]
with bpy.data.libraries.load(file_name, link=link, relative=True) as ( with bpy.data.libraries.load(file_name, link=link, relative=True) as (
data_from, data_from,
data_to, data_to,
): ):
for m in data_from.brushes: 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] data_to.brushes = [m]
brushname = 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 brush.use_fake_user = fake_user
return brush return brush
@@ -93,8 +97,7 @@ def append_nodegroup(
data_to, data_to,
): ):
for g in data_from.node_groups: for g in data_from.node_groups:
print(g) if nodegroupname is None or g.strip() == nodegroupname.strip():
if g == nodegroupname or nodegroupname is None:
data_to.node_groups = [g] data_to.node_groups = [g]
nodegroupname = g nodegroupname = g
nodegroup = bpy.data.node_groups[nodegroupname] nodegroup = bpy.data.node_groups[nodegroupname]
@@ -281,7 +284,7 @@ def append_material(file_name, matname=None, link=False, fake_user=True):
): ):
found = False found = False
for m in data_from.materials: 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] data_to.materials = [m]
matname = m matname = m
found = True found = True
@@ -319,7 +322,7 @@ def append_scene(file_name, scenename=None, link=False, fake_user=False):
data_to, data_to,
): ):
for s in data_from.scenes: 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] data_to.scenes = [s]
scenename = s scenename = s
scene = bpy.data.scenes[scenename] scene = bpy.data.scenes[scenename]
@@ -448,7 +451,7 @@ def link_collection(
data_to, data_to,
): ):
for col in data_from.collections: for col in data_from.collections:
if col == kwargs["name"]: if col.strip() == kwargs["name"].strip():
data_to.collections = [col] data_to.collections = [col]
rotation = (0, 0, 0) rotation = (0, 0, 0)
@@ -21,7 +21,7 @@ import math
import os import os
import re import re
import time import time
from typing import Any, Dict from typing import Any, Dict, Union
import bpy import bpy
from bpy.props import BoolProperty, StringProperty 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): if self.check_ui_resized(context) or self.check_new_search_results(context):
self.update_assetbar_sizes(context) self.update_assetbar_sizes(context)
self.update_assetbar_layout(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( self.scroll_update(
always=True always=True
) # one extra update for scroll for correct redraw, updates all buttons ) # one extra update for scroll for correct redraw, updates all buttons
@@ -395,6 +401,17 @@ def get_tooltip_data(asset_data):
# Add pricing information # Add pricing information
price_text = "" price_text = ""
price_color = colors.WHITE 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) # Check if asset is free or paid (works for all asset types)
is_free = asset_data.get("isFree", True) is_free = asset_data.get("isFree", True)
@@ -403,23 +420,38 @@ def get_tooltip_data(asset_data):
if asset_data.get("assetType") == "addon": if asset_data.get("assetType") == "addon":
# Get pricing info from extensions cache. # Get pricing info from extensions cache.
# Pricing info is shown only for add-ons. # 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") is_for_sale = asset_data.get("isForSale")
if is_for_sale and not can_download and base_price: if utils.profile_is_validator():
price_text = f"${base_price}" segments = []
price_color = colors.PURPLE 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: elif not is_free and not is_for_sale:
price_text = "Full Plan" price_text = "Full Plan"
price_color = colors.PURPLE price_background = colors.ORANGE_FULL
elif (
is_for_sale and can_download elif is_for_sale and can_download:
): # purchased, but not yet downloaded, so we can't show price price_text = "Purchased"
price_text = f"Purchased (${base_price})" price_background = colors.PURPLE_PRICE
price_color = colors.PURPLE
else: else:
price_text = "Free" price_text = "Free"
price_color = colors.GREEN_FREE price_background = colors.GREEN_PRICE
tooltip_data = { tooltip_data = {
"aname": aname, "aname": aname,
@@ -427,12 +459,15 @@ def get_tooltip_data(asset_data):
"quality": quality, "quality": quality,
"price_text": price_text, "price_text": price_text,
"price_color": price_color, "price_color": price_color,
"price_background": price_background,
} }
asset_data["tooltip_data"] = tooltip_data asset_data["tooltip_data"] = tooltip_data
def set_thumb_check( 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: ) -> None:
"""Set image in case it is loaded in search results. Checks global_vars.DATA["images available"]. """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' - 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): class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
"""BlenderKit Asset Bar Operator."""
bl_idname = "view3d.blenderkit_asset_bar_widget" bl_idname = "view3d.blenderkit_asset_bar_widget"
bl_label = "BlenderKit asset bar refresh" bl_label = "BlenderKit asset bar refresh"
bl_description = "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.""" """Initialize the tooltip panel and its widgets."""
self.tooltip_widgets = [] self.tooltip_widgets = []
self.tooltip_scale = 1.0 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 # total_size = tooltip# + 2 * self.margin
self.tooltip_panel = BL_UI_Drag_Panel( self.tooltip_panel = BL_UI_Drag_Panel(
0, 0, self.tooltip_width, self.tooltip_height 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) tooltip_image = BL_UI_Image(0, 0, 1, 1)
img_path = paths.get_addon_thumbnail_path("thumbnail_notready.jpg") img_path = paths.get_addon_thumbnail_path("thumbnail_notready.jpg")
tooltip_image.set_image(img_path) 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_position((0, 0))
tooltip_image.set_image_colorspace("") tooltip_image.set_image_colorspace("")
self.tooltip_image = tooltip_image self.tooltip_image = tooltip_image
self.tooltip_widgets.append(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( dark_panel = BL_UI_Widget(
0, 0,
self.labels_start, self.labels_start,
self.tooltip_width, 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) dark_panel.bg_color = (0.0, 0.0, 0.0, 0.7)
self.tooltip_dark_panel = dark_panel self.tooltip_dark_panel = dark_panel
@@ -549,8 +597,9 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
self.asset_name = name_label self.asset_name = name_label
self.tooltip_widgets.append(name_label) self.tooltip_widgets.append(name_label)
self.gravatar_size = int( self.gravatar_size = max(
self.tooltip_height * self.bottom_panel_fraction - self.tooltip_margin int(self.tooltip_info_height - 2 * self.tooltip_margin),
self.asset_name_text_size,
) )
authors_name = self.new_text( authors_name = self.new_text(
@@ -566,8 +615,8 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
self.tooltip_widgets.append(authors_name) self.tooltip_widgets.append(authors_name)
gravatar_image = BL_UI_Image( gravatar_image = BL_UI_Image(
self.tooltip_width - self.gravatar_size, self.tooltip_width - self.gravatar_size - self.tooltip_margin,
self.tooltip_height - self.gravatar_size, self.tooltip_height - self.gravatar_size - self.tooltip_margin,
1, 1,
1, 1,
) )
@@ -575,8 +624,8 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
gravatar_image.set_image(img_path) gravatar_image.set_image(img_path)
gravatar_image.set_image_size( gravatar_image.set_image_size(
( (
self.gravatar_size - 1 * self.tooltip_margin, self.gravatar_size,
self.gravatar_size - 1 * self.tooltip_margin, self.gravatar_size,
) )
) )
gravatar_image.set_image_position((0, 0)) gravatar_image.set_image_position((0, 0))
@@ -617,7 +666,14 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
height=self.asset_name_text_size, height=self.asset_name_text_size,
text_size=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.tooltip_widgets.append(price_label)
self.price_label = 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""" """Calculate all important sizes for the tooltip"""
region = context.region region = context.region
ui_props = bpy.context.window_manager.blenderkitUI 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"): 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: 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( 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( self.asset_name_text_size = int(
0.039 * self.tooltip_base_size_pixels * ui_scale * self.tooltip_scale 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": if ui_props.asset_type == "HDR":
self.tooltip_width = self.tooltip_size * 2 self.tooltip_width = self.tooltip_size * 2
self.tooltip_height = self.tooltip_size self.tooltip_image_height = self.tooltip_size
else: else:
self.tooltip_width = self.tooltip_size 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_info_height = max(
self.tooltip_height * self.bottom_panel_fraction - self.tooltip_margin 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): def update_assetbar_sizes(self, context):
"""Calculate all important sizes for the asset bar""" """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 ui_props = bpy.context.window_manager.blenderkitUI
user_preferences = bpy.context.preferences.addons[__package__].preferences user_preferences = bpy.context.preferences.addons[__package__].preferences
ui_scale = bpy.context.preferences.view.ui_scale ui_scale = self.get_ui_scale()
# assetbar scaling # assetbar scaling
self.button_margin = int(0 * ui_scale) self.button_margin = int(0 * ui_scale)
self.assetbar_margin = int(2 * ui_scale) self.assetbar_margin = int(2 * ui_scale)
@@ -793,6 +883,10 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
self.bar_x = int( self.bar_x = int(
tools_width + self.button_margin + ui_props.bar_x_offset * ui_scale 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_end = int(ui_width + 180 * ui_scale + self.other_button_size)
self.bar_width = int(region.width - self.bar_x - self.bar_end) 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 search_results is not None and self.wcount > 0:
if user_preferences.assetbar_expanded: if user_preferences.assetbar_expanded:
max_rows = user_preferences.maximized_assetbar_rows 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: else:
max_rows = 1 max_rows = 1
self.hcount = min( self.hcount = min(
@@ -821,8 +925,6 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
self.hcount = 1 self.hcount = 1
self.bar_height = (self.button_size) * self.hcount + 2 * self.assetbar_margin 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": if ui_props.down_up == "UPLOAD":
self.reports_y = region.height - self.bar_y - 600 self.reports_y = region.height - self.bar_y - 600
ui_props.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.width = self.tooltip_width
self.tooltip_panel.height = self.tooltip_height self.tooltip_panel.height = self.tooltip_height
self.tooltip_image.width = self.tooltip_width 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.tooltip_image.set_location(0, 0)
self.gravatar_image.set_location( self.gravatar_image.set_location(
self.tooltip_width - self.gravatar_size, self.tooltip_width - self.gravatar_size - self.tooltip_margin,
self.tooltip_height - self.gravatar_size, self.tooltip_height - self.gravatar_size - self.tooltip_margin,
) )
self.gravatar_image.set_image_size( self.gravatar_image.set_image_size(
( (
self.gravatar_size - 1 * self.tooltip_margin, self.gravatar_size,
self.gravatar_size - 1 * self.tooltip_margin, self.gravatar_size,
) )
) )
self.authors_name.set_location( 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.tooltip_height - self.author_text_size - self.tooltip_margin,
) )
self.authors_name.text_size = self.author_text_size self.authors_name.text_size = self.author_text_size
@@ -922,9 +1026,7 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
0, 0,
self.labels_start, self.labels_start,
) )
self.tooltip_dark_panel.height = ( self.tooltip_dark_panel.height = self.tooltip_info_height
self.tooltip_height * self.bottom_panel_fraction
)
self.tooltip_dark_panel.width = self.tooltip_width self.tooltip_dark_panel.width = self.tooltip_width
self.quality_label.set_location( 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) (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): def update_layout(self, context, event):
"""update UI sizes after their recalculation""" """update UI sizes after their recalculation"""
self.update_assetbar_layout(context) 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_bg_color = (0.2, 0.2, 0.2, 1.0)
self.button_hover_color = (0.8, 0.8, 0.8, 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 = (0.5, 0.5, 0.5, 1.0)
self.button_selected_color_dim = (0.3, 0.3, 0.3, 1.0)
self.buttons = [] self.buttons = []
self.asset_buttons = [] self.asset_buttons = []
@@ -1072,7 +1184,7 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
self.other_button_size, # Same height as tab buttons self.other_button_size, # Same height as tab buttons
) )
# dark blue # 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 # Add widgets to panel - add tab background first so it's behind everything
self.widgets_panel.append(self.tab_area_bg) self.widgets_panel.append(self.tab_area_bg)
@@ -1162,8 +1274,11 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
# Add tab navigation elements # Add tab navigation elements
button_size = self.other_button_size button_size = self.other_button_size
margin = int(button_size * 0.05) 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_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 # Back/Forward history buttons
self.history_back_button = BL_UI_Button( self.history_back_button = BL_UI_Button(
@@ -1199,10 +1314,14 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
tabs = global_vars.TABS["tabs"] tabs = global_vars.TABS["tabs"]
tab_x_start = margin * 4 + button_size * 3 # Starting x position of first tab tab_x_start = margin * 4 + button_size * 3 # Starting x position of first tab
tabs_end_x = 0
for i, tab in enumerate(tabs): for i, tab in enumerate(tabs):
is_active = i == global_vars.TABS["active_tab"]
# Calculate positions # Calculate positions
tab_x = tab_x_start + i * ( tab_x = tab_x_start + i * (
tab_width + button_size + margin tab_width + button_size + margin + space
) # Space for tab and close button ) # Space for tab and close button
# Tab button # Tab button
@@ -1212,13 +1331,15 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
tab_width, # Width of tab tab_width, # Width of tab
button_size, 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.hover_bg_color = self.button_hover_color
tab_button.text = tab["name"] tab_button.text = tab["name"]
tab_button.text_size = button_size * 0.5 tab_button.text_size = button_size * 0.5
tab_button.text_color = self.text_color 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.tab_index = i # Store tab index
tab_button.set_mouse_down(self.switch_tab) # Add click handler tab_button.set_mouse_down(self.switch_tab) # Add click handler
self.tab_buttons.append(tab_button) 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 # Set asset type icon as tab button image
tab_button.set_image_size((tab_icon_size, tab_icon_size)) tab_button.set_image_size((tab_icon_size, tab_icon_size))
tab_button.set_image_position( tab_button.set_image_position(
(margin, (button_size - tab_icon_size) / 2) (margin * 2, (button_size - tab_icon_size) / 2)
) # Center vertically ) # Center vertically
# Only create close button if there's more than one tab # 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 = "×" # Set text after creation
close_tab.text_size = button_size * 0.8 close_tab.text_size = button_size * 0.8
close_tab.text_color = self.text_color 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 close_tab.tab_index = i # Store tab index
# if there's only one tab, the button closes asset bar instead of closing tab # if there's only one tab, the button closes asset bar instead of closing tab
if len(tabs) > 1: if len(tabs) > 1:
close_tab.set_mouse_down(self.remove_tab) # Add click handler close_tab.set_mouse_down(self.remove_tab) # Add click handler
else: else:
close_tab.set_mouse_down(self.cancel_press) close_tab.set_mouse_down(self.cancel_press)
self.close_tab_buttons.append(close_tab) self.close_tab_buttons.append(close_tab)
tabs_end_x = close_x + button_size
# New tab button - position after all tabs and close buttons # New tab button - position after all tabs and close buttons
if len(tabs) > 0: 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 = ( 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 ) # After last tab and its close button
else: else:
new_tab_x = tab_x_start # If no tabs, start at the beginning 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 active_tab["history_index"] < len(active_tab["history"]) - 1
) )
# self.update_buttons()
def set_element_images(self): def set_element_images(self):
"""set ui elements images, has to be done after init of UI.""" """set ui elements images, has to be done after init of UI."""
# img_fp = paths.get_addon_thumbnail_path("vs_rejected.png") # 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( icon_path = paths.get_addon_thumbnail_path(
f"asset_type_{asset_type}.png" 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( icon_path = paths.get_addon_thumbnail_path(
"asset_type_model.png" "asset_type_model.png"
) )
@@ -1444,7 +1565,7 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
"""Initialize the asset bar operator.""" """Initialize the asset bar operator."""
self.tooltip_base_size_pixels = 512 self.tooltip_base_size_pixels = 512
self.tooltip_scale = 1.0 self.tooltip_scale = 1.0
self.bottom_panel_fraction = 0.15 self.bottom_panel_fraction = 0.18
self.needs_tooltip_update = False self.needs_tooltip_update = False
self.update_ui_size(bpy.context) 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 = asset_data["tooltip_data"].get(
"price_color", (1.0, 0.8, 0.2, 1.0) "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 = price_text
self.price_label.text_color = price_color self.price_label.text_color = price_color
self.price_label.visible = bool(price_text) self.price_label.visible = bool(price_text)
self.price_label.bg_color = price_background
# preview comments for validators # preview comments for validators
self.update_comments_for_validators(asset_data) self.update_comments_for_validators(asset_data)
@@ -1721,7 +1846,22 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
- properties_width - 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. # need to set image here because of context issues.
img_path = paths.get_addon_thumbnail_path("star_grey.png") img_path = paths.get_addon_thumbnail_path("star_grey.png")
self.quality_star.set_image(img_path) 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.tooltip_panel.set_location(tooltip_x, tooltip_y)
self.update_tooltip_size(bpy.context) self.update_tooltip_size(bpy.context)
self.update_tooltip_layout(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() self.tooltip_panel.layout_widgets()
# show bookmark button - always on mouse enter # show bookmark button - always on mouse enter
if widget.bookmark_button: if widget.bookmark_button:
@@ -2317,6 +2457,10 @@ class BlenderKitAssetBarOperator(BL_UI_OT_draw_operator):
return # Already on this tab and history step return # Already on this tab and history step
# make original tab original background color # make original tab original background color
self.tab_buttons[global_vars.TABS["active_tab"]].bg_color = self.button_bg_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["active_tab"] = tab_index
global_vars.TABS["tabs"][tab_index]["history_index"] = history_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 # Update history button visibility
active_tab = global_vars.TABS["tabs"][tab_index] active_tab = global_vars.TABS["tabs"][tab_index]
self.history_back_button.visible = active_tab["history_index"] > 0 self.history_back_button.visible = active_tab["history_index"] > 0
self.history_forward_button.visible = ( self.history_forward_button.visible = (
active_tab["history_index"] < len(active_tab["history"]) - 1 active_tab["history_index"] < len(active_tab["history"]) - 1
) )
# make active tab a bit darker # update tab colors
if len(self.tab_buttons) > tab_index: for tab_button in self.tab_buttons:
self.tab_buttons[tab_index].bg_color = self.button_selected_color 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 # update filters
search.update_filters() search.update_filters()
@@ -1467,8 +1467,18 @@ class AssetDragOperator(bpy.types.Operator):
Tuple[None, None, None], Tuple[None, None, None],
]: ]:
"""Find the window, region and area under the mouse cursor.""" """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 # first let's test if it's in this window, so we know we shall continue
window_x = window.x * self.resolution_factor window_x = window.x * self.resolution_factor
window_y = window.y * self.resolution_factor window_y = window.y * self.resolution_factor
@@ -27,6 +27,8 @@ from . import utils
RENDER_OBTYPES = ["MESH", "CURVE", "SURFACE", "METABALL", "TEXT"] RENDER_OBTYPES = ["MESH", "CURVE", "SURFACE", "METABALL", "TEXT"]
_BLE_5_PLUS = bpy.app.version >= (5, 0, 0)
def check_material(props, mat): def check_material(props, mat):
e = bpy.context.scene.render.engine e = bpy.context.scene.render.engine
@@ -217,17 +219,39 @@ def check_rig(props, obs):
props.rig = True 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): def check_anim(props, obs):
animated = False animated = False
for ob in obs: for ob in obs:
if ob.animation_data is not None: if has_keyframes(ob):
a = ob.animation_data.action animated = True
if a is not None: break
for c in a.fcurves:
if len(c.keyframe_points) > 1:
animated = True
# c.keyframe_points.remove(c.keyframe_points[0])
if animated: if animated:
props.animated = True props.animated = True
@@ -209,8 +209,17 @@ def start_model_thumbnailer(
blender_user_scripts_dir = ( blender_user_scripts_dir = (
Path(__file__).resolve().parents[2] Path(__file__).resolve().parents[2]
) # scripts/addons/blenderkit/autothumb.py ) # scripts/addons/blenderkit/autothumb.py
env = {"BLENDER_USER_SCRIPTS": str(blender_user_scripts_dir)} env = {"BLENDER_USER_SCRIPTS": str(blender_user_scripts_dir)}
env.update(os.environ) 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( proc = subprocess.Popen(
args, args,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
@@ -218,7 +227,7 @@ def start_model_thumbnailer(
creationflags=utils.get_process_flags(), creationflags=utils.get_process_flags(),
env=env, 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_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_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 = f"bpy.data.objects['{json_args['asset_name']}']"
@@ -284,8 +293,16 @@ def start_material_thumbnailer(
blender_user_scripts_dir = ( blender_user_scripts_dir = (
Path(__file__).resolve().parents[2] Path(__file__).resolve().parents[2]
) # scripts/addons/blenderkit/autothumb.py ) # scripts/addons/blenderkit/autothumb.py
env = {"BLENDER_USER_SCRIPTS": str(blender_user_scripts_dir)} env = {"BLENDER_USER_SCRIPTS": str(blender_user_scripts_dir)}
env.update(os.environ) env.update(os.environ)
if (
user_preferences.experimental_features
and user_preferences.ignore_env_for_thumbnails
):
env = None
proc = subprocess.Popen( proc = subprocess.Popen(
args, args,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
@@ -293,7 +310,7 @@ def start_material_thumbnailer(
creationflags=utils.get_process_flags(), creationflags=utils.get_process_flags(),
env=env, 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_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" 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.x = 1 # / tscale
ob.data.texspace_size.y = 1 # / tscale ob.data.texspace_size.y = 1 # / tscale
ob.data.texspace_size.z = 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: 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"] ts = data["texture_size_meters"]
if data["thumbnail_type"] in ["BALL", "BALL_COMPLEX", "CLOTH"]: if data["thumbnail_type"] in ["BALL", "BALL_COMPLEX", "CLOTH"]:
utils.automap( utils.automap(
@@ -179,7 +188,13 @@ if __name__ == "__main__":
) )
bpy.context.view_layer.update() 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: if thumbnail_use_gpu is True:
bpy.context.scene.cycles.device = "GPU" bpy.context.scene.cycles.device = "GPU"
@@ -71,6 +71,9 @@ def threadread(tcom: ThreadCom):
return # process terminated return # process terminated
inline = tcom.proc.stdout.readline() inline = tcom.proc.stdout.readline()
inline = inline.decode("utf-8") inline = inline.decode("utf-8")
# ignore empty lines
if inline.strip() == "":
continue
bk_logger.info(inline.strip()) bk_logger.info(inline.strip())
progress = re.findall(r"progress\{(.*?)\}", inline) progress = re.findall(r"progress\{(.*?)\}", inline)
if len(progress) > 0: if len(progress) > 0:
@@ -139,8 +139,8 @@ def login(signup: bool) -> None:
def generate_pkce_pair() -> tuple[str, str]: def generate_pkce_pair() -> tuple[str, str]:
"""Generate PKCE pair - a code verifier and code challange. """Generate PKCE pair - a code verifier and code challenge.
The challange should be sent first to the server, the verifier is used in next steps to verify identity (handles Client). The challenge should be sent first to the server, the verifier is used in next steps to verify identity (handles Client).
""" """
rand = random.SystemRandom() rand = random.SystemRandom()
code_verifier = "".join(rand.choices(string.ascii_letters + string.digits, k=128)) 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.ensure_repository(api_key=auth_token)
override_extension_draw.clear_repo_cache() override_extension_draw.clear_repo_cache()
#
def ensure_token_refresh() -> bool: def ensure_token_refresh() -> bool:
"""Check if API token needs refresh, call refresh and return True if so. """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", "last_check": "2026-04-02 12:41:20.491300",
"backup_date": "December-1-2025", "backup_date": "January-12-2026",
"update_ready": true, "update_ready": true,
"ignore": false, "ignore": false,
"just_restored": false, "just_restored": false,
"just_updated": false, "just_updated": false,
"version_text": { "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": [ "version": [
3, 3,
18, 19,
1, 1,
251219 260402
] ]
} }
} }
@@ -1,5 +1,10 @@
import blf import blf
import bpy 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 from .bl_ui_widget import BL_UI_Widget
@@ -17,6 +22,9 @@ class BL_UI_Label(BL_UI_Widget):
self.multiline = False self.multiline = False
self.row_height = 20 self.row_height = 20
self.padding: Union[Tuple[float, float], float] = 0
self.background = False
@property @property
def text_color(self): def text_color(self):
return self._text_color return self._text_color
@@ -61,6 +69,30 @@ class BL_UI_Label(BL_UI_Widget):
blf.size(font_id, self._text_size, 72) blf.size(font_id, self._text_size, 72)
else: else:
blf.size(font_id, self._text_size) 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 textpos_y = area_height - self.y_screen - self.height
@@ -76,16 +108,55 @@ class BL_UI_Label(BL_UI_Widget):
if self._valign == "CENTER": if self._valign == "CENTER":
y -= height // 2 y -= height // 2
# bottom could be here but there's no reason for it # 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: 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.color(font_id, r, g, b, a)
blf.draw(font_id, self._text) blf.draw(font_id, self._text)
else: else:
lines = self._text.split("\n") for line, _, line_height in line_metrics:
for line in lines: blf.position(font_id, x, current_y, 0)
blf.position(font_id, x, y, 0)
blf.color(font_id, r, g, b, a) blf.color(font_id, r, g, b, a)
blf.draw(font_id, line) 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" id = "blenderkit"
type = "add-on" 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" name = "BlenderKit Online Asset Library"
tagline = "Drag & drop of assets from the community driven 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 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: """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. - 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. - 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. 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) WHITE = (1, 1, 1, 0.9)
TEXT = (0.9, 0.9, 0.9, 0.6) TEXT = (0.9, 0.9, 0.9, 0.9)
GREEN = (0.9, 1, 0.9, 0.6) """TEXT Color for BlenderKit UI text."""
RED = (1, 0.5, 0.5, 0.8)
BLUE = (0.8, 0.8, 1, 0.8)
PURPLE = (0.8, 0.4, 1.0, 1.0) # Full Plan purple TEXT_DIM = (0.8, 0.8, 0.8, 0.9)
GREEN_FREE = (0.4, 0.8, 0.4, 1.0) # Green for free addons
"""Color for validator reports.""" 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) GRAY = (0.7, 0.7, 0.7, 0.6)
"""Default color for debug reports.""" """Default color for debug reports."""
@@ -109,8 +109,10 @@ def get_addon_installation_status(asset_data):
if not is_enabled: if not is_enabled:
extension_module_name = f"bl_ext.www_blenderkit_com.{extension_id}" extension_module_name = f"bl_ext.www_blenderkit_com.{extension_id}"
is_enabled = extension_module_name in enabled_addons is_enabled = extension_module_name in enabled_addons
bk_logger.info( bk_logger.debug(
f"Checking extension format: {extension_module_name} -> enabled: {is_enabled}" "Checking extension format: %s -> enabled: %s",
extension_module_name,
is_enabled,
) )
# Also try other possible repository name formats # 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" in addon.lower() or addon.endswith(extension_id)
] ]
if blenderkit_addons: 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( bk_logger.debug(
f"Addon status check for '{extension_id}': installed={is_installed}, enabled={is_enabled}" "Addon status check for '%s': installed=%s, enabled=%s",
extension_id,
is_installed,
is_enabled,
) )
return { 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): if asset_blender_version < (4, 3, 0) and bpy.app.version >= (4, 3, 0):
brush.asset_clear() brush.asset_clear()
brush.asset_mark() 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 # set the brush active
if bpy.context.view_layer.objects.active.mode == "SCULPT": if bpy.context.view_layer.objects.active.mode == "SCULPT":
if bpy.app.version < (4, 3, 0): 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}" relative_asset_identifier=f"Brush{os.sep}{brush.name}"
) )
# TODO add grease pencil brushes! # TODO add grease pencil brushes!
# bpy.context.tool_settings.image_paint.brush = brush # bpy.context.tool_settings.image_paint.brush = brush
asset_main = brush asset_main = brush
@@ -16,7 +16,7 @@
# #
# ##### END GPL LICENSE BLOCK ##### # ##### END GPL LICENSE BLOCK #####
from logging import INFO, WARN from logging import DEBUG, INFO, WARN
from os import environ from os import environ
from subprocess import Popen from subprocess import Popen
from typing import Any, Optional 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.""" """All loaded profiles of other users. Current user is also present in stripped down version. Key is the UserProfile.id."""
LOGGING_LEVEL_BLENDERKIT = INFO 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 LOGGING_LEVEL_IMPORTED = WARN
PREFS = {} PREFS = {}
@@ -27,8 +27,10 @@ import bpy
icon_collections = {} icon_collections = {}
icons_read = { icons_read = {
"fp.png": "free", "free_plan.png": "free",
"flp.png": "full", "full_plan.png": "full",
"promo_sale_symbol.png": "promo_sale_symbol",
"sale_purple.png": "for_sale",
"trophy.png": "trophy", "trophy.png": "trophy",
"dumbbell.png": "dumbbell", "dumbbell.png": "dumbbell",
"cc0.png": "cc0", "cc0.png": "cc0",
@@ -24,6 +24,7 @@ import shutil
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
from functools import lru_cache
import bpy 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_USER_ASSETS_URL = f"{global_vars.SERVER}/my-assets"
BLENDERKIT_MANUAL_URL = "https://youtu.be/0P8ZjfbUjeA" BLENDERKIT_MANUAL_URL = "https://youtu.be/0P8ZjfbUjeA"
BLENDERKIT_MODEL_UPLOAD_INSTRUCTIONS_URL = f"{global_vars.SERVER}/docs/upload/" 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 = ( BLENDERKIT_MATERIAL_UPLOAD_INSTRUCTIONS_URL = (
f"{global_vars.SERVER}/docs/uploading-material/" 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__)) 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): def get_addon_thumbnail_path(name):
global script_path global script_path
# fpath = os.path.join(p, subpath) # fpath = os.path.join(p, subpath)
@@ -474,6 +480,13 @@ def get_addon_thumbnail_path(name):
return os.path.join(script_path, subpath) 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: def get_config_dir_path() -> str:
"""Get the path to the config directory in global_dir.""" """Get the path to the config directory in global_dir."""
global_dir = bpy.context.preferences.addons[__package__].preferences.global_dir # type: ignore 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'.""" """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_idname = "wm.blenderkit_bookmark_asset"
bl_label = "BlenderKit bookmark assest" bl_label = "BlenderKit bookmark assets"
bl_options = {"REGISTER", "UNDO", "INTERNAL"} bl_options = {"REGISTER", "UNDO", "INTERNAL"}
asset_id: StringProperty( # type: ignore[valid-type] asset_id: StringProperty( # type: ignore[valid-type]
@@ -233,28 +233,29 @@ class SetBookmark(bpy.types.Operator):
ratings_utils.store_rating_local( ratings_utils.store_rating_local(
self.asset_id, rating_type="bookmarks", value=bookmark_value 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"} return {"FINISHED"}
def rating_menu_draw(self, context): ## NOT USED ANYMORE
layout = self.layout # def rating_menu_draw(self, context):
# layout = self.layout
ui_props = context.window_manager.blenderkitUI # ui_props = context.window_manager.blenderkitUI
sr = search.get_search_results() # sr = search.get_search_results()
asset_search_index = ui_props.active_index # asset_search_index = ui_props.active_index
if asset_search_index > -1: # if asset_search_index > -1:
asset_data = dict(sr["results"][asset_search_index]) # asset_data = dict(sr["results"][asset_search_index])
col = layout.column() # col = layout.column()
layout.label(text="Admin rating Tools:") # layout.label(text="Admin rating Tools:")
col.operator_context = "INVOKE_DEFAULT" # col.operator_context = "INVOKE_DEFAULT"
op = col.operator("wm.blenderkit_menu_rating_upload", text="Add Rating") # op = col.operator("wm.blenderkit_menu_rating_upload", text="Add Rating")
op.asset_id = asset_data["id"] # op.asset_id = asset_data["id"]
op.asset_name = asset_data["name"] # op.asset_name = asset_data["name"]
op.asset_type = asset_data["assetType"] # op.asset_type = asset_data["assetType"]
# Coordinates (each one is a triangle). # Coordinates (each one is a triangle).
@@ -20,6 +20,7 @@ import copy
import json import json
import logging import logging
import math import math
from functools import lru_cache
import os import os
import re import re
import unicodedata import unicodedata
@@ -43,6 +44,7 @@ from . import (
image_utils, image_utils,
paths, paths,
reports, reports,
search_price,
resolutions, resolutions,
tasks_queue, tasks_queue,
utils, utils,
@@ -53,6 +55,51 @@ bk_logger = logging.getLogger(__name__)
search_tasks = {} 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): def update_ad(ad):
if not ad.get("assetBaseId"): if not ad.get("assetBaseId"):
try: try:
@@ -136,22 +183,23 @@ def check_clipboard():
""" """
global last_clipboard global last_clipboard
try: # could be problematic on Linux 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: except Exception as e:
bk_logger.warning(f"Failed to get clipboard: {e}") bk_logger.warning(f"Failed to get clipboard: {e}")
return return
if current_clipboard == last_clipboard: if current_clipboard == last_clipboard:
return 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: if asset_type_index == -1:
return return
if not last_clipboard.startswith("asset_base_id:"): if not current_clipboard.startswith("asset_base_id:"):
return return
last_clipboard = current_clipboard
asset_type_string = current_clipboard[asset_type_index:].lower() asset_type_string = current_clipboard[asset_type_index:].lower()
if asset_type_string.find("model") > -1: if asset_type_string.find("model") > -1:
target_asset_type = "MODEL" target_asset_type = "MODEL"
@@ -169,6 +217,10 @@ def check_clipboard():
target_asset_type = "NODEGROUP" target_asset_type = "NODEGROUP"
elif asset_type_string.find("addon") > -1 or asset_type_string.find("add-on") > -1: elif asset_type_string.find("addon") > -1 or asset_type_string.find("add-on") > -1:
target_asset_type = "ADDON" target_asset_type = "ADDON"
else:
bk_logger.debug("Clipboard does not contain valid asset type.")
return
ui_props = bpy.context.window_manager.blenderkitUI ui_props = bpy.context.window_manager.blenderkitUI
if ui_props.asset_type != target_asset_type: 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 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 return True
# don't do anything while dragging - this could switch asset during drag, and make results list length different, # 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] if bpy.context.window_manager.blenderkitUI.dragging: # type: ignore[attr-defined]
return False 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 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 # Store results in history step
history_step["search_results"] = result_field history_step["search_results"] = result_field
history_step["search_results_orig"] = task.result history_step["search_results_orig"] = task.result
@@ -710,7 +766,7 @@ def query_to_url(
scene_uuid: str = "", scene_uuid: str = "",
page_size: int = 15, page_size: int = 15,
) -> str: ) -> 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. 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. TODO: just convert to URL here and move the sorting and adding of params to separate function.
https://www.blenderkit.com/api/v1/search/ 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( def add_search_process(
query, get_next: bool, page_size: int, next_url: str, history_id: str 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 global search_tasks
addon_version = utils.get_addon_version() addon_version = utils.get_addon_version()
blender_version = utils.get_blender_version() blender_version = utils.get_blender_version()
@@ -1232,7 +1289,7 @@ def search(get_next=False, query=None, author_id=""):
def clean_filters(): 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() sprops = utils.get_search_props()
ui_props = bpy.context.window_manager.blenderkitUI ui_props = bpy.context.window_manager.blenderkitUI
ui_props.property_unset("own_only") 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" 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 @classmethod
def description(cls, context, properties): def description(cls, context, properties):
return properties.tooltip return properties.tooltip
@@ -1564,16 +1628,25 @@ class SearchOperator(Operator):
if self.esc: if self.esc:
bpy.ops.view3d.close_popup_button("INVOKE_DEFAULT") bpy.ops.view3d.close_popup_button("INVOKE_DEFAULT")
ui_props = bpy.context.window_manager.blenderkitUI 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 != "": if self.author_id != "":
bk_logger.info(f"Author ID: {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 # 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 # use regex to find it
ui_props.search_keywords = re.sub( search_keywords = re.sub(r"\+author_id:\d+", "", search_keywords)
r"\+author_id:\d+", "", ui_props.search_keywords search_keywords += f"+author_id:{self.author_id}"
)
ui_props.search_keywords += f"+author_id:{self.author_id}" ui_props.search_keywords = search_keywords
if self.keywords != "":
ui_props.search_keywords = self.keywords
search(get_next=self.get_next) 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 @bpy.app.handlers.persistent
def client_communication_timer(): 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. This function is the only one responsible for keeping the Client up and running.
""" """
global pending_tasks global pending_tasks
bk_logger.debug("Getting tasks from Client") bk_logger.log(5, "Getting tasks from Client")
search.check_clipboard() user_preferences = bpy.context.preferences.addons[__package__].preferences
if user_preferences.use_clipboard_scan:
search.check_clipboard()
results = list() results = list()
try: try:
results = client_lib.get_reports(os.getpid()) results = client_lib.get_reports(os.getpid())
@@ -141,7 +143,7 @@ def client_communication_timer():
wm = bpy.context.window_manager wm = bpy.context.window_manager
wm.blenderkitUI.logo_status = "logo" wm.blenderkitUI.logo_status = "logo"
bk_logger.debug("Handling tasks") bk_logger.log(5, "Handling tasks")
results_converted_tasks = [] results_converted_tasks = []
# convert to task type # convert to task type
@@ -166,8 +168,8 @@ def client_communication_timer():
for task in results_converted_tasks: for task in results_converted_tasks:
handle_task(task) handle_task(task)
bk_logger.debug("Task handling finished") bk_logger.log(5, "Task handling finished")
delay = bpy.context.preferences.addons[__package__].preferences.client_polling delay = user_preferences.client_polling
if len(download.download_tasks) > 0: if len(download.download_tasks) > 0:
return min(0.2, delay) return min(0.2, delay)
return 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 url = "" # paths.BLENDERKIT_NODEGROUP_UPLOAD_INSTRUCTIONS_URL
if asset_type == "PRINTABLE": if asset_type == "PRINTABLE":
url = ( url = (
paths.BLENDERKIT_MODEL_UPLOAD_INSTRUCTIONS_URL paths.BLENDERKIT_PRINTABLE_UPLOAD_INSTRUCTIONS_URL
) # Reuse model instructions since prints are similar ) # Reuse model instructions since prints are similar
if asset_type == "ADDON": if asset_type == "ADDON":
asset_type_text = asset_type asset_type_text = asset_type
@@ -1721,6 +1721,10 @@ class VIEW3D_PT_blenderkit_import_settings(Panel):
layout.prop(preferences, "resolution") layout.prop(preferences, "resolution")
# layout.prop(props, 'unpack_files') # 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): def deferred_set_name(props, expected_obj_name):
"""Deferred timer to set empty name of uploaded asset to active Object's 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") is_free = self.asset_data.get("isFree")
# Get pricing info from extensions cache # Get pricing info from extensions cache
user_price = self.asset_data.get("userPrice")
base_price = self.asset_data.get("basePrice") base_price = self.asset_data.get("basePrice")
is_for_sale = self.asset_data.get("isForSale") is_for_sale = self.asset_data.get("isForSale")
if self.asset_data["isPrivate"]: if self.asset_data["isPrivate"]:
text = "Private" text = "Private"
self.draw_property(box, "Access", text, icon="LOCKED") 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: elif is_for_sale and not can_download and base_price:
text = f"${base_price} (Not purchased)" text = f"${base_price} (Not purchased)"
icon = pcoll["full"] icon = pcoll["for_sale"]
self.draw_property( self.draw_property(
box, box,
"Price", "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", tooltip="This addon is for sale but you haven't purchased it yet",
) )
elif is_for_sale and can_download and base_price: elif is_for_sale and can_download and base_price:
text = f"${base_price} (Purchased)" text = f"Purchased"
icon = pcoll["full"] icon = pcoll["for_sale"]
self.draw_property( self.draw_property(
box, box,
"Price", "Price",
@@ -2752,7 +2767,7 @@ class AssetPopupCard(bpy.types.Operator, ratings_utils.RatingProperties):
tooltip="You have purchased this addon", tooltip="You have purchased this addon",
) )
elif not is_free and not is_for_sale: elif not is_free and not is_for_sale:
text = "Full plan required" text = "Full plan"
icon = pcoll["full"] icon = pcoll["full"]
self.draw_property( self.draw_property(
box, 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.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.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] op.author_id = str(author_id) # type: ignore[attr-defined]
button_row = button_row.row(align=True) 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='>')
name_row.label(text=aname) 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") op = name_row.operator("view3d.close_popup_button", text="", icon="CANCEL")
def draw_comment_response(self, context, layout, comment_id): 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" 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): class ClearSearchKeywords(bpy.types.Operator):
"""Clear search keywords""" """Clear search keywords"""
@@ -339,6 +339,25 @@ def get_active_asset_by_type(asset_type="model"):
return None 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(): def get_active_asset():
scene = bpy.context.scene scene = bpy.context.scene
ui_props = bpy.context.window_manager.blenderkitUI ui_props = bpy.context.window_manager.blenderkitUI
@@ -970,12 +989,17 @@ def get_dimensions(obs):
return dim, bbmin, bbmax return dim, bbmin, bbmax
def get_headers(api_key: str = "") -> dict[str, str]: def get_simple_headers() -> dict[str, str]:
headers = { headers = {
"accept": "application/json", "accept": "application/json",
"Platform-Version": platform.platform(), "Platform-Version": platform.platform(),
"addon-version": f"{global_vars.VERSION[0]}.{global_vars.VERSION[1]}.{global_vars.VERSION[2]}.{global_vars.VERSION[3]}", "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 != "": if api_key != "":
headers["Authorization"] = f"Bearer {api_key}" headers["Authorization"] = f"Bearer {api_key}"
@@ -1129,6 +1153,18 @@ def name_update(props, context=None):
asset = get_active_asset() asset = get_active_asset()
if asset.name != fname: # Here we actually rename assets datablocks if asset.name != fname: # Here we actually rename assets datablocks
asset.name = fname # change name of active object to upload Name 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): def fmt_dimensions(p):
@@ -1,6 +1,6 @@
{ {
"last_check": "2026-03-16 11:19:55.639825", "last_check": "2026-04-02 12:41:20.491300",
"backup_date": "January-12-2026", "backup_date": "April-2-2026",
"update_ready": false, "update_ready": false,
"ignore": false, "ignore": false,
"just_restored": false, "just_restored": false,
@@ -1,14 +1,12 @@
import os import os
import logging import logging
from typing import Optional
import blf import blf
import bpy import bpy
import gpu import gpu
from .. import image_utils, ui_bgl from .. import image_utils, ui_bgl
from .bl_ui_widget import BL_UI_Widget from .bl_ui_widget import BL_UI_Widget, region_redraw
from .bl_ui_image import BL_UI_Image
bk_logger = logging.getLogger(__name__) bk_logger = logging.getLogger(__name__)
@@ -18,6 +16,8 @@ class BL_UI_Button(BL_UI_Widget):
def __init__(self, x, y, width, height): def __init__(self, x, y, width, height):
super().__init__(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._text_color = (1.0, 1.0, 1.0, 1.0)
self._hover_bg_color = (0.5, 0.5, 0.5, 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) 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 = None
self.__image_size = (24, 24) self.__image_size = (24, 24)
self.__image_position = (4, 2) self.__image_position = (4, 2)
self.__image_padding = 0.0
self.image_corner_radius = None
@property @property
def text_color(self): def text_color(self):
@@ -38,7 +40,7 @@ class BL_UI_Button(BL_UI_Widget):
@text_color.setter @text_color.setter
def text_color(self, value): def text_color(self, value):
if value != self._text_color: if value != self._text_color:
bpy.context.region.tag_redraw() region_redraw()
self._text_color = value self._text_color = value
@property @property
@@ -48,7 +50,7 @@ class BL_UI_Button(BL_UI_Widget):
@text.setter @text.setter
def text(self, value): def text(self, value):
if value != self._text: if value != self._text:
bpy.context.region.tag_redraw() region_redraw()
self._text = value self._text = value
@property @property
@@ -58,7 +60,7 @@ class BL_UI_Button(BL_UI_Widget):
@text_size.setter @text_size.setter
def text_size(self, value): def text_size(self, value):
if value != self._text_size: if value != self._text_size:
bpy.context.region.tag_redraw() region_redraw()
self._text_size = value self._text_size = value
@property @property
@@ -68,7 +70,7 @@ class BL_UI_Button(BL_UI_Widget):
@hover_bg_color.setter @hover_bg_color.setter
def hover_bg_color(self, value): def hover_bg_color(self, value):
if value != self._hover_bg_color: if value != self._hover_bg_color:
bpy.context.region.tag_redraw() region_redraw()
self._hover_bg_color = value self._hover_bg_color = value
@property @property
@@ -78,7 +80,7 @@ class BL_UI_Button(BL_UI_Widget):
@select_bg_color.setter @select_bg_color.setter
def select_bg_color(self, value): def select_bg_color(self, value):
if value != self._select_bg_color: if value != self._select_bg_color:
bpy.context.region.tag_redraw() region_redraw()
self._select_bg_color = value self._select_bg_color = value
def set_image_size(self, image_size): def set_image_size(self, image_size):
@@ -93,13 +95,16 @@ class BL_UI_Button(BL_UI_Widget):
self.__image self.__image
self.__image.filepath self.__image.filepath
# self.__image.pixels # self.__image.pixels
except Exception as e: except Exception:
self.__image = None self.__image = None
def set_image_colorspace(self, colorspace: str = ""): def set_image_colorspace(self, colorspace: str = ""):
image_utils.set_colorspace(self.__image, colorspace) image_utils.set_colorspace(self.__image, colorspace)
def set_image(self, rel_filepath): 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 # first try to access the image, for cases where it can get removed
self.check_image_exists() self.check_image_exists()
try: try:
@@ -117,6 +122,17 @@ class BL_UI_Button(BL_UI_Widget):
return None return None
return self.__image.filepath 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): def update(self, x, y):
super().update(x, y) super().update(x, y)
self._textpos = [x, y] self._textpos = [x, y]
@@ -127,19 +143,30 @@ class BL_UI_Button(BL_UI_Widget):
area_height = self.get_area_height() area_height = self.get_area_height()
gpu.state.blend_set("ALPHA") gpu.state.blend_set("ALPHA")
fill_color = self._resolve_panel_color()
self.shader.bind() if self.use_rounded_background:
rect_y = area_height - self.y_screen - self.height
self.set_colors() self.draw_background_rect(
self.x_screen,
self.batch_panel.draw(self.shader) 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() self.draw_image()
# Draw text # Draw text
self.draw_text(area_height) self.draw_text(area_height)
def set_colors(self): def _resolve_panel_color(self):
color = self._bg_color color = self._bg_color
# pressed # pressed
@@ -150,7 +177,7 @@ class BL_UI_Button(BL_UI_Widget):
elif self.__state == 2: elif self.__state == 2:
color = self._hover_bg_color color = self._hover_bg_color
self.shader.uniform_float("color", color) return color
def draw_text(self, area_height): def draw_text(self, area_height):
font_id = 1 font_id = 1
@@ -165,9 +192,19 @@ class BL_UI_Button(BL_UI_Widget):
size = blf.dimensions(font_id, self._text) 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 textpos_y = area_height - self._textpos[1] - (self.height + size[1]) / 2.0
blf.position( 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 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 y_screen_flip = self.get_area_height() - self.y_screen
off_x, off_y = self.__image_position off_x, off_y = self.__image_position
sx, sy = self.__image_size sx, sy = self.__image_size
pad = self.__image_padding
ui_bgl.draw_image_runtime( ui_bgl.draw_image_runtime(
self.x_screen + off_x, self.x_screen + off_x + pad,
y_screen_flip - off_y - sy, y_screen_flip - off_y - sy + pad,
sx, sx - 2 * pad,
sy, sy - 2 * pad,
self.__image, self.__image,
1.0, 1.0,
crop=(0, 0, 1, 1), crop=(0, 0, 1, 1),
batch=None, batch=None,
corner_radius=self.image_corner_radius,
) )
return True return True
return False return False
@@ -203,9 +242,7 @@ class BL_UI_Button(BL_UI_Widget):
self.mouse_down_func(self) self.mouse_down_func(self)
except Exception: except Exception:
bk_logger.exception("BL_UI_BUTTON mouse_down() error:") bk_logger.exception("BL_UI_BUTTON mouse_down() error:")
return True return True
return False return False
def set_mouse_down_right(self, mouse_down_right_func): 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): def mouse_down_right(self, x, y):
if self.is_in_rect(x, y): if self.is_in_rect(x, y):
try: if hasattr(self, "mouse_down_right_func"):
self.mouse_down_right_func(self) try:
except Exception: self.mouse_down_right_func(self)
bk_logger.exception("BL_UI_BUTTON mouse_down_right() error:") except Exception:
bk_logger.exception("BL_UI_BUTTON mouse_down_right() error:")
return True return True

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