2026-02-16
This commit is contained in:
@@ -7,7 +7,7 @@
|
||||
"id": "basedplayblast",
|
||||
"name": "BasedPlayblast",
|
||||
"tagline": "Easily create playblasts from Blender and Flamenco",
|
||||
"version": "2.6.1",
|
||||
"version": "2.6.3",
|
||||
"type": "add-on",
|
||||
"maintainer": "RaincloudTheDragon <raincloudthedragon@gmail.com>",
|
||||
"license": [
|
||||
@@ -24,16 +24,16 @@
|
||||
"Workflow",
|
||||
"Video"
|
||||
],
|
||||
"archive_url": "https://github.com/RaincloudTheDragon/BasedPlayblast/releases/download/v2.6.1/BasedPlayblast.v2.6.1.zip",
|
||||
"archive_size": 48471,
|
||||
"archive_hash": "sha256:ce9740ad252a00643f75202b53c9ef1e9c6ee8b5a2d34cbaf751b4084e78665c"
|
||||
"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.14.0",
|
||||
"version": "0.16.0",
|
||||
"type": "add-on",
|
||||
"maintainer": "RaincloudTheDragon <raincloudthedragon@gmail.com>",
|
||||
"license": [
|
||||
@@ -49,16 +49,16 @@
|
||||
"Workflow",
|
||||
"Materials"
|
||||
],
|
||||
"archive_url": "https://github.com/RaincloudTheDragon/Rainys-Bulk-Scene-Tools/releases/download/v0.14.0/Rainys_Bulk_Scene_Tools.v0.14.0.zip",
|
||||
"archive_size": 78363,
|
||||
"archive_hash": "sha256:943c723511fb8d7199bf079cb94ba63c552d6477b9a4e003bfffc185c169ea4b"
|
||||
"archive_url": "https://github.com/RaincloudTheDragon/Rainys-Bulk-Scene-Tools/releases/download/v0.16.0/Rainys_Bulk_Scene_Tools.v0.16.0.zip",
|
||||
"archive_size": 80251,
|
||||
"archive_hash": "sha256:3e6fafe11caa39e48b94288c12b2a88e521c928955a854ffdd1bd0936e6bc70a"
|
||||
},
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"id": "atomic_data_manager",
|
||||
"name": "Atomic Data Manager",
|
||||
"tagline": "Smart cleanup and inspection of Blender data-blocks",
|
||||
"version": "2.1.0",
|
||||
"version": "2.5.0",
|
||||
"type": "add-on",
|
||||
"maintainer": "RaincloudTheDragon",
|
||||
"license": [
|
||||
@@ -70,9 +70,31 @@
|
||||
"management",
|
||||
"cleanup"
|
||||
],
|
||||
"archive_url": "https://github.com/RaincloudTheDragon/atomic-data-manager/releases/download/v2.1.0/Atomic_Data_Manager.v2.1.0.zip",
|
||||
"archive_size": 73646,
|
||||
"archive_hash": "sha256:a10f6b7eb9d7c437574c66dc15f73d74a0ff86e793c7460804d7bf5cb7cb29cc"
|
||||
"archive_url": "https://github.com/RaincloudTheDragon/atomic-data-manager/releases/download/v2.5.0/Atomic_Data_Manager.v2.5.0.zip",
|
||||
"archive_size": 114674,
|
||||
"archive_hash": "sha256:4b4834ed3910a428d4cb01f1891247ad80089b6c5324fc27c6862b09e81ff1c1"
|
||||
},
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"id": "sheepit_project_submitter",
|
||||
"name": "SheepIt Project Submitter",
|
||||
"tagline": "Submit projects to SheepIt render farm",
|
||||
"version": "0.0.7",
|
||||
"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.7/SheepIt_Project_Submitter.v0.0.7.zip",
|
||||
"archive_size": 47250,
|
||||
"archive_hash": "sha256:cb8dee48c45cc51dd8237981f4ab96d97d476b547c8c640606e9bbfd0390a055"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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.16.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.16.0/Rainys_Bulk_Scene_Tools.v0.16.0.zip",
|
||||
"archive_size": 80251,
|
||||
"archive_hash": "sha256:3e6fafe11caa39e48b94288c12b2a88e521c928955a854ffdd1bd0936e6bc70a"
|
||||
},
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"id": "atomic_data_manager",
|
||||
"name": "Atomic Data Manager",
|
||||
"tagline": "Smart cleanup and inspection of Blender data-blocks",
|
||||
"version": "2.5.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.5.0/Atomic_Data_Manager.v2.5.0.zip",
|
||||
"archive_size": 114674,
|
||||
"archive_hash": "sha256:4b4834ed3910a428d4cb01f1891247ad80089b6c5324fc27c6862b09e81ff1c1"
|
||||
},
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"id": "sheepit_project_submitter",
|
||||
"name": "SheepIt Project Submitter",
|
||||
"tagline": "Submit projects to SheepIt render farm",
|
||||
"version": "0.0.6",
|
||||
"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.6/SheepIt_Project_Submitter.v0.0.6.zip",
|
||||
"archive_size": 46535,
|
||||
"archive_hash": "sha256:c465fe190fc2e487f9a0bd7e335d7fbaa8da1000bfbedb785dc821775fbc82a5"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"version": "v1",
|
||||
"blocklist": [],
|
||||
"data": [
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"id": "basedplayblast",
|
||||
"name": "BasedPlayblast",
|
||||
"tagline": "Easily create playblasts from Blender and Flamenco",
|
||||
"version": "2.6.2",
|
||||
"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.2/BasedPlayblast.v2.6.2.zip",
|
||||
"archive_size": 48968,
|
||||
"archive_hash": "sha256:c359a24fccb10b9d8df2941b0d75435eb0f7ac89db61836edb6d993b86354952"
|
||||
},
|
||||
{
|
||||
"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.15.1",
|
||||
"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.15.1/Rainys_Bulk_Scene_Tools.v0.15.1.zip",
|
||||
"archive_size": 81044,
|
||||
"archive_hash": "sha256:a72f7dbf7c35fda94a7b67df79ef131391e0fe2ac4f416703b07ef59afd7235b"
|
||||
},
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"id": "atomic_data_manager",
|
||||
"name": "Atomic Data Manager",
|
||||
"tagline": "Smart cleanup and inspection of Blender data-blocks",
|
||||
"version": "2.4.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.4.1/Atomic_Data_Manager.v2.4.1.zip",
|
||||
"archive_size": 108842,
|
||||
"archive_hash": "sha256:4086ada3e9e8c852fd02d455f11f2f20fd19ca68acd10b101ab3aa0fae2be210"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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.16.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.16.0/Rainys_Bulk_Scene_Tools.v0.16.0.zip",
|
||||
"archive_size": 80251,
|
||||
"archive_hash": "sha256:3e6fafe11caa39e48b94288c12b2a88e521c928955a854ffdd1bd0936e6bc70a"
|
||||
},
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"id": "atomic_data_manager",
|
||||
"name": "Atomic Data Manager",
|
||||
"tagline": "Smart cleanup and inspection of Blender data-blocks",
|
||||
"version": "2.5.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.5.0/Atomic_Data_Manager.v2.5.0.zip",
|
||||
"archive_size": 114674,
|
||||
"archive_hash": "sha256:4b4834ed3910a428d4cb01f1891247ad80089b6c5324fc27c6862b09e81ff1c1"
|
||||
},
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"id": "sheepit_project_submitter",
|
||||
"name": "SheepIt Project Submitter",
|
||||
"tagline": "Submit projects to SheepIt render farm",
|
||||
"version": "0.0.6",
|
||||
"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.6/SheepIt_Project_Submitter.v0.0.6.zip",
|
||||
"archive_size": 46535,
|
||||
"archive_hash": "sha256:c465fe190fc2e487f9a0bd7e335d7fbaa8da1000bfbedb785dc821775fbc82a5"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"version": "v1",
|
||||
"blocklist": [],
|
||||
"data": [
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"id": "basedplayblast",
|
||||
"name": "BasedPlayblast",
|
||||
"tagline": "Easily create playblasts from Blender and Flamenco",
|
||||
"version": "2.6.2",
|
||||
"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.2/BasedPlayblast.v2.6.2.zip",
|
||||
"archive_size": 48968,
|
||||
"archive_hash": "sha256:c359a24fccb10b9d8df2941b0d75435eb0f7ac89db61836edb6d993b86354952"
|
||||
},
|
||||
{
|
||||
"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.15.1",
|
||||
"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.15.1/Rainys_Bulk_Scene_Tools.v0.15.1.zip",
|
||||
"archive_size": 81044,
|
||||
"archive_hash": "sha256:a72f7dbf7c35fda94a7b67df79ef131391e0fe2ac4f416703b07ef59afd7235b"
|
||||
},
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"id": "atomic_data_manager",
|
||||
"name": "Atomic Data Manager",
|
||||
"tagline": "Smart cleanup and inspection of Blender data-blocks",
|
||||
"version": "2.4.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.4.1/Atomic_Data_Manager.v2.4.1.zip",
|
||||
"archive_size": 108842,
|
||||
"archive_hash": "sha256:4086ada3e9e8c852fd02d455f11f2f20fd19ca68acd10b101ab3aa0fae2be210"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"version": "v1",
|
||||
"blocklist": [],
|
||||
"data": [
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"id": "basedplayblast",
|
||||
"name": "BasedPlayblast",
|
||||
"tagline": "Easily create playblasts from Blender and Flamenco",
|
||||
"version": "2.6.2",
|
||||
"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.2/BasedPlayblast.v2.6.2.zip",
|
||||
"archive_size": 48968,
|
||||
"archive_hash": "sha256:c359a24fccb10b9d8df2941b0d75435eb0f7ac89db61836edb6d993b86354952"
|
||||
},
|
||||
{
|
||||
"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.14.1",
|
||||
"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.14.1/Rainys_Bulk_Scene_Tools.v0.14.1.zip",
|
||||
"archive_size": 79521,
|
||||
"archive_hash": "sha256:0ae09f57cf81a971406f05f50dc0d9a25c8dfbbedfabb1a9d72655194c1a9250"
|
||||
},
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"id": "atomic_data_manager",
|
||||
"name": "Atomic Data Manager",
|
||||
"tagline": "Smart cleanup and inspection of Blender data-blocks",
|
||||
"version": "2.3.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.3.0/Atomic_Data_Manager.v2.3.0.zip",
|
||||
"archive_size": 92609,
|
||||
"archive_hash": "sha256:be0304820428e461c3ecda4ab652d5c84d3df9c0548292870350ca86a9ba828c"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"version": "v1",
|
||||
"blocklist": [],
|
||||
"data": [
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"id": "basedplayblast",
|
||||
"name": "BasedPlayblast",
|
||||
"tagline": "Easily create playblasts from Blender and Flamenco",
|
||||
"version": "2.6.2",
|
||||
"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.2/BasedPlayblast.v2.6.2.zip",
|
||||
"archive_size": 48968,
|
||||
"archive_hash": "sha256:c359a24fccb10b9d8df2941b0d75435eb0f7ac89db61836edb6d993b86354952"
|
||||
},
|
||||
{
|
||||
"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.15.1",
|
||||
"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.15.1/Rainys_Bulk_Scene_Tools.v0.15.1.zip",
|
||||
"archive_size": 81044,
|
||||
"archive_hash": "sha256:a72f7dbf7c35fda94a7b67df79ef131391e0fe2ac4f416703b07ef59afd7235b"
|
||||
},
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"id": "atomic_data_manager",
|
||||
"name": "Atomic Data Manager",
|
||||
"tagline": "Smart cleanup and inspection of Blender data-blocks",
|
||||
"version": "2.4.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.4.1/Atomic_Data_Manager.v2.4.1.zip",
|
||||
"archive_size": 108842,
|
||||
"archive_hash": "sha256:4086ada3e9e8c852fd02d455f11f2f20fd19ca68acd10b101ab3aa0fae2be210"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,3 +1,102 @@
|
||||
## [v2.5.0] - 2026-01-28
|
||||
|
||||
### Features
|
||||
- Missing file tools: add “Relink All” and improve replacement workflow.
|
||||
|
||||
### Fixes
|
||||
- Missing file UI: fix text field paste + layout/truncation issues; center the detect-missing popup; refine replacement path handling (better dir vs file behavior).
|
||||
- RNA analysis: expand datablock coverage and refine dependency tracking to reduce false “unused” results.
|
||||
|
||||
### Internal
|
||||
- Maintenance: remove deprecated recovery option; improve ignore rules for hidden dot-directories.
|
||||
|
||||
## [v2.4.1] - 2026-01-14
|
||||
|
||||
### Fixes
|
||||
- Fixed RNA analysis crashes when opening new blend files by rebuilding data-block type references dynamically
|
||||
- Fixed indentation errors that prevented RNA dump from processing most data-blocks
|
||||
- Fixed compositing nodetree detection by adding scenes as root nodes in dependency graph
|
||||
- RNA dump JSON file now always generated regardless of debug print settings
|
||||
- Refactored repetitive snapshotting code into `_safe_snapshot()` helper function
|
||||
|
||||
## [v2.4.0] - 2026-01-13
|
||||
|
||||
### Features
|
||||
- **Major Architecture Change: RNA-Based Analysis System**
|
||||
- Replaced multi-process worker system with faster, more robust RNA-based dependency analysis
|
||||
- All data types now use unified RNA introspection for dependency tracking
|
||||
- Eliminated worker processes, job indexing, and subprocess overhead
|
||||
- RNA data can be optionally dumped to JSON for debugging
|
||||
- Improved Clean dialog layout
|
||||
- Increased dialog width to 1000px for better visibility
|
||||
- Items now display in 4-column grid layout to reduce vertical scrolling
|
||||
|
||||
### Fixes
|
||||
- Fixed node groups used by objects via Geometry Nodes modifiers not being detected as used
|
||||
- Fixed RigidBodyWorld and other scene-linked data-blocks incorrectly flagged as cleanable
|
||||
- Fixed area lights and other object data-blocks in scene collections not being marked as used
|
||||
- Added safety checks to prevent crashes during RNA extraction (recursion limits, data-block validation)
|
||||
- Fixed RNA extraction handling for objects' modifier node groups
|
||||
|
||||
### Performance
|
||||
- Significantly faster scanning across all categories using RNA analysis
|
||||
- Single-pass dependency graph building shared across all category scans
|
||||
|
||||
## [v2.3.1] - 2026-01-13
|
||||
|
||||
### Fixes
|
||||
- Integrate proper UDIM detection
|
||||
|
||||
## [v2.3.0] - 2026-01-06
|
||||
|
||||
### Features
|
||||
- Added "Enable Debug Prints" preference to control debug console output
|
||||
- Debug messages now only print when this preference is enabled (default: off)
|
||||
- All debug print statements use centralized `config.debug_print()` helper
|
||||
|
||||
### Fixes
|
||||
- Fixed preferences not displaying in Blender 5.0 extensions
|
||||
- Preferences now correctly match the full module path (`bl_ext.vscode_development.atomic_data_manager`)
|
||||
- Added safe property setter to handle read-only context errors during file loading
|
||||
- Fixed node groups used only by unused materials/objects not being detected as unused (#5)
|
||||
- Node groups now recursively check if parent node groups are unused
|
||||
- Fixed compositor node tree detection to use reference comparison instead of name
|
||||
- Fixed missing import error in node_group_compositors()
|
||||
- Made Clean execute deletion synchronous for faster performance
|
||||
- Fixed callback-initiated scan state not being preserved, causing scans to fail
|
||||
- Fixed instanced collection usage detection
|
||||
|
||||
## [v2.2.0] - 2026-01-05
|
||||
|
||||
### Features
|
||||
- Add loading bars; non-blocking timer-based UI (#10)
|
||||
- Operations no longer freeze the UI during scanning
|
||||
- Real-time progress updates with cancel support at any time
|
||||
- Descriptive status messages showing current operation details
|
||||
- Unified Smart Select and Clean scanning logic
|
||||
- Eliminated code duplication between operations
|
||||
- Clean now only scans selected categories (more efficient)
|
||||
- Both operations use consistent incremental scanning for images and worlds
|
||||
- Added manual cache clear operator for testing and debugging
|
||||
|
||||
### Performance
|
||||
- Optimized deep scan functions with caching and fast-path checks
|
||||
- Image scanning now uses cached results to avoid redundant scene scans
|
||||
- Early exit for clearly unused images using Blender's built-in user count
|
||||
- Incremental processing for large datasets
|
||||
- Images processed in batches (5 per callback) to maintain UI responsiveness
|
||||
- Worlds processed one at a time incrementally
|
||||
|
||||
### Fixes
|
||||
- Fixed images used only by unused objects being incorrectly flagged as unused (#5)
|
||||
- Fixed material detection in brushes and node groups (#6, #7)
|
||||
- Fixed Clean operator not showing dialog when invoked programmatically (#8)
|
||||
- Improved material detection in inspection tools (brushes, node groups)
|
||||
|
||||
### Internal
|
||||
- Refactored scanning architecture for maintainability
|
||||
- Added comprehensive debug output for troubleshooting
|
||||
|
||||
## [v2.1.0] - 2025-12-18
|
||||
|
||||
### Features
|
||||
|
||||
@@ -224,6 +224,23 @@ class ATOMIC_PG_main(bpy.types.PropertyGroup):
|
||||
# search field for the inspect replace operator
|
||||
replace_field: bpy.props.StringProperty()
|
||||
|
||||
# progress tracking properties for timer-based operations
|
||||
is_operation_running: bpy.props.BoolProperty(default=False)
|
||||
operation_progress: bpy.props.FloatProperty(
|
||||
default=0.0,
|
||||
min=0.0,
|
||||
max=100.0,
|
||||
subtype='PERCENTAGE' # This makes it display as percentage
|
||||
)
|
||||
operation_status: bpy.props.StringProperty(default="")
|
||||
cancel_operation: bpy.props.BoolProperty(default=False)
|
||||
|
||||
|
||||
def _on_undo_pre(scene):
|
||||
"""Handler called before undo - invalidate cache."""
|
||||
from .ops import main_ops
|
||||
main_ops._invalidate_cache()
|
||||
|
||||
|
||||
def register():
|
||||
register_class(ATOMIC_PG_main)
|
||||
@@ -233,6 +250,9 @@ def register():
|
||||
ui.register()
|
||||
ops.register()
|
||||
|
||||
# Register undo handler to invalidate cache
|
||||
bpy.app.handlers.undo_pre.append(_on_undo_pre)
|
||||
|
||||
# bootstrap Rainy's Extensions repository
|
||||
rainys_repo_bootstrap.register()
|
||||
|
||||
@@ -241,6 +261,10 @@ def unregister():
|
||||
# bootstrap unregistration
|
||||
rainys_repo_bootstrap.unregister()
|
||||
|
||||
# Remove undo handler
|
||||
if _on_undo_pre in bpy.app.handlers.undo_pre:
|
||||
bpy.app.handlers.undo_pre.remove(_on_undo_pre)
|
||||
|
||||
# atomic package unregistration
|
||||
ui.unregister()
|
||||
ops.unregister()
|
||||
|
||||
@@ -2,7 +2,7 @@ schema_version = "1.0.0"
|
||||
|
||||
id = "atomic_data_manager"
|
||||
name = "Atomic Data Manager"
|
||||
version = "2.1.0"
|
||||
version = "2.5.0"
|
||||
type = "add-on"
|
||||
author = "RaincloudTheDragon"
|
||||
maintainer = "RaincloudTheDragon"
|
||||
|
||||
@@ -32,6 +32,7 @@ Blender, not in here.
|
||||
enable_missing_file_warning = True
|
||||
include_fake_users = False
|
||||
enable_pie_menu_ui = True
|
||||
enable_debug_prints = False
|
||||
|
||||
# hidden atomic preferences
|
||||
pie_menu_type = "D"
|
||||
@@ -39,4 +40,13 @@ pie_menu_alt = False
|
||||
pie_menu_any = False
|
||||
pie_menu_ctrl = False
|
||||
pie_menu_oskey = False
|
||||
pie_menu_shift = False
|
||||
pie_menu_shift = False
|
||||
|
||||
|
||||
def debug_print(*args, **kwargs):
|
||||
"""
|
||||
Print debug messages only if enable_debug_prints is True.
|
||||
Usage: debug_print("message") or debug_print(f"formatted {value}")
|
||||
"""
|
||||
if enable_debug_prints:
|
||||
print(*args, **kwargs)
|
||||
@@ -30,6 +30,9 @@ from ..utils import compat
|
||||
from .utils import delete
|
||||
from .utils import duplicate
|
||||
|
||||
# Module-level state for inspection delete
|
||||
_inspect_delete_state = None
|
||||
|
||||
|
||||
def _check_library_or_override(datablock):
|
||||
"""Check if datablock is library-linked or override, return error message if so."""
|
||||
@@ -488,115 +491,181 @@ class ATOMIC_OT_inspection_delete(bpy.types.Operator):
|
||||
bl_label = "Delete Data-Block"
|
||||
|
||||
def execute(self, context):
|
||||
atom = bpy.context.scene.atomic
|
||||
atom = context.scene.atomic
|
||||
inspection = atom.active_inspection
|
||||
|
||||
if inspection == 'COLLECTIONS':
|
||||
key = atom.collections_field
|
||||
collections = bpy.data.collections
|
||||
|
||||
if key in collections.keys():
|
||||
collection = collections[key]
|
||||
error = _check_library_or_override(collection)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
delete.collection(key)
|
||||
atom.collections_field = ""
|
||||
|
||||
elif inspection == 'IMAGES':
|
||||
key = atom.images_field
|
||||
images = bpy.data.images
|
||||
|
||||
if key in images.keys():
|
||||
image = images[key]
|
||||
error = _check_library_or_override(image)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
delete.image(key)
|
||||
atom.images_field = ""
|
||||
|
||||
elif inspection == 'LIGHTS':
|
||||
key = atom.lights_field
|
||||
lights = bpy.data.lights
|
||||
|
||||
if key in lights.keys():
|
||||
light = lights[key]
|
||||
error = _check_library_or_override(light)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
delete.light(key)
|
||||
atom.lights_field = ""
|
||||
|
||||
elif inspection == 'MATERIALS':
|
||||
key = atom.materials_field
|
||||
materials = bpy.data.materials
|
||||
|
||||
if key in materials.keys():
|
||||
material = materials[key]
|
||||
error = _check_library_or_override(material)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
delete.material(key)
|
||||
atom.materials_field = ""
|
||||
|
||||
elif inspection == 'NODE_GROUPS':
|
||||
key = atom.node_groups_field
|
||||
node_groups = bpy.data.node_groups
|
||||
|
||||
if key in node_groups.keys():
|
||||
node_group = node_groups[key]
|
||||
error = _check_library_or_override(node_group)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
delete.node_group(key)
|
||||
atom.node_groups_field = ""
|
||||
|
||||
elif inspection == 'PARTICLES':
|
||||
key = atom.particles_field
|
||||
particles = bpy.data.particles
|
||||
if key in particles.keys():
|
||||
particle = particles[key]
|
||||
error = _check_library_or_override(particle)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
delete.particle(key)
|
||||
atom.particles_field = ""
|
||||
|
||||
elif inspection == 'TEXTURES':
|
||||
key = atom.textures_field
|
||||
textures = bpy.data.textures
|
||||
|
||||
if key in textures.keys():
|
||||
texture = textures[key]
|
||||
error = _check_library_or_override(texture)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
delete.texture(key)
|
||||
atom.textures_field = ""
|
||||
|
||||
elif inspection == 'WORLDS':
|
||||
key = atom.worlds_field
|
||||
worlds = bpy.data.worlds
|
||||
|
||||
if key in worlds.keys():
|
||||
world = worlds[key]
|
||||
error = _check_library_or_override(world)
|
||||
if error:
|
||||
self.report({'ERROR'}, error)
|
||||
return {'CANCELLED'}
|
||||
delete.world(key)
|
||||
atom.worlds_field = ""
|
||||
|
||||
# Initialize progress tracking
|
||||
atom.is_operation_running = True
|
||||
atom.operation_progress = 0.0
|
||||
atom.operation_status = f"Deleting {inspection.lower()}..."
|
||||
atom.cancel_operation = False
|
||||
|
||||
# Store state in module-level variable for timer processing
|
||||
global _inspect_delete_state
|
||||
_inspect_delete_state = {
|
||||
'inspection': inspection
|
||||
}
|
||||
|
||||
# Start timer for processing (even though it's quick, keep UI responsive)
|
||||
bpy.app.timers.register(_process_inspect_delete_step)
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
def _process_inspect_delete_step():
|
||||
"""Process inspection delete in steps to avoid blocking the UI"""
|
||||
atom = bpy.context.scene.atomic
|
||||
global _inspect_delete_state
|
||||
|
||||
if _inspect_delete_state is None:
|
||||
return None
|
||||
|
||||
inspection = _inspect_delete_state['inspection']
|
||||
|
||||
# Check for cancellation
|
||||
if atom.cancel_operation:
|
||||
atom.is_operation_running = False
|
||||
atom.operation_progress = 0.0
|
||||
atom.operation_status = "Operation cancelled"
|
||||
atom.cancel_operation = False
|
||||
_inspect_delete_state = None
|
||||
# Force UI update
|
||||
for area in bpy.context.screen.areas:
|
||||
area.tag_redraw()
|
||||
return None
|
||||
|
||||
atom.operation_progress = 50.0
|
||||
|
||||
# Perform deletion
|
||||
try:
|
||||
if inspection == 'COLLECTIONS':
|
||||
key = atom.collections_field
|
||||
collections = bpy.data.collections
|
||||
|
||||
if key in collections.keys():
|
||||
collection = collections[key]
|
||||
error = _check_library_or_override(collection)
|
||||
if error:
|
||||
atom.is_operation_running = False
|
||||
atom.operation_status = ""
|
||||
return None
|
||||
delete.collection(key)
|
||||
atom.collections_field = ""
|
||||
|
||||
elif inspection == 'IMAGES':
|
||||
key = atom.images_field
|
||||
images = bpy.data.images
|
||||
|
||||
if key in images.keys():
|
||||
image = images[key]
|
||||
error = _check_library_or_override(image)
|
||||
if error:
|
||||
atom.is_operation_running = False
|
||||
atom.operation_status = ""
|
||||
return None
|
||||
delete.image(key)
|
||||
atom.images_field = ""
|
||||
|
||||
elif inspection == 'LIGHTS':
|
||||
key = atom.lights_field
|
||||
lights = bpy.data.lights
|
||||
|
||||
if key in lights.keys():
|
||||
light = lights[key]
|
||||
error = _check_library_or_override(light)
|
||||
if error:
|
||||
atom.is_operation_running = False
|
||||
atom.operation_status = ""
|
||||
return None
|
||||
delete.light(key)
|
||||
atom.lights_field = ""
|
||||
|
||||
elif inspection == 'MATERIALS':
|
||||
key = atom.materials_field
|
||||
materials = bpy.data.materials
|
||||
|
||||
if key in materials.keys():
|
||||
material = materials[key]
|
||||
error = _check_library_or_override(material)
|
||||
if error:
|
||||
atom.is_operation_running = False
|
||||
atom.operation_status = ""
|
||||
return None
|
||||
delete.material(key)
|
||||
atom.materials_field = ""
|
||||
|
||||
elif inspection == 'NODE_GROUPS':
|
||||
key = atom.node_groups_field
|
||||
node_groups = bpy.data.node_groups
|
||||
|
||||
if key in node_groups.keys():
|
||||
node_group = node_groups[key]
|
||||
error = _check_library_or_override(node_group)
|
||||
if error:
|
||||
atom.is_operation_running = False
|
||||
atom.operation_status = ""
|
||||
return None
|
||||
delete.node_group(key)
|
||||
atom.node_groups_field = ""
|
||||
|
||||
elif inspection == 'PARTICLES':
|
||||
key = atom.particles_field
|
||||
particles = bpy.data.particles
|
||||
if key in particles.keys():
|
||||
particle = particles[key]
|
||||
error = _check_library_or_override(particle)
|
||||
if error:
|
||||
atom.is_operation_running = False
|
||||
atom.operation_status = ""
|
||||
return None
|
||||
delete.particle(key)
|
||||
atom.particles_field = ""
|
||||
|
||||
elif inspection == 'TEXTURES':
|
||||
key = atom.textures_field
|
||||
textures = bpy.data.textures
|
||||
|
||||
if key in textures.keys():
|
||||
texture = textures[key]
|
||||
error = _check_library_or_override(texture)
|
||||
if error:
|
||||
atom.is_operation_running = False
|
||||
atom.operation_status = ""
|
||||
return None
|
||||
delete.texture(key)
|
||||
atom.textures_field = ""
|
||||
|
||||
elif inspection == 'WORLDS':
|
||||
key = atom.worlds_field
|
||||
worlds = bpy.data.worlds
|
||||
|
||||
if key in worlds.keys():
|
||||
world = worlds[key]
|
||||
error = _check_library_or_override(world)
|
||||
if error:
|
||||
atom.is_operation_running = False
|
||||
atom.operation_status = ""
|
||||
return None
|
||||
delete.world(key)
|
||||
atom.worlds_field = ""
|
||||
except:
|
||||
pass # Handle any errors gracefully
|
||||
|
||||
# Operation complete
|
||||
atom.is_operation_running = False
|
||||
atom.operation_progress = 100.0
|
||||
atom.operation_status = ""
|
||||
|
||||
# Clear state
|
||||
_inspect_delete_state = None
|
||||
|
||||
# Force UI update
|
||||
for area in bpy.context.screen.areas:
|
||||
area.tag_redraw()
|
||||
|
||||
return None # Stop timer
|
||||
|
||||
|
||||
reg_list = [
|
||||
ATOMIC_OT_inspection_rename,
|
||||
ATOMIC_OT_inspection_replace,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -26,61 +26,131 @@ import bpy
|
||||
from ...stats import unused
|
||||
|
||||
|
||||
def collections():
|
||||
def collections(cached_list=None):
|
||||
# removes all unused collections from the project
|
||||
for collection_key in unused.collections_deep():
|
||||
bpy.data.collections.remove(bpy.data.collections[collection_key])
|
||||
# If cached_list is provided, use it instead of recalculating
|
||||
if cached_list is not None:
|
||||
collection_keys = cached_list
|
||||
else:
|
||||
collection_keys = unused.collections_deep()
|
||||
|
||||
for collection_key in collection_keys:
|
||||
if collection_key in bpy.data.collections:
|
||||
bpy.data.collections.remove(bpy.data.collections[collection_key])
|
||||
|
||||
|
||||
def images():
|
||||
def images(cached_list=None):
|
||||
# removes all unused images from the project
|
||||
for image_key in unused.images_deep():
|
||||
bpy.data.images.remove(bpy.data.images[image_key])
|
||||
# If cached_list is provided, use it instead of recalculating
|
||||
if cached_list is not None:
|
||||
image_keys = cached_list
|
||||
else:
|
||||
image_keys = unused.images_deep()
|
||||
|
||||
for image_key in image_keys:
|
||||
if image_key in bpy.data.images:
|
||||
bpy.data.images.remove(bpy.data.images[image_key])
|
||||
|
||||
|
||||
def lights():
|
||||
def lights(cached_list=None):
|
||||
# removes all unused lights from the project
|
||||
for light_key in unused.lights_deep():
|
||||
bpy.data.lights.remove(bpy.data.lights[light_key])
|
||||
# If cached_list is provided, use it instead of recalculating
|
||||
if cached_list is not None:
|
||||
light_keys = cached_list
|
||||
else:
|
||||
light_keys = unused.lights_deep()
|
||||
|
||||
for light_key in light_keys:
|
||||
if light_key in bpy.data.lights:
|
||||
bpy.data.lights.remove(bpy.data.lights[light_key])
|
||||
|
||||
|
||||
def materials():
|
||||
def materials(cached_list=None):
|
||||
# removes all unused materials from the project
|
||||
for light_key in unused.materials_deep():
|
||||
bpy.data.materials.remove(bpy.data.materials[light_key])
|
||||
# If cached_list is provided, use it instead of recalculating
|
||||
if cached_list is not None:
|
||||
material_keys = cached_list
|
||||
else:
|
||||
material_keys = unused.materials_deep()
|
||||
|
||||
for material_key in material_keys:
|
||||
if material_key in bpy.data.materials:
|
||||
bpy.data.materials.remove(bpy.data.materials[material_key])
|
||||
|
||||
|
||||
def node_groups():
|
||||
def node_groups(cached_list=None):
|
||||
# removes all unused node groups from the project
|
||||
for node_group_key in unused.node_groups_deep():
|
||||
bpy.data.node_groups.remove(bpy.data.node_groups[node_group_key])
|
||||
# If cached_list is provided, use it instead of recalculating
|
||||
if cached_list is not None:
|
||||
node_group_keys = cached_list
|
||||
else:
|
||||
node_group_keys = unused.node_groups_deep()
|
||||
|
||||
for node_group_key in node_group_keys:
|
||||
if node_group_key in bpy.data.node_groups:
|
||||
bpy.data.node_groups.remove(bpy.data.node_groups[node_group_key])
|
||||
|
||||
|
||||
def particles():
|
||||
def particles(cached_list=None):
|
||||
# removes all unused particle systems from the project
|
||||
for particle_key in unused.particles_deep():
|
||||
bpy.data.particles.remove(bpy.data.particles[particle_key])
|
||||
# If cached_list is provided, use it instead of recalculating
|
||||
if cached_list is not None:
|
||||
particle_keys = cached_list
|
||||
else:
|
||||
particle_keys = unused.particles_deep()
|
||||
|
||||
for particle_key in particle_keys:
|
||||
if particle_key in bpy.data.particles:
|
||||
bpy.data.particles.remove(bpy.data.particles[particle_key])
|
||||
|
||||
|
||||
def textures():
|
||||
def textures(cached_list=None):
|
||||
# removes all unused textures from the project
|
||||
for texture_key in unused.textures_deep():
|
||||
bpy.data.textures.remove(bpy.data.textures[texture_key])
|
||||
# If cached_list is provided, use it instead of recalculating
|
||||
if cached_list is not None:
|
||||
texture_keys = cached_list
|
||||
else:
|
||||
texture_keys = unused.textures_deep()
|
||||
|
||||
for texture_key in texture_keys:
|
||||
if texture_key in bpy.data.textures:
|
||||
bpy.data.textures.remove(bpy.data.textures[texture_key])
|
||||
|
||||
|
||||
def worlds():
|
||||
def worlds(cached_list=None):
|
||||
# removes all unused worlds from the project
|
||||
for world_key in unused.worlds():
|
||||
bpy.data.worlds.remove(bpy.data.worlds[world_key])
|
||||
# If cached_list is provided, use it instead of recalculating
|
||||
if cached_list is not None:
|
||||
world_keys = cached_list
|
||||
else:
|
||||
world_keys = unused.worlds()
|
||||
|
||||
for world_key in world_keys:
|
||||
if world_key in bpy.data.worlds:
|
||||
bpy.data.worlds.remove(bpy.data.worlds[world_key])
|
||||
|
||||
|
||||
def objects():
|
||||
def objects(cached_list=None):
|
||||
# removes all unused objects from the project
|
||||
for object_key in unused.objects_deep():
|
||||
bpy.data.objects.remove(bpy.data.objects[object_key])
|
||||
# If cached_list is provided, use it instead of recalculating
|
||||
if cached_list is not None:
|
||||
object_keys = cached_list
|
||||
else:
|
||||
object_keys = unused.objects_deep()
|
||||
|
||||
for object_key in object_keys:
|
||||
if object_key in bpy.data.objects:
|
||||
bpy.data.objects.remove(bpy.data.objects[object_key])
|
||||
|
||||
|
||||
def armatures():
|
||||
def armatures(cached_list=None):
|
||||
# removes all unused armatures from the project
|
||||
for armature_key in unused.armatures_deep():
|
||||
bpy.data.armatures.remove(bpy.data.armatures[armature_key])
|
||||
# If cached_list is provided, use it instead of recalculating
|
||||
if cached_list is not None:
|
||||
armature_keys = cached_list
|
||||
else:
|
||||
armature_keys = unused.armatures_deep()
|
||||
|
||||
for armature_key in armature_keys:
|
||||
if armature_key in bpy.data.armatures:
|
||||
bpy.data.armatures.remove(bpy.data.armatures[armature_key])
|
||||
|
||||
@@ -59,8 +59,23 @@ def get_missing(data):
|
||||
# Blender 4.2/4.5: Both use 'packed_file' (singular)
|
||||
is_packed = bool(datablock.packed_file) if hasattr(datablock, 'packed_file') else False
|
||||
|
||||
# Check if file exists (with special handling for UDIM images)
|
||||
file_exists = False
|
||||
if abspath and isinstance(datablock, bpy.types.Image) and '<UDIM>' in abspath:
|
||||
# UDIM image: check if any UDIM tile files exist
|
||||
# UDIM tiles are numbered 1001, 1002, etc. (standard range is 1001-1099)
|
||||
# Check a reasonable range of UDIM tiles
|
||||
for udim_tile in range(1001, 1100): # Check tiles 1001-1099
|
||||
udim_path = abspath.replace('<UDIM>', str(udim_tile))
|
||||
if os.path.isfile(udim_path):
|
||||
file_exists = True
|
||||
break
|
||||
elif abspath:
|
||||
# Regular file: check if it exists
|
||||
file_exists = os.path.isfile(abspath)
|
||||
|
||||
# if data-block is not packed and has an invalid filepath
|
||||
if not is_packed and not os.path.isfile(abspath):
|
||||
if not is_packed and not file_exists:
|
||||
|
||||
# if data-block is not in our do not flag list
|
||||
# append it to the missing data list
|
||||
@@ -86,3 +101,54 @@ def images():
|
||||
def libraries():
|
||||
# returns a list of keys of libraries with a non-existent filepath
|
||||
return get_missing(bpy.data.libraries)
|
||||
|
||||
|
||||
def get_missing_library_info(library_key):
|
||||
"""
|
||||
Get information about a missing library for matching and validation.
|
||||
|
||||
Returns:
|
||||
dict with keys:
|
||||
- 'filepath': original filepath
|
||||
- 'filename': basename for matching
|
||||
- 'linked_data_blocks': list of data-block names linked from this library
|
||||
"""
|
||||
if library_key not in bpy.data.libraries:
|
||||
return None
|
||||
|
||||
library = bpy.data.libraries[library_key]
|
||||
filepath = library.filepath
|
||||
filename = os.path.basename(bpy.path.abspath(filepath)) if filepath else ""
|
||||
|
||||
# Get linked data-block names (collections, objects, materials, etc.)
|
||||
linked_data_blocks = []
|
||||
try:
|
||||
# Collections
|
||||
for collection in bpy.data.collections:
|
||||
if collection.library == library:
|
||||
linked_data_blocks.append(('COLLECTION', collection.name))
|
||||
# Objects
|
||||
for obj in bpy.data.objects:
|
||||
if obj.library == library:
|
||||
linked_data_blocks.append(('OBJECT', obj.name))
|
||||
# Materials
|
||||
for material in bpy.data.materials:
|
||||
if material.library == library:
|
||||
linked_data_blocks.append(('MATERIAL', material.name))
|
||||
# Meshes
|
||||
for mesh in bpy.data.meshes:
|
||||
if mesh.library == library:
|
||||
linked_data_blocks.append(('MESH', mesh.name))
|
||||
# Armatures
|
||||
for armature in bpy.data.armatures:
|
||||
if armature.library == library:
|
||||
linked_data_blocks.append(('ARMATURE', armature.name))
|
||||
except Exception:
|
||||
# If we can't access library data, return what we have
|
||||
pass
|
||||
|
||||
return {
|
||||
'filepath': filepath,
|
||||
'filename': filename,
|
||||
'linked_data_blocks': linked_data_blocks
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,7 @@ as determined by stats.users.py
|
||||
import bpy
|
||||
from .. import config
|
||||
from ..utils import compat
|
||||
from ..utils import version
|
||||
from . import users
|
||||
|
||||
|
||||
@@ -90,20 +91,72 @@ def images_deep():
|
||||
# this list also exists in images_shallow()
|
||||
do_not_flag = ["Render Result", "Viewer Node", "D-NOISE Export"]
|
||||
|
||||
total_images = len(bpy.data.images)
|
||||
config.debug_print(f"[Atomic Debug] images_deep(): Starting, total images: {total_images}")
|
||||
checked = 0
|
||||
|
||||
for image in bpy.data.images:
|
||||
# Skip library-linked and override datablocks
|
||||
if compat.is_library_or_override(image):
|
||||
continue
|
||||
|
||||
checked += 1
|
||||
config.debug_print(f"[Atomic Debug] images_deep(): Checking image {checked}/{total_images}: '{image.name}'")
|
||||
|
||||
# First check: standard unused detection
|
||||
config.debug_print(f"[Atomic Debug] images_deep(): Calling users.image_all('{image.name}')...")
|
||||
if not users.image_all(image.name):
|
||||
|
||||
config.debug_print(f"[Atomic Debug] images_deep(): Image '{image.name}' is unused (first check)")
|
||||
# check if image has a fake user or if ignore fake users
|
||||
# is enabled
|
||||
if not image.use_fake_user or config.include_fake_users:
|
||||
|
||||
# if image is not in our do not flag list
|
||||
if image.name not in do_not_flag:
|
||||
unused.append(image.name)
|
||||
else:
|
||||
# Second check: image is used, but check if it's ONLY used by unused objects
|
||||
# This fixes issue #5: images used by unused objects should be marked as unused
|
||||
# Get all objects that use this image (directly or indirectly)
|
||||
objects_using_image = []
|
||||
|
||||
# Check materials that use the image
|
||||
config.debug_print(f"[Atomic Debug] images_deep(): Getting materials for '{image.name}'...")
|
||||
mat_names = users.image_materials(image.name)
|
||||
config.debug_print(f"[Atomic Debug] images_deep(): Found {len(mat_names)} materials using '{image.name}'")
|
||||
for mat_name in mat_names:
|
||||
# Get objects using this material
|
||||
config.debug_print(f"[Atomic Debug] images_deep(): Getting objects for material '{mat_name}'...")
|
||||
objects_using_image.extend(users.material_objects(mat_name))
|
||||
# Also check Geometry Nodes usage
|
||||
config.debug_print(f"[Atomic Debug] images_deep(): Getting Geometry Nodes objects for material '{mat_name}'...")
|
||||
objects_using_image.extend(users.material_geometry_nodes(mat_name))
|
||||
|
||||
# Check Geometry Nodes directly
|
||||
config.debug_print(f"[Atomic Debug] images_deep(): Getting Geometry Nodes objects for '{image.name}'...")
|
||||
objects_using_image.extend(users.image_geometry_nodes(image.name))
|
||||
|
||||
# Remove duplicates
|
||||
objects_using_image = list(set(objects_using_image))
|
||||
config.debug_print(f"[Atomic Debug] images_deep(): Found {len(objects_using_image)} objects using '{image.name}'")
|
||||
|
||||
# If image is only used by objects, and ALL those objects are unused, mark image as unused
|
||||
# Check each object individually to avoid recursion issues
|
||||
if objects_using_image:
|
||||
config.debug_print(f"[Atomic Debug] images_deep(): Checking if all {len(objects_using_image)} objects are unused...")
|
||||
all_objects_unused = all(not users.object_all(obj_name) for obj_name in objects_using_image)
|
||||
if all_objects_unused:
|
||||
config.debug_print(f"[Atomic Debug] images_deep(): All objects are unused, marking '{image.name}' as unused")
|
||||
# Check if image has a fake user or if ignore fake users is enabled
|
||||
if not image.use_fake_user or config.include_fake_users:
|
||||
# if image is not in our do not flag list
|
||||
if image.name not in do_not_flag:
|
||||
unused.append(image.name)
|
||||
config.debug_print(f"[Atomic Debug] images_deep(): Added '{image.name}' to unused list")
|
||||
else:
|
||||
config.debug_print(f"[Atomic Debug] images_deep(): Some objects are used, '{image.name}' is not unused")
|
||||
config.debug_print(f"[Atomic Debug] images_deep(): Finished checking '{image.name}'")
|
||||
|
||||
config.debug_print(f"[Atomic Debug] images_deep(): Complete, checked {checked} images, found {len(unused)} unused")
|
||||
return unused
|
||||
|
||||
|
||||
@@ -160,12 +213,36 @@ def materials_deep():
|
||||
# Skip library-linked and override datablocks
|
||||
if compat.is_library_or_override(material):
|
||||
continue
|
||||
|
||||
# Check if material is used by brushes - these should always be ignored
|
||||
if users.material_brushes(material.name):
|
||||
continue
|
||||
|
||||
# First check: standard unused detection
|
||||
if not users.material_all(material.name):
|
||||
|
||||
# check if material has a fake user or if ignore fake users
|
||||
# is enabled
|
||||
if not material.use_fake_user or config.include_fake_users:
|
||||
unused.append(material.name)
|
||||
else:
|
||||
# Second check: material is used, but check if it's ONLY used by unused objects
|
||||
# This fixes issue #5: materials used by unused objects should be marked as unused
|
||||
# Get all objects that use this material
|
||||
objects_using_material = []
|
||||
objects_using_material.extend(users.material_objects(material.name))
|
||||
objects_using_material.extend(users.material_geometry_nodes(material.name))
|
||||
|
||||
# Remove duplicates
|
||||
objects_using_material = list(set(objects_using_material))
|
||||
|
||||
# If material is only used by objects, and ALL those objects are unused, mark material as unused
|
||||
# Check each object individually to avoid recursion issues
|
||||
if objects_using_material:
|
||||
all_objects_unused = all(not users.object_all(obj_name) for obj_name in objects_using_material)
|
||||
if all_objects_unused:
|
||||
# Check if material has a fake user or if ignore fake users is enabled
|
||||
if not material.use_fake_user or config.include_fake_users:
|
||||
unused.append(material.name)
|
||||
|
||||
return unused
|
||||
|
||||
@@ -174,24 +251,147 @@ def materials_shallow():
|
||||
# returns a list of keys of unused material that may be
|
||||
# incomplete, but is significantly faster than doing a deep search
|
||||
|
||||
return shallow(bpy.data.materials)
|
||||
unused_materials = shallow(bpy.data.materials)
|
||||
|
||||
# Filter out materials used by brushes - these should always be ignored
|
||||
filtered = []
|
||||
for key in unused_materials:
|
||||
material = bpy.data.materials.get(key)
|
||||
if material and not users.material_brushes(key):
|
||||
filtered.append(key)
|
||||
|
||||
return filtered
|
||||
|
||||
|
||||
def _is_compositor_node_tree(node_group):
|
||||
"""
|
||||
Check if a node group is a compositor node tree.
|
||||
In Blender 5.0+, each scene has a compositing_node_tree that should be ignored.
|
||||
|
||||
Args:
|
||||
node_group: The node group to check
|
||||
|
||||
Returns:
|
||||
bool: True if the node group is a compositor node tree
|
||||
"""
|
||||
# Check if this node group is any scene's compositor node tree
|
||||
# Use compat function to handle version differences properly
|
||||
for scene in bpy.data.scenes:
|
||||
if scene.use_nodes:
|
||||
node_tree = compat.get_scene_compositor_node_tree(scene)
|
||||
config.debug_print(f"[Atomic Debug] _is_compositor_node_tree: scene='{scene.name}', use_nodes={scene.use_nodes}, node_tree={node_tree}")
|
||||
if node_tree:
|
||||
config.debug_print(f"[Atomic Debug] _is_compositor_node_tree: node_tree.name='{node_tree.name}', checking against '{node_group.name}'")
|
||||
# Check by reference, not just name (in case user renamed it)
|
||||
if node_tree == node_group:
|
||||
config.debug_print(f"[Atomic Debug] _is_compositor_node_tree: '{node_group.name}' is scene '{scene.name}' compositor node tree")
|
||||
return True
|
||||
else:
|
||||
config.debug_print(f"[Atomic Debug] _is_compositor_node_tree: node_tree != node_group (reference comparison failed)")
|
||||
|
||||
# Also check if it's used as a node within any compositor (via node_group_compositors)
|
||||
# This handles the case where the node group is used within a compositor, not just as the tree itself
|
||||
comp_users = users.node_group_compositors(node_group.name)
|
||||
if comp_users:
|
||||
config.debug_print(f"[Atomic Debug] _is_compositor_node_tree: '{node_group.name}' is used in compositor: {comp_users}")
|
||||
return True
|
||||
|
||||
config.debug_print(f"[Atomic Debug] _is_compositor_node_tree: '{node_group.name}' is NOT a compositor node tree")
|
||||
return False
|
||||
|
||||
|
||||
def node_groups_deep():
|
||||
# returns a list of keys of unused node_groups
|
||||
|
||||
unused = []
|
||||
# Track which node groups we've already determined are unused (to avoid infinite recursion)
|
||||
_unused_node_groups_cache = set()
|
||||
|
||||
def _is_node_group_unused(ng_name, visited=None):
|
||||
"""Recursively check if a node group is unused.
|
||||
Returns True if the node group is only used by unused materials/objects/node_groups."""
|
||||
if visited is None:
|
||||
visited = set()
|
||||
|
||||
# Avoid infinite recursion
|
||||
if ng_name in visited:
|
||||
return False
|
||||
visited.add(ng_name)
|
||||
|
||||
# Check cache first
|
||||
if ng_name in _unused_node_groups_cache:
|
||||
return True
|
||||
|
||||
node_group = bpy.data.node_groups.get(ng_name)
|
||||
if not node_group:
|
||||
return False
|
||||
|
||||
# Skip library-linked and override datablocks
|
||||
if compat.is_library_or_override(node_group):
|
||||
return False
|
||||
# Skip compositor node trees
|
||||
if _is_compositor_node_tree(node_group):
|
||||
return False
|
||||
|
||||
# First check: node group has no users at all
|
||||
all_users = users.node_group_all(ng_name)
|
||||
config.debug_print(f"[Atomic Debug] _is_node_group_unused: '{ng_name}' - all_users = {all_users}")
|
||||
if not all_users:
|
||||
if not node_group.use_fake_user or config.include_fake_users:
|
||||
config.debug_print(f"[Atomic Debug] _is_node_group_unused: '{ng_name}' has no users, marking as unused")
|
||||
_unused_node_groups_cache.add(ng_name)
|
||||
return True
|
||||
|
||||
# Second check: node group is used, but check if it's ONLY used by unused materials/objects/node_groups
|
||||
# Get all materials and objects that use this node group
|
||||
materials_using_ng = users.node_group_materials(ng_name)
|
||||
objects_using_ng = users.node_group_objects(ng_name)
|
||||
parent_node_groups = users.node_group_node_groups(ng_name)
|
||||
|
||||
# Collect all objects that use this node group (directly or via materials)
|
||||
all_objects_using_ng = list(objects_using_ng) # Direct object usage via geometry nodes
|
||||
|
||||
# For each material using this node group, get objects using that material
|
||||
for mat_name in materials_using_ng:
|
||||
# Get objects using this material
|
||||
objects_using_mat = users.material_objects(mat_name)
|
||||
objects_using_mat.extend(users.material_geometry_nodes(mat_name))
|
||||
all_objects_using_ng.extend(objects_using_mat)
|
||||
|
||||
# Remove duplicates
|
||||
all_objects_using_ng = list(set(all_objects_using_ng))
|
||||
|
||||
# Check if all objects are unused
|
||||
all_objects_unused = True
|
||||
if all_objects_using_ng:
|
||||
all_objects_unused = all(not users.object_all(obj_name) for obj_name in all_objects_using_ng)
|
||||
|
||||
# Check if all parent node groups are unused (recursive)
|
||||
all_parent_ngs_unused = True
|
||||
if parent_node_groups:
|
||||
for parent_ng_name in parent_node_groups:
|
||||
if not _is_node_group_unused(parent_ng_name, visited.copy()):
|
||||
all_parent_ngs_unused = False
|
||||
break
|
||||
|
||||
# If node group is only used by unused objects and unused parent node groups, mark it as unused
|
||||
if all_objects_unused and all_parent_ngs_unused:
|
||||
if not node_group.use_fake_user or config.include_fake_users:
|
||||
_unused_node_groups_cache.add(ng_name)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
for node_group in bpy.data.node_groups:
|
||||
# Skip library-linked and override datablocks
|
||||
if compat.is_library_or_override(node_group):
|
||||
continue
|
||||
if not users.node_group_all(node_group.name):
|
||||
|
||||
# check if node group has a fake user or if ignore fake users
|
||||
# is enabled
|
||||
if not node_group.use_fake_user or config.include_fake_users:
|
||||
unused.append(node_group.name)
|
||||
# Skip compositor node trees (Blender 5.0+ creates one per file)
|
||||
if _is_compositor_node_tree(node_group):
|
||||
continue
|
||||
|
||||
if _is_node_group_unused(node_group.name):
|
||||
unused.append(node_group.name)
|
||||
|
||||
return unused
|
||||
|
||||
@@ -200,7 +400,16 @@ def node_groups_shallow():
|
||||
# returns a list of keys of unused node groups that may be
|
||||
# incomplete, but is significantly faster than doing a deep search
|
||||
|
||||
return shallow(bpy.data.node_groups)
|
||||
unused = shallow(bpy.data.node_groups)
|
||||
|
||||
# Filter out compositor node trees (Blender 5.0+ creates one per file)
|
||||
filtered = []
|
||||
for node_group_name in unused:
|
||||
node_group = bpy.data.node_groups.get(node_group_name)
|
||||
if node_group and not _is_compositor_node_tree(node_group):
|
||||
filtered.append(node_group_name)
|
||||
|
||||
return filtered
|
||||
|
||||
|
||||
def particles_deep():
|
||||
@@ -263,21 +472,27 @@ def textures_shallow():
|
||||
|
||||
def worlds():
|
||||
# returns a full list of keys of unused worlds
|
||||
|
||||
config.debug_print(f"[Atomic Debug] unused.worlds(): Starting, total worlds: {len(bpy.data.worlds)}")
|
||||
unused = []
|
||||
checked = 0
|
||||
|
||||
for world in bpy.data.worlds:
|
||||
# Skip library-linked and override datablocks
|
||||
if compat.is_library_or_override(world):
|
||||
continue
|
||||
|
||||
checked += 1
|
||||
config.debug_print(f"[Atomic Debug] unused.worlds(): Checking '{world.name}' (users={world.users}, fake_user={world.use_fake_user})")
|
||||
|
||||
# if data-block has no users or if it has a fake user and
|
||||
# ignore fake users is enabled
|
||||
if world.users == 0 or (world.users == 1 and
|
||||
world.use_fake_user and
|
||||
config.include_fake_users):
|
||||
config.debug_print(f"[Atomic Debug] unused.worlds(): '{world.name}' is unused, adding to list")
|
||||
unused.append(world.name)
|
||||
|
||||
config.debug_print(f"[Atomic Debug] unused.worlds(): Complete, checked {checked} worlds, found {len(unused)} unused")
|
||||
return unused
|
||||
|
||||
|
||||
|
||||
@@ -23,18 +23,35 @@ def get_all_unused_parallel():
|
||||
"""
|
||||
# Execute all checks sequentially but in a clean batch
|
||||
# This avoids threading overhead while keeping code organized
|
||||
return {
|
||||
'collections': unused.collections_deep(),
|
||||
'images': unused.images_deep(),
|
||||
'lights': unused.lights_deep(),
|
||||
'materials': unused.materials_deep(),
|
||||
'node_groups': unused.node_groups_deep(),
|
||||
'objects': unused.objects_deep(),
|
||||
'particles': unused.particles_deep(),
|
||||
'textures': unused.textures_deep(),
|
||||
'armatures': unused.armatures_deep(),
|
||||
'worlds': unused.worlds(),
|
||||
}
|
||||
config.debug_print(f"[Atomic Debug] get_all_unused_parallel: Starting, will scan {len(CATEGORIES)} categories")
|
||||
result = {}
|
||||
for i, category in enumerate(CATEGORIES):
|
||||
config.debug_print(f"[Atomic Debug] get_all_unused_parallel: Scanning {category} ({i+1}/{len(CATEGORIES)})...")
|
||||
if category == 'collections':
|
||||
result[category] = unused.collections_deep()
|
||||
elif category == 'images':
|
||||
result[category] = unused.images_deep()
|
||||
elif category == 'lights':
|
||||
result[category] = unused.lights_deep()
|
||||
elif category == 'materials':
|
||||
result[category] = unused.materials_deep()
|
||||
elif category == 'node_groups':
|
||||
result[category] = unused.node_groups_deep()
|
||||
elif category == 'objects':
|
||||
result[category] = unused.objects_deep()
|
||||
elif category == 'particles':
|
||||
result[category] = unused.particles_deep()
|
||||
elif category == 'textures':
|
||||
result[category] = unused.textures_deep()
|
||||
elif category == 'armatures':
|
||||
result[category] = unused.armatures_deep()
|
||||
elif category == 'worlds':
|
||||
config.debug_print(f"[Atomic Debug] get_all_unused_parallel: Calling unused.worlds()...")
|
||||
result[category] = unused.worlds()
|
||||
config.debug_print(f"[Atomic Debug] get_all_unused_parallel: unused.worlds() returned {len(result[category])} unused worlds")
|
||||
config.debug_print(f"[Atomic Debug] get_all_unused_parallel: Finished {category}")
|
||||
config.debug_print(f"[Atomic Debug] get_all_unused_parallel: Complete, returning results")
|
||||
return result
|
||||
|
||||
|
||||
def _has_any_unused_collections():
|
||||
@@ -54,10 +71,35 @@ def _has_any_unused_images():
|
||||
for image in bpy.data.images:
|
||||
if compat.is_library_or_override(image):
|
||||
continue
|
||||
|
||||
# First check: standard unused detection
|
||||
if not users.image_all(image.name):
|
||||
if not image.use_fake_user or config.include_fake_users:
|
||||
if image.name not in do_not_flag:
|
||||
return True
|
||||
else:
|
||||
# Second check: image is used, but check if it's ONLY used by unused objects
|
||||
# This fixes issue #5: images used by unused objects should be marked as unused
|
||||
objects_using_image = []
|
||||
|
||||
# Check materials that use the image
|
||||
for mat_name in users.image_materials(image.name):
|
||||
objects_using_image.extend(users.material_objects(mat_name))
|
||||
objects_using_image.extend(users.material_geometry_nodes(mat_name))
|
||||
|
||||
# Check Geometry Nodes directly
|
||||
objects_using_image.extend(users.image_geometry_nodes(image.name))
|
||||
|
||||
# Remove duplicates
|
||||
objects_using_image = list(set(objects_using_image))
|
||||
|
||||
# If image is only used by objects, and ALL those objects are unused, mark image as unused
|
||||
if objects_using_image:
|
||||
all_objects_unused = all(not users.object_all(obj_name) for obj_name in objects_using_image)
|
||||
if all_objects_unused:
|
||||
if not image.use_fake_user or config.include_fake_users:
|
||||
if image.name not in do_not_flag:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@@ -77,9 +119,31 @@ def _has_any_unused_materials():
|
||||
for material in bpy.data.materials:
|
||||
if compat.is_library_or_override(material):
|
||||
continue
|
||||
|
||||
# Skip materials used by brushes - these should always be ignored
|
||||
if users.material_brushes(material.name):
|
||||
continue
|
||||
|
||||
# First check: standard unused detection
|
||||
if not users.material_all(material.name):
|
||||
if not material.use_fake_user or config.include_fake_users:
|
||||
return True
|
||||
else:
|
||||
# Second check: material is used, but check if it's ONLY used by unused objects
|
||||
# This fixes issue #5: materials used by unused objects should be marked as unused
|
||||
objects_using_material = []
|
||||
objects_using_material.extend(users.material_objects(material.name))
|
||||
objects_using_material.extend(users.material_geometry_nodes(material.name))
|
||||
|
||||
# Remove duplicates
|
||||
objects_using_material = list(set(objects_using_material))
|
||||
|
||||
# If material is only used by objects, and ALL those objects are unused, mark material as unused
|
||||
if objects_using_material:
|
||||
all_objects_unused = all(not users.object_all(obj_name) for obj_name in objects_using_material)
|
||||
if all_objects_unused:
|
||||
if not material.use_fake_user or config.include_fake_users:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@@ -88,6 +152,10 @@ def _has_any_unused_node_groups():
|
||||
for node_group in bpy.data.node_groups:
|
||||
if compat.is_library_or_override(node_group):
|
||||
continue
|
||||
# Skip compositor node trees (Blender 5.0+ creates one per file)
|
||||
# Import the helper function from unused module
|
||||
if unused._is_compositor_node_tree(node_group):
|
||||
continue
|
||||
if not users.node_group_all(node_group.name):
|
||||
if not node_group.use_fake_user or config.include_fake_users:
|
||||
return True
|
||||
@@ -124,13 +192,19 @@ def _has_any_unused_textures():
|
||||
|
||||
def _has_any_unused_worlds():
|
||||
"""Check if there are any unused worlds (short-circuits early)."""
|
||||
config.debug_print(f"[Atomic Debug] _has_any_unused_worlds: Starting, total worlds: {len(bpy.data.worlds)}")
|
||||
checked = 0
|
||||
for world in bpy.data.worlds:
|
||||
if compat.is_library_or_override(world):
|
||||
continue
|
||||
checked += 1
|
||||
config.debug_print(f"[Atomic Debug] _has_any_unused_worlds: Checking world '{world.name}' (users={world.users}, fake_user={world.use_fake_user}, include_fake_users={config.include_fake_users})")
|
||||
if world.users == 0 or (world.users == 1 and
|
||||
world.use_fake_user and
|
||||
config.include_fake_users):
|
||||
config.debug_print(f"[Atomic Debug] _has_any_unused_worlds: Found unused world '{world.name}', returning True")
|
||||
return True
|
||||
config.debug_print(f"[Atomic Debug] _has_any_unused_worlds: Checked {checked} worlds, none unused, returning False")
|
||||
return False
|
||||
|
||||
|
||||
@@ -156,6 +230,10 @@ def _has_any_unused_armatures():
|
||||
return False
|
||||
|
||||
|
||||
# Category order for progress tracking
|
||||
CATEGORIES = ['collections', 'images', 'lights', 'materials', 'node_groups',
|
||||
'objects', 'particles', 'textures', 'armatures', 'worlds']
|
||||
|
||||
def get_unused_for_smart_select():
|
||||
"""
|
||||
Get unused data for smart select operation (returns booleans).
|
||||
@@ -168,17 +246,29 @@ def get_unused_for_smart_select():
|
||||
"""
|
||||
# Use optimized short-circuit versions that stop as soon as
|
||||
# they find ONE unused item, rather than computing the full list
|
||||
return {
|
||||
'collections': _has_any_unused_collections(),
|
||||
'images': _has_any_unused_images(),
|
||||
'lights': _has_any_unused_lights(),
|
||||
'materials': _has_any_unused_materials(),
|
||||
'node_groups': _has_any_unused_node_groups(),
|
||||
'objects': _has_any_unused_objects(),
|
||||
'particles': _has_any_unused_particles(),
|
||||
'textures': _has_any_unused_textures(),
|
||||
'armatures': _has_any_unused_armatures(),
|
||||
'worlds': _has_any_unused_worlds(),
|
||||
}
|
||||
result = {}
|
||||
for category in CATEGORIES:
|
||||
if category == 'collections':
|
||||
result[category] = _has_any_unused_collections()
|
||||
elif category == 'images':
|
||||
result[category] = _has_any_unused_images()
|
||||
elif category == 'lights':
|
||||
result[category] = _has_any_unused_lights()
|
||||
elif category == 'materials':
|
||||
result[category] = _has_any_unused_materials()
|
||||
elif category == 'node_groups':
|
||||
result[category] = _has_any_unused_node_groups()
|
||||
elif category == 'objects':
|
||||
result[category] = _has_any_unused_objects()
|
||||
elif category == 'particles':
|
||||
result[category] = _has_any_unused_particles()
|
||||
elif category == 'textures':
|
||||
result[category] = _has_any_unused_textures()
|
||||
elif category == 'armatures':
|
||||
result[category] = _has_any_unused_armatures()
|
||||
elif category == 'worlds':
|
||||
result[category] = _has_any_unused_worlds()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ a material would be searching for the image_materials() function.
|
||||
"""
|
||||
|
||||
import bpy
|
||||
from ..utils import compat
|
||||
|
||||
|
||||
def collection_all(collection_key):
|
||||
@@ -42,7 +43,8 @@ def collection_all(collection_key):
|
||||
collection_meshes(collection_key) + \
|
||||
collection_others(collection_key) + \
|
||||
collection_rigidbody_world(collection_key) + \
|
||||
collection_scenes(collection_key)
|
||||
collection_scenes(collection_key) + \
|
||||
collection_instances(collection_key)
|
||||
|
||||
|
||||
def collection_cameras(collection_key):
|
||||
@@ -189,6 +191,32 @@ def collection_scenes(collection_key):
|
||||
return distinct(users)
|
||||
|
||||
|
||||
def collection_instances(collection_key):
|
||||
# returns a list of object keys that instance this collection
|
||||
# Collection instances are objects with instance_type='COLLECTION' and
|
||||
# instance_collection pointing to this collection
|
||||
# Only counts objects that are actually in scene collections
|
||||
|
||||
users = []
|
||||
collection = bpy.data.collections[collection_key]
|
||||
|
||||
for obj in bpy.data.objects:
|
||||
# Skip library-linked and override objects
|
||||
if compat.is_library_or_override(obj):
|
||||
continue
|
||||
|
||||
# Check if object is a collection instance
|
||||
if hasattr(obj, 'instance_type') and obj.instance_type == 'COLLECTION':
|
||||
if hasattr(obj, 'instance_collection') and obj.instance_collection:
|
||||
if obj.instance_collection.name == collection.name:
|
||||
# Only count if the instance object is in a scene
|
||||
# (otherwise the collection isn't really being used)
|
||||
if object_all(obj.name):
|
||||
users.append(obj.name)
|
||||
|
||||
return distinct(users)
|
||||
|
||||
|
||||
def _scene_collection_contains(parent_collection, target_collection):
|
||||
# helper that checks whether target_collection exists inside the
|
||||
# parent_collection hierarchy
|
||||
@@ -255,6 +283,9 @@ def image_compositors(image_key):
|
||||
|
||||
def image_materials(image_key):
|
||||
# returns a list of material keys that use the image
|
||||
# Only returns materials that are actually used (in scenes)
|
||||
# This ensures images are correctly detected as unused when their
|
||||
# materials are unused (fixes issue #5)
|
||||
|
||||
users = []
|
||||
image = bpy.data.images[image_key]
|
||||
@@ -263,6 +294,13 @@ def image_materials(image_key):
|
||||
node_group_users = image_node_groups(image_key)
|
||||
|
||||
for mat in bpy.data.materials:
|
||||
# Skip library-linked and override materials
|
||||
from ..utils import compat
|
||||
if compat.is_library_or_override(mat):
|
||||
continue
|
||||
|
||||
# Check if this material uses the image
|
||||
material_uses_image = False
|
||||
|
||||
# if material uses a valid node tree, check each node
|
||||
if mat.use_nodes and mat.node_tree:
|
||||
@@ -273,7 +311,7 @@ def image_materials(image_key):
|
||||
|
||||
# if the nodes image is our image
|
||||
if node.image.name == image.name:
|
||||
users.append(mat.name)
|
||||
material_uses_image = True
|
||||
|
||||
# if image in node in node group in node tree
|
||||
elif node.type == 'GROUP':
|
||||
@@ -282,23 +320,38 @@ def image_materials(image_key):
|
||||
# list of node groups that use this image
|
||||
if node.node_tree and \
|
||||
node.node_tree.name in node_group_users:
|
||||
users.append(mat.name)
|
||||
material_uses_image = True
|
||||
|
||||
# Only add material if it uses the image AND is actually used
|
||||
if material_uses_image:
|
||||
# Check if material is actually used (in scenes)
|
||||
if material_all(mat.name):
|
||||
users.append(mat.name)
|
||||
|
||||
return distinct(users)
|
||||
|
||||
|
||||
def image_node_groups(image_key):
|
||||
# returns a list of keys of node groups that use this image
|
||||
# Only returns node groups that are actually used (in scenes)
|
||||
# This ensures images are correctly detected as unused when their
|
||||
# node groups are unused (fixes issue #5)
|
||||
|
||||
users = []
|
||||
image = bpy.data.images[image_key]
|
||||
|
||||
# for each node group
|
||||
for node_group in bpy.data.node_groups:
|
||||
# Skip library-linked and override node groups
|
||||
from ..utils import compat
|
||||
if compat.is_library_or_override(node_group):
|
||||
continue
|
||||
|
||||
# if node group contains our image
|
||||
if node_group_has_image(node_group.name, image.name):
|
||||
users.append(node_group.name)
|
||||
# Only add node group if it is actually used (in scenes)
|
||||
if node_group_all(node_group.name):
|
||||
users.append(node_group.name)
|
||||
|
||||
return distinct(users)
|
||||
|
||||
@@ -349,6 +402,9 @@ def image_textures(image_key):
|
||||
|
||||
def image_geometry_nodes(image_key):
|
||||
# returns a list of object keys that use the image through Geometry Nodes
|
||||
# Only returns objects that are actually used (in scenes)
|
||||
# This ensures images are correctly detected as unused when their
|
||||
# objects are unused (fixes issue #5)
|
||||
|
||||
users = []
|
||||
image = bpy.data.images[image_key]
|
||||
@@ -360,6 +416,19 @@ def image_geometry_nodes(image_key):
|
||||
from ..utils import compat
|
||||
|
||||
for obj in bpy.data.objects:
|
||||
# Skip library-linked and override objects
|
||||
if compat.is_library_or_override(obj):
|
||||
continue
|
||||
|
||||
# Check if object is in any scene collection (reuse object_all logic)
|
||||
# This ensures recursive checking: if the object using the image isn't in a scene,
|
||||
# the image isn't considered used
|
||||
obj_scenes = object_all(obj.name)
|
||||
is_in_scene = bool(obj_scenes)
|
||||
|
||||
if not is_in_scene:
|
||||
continue # Skip objects not in scene collections
|
||||
|
||||
# check Geometry Nodes modifiers
|
||||
if hasattr(obj, 'modifiers'):
|
||||
for modifier in obj.modifiers:
|
||||
@@ -438,6 +507,65 @@ def material_all(material_key):
|
||||
# Check node group usage (materials in node groups used elsewhere)
|
||||
users.extend(material_node_groups(material_key))
|
||||
|
||||
# Check brush usage (materials used by brushes for stroke)
|
||||
users.extend(material_brushes(material_key))
|
||||
|
||||
return distinct(users)
|
||||
|
||||
|
||||
def material_brushes(material_key):
|
||||
# returns a list of brush keys that use this material
|
||||
# Brushes use materials for stroke rendering (Grease Pencil brushes)
|
||||
|
||||
users = []
|
||||
try:
|
||||
material = bpy.data.materials[material_key]
|
||||
except (KeyError, AttributeError):
|
||||
return []
|
||||
|
||||
if not hasattr(bpy.data, 'brushes'):
|
||||
return []
|
||||
|
||||
for brush in bpy.data.brushes:
|
||||
# Grease Pencil brushes use materials via gpencil_settings
|
||||
if hasattr(brush, 'gpencil_settings'):
|
||||
gp_settings = brush.gpencil_settings
|
||||
if gp_settings:
|
||||
# Check material property in gpencil_settings
|
||||
if hasattr(gp_settings, 'material'):
|
||||
gp_mat = gp_settings.material
|
||||
# Compare by datablock reference to avoid matching linked materials with same name
|
||||
if gp_mat and gp_mat == material:
|
||||
users.append(brush.name)
|
||||
|
||||
# Check material_index - need to get material from Grease Pencil object
|
||||
if hasattr(gp_settings, 'material_index'):
|
||||
mat_idx = gp_settings.material_index
|
||||
# Check all Grease Pencil objects for this material
|
||||
for gp_obj in bpy.data.objects:
|
||||
if gp_obj.type == 'GPENCIL' and gp_obj.data:
|
||||
gp_data = gp_obj.data
|
||||
if hasattr(gp_data, 'materials') and gp_data.materials:
|
||||
if 0 <= mat_idx < len(gp_data.materials):
|
||||
gp_mat = gp_data.materials[mat_idx]
|
||||
# Compare by datablock reference to avoid matching linked materials with same name
|
||||
if gp_mat and gp_mat == material:
|
||||
users.append(brush.name)
|
||||
|
||||
# Also check for stroke_material (some brush types)
|
||||
if hasattr(brush, 'stroke_material'):
|
||||
stroke_mat = brush.stroke_material
|
||||
# Compare by datablock reference to avoid matching linked materials with same name
|
||||
if stroke_mat and stroke_mat == material:
|
||||
users.append(brush.name)
|
||||
|
||||
# Check for material property (some brush types)
|
||||
if hasattr(brush, 'material'):
|
||||
mat = brush.material
|
||||
# Compare by datablock reference to avoid matching linked materials with same name
|
||||
if mat and mat == material:
|
||||
users.append(brush.name)
|
||||
|
||||
return distinct(users)
|
||||
|
||||
|
||||
@@ -446,7 +574,10 @@ def material_geometry_nodes(material_key):
|
||||
# Only counts objects that are in scene collections (recursive check)
|
||||
|
||||
users = []
|
||||
material = bpy.data.materials[material_key]
|
||||
try:
|
||||
material = bpy.data.materials[material_key]
|
||||
except (KeyError, AttributeError):
|
||||
return []
|
||||
|
||||
# Import compat module for version-safe geometry nodes access
|
||||
from ..utils import compat
|
||||
@@ -471,7 +602,8 @@ def material_geometry_nodes(material_key):
|
||||
ng = compat.get_geometry_nodes_modifier_node_group(modifier)
|
||||
if ng:
|
||||
# Check if this node group or any nested node groups contain the material
|
||||
if node_group_has_material(ng.name, material.name):
|
||||
# Pass material datablock reference to ensure we match the correct material
|
||||
if node_group_has_material_by_ref(ng.name, material):
|
||||
users.append(obj.name)
|
||||
|
||||
return distinct(users)
|
||||
@@ -486,14 +618,18 @@ def material_node_groups(material_key):
|
||||
# Optimized to return early when usage is found
|
||||
|
||||
from ..utils import compat
|
||||
material = bpy.data.materials[material_key]
|
||||
try:
|
||||
material = bpy.data.materials[material_key]
|
||||
except (KeyError, AttributeError):
|
||||
return []
|
||||
|
||||
# Check all node groups to see if they contain this material
|
||||
for node_group in bpy.data.node_groups:
|
||||
# Skip library-linked and override node groups
|
||||
if compat.is_library_or_override(node_group):
|
||||
continue
|
||||
if node_group_has_material(node_group.name, material.name):
|
||||
# Use the by_ref version to avoid name collision issues with linked materials
|
||||
if node_group_has_material_by_ref(node_group.name, material):
|
||||
# This node group contains the material, check if the node group is used
|
||||
# Check usage contexts in order of likelihood, return early when found
|
||||
|
||||
@@ -550,11 +686,40 @@ def material_node_groups(material_key):
|
||||
return [] # Material not used in any node groups
|
||||
|
||||
|
||||
def material_node_groups_list(material_key):
|
||||
# returns a list of node group names that contain this material
|
||||
# This is used for inspection UI to show which node groups use the material
|
||||
# Unlike material_node_groups(), this returns all node groups that contain
|
||||
# the material, regardless of whether they're used
|
||||
|
||||
from ..utils import compat
|
||||
users = []
|
||||
|
||||
try:
|
||||
material = bpy.data.materials[material_key]
|
||||
except (KeyError, AttributeError):
|
||||
return []
|
||||
|
||||
# Check all node groups to see if they contain this material
|
||||
for node_group in bpy.data.node_groups:
|
||||
# Skip library-linked and override node groups
|
||||
if compat.is_library_or_override(node_group):
|
||||
continue
|
||||
# Use the by_ref version to avoid name collision issues with linked materials
|
||||
if node_group_has_material_by_ref(node_group.name, material):
|
||||
users.append(node_group.name)
|
||||
|
||||
return distinct(users)
|
||||
|
||||
|
||||
def material_objects(material_key):
|
||||
# returns a list of object keys that use this material
|
||||
|
||||
users = []
|
||||
material = bpy.data.materials[material_key]
|
||||
try:
|
||||
material = bpy.data.materials[material_key]
|
||||
except (KeyError, AttributeError):
|
||||
return []
|
||||
|
||||
for obj in bpy.data.objects:
|
||||
|
||||
@@ -564,10 +729,12 @@ def material_objects(material_key):
|
||||
# for each material slot
|
||||
for slot in obj.material_slots:
|
||||
|
||||
# if material slot has a valid material and it is our
|
||||
# material
|
||||
if slot.material and slot.material.name == material.name:
|
||||
users.append(obj.name)
|
||||
# if material slot has a valid material, check if it's our material
|
||||
# Compare by datablock reference first to avoid matching linked materials with same name
|
||||
if slot.material:
|
||||
# Compare by reference (handles name collisions between local and linked materials)
|
||||
if slot.material == material:
|
||||
users.append(obj.name)
|
||||
|
||||
return distinct(users)
|
||||
|
||||
@@ -585,7 +752,7 @@ def node_group_all(node_group_key):
|
||||
|
||||
def node_group_compositors(node_group_key):
|
||||
# returns a list containing "Compositor" if the node group is used in
|
||||
# the scene's compositor
|
||||
# any scene's compositor (either as the compositor's node tree itself, or as a node within it)
|
||||
|
||||
users = []
|
||||
node_group = bpy.data.node_groups[node_group_key]
|
||||
@@ -593,27 +760,40 @@ def node_group_compositors(node_group_key):
|
||||
# a list of node groups that use our node group
|
||||
node_group_users = node_group_node_groups(node_group_key)
|
||||
|
||||
# Import compat module for version-safe compositor access
|
||||
# Import compat and version modules for version-safe compositor access
|
||||
from ..utils import compat
|
||||
from ..utils import version
|
||||
|
||||
# if our compositor uses nodes and has a valid node tree
|
||||
scene = bpy.context.scene
|
||||
if scene.use_nodes:
|
||||
node_tree = compat.get_scene_compositor_node_tree(scene)
|
||||
if node_tree:
|
||||
# check each node in the compositor
|
||||
for node in node_tree.nodes:
|
||||
|
||||
# if the node is a group and has a valid node tree
|
||||
if hasattr(node, 'node_tree') and node.node_tree:
|
||||
|
||||
# if the node group is our node group
|
||||
if node.node_tree.name == node_group.name:
|
||||
users.append("Compositor")
|
||||
|
||||
# if the node group is in our list of node group users
|
||||
if node.node_tree.name in node_group_users:
|
||||
users.append("Compositor")
|
||||
# Check ALL scenes, not just the current one
|
||||
for scene in bpy.data.scenes:
|
||||
# First check: is this node group the compositor's node tree itself?
|
||||
if version.is_version_at_least(5, 0, 0):
|
||||
if hasattr(scene, 'compositing_node_tree') and scene.compositing_node_tree:
|
||||
# Check by reference, not just name (in case user renamed it)
|
||||
if scene.compositing_node_tree == node_group:
|
||||
users.append("Compositor")
|
||||
continue # Already found, skip checking nodes within it
|
||||
else:
|
||||
if hasattr(scene, 'node_tree') and scene.node_tree:
|
||||
# Check by reference, not just name (in case user renamed it)
|
||||
if scene.node_tree == node_group:
|
||||
users.append("Compositor")
|
||||
continue # Already found, skip checking nodes within it
|
||||
|
||||
# Second check: is this node group used as a node within the compositor?
|
||||
if scene.use_nodes:
|
||||
node_tree = compat.get_scene_compositor_node_tree(scene)
|
||||
if node_tree:
|
||||
# check each node in the compositor
|
||||
for node in node_tree.nodes:
|
||||
# if the node is a group and has a valid node tree
|
||||
if hasattr(node, 'node_tree') and node.node_tree:
|
||||
# if the node group is our node group (check by reference)
|
||||
if node.node_tree == node_group:
|
||||
users.append("Compositor")
|
||||
# if the node group is in our list of node group users
|
||||
elif node.node_tree.name in node_group_users:
|
||||
users.append("Compositor")
|
||||
|
||||
return distinct(users)
|
||||
|
||||
@@ -621,6 +801,8 @@ def node_group_compositors(node_group_key):
|
||||
def node_group_materials(node_group_key):
|
||||
# returns a list of material keys that use the node group in their
|
||||
# node trees
|
||||
# Note: Unlike image_materials(), this returns ALL materials using the node group,
|
||||
# not just used ones. This allows node_groups_deep() to check if materials are unused.
|
||||
|
||||
users = []
|
||||
node_group = bpy.data.node_groups[node_group_key]
|
||||
@@ -629,6 +811,10 @@ def node_group_materials(node_group_key):
|
||||
node_group_users = node_group_node_groups(node_group_key)
|
||||
|
||||
for material in bpy.data.materials:
|
||||
# Skip library-linked and override materials
|
||||
from ..utils import compat
|
||||
if compat.is_library_or_override(material):
|
||||
continue
|
||||
|
||||
# if material uses nodes and has a valid node tree, check each node
|
||||
if material.use_nodes and material.node_tree:
|
||||
@@ -754,6 +940,31 @@ def node_group_objects(node_group_key):
|
||||
return distinct(users)
|
||||
|
||||
|
||||
def _check_node_input_sockets_for_image(node, image_key):
|
||||
"""Helper function to check if any input socket of a node contains the image.
|
||||
This handles nodes like Menu Switch that have materials/images in input sockets."""
|
||||
try:
|
||||
image = bpy.data.images[image_key]
|
||||
if not hasattr(node, 'inputs'):
|
||||
return False
|
||||
|
||||
for input_socket in node.inputs:
|
||||
try:
|
||||
# Check if socket has a default_value that is an image
|
||||
if hasattr(input_socket, 'default_value') and input_socket.default_value:
|
||||
socket_value = input_socket.default_value
|
||||
# Check if it's an image datablock
|
||||
if hasattr(socket_value, 'name') and hasattr(socket_value, 'filepath'):
|
||||
if socket_value.name == image.name or socket_value == image:
|
||||
return True
|
||||
except (AttributeError, ReferenceError, RuntimeError, TypeError, KeyError):
|
||||
continue # Skip this socket if we can't access it
|
||||
except (KeyError, AttributeError):
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def node_group_has_image(node_group_key, image_key):
|
||||
# recursively returns true if the node group contains this image
|
||||
# directly or if it contains a node group a node group that contains
|
||||
@@ -774,9 +985,14 @@ def node_group_has_image(node_group_key, image_key):
|
||||
if node.image.name == image.name:
|
||||
has_image = True
|
||||
|
||||
# Check input sockets for images (e.g., Menu Switch nodes)
|
||||
# This handles nodes that have images connected via input sockets
|
||||
if not has_image:
|
||||
has_image = _check_node_input_sockets_for_image(node, image_key)
|
||||
|
||||
# recurse case
|
||||
# if node is a node group and has a valid node tree
|
||||
elif hasattr(node, 'node_tree') and node.node_tree:
|
||||
if not has_image and hasattr(node, 'node_tree') and node.node_tree:
|
||||
has_image = node_group_has_image(
|
||||
node.node_tree.name, image.name)
|
||||
|
||||
@@ -855,15 +1071,51 @@ def node_group_has_texture(node_group_key, texture_key):
|
||||
return has_texture
|
||||
|
||||
|
||||
def node_group_has_material(node_group_key, material_key):
|
||||
def _check_node_input_sockets_for_material(node, material_key):
|
||||
"""Helper function to check if any input socket of a node contains the material.
|
||||
This handles nodes like Menu Switch that have materials in input sockets."""
|
||||
try:
|
||||
material = bpy.data.materials[material_key]
|
||||
return _check_node_input_sockets_for_material_by_ref(node, material)
|
||||
except (KeyError, AttributeError):
|
||||
return False
|
||||
|
||||
|
||||
def _check_node_input_sockets_for_material_by_ref(node, material):
|
||||
"""Helper function to check if any input socket of a node contains the material.
|
||||
Takes material datablock directly to avoid name collision issues with linked materials."""
|
||||
if not material or not hasattr(node, 'inputs'):
|
||||
return False
|
||||
|
||||
for input_socket in node.inputs:
|
||||
try:
|
||||
# Check socket type - material sockets are typically 'MATERIAL' type
|
||||
socket_type = getattr(input_socket, 'type', '')
|
||||
if socket_type == 'MATERIAL' or 'material' in str(socket_type).lower():
|
||||
# Check if this socket has a default_value that is a material
|
||||
if hasattr(input_socket, 'default_value') and input_socket.default_value:
|
||||
socket_material = input_socket.default_value
|
||||
# Compare by datablock reference to avoid matching linked materials with same name
|
||||
if socket_material and socket_material == material:
|
||||
return True
|
||||
except (AttributeError, ReferenceError, RuntimeError, TypeError, KeyError):
|
||||
continue # Skip this socket if we can't access it
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def node_group_has_material_by_ref(node_group_key, material):
|
||||
# returns true if a node group contains this material (directly or nested)
|
||||
# Takes material datablock directly to avoid name collision issues with linked materials
|
||||
|
||||
has_material = False
|
||||
try:
|
||||
node_group = bpy.data.node_groups[node_group_key]
|
||||
material = bpy.data.materials[material_key]
|
||||
except (KeyError, AttributeError):
|
||||
return False
|
||||
|
||||
if not material:
|
||||
return False
|
||||
|
||||
try:
|
||||
for node in node_group.nodes:
|
||||
@@ -883,10 +1135,9 @@ def node_group_has_material(node_group_key, material_key):
|
||||
# Check the default_value (for unlinked materials)
|
||||
if hasattr(material_socket, 'default_value'):
|
||||
socket_material = material_socket.default_value
|
||||
if socket_material and hasattr(socket_material, 'name'):
|
||||
if (socket_material.name == material.name or
|
||||
socket_material == material):
|
||||
has_material = True
|
||||
# Compare by datablock reference to avoid matching linked materials with same name
|
||||
if socket_material and socket_material == material:
|
||||
has_material = True
|
||||
except (KeyError, AttributeError, ReferenceError, RuntimeError, TypeError):
|
||||
pass
|
||||
|
||||
@@ -900,11 +1151,10 @@ def node_group_has_material(node_group_key, material_key):
|
||||
# Check if this socket has a default_value that is a material
|
||||
if hasattr(input_socket, 'default_value') and input_socket.default_value:
|
||||
socket_material = input_socket.default_value
|
||||
if socket_material and hasattr(socket_material, 'name'):
|
||||
if (socket_material.name == material.name or
|
||||
socket_material == material):
|
||||
has_material = True
|
||||
break # Found it, no need to continue
|
||||
# Compare by datablock reference to avoid matching linked materials with same name
|
||||
if socket_material and socket_material == material:
|
||||
has_material = True
|
||||
break # Found it, no need to continue
|
||||
except (AttributeError, ReferenceError, RuntimeError, TypeError):
|
||||
continue # Skip this socket if we can't access it
|
||||
|
||||
@@ -912,8 +1162,8 @@ def node_group_has_material(node_group_key, material_key):
|
||||
if not has_material and hasattr(node, 'material'):
|
||||
try:
|
||||
if node.material:
|
||||
if (node.material.name == material.name or
|
||||
node.material == material):
|
||||
# Compare by datablock reference to avoid matching linked materials with same name
|
||||
if node.material == material:
|
||||
has_material = True
|
||||
break # Found it, no need to continue
|
||||
except (AttributeError, ReferenceError, RuntimeError):
|
||||
@@ -926,14 +1176,33 @@ def node_group_has_material(node_group_key, material_key):
|
||||
except (AttributeError, ReferenceError, RuntimeError):
|
||||
pass # Skip if bl_idname access fails
|
||||
|
||||
# Check for Menu Switch nodes and other nodes with material inputs
|
||||
# Menu Switch nodes can have materials in any of their input sockets
|
||||
if not has_material and hasattr(node, 'bl_idname'):
|
||||
try:
|
||||
node_type = node.bl_idname
|
||||
# Check for Menu Switch node (GeometryNodeMenuSwitch)
|
||||
if node_type == 'GeometryNodeMenuSwitch' or 'MenuSwitch' in node_type:
|
||||
has_material = _check_node_input_sockets_for_material_by_ref(node, material)
|
||||
if has_material:
|
||||
break
|
||||
except (AttributeError, ReferenceError, RuntimeError):
|
||||
pass # Skip if bl_idname access fails
|
||||
|
||||
# General check: Check all input sockets for materials (catches other node types)
|
||||
# This is a fallback for any node that might have material inputs
|
||||
if not has_material:
|
||||
has_material = _check_node_input_sockets_for_material_by_ref(node, material)
|
||||
if has_material:
|
||||
break
|
||||
|
||||
# Fallback: Check for any node with a material property (e.g., Set Material)
|
||||
# This catches other node types that might have materials
|
||||
if not has_material and hasattr(node, 'material'):
|
||||
try:
|
||||
if node.material:
|
||||
# Check both by name and by direct reference for robustness
|
||||
if (node.material.name == material.name or
|
||||
node.material == material):
|
||||
# Compare by datablock reference to avoid matching linked materials with same name
|
||||
if node.material == material:
|
||||
has_material = True
|
||||
break # Found it, no need to continue
|
||||
except (AttributeError, ReferenceError, RuntimeError):
|
||||
@@ -948,8 +1217,8 @@ def node_group_has_material(node_group_key, material_key):
|
||||
if hasattr(node, 'material'):
|
||||
try:
|
||||
if node.material:
|
||||
if (node.material.name == material.name or
|
||||
node.material == material):
|
||||
# Compare by datablock reference to avoid matching linked materials with same name
|
||||
if node.material == material:
|
||||
has_material = True
|
||||
break
|
||||
except (AttributeError, ReferenceError, RuntimeError):
|
||||
@@ -962,8 +1231,8 @@ def node_group_has_material(node_group_key, material_key):
|
||||
if not has_material and hasattr(node, 'node_tree'):
|
||||
try:
|
||||
if node.node_tree:
|
||||
has_material = node_group_has_material(
|
||||
node.node_tree.name, material.name)
|
||||
has_material = node_group_has_material_by_ref(
|
||||
node.node_tree.name, material)
|
||||
except (KeyError, AttributeError, ReferenceError, RuntimeError):
|
||||
continue # Skip invalid node groups
|
||||
|
||||
@@ -979,6 +1248,18 @@ def node_group_has_material(node_group_key, material_key):
|
||||
return has_material
|
||||
|
||||
|
||||
def node_group_has_material(node_group_key, material_key):
|
||||
# returns true if a node group contains this material (directly or nested)
|
||||
# Wrapper that converts material_key to material datablock for reference-based comparison
|
||||
|
||||
try:
|
||||
material = bpy.data.materials[material_key]
|
||||
except (KeyError, AttributeError):
|
||||
return False
|
||||
|
||||
return node_group_has_material_by_ref(node_group_key, material)
|
||||
|
||||
|
||||
def particle_all(particle_key):
|
||||
# returns a list of keys of every data-block that uses this particle
|
||||
# system
|
||||
@@ -1168,22 +1449,68 @@ def texture_particles(texture_key):
|
||||
return distinct(users)
|
||||
|
||||
|
||||
def object_all(object_key):
|
||||
def object_all(object_key, _visited_objects=None):
|
||||
# returns a list of scene names where the object is used
|
||||
# An object is "used" if it's in any collection that's part of any scene's collection hierarchy
|
||||
# OR if it's in a collection that is instanced (and that instance is in a scene)
|
||||
# _visited_objects is used to prevent infinite recursion when checking instanced collections
|
||||
|
||||
users = []
|
||||
obj = bpy.data.objects[object_key]
|
||||
if _visited_objects is None:
|
||||
_visited_objects = set()
|
||||
|
||||
# Prevent infinite recursion
|
||||
if object_key in _visited_objects:
|
||||
return []
|
||||
_visited_objects.add(object_key)
|
||||
|
||||
try:
|
||||
users = []
|
||||
obj = bpy.data.objects[object_key]
|
||||
|
||||
# Get all collections that contain this object
|
||||
for collection in obj.users_collection:
|
||||
# Check if this collection is in any scene's hierarchy
|
||||
for scene in bpy.data.scenes:
|
||||
if _scene_collection_contains(scene.collection, collection):
|
||||
if scene.name not in users:
|
||||
users.append(scene.name)
|
||||
|
||||
return distinct(users)
|
||||
# Get all collections that contain this object
|
||||
for collection in obj.users_collection:
|
||||
# Check if this collection is in any scene's hierarchy
|
||||
for scene in bpy.data.scenes:
|
||||
if _scene_collection_contains(scene.collection, collection):
|
||||
if scene.name not in users:
|
||||
users.append(scene.name)
|
||||
|
||||
# Also check if this collection is instanced (and the instance is in a scene)
|
||||
# Get all objects that instance this collection
|
||||
for instance_obj in bpy.data.objects:
|
||||
# Skip library-linked and override objects
|
||||
if compat.is_library_or_override(instance_obj):
|
||||
continue
|
||||
|
||||
# Check if object is a collection instance
|
||||
if hasattr(instance_obj, 'instance_type') and instance_obj.instance_type == 'COLLECTION':
|
||||
if hasattr(instance_obj, 'instance_collection') and instance_obj.instance_collection:
|
||||
if instance_obj.instance_collection.name == collection.name:
|
||||
# Check if the instance object is in a scene (using visited set to prevent recursion)
|
||||
# First check if instance object is directly in a scene collection
|
||||
instance_direct_scenes = []
|
||||
for instance_collection in instance_obj.users_collection:
|
||||
for scene in bpy.data.scenes:
|
||||
if _scene_collection_contains(scene.collection, instance_collection):
|
||||
if scene.name not in instance_direct_scenes:
|
||||
instance_direct_scenes.append(scene.name)
|
||||
|
||||
# If instance object is directly in a scene, the instanced collection's objects are used
|
||||
if instance_direct_scenes:
|
||||
for scene_name in instance_direct_scenes:
|
||||
if scene_name not in users:
|
||||
users.append(scene_name)
|
||||
else:
|
||||
# Instance object is not directly in a scene, but might be in an instanced collection
|
||||
# Recursively check (with visited set to prevent infinite loops)
|
||||
instance_scenes = object_all(instance_obj.name, _visited_objects)
|
||||
for scene_name in instance_scenes:
|
||||
if scene_name not in users:
|
||||
users.append(scene_name)
|
||||
|
||||
return distinct(users)
|
||||
finally:
|
||||
_visited_objects.remove(object_key)
|
||||
|
||||
|
||||
def armature_all(armature_key):
|
||||
|
||||
@@ -328,6 +328,8 @@ class ATOMIC_OT_inspect_materials(bpy.types.Operator):
|
||||
|
||||
# user lists
|
||||
users_objects = []
|
||||
users_brushes = []
|
||||
users_node_groups = []
|
||||
|
||||
def draw(self, context):
|
||||
global inspection_update_trigger
|
||||
@@ -349,13 +351,35 @@ class ATOMIC_OT_inspect_materials(bpy.types.Operator):
|
||||
if atom.materials_field in bpy.data.materials.keys():
|
||||
self.users_objects = \
|
||||
users.material_objects(atom.materials_field)
|
||||
self.users_brushes = \
|
||||
users.material_brushes(atom.materials_field)
|
||||
self.users_node_groups = \
|
||||
users.material_node_groups_list(atom.materials_field)
|
||||
|
||||
# if key is invalid, empty the user lists
|
||||
else:
|
||||
self.users_objects = []
|
||||
self.users_brushes = []
|
||||
self.users_node_groups = []
|
||||
|
||||
inspection_update_trigger = False
|
||||
|
||||
# brushes box list
|
||||
ui_layouts.box_list(
|
||||
layout=layout,
|
||||
title="Brushes",
|
||||
items=self.users_brushes,
|
||||
icon="BRUSH_DATA"
|
||||
)
|
||||
|
||||
# node groups box list
|
||||
ui_layouts.box_list(
|
||||
layout=layout,
|
||||
title="Node Groups",
|
||||
items=self.users_node_groups,
|
||||
icon="NODETREE"
|
||||
)
|
||||
|
||||
# objects box list
|
||||
ui_layouts.box_list_diverse(
|
||||
layout=layout,
|
||||
|
||||
@@ -57,6 +57,27 @@ class ATOMIC_PT_main_panel(bpy.types.Panel):
|
||||
atom.worlds
|
||||
]
|
||||
|
||||
# Progress display section (only visible when operation is running)
|
||||
if atom.is_operation_running:
|
||||
box = layout.box()
|
||||
col = box.column(align=True)
|
||||
|
||||
# Progress bar with percentage (Blender shows percentage in the bar with PERCENTAGE subtype)
|
||||
progress_row = col.row(align=True)
|
||||
progress_row.scale_y = 1.5
|
||||
progress_row.prop(atom, "operation_progress", text="", slider=True)
|
||||
|
||||
# Status text
|
||||
if atom.operation_status:
|
||||
col.label(text=atom.operation_status, icon='TIME')
|
||||
|
||||
# Cancel button
|
||||
row = col.row()
|
||||
row.scale_y = 1.5
|
||||
row.operator("atomic.cancel_operation", text="Cancel", icon='X')
|
||||
|
||||
layout.separator()
|
||||
|
||||
# nuke and clean buttons
|
||||
row = layout.row(align=True)
|
||||
row.scale_y = 2.0
|
||||
@@ -160,10 +181,12 @@ class ATOMIC_PT_main_panel(bpy.types.Panel):
|
||||
# right column
|
||||
col = split.column(align=True)
|
||||
|
||||
# images buttons
|
||||
splitcol = col.split(factor=0.8, align=True)
|
||||
|
||||
splitcol.prop(
|
||||
# images buttons (deep scan checkbox, images checkbox, inspect button)
|
||||
# Standard split layout for images (matches other categories)
|
||||
images_split = col.split(factor=0.8, align=True)
|
||||
|
||||
# Images checkbox (will be slightly offset due to deep scan, but inspect aligns)
|
||||
images_split.prop(
|
||||
atom,
|
||||
"images",
|
||||
text="Images",
|
||||
@@ -171,7 +194,8 @@ class ATOMIC_PT_main_panel(bpy.types.Panel):
|
||||
icon='IMAGE_DATA'
|
||||
)
|
||||
|
||||
splitcol.operator(
|
||||
# Inspect button (right, aligns with other inspect buttons)
|
||||
images_split.operator(
|
||||
"atomic.inspect_images",
|
||||
icon='VIEWZOOM',
|
||||
text=""
|
||||
@@ -267,7 +291,11 @@ class ATOMIC_PT_main_panel(bpy.types.Panel):
|
||||
icon='RESTRICT_SELECT_OFF'
|
||||
)
|
||||
|
||||
|
||||
# Cache and missing file management
|
||||
row = layout.row(align=True)
|
||||
row.operator("atomic.clear_cache", text="Clear Cache", icon="FILE_REFRESH")
|
||||
row.operator("atomic.detect_missing", text="Detect Missing", icon="LIBRARY_DATA_DIRECT")
|
||||
|
||||
reg_list = [ATOMIC_PT_main_panel]
|
||||
|
||||
|
||||
|
||||
@@ -31,6 +31,41 @@ from .. import config
|
||||
from ..stats import missing
|
||||
from .utils import ui_layouts
|
||||
|
||||
# Module-level state for detect missing operator instance
|
||||
_detect_missing_operator_instance = None
|
||||
|
||||
|
||||
def _warp_cursor_to_area_center(context, prefer_area_type="VIEW_3D") -> None:
|
||||
"""Best-effort: move cursor to area center so popups appear centered.
|
||||
|
||||
Blender's popup placement is often tied to the last event mouse position.
|
||||
Warping is a hack, but it's the only reliable way to 'center' popups in some contexts.
|
||||
"""
|
||||
win = getattr(context, "window", None)
|
||||
screen = getattr(context, "screen", None)
|
||||
if win is None or screen is None:
|
||||
return
|
||||
|
||||
area = None
|
||||
for a in screen.areas:
|
||||
if a.type == prefer_area_type:
|
||||
area = a
|
||||
break
|
||||
if area is None and screen.areas:
|
||||
area = screen.areas[0]
|
||||
if area is None:
|
||||
return
|
||||
|
||||
try:
|
||||
# cursor_warp expects WINDOW-relative coordinates (0,0 at window bottom-left),
|
||||
# not OS desktop coordinates. Warping to the window center is the most
|
||||
# reliable option across layouts.
|
||||
x = int(win.width / 2)
|
||||
y = int(win.height / 2)
|
||||
win.cursor_warp(x, y)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# Atomic Data Manager Detect Missing Files Popup
|
||||
class ATOMIC_OT_detect_missing(bpy.types.Operator):
|
||||
@@ -42,38 +77,6 @@ class ATOMIC_OT_detect_missing(bpy.types.Operator):
|
||||
missing_images = []
|
||||
missing_libraries = []
|
||||
|
||||
# missing file recovery option enum property
|
||||
recovery_option: bpy.props.EnumProperty(
|
||||
items=[
|
||||
(
|
||||
'IGNORE',
|
||||
'Ignore Missing Files',
|
||||
'Ignore the missing files and leave them offline'
|
||||
),
|
||||
(
|
||||
'RELOAD',
|
||||
'Reload Missing Files',
|
||||
'Reload the missing files from their existing file paths'
|
||||
),
|
||||
(
|
||||
'REMOVE',
|
||||
'Remove Missing Files',
|
||||
'Remove the missing files from the project'
|
||||
),
|
||||
(
|
||||
'SEARCH',
|
||||
'Search for Missing Files (under development)',
|
||||
'Search for the missing files in a directory'
|
||||
),
|
||||
(
|
||||
'REPLACE',
|
||||
'Specify Replacement Files (under development)',
|
||||
'Replace missing files with new files'
|
||||
),
|
||||
],
|
||||
default='IGNORE'
|
||||
)
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
|
||||
@@ -109,12 +112,20 @@ class ATOMIC_OT_detect_missing(bpy.types.Operator):
|
||||
|
||||
row = layout.separator() # extra space
|
||||
|
||||
# recovery option selection
|
||||
# recovery option buttons
|
||||
row = layout.row()
|
||||
row.label(text="What would you like to do?")
|
||||
|
||||
row = layout.row()
|
||||
row.prop(self, 'recovery_option', text="")
|
||||
row.scale_y = 1.5
|
||||
op_reload = row.operator("atomic.reload_missing", text="Reload", icon="FILE_REFRESH")
|
||||
op_remove = row.operator("atomic.remove_missing", text="Remove", icon="TRASH")
|
||||
op_search = row.operator("atomic.search_missing", text="Search", icon="VIEWZOOM")
|
||||
op_replace = row.operator("atomic.replace_missing", text="Replace", icon="FILEBROWSER")
|
||||
|
||||
# Refresh button
|
||||
row = layout.row()
|
||||
refresh_op = row.operator("atomic.detect_missing_refresh", text="Refresh", icon="FILE_REFRESH")
|
||||
|
||||
# missing files interface if no missing files are found
|
||||
else:
|
||||
@@ -129,54 +140,89 @@ class ATOMIC_OT_detect_missing(bpy.types.Operator):
|
||||
row = layout.separator() # extra space
|
||||
|
||||
def execute(self, context):
|
||||
|
||||
# ignore missing files will take no action
|
||||
|
||||
# reload missing files
|
||||
if self.recovery_option == 'RELOAD':
|
||||
bpy.ops.atomic.reload_missing('INVOKE_DEFAULT')
|
||||
|
||||
# remove missing files
|
||||
elif self.recovery_option == 'REMOVE':
|
||||
bpy.ops.atomic.remove_missing('INVOKE_DEFAULT')
|
||||
|
||||
# search for missing files
|
||||
elif self.recovery_option == 'SEARCH':
|
||||
bpy.ops.atomic.search_missing('INVOKE_DEFAULT')
|
||||
|
||||
# replace missing files
|
||||
elif self.recovery_option == 'REPLACE':
|
||||
bpy.ops.atomic.replace_missing('INVOKE_DEFAULT')
|
||||
|
||||
# Buttons now directly invoke operators, so execute just closes the dialog
|
||||
# IGNORE is the default behavior (no action taken)
|
||||
return {'FINISHED'}
|
||||
|
||||
def invoke(self, context, event):
|
||||
|
||||
# update missing file lists
|
||||
global _detect_missing_operator_instance
|
||||
|
||||
# Store operator instance for refresh functionality
|
||||
_detect_missing_operator_instance = self
|
||||
|
||||
# Always refresh missing file lists when invoked
|
||||
self.missing_images = missing.images()
|
||||
self.missing_libraries = missing.libraries()
|
||||
|
||||
wm = context.window_manager
|
||||
|
||||
# Force popup placement to screen center (see BlenderArtists reference).
|
||||
_warp_cursor_to_area_center(context)
|
||||
|
||||
# invoke large dialog if there are missing files
|
||||
if self.missing_images or self.missing_libraries:
|
||||
return wm.invoke_props_dialog(self, width=500)
|
||||
|
||||
# invoke small dialog if there are no missing files
|
||||
else:
|
||||
return wm.invoke_popup(self, width=300)
|
||||
return wm.invoke_props_dialog(self, width=300)
|
||||
|
||||
|
||||
@persistent
|
||||
def autodetect_missing_files(dummy=None):
|
||||
# invokes the detect missing popup when missing files are detected upon
|
||||
# loading a new Blender project
|
||||
# Use a timer to defer the operator call since load_post handlers
|
||||
# cannot directly invoke operators that modify data
|
||||
if config.enable_missing_file_warning and \
|
||||
(missing.images() or missing.libraries()):
|
||||
def invoke_detect_missing():
|
||||
try:
|
||||
bpy.ops.atomic.detect_missing('INVOKE_DEFAULT')
|
||||
except RuntimeError:
|
||||
# If still in invalid context, ignore (will be handled on next user action)
|
||||
pass
|
||||
return None # Run once
|
||||
|
||||
bpy.app.timers.register(invoke_detect_missing, first_interval=0.1)
|
||||
|
||||
|
||||
# Refresh operator for missing file detection
|
||||
class ATOMIC_OT_detect_missing_refresh(bpy.types.Operator):
|
||||
"""Refresh missing file detection"""
|
||||
bl_idname = "atomic.detect_missing_refresh"
|
||||
bl_label = "Refresh Missing Files"
|
||||
bl_options = {'INTERNAL'}
|
||||
|
||||
def execute(self, context):
|
||||
global _detect_missing_operator_instance
|
||||
|
||||
# Update the stored operator instance if it exists and is valid
|
||||
if _detect_missing_operator_instance is not None:
|
||||
try:
|
||||
# Check if operator instance is still valid
|
||||
_ = _detect_missing_operator_instance.bl_idname
|
||||
|
||||
# Update the missing file lists
|
||||
_detect_missing_operator_instance.missing_images = missing.images()
|
||||
_detect_missing_operator_instance.missing_libraries = missing.libraries()
|
||||
|
||||
# Redraw all areas to refresh the dialog
|
||||
for area in context.screen.areas:
|
||||
area.tag_redraw()
|
||||
|
||||
self.report({'INFO'}, "Missing files list refreshed")
|
||||
return {'FINISHED'}
|
||||
except (ReferenceError, AttributeError, TypeError):
|
||||
# Operator instance invalidated, clear it
|
||||
_detect_missing_operator_instance = None
|
||||
|
||||
# If no valid instance, invoke a new dialog
|
||||
bpy.ops.atomic.detect_missing('INVOKE_DEFAULT')
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
reg_list = [ATOMIC_OT_detect_missing]
|
||||
reg_list = [ATOMIC_OT_detect_missing, ATOMIC_OT_detect_missing_refresh]
|
||||
|
||||
|
||||
def register():
|
||||
|
||||
@@ -24,11 +24,41 @@ some functions for syncing the preference properties with external factors.
|
||||
"""
|
||||
|
||||
import bpy
|
||||
import os
|
||||
from bpy.utils import register_class
|
||||
from ..utils import compat
|
||||
from .. import config
|
||||
import sys
|
||||
# updater removed in Blender 4.5 extension format
|
||||
|
||||
# Get the root module name dynamically
|
||||
def _get_addon_module_name():
|
||||
"""Get the root addon module name for bl_idname."""
|
||||
# In Blender 5.0 extensions loaded via VSCode, the module name is the full path
|
||||
# e.g., "bl_ext.vscode_development.atomic_data_manager"
|
||||
# We need to get it from the parent package (atomic_data_manager)
|
||||
try:
|
||||
# Get parent package name from __package__ (remove .ui suffix)
|
||||
if __package__:
|
||||
parent_pkg = __package__.rsplit('.', 1)[0] if '.' in __package__ else __package__
|
||||
# Get the actual module from sys.modules to get its __name__
|
||||
parent_module = sys.modules.get(parent_pkg)
|
||||
if parent_module and hasattr(parent_module, '__name__'):
|
||||
module_name = parent_module.__name__
|
||||
config.debug_print(f"[Atomic Debug] Using parent module __name__ as bl_idname: {module_name}")
|
||||
return module_name
|
||||
else:
|
||||
# Use the package name directly
|
||||
config.debug_print(f"[Atomic Debug] Using parent package name as bl_idname: {parent_pkg}")
|
||||
return parent_pkg
|
||||
except Exception as e:
|
||||
config.debug_print(f"[Atomic Debug] Could not get parent module name: {e}")
|
||||
|
||||
# Last fallback
|
||||
module_name = "atomic_data_manager"
|
||||
config.debug_print(f"[Atomic Debug] Using fallback bl_idname: {module_name}")
|
||||
return module_name
|
||||
|
||||
|
||||
def _get_addon_prefs():
|
||||
# robustly find our AddonPreferences instance regardless of module name
|
||||
@@ -106,6 +136,9 @@ def copy_prefs_to_config(self, context):
|
||||
config.include_fake_users = \
|
||||
atomic_preferences.include_fake_users
|
||||
|
||||
config.enable_debug_prints = \
|
||||
atomic_preferences.enable_debug_prints
|
||||
|
||||
# hidden atomic preferences
|
||||
config.pie_menu_type = \
|
||||
atomic_preferences.pie_menu_type
|
||||
@@ -192,7 +225,9 @@ def remove_pie_menu_hotkeys():
|
||||
|
||||
# Atomic Data Manager Preference Panel UI
|
||||
class ATOMIC_PT_preferences_panel(bpy.types.AddonPreferences):
|
||||
bl_idname = "atomic_data_manager"
|
||||
# bl_idname must match the add-on's module name exactly
|
||||
# Get it dynamically to ensure it matches what Blender registered
|
||||
bl_idname = _get_addon_module_name()
|
||||
|
||||
# visible atomic preferences
|
||||
enable_missing_file_warning: bpy.props.BoolProperty(
|
||||
@@ -214,6 +249,11 @@ class ATOMIC_PT_preferences_panel(bpy.types.AddonPreferences):
|
||||
update=update_pie_menu_hotkeys
|
||||
)
|
||||
|
||||
enable_debug_prints: bpy.props.BoolProperty(
|
||||
description="Enable debug print statements in the console",
|
||||
default=False
|
||||
)
|
||||
|
||||
# hidden atomic preferences
|
||||
pie_menu_type: bpy.props.StringProperty(
|
||||
default="D"
|
||||
@@ -243,6 +283,9 @@ class ATOMIC_PT_preferences_panel(bpy.types.AddonPreferences):
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
|
||||
# Debug: verify draw is being called
|
||||
config.debug_print("[Atomic Debug] Preferences draw() method called")
|
||||
|
||||
split = layout.split()
|
||||
|
||||
@@ -266,6 +309,13 @@ class ATOMIC_PT_preferences_panel(bpy.types.AddonPreferences):
|
||||
text="Include Fake Users"
|
||||
)
|
||||
|
||||
# enable debug prints toggle
|
||||
col.prop(
|
||||
self,
|
||||
"enable_debug_prints",
|
||||
text="Enable Debug Prints"
|
||||
)
|
||||
|
||||
# pie menu settings
|
||||
pie_split = col.split(factor=0.55) # nice
|
||||
|
||||
@@ -317,7 +367,13 @@ keymaps = []
|
||||
|
||||
def register():
|
||||
for cls in reg_list:
|
||||
register_class(cls)
|
||||
try:
|
||||
register_class(cls)
|
||||
config.debug_print(f"[Atomic Debug] Registered preferences class: {cls.__name__} with bl_idname: {cls.bl_idname}")
|
||||
except Exception as e:
|
||||
print(f"[Atomic Error] Failed to register preferences class {cls.__name__}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
# make sure global preferences are updated on registration
|
||||
copy_prefs_to_config(None, None)
|
||||
|
||||
@@ -362,7 +362,6 @@ class ATOMIC_PT_stats_panel(bpy.types.Panel):
|
||||
text="Unnamed: {0}".format(count.worlds_unnamed())
|
||||
)
|
||||
|
||||
|
||||
reg_list = [ATOMIC_PT_stats_panel]
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ between Blender 4.2 LTS, 4.5 LTS, and 5.0.
|
||||
import bpy
|
||||
from bpy.utils import register_class, unregister_class
|
||||
from . import version
|
||||
from .. import config
|
||||
|
||||
|
||||
def safe_register_class(cls):
|
||||
@@ -116,7 +117,7 @@ def get_scene_compositor_node_tree(scene):
|
||||
Get the compositor node tree from a scene, handling version differences.
|
||||
|
||||
In Blender 4.2/4.5: scene.node_tree
|
||||
In Blender 5.0+: scene.compositing_node_tree
|
||||
In Blender 5.0+: scene.compositing_node_group
|
||||
|
||||
Args:
|
||||
scene: The scene object
|
||||
@@ -124,14 +125,35 @@ def get_scene_compositor_node_tree(scene):
|
||||
Returns:
|
||||
NodeTree or None: The compositor node tree if available
|
||||
"""
|
||||
# Blender 5.0+ uses compositing_node_tree
|
||||
# Blender 5.0+ uses compositing_node_group (not compositing_node_tree!)
|
||||
if version.is_version_at_least(5, 0, 0):
|
||||
if hasattr(scene, 'compositing_node_tree') and scene.compositing_node_tree:
|
||||
return scene.compositing_node_tree
|
||||
# Try compositing_node_group first (Blender 5.0+)
|
||||
try:
|
||||
node_tree = getattr(scene, 'compositing_node_group', None)
|
||||
config.debug_print(f"[Atomic Debug] get_scene_compositor_node_tree: scene='{scene.name}', use_nodes={scene.use_nodes}, compositing_node_group={node_tree}")
|
||||
if node_tree:
|
||||
config.debug_print(f"[Atomic Debug] get_scene_compositor_node_tree: Found compositor node tree: {node_tree.name}")
|
||||
return node_tree
|
||||
except (AttributeError, TypeError) as e:
|
||||
config.debug_print(f"[Atomic Debug] get_scene_compositor_node_tree: compositing_node_group access failed: {e}")
|
||||
|
||||
# Fallback: try compositing_node_tree (in case it exists in some versions)
|
||||
try:
|
||||
node_tree = getattr(scene, 'compositing_node_tree', None)
|
||||
config.debug_print(f"[Atomic Debug] get_scene_compositor_node_tree: compositing_node_tree={node_tree}")
|
||||
if node_tree:
|
||||
config.debug_print(f"[Atomic Debug] get_scene_compositor_node_tree: Found via compositing_node_tree: {node_tree.name}")
|
||||
return node_tree
|
||||
except (AttributeError, TypeError) as e:
|
||||
config.debug_print(f"[Atomic Debug] get_scene_compositor_node_tree: compositing_node_tree access failed: {e}")
|
||||
else:
|
||||
# Blender 4.2/4.5 uses node_tree
|
||||
if hasattr(scene, 'node_tree') and scene.node_tree:
|
||||
return scene.node_tree
|
||||
try:
|
||||
node_tree = getattr(scene, 'node_tree', None)
|
||||
if node_tree:
|
||||
return node_tree
|
||||
except (AttributeError, TypeError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import bpy # type: ignore
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -91,6 +92,46 @@ def get_file_extension(video_format):
|
||||
else:
|
||||
return ".mp4" # Default to mp4 if unknown
|
||||
|
||||
def _resolve_ffmpeg_path():
|
||||
"""Resolve ffmpeg executable. Prefer addon pref, then PATH, then Blender's bundled ffmpeg."""
|
||||
try:
|
||||
for addon in (__name__, "basedplayblast", "bl_ext.basedplayblast", "BasedPlayblast"):
|
||||
prefs = bpy.context.preferences.addons.get(addon)
|
||||
if prefs and prefs.preferences and hasattr(prefs.preferences, "ffmpeg_path"):
|
||||
custom = getattr(prefs.preferences, "ffmpeg_path", "").strip()
|
||||
if custom and os.path.isfile(custom):
|
||||
return custom
|
||||
except Exception:
|
||||
pass
|
||||
exe = shutil.which("ffmpeg")
|
||||
if exe:
|
||||
return exe
|
||||
try:
|
||||
blender_dir = os.path.dirname(bpy.app.binary_path)
|
||||
version_str = f"{bpy.app.version[0]}.{bpy.app.version[1]}"
|
||||
for search_dir in (blender_dir, os.path.join(blender_dir, version_str)):
|
||||
for name in ("ffmpeg.exe", "ffmpeg"):
|
||||
path = os.path.join(search_dir, name)
|
||||
if os.path.isfile(path):
|
||||
return path
|
||||
except Exception:
|
||||
pass
|
||||
# Common Windows install locations (Steam/launcher often omit PATH)
|
||||
for candidate in (
|
||||
r"C:\ProgramData\chocolatey\bin\ffmpeg.exe", # Chocolatey
|
||||
r"C:\Program Files\ffmpeg\bin\ffmpeg.exe",
|
||||
r"C:\Program Files (x86)\ffmpeg\bin\ffmpeg.exe",
|
||||
r"C:\ffmpeg\bin\ffmpeg.exe",
|
||||
os.path.join(os.environ.get("ProgramFiles", "C:\\Program Files"), "ffmpeg", "bin", "ffmpeg.exe"),
|
||||
os.path.join(os.environ.get("ProgramFiles(x86)", "C:\\Program Files (x86)"), "ffmpeg", "bin", "ffmpeg.exe"),
|
||||
):
|
||||
try:
|
||||
if candidate and os.path.isfile(candidate):
|
||||
return candidate
|
||||
except Exception:
|
||||
pass
|
||||
return "ffmpeg" # Fallback; will fail with clear error if missing
|
||||
|
||||
# Helper function to convert quality enum to FFmpeg CRF value
|
||||
def get_ffmpeg_quality(quality_enum):
|
||||
quality_map = {
|
||||
@@ -814,7 +855,8 @@ class BPL_OT_create_playblast(Operator):
|
||||
'frame_end': original_frame_end, # Store original frame end
|
||||
'image_settings': {
|
||||
'file_format': scene.render.image_settings.file_format,
|
||||
'color_mode': scene.render.image_settings.color_mode
|
||||
'color_mode': scene.render.image_settings.color_mode,
|
||||
'compression': self._safe_get_compression_value(scene)
|
||||
},
|
||||
'display_mode': context.preferences.view.render_display_type,
|
||||
# Store metadata settings
|
||||
@@ -1523,7 +1565,7 @@ class BPL_OT_create_playblast(Operator):
|
||||
# Note: FFmpeg's %04d pattern expects frames starting at 0000, but our frames start at frame_start
|
||||
# We need to add -start_number to tell FFmpeg the actual starting frame number
|
||||
ffmpeg_cmd = [
|
||||
"ffmpeg", "-y", # Overwrite output file
|
||||
_resolve_ffmpeg_path(), "-y", # Overwrite output file
|
||||
"-framerate", str(framerate),
|
||||
"-start_number", str(self._frame_start), # Tell FFmpeg the starting frame number
|
||||
"-i", frame_pattern,
|
||||
@@ -1675,7 +1717,7 @@ class BPL_OT_create_playblast(Operator):
|
||||
duration = (self._frame_end - self._frame_start + 1) / fps
|
||||
|
||||
ffmpeg_extract_cmd = [
|
||||
"ffmpeg", "-y",
|
||||
_resolve_ffmpeg_path(), "-y",
|
||||
"-i", sound_path,
|
||||
"-ss", str(max(0, start_time)),
|
||||
"-t", str(duration),
|
||||
@@ -1868,8 +1910,22 @@ class BPL_OT_create_playblast(Operator):
|
||||
self.report({'ERROR'}, f"Video conversion failed: {result.stderr}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error converting frames to video: {str(e)}")
|
||||
self.report({'ERROR'}, f"Video conversion error: {str(e)}")
|
||||
err_msg = str(e)
|
||||
print(f"Error converting frames to video: {err_msg}")
|
||||
hint = " Set FFmpeg Path in Edit > Preferences > Add-ons > BasedPlayblast." if "WinError 2" in err_msg or "cannot find the file" in err_msg.lower() else ""
|
||||
self.report({'ERROR'}, f"Video conversion error: {err_msg}{hint}")
|
||||
|
||||
def _safe_get_compression_value(self, scene):
|
||||
"""Safely get compression value, returning None if not available or format doesn't support it."""
|
||||
try:
|
||||
if not hasattr(scene.render.image_settings, 'compression'):
|
||||
return None
|
||||
current_format = str(scene.render.image_settings.file_format).upper()
|
||||
if current_format not in ('PNG', 'JPEG', 'JPEG2000'):
|
||||
return None
|
||||
return scene.render.image_settings.compression
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
return None
|
||||
|
||||
def cleanup(self, context):
|
||||
# Reset progress properties
|
||||
@@ -1916,8 +1972,42 @@ class BPL_OT_create_playblast(Operator):
|
||||
scene.render.use_overwrite = self._original_settings['use_overwrite']
|
||||
scene.render.use_placeholder = self._original_settings['use_placeholder']
|
||||
scene.camera = self._original_settings['camera']
|
||||
scene.render.image_settings.file_format = self._original_settings['image_settings']['file_format']
|
||||
scene.render.image_settings.color_mode = self._original_settings['image_settings']['color_mode']
|
||||
# Restore image settings safely using .get() to avoid KeyError
|
||||
try:
|
||||
if isinstance(self._original_settings, dict):
|
||||
img_settings = self._original_settings.get('image_settings')
|
||||
if img_settings and isinstance(img_settings, dict):
|
||||
# Restore file_format first
|
||||
file_format = img_settings.get('file_format')
|
||||
if file_format is not None:
|
||||
scene.render.image_settings.file_format = file_format
|
||||
|
||||
# Restore color_mode
|
||||
color_mode = img_settings.get('color_mode')
|
||||
if color_mode is not None:
|
||||
scene.render.image_settings.color_mode = color_mode
|
||||
|
||||
# Restore compression - check if format supports it
|
||||
compression = img_settings.get('compression')
|
||||
if compression is not None:
|
||||
# Get current format (after restoration above)
|
||||
try:
|
||||
current_format = scene.render.image_settings.file_format
|
||||
# Convert to string for comparison (handles enum types)
|
||||
format_str = str(current_format).upper()
|
||||
|
||||
# Only restore compression if file format supports it
|
||||
if format_str in ('PNG', 'JPEG', 'JPEG2000'):
|
||||
try:
|
||||
if hasattr(scene.render.image_settings, 'compression'):
|
||||
scene.render.image_settings.compression = compression
|
||||
print(f"Restored PNG compression: {compression}%")
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
pass # Silently fail if compression can't be set
|
||||
except (AttributeError, TypeError):
|
||||
pass # Silently fail if format can't be read
|
||||
except (KeyError, TypeError, AttributeError):
|
||||
pass # Silently fail if settings can't be restored
|
||||
context.preferences.view.render_display_type = self._original_settings['display_mode']
|
||||
|
||||
# CRITICAL: Restore frame range to original values - THIS FIXES THE MAIN BUG
|
||||
@@ -3893,6 +3983,13 @@ class BPL_AddonPreferences(AddonPreferences):
|
||||
default="-c:v h264_nvenc -preset fast -crf 0"
|
||||
)
|
||||
|
||||
ffmpeg_path: StringProperty(
|
||||
name="FFmpeg Path",
|
||||
description="Full path to ffmpeg.exe (e.g. C:\\ffmpeg\\bin\\ffmpeg.exe). Leave blank to use PATH or Blender's bundled ffmpeg. Required when launching Blender from Steam.",
|
||||
default="",
|
||||
subtype='FILE_PATH'
|
||||
)
|
||||
|
||||
repo_initialized: BoolProperty(
|
||||
name="Rainy's Extensions Added",
|
||||
description="Internal flag to avoid re-adding Rainy's Extensions repository multiple times.",
|
||||
@@ -3907,6 +4004,7 @@ class BPL_AddonPreferences(AddonPreferences):
|
||||
box.prop(self, "default_video_quality")
|
||||
box.prop(self, "default_use_custom_ffmpeg_args")
|
||||
box.prop(self, "default_ffmpeg_args")
|
||||
box.prop(self, "ffmpeg_path", text="FFmpeg Path (Steam)")
|
||||
|
||||
def on_load_post(dummy):
|
||||
"""Applies user defaults after a file is loaded."""
|
||||
|
||||
@@ -3,7 +3,7 @@ schema_version = "1.0.0"
|
||||
id = "basedplayblast"
|
||||
name = "BasedPlayblast"
|
||||
tagline = "Easily create playblasts from Blender and Flamenco"
|
||||
version = "2.6.1"
|
||||
version = "2.6.3"
|
||||
type = "add-on"
|
||||
|
||||
maintainer = "RaincloudTheDragon <raincloudthedragon@gmail.com>"
|
||||
|
||||
@@ -5,15 +5,15 @@ from .panels import bulk_viewport_display
|
||||
from .panels import bulk_data_remap
|
||||
from .panels import bulk_path_management
|
||||
from .panels import bulk_scene_general
|
||||
from .ops.AutoMatExtractor import AutoMatExtractor, AUTOMAT_OT_summary_dialog
|
||||
from .ops.Rename_images_by_mat import Rename_images_by_mat, RENAME_OT_summary_dialog
|
||||
from .ops.FreeGPU import BST_FreeGPU
|
||||
from .ops.AutoMatExtractor import RBST_AutoMat_OT_AutoMatExtractor, RBST_AutoMat_OT_summary_dialog
|
||||
from .ops.Rename_images_by_mat import RBST_RenameImg_OT_Rename_images_by_mat, RBST_RenameImg_OT_summary_dialog
|
||||
from .ops.FreeGPU import RBST_FreeGPU
|
||||
from .ops import ghost_buster
|
||||
from . import rainys_repo_bootstrap
|
||||
from .utils import compat
|
||||
|
||||
# Addon preferences class for update settings
|
||||
class BST_AddonPreferences(AddonPreferences):
|
||||
class RBST_AddonPreferences(AddonPreferences):
|
||||
bl_idname = __package__
|
||||
|
||||
# AutoMat Extractor settings
|
||||
@@ -48,12 +48,12 @@ class VIEW3D_PT_BulkSceneTools(Panel):
|
||||
# List of all classes in this module
|
||||
classes = (
|
||||
VIEW3D_PT_BulkSceneTools,
|
||||
BST_AddonPreferences,
|
||||
AutoMatExtractor,
|
||||
AUTOMAT_OT_summary_dialog,
|
||||
Rename_images_by_mat,
|
||||
RENAME_OT_summary_dialog,
|
||||
BST_FreeGPU,
|
||||
RBST_AddonPreferences,
|
||||
RBST_AutoMat_OT_AutoMatExtractor,
|
||||
RBST_AutoMat_OT_summary_dialog,
|
||||
RBST_RenameImg_OT_Rename_images_by_mat,
|
||||
RBST_RenameImg_OT_summary_dialog,
|
||||
RBST_FreeGPU,
|
||||
)
|
||||
|
||||
def register():
|
||||
|
||||
@@ -3,7 +3,7 @@ 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.14.0"
|
||||
version = "0.16.0"
|
||||
type = "add-on"
|
||||
|
||||
maintainer = "RaincloudTheDragon <raincloudthedragon@gmail.com>"
|
||||
|
||||
@@ -1,3 +1,23 @@
|
||||
# v0.16.0 2026-01-15
|
||||
- Delete Single Keyframe Actions: Blender 5.0 compatible (action layers/strips/channelbags); skip actions from linked libraries
|
||||
- Find Material Users: removed (issue #14; use Atomic or DBU)
|
||||
- White World: new operator in Scene General (#13)—white background world, film transparent, orphans purge
|
||||
|
||||
# v0.15.1 2026-01-12
|
||||
- Fixed AutoMatExtractor MSMNAO Pack texture handling: preserve texture type suffixes (MSMNAO Pack, SSTM Pack, etc.) when sanitizing filenames
|
||||
- Fixed issue #12: corrected duplicated prefix in RBST_ViewDisp_MaterialStatus enum definition
|
||||
- Added Props subcollection under Char in SpawnSceneStructure operator
|
||||
|
||||
# v0.15.0 2026-01-07
|
||||
- Refactored all class, function, and constant names to use consistent RBST_ prefix system with module-specific sub-prefixes
|
||||
- Added Data-Block Utils integration: Merge Duplicates button in Bulk Data Remap panel that automates finding and merging duplicates across Node Groups, Materials, Lights, Images, and Meshes
|
||||
|
||||
# v0.14.1
|
||||
- Fixed crash in Blender 5.0 when refreshing material previews (#11)
|
||||
- Removed problematic batch preview operators and unsafe icon_id access that caused crashes
|
||||
- Added version-specific optimizations for 4.2/4.5 vs 5.0+ compatibility
|
||||
- Improved cleanup and reduced CPU usage
|
||||
|
||||
# v0.14.0
|
||||
- Added operator to select all images with absolute paths (#3)
|
||||
- Added search functionality to filter datablocks in PathMan and Data Remapper panels (#4)
|
||||
|
||||
@@ -9,7 +9,7 @@ from ..panels.bulk_path_management import (
|
||||
)
|
||||
from ..utils import compat
|
||||
|
||||
class AUTOMAT_OT_summary_dialog(bpy.types.Operator):
|
||||
class RBST_AutoMat_OT_summary_dialog(bpy.types.Operator):
|
||||
"""Show AutoMat Extractor operation summary"""
|
||||
bl_idname = "bst.automat_summary_dialog"
|
||||
bl_label = "AutoMat Extractor Summary"
|
||||
@@ -62,7 +62,7 @@ class AUTOMAT_OT_summary_dialog(bpy.types.Operator):
|
||||
def invoke(self, context, event):
|
||||
return context.window_manager.invoke_popup(self, width=500)
|
||||
|
||||
class AutoMatExtractor(bpy.types.Operator):
|
||||
class RBST_AutoMat_OT_AutoMatExtractor(bpy.types.Operator):
|
||||
bl_idname = "bst.automatextractor"
|
||||
bl_label = "AutoMatExtractor"
|
||||
bl_description = "Pack selected images and extract them with organized paths by blend file and material"
|
||||
@@ -405,8 +405,10 @@ class AutoMatExtractor(bpy.types.Operator):
|
||||
def sanitize_filename(self, filename):
|
||||
"""Sanitize filename/folder name for filesystem compatibility"""
|
||||
# First, remove potential file extensions, including numerical ones like .001
|
||||
base_name = re.sub(r'\.\d{3}$', '', filename) # Remove .001, .002 etc.
|
||||
base_name = os.path.splitext(base_name)[0] # Remove standard extensions
|
||||
# Remove .001, .002 etc. when followed by _ or space (for CC/iC Pack textures)
|
||||
base_name = re.sub(r'\.\d{3}(?=[_\s])', '', filename) # Remove .001, .002 etc. when followed by _ or space
|
||||
base_name = re.sub(r'\.\d{3}$', '', base_name) # Also remove if at the end
|
||||
base_name = os.path.splitext(base_name)[0] # Remove standard extensions
|
||||
|
||||
# Remove or replace invalid characters for Windows/Mac/Linux
|
||||
sanitized = re.sub(r'[<>:"/\\|?*]', '_', base_name)
|
||||
@@ -539,8 +541,8 @@ class AutoMatExtractor(bpy.types.Operator):
|
||||
|
||||
# Must register the new dialog class as well
|
||||
classes = (
|
||||
AUTOMAT_OT_summary_dialog,
|
||||
AutoMatExtractor,
|
||||
RBST_AutoMat_OT_summary_dialog,
|
||||
RBST_AutoMat_OT_AutoMatExtractor,
|
||||
)
|
||||
|
||||
def register():
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import bpy
|
||||
|
||||
class BST_FreeGPU(bpy.types.Operator):
|
||||
class RBST_FreeGPU(bpy.types.Operator):
|
||||
bl_idname = "bst.free_gpu"
|
||||
bl_label = "Free VRAM"
|
||||
bl_description = "Unallocate all material images from VRAM"
|
||||
|
||||
+4
-4
@@ -2,7 +2,7 @@ import bpy
|
||||
import re
|
||||
from ..utils import compat
|
||||
|
||||
class RENAME_OT_summary_dialog(bpy.types.Operator):
|
||||
class RBST_RenameImg_OT_summary_dialog(bpy.types.Operator):
|
||||
"""Show rename operation summary"""
|
||||
bl_idname = "bst.rename_summary_dialog"
|
||||
bl_label = "Rename Summary"
|
||||
@@ -66,7 +66,7 @@ class RENAME_OT_summary_dialog(bpy.types.Operator):
|
||||
def invoke(self, context, event):
|
||||
return context.window_manager.invoke_popup(self, width=500)
|
||||
|
||||
class Rename_images_by_mat(bpy.types.Operator):
|
||||
class RBST_RenameImg_OT_Rename_images_by_mat(bpy.types.Operator):
|
||||
bl_idname = "bst.rename_images_by_mat"
|
||||
bl_label = "Rename Images by Material"
|
||||
bl_description = "Rename selected images based on their material usage, preserving texture type suffixes"
|
||||
@@ -500,8 +500,8 @@ class Rename_images_by_mat(bpy.types.Operator):
|
||||
|
||||
# Registration classes - need to register both operators
|
||||
classes = (
|
||||
RENAME_OT_summary_dialog,
|
||||
Rename_images_by_mat,
|
||||
RBST_RenameImg_OT_summary_dialog,
|
||||
RBST_RenameImg_OT_Rename_images_by_mat,
|
||||
)
|
||||
|
||||
def register():
|
||||
|
||||
+30
-7
@@ -1,5 +1,32 @@
|
||||
import bpy
|
||||
|
||||
from ..utils import version
|
||||
|
||||
|
||||
def _collect_keyframe_stats(action):
|
||||
"""Return (total_keyframes, keyframe_frames_set). Compatible with 4.2/4.5 (action.fcurves) and 5.0 (layers/strips/channelbags)."""
|
||||
keyframe_frames = set()
|
||||
total_keyframes = 0
|
||||
if version.is_version_less_than(5, 0, 0):
|
||||
for fcurve in action.fcurves:
|
||||
for kf in fcurve.keyframe_points:
|
||||
keyframe_frames.add(kf.co[0])
|
||||
total_keyframes += 1
|
||||
else:
|
||||
# Blender 5.0+: legacy action.fcurves removed; use layers → strips → channelbag(slot).fcurves
|
||||
for layer in action.layers:
|
||||
for strip in layer.strips:
|
||||
for slot in action.slots:
|
||||
channelbag = strip.channelbag(slot, ensure=False)
|
||||
if channelbag is None:
|
||||
continue
|
||||
for fcurve in channelbag.fcurves:
|
||||
for kf in fcurve.keyframe_points:
|
||||
keyframe_frames.add(kf.co[0])
|
||||
total_keyframes += 1
|
||||
return total_keyframes, keyframe_frames
|
||||
|
||||
|
||||
class DeleteSingleKeyframeActions(bpy.types.Operator):
|
||||
"""Delete actions that have no keyframes, only one keyframe, or all keyframes on the same frame"""
|
||||
bl_idname = "bst.delete_single_keyframe_actions"
|
||||
@@ -12,13 +39,9 @@ class DeleteSingleKeyframeActions(bpy.types.Operator):
|
||||
actions_to_delete = []
|
||||
|
||||
for action in actions:
|
||||
keyframe_frames = set()
|
||||
total_keyframes = 0
|
||||
for fcurve in action.fcurves:
|
||||
for kf in fcurve.keyframe_points:
|
||||
keyframe_frames.add(kf.co[0])
|
||||
total_keyframes += 1
|
||||
|
||||
if getattr(action, "library", None) is not None:
|
||||
continue
|
||||
total_keyframes, keyframe_frames = _collect_keyframe_stats(action)
|
||||
# No keyframes
|
||||
if total_keyframes == 0:
|
||||
actions_to_delete.append(action)
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
import bpy
|
||||
|
||||
class MATERIAL_USERS_OT_summary_dialog(bpy.types.Operator):
|
||||
"""Show material users analysis in a popup dialog"""
|
||||
bl_idname = "bst.material_users_summary_dialog"
|
||||
bl_label = "Material Users Summary"
|
||||
bl_options = {'REGISTER', 'INTERNAL'}
|
||||
|
||||
# Properties to store summary data
|
||||
material_name: bpy.props.StringProperty(default="")
|
||||
users_count: bpy.props.IntProperty(default=0)
|
||||
fake_user: bpy.props.BoolProperty(default=False)
|
||||
object_users: bpy.props.StringProperty(default="")
|
||||
node_users: bpy.props.StringProperty(default="")
|
||||
material_node_users: bpy.props.StringProperty(default="")
|
||||
total_user_count: bpy.props.IntProperty(default=0)
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
|
||||
# Title
|
||||
layout.label(text=f"Material Users - '{self.material_name}'", icon='MATERIAL')
|
||||
layout.separator()
|
||||
|
||||
# Basic info box
|
||||
box = layout.box()
|
||||
col = box.column(align=True)
|
||||
col.label(text=f"Blender Users Count: {self.users_count}")
|
||||
col.label(text=f"Fake User: {'Yes' if self.fake_user else 'No'}")
|
||||
col.label(text=f"Total Found Users: {self.total_user_count}")
|
||||
|
||||
layout.separator()
|
||||
|
||||
# Object users section
|
||||
if self.object_users:
|
||||
layout.label(text="Object Users:", icon='OBJECT_DATA')
|
||||
objects_box = layout.box()
|
||||
objects_col = objects_box.column(align=True)
|
||||
for obj_name in self.object_users.split('|'):
|
||||
if obj_name.strip():
|
||||
objects_col.label(text=f"• {obj_name}", icon='RIGHTARROW_THIN')
|
||||
else:
|
||||
layout.label(text="Object Users: None", icon='OBJECT_DATA')
|
||||
|
||||
# Node tree users section
|
||||
if self.node_users:
|
||||
layout.separator()
|
||||
layout.label(text="Node Tree Users:", icon='NODETREE')
|
||||
nodes_box = layout.box()
|
||||
nodes_col = nodes_box.column(align=True)
|
||||
for node_ref in self.node_users.split('|'):
|
||||
if node_ref.strip():
|
||||
nodes_col.label(text=f"• {node_ref}", icon='RIGHTARROW_THIN')
|
||||
|
||||
# Material node tree users section
|
||||
if self.material_node_users:
|
||||
layout.separator()
|
||||
layout.label(text="Material Node Tree Users:", icon='MATERIAL')
|
||||
mat_nodes_box = layout.box()
|
||||
mat_nodes_col = mat_nodes_box.column(align=True)
|
||||
for mat_node_ref in self.material_node_users.split('|'):
|
||||
if mat_node_ref.strip():
|
||||
mat_nodes_col.label(text=f"• {mat_node_ref}", icon='RIGHTARROW_THIN')
|
||||
|
||||
layout.separator()
|
||||
|
||||
def execute(self, context):
|
||||
return {'FINISHED'}
|
||||
|
||||
def invoke(self, context, event):
|
||||
return context.window_manager.invoke_popup(self, width=500)
|
||||
|
||||
class FindMaterialUsers(bpy.types.Operator):
|
||||
"""Find all users of a specified material and display detailed information"""
|
||||
bl_idname = "bst.find_material_users"
|
||||
bl_label = "Find Material Users"
|
||||
bl_description = "Find and display all users of a specified material"
|
||||
bl_options = {'REGISTER'}
|
||||
|
||||
material_name: bpy.props.StringProperty(
|
||||
name="Material",
|
||||
description="Name of the material to analyze",
|
||||
default="",
|
||||
)
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
|
||||
# Set the material if we have a name
|
||||
if self.material_name and self.material_name in bpy.data.materials:
|
||||
context.scene.bst_temp_material = bpy.data.materials[self.material_name]
|
||||
|
||||
# Use template_ID to get the proper material selector (without new button)
|
||||
layout.template_ID(context.scene, "bst_temp_material", text="Material")
|
||||
|
||||
def execute(self, context):
|
||||
# Get the material from the temp property
|
||||
material = getattr(context.scene, 'bst_temp_material', None)
|
||||
|
||||
if not material:
|
||||
self.report({'ERROR'}, "No material selected")
|
||||
return {'CANCELLED'}
|
||||
|
||||
# Update our material_name property
|
||||
self.material_name = material.name
|
||||
|
||||
# Check objects
|
||||
object_users = []
|
||||
for obj in bpy.data.objects:
|
||||
if obj.material_slots:
|
||||
for slot in obj.material_slots:
|
||||
if slot.material == material:
|
||||
object_users.append(obj.name)
|
||||
break
|
||||
|
||||
# Check node groups more thoroughly
|
||||
node_users = []
|
||||
for node_tree in bpy.data.node_groups:
|
||||
for node in node_tree.nodes:
|
||||
# Check material nodes
|
||||
if hasattr(node, 'material') and node.material == material:
|
||||
node_users.append(f"{node_tree.name}.{node.name}")
|
||||
# Check material input sockets
|
||||
for input_socket in node.inputs:
|
||||
if hasattr(input_socket, 'default_value') and hasattr(input_socket.default_value, 'name'):
|
||||
if input_socket.default_value.name == material.name:
|
||||
node_users.append(f"{node_tree.name}.{node.name}.{input_socket.name}")
|
||||
|
||||
# Check material node trees
|
||||
material_node_users = []
|
||||
for mat in bpy.data.materials:
|
||||
if mat.node_tree:
|
||||
for node in mat.node_tree.nodes:
|
||||
if hasattr(node, 'material') and node.material == material:
|
||||
material_node_users.append(f"{mat.name}.{node.name}")
|
||||
|
||||
# Show summary dialog
|
||||
self.show_summary_dialog(context, material, object_users, node_users, material_node_users)
|
||||
return {'FINISHED'}
|
||||
|
||||
def show_summary_dialog(self, context, material, object_users, node_users, material_node_users):
|
||||
"""Show the material users summary in a popup dialog"""
|
||||
total_user_count = len(object_users) + len(node_users) + len(material_node_users)
|
||||
|
||||
# Create and configure the summary dialog
|
||||
dialog_op = bpy.ops.bst.material_users_summary_dialog
|
||||
dialog_op('INVOKE_DEFAULT',
|
||||
material_name=material.name,
|
||||
users_count=material.users,
|
||||
fake_user=material.use_fake_user,
|
||||
object_users='|'.join(object_users),
|
||||
node_users='|'.join(node_users),
|
||||
material_node_users='|'.join(material_node_users),
|
||||
total_user_count=total_user_count)
|
||||
|
||||
def invoke(self, context, event):
|
||||
return context.window_manager.invoke_props_dialog(self)
|
||||
@@ -369,7 +369,7 @@ def main(delete_low_priority=False):
|
||||
print(f"Object ghosts removed: {object_ghosts_removed}")
|
||||
print("="*80)
|
||||
|
||||
class GhostBuster(bpy.types.Operator):
|
||||
class RBST_Bustin_OT_GhostBuster(bpy.types.Operator):
|
||||
"""Conservative cleanup of ghost data (unused WGT objects, empty collections)"""
|
||||
bl_idname = "bst.ghost_buster"
|
||||
bl_label = "Ghost Buster"
|
||||
@@ -388,7 +388,7 @@ class GhostBuster(bpy.types.Operator):
|
||||
self.report({'ERROR'}, f"Ghost buster failed: {str(e)}")
|
||||
return {'CANCELLED'}
|
||||
|
||||
class GhostDetector(bpy.types.Operator):
|
||||
class RBST_Bustin_OT_GhostDetector(bpy.types.Operator):
|
||||
"""Detect and analyze ghost data without removing it"""
|
||||
bl_idname = "bst.ghost_detector"
|
||||
bl_label = "Ghost Detector"
|
||||
@@ -606,7 +606,7 @@ class GhostDetector(bpy.types.Operator):
|
||||
self.analyze_ghost_data()
|
||||
return context.window_manager.invoke_popup(self, width=500)
|
||||
|
||||
class ResyncEnforce(bpy.types.Operator):
|
||||
class RBST_Bustin_OT_ResyncEnforce(bpy.types.Operator):
|
||||
"""Resync Enforce: Fix broken library override hierarchies by rebuilding from linked references"""
|
||||
bl_idname = "bst.resync_enforce"
|
||||
bl_label = "Resync Enforce"
|
||||
@@ -674,9 +674,9 @@ class ResyncEnforce(bpy.types.Operator):
|
||||
|
||||
# List of classes to register
|
||||
classes = (
|
||||
GhostBuster,
|
||||
GhostDetector,
|
||||
ResyncEnforce,
|
||||
RBST_Bustin_OT_GhostBuster,
|
||||
RBST_Bustin_OT_GhostDetector,
|
||||
RBST_Bustin_OT_ResyncEnforce,
|
||||
)
|
||||
|
||||
def register():
|
||||
|
||||
@@ -81,6 +81,23 @@ class SpawnSceneStructure(bpy.types.Operator):
|
||||
layer_collection = self.find_layer_collection(view_layer.layer_collection, subcollection_name)
|
||||
if layer_collection:
|
||||
layer_collection.exclude = True
|
||||
|
||||
# Create Props collection under Char
|
||||
if subcollection_name == "Char":
|
||||
char_collection = existing_subcollection if subcollection_exists else subcollection
|
||||
props_exists = False
|
||||
existing_props = None
|
||||
for child in char_collection.children:
|
||||
if child.name == "Props":
|
||||
props_exists = True
|
||||
existing_props = child
|
||||
skipped_collections.append(f"{main_collection_name}/{subcollection_name}/Props")
|
||||
break
|
||||
|
||||
if not props_exists:
|
||||
props_collection = bpy.data.collections.new("Props")
|
||||
char_collection.children.link(props_collection)
|
||||
created_collections.append(f"{main_collection_name}/{subcollection_name}/Props")
|
||||
|
||||
# Report results
|
||||
if created_collections:
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import bpy
|
||||
|
||||
|
||||
class WhiteWorld(bpy.types.Operator):
|
||||
"""Create a pure-white world and set it active; remove 'Dual Node Background' if present, enable transparent film"""
|
||||
bl_idname = "bst.white_world"
|
||||
bl_label = "White World"
|
||||
bl_description = "Create white background world, set film transparent, purge orphans"
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
def execute(self, context):
|
||||
if "Dual Node Background" in bpy.data.worlds:
|
||||
w = bpy.data.worlds["Dual Node Background"]
|
||||
bpy.data.worlds.remove(w, do_unlink=True)
|
||||
new_world = bpy.data.worlds.new(name="World")
|
||||
new_world.use_nodes = True
|
||||
nodes = new_world.node_tree.nodes
|
||||
links = new_world.node_tree.links
|
||||
nodes.clear()
|
||||
bg = nodes.new(type="ShaderNodeBackground")
|
||||
bg.inputs[0].default_value = (1.0, 1.0, 1.0, 1.0)
|
||||
out = nodes.new(type="ShaderNodeOutputWorld")
|
||||
links.new(bg.outputs[0], out.inputs[0])
|
||||
context.scene.world = new_world
|
||||
bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True)
|
||||
context.scene.render.film_transparent = True
|
||||
self.report({'INFO'}, "White world set, film transparent, orphans purged")
|
||||
return {'FINISHED'}
|
||||
+192
-102
@@ -5,14 +5,14 @@ import sys
|
||||
import subprocess
|
||||
|
||||
# Import ghost buster functionality
|
||||
from ..ops.ghost_buster import GhostBuster, GhostDetector, ResyncEnforce
|
||||
from ..ops.ghost_buster import RBST_Bustin_OT_GhostBuster, RBST_Bustin_OT_GhostDetector, RBST_Bustin_OT_ResyncEnforce
|
||||
from ..utils import compat
|
||||
|
||||
# Regular expression to match numbered suffixes like .001, .002, _001, _0001, etc.
|
||||
NUMBERED_SUFFIX_PATTERN = re.compile(r'(.*?)[._](\d{3,})$')
|
||||
RBST_DatRem_NUMBERED_SUFFIX_PATTERN = re.compile(r'(.*?)[._](\d{3,})$')
|
||||
|
||||
# Function to check if any datablocks in a collection are linked from a library
|
||||
def has_linked_datablocks(data_collection):
|
||||
def RBST_DatRem_has_linked_datablocks(data_collection):
|
||||
"""Check if any datablocks in the collection are linked from a library"""
|
||||
for data in data_collection:
|
||||
if data.users > 0 and hasattr(data, 'library') and data.library is not None:
|
||||
@@ -20,7 +20,7 @@ def has_linked_datablocks(data_collection):
|
||||
return False
|
||||
|
||||
# Register properties for data remap settings
|
||||
def register_dataremap_properties():
|
||||
def RBST_DatRem_register_properties():
|
||||
bpy.types.Scene.dataremap_images = bpy.props.BoolProperty( # type: ignore
|
||||
name="Images",
|
||||
description="Find and remap duplicate images",
|
||||
@@ -136,7 +136,7 @@ def register_dataremap_properties():
|
||||
default=False
|
||||
)
|
||||
|
||||
def unregister_dataremap_properties():
|
||||
def RBST_DatRem_unregister_properties():
|
||||
del bpy.types.Scene.dataremap_images
|
||||
del bpy.types.Scene.dataremap_materials
|
||||
del bpy.types.Scene.dataremap_fonts
|
||||
@@ -162,14 +162,14 @@ def unregister_dataremap_properties():
|
||||
if hasattr(bpy.types.Scene, "ghost_buster_delete_low_priority"):
|
||||
del bpy.types.Scene.ghost_buster_delete_low_priority
|
||||
|
||||
def get_base_name(name):
|
||||
def RBST_DatRem_get_base_name(name):
|
||||
"""Extract the base name without numbered suffix"""
|
||||
match = NUMBERED_SUFFIX_PATTERN.match(name)
|
||||
match = RBST_DatRem_NUMBERED_SUFFIX_PATTERN.match(name)
|
||||
if match:
|
||||
return match.group(1) # Return the base name
|
||||
return name
|
||||
|
||||
def find_data_groups(data_collection):
|
||||
def RBST_DatRem_find_data_groups(data_collection):
|
||||
"""Group data blocks by their base name, excluding those with no users or linked from libraries"""
|
||||
groups = {}
|
||||
|
||||
@@ -182,7 +182,7 @@ def find_data_groups(data_collection):
|
||||
if hasattr(data, 'library') and data.library is not None:
|
||||
continue
|
||||
|
||||
base_name = get_base_name(data.name)
|
||||
base_name = RBST_DatRem_get_base_name(data.name)
|
||||
|
||||
# Only group local datablocks
|
||||
if base_name not in groups:
|
||||
@@ -194,7 +194,7 @@ def find_data_groups(data_collection):
|
||||
return {name: items for name, items in groups.items()
|
||||
if len(items) > 1 and any(not (hasattr(item, 'library') and item.library is not None) for item in items)}
|
||||
|
||||
def find_target_data(data_group):
|
||||
def RBST_DatRem_find_target_data(data_group):
|
||||
"""Find the target data block to remap to"""
|
||||
# Filter out linked datablocks
|
||||
local_data_group = [data for data in data_group if not (hasattr(data, 'library') and data.library is not None)]
|
||||
@@ -205,7 +205,7 @@ def find_target_data(data_group):
|
||||
|
||||
# First, try to find a data block without a numbered suffix
|
||||
for data in local_data_group:
|
||||
if get_base_name(data.name) == data.name:
|
||||
if RBST_DatRem_get_base_name(data.name) == data.name:
|
||||
return data
|
||||
|
||||
# If no unnumbered version exists, find the "youngest" version (highest number)
|
||||
@@ -213,7 +213,7 @@ def find_target_data(data_group):
|
||||
highest_suffix = 0
|
||||
|
||||
for data in local_data_group:
|
||||
match = NUMBERED_SUFFIX_PATTERN.match(data.name)
|
||||
match = RBST_DatRem_NUMBERED_SUFFIX_PATTERN.match(data.name)
|
||||
if match:
|
||||
suffix_num = int(match.group(2))
|
||||
if suffix_num > highest_suffix:
|
||||
@@ -222,7 +222,7 @@ def find_target_data(data_group):
|
||||
|
||||
return youngest
|
||||
|
||||
def clean_data_names(data_collection):
|
||||
def RBST_DatRem_clean_data_names(data_collection):
|
||||
"""Remove numbered suffixes from all data blocks with users"""
|
||||
cleaned_count = 0
|
||||
|
||||
@@ -235,14 +235,14 @@ def clean_data_names(data_collection):
|
||||
if hasattr(data, 'library') and data.library is not None:
|
||||
continue
|
||||
|
||||
base_name = get_base_name(data.name)
|
||||
base_name = RBST_DatRem_get_base_name(data.name)
|
||||
if base_name != data.name:
|
||||
data.name = base_name
|
||||
cleaned_count += 1
|
||||
|
||||
return cleaned_count
|
||||
|
||||
def remap_data_blocks(context, remap_images, remap_materials, remap_fonts, remap_worlds):
|
||||
def RBST_DatRem_remap_data_blocks(context, remap_images, remap_materials, remap_fonts, remap_worlds):
|
||||
"""Remap redundant data blocks to their base versions like Blender's Remap Users function, and clean up names."""
|
||||
remapped_count = 0
|
||||
cleaned_count = 0
|
||||
@@ -250,18 +250,18 @@ def remap_data_blocks(context, remap_images, remap_materials, remap_fonts, remap
|
||||
# Process images
|
||||
if remap_images:
|
||||
# First remap duplicates
|
||||
image_groups = find_data_groups(bpy.data.images)
|
||||
image_groups = RBST_DatRem_find_data_groups(bpy.data.images)
|
||||
for base_name, images in image_groups.items():
|
||||
# Skip excluded groups
|
||||
if f"images:{base_name}" in context.scene.excluded_remap_groups:
|
||||
continue
|
||||
|
||||
target_image = find_target_data(images)
|
||||
target_image = RBST_DatRem_find_target_data(images)
|
||||
|
||||
# Rename the target if it has a numbered suffix and is the youngest
|
||||
if get_base_name(target_image.name) != target_image.name:
|
||||
if RBST_DatRem_get_base_name(target_image.name) != target_image.name:
|
||||
try:
|
||||
target_image.name = get_base_name(target_image.name)
|
||||
target_image.name = RBST_DatRem_get_base_name(target_image.name)
|
||||
except AttributeError:
|
||||
# Skip if the target is linked and can't be renamed
|
||||
print(f"Warning: Cannot rename linked image {target_image.name}")
|
||||
@@ -323,23 +323,23 @@ def remap_data_blocks(context, remap_images, remap_materials, remap_fonts, remap
|
||||
# This matches Blender's Remap Users behavior
|
||||
|
||||
# Then clean up any remaining numbered suffixes
|
||||
cleaned_count += clean_data_names(bpy.data.images)
|
||||
cleaned_count += RBST_DatRem_clean_data_names(bpy.data.images)
|
||||
|
||||
# Process materials
|
||||
if remap_materials:
|
||||
# First remap duplicates
|
||||
material_groups = find_data_groups(bpy.data.materials)
|
||||
material_groups = RBST_DatRem_find_data_groups(bpy.data.materials)
|
||||
for base_name, materials in material_groups.items():
|
||||
# Skip excluded groups
|
||||
if f"materials:{base_name}" in context.scene.excluded_remap_groups:
|
||||
continue
|
||||
|
||||
target_material = find_target_data(materials)
|
||||
target_material = RBST_DatRem_find_target_data(materials)
|
||||
|
||||
# Rename the target if it has a numbered suffix and is the youngest
|
||||
if get_base_name(target_material.name) != target_material.name:
|
||||
if RBST_DatRem_get_base_name(target_material.name) != target_material.name:
|
||||
try:
|
||||
target_material.name = get_base_name(target_material.name)
|
||||
target_material.name = RBST_DatRem_get_base_name(target_material.name)
|
||||
except AttributeError:
|
||||
# Skip if the target is linked and can't be renamed
|
||||
print(f"Warning: Cannot rename linked material {target_material.name}")
|
||||
@@ -443,23 +443,23 @@ def remap_data_blocks(context, remap_images, remap_materials, remap_fonts, remap
|
||||
# This matches Blender's Remap Users behavior
|
||||
|
||||
# Then clean up any remaining numbered suffixes
|
||||
cleaned_count += clean_data_names(bpy.data.materials)
|
||||
cleaned_count += RBST_DatRem_clean_data_names(bpy.data.materials)
|
||||
|
||||
# Process fonts
|
||||
if remap_fonts:
|
||||
# First remap duplicates
|
||||
font_groups = find_data_groups(bpy.data.fonts)
|
||||
font_groups = RBST_DatRem_find_data_groups(bpy.data.fonts)
|
||||
for base_name, fonts in font_groups.items():
|
||||
# Skip excluded groups
|
||||
if f"fonts:{base_name}" in context.scene.excluded_remap_groups:
|
||||
continue
|
||||
|
||||
target_font = find_target_data(fonts)
|
||||
target_font = RBST_DatRem_find_target_data(fonts)
|
||||
|
||||
# Rename the target if it has a numbered suffix and is the youngest
|
||||
if get_base_name(target_font.name) != target_font.name:
|
||||
if RBST_DatRem_get_base_name(target_font.name) != target_font.name:
|
||||
try:
|
||||
target_font.name = get_base_name(target_font.name)
|
||||
target_font.name = RBST_DatRem_get_base_name(target_font.name)
|
||||
except AttributeError:
|
||||
# Skip if the target is linked and can't be renamed
|
||||
print(f"Warning: Cannot rename linked font {target_font.name}")
|
||||
@@ -519,23 +519,23 @@ def remap_data_blocks(context, remap_images, remap_materials, remap_fonts, remap
|
||||
# This matches Blender's Remap Users behavior
|
||||
|
||||
# Then clean up any remaining numbered suffixes
|
||||
cleaned_count += clean_data_names(bpy.data.fonts)
|
||||
cleaned_count += RBST_DatRem_clean_data_names(bpy.data.fonts)
|
||||
|
||||
# Process worlds
|
||||
if remap_worlds:
|
||||
# First remap duplicates
|
||||
world_groups = find_data_groups(bpy.data.worlds)
|
||||
world_groups = RBST_DatRem_find_data_groups(bpy.data.worlds)
|
||||
for base_name, worlds in world_groups.items():
|
||||
# Skip excluded groups
|
||||
if f"worlds:{base_name}" in context.scene.excluded_remap_groups:
|
||||
continue
|
||||
|
||||
target_world = find_target_data(worlds)
|
||||
target_world = RBST_DatRem_find_target_data(worlds)
|
||||
|
||||
# Rename the target if it has a numbered suffix and is the youngest
|
||||
if get_base_name(target_world.name) != target_world.name:
|
||||
if RBST_DatRem_get_base_name(target_world.name) != target_world.name:
|
||||
try:
|
||||
target_world.name = get_base_name(target_world.name)
|
||||
target_world.name = RBST_DatRem_get_base_name(target_world.name)
|
||||
except AttributeError:
|
||||
# Skip if the target is linked and can't be renamed
|
||||
print(f"Warning: Cannot rename linked world {target_world.name}")
|
||||
@@ -585,7 +585,7 @@ def remap_data_blocks(context, remap_images, remap_materials, remap_fonts, remap
|
||||
# This matches Blender's Remap Users behavior
|
||||
|
||||
# Then clean up any remaining numbered suffixes
|
||||
cleaned_count += clean_data_names(bpy.data.worlds)
|
||||
cleaned_count += RBST_DatRem_clean_data_names(bpy.data.worlds)
|
||||
|
||||
# Force an update of the dependency graph to ensure all users are properly updated
|
||||
if context.view_layer:
|
||||
@@ -593,7 +593,7 @@ def remap_data_blocks(context, remap_images, remap_materials, remap_fonts, remap
|
||||
|
||||
return remapped_count, cleaned_count
|
||||
|
||||
class DATAREMAP_OT_RemapData(bpy.types.Operator):
|
||||
class RBST_DatRem_OT_RemapData(bpy.types.Operator):
|
||||
"""Remap redundant data blocks to reduce duplicates"""
|
||||
bl_idname = "bst.bulk_data_remap"
|
||||
bl_label = "Remap Data"
|
||||
@@ -607,10 +607,10 @@ class DATAREMAP_OT_RemapData(bpy.types.Operator):
|
||||
remap_worlds = context.scene.dataremap_worlds
|
||||
|
||||
# Count duplicates before remapping (only for local datablocks)
|
||||
image_groups = find_data_groups(bpy.data.images) if remap_images else {}
|
||||
material_groups = find_data_groups(bpy.data.materials) if remap_materials else {}
|
||||
font_groups = find_data_groups(bpy.data.fonts) if remap_fonts else {}
|
||||
world_groups = find_data_groups(bpy.data.worlds) if remap_worlds else {}
|
||||
image_groups = RBST_DatRem_find_data_groups(bpy.data.images) if remap_images else {}
|
||||
material_groups = RBST_DatRem_find_data_groups(bpy.data.materials) if remap_materials else {}
|
||||
font_groups = RBST_DatRem_find_data_groups(bpy.data.fonts) if remap_fonts else {}
|
||||
world_groups = RBST_DatRem_find_data_groups(bpy.data.worlds) if remap_worlds else {}
|
||||
|
||||
total_duplicates = sum(len(group) - 1 for groups in [image_groups, material_groups, font_groups, world_groups] for group in groups.values())
|
||||
|
||||
@@ -620,29 +620,29 @@ class DATAREMAP_OT_RemapData(bpy.types.Operator):
|
||||
total_numbered += sum(1 for img in bpy.data.images
|
||||
if img.users > 0
|
||||
and not (hasattr(img, 'library') and img.library is not None)
|
||||
and get_base_name(img.name) != img.name)
|
||||
and RBST_DatRem_get_base_name(img.name) != img.name)
|
||||
if remap_materials:
|
||||
total_numbered += sum(1 for mat in bpy.data.materials
|
||||
if mat.users > 0
|
||||
and not (hasattr(mat, 'library') and mat.library is not None)
|
||||
and get_base_name(mat.name) != mat.name)
|
||||
and RBST_DatRem_get_base_name(mat.name) != mat.name)
|
||||
if remap_fonts:
|
||||
total_numbered += sum(1 for font in bpy.data.fonts
|
||||
if font.users > 0
|
||||
and not (hasattr(font, 'library') and font.library is not None)
|
||||
and get_base_name(font.name) != font.name)
|
||||
and RBST_DatRem_get_base_name(font.name) != font.name)
|
||||
if remap_worlds:
|
||||
total_numbered += sum(1 for world in bpy.data.worlds
|
||||
if world.users > 0
|
||||
and not (hasattr(world, 'library') and world.library is not None)
|
||||
and get_base_name(world.name) != world.name)
|
||||
and RBST_DatRem_get_base_name(world.name) != world.name)
|
||||
|
||||
if total_duplicates == 0 and total_numbered == 0:
|
||||
self.report({'INFO'}, "No local data blocks to process")
|
||||
return {'CANCELLED'}
|
||||
|
||||
# Perform the remapping and cleaning
|
||||
remapped_count, cleaned_count = remap_data_blocks(
|
||||
remapped_count, cleaned_count = RBST_DatRem_remap_data_blocks(
|
||||
context,
|
||||
remap_images,
|
||||
remap_materials,
|
||||
@@ -662,8 +662,80 @@ class DATAREMAP_OT_RemapData(bpy.types.Operator):
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
# Add a new operator for merging duplicates using data-block utilities
|
||||
class RBST_DatRem_OT_MergeDuplicatesWithDBU(bpy.types.Operator):
|
||||
"""Merge duplicates using data-block utilities addon for all supported data types"""
|
||||
bl_idname = "bst.merge_duplicates_dbu"
|
||||
bl_label = "Merge Duplicates (DBU)"
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
def execute(self, context):
|
||||
# Check if data-block utilities addon is installed
|
||||
if not hasattr(context.scene, 'dbu_similar_settings'):
|
||||
self.report({'ERROR'}, "Data-block utilities addon is not installed or enabled")
|
||||
return {'CANCELLED'}
|
||||
|
||||
# Data types to process in order
|
||||
data_types = ['NODETREE', 'MATERIAL', 'LIGHT', 'IMAGE', 'MESH']
|
||||
type_labels = {
|
||||
'NODETREE': 'Node Groups',
|
||||
'MATERIAL': 'Materials',
|
||||
'LIGHT': 'Lights',
|
||||
'IMAGE': 'Images',
|
||||
'MESH': 'Meshes'
|
||||
}
|
||||
|
||||
total_merged = 0
|
||||
processed_types = []
|
||||
|
||||
try:
|
||||
settings = context.scene.dbu_similar_settings
|
||||
|
||||
for id_type in data_types:
|
||||
# Set the id_type
|
||||
settings.id_type = id_type
|
||||
|
||||
# Find similar and duplicates
|
||||
try:
|
||||
bpy.ops.scene.dbu_find_similar_and_duplicates()
|
||||
except Exception as e:
|
||||
self.report({'WARNING'}, f"Failed to find duplicates for {type_labels[id_type]}: {str(e)}")
|
||||
continue
|
||||
|
||||
# Check if any duplicates were found
|
||||
if not settings.duplicates:
|
||||
continue
|
||||
|
||||
# Count items to be merged (each group has duplicates, so count items - groups)
|
||||
# Each group keeps one item, so we count (total items - number of groups)
|
||||
total_items = sum(len(group.group) for group in settings.duplicates)
|
||||
num_groups = len(settings.duplicates)
|
||||
items_to_remove = total_items - num_groups # One item per group is kept
|
||||
|
||||
# Merge duplicates
|
||||
try:
|
||||
bpy.ops.scene.dbu_merge_duplicates()
|
||||
total_merged += items_to_remove
|
||||
processed_types.append(f"{type_labels[id_type]} ({items_to_remove})")
|
||||
except Exception as e:
|
||||
self.report({'WARNING'}, f"Failed to merge duplicates for {type_labels[id_type]}: {str(e)}")
|
||||
continue
|
||||
|
||||
# Report results
|
||||
if total_merged > 0:
|
||||
types_str = ", ".join(processed_types)
|
||||
self.report({'INFO'}, f"Merged {total_merged} duplicate(s) across: {types_str}")
|
||||
else:
|
||||
self.report({'INFO'}, "No duplicates found to merge")
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
except Exception as e:
|
||||
self.report({'ERROR'}, f"Error during merge operation: {str(e)}")
|
||||
return {'CANCELLED'}
|
||||
|
||||
# Add a new operator for purging unused data
|
||||
class DATAREMAP_OT_PurgeUnused(bpy.types.Operator):
|
||||
class RBST_DatRem_OT_PurgeUnused(bpy.types.Operator):
|
||||
"""Purge all unused data-blocks from the file (equivalent to File > Clean Up > Purge Unused Data)"""
|
||||
bl_idname = "bst.purge_unused_data"
|
||||
bl_label = "Purge Unused Data"
|
||||
@@ -678,7 +750,7 @@ class DATAREMAP_OT_PurgeUnused(bpy.types.Operator):
|
||||
return {'FINISHED'}
|
||||
|
||||
# Add a new operator for toggling group exclusion
|
||||
class DATAREMAP_OT_ToggleGroupExclusion(bpy.types.Operator):
|
||||
class RBST_DatRem_OT_ToggleGroupExclusion(bpy.types.Operator):
|
||||
"""Toggle whether this group should be included in remapping"""
|
||||
bl_idname = "bst.toggle_group_exclusion"
|
||||
bl_label = "Toggle Group"
|
||||
@@ -712,7 +784,7 @@ class DATAREMAP_OT_ToggleGroupExclusion(bpy.types.Operator):
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
class DATAREMAP_OT_SelectAllGroups(bpy.types.Operator):
|
||||
class RBST_DatRem_OT_SelectAllGroups(bpy.types.Operator):
|
||||
"""Select or deselect all groups of a specific data type"""
|
||||
bl_idname = "bst.select_all_data_groups"
|
||||
bl_label = "Select All Groups"
|
||||
@@ -738,13 +810,13 @@ class DATAREMAP_OT_SelectAllGroups(bpy.types.Operator):
|
||||
# Get the appropriate data groups based on data_type
|
||||
data_groups = {}
|
||||
if self.data_type == "images":
|
||||
data_groups = find_data_groups(bpy.data.images)
|
||||
data_groups = RBST_DatRem_find_data_groups(bpy.data.images)
|
||||
elif self.data_type == "materials":
|
||||
data_groups = find_data_groups(bpy.data.materials)
|
||||
data_groups = RBST_DatRem_find_data_groups(bpy.data.materials)
|
||||
elif self.data_type == "fonts":
|
||||
data_groups = find_data_groups(bpy.data.fonts)
|
||||
data_groups = RBST_DatRem_find_data_groups(bpy.data.fonts)
|
||||
elif self.data_type == "worlds":
|
||||
data_groups = find_data_groups(bpy.data.worlds)
|
||||
data_groups = RBST_DatRem_find_data_groups(bpy.data.worlds)
|
||||
|
||||
# Process only groups with more than one item
|
||||
for base_name, items in data_groups.items():
|
||||
@@ -762,7 +834,7 @@ class DATAREMAP_OT_SelectAllGroups(bpy.types.Operator):
|
||||
return {'FINISHED'}
|
||||
|
||||
# Update the toggle group selection operator to handle shift-click range selection
|
||||
class DATAREMAP_OT_ToggleGroupSelection(bpy.types.Operator):
|
||||
class RBST_DatRem_OT_ToggleGroupSelection(bpy.types.Operator):
|
||||
"""Toggle whether this group should be included in remapping"""
|
||||
bl_idname = "bst.toggle_group_selection"
|
||||
bl_label = "Toggle Group Selection"
|
||||
@@ -803,13 +875,13 @@ class DATAREMAP_OT_ToggleGroupSelection(bpy.types.Operator):
|
||||
# Get all data groups for this data type
|
||||
data_groups = []
|
||||
if self.data_type == "images":
|
||||
data_groups = list(find_data_groups(bpy.data.images).keys())
|
||||
data_groups = list(RBST_DatRem_find_data_groups(bpy.data.images).keys())
|
||||
elif self.data_type == "materials":
|
||||
data_groups = list(find_data_groups(bpy.data.materials).keys())
|
||||
data_groups = list(RBST_DatRem_find_data_groups(bpy.data.materials).keys())
|
||||
elif self.data_type == "fonts":
|
||||
data_groups = list(find_data_groups(bpy.data.fonts).keys())
|
||||
data_groups = list(RBST_DatRem_find_data_groups(bpy.data.fonts).keys())
|
||||
elif self.data_type == "worlds":
|
||||
data_groups = list(find_data_groups(bpy.data.worlds).keys())
|
||||
data_groups = list(RBST_DatRem_find_data_groups(bpy.data.worlds).keys())
|
||||
|
||||
# Find the indices of the last clicked group and the current group
|
||||
try:
|
||||
@@ -869,7 +941,7 @@ class DATAREMAP_OT_ToggleGroupSelection(bpy.types.Operator):
|
||||
return {'FINISHED'}
|
||||
|
||||
# Add a custom draw function for checkboxes that supports drag selection
|
||||
def draw_drag_selectable_checkbox(layout, context, data_type, group_key):
|
||||
def RBST_DatRem_draw_drag_selectable_checkbox(layout, context, data_type, group_key):
|
||||
"""Draw a checkbox that supports drag selection"""
|
||||
# Create a unique key for this group
|
||||
key = f"{data_type}:{group_key}"
|
||||
@@ -885,7 +957,7 @@ def draw_drag_selectable_checkbox(layout, context, data_type, group_key):
|
||||
op.group_key = group_key
|
||||
op.data_type = data_type
|
||||
|
||||
def search_matches_group(group, search_string):
|
||||
def RBST_DatRem_search_matches_group(group, search_string):
|
||||
"""Check if search string matches group base name or any item in group"""
|
||||
if not search_string:
|
||||
return True
|
||||
@@ -901,7 +973,7 @@ def search_matches_group(group, search_string):
|
||||
return False
|
||||
|
||||
# Update the UI code to use the custom draw function
|
||||
def draw_data_duplicates(layout, context, data_type, data_groups):
|
||||
def RBST_DatRem_draw_data_duplicates(layout, context, data_type, data_groups):
|
||||
"""Draw the list of duplicate data items with drag-selectable checkboxes and click to rename"""
|
||||
box_dup = layout.box()
|
||||
|
||||
@@ -945,7 +1017,7 @@ def draw_data_duplicates(layout, context, data_type, data_groups):
|
||||
search_string = getattr(context.scene, search_prop_name)
|
||||
|
||||
if search_string:
|
||||
group_items = [group for group in group_items if search_matches_group(group, search_string)]
|
||||
group_items = [group for group in group_items if RBST_DatRem_search_matches_group(group, search_string)]
|
||||
|
||||
# Sort by selection if enabled
|
||||
sort_prop_name = f"dataremap_sort_{data_type}"
|
||||
@@ -965,7 +1037,7 @@ def draw_data_duplicates(layout, context, data_type, data_groups):
|
||||
row = box_dup.row()
|
||||
|
||||
# Add checkbox to include/exclude this group using the custom draw function
|
||||
draw_drag_selectable_checkbox(row, context, data_type, base_name)
|
||||
RBST_DatRem_draw_drag_selectable_checkbox(row, context, data_type, base_name)
|
||||
|
||||
# Add dropdown toggle
|
||||
group_key = f"{data_type}:{base_name}"
|
||||
@@ -979,7 +1051,7 @@ def draw_data_duplicates(layout, context, data_type, data_groups):
|
||||
exp_op.data_type = data_type
|
||||
|
||||
# Find the original data item (target)
|
||||
target_item = find_target_data(items)
|
||||
target_item = RBST_DatRem_find_target_data(items)
|
||||
|
||||
# Add icon based on data type
|
||||
if data_type == "images":
|
||||
@@ -1046,10 +1118,10 @@ def draw_data_duplicates(layout, context, data_type, data_groups):
|
||||
rename_op.data_type = data_type
|
||||
rename_op.old_name = item.name
|
||||
|
||||
class VIEW3D_PT_BulkDataRemap(bpy.types.Panel):
|
||||
class RBST_DatRem_PT_BulkDataRemap(bpy.types.Panel):
|
||||
"""Bulk Data Remap Panel"""
|
||||
bl_label = "Bulk Data Remap"
|
||||
bl_idname = "VIEW3D_PT_bulk_data_remap"
|
||||
bl_idname = "RBST_DatRem_PT_bulk_data_remap"
|
||||
bl_space_type = 'VIEW_3D'
|
||||
bl_region_type = 'UI'
|
||||
bl_category = 'Edit'
|
||||
@@ -1068,25 +1140,25 @@ class VIEW3D_PT_BulkDataRemap(bpy.types.Panel):
|
||||
linked_types = []
|
||||
linked_paths = set()
|
||||
|
||||
if context.scene.dataremap_images and has_linked_datablocks(bpy.data.images):
|
||||
if context.scene.dataremap_images and RBST_DatRem_has_linked_datablocks(bpy.data.images):
|
||||
linked_datablocks_found = True
|
||||
linked_types.append("images")
|
||||
linked_paths.update(get_linked_file_paths(bpy.data.images))
|
||||
linked_paths.update(RBST_DatRem_get_linked_file_paths(bpy.data.images))
|
||||
|
||||
if context.scene.dataremap_materials and has_linked_datablocks(bpy.data.materials):
|
||||
if context.scene.dataremap_materials and RBST_DatRem_has_linked_datablocks(bpy.data.materials):
|
||||
linked_datablocks_found = True
|
||||
linked_types.append("materials")
|
||||
linked_paths.update(get_linked_file_paths(bpy.data.materials))
|
||||
linked_paths.update(RBST_DatRem_get_linked_file_paths(bpy.data.materials))
|
||||
|
||||
if context.scene.dataremap_fonts and has_linked_datablocks(bpy.data.fonts):
|
||||
if context.scene.dataremap_fonts and RBST_DatRem_has_linked_datablocks(bpy.data.fonts):
|
||||
linked_datablocks_found = True
|
||||
linked_types.append("fonts")
|
||||
linked_paths.update(get_linked_file_paths(bpy.data.fonts))
|
||||
linked_paths.update(RBST_DatRem_get_linked_file_paths(bpy.data.fonts))
|
||||
|
||||
if context.scene.dataremap_worlds and has_linked_datablocks(bpy.data.worlds):
|
||||
if context.scene.dataremap_worlds and RBST_DatRem_has_linked_datablocks(bpy.data.worlds):
|
||||
linked_datablocks_found = True
|
||||
linked_types.append("worlds")
|
||||
linked_paths.update(get_linked_file_paths(bpy.data.worlds))
|
||||
linked_paths.update(RBST_DatRem_get_linked_file_paths(bpy.data.worlds))
|
||||
|
||||
# Display warning about linked datablocks in a separate section if found
|
||||
if linked_datablocks_found:
|
||||
@@ -1116,20 +1188,20 @@ class VIEW3D_PT_BulkDataRemap(bpy.types.Panel):
|
||||
col = box.column(align=True)
|
||||
|
||||
# Count duplicates and numbered suffixes for each type
|
||||
image_groups = find_data_groups(bpy.data.images)
|
||||
material_groups = find_data_groups(bpy.data.materials)
|
||||
font_groups = find_data_groups(bpy.data.fonts)
|
||||
world_groups = find_data_groups(bpy.data.worlds)
|
||||
image_groups = RBST_DatRem_find_data_groups(bpy.data.images)
|
||||
material_groups = RBST_DatRem_find_data_groups(bpy.data.materials)
|
||||
font_groups = RBST_DatRem_find_data_groups(bpy.data.fonts)
|
||||
world_groups = RBST_DatRem_find_data_groups(bpy.data.worlds)
|
||||
|
||||
image_duplicates = sum(len(group) - 1 for group in image_groups.values())
|
||||
material_duplicates = sum(len(group) - 1 for group in material_groups.values())
|
||||
font_duplicates = sum(len(group) - 1 for group in font_groups.values())
|
||||
world_duplicates = sum(len(group) - 1 for group in world_groups.values())
|
||||
|
||||
image_numbered = sum(1 for img in bpy.data.images if img.users > 0 and get_base_name(img.name) != img.name)
|
||||
material_numbered = sum(1 for mat in bpy.data.materials if mat.users > 0 and get_base_name(mat.name) != mat.name)
|
||||
font_numbered = sum(1 for font in bpy.data.fonts if font.users > 0 and get_base_name(font.name) != font.name)
|
||||
world_numbered = sum(1 for world in bpy.data.worlds if world.users > 0 and get_base_name(world.name) != world.name)
|
||||
image_numbered = sum(1 for img in bpy.data.images if img.users > 0 and RBST_DatRem_get_base_name(img.name) != img.name)
|
||||
material_numbered = sum(1 for mat in bpy.data.materials if mat.users > 0 and RBST_DatRem_get_base_name(mat.name) != mat.name)
|
||||
font_numbered = sum(1 for font in bpy.data.fonts if font.users > 0 and RBST_DatRem_get_base_name(font.name) != font.name)
|
||||
world_numbered = sum(1 for world in bpy.data.worlds if world.users > 0 and RBST_DatRem_get_base_name(world.name) != world.name)
|
||||
|
||||
# Initialize excluded_remap_groups if it doesn't exist
|
||||
if not hasattr(context.scene, "excluded_remap_groups"):
|
||||
@@ -1165,7 +1237,7 @@ class VIEW3D_PT_BulkDataRemap(bpy.types.Panel):
|
||||
|
||||
# Show image duplicates if enabled
|
||||
if context.scene.show_image_duplicates and image_duplicates > 0 and context.scene.dataremap_images:
|
||||
draw_data_duplicates(col, context, "images", image_groups)
|
||||
RBST_DatRem_draw_data_duplicates(col, context, "images", image_groups)
|
||||
|
||||
# Materials
|
||||
row = col.row()
|
||||
@@ -1196,7 +1268,7 @@ class VIEW3D_PT_BulkDataRemap(bpy.types.Panel):
|
||||
|
||||
# Show material duplicates if enabled
|
||||
if context.scene.show_material_duplicates and material_duplicates > 0 and context.scene.dataremap_materials:
|
||||
draw_data_duplicates(col, context, "materials", material_groups)
|
||||
RBST_DatRem_draw_data_duplicates(col, context, "materials", material_groups)
|
||||
|
||||
# Fonts
|
||||
row = col.row()
|
||||
@@ -1227,7 +1299,7 @@ class VIEW3D_PT_BulkDataRemap(bpy.types.Panel):
|
||||
|
||||
# Show font duplicates if enabled
|
||||
if context.scene.show_font_duplicates and font_duplicates > 0 and context.scene.dataremap_fonts:
|
||||
draw_data_duplicates(col, context, "fonts", font_groups)
|
||||
RBST_DatRem_draw_data_duplicates(col, context, "fonts", font_groups)
|
||||
|
||||
# World
|
||||
row = col.row()
|
||||
@@ -1258,13 +1330,30 @@ class VIEW3D_PT_BulkDataRemap(bpy.types.Panel):
|
||||
|
||||
# Show world duplicates if enabled
|
||||
if context.scene.show_world_duplicates and world_duplicates > 0 and context.scene.dataremap_worlds:
|
||||
draw_data_duplicates(col, context, "worlds", world_groups)
|
||||
RBST_DatRem_draw_data_duplicates(col, context, "worlds", world_groups)
|
||||
|
||||
# Add the operator button
|
||||
row = box.row()
|
||||
row.scale_y = 1.5
|
||||
row.operator("bst.bulk_data_remap")
|
||||
|
||||
# Add Data-Block Utils Integration section
|
||||
box.separator()
|
||||
dbu_box = box.box()
|
||||
dbu_box.label(text="Data-Block Utils Integration")
|
||||
dbu_col = dbu_box.column()
|
||||
dbu_col.label(text="Merge duplicates using data-block utilities addon")
|
||||
dbu_col.label(text="Processes: Node Groups, Materials, Lights, Images, Meshes")
|
||||
|
||||
# Check if data-block utilities addon is available
|
||||
if hasattr(context.scene, 'dbu_similar_settings'):
|
||||
dbu_row = dbu_box.row()
|
||||
dbu_row.scale_y = 1.5
|
||||
dbu_row.operator("bst.merge_duplicates_dbu", text="Merge Duplicates (DBU)", icon='FILE_PARENT')
|
||||
else:
|
||||
dbu_box.alert = True
|
||||
dbu_box.label(text="Data-block utilities addon not installed", icon='ERROR')
|
||||
|
||||
# Show total counts
|
||||
total_duplicates = image_duplicates + material_duplicates + font_duplicates + world_duplicates
|
||||
total_numbered = image_numbered + material_numbered + font_numbered + world_numbered
|
||||
@@ -1311,7 +1400,7 @@ class VIEW3D_PT_BulkDataRemap(bpy.types.Panel):
|
||||
row.operator("bst.resync_enforce", text="Resync Enforce", icon='FILE_REFRESH')
|
||||
|
||||
# Add a new operator for toggling data types
|
||||
class DATAREMAP_OT_ToggleDataType(bpy.types.Operator):
|
||||
class RBST_DatRem_OT_ToggleDataType(bpy.types.Operator):
|
||||
"""Toggle whether this data type should be included in remapping"""
|
||||
bl_idname = "bst.toggle_data_type"
|
||||
bl_label = "Toggle Data Type"
|
||||
@@ -1336,7 +1425,7 @@ class DATAREMAP_OT_ToggleDataType(bpy.types.Operator):
|
||||
return {'FINISHED'}
|
||||
|
||||
# Add a new operator for toggling group expansion
|
||||
class DATAREMAP_OT_ToggleGroupExpansion(bpy.types.Operator):
|
||||
class RBST_DatRem_OT_ToggleGroupExpansion(bpy.types.Operator):
|
||||
"""Toggle whether this group should be expanded to show details"""
|
||||
bl_idname = "bst.toggle_group_expansion"
|
||||
bl_label = "Toggle Group Expansion"
|
||||
@@ -1371,7 +1460,7 @@ class DATAREMAP_OT_ToggleGroupExpansion(bpy.types.Operator):
|
||||
return {'FINISHED'}
|
||||
|
||||
# Function to get unique linked file paths from datablocks
|
||||
def get_linked_file_paths(data_collection):
|
||||
def RBST_DatRem_get_linked_file_paths(data_collection):
|
||||
"""Get unique file paths of linked libraries from datablocks"""
|
||||
linked_paths = set()
|
||||
|
||||
@@ -1382,7 +1471,7 @@ def get_linked_file_paths(data_collection):
|
||||
|
||||
return linked_paths
|
||||
|
||||
class DATAREMAP_OT_OpenLinkedFile(bpy.types.Operator):
|
||||
class RBST_DatRem_OT_OpenLinkedFile(bpy.types.Operator):
|
||||
"""Open the linked file in a new Blender instance"""
|
||||
bl_idname = "bst.open_linked_file"
|
||||
bl_label = "Open Linked File"
|
||||
@@ -1411,7 +1500,7 @@ class DATAREMAP_OT_OpenLinkedFile(bpy.types.Operator):
|
||||
return {'FINISHED'}
|
||||
|
||||
# Add a new operator for renaming datablocks
|
||||
class DATAREMAP_OT_RenameDatablock(bpy.types.Operator):
|
||||
class RBST_DatRem_OT_RenameDatablock(bpy.types.Operator):
|
||||
"""Click to rename datablock"""
|
||||
bl_idname = "bst.rename_datablock_remap"
|
||||
bl_label = "Rename Datablock"
|
||||
@@ -1483,21 +1572,22 @@ class DATAREMAP_OT_RenameDatablock(bpy.types.Operator):
|
||||
|
||||
# List of all classes in this module
|
||||
classes = (
|
||||
DATAREMAP_OT_RemapData,
|
||||
DATAREMAP_OT_PurgeUnused,
|
||||
DATAREMAP_OT_ToggleDataType,
|
||||
DATAREMAP_OT_ToggleGroupExclusion,
|
||||
DATAREMAP_OT_SelectAllGroups,
|
||||
VIEW3D_PT_BulkDataRemap,
|
||||
DATAREMAP_OT_ToggleGroupExpansion,
|
||||
DATAREMAP_OT_ToggleGroupSelection,
|
||||
DATAREMAP_OT_OpenLinkedFile,
|
||||
DATAREMAP_OT_RenameDatablock,
|
||||
RBST_DatRem_OT_RemapData,
|
||||
RBST_DatRem_OT_MergeDuplicatesWithDBU,
|
||||
RBST_DatRem_OT_PurgeUnused,
|
||||
RBST_DatRem_OT_ToggleDataType,
|
||||
RBST_DatRem_OT_ToggleGroupExclusion,
|
||||
RBST_DatRem_OT_SelectAllGroups,
|
||||
RBST_DatRem_PT_BulkDataRemap,
|
||||
RBST_DatRem_OT_ToggleGroupExpansion,
|
||||
RBST_DatRem_OT_ToggleGroupSelection,
|
||||
RBST_DatRem_OT_OpenLinkedFile,
|
||||
RBST_DatRem_OT_RenameDatablock,
|
||||
)
|
||||
|
||||
# Registration
|
||||
def register():
|
||||
register_dataremap_properties()
|
||||
RBST_DatRem_register_properties()
|
||||
|
||||
for cls in classes:
|
||||
compat.safe_register_class(cls)
|
||||
@@ -1507,6 +1597,6 @@ def unregister():
|
||||
compat.safe_unregister_class(cls)
|
||||
# Unregister properties
|
||||
try:
|
||||
unregister_dataremap_properties()
|
||||
RBST_DatRem_unregister_properties()
|
||||
except Exception:
|
||||
pass
|
||||
+52
-52
@@ -5,7 +5,7 @@ import os
|
||||
import re
|
||||
from ..utils import compat
|
||||
|
||||
class REMOVE_EXT_OT_summary_dialog(bpy.types.Operator):
|
||||
class RBST_PathMan_OT_summary_dialog(bpy.types.Operator):
|
||||
"""Show remove extensions operation summary"""
|
||||
bl_idname = "remove_ext.summary_dialog"
|
||||
bl_label = "Remove Extensions Summary"
|
||||
@@ -204,7 +204,7 @@ def bulk_remap_paths(mapping_dict):
|
||||
return (success_count, failed_list)
|
||||
|
||||
# Properties for path management
|
||||
class BST_PathProperties(PropertyGroup):
|
||||
class RBST_PathMan_PG_PathProperties(PropertyGroup):
|
||||
# Active image pointer
|
||||
active_image: PointerProperty(
|
||||
name="Image",
|
||||
@@ -328,7 +328,7 @@ class BST_PathProperties(PropertyGroup):
|
||||
)
|
||||
|
||||
# Operator to remap a single datablock path
|
||||
class BST_OT_remap_path(Operator):
|
||||
class RBST_PathMan_OT_remap_path(Operator):
|
||||
bl_idname = "bst.remap_path"
|
||||
bl_label = "Remap Path"
|
||||
bl_description = "Change the filepath and filepath_raw for a datablock"
|
||||
@@ -374,7 +374,7 @@ class BST_OT_remap_path(Operator):
|
||||
return context.window_manager.invoke_props_dialog(self)
|
||||
|
||||
# Operator to toggle all image selections
|
||||
class BST_OT_toggle_select_all(Operator):
|
||||
class RBST_PathMan_OT_toggle_select_all(Operator):
|
||||
bl_idname = "bst.toggle_select_all"
|
||||
bl_label = "Toggle All"
|
||||
bl_description = "Toggle selection of all datablocks"
|
||||
@@ -394,7 +394,7 @@ class BST_OT_toggle_select_all(Operator):
|
||||
return {'FINISHED'}
|
||||
|
||||
# Operator to remap multiple paths at once
|
||||
class BST_OT_bulk_remap(Operator):
|
||||
class RBST_PathMan_OT_bulk_remap(Operator):
|
||||
bl_idname = "bst.bulk_remap"
|
||||
bl_label = "Remap Paths"
|
||||
bl_description = "Apply the new path to all selected datablocks"
|
||||
@@ -493,7 +493,7 @@ class BST_OT_bulk_remap(Operator):
|
||||
return 0.05 # Process next item in 0.05 seconds (50ms) for better stability
|
||||
|
||||
# Operator to toggle path editing mode
|
||||
class BST_OT_toggle_path_edit(Operator):
|
||||
class RBST_PathMan_OT_toggle_path_edit(Operator):
|
||||
bl_idname = "bst.toggle_path_edit"
|
||||
bl_label = "Toggle Path Edit"
|
||||
bl_description = "Toggle between view and edit mode for paths"
|
||||
@@ -535,7 +535,7 @@ class BST_OT_toggle_path_edit(Operator):
|
||||
return {'FINISHED'}
|
||||
|
||||
# Operator to select all images used in the current material
|
||||
class BST_OT_select_material_images(Operator):
|
||||
class RBST_PathMan_OT_select_material_images(Operator):
|
||||
bl_idname = "bst.select_material_images"
|
||||
bl_label = "Select Material Images"
|
||||
bl_description = "Select all images used in the current material in the node editor"
|
||||
@@ -569,7 +569,7 @@ class BST_OT_select_material_images(Operator):
|
||||
return {'FINISHED'}
|
||||
|
||||
# Operator to select active/selected image texture nodes
|
||||
class BST_OT_select_active_images(Operator):
|
||||
class RBST_PathMan_OT_select_active_images(Operator):
|
||||
bl_idname = "bst.select_active_images"
|
||||
bl_label = "Select Active Images"
|
||||
bl_description = "Select all images from currently selected texture nodes in the node editor"
|
||||
@@ -603,7 +603,7 @@ class BST_OT_select_active_images(Operator):
|
||||
return {'FINISHED'}
|
||||
|
||||
# Operator to select all images with absolute paths
|
||||
class BST_OT_select_absolute_images(Operator):
|
||||
class RBST_PathMan_OT_select_absolute_images(Operator):
|
||||
bl_idname = "bst.select_absolute_images"
|
||||
bl_label = "Select Absolute Images"
|
||||
bl_description = "Select all images with absolute file paths"
|
||||
@@ -658,7 +658,7 @@ class BST_OT_select_absolute_images(Operator):
|
||||
return {'FINISHED'}
|
||||
|
||||
# Add a class for renaming datablocks
|
||||
class BST_OT_rename_datablock(Operator):
|
||||
class RBST_PathMan_OT_rename_datablock(Operator):
|
||||
"""Click to rename datablock"""
|
||||
bl_idname = "bst.rename_datablock"
|
||||
bl_label = "Rename Datablock"
|
||||
@@ -706,7 +706,7 @@ class BST_OT_rename_datablock(Operator):
|
||||
return {'CANCELLED'}
|
||||
|
||||
# Update class for shift+click selection
|
||||
class BST_OT_toggle_image_selection(Operator):
|
||||
class RBST_PathMan_OT_toggle_image_selection(Operator):
|
||||
"""Toggle whether this image should be included in bulk operations"""
|
||||
bl_idname = "bst.toggle_image_selection"
|
||||
bl_label = "Toggle Image Selection"
|
||||
@@ -759,7 +759,7 @@ class BST_OT_toggle_image_selection(Operator):
|
||||
return {'FINISHED'}
|
||||
|
||||
# Add new operator for reusing material name in path
|
||||
class BST_OT_reuse_material_path(Operator):
|
||||
class RBST_PathMan_OT_reuse_material_path(Operator):
|
||||
"""Use the active material's name in the path"""
|
||||
bl_idname = "bst.reuse_material_path"
|
||||
bl_label = "Use Material Path"
|
||||
@@ -793,7 +793,7 @@ class BST_OT_reuse_material_path(Operator):
|
||||
return {'CANCELLED'}
|
||||
|
||||
# Add new operator for reusing blend file name in path
|
||||
class BST_OT_reuse_blend_name(Operator):
|
||||
class RBST_PathMan_OT_reuse_blend_name(Operator):
|
||||
"""Use the current blend file name in the path"""
|
||||
bl_idname = "bst.reuse_blend_name"
|
||||
bl_label = "Use Blend Name"
|
||||
@@ -817,7 +817,7 @@ class BST_OT_reuse_blend_name(Operator):
|
||||
return {'CANCELLED'}
|
||||
|
||||
# Make Paths Relative Operator
|
||||
class BST_OT_make_paths_relative(Operator):
|
||||
class RBST_PathMan_OT_make_paths_relative(Operator):
|
||||
bl_idname = "bst.make_paths_relative"
|
||||
bl_label = "Make Paths Relative"
|
||||
bl_description = "Convert absolute paths to relative paths for all datablocks"
|
||||
@@ -829,7 +829,7 @@ class BST_OT_make_paths_relative(Operator):
|
||||
return {'FINISHED'}
|
||||
|
||||
# Make Paths Absolute Operator
|
||||
class BST_OT_make_paths_absolute(Operator):
|
||||
class RBST_PathMan_OT_make_paths_absolute(Operator):
|
||||
bl_idname = "bst.make_paths_absolute"
|
||||
bl_label = "Make Paths Absolute"
|
||||
bl_description = "Convert relative paths to absolute paths for all datablocks"
|
||||
@@ -841,7 +841,7 @@ class BST_OT_make_paths_absolute(Operator):
|
||||
return {'FINISHED'}
|
||||
|
||||
# Pack Images Operator
|
||||
class BST_OT_pack_images(Operator):
|
||||
class RBST_PathMan_OT_pack_images(Operator):
|
||||
bl_idname = "bst.pack_images"
|
||||
bl_label = "Pack Images"
|
||||
bl_description = "Pack selected images into the .blend file"
|
||||
@@ -883,7 +883,7 @@ class BST_OT_pack_images(Operator):
|
||||
return {'FINISHED'}
|
||||
|
||||
# Unpack Images Operator
|
||||
class BST_OT_unpack_images(Operator):
|
||||
class RBST_PathMan_OT_unpack_images(Operator):
|
||||
bl_idname = "bst.unpack_images"
|
||||
bl_label = "Unpack Images (Use Local)"
|
||||
bl_description = "Unpack selected images to their file paths using the 'USE_LOCAL' option"
|
||||
@@ -918,7 +918,7 @@ class BST_OT_unpack_images(Operator):
|
||||
return {'FINISHED'}
|
||||
|
||||
# Remove Packed Images Operator
|
||||
class BST_OT_remove_packed_images(Operator):
|
||||
class RBST_PathMan_OT_remove_packed_images(Operator):
|
||||
bl_idname = "bst.remove_packed_images"
|
||||
bl_label = "Remove Packed Data"
|
||||
bl_description = "Remove packed image data without saving to disk"
|
||||
@@ -953,7 +953,7 @@ class BST_OT_remove_packed_images(Operator):
|
||||
return {'FINISHED'}
|
||||
|
||||
# Save All Images Operator
|
||||
class BST_OT_save_all_images(Operator):
|
||||
class RBST_PathMan_OT_save_all_images(Operator):
|
||||
bl_idname = "bst.save_all_images"
|
||||
bl_label = "Save All Images"
|
||||
bl_description = "Save all selected images to image paths"
|
||||
@@ -1059,7 +1059,7 @@ class BST_OT_save_all_images(Operator):
|
||||
return 0.05 # Process next item in 0.05 seconds (50ms) for better stability
|
||||
|
||||
# Remove Extensions Operator
|
||||
class BST_OT_remove_extensions(Operator):
|
||||
class RBST_PathMan_OT_remove_extensions(Operator):
|
||||
bl_idname = "bst.remove_extensions"
|
||||
bl_label = "Remove Extensions"
|
||||
bl_description = "Remove common file extensions from selected image datablock names."
|
||||
@@ -1148,7 +1148,7 @@ class BST_OT_remove_extensions(Operator):
|
||||
removal_details=details_text.strip())
|
||||
|
||||
# Add new operator for flat color texture renaming
|
||||
class BST_OT_rename_flat_colors(Operator):
|
||||
class RBST_PathMan_OT_rename_flat_colors(Operator):
|
||||
"""Rename flat color textures to their hex color values"""
|
||||
bl_idname = "bst.rename_flat_colors"
|
||||
bl_label = "Rename Flat Colors"
|
||||
@@ -1354,7 +1354,7 @@ class BST_OT_rename_flat_colors(Operator):
|
||||
return 0.05 # Process next item in 0.05 seconds (50ms) for better stability
|
||||
|
||||
# Cancel Operation Operator
|
||||
class BST_OT_cancel_operation(Operator):
|
||||
class RBST_PathMan_OT_cancel_operation(Operator):
|
||||
bl_idname = "bst.cancel_operation"
|
||||
bl_label = "Cancel Operation"
|
||||
bl_description = "Cancel the currently running operation"
|
||||
@@ -1433,9 +1433,9 @@ def get_combined_path(context, datablock_name, extension=""):
|
||||
return path + datablock_name + extension
|
||||
|
||||
# Panel for Shader Editor sidebar
|
||||
class NODE_PT_bulk_path_tools(Panel):
|
||||
class RBST_PathMan_PT_bulk_path_tools(Panel):
|
||||
bl_label = "Bulk Pathing"
|
||||
bl_idname = "NODE_PT_bulk_path_tools"
|
||||
bl_idname = "RBST_PathMan_PT_bulk_path_tools"
|
||||
bl_space_type = 'NODE_EDITOR'
|
||||
bl_region_type = 'UI'
|
||||
bl_category = 'Node'
|
||||
@@ -1643,9 +1643,9 @@ class NODE_PT_bulk_path_tools(Panel):
|
||||
box.label(text="No images in blend file")
|
||||
|
||||
# Sub-panel for existing Bulk Scene Tools
|
||||
class VIEW3D_PT_bulk_path_subpanel(Panel):
|
||||
class RBST_PathMan_PT_bulk_path_subpanel(Panel):
|
||||
bl_label = "Bulk Path Management"
|
||||
bl_idname = "VIEW3D_PT_bulk_path_subpanel"
|
||||
bl_idname = "RBST_PathMan_PT_bulk_path_subpanel"
|
||||
bl_space_type = 'VIEW_3D'
|
||||
bl_region_type = 'UI'
|
||||
bl_category = 'Edit'
|
||||
@@ -1655,34 +1655,34 @@ class VIEW3D_PT_bulk_path_subpanel(Panel):
|
||||
|
||||
def draw(self, context):
|
||||
# Use the same draw function as the NODE_EDITOR panel
|
||||
NODE_PT_bulk_path_tools.draw(self, context)
|
||||
RBST_PathMan_PT_bulk_path_tools.draw(self, context)
|
||||
|
||||
# Registration function for this module
|
||||
classes = (
|
||||
REMOVE_EXT_OT_summary_dialog,
|
||||
BST_PathProperties,
|
||||
BST_OT_remap_path,
|
||||
BST_OT_toggle_select_all,
|
||||
BST_OT_bulk_remap,
|
||||
BST_OT_toggle_path_edit,
|
||||
BST_OT_select_material_images,
|
||||
BST_OT_select_active_images,
|
||||
BST_OT_select_absolute_images,
|
||||
BST_OT_rename_datablock,
|
||||
BST_OT_toggle_image_selection,
|
||||
BST_OT_reuse_material_path,
|
||||
BST_OT_reuse_blend_name,
|
||||
BST_OT_make_paths_relative,
|
||||
BST_OT_make_paths_absolute,
|
||||
BST_OT_pack_images,
|
||||
BST_OT_unpack_images,
|
||||
BST_OT_remove_packed_images,
|
||||
BST_OT_save_all_images,
|
||||
BST_OT_remove_extensions,
|
||||
BST_OT_rename_flat_colors,
|
||||
BST_OT_cancel_operation,
|
||||
NODE_PT_bulk_path_tools,
|
||||
VIEW3D_PT_bulk_path_subpanel,
|
||||
RBST_PathMan_OT_summary_dialog,
|
||||
RBST_PathMan_PG_PathProperties,
|
||||
RBST_PathMan_OT_remap_path,
|
||||
RBST_PathMan_OT_toggle_select_all,
|
||||
RBST_PathMan_OT_bulk_remap,
|
||||
RBST_PathMan_OT_toggle_path_edit,
|
||||
RBST_PathMan_OT_select_material_images,
|
||||
RBST_PathMan_OT_select_active_images,
|
||||
RBST_PathMan_OT_select_absolute_images,
|
||||
RBST_PathMan_OT_rename_datablock,
|
||||
RBST_PathMan_OT_toggle_image_selection,
|
||||
RBST_PathMan_OT_reuse_material_path,
|
||||
RBST_PathMan_OT_reuse_blend_name,
|
||||
RBST_PathMan_OT_make_paths_relative,
|
||||
RBST_PathMan_OT_make_paths_absolute,
|
||||
RBST_PathMan_OT_pack_images,
|
||||
RBST_PathMan_OT_unpack_images,
|
||||
RBST_PathMan_OT_remove_packed_images,
|
||||
RBST_PathMan_OT_save_all_images,
|
||||
RBST_PathMan_OT_remove_extensions,
|
||||
RBST_PathMan_OT_rename_flat_colors,
|
||||
RBST_PathMan_OT_cancel_operation,
|
||||
RBST_PathMan_PT_bulk_path_tools,
|
||||
RBST_PathMan_PT_bulk_path_subpanel,
|
||||
)
|
||||
|
||||
def register():
|
||||
@@ -1690,7 +1690,7 @@ def register():
|
||||
compat.safe_register_class(cls)
|
||||
|
||||
# Register properties
|
||||
bpy.types.Scene.bst_path_props = PointerProperty(type=BST_PathProperties)
|
||||
bpy.types.Scene.bst_path_props = PointerProperty(type=RBST_PathMan_PG_PathProperties)
|
||||
|
||||
# Add custom property to images for selection
|
||||
bpy.types.Image.bst_selected = BoolProperty(
|
||||
|
||||
+8
-18
@@ -4,15 +4,15 @@ from ..ops.remove_custom_split_normals import RemoveCustomSplitNormals
|
||||
from ..ops.create_ortho_camera import CreateOrthoCamera
|
||||
from ..ops.spawn_scene_structure import SpawnSceneStructure
|
||||
from ..ops.delete_single_keyframe_actions import DeleteSingleKeyframeActions
|
||||
from ..ops.find_material_users import FindMaterialUsers, MATERIAL_USERS_OT_summary_dialog
|
||||
from ..ops.remove_unused_material_slots import RemoveUnusedMaterialSlots
|
||||
from ..ops.convert_relations_to_constraint import ConvertRelationsToConstraint
|
||||
from ..ops.white_world import WhiteWorld
|
||||
from ..utils import compat
|
||||
|
||||
class BulkSceneGeneral(bpy.types.Panel):
|
||||
class RBST_SceneGen_PT_BulkSceneGeneral(bpy.types.Panel):
|
||||
"""Bulk Scene General Panel"""
|
||||
bl_label = "Scene General"
|
||||
bl_idname = "VIEW3D_PT_bulk_scene_general"
|
||||
bl_idname = "RBST_SceneGen_PT_bulk_scene_general"
|
||||
bl_space_type = 'VIEW_3D'
|
||||
bl_region_type = 'UI'
|
||||
bl_category = 'Edit'
|
||||
@@ -28,6 +28,8 @@ class BulkSceneGeneral(bpy.types.Panel):
|
||||
row = box.row()
|
||||
row.scale_y = 1.2
|
||||
row.operator("bst.spawn_scene_structure", text="Spawn Scene Structure", icon='OUTLINER_COLLECTION')
|
||||
row = box.row(align=True)
|
||||
row.operator("bst.white_world", text="White World", icon='WORLD')
|
||||
|
||||
# Mesh section
|
||||
box = layout.box()
|
||||
@@ -49,8 +51,6 @@ class BulkSceneGeneral(bpy.types.Panel):
|
||||
box.label(text="Materials")
|
||||
row = box.row(align=True)
|
||||
row.operator("bst.remove_unused_material_slots", text="Remove Unused Material Slots", icon='MATERIAL')
|
||||
row = box.row(align=True)
|
||||
row.operator("bst.find_material_users", text="Find Material Users", icon='VIEWZOOM')
|
||||
|
||||
# Animation Data section
|
||||
box = layout.box()
|
||||
@@ -62,14 +62,13 @@ class BulkSceneGeneral(bpy.types.Panel):
|
||||
|
||||
# List of all classes in this module
|
||||
classes = (
|
||||
BulkSceneGeneral,
|
||||
RBST_SceneGen_PT_BulkSceneGeneral,
|
||||
NoSubdiv, # Add NoSubdiv operator class
|
||||
RemoveCustomSplitNormals,
|
||||
CreateOrthoCamera,
|
||||
SpawnSceneStructure,
|
||||
WhiteWorld,
|
||||
DeleteSingleKeyframeActions,
|
||||
FindMaterialUsers,
|
||||
MATERIAL_USERS_OT_summary_dialog,
|
||||
RemoveUnusedMaterialSlots,
|
||||
ConvertRelationsToConstraint,
|
||||
)
|
||||
@@ -84,19 +83,10 @@ def register():
|
||||
description="Apply only to selected objects",
|
||||
default=True
|
||||
)
|
||||
# Register temporary material property for Find Material Users operator
|
||||
bpy.types.Scene.bst_temp_material = bpy.props.PointerProperty(
|
||||
name="Temporary Material",
|
||||
description="Temporary material selection for Find Material Users operator",
|
||||
type=bpy.types.Material
|
||||
)
|
||||
|
||||
def unregister():
|
||||
for cls in reversed(classes):
|
||||
compat.safe_unregister_class(cls)
|
||||
# Unregister the window manager property
|
||||
if hasattr(bpy.types.WindowManager, "bst_no_subdiv_only_selected"):
|
||||
del bpy.types.WindowManager.bst_no_subdiv_only_selected
|
||||
# Unregister temporary material property
|
||||
if hasattr(bpy.types.Scene, "bst_temp_material"):
|
||||
del bpy.types.Scene.bst_temp_material
|
||||
del bpy.types.WindowManager.bst_no_subdiv_only_selected
|
||||
+153
-67
@@ -6,9 +6,10 @@ from enum import Enum
|
||||
import colorsys # Add colorsys for RGB to HSV conversion
|
||||
from ..ops.select_diffuse_nodes import select_diffuse_nodes # Import the specific function
|
||||
from ..utils import compat
|
||||
from ..utils import version
|
||||
|
||||
# Material processing status enum
|
||||
class MaterialStatus(Enum):
|
||||
class RBST_ViewDisp_MaterialStatus(Enum):
|
||||
PENDING = 0
|
||||
PROCESSING = 1
|
||||
COMPLETED = 2
|
||||
@@ -26,7 +27,7 @@ material_queue = []
|
||||
current_index = 0
|
||||
|
||||
# Scene properties for viewport display settings
|
||||
def register_viewport_properties():
|
||||
def RBST_ViewDisp_register_properties():
|
||||
bpy.types.Scene.viewport_colors_selected_only = bpy.props.BoolProperty( # type: ignore
|
||||
name="Selected Objects Only",
|
||||
description="Apply viewport colors only to materials in selected objects",
|
||||
@@ -104,7 +105,7 @@ def unregister_viewport_properties():
|
||||
del bpy.types.Scene.viewport_colors_show_advanced
|
||||
del bpy.types.Scene.show_material_results
|
||||
|
||||
class VIEWPORT_OT_SetViewportColors(bpy.types.Operator):
|
||||
class RBST_ViewDisp_OT_SetViewportColors(bpy.types.Operator):
|
||||
"""Set Viewport Display colors from BSDF base color or texture"""
|
||||
bl_idname = "bst.set_viewport_colors"
|
||||
bl_label = "Set Viewport Colors"
|
||||
@@ -252,11 +253,11 @@ class VIEWPORT_OT_SetViewportColors(bpy.types.Operator):
|
||||
failed_count = 0
|
||||
|
||||
for _, status in material_results.values():
|
||||
if status == MaterialStatus.PREVIEW_BASED:
|
||||
if status == RBST_ViewDisp_MaterialStatus.PREVIEW_BASED:
|
||||
preview_count += 1
|
||||
elif status == MaterialStatus.COMPLETED:
|
||||
elif status == RBST_ViewDisp_MaterialStatus.COMPLETED:
|
||||
node_count += 1
|
||||
elif status == MaterialStatus.FAILED:
|
||||
elif status == RBST_ViewDisp_MaterialStatus.FAILED:
|
||||
failed_count += 1
|
||||
|
||||
# Use a popup menu instead of self.report since this might be called from a timer
|
||||
@@ -268,7 +269,7 @@ class VIEWPORT_OT_SetViewportColors(bpy.types.Operator):
|
||||
bpy.context.window_manager.popup_menu(draw_popup, title="Processing Complete", icon='INFO')
|
||||
|
||||
|
||||
class VIEWPORT_OT_RefreshMaterialPreviews(bpy.types.Operator):
|
||||
class RBST_ViewDisp_OT_RefreshMaterialPreviews(bpy.types.Operator):
|
||||
"""Regenerate material previews to avoid stale thumbnails"""
|
||||
bl_idname = "bst.refresh_material_previews"
|
||||
bl_label = "Refresh Material Previews"
|
||||
@@ -276,22 +277,37 @@ class VIEWPORT_OT_RefreshMaterialPreviews(bpy.types.Operator):
|
||||
|
||||
def execute(self, context):
|
||||
forced_count = 0
|
||||
try:
|
||||
bpy.ops.wm.previews_clear()
|
||||
bpy.ops.wm.previews_batch_generate()
|
||||
bpy.ops.wm.previews_ensure()
|
||||
except Exception as exc:
|
||||
self.report({'WARNING'}, f"Pre-clearing previews failed: {exc}")
|
||||
|
||||
# Skip the preview clearing operators in Blender 5.0 - they can cause crashes
|
||||
# Instead, we'll just regenerate previews for each material individually
|
||||
# This is safer and avoids the EXCEPTION_ACCESS_VIOLATION crashes
|
||||
if not version.is_version_5_0():
|
||||
# Only use these operators in older Blender versions
|
||||
try:
|
||||
if hasattr(bpy.context, 'temp_override'):
|
||||
with bpy.context.temp_override():
|
||||
if hasattr(bpy.ops.wm, 'previews_clear'):
|
||||
bpy.ops.wm.previews_clear()
|
||||
else:
|
||||
if hasattr(bpy.ops.wm, 'previews_clear'):
|
||||
bpy.ops.wm.previews_clear()
|
||||
except Exception as exc:
|
||||
# These operations are optional - continue even if they fail
|
||||
print(f"BST preview refresh: Pre-clearing previews failed (non-fatal): {exc}")
|
||||
|
||||
temp_obj = self._create_preview_object(context)
|
||||
|
||||
if not temp_obj:
|
||||
self.report({'ERROR'}, "Failed to create preview object. Cannot refresh material previews.")
|
||||
return {'CANCELLED'}
|
||||
|
||||
try:
|
||||
for material in bpy.data.materials:
|
||||
if not material or material.is_grease_pencil:
|
||||
continue
|
||||
|
||||
try:
|
||||
self._force_preview(material, temp_obj)
|
||||
self._force_preview(material, temp_obj, context)
|
||||
forced_count += 1
|
||||
except Exception as exc:
|
||||
print(f"BST preview refresh: failed for {material.name}: {exc}")
|
||||
@@ -303,36 +319,106 @@ class VIEWPORT_OT_RefreshMaterialPreviews(bpy.types.Operator):
|
||||
return {'FINISHED'}
|
||||
|
||||
def _create_preview_object(self, context):
|
||||
mesh = bpy.data.meshes.new("BST_PreviewMesh")
|
||||
mesh.from_pydata(
|
||||
[(0, 0, 0), (1, 0, 0), (0, 1, 0), (0, 0, 1)],
|
||||
[],
|
||||
[(0, 1, 2), (0, 2, 3), (0, 3, 1), (1, 3, 2)]
|
||||
)
|
||||
obj = bpy.data.objects.new("BST_PreviewObject", mesh)
|
||||
obj.hide_viewport = True
|
||||
obj.hide_render = True
|
||||
context.scene.collection.objects.link(obj)
|
||||
return obj
|
||||
"""Create a temporary preview object for material preview generation."""
|
||||
try:
|
||||
mesh = bpy.data.meshes.new("RBST_PreviewMesh")
|
||||
mesh.from_pydata(
|
||||
[(0, 0, 0), (1, 0, 0), (0, 1, 0), (0, 0, 1)],
|
||||
[],
|
||||
[(0, 1, 2), (0, 2, 3), (0, 3, 1), (1, 3, 2)]
|
||||
)
|
||||
obj = bpy.data.objects.new("RBST_PreviewObject", mesh)
|
||||
obj.hide_viewport = True
|
||||
obj.hide_render = True
|
||||
|
||||
# Link to scene collection - may fail in some contexts
|
||||
try:
|
||||
context.scene.collection.objects.link(obj)
|
||||
except Exception:
|
||||
# Fallback: try master collection or view layer
|
||||
if hasattr(context, 'view_layer') and hasattr(context.view_layer, 'active_layer_collection'):
|
||||
context.view_layer.active_layer_collection.collection.objects.link(obj)
|
||||
elif hasattr(bpy.context, 'scene') and hasattr(bpy.context.scene, 'collection'):
|
||||
bpy.context.scene.collection.objects.link(obj)
|
||||
|
||||
return obj
|
||||
except Exception as exc:
|
||||
print(f"BST preview refresh: Failed to create preview object: {exc}")
|
||||
return None
|
||||
|
||||
def _cleanup_preview_object(self, obj):
|
||||
"""Safely cleanup the preview object with proper error handling."""
|
||||
if not obj:
|
||||
return
|
||||
mesh = obj.data
|
||||
bpy.data.objects.remove(obj, do_unlink=True)
|
||||
if mesh:
|
||||
bpy.data.meshes.remove(mesh, do_unlink=True)
|
||||
|
||||
try:
|
||||
# Clear any material assignments first to avoid dangling references
|
||||
if hasattr(obj, 'data') and obj.data and hasattr(obj.data, 'materials'):
|
||||
try:
|
||||
obj.data.materials.clear()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Get mesh reference before removing object
|
||||
mesh = obj.data if hasattr(obj, 'data') else None
|
||||
|
||||
# Remove object
|
||||
try:
|
||||
bpy.data.objects.remove(obj, do_unlink=True)
|
||||
except Exception as exc:
|
||||
print(f"BST preview refresh: Failed to remove preview object: {exc}")
|
||||
|
||||
# Remove mesh if it still exists
|
||||
if mesh and mesh.name in bpy.data.meshes:
|
||||
try:
|
||||
bpy.data.meshes.remove(mesh, do_unlink=True)
|
||||
except Exception as exc:
|
||||
print(f"BST preview refresh: Failed to remove preview mesh: {exc}")
|
||||
except Exception as exc:
|
||||
print(f"BST preview refresh: Error during cleanup: {exc}")
|
||||
|
||||
def _force_preview(self, material, temp_obj):
|
||||
if temp_obj.data.materials:
|
||||
temp_obj.data.materials[0] = material
|
||||
else:
|
||||
temp_obj.data.materials.append(material)
|
||||
material.preview_render_type = 'SPHERE'
|
||||
preview = material.preview_ensure()
|
||||
if preview:
|
||||
# Touch icon id to ensure generation
|
||||
_ = preview.icon_id
|
||||
def _force_preview(self, material, temp_obj, context):
|
||||
"""Force preview generation for a material with proper error handling."""
|
||||
try:
|
||||
# Assign material to temp object
|
||||
if temp_obj.data.materials:
|
||||
temp_obj.data.materials[0] = material
|
||||
else:
|
||||
temp_obj.data.materials.append(material)
|
||||
|
||||
# Set preview render type if available
|
||||
if hasattr(material, 'preview_render_type'):
|
||||
material.preview_render_type = 'SPHERE'
|
||||
|
||||
# Ensure preview exists - this may fail in some Blender versions
|
||||
# In Blender 5.0, accessing icon_id immediately after preview_ensure() can cause crashes
|
||||
if hasattr(material, 'preview_ensure'):
|
||||
try:
|
||||
preview = material.preview_ensure()
|
||||
# In Blender 4.2/4.5, accessing icon_id is safe and helps ensure generation
|
||||
# In Blender 5.0+, we skip this to avoid crashes
|
||||
if not version.is_version_5_0() and preview and hasattr(preview, 'icon_id'):
|
||||
try:
|
||||
_ = preview.icon_id
|
||||
except Exception:
|
||||
# If icon_id access fails, that's okay - preview_ensure() is enough
|
||||
pass
|
||||
except Exception as exc:
|
||||
# Preview generation may fail for some materials - this is acceptable
|
||||
print(f"BST preview refresh: Could not generate preview for {material.name}: {exc}")
|
||||
# Try alternative method: force update by accessing preview property
|
||||
if hasattr(material, 'preview'):
|
||||
try:
|
||||
_ = material.preview
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Clear material assignment to avoid keeping references
|
||||
if temp_obj.data.materials:
|
||||
temp_obj.data.materials.clear()
|
||||
except Exception as exc:
|
||||
# Re-raise to be caught by caller
|
||||
raise exc
|
||||
|
||||
|
||||
def correct_viewport_color(color):
|
||||
@@ -373,11 +459,11 @@ def process_material(material, use_vectorized=True):
|
||||
"""Process a material to determine its viewport color"""
|
||||
if not material:
|
||||
print(f"Material is None, using fallback color")
|
||||
return (1, 1, 1), MaterialStatus.PREVIEW_BASED
|
||||
return (1, 1, 1), RBST_ViewDisp_MaterialStatus.PREVIEW_BASED
|
||||
|
||||
if material.is_grease_pencil:
|
||||
print(f"Material {material.name}: is a grease pencil material, using fallback color")
|
||||
return (1, 1, 1), MaterialStatus.PREVIEW_BASED
|
||||
return (1, 1, 1), RBST_ViewDisp_MaterialStatus.PREVIEW_BASED
|
||||
|
||||
try:
|
||||
# Get color from material thumbnail
|
||||
@@ -393,14 +479,14 @@ def process_material(material, use_vectorized=True):
|
||||
corrected_color = correct_viewport_color(color)
|
||||
print(f"Material {material.name}: Corrected thumbnail color = {corrected_color}")
|
||||
|
||||
return corrected_color, MaterialStatus.PREVIEW_BASED
|
||||
return corrected_color, RBST_ViewDisp_MaterialStatus.PREVIEW_BASED
|
||||
else:
|
||||
print(f"Material {material.name}: Could not extract color from thumbnail, using fallback color")
|
||||
return (1, 1, 1), MaterialStatus.PREVIEW_BASED
|
||||
return (1, 1, 1), RBST_ViewDisp_MaterialStatus.PREVIEW_BASED
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing material {material.name}: {e}")
|
||||
return (1, 1, 1), MaterialStatus.FAILED
|
||||
return (1, 1, 1), RBST_ViewDisp_MaterialStatus.FAILED
|
||||
|
||||
def get_average_color(image, use_vectorized=True):
|
||||
"""Calculate the average color of an image"""
|
||||
@@ -719,38 +805,38 @@ def find_diffuse_texture(material):
|
||||
|
||||
def get_status_icon(status):
|
||||
"""Get the icon for a material status"""
|
||||
if status == MaterialStatus.PENDING:
|
||||
if status == RBST_ViewDisp_MaterialStatus.PENDING:
|
||||
return 'TRIA_RIGHT'
|
||||
elif status == MaterialStatus.PROCESSING:
|
||||
elif status == RBST_ViewDisp_MaterialStatus.PROCESSING:
|
||||
return 'SORTTIME'
|
||||
elif status == MaterialStatus.COMPLETED:
|
||||
elif status == RBST_ViewDisp_MaterialStatus.COMPLETED:
|
||||
return 'CHECKMARK'
|
||||
elif status == MaterialStatus.PREVIEW_BASED:
|
||||
elif status == RBST_ViewDisp_MaterialStatus.PREVIEW_BASED:
|
||||
return 'IMAGE_DATA'
|
||||
elif status == MaterialStatus.FAILED:
|
||||
elif status == RBST_ViewDisp_MaterialStatus.FAILED:
|
||||
return 'ERROR'
|
||||
else:
|
||||
return 'QUESTION'
|
||||
|
||||
def get_status_text(status):
|
||||
"""Get the text for a material status"""
|
||||
if status == MaterialStatus.PENDING:
|
||||
if status == RBST_ViewDisp_MaterialStatus.PENDING:
|
||||
return "Pending"
|
||||
elif status == MaterialStatus.PROCESSING:
|
||||
elif status == RBST_ViewDisp_MaterialStatus.PROCESSING:
|
||||
return "Processing"
|
||||
elif status == MaterialStatus.COMPLETED:
|
||||
elif status == RBST_ViewDisp_MaterialStatus.COMPLETED:
|
||||
return "Node-based"
|
||||
elif status == MaterialStatus.PREVIEW_BASED:
|
||||
elif status == RBST_ViewDisp_MaterialStatus.PREVIEW_BASED:
|
||||
return "Thumbnail-based"
|
||||
elif status == MaterialStatus.FAILED:
|
||||
elif status == RBST_ViewDisp_MaterialStatus.FAILED:
|
||||
return "Failed"
|
||||
else:
|
||||
return "Unknown"
|
||||
|
||||
class VIEW3D_PT_BulkViewportDisplay(bpy.types.Panel):
|
||||
class RBST_ViewDisp_PT_BulkViewportDisplay(bpy.types.Panel):
|
||||
"""Bulk Viewport Display Panel"""
|
||||
bl_label = "Bulk Viewport Display"
|
||||
bl_idname = "VIEW3D_PT_bulk_viewport_display"
|
||||
bl_idname = "RBST_ViewDisp_PT_bulk_viewport_display"
|
||||
bl_space_type = 'VIEW_3D'
|
||||
bl_region_type = 'UI'
|
||||
bl_category = 'Edit'
|
||||
@@ -827,9 +913,9 @@ class VIEW3D_PT_BulkViewportDisplay(bpy.types.Panel):
|
||||
color, status = material_results[material_name]
|
||||
|
||||
# Update counts
|
||||
if status == MaterialStatus.PREVIEW_BASED:
|
||||
if status == RBST_ViewDisp_MaterialStatus.PREVIEW_BASED:
|
||||
preview_count += 1
|
||||
elif status == MaterialStatus.FAILED:
|
||||
elif status == RBST_ViewDisp_MaterialStatus.FAILED:
|
||||
failed_count += 1
|
||||
|
||||
row = col.row(align=True)
|
||||
@@ -868,7 +954,7 @@ class VIEW3D_PT_BulkViewportDisplay(bpy.types.Panel):
|
||||
layout.separator()
|
||||
layout.operator("bst.select_diffuse_nodes", icon='NODE_TEXTURE')
|
||||
|
||||
class MATERIAL_OT_SelectInEditor(bpy.types.Operator):
|
||||
class RBST_ViewDisp_OT_SelectInEditor(bpy.types.Operator):
|
||||
"""Select this material in the editor"""
|
||||
bl_idname = "bst.select_in_editor"
|
||||
bl_label = "Select Material"
|
||||
@@ -989,7 +1075,7 @@ def get_color_from_preview(material, use_vectorized=True):
|
||||
else:
|
||||
return None
|
||||
|
||||
class VIEWPORT_OT_SelectDiffuseNodes(bpy.types.Operator):
|
||||
class RBST_ViewDisp_OT_SelectDiffuseNodes(bpy.types.Operator):
|
||||
bl_idname = "bst.select_diffuse_nodes"
|
||||
bl_label = "Set Texture Display"
|
||||
bl_description = "Select the most relevant diffuse/base color image texture node in each material"
|
||||
@@ -1005,11 +1091,11 @@ class VIEWPORT_OT_SelectDiffuseNodes(bpy.types.Operator):
|
||||
|
||||
# List of all classes in this module
|
||||
classes = (
|
||||
VIEWPORT_OT_SetViewportColors,
|
||||
VIEWPORT_OT_RefreshMaterialPreviews,
|
||||
VIEW3D_PT_BulkViewportDisplay,
|
||||
MATERIAL_OT_SelectInEditor,
|
||||
VIEWPORT_OT_SelectDiffuseNodes,
|
||||
RBST_ViewDisp_OT_SetViewportColors,
|
||||
RBST_ViewDisp_OT_RefreshMaterialPreviews,
|
||||
RBST_ViewDisp_PT_BulkViewportDisplay,
|
||||
RBST_ViewDisp_OT_SelectInEditor,
|
||||
RBST_ViewDisp_OT_SelectDiffuseNodes,
|
||||
)
|
||||
|
||||
# Registration
|
||||
@@ -1018,12 +1104,12 @@ def register():
|
||||
compat.safe_register_class(cls)
|
||||
|
||||
# Register properties
|
||||
register_viewport_properties()
|
||||
RBST_ViewDisp_register_properties()
|
||||
|
||||
def unregister():
|
||||
# Unregister properties
|
||||
try:
|
||||
unregister_viewport_properties()
|
||||
RBST_ViewDisp_unregister_properties()
|
||||
except Exception:
|
||||
pass
|
||||
# Unregister classes
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
## [v0.0.7] - 2026-02-12
|
||||
|
||||
### Fixed
|
||||
- Pack: enable autopack before pack_all; force-load images and run pack_all twice; pack remaining images so textures are embedded (fixes "Failed to create GPU texture from Blender image" when rendering headless).
|
||||
- Remap: print actual paths in warnings (not placeholders); normalized path lookup and reverse copy_map so library blend image paths resolve.
|
||||
- pack_linked: catch PermissionError on library path checks so inaccessible (e.g. NAS) libs don’t abort; remove missing/inaccessible library refs from blend before save.
|
||||
|
||||
---
|
||||
|
||||
## [v0.0.6] - 2026-01-27
|
||||
|
||||
### Fixed
|
||||
- Config import in `utils.compat`: use `from .. import config` (config is at addon root)
|
||||
- Output panel: no longer write to scene in draw(); Blender 5.0 forbids ID writes in draw; operators already fall back to prefs when output_path empty
|
||||
|
||||
---
|
||||
|
||||
## [v0.0.5] - 2026-01-30
|
||||
|
||||
### Added
|
||||
- Project size limit (GB) in Output panel: per-pack int (0 = no limit, default 2), max 32-bit int
|
||||
|
||||
### Fixed
|
||||
- USD/cache file paths remapped: `bpy.data.cache_files[].filepath` remapped to packed location; .usd/.usdc/.usda added to copy_map
|
||||
|
||||
---
|
||||
|
||||
## [v0.0.4] - 2026-01-27
|
||||
|
||||
### Added
|
||||
- ZIP pack: option to exclude video and audio files from archive
|
||||
- Default output path in preferences
|
||||
- NLA enable for animation layers (moved to UI panel; only runs on objects with anim layers)
|
||||
|
||||
### Fixed
|
||||
- Physics/point cache included in ZIP pack (robocopy fallback when Python copy fails on network paths)
|
||||
- Cache truncated to frame range (Blender bphys `name_frame_index` naming; safeguard if no files match)
|
||||
- External cache paths remapped to relative (cache dirs in copy_map; prefix matching in remap script)
|
||||
- Frame range applied only to top-level target blend, not dependent blends
|
||||
- Recursion issue in all three pack ops; send-current-blend path handling
|
||||
- Removed packed-suffix behavior
|
||||
|
||||
---
|
||||
|
||||
## [v0.0.3] - 2026-01-22
|
||||
|
||||
### Changed
|
||||
- **Removed all website functionality** per SheepIt developer request
|
||||
- Operators now save packed files to user-specified locations instead of uploading
|
||||
- All authentication and website interaction code has been removed
|
||||
- Users must manually upload and configure projects on the SheepIt website
|
||||
|
||||
---
|
||||
|
||||
## [v0.0.2] - 2026-01-22
|
||||
|
||||
### Fixed
|
||||
- Fixed Blender extension policy violations related to `batter.asset_usage` module import
|
||||
- Removed `sys.path` manipulation to comply with Blender extension policies
|
||||
- Changed from top-level module import to submodule import (registered as `ops._asset_usage`)
|
||||
- Fixed `dataclasses` `__module__` resolution issue when loading modules via `importlib`
|
||||
|
||||
### Internal
|
||||
- Refactored `batter.asset_usage` import to use `importlib` without violating extension policies
|
||||
- Module now properly registered in `sys.modules` as a submodule before execution
|
||||
|
||||
---
|
||||
|
||||
## [v0.0.1] - 2026-01-21
|
||||
|
||||
### Features
|
||||
- Initial release of SheepIt Project Submitter
|
||||
- Three submission workflows:
|
||||
- Submit Current: Direct submission of current blend file
|
||||
- Submit as ZIP: Automatic asset packing with ZIP archive creation
|
||||
- Submit as Packed Blend: Automatic asset packing directly into blend file
|
||||
- Frame range configuration (full range or custom)
|
||||
- Automatic asset packing for linked blend files, textures, images, and videos
|
||||
- Cache truncation to match selected frame range
|
||||
- Real-time progress tracking with cancellable operations
|
||||
- File size validation (2GB limit) with optimization suggestions
|
||||
- Automatic path remapping for all asset types
|
||||
- Missing file detection and reporting
|
||||
- Oversized file detection (>2GB linked files)
|
||||
- Automatic backup file cleanup (`.blend1` through `.blend32`)
|
||||
- Compressed blend file saves
|
||||
- Username/password authentication
|
||||
- Browser redirect to project configuration page after submission
|
||||
- Works with unsaved blend files (operates on in-memory state)
|
||||
|
||||
### Internal
|
||||
- Based on asset usage detection from Batter project
|
||||
- Modal operator architecture for responsive UI
|
||||
- Incremental packing system for large projects
|
||||
- Subprocess-based asset processing for stability
|
||||
- Comprehensive error handling and user feedback
|
||||
@@ -0,0 +1,53 @@
|
||||
# SheepIt Project Submitter
|
||||
|
||||
A Blender addon for packing projects for the SheepIt render farm with automatic asset packing and intelligent workflow management.
|
||||
|
||||
## Features
|
||||
|
||||
| Automatic Asset Packing | Frame Range Control | Multiple Packing Methods |
|
||||
|--|--|--|
|
||||
| Automatically packs all linked blend files, textures, images, and external assets into your project. Supports both ZIP and packed blend file workflows. | Configure custom frame ranges directly in Blender without saving your file. Frame ranges are automatically applied to packed files. | Pack as current blend file, packed ZIP archive, or packed blend file. Choose the method that best fits your project. |
|
||||
|
||||
| Cache Management | Size Validation | Progress Tracking |
|
||||
|--|--|--|
|
||||
| Automatically truncates cache files to match your selected frame range, reducing file sizes significantly. | Validates file sizes before packing (2GB limit) with helpful suggestions for optimization. | Real-time progress bars and status messages for all operations. All steps are cancellable. |
|
||||
|
||||
| Path Remapping | Missing File Detection | Error Reporting |
|
||||
|--|--|--|
|
||||
| Intelligently remaps all asset paths to work correctly on the render farm. Handles textures, images, videos, and linked blend files. | Detects and reports missing linked files and oversized files (>2GB) that cannot be packed. | Comprehensive error messages with actionable suggestions for resolving issues. |
|
||||
|
||||
### Additional Features:
|
||||
- Works with unsaved blend files (operates on in-memory state)
|
||||
- Automatic backup file cleanup (`.blend1` through `.blend32`)
|
||||
- Compressed blend file saves for optimal file sizes
|
||||
- File browser for selecting output location
|
||||
|
||||
## Installation
|
||||
|
||||
1. Download the latest release from [GitHub Releases](https://github.com/RaincloudTheDragon/sheepit-project-submitter/releases)
|
||||
2. In Blender, go to `Edit > Preferences > Add-ons`
|
||||
3. Click `Install...` and select the downloaded ZIP file
|
||||
4. Enable the addon by checking the box next to "SheepIt Project Submitter"
|
||||
|
||||
## Usage
|
||||
|
||||
1. **Set Frame Range**: In the Output properties panel, configure your frame range (full range or custom)
|
||||
2. **Pack Project**: Choose your packing method:
|
||||
- **Pack Current Blend**: Saves the current blend file with frame range applied
|
||||
- **Pack as ZIP**: Packs all assets and creates a ZIP archive (recommended for scenes with caches)
|
||||
- **Pack as Blend**: Packs all assets directly into the blend file
|
||||
3. **Select Output Location**: A file browser will open to select where to save the packed file
|
||||
4. **Upload Manually**: Upload the packed file to SheepIt via the website and configure your project settings
|
||||
|
||||
## Requirements
|
||||
|
||||
- Blender 3.0.0 or later
|
||||
|
||||
## License
|
||||
|
||||
GPL-3.0-or-later
|
||||
|
||||
## Links
|
||||
|
||||
- **GitHub Repository**: [https://github.com/RaincloudTheDragon/sheepit-project-submitter](https://github.com/RaincloudTheDragon/sheepit-project-submitter)
|
||||
- **SheepIt Render Farm**: [https://www.sheepit-renderfarm.com](https://www.sheepit-renderfarm.com)
|
||||
@@ -0,0 +1,188 @@
|
||||
"""
|
||||
SheepIt Project Submitter Addon
|
||||
|
||||
A Blender addon for submitting projects to SheepIt render farm with automatic asset packing.
|
||||
"""
|
||||
|
||||
import bpy
|
||||
from bpy.utils import register_class
|
||||
from .utils import compat
|
||||
from . import ops
|
||||
from . import ui
|
||||
from . import rainys_repo_bootstrap
|
||||
|
||||
|
||||
def _update_output_path(self, context):
|
||||
"""Update callback for output_path property - auto-populates from preferences if empty."""
|
||||
if not self.output_path:
|
||||
from .utils.compat import get_addon_prefs
|
||||
prefs = get_addon_prefs()
|
||||
if prefs and prefs.default_output_path:
|
||||
self.output_path = prefs.default_output_path
|
||||
|
||||
|
||||
# SheepIt Submit Settings Property Group
|
||||
class SHEEPIT_PG_submit_settings(bpy.types.PropertyGroup):
|
||||
"""Property group for storing submit settings."""
|
||||
|
||||
# Frame range mode
|
||||
frame_range_mode: bpy.props.EnumProperty(
|
||||
name="Frame Range Mode",
|
||||
description="Choose between full range or custom frame range",
|
||||
items=[
|
||||
('FULL', "Full Range", "Use the full frame range from scene settings"),
|
||||
('CUSTOM', "Custom", "Specify custom start, end, and step frames"),
|
||||
],
|
||||
default='FULL',
|
||||
)
|
||||
|
||||
# Custom frame range
|
||||
frame_start: bpy.props.IntProperty(
|
||||
name="Start Frame",
|
||||
description="Start frame for rendering",
|
||||
default=1,
|
||||
min=0,
|
||||
)
|
||||
|
||||
frame_end: bpy.props.IntProperty(
|
||||
name="End Frame",
|
||||
description="End frame for rendering",
|
||||
default=250,
|
||||
min=0,
|
||||
)
|
||||
|
||||
frame_step: bpy.props.IntProperty(
|
||||
name="Frame Step",
|
||||
description="Frame step (render every Nth frame)",
|
||||
default=1,
|
||||
min=1,
|
||||
)
|
||||
|
||||
# Compute method
|
||||
compute_method: bpy.props.EnumProperty(
|
||||
name="Compute Method",
|
||||
description="Choose CPU or GPU rendering",
|
||||
items=[
|
||||
('CPU', "CPU", "Use CPU for rendering"),
|
||||
('GPU', "GPU", "Use GPU for rendering"),
|
||||
],
|
||||
default='CPU',
|
||||
)
|
||||
|
||||
# Checkboxes
|
||||
renderable_by_all: bpy.props.BoolProperty(
|
||||
name="Renderable by all members",
|
||||
description="Allow all SheepIt members to render this project",
|
||||
default=True,
|
||||
)
|
||||
|
||||
generate_mp4: bpy.props.BoolProperty(
|
||||
name="Generate MP4 video",
|
||||
description="Generate MP4 video from rendered frames",
|
||||
default=False,
|
||||
)
|
||||
|
||||
# Advanced options
|
||||
memory_used_mb: bpy.props.StringProperty(
|
||||
name="Memory used (MB)",
|
||||
description="Memory limit in MB (leave empty for default)",
|
||||
default="",
|
||||
)
|
||||
|
||||
# Advanced options visibility
|
||||
show_advanced: bpy.props.BoolProperty(
|
||||
name="Show Advanced Options",
|
||||
description="Show advanced submission options",
|
||||
default=False,
|
||||
)
|
||||
|
||||
# ZIP pack: exclude video/audio files
|
||||
exclude_video_from_zip: bpy.props.BoolProperty(
|
||||
name="Exclude video/audio from ZIP",
|
||||
description="Exclude video and audio files (e.g. mp4, avi, mov, wav, mp3) from the ZIP pack",
|
||||
default=False,
|
||||
)
|
||||
|
||||
# Project size limit (GB); 0 = no limit (max 32-bit signed int for Blender C API)
|
||||
project_size_limit_gb: bpy.props.IntProperty(
|
||||
name="Project Size Limit (GB)",
|
||||
description="Maximum project/ZIP/blend size in GB (0 = no limit)",
|
||||
default=2,
|
||||
min=0,
|
||||
max=(1 << 31) - 1,
|
||||
)
|
||||
|
||||
# Pack output path (set by pack operators)
|
||||
pack_output_path: bpy.props.StringProperty(
|
||||
name="Pack Output Path",
|
||||
description="Path to the packed output directory",
|
||||
default="",
|
||||
)
|
||||
|
||||
output_file_path: bpy.props.StringProperty(
|
||||
name="Output File Path",
|
||||
description="Path where the packed file (ZIP or blend) will be saved",
|
||||
default="",
|
||||
subtype='FILE_PATH',
|
||||
)
|
||||
|
||||
output_path: bpy.props.StringProperty(
|
||||
name="Output Path",
|
||||
description="Directory path where packed files will be saved",
|
||||
default="",
|
||||
subtype='DIR_PATH',
|
||||
update=_update_output_path,
|
||||
)
|
||||
|
||||
# Progress tracking for submission operations
|
||||
is_submitting: bpy.props.BoolProperty(
|
||||
name="Is Submitting",
|
||||
description="Whether a submission is currently in progress",
|
||||
default=False,
|
||||
)
|
||||
|
||||
submit_progress: bpy.props.FloatProperty(
|
||||
name="Submit Progress",
|
||||
description="Progress percentage for submission operations",
|
||||
default=0.0,
|
||||
min=0.0,
|
||||
max=100.0,
|
||||
subtype='PERCENTAGE',
|
||||
)
|
||||
|
||||
submit_status_message: bpy.props.StringProperty(
|
||||
name="Submit Status Message",
|
||||
description="Current status message for submission operations",
|
||||
default="",
|
||||
)
|
||||
|
||||
|
||||
def register():
|
||||
"""Register the addon."""
|
||||
from .utils import compat
|
||||
|
||||
compat.safe_register_class(SHEEPIT_PG_submit_settings)
|
||||
bpy.types.Scene.sheepit_submit = bpy.props.PointerProperty(type=SHEEPIT_PG_submit_settings)
|
||||
|
||||
# Register operators and UI (preferences are registered in ui.register())
|
||||
ops.register()
|
||||
ui.register()
|
||||
|
||||
# Bootstrap Rainy's Extensions repository
|
||||
rainys_repo_bootstrap.register()
|
||||
|
||||
|
||||
def unregister():
|
||||
"""Unregister the addon."""
|
||||
from .utils import compat
|
||||
|
||||
# Bootstrap unregistration
|
||||
rainys_repo_bootstrap.unregister()
|
||||
|
||||
# Unregister operators and UI
|
||||
ui.unregister()
|
||||
ops.unregister()
|
||||
|
||||
compat.safe_unregister_class(SHEEPIT_PG_submit_settings)
|
||||
if hasattr(bpy.types.Scene, 'sheepit_submit'):
|
||||
del bpy.types.Scene.sheepit_submit
|
||||
@@ -0,0 +1,213 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import functools
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
import bpy
|
||||
from bpy.types import Library, ID
|
||||
|
||||
|
||||
def find() -> dict[Library | None, set[AssetUsage]]:
|
||||
"""Return a mapping from each blend file to the assets it uses.
|
||||
|
||||
The None key indicates the direct dependencies of the currently-open blend file.
|
||||
"""
|
||||
|
||||
_blend_asset_usage = find_blend_asset_usage()
|
||||
_nonblend_asset_usage = find_nonblend_asset_usage()
|
||||
return _merge_keys(_blend_asset_usage, _nonblend_asset_usage)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class AssetUsage:
|
||||
"""The usage of an asset by a specific blend file."""
|
||||
|
||||
abspath: Path
|
||||
"""Absolute path to the asset."""
|
||||
|
||||
# user: Path
|
||||
# """Absolute path of whatever blend file uses this Asset."""
|
||||
|
||||
reference_path: str
|
||||
"""The path by which this asset is referenced.
|
||||
|
||||
This is tracked so that the search & replace operation for path rewriting
|
||||
knows what to search for.
|
||||
|
||||
NOTE: the above may not be true, as paths to assets from a library blend
|
||||
file may already be rewritten by Blender upon loading; this process ensures
|
||||
that all relative paths are relative to the main blend file, and so they may
|
||||
not be the same in the library itself.
|
||||
"""
|
||||
|
||||
is_blendfile: bool
|
||||
"""Whether this asset is a blend file or not.
|
||||
|
||||
Blend files can refer to other assets.
|
||||
"""
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash((self.abspath, self.reference_path, self.is_blendfile))
|
||||
|
||||
def __eq__(self, value: object) -> bool:
|
||||
if not isinstance(value, AssetUsage):
|
||||
return False
|
||||
return (
|
||||
self.abspath,
|
||||
self.reference_path,
|
||||
self.is_blendfile,
|
||||
) == (
|
||||
value.abspath,
|
||||
value.reference_path,
|
||||
value.is_blendfile,
|
||||
)
|
||||
|
||||
|
||||
@functools.lru_cache
|
||||
def library_abspath(lib: Library | None) -> Path:
|
||||
"""Return the absolute path to the library.
|
||||
|
||||
lib=None returns the absolute path of the current blend file.
|
||||
"""
|
||||
if lib is None:
|
||||
filepath = bpy.data.filepath
|
||||
else:
|
||||
filepath = bpy.path.abspath(lib.filepath)
|
||||
return Path(filepath).resolve()
|
||||
|
||||
|
||||
def find_blend_asset_usage() -> dict[Library | None, set[AssetUsage]]:
|
||||
"""Return a mapping from each blend file to the blend files it uses as libraries."""
|
||||
|
||||
# Find all dependencies between libraries.
|
||||
# Keys: Library ID (or `None` for current blendfile)
|
||||
# Values: Libraries used by the key one.
|
||||
libs_deps: dict[Library | None, set[AssetUsage]] = defaultdict(set)
|
||||
for id, id_users in bpy.data.user_map().items():
|
||||
id_lib = id.library
|
||||
libs_deps.setdefault(id_lib, set())
|
||||
for id_user in id_users:
|
||||
if id_user.library == id_lib:
|
||||
continue
|
||||
|
||||
asset_usage = AssetUsage(
|
||||
abspath=library_abspath(id_lib),
|
||||
reference_path=id_lib.filepath,
|
||||
is_blendfile=True,
|
||||
)
|
||||
|
||||
libs_deps[id_user.library].add(asset_usage)
|
||||
|
||||
return dict(libs_deps)
|
||||
|
||||
|
||||
def find_nonblend_asset_usage() -> dict[Library | None, set[AssetUsage]]:
|
||||
"""Return a mapping from a blend file to the non-blend asset files it uses."""
|
||||
|
||||
file_path_map = bpy.data.file_path_map(include_libraries=False)
|
||||
|
||||
asset_usages: dict[Library | None, set[AssetUsage]] = defaultdict(set)
|
||||
|
||||
for asset_user, filepaths in file_path_map.items():
|
||||
if not filepaths:
|
||||
continue
|
||||
if isinstance(asset_user, Library):
|
||||
continue
|
||||
|
||||
assert isinstance(asset_user, ID)
|
||||
|
||||
lib = asset_user.library
|
||||
if lib and lib.packed_file:
|
||||
raise RuntimeError(f"Batter does not support packed libraries (yet): {lib}")
|
||||
|
||||
for filepath in filepaths:
|
||||
abspath = Path(bpy.path.abspath(filepath, library=lib)).resolve()
|
||||
asset_usage = AssetUsage(
|
||||
abspath=abspath,
|
||||
reference_path=filepath,
|
||||
is_blendfile=False,
|
||||
)
|
||||
asset_usages[lib].add(asset_usage)
|
||||
|
||||
# Also check bpy.data.images directly, as file_path_map may miss some image references
|
||||
# (e.g., PNG, EXR files in certain node setups)
|
||||
for img in bpy.data.images:
|
||||
if img.packed_file:
|
||||
continue
|
||||
if not img.filepath:
|
||||
continue
|
||||
# Skip generated images and sequences/movies
|
||||
if getattr(img, 'source', 'FILE') not in {'FILE', 'TILED'}:
|
||||
continue
|
||||
|
||||
lib = getattr(img, 'library', None)
|
||||
if lib and lib.packed_file:
|
||||
raise RuntimeError(f"Batter does not support packed libraries (yet): {lib}")
|
||||
|
||||
try:
|
||||
abspath = Path(bpy.path.abspath(img.filepath, library=lib)).resolve()
|
||||
asset_usage = AssetUsage(
|
||||
abspath=abspath,
|
||||
reference_path=img.filepath,
|
||||
is_blendfile=False,
|
||||
)
|
||||
asset_usages[lib].add(asset_usage)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Also check sounds and movie clips for completeness
|
||||
for snd in getattr(bpy.data, 'sounds', []):
|
||||
if snd.packed_file:
|
||||
continue
|
||||
if not snd.filepath:
|
||||
continue
|
||||
|
||||
lib = getattr(snd, 'library', None)
|
||||
if lib and lib.packed_file:
|
||||
raise RuntimeError(f"Batter does not support packed libraries (yet): {lib}")
|
||||
|
||||
try:
|
||||
abspath = Path(bpy.path.abspath(snd.filepath, library=lib)).resolve()
|
||||
asset_usage = AssetUsage(
|
||||
abspath=abspath,
|
||||
reference_path=snd.filepath,
|
||||
is_blendfile=False,
|
||||
)
|
||||
asset_usages[lib].add(asset_usage)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for mc in getattr(bpy.data, 'movieclips', []):
|
||||
if not mc.filepath:
|
||||
continue
|
||||
|
||||
lib = getattr(mc, 'library', None)
|
||||
if lib and lib.packed_file:
|
||||
raise RuntimeError(f"Batter does not support packed libraries (yet): {lib}")
|
||||
|
||||
try:
|
||||
abspath = Path(bpy.path.abspath(mc.filepath, library=lib)).resolve()
|
||||
asset_usage = AssetUsage(
|
||||
abspath=abspath,
|
||||
reference_path=mc.filepath,
|
||||
is_blendfile=False,
|
||||
)
|
||||
asset_usages[lib].add(asset_usage)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return dict(asset_usages)
|
||||
|
||||
|
||||
def _merge_keys(
|
||||
a: dict[Library | None, set[AssetUsage]],
|
||||
b: dict[Library | None, set[AssetUsage]],
|
||||
) -> dict[Library | None, set[AssetUsage]]:
|
||||
merged = defaultdict(set)
|
||||
for key, values in a.items():
|
||||
merged[key].update(values)
|
||||
for key, values in b.items():
|
||||
merged[key].update(values)
|
||||
return merged
|
||||
@@ -0,0 +1,18 @@
|
||||
schema_version = "1.0.0"
|
||||
|
||||
id = "sheepit_project_submitter"
|
||||
name = "SheepIt Project Submitter"
|
||||
version = "0.0.7"
|
||||
type = "add-on"
|
||||
author = "RaincloudTheDragon"
|
||||
maintainer = "RaincloudTheDragon"
|
||||
blender_version_min = "3.0.0"
|
||||
license = ["GPL-3.0-or-later"]
|
||||
description = "Submit Blender projects to SheepIt render farm with automatic asset packing."
|
||||
homepage = "https://github.com/RaincloudTheDragon/sheepit-project-submitter/"
|
||||
tagline = "Submit projects to SheepIt render farm"
|
||||
|
||||
tags = ["render", "farm", "submission", "utility"]
|
||||
|
||||
# Python modules to load for this add-on
|
||||
modules = ["sheepit_project_submitter"]
|
||||
@@ -0,0 +1,20 @@
|
||||
"""
|
||||
Configuration constants for SheepIt Project Submitter addon.
|
||||
"""
|
||||
|
||||
# Addon metadata
|
||||
ADDON_NAME = "SheepIt Project Submitter"
|
||||
ADDON_ID = "sheepit_project_submitter"
|
||||
|
||||
# SheepIt API endpoints (to be researched and updated)
|
||||
SHEEPIT_API_BASE = "https://www.sheepit-renderfarm.com"
|
||||
SHEEPIT_CLIENT_BASE = "https://client.sheepit-renderfarm.com"
|
||||
|
||||
# Debug mode
|
||||
DEBUG = False
|
||||
|
||||
|
||||
def debug_print(message: str) -> None:
|
||||
"""Print debug message if DEBUG is enabled."""
|
||||
if DEBUG:
|
||||
print(f"[{ADDON_NAME}] {message}")
|
||||
@@ -0,0 +1,24 @@
|
||||
"""
|
||||
Operators for SheepIt Project Submitter addon.
|
||||
"""
|
||||
|
||||
|
||||
def register():
|
||||
"""Register all operators."""
|
||||
# Lazy imports - these are only executed when register() is called
|
||||
# This avoids circular import issues since imports happen at function call time
|
||||
from . import pack_ops
|
||||
from . import submit_ops
|
||||
|
||||
pack_ops.register()
|
||||
submit_ops.register()
|
||||
|
||||
|
||||
def unregister():
|
||||
"""Unregister all operators."""
|
||||
# Lazy imports - these are only executed when unregister() is called
|
||||
from . import pack_ops
|
||||
from . import submit_ops
|
||||
|
||||
submit_ops.unregister()
|
||||
pack_ops.unregister()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,460 @@
|
||||
"""
|
||||
Submission operations for SheepIt render farm.
|
||||
"""
|
||||
|
||||
import os
|
||||
import zipfile
|
||||
import tempfile
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import bpy
|
||||
from bpy.types import Operator
|
||||
|
||||
from .. import config
|
||||
|
||||
|
||||
def apply_frame_range_to_blend(blend_path: Path, frame_start: int, frame_end: int, frame_step: int) -> None:
|
||||
"""
|
||||
Apply frame range settings to a blend file using subprocess.
|
||||
|
||||
Args:
|
||||
blend_path: Path to the blend file to modify
|
||||
frame_start: Start frame value
|
||||
frame_end: End frame value
|
||||
frame_step: Frame step value
|
||||
"""
|
||||
script = f"""
|
||||
import bpy
|
||||
for scene in bpy.data.scenes:
|
||||
scene.frame_start = {frame_start}
|
||||
scene.frame_end = {frame_end}
|
||||
scene.frame_step = {frame_step}
|
||||
bpy.ops.wm.save_mainfile(compress=True)
|
||||
print(f'Applied frame range {frame_start}-{frame_end} (step {frame_step}) to all scenes')
|
||||
"""
|
||||
|
||||
result = subprocess.run([
|
||||
"blender", "--factory-startup", "-b", str(blend_path), "--python-expr", script
|
||||
], capture_output=True, text=True, check=False)
|
||||
|
||||
if result.returncode != 0:
|
||||
print(f"[SheepIt Submit] WARNING: Failed to apply frame range to {blend_path.name}")
|
||||
if result.stderr:
|
||||
print(f"[SheepIt Submit] Error: {result.stderr[:200]}")
|
||||
else:
|
||||
print(f"[SheepIt Submit] Applied frame range {frame_start}-{frame_end} (step {frame_step}) to {blend_path.name}")
|
||||
|
||||
|
||||
def save_current_blend_with_frame_range(submit_settings, temp_dir: Optional[Path] = None) -> Tuple[Path, int, int, int]:
|
||||
"""
|
||||
Save current blend state to a temporary file and apply frame range from submit_settings.
|
||||
|
||||
Args:
|
||||
submit_settings: Submit settings containing frame range configuration
|
||||
temp_dir: Optional temporary directory (if None, creates a new one)
|
||||
|
||||
Returns:
|
||||
Tuple of (temp_blend_path, frame_start, frame_end, frame_step)
|
||||
"""
|
||||
# Determine frame range from submit_settings
|
||||
if submit_settings.frame_range_mode == 'FULL':
|
||||
frame_start = bpy.context.scene.frame_start
|
||||
frame_end = bpy.context.scene.frame_end
|
||||
frame_step = bpy.context.scene.frame_step
|
||||
else:
|
||||
frame_start = submit_settings.frame_start
|
||||
frame_end = submit_settings.frame_end
|
||||
frame_step = submit_settings.frame_step
|
||||
|
||||
# Create temp directory if not provided
|
||||
if temp_dir is None:
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix="sheepit_submit_"))
|
||||
|
||||
# Generate temp blend filename
|
||||
blend_name = bpy.data.filepath if bpy.data.filepath else "untitled"
|
||||
blend_name = Path(blend_name).stem if blend_name else "untitled"
|
||||
temp_blend = temp_dir / f"{blend_name}.blend"
|
||||
|
||||
print(f"[SheepIt Submit] Saving current blend state to: {temp_blend}")
|
||||
print(f"[SheepIt Submit] Frame range: {frame_start} - {frame_end} (step: {frame_step})")
|
||||
|
||||
# Save current blend state
|
||||
try:
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
bpy.ops.wm.save_as_mainfile(filepath=str(temp_blend), copy=True, compress=True)
|
||||
print(f"[SheepIt Submit] Saved current blend state to temp file")
|
||||
except Exception as e:
|
||||
error_msg = f"Failed to save current blend state: {type(e).__name__}: {str(e)}"
|
||||
print(f"[SheepIt Submit] ERROR: {error_msg}")
|
||||
raise RuntimeError(error_msg) from e
|
||||
|
||||
# Apply frame range to the saved file
|
||||
apply_frame_range_to_blend(temp_blend, frame_start, frame_end, frame_step)
|
||||
|
||||
return temp_blend, frame_start, frame_end, frame_step
|
||||
|
||||
|
||||
class SHEEPIT_OT_submit_current(Operator):
|
||||
"""Pack current blend file to output location without packing assets."""
|
||||
bl_idname = "sheepit.submit_current"
|
||||
bl_label = "Pack Current Blend"
|
||||
bl_description = "Save the current blend file with frame range applied to the specified output location"
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
def invoke(self, context, event):
|
||||
"""Start the packing operation."""
|
||||
submit_settings = context.scene.sheepit_submit
|
||||
|
||||
# Check if already packing
|
||||
if submit_settings.is_submitting:
|
||||
self.report({'WARNING'}, "A packing operation is already in progress.")
|
||||
return {'CANCELLED'}
|
||||
|
||||
# Get output path from settings or preferences
|
||||
output_dir = submit_settings.output_path
|
||||
if not output_dir:
|
||||
from ..utils.compat import get_addon_prefs
|
||||
prefs = get_addon_prefs()
|
||||
if prefs and prefs.default_output_path:
|
||||
output_dir = prefs.default_output_path
|
||||
submit_settings.output_path = output_dir
|
||||
|
||||
if not output_dir:
|
||||
self.report({'ERROR'}, "Please specify an output path in the panel below.")
|
||||
return {'CANCELLED'}
|
||||
|
||||
# Generate filename
|
||||
blend_name = bpy.data.filepath if bpy.data.filepath else "untitled"
|
||||
if blend_name:
|
||||
blend_name = Path(blend_name).stem
|
||||
else:
|
||||
blend_name = "untitled"
|
||||
output_file = Path(output_dir) / f"{blend_name}.blend"
|
||||
|
||||
# Initialize progress properties
|
||||
submit_settings.is_submitting = True
|
||||
submit_settings.submit_progress = 0.0
|
||||
submit_settings.submit_status_message = "Initializing..."
|
||||
|
||||
# Initialize phase tracking
|
||||
self._phase = 'INIT'
|
||||
self._output_path = output_file
|
||||
self._temp_blend_path = None
|
||||
self._temp_dir = None
|
||||
self._success = False
|
||||
self._message = ""
|
||||
self._error = None
|
||||
|
||||
# Create timer for modal updates
|
||||
self._timer = context.window_manager.event_timer_add(0.1, window=context.window)
|
||||
|
||||
# Force UI redraw
|
||||
for area in context.screen.areas:
|
||||
if area.type == 'PROPERTIES':
|
||||
area.tag_redraw()
|
||||
|
||||
# Start modal operation
|
||||
context.window_manager.modal_handler_add(self)
|
||||
return {'RUNNING_MODAL'}
|
||||
|
||||
def execute(self, context):
|
||||
"""Legacy execute method - redirects to invoke for modal operation."""
|
||||
return self.invoke(context, None)
|
||||
|
||||
def modal(self, context, event):
|
||||
"""Handle modal events and update progress."""
|
||||
submit_settings = context.scene.sheepit_submit
|
||||
|
||||
# Handle ESC key to cancel
|
||||
if event.type == 'ESC':
|
||||
self._cleanup(context, cancelled=True)
|
||||
self.report({'INFO'}, "Packing cancelled.")
|
||||
return {'CANCELLED'}
|
||||
|
||||
# Handle timer events
|
||||
if event.type == 'TIMER':
|
||||
try:
|
||||
if self._phase == 'INIT':
|
||||
submit_settings.submit_progress = 0.0
|
||||
submit_settings.submit_status_message = "Initializing..."
|
||||
self._phase = 'SAVING_BLEND'
|
||||
return {'RUNNING_MODAL'}
|
||||
|
||||
elif self._phase == 'SAVING_BLEND':
|
||||
submit_settings.submit_progress = 10.0
|
||||
submit_settings.submit_status_message = "Saving current blend state..."
|
||||
|
||||
# Save current blend state to temp file with frame range applied
|
||||
try:
|
||||
self._temp_blend_path, frame_start, frame_end, frame_step = save_current_blend_with_frame_range(submit_settings)
|
||||
self._temp_dir = self._temp_blend_path.parent
|
||||
print(f"[SheepIt Submit] Using temp blend file: {self._temp_blend_path}")
|
||||
except Exception as e:
|
||||
self._error = f"Failed to save current blend state: {str(e)}"
|
||||
self._cleanup(context, cancelled=True)
|
||||
self.report({'ERROR'}, self._error)
|
||||
return {'CANCELLED'}
|
||||
|
||||
self._phase = 'APPLYING_FRAME_RANGE'
|
||||
return {'RUNNING_MODAL'}
|
||||
|
||||
elif self._phase == 'APPLYING_FRAME_RANGE':
|
||||
submit_settings.submit_progress = 20.0
|
||||
submit_settings.submit_status_message = "Frame range applied."
|
||||
# Frame range is already applied in save_current_blend_with_frame_range
|
||||
self._phase = 'VALIDATING_FILE_SIZE'
|
||||
return {'RUNNING_MODAL'}
|
||||
|
||||
elif self._phase == 'VALIDATING_FILE_SIZE':
|
||||
submit_settings.submit_progress = 30.0
|
||||
submit_settings.submit_status_message = "Validating file size..."
|
||||
|
||||
# Check blend file size
|
||||
if self._temp_blend_path and self._temp_blend_path.exists():
|
||||
blend_size = self._temp_blend_path.stat().st_size
|
||||
blend_size_gb = blend_size / (1024 * 1024 * 1024)
|
||||
from .pack_ops import _get_project_size_limit_bytes
|
||||
max_bytes = _get_project_size_limit_bytes(context)
|
||||
if max_bytes is not None and blend_size > max_bytes:
|
||||
limit_gb = max_bytes / (1024 * 1024 * 1024)
|
||||
print(f"[SheepIt Pack] Blend file size: {blend_size_gb:.2f} GB")
|
||||
error_msg = (
|
||||
f"Blend file size ({blend_size_gb:.2f} GB) exceeds project limit ({limit_gb:.1f} GB).\n\n"
|
||||
"To reduce file size, consider:\n"
|
||||
"- Optimizing the scene (reduce geometry, simplify materials)\n"
|
||||
"- Optimizing asset files (compress textures, reduce resolution)\n"
|
||||
"- Splitting the frame range (render in smaller chunks)"
|
||||
)
|
||||
print(f"[SheepIt Pack] ERROR: {error_msg}")
|
||||
self._error = error_msg
|
||||
self._cleanup(context, cancelled=True)
|
||||
self.report({'ERROR'}, self._error)
|
||||
return {'CANCELLED'}
|
||||
|
||||
self._phase = 'SAVING_FILE'
|
||||
return {'RUNNING_MODAL'}
|
||||
|
||||
elif self._phase == 'SAVING_FILE':
|
||||
submit_settings.submit_progress = 50.0
|
||||
submit_settings.submit_status_message = "Saving file to output location..."
|
||||
|
||||
try:
|
||||
# Ensure output directory exists
|
||||
self._output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Copy temp file to output location
|
||||
import shutil
|
||||
shutil.copy2(self._temp_blend_path, self._output_path)
|
||||
|
||||
print(f"[SheepIt Pack] Saved blend file to: {self._output_path}")
|
||||
self._success = True
|
||||
self._message = f"Blend file saved to: {self._output_path}"
|
||||
|
||||
submit_settings.submit_progress = 90.0
|
||||
submit_settings.submit_status_message = "File saved successfully!"
|
||||
self._phase = 'CLEANUP'
|
||||
except Exception as e:
|
||||
self._error = f"Failed to save file: {str(e)}"
|
||||
self._cleanup(context, cancelled=True)
|
||||
self.report({'ERROR'}, self._error)
|
||||
return {'CANCELLED'}
|
||||
|
||||
return {'RUNNING_MODAL'}
|
||||
|
||||
elif self._phase == 'CLEANUP':
|
||||
submit_settings.submit_progress = 98.0
|
||||
submit_settings.submit_status_message = "Cleaning up..."
|
||||
|
||||
# Clean up temp file on success
|
||||
if self._temp_blend_path and self._temp_blend_path.exists():
|
||||
try:
|
||||
self._temp_blend_path.unlink()
|
||||
if self._temp_dir and self._temp_dir.exists():
|
||||
try:
|
||||
self._temp_dir.rmdir()
|
||||
except Exception:
|
||||
pass # Directory may not be empty
|
||||
print(f"[SheepIt Submit] Cleaned up temp file: {self._temp_blend_path}")
|
||||
except Exception as e:
|
||||
print(f"[SheepIt Submit] WARNING: Could not clean up temp file: {e}")
|
||||
|
||||
self._phase = 'COMPLETE'
|
||||
return {'RUNNING_MODAL'}
|
||||
|
||||
elif self._phase == 'COMPLETE':
|
||||
submit_settings.submit_progress = 100.0
|
||||
submit_settings.submit_status_message = "Packing complete!"
|
||||
|
||||
# Small delay to show completion
|
||||
import time
|
||||
time.sleep(0.2)
|
||||
|
||||
self._cleanup(context, cancelled=False)
|
||||
self.report({'INFO'}, f"Blend file saved to: {self._output_path}")
|
||||
return {'FINISHED'}
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
self._error = f"Packing failed: {type(e).__name__}: {str(e)}"
|
||||
self._cleanup(context, cancelled=True)
|
||||
self.report({'ERROR'}, self._error)
|
||||
return {'CANCELLED'}
|
||||
|
||||
return {'RUNNING_MODAL'}
|
||||
|
||||
def _cleanup(self, context, cancelled=False):
|
||||
"""Clean up progress properties and timer."""
|
||||
submit_settings = context.scene.sheepit_submit
|
||||
|
||||
# Remove timer
|
||||
if hasattr(self, '_timer') and self._timer:
|
||||
context.window_manager.event_timer_remove(self._timer)
|
||||
|
||||
# Reset progress properties
|
||||
submit_settings.is_submitting = False
|
||||
submit_settings.submit_progress = 0.0
|
||||
if cancelled and self._error:
|
||||
submit_settings.submit_status_message = self._error
|
||||
else:
|
||||
submit_settings.submit_status_message = ""
|
||||
|
||||
# Force UI redraw
|
||||
for area in context.screen.areas:
|
||||
if area.type == 'PROPERTIES':
|
||||
area.tag_redraw()
|
||||
|
||||
def execute(self, context):
|
||||
"""Execute method - starts the modal operation."""
|
||||
# This is called by invoke, so we don't redirect back to avoid recursion
|
||||
# The actual implementation is in the invoke method above
|
||||
pass
|
||||
|
||||
|
||||
# Video and audio extensions to exclude when exclude_video=True
|
||||
_MEDIA_EXTENSIONS = frozenset({
|
||||
'.mp4', '.avi', '.mov', '.mkv', '.webm', '.m4v', '.wmv', '.flv', '.ogv', '.mpg', '.mpeg', '.m2v',
|
||||
'.wav', '.mp3', '.ogg', '.flac', '.aac', '.m4a', '.wma', '.opus', '.aiff', '.aif',
|
||||
})
|
||||
|
||||
|
||||
def create_zip_from_directory(directory: Path, output_zip: Path, progress_callback=None, cancel_check=None, exclude_video: bool = False) -> None:
|
||||
"""Create a ZIP file from a directory.
|
||||
|
||||
Args:
|
||||
directory: Directory to zip
|
||||
output_zip: Output ZIP file path
|
||||
progress_callback: Optional callback(progress_pct, message) for progress updates
|
||||
cancel_check: Optional callback() -> bool to check for cancellation
|
||||
exclude_video: If True, skip common video and audio file extensions
|
||||
"""
|
||||
import time
|
||||
|
||||
print(f"[SheepIt Submit] Starting ZIP creation...")
|
||||
print(f"[SheepIt Submit] Directory: {directory}")
|
||||
print(f"[SheepIt Submit] Output: {output_zip}")
|
||||
|
||||
# Delete .blend1 through .blend32 backup files before zipping
|
||||
if progress_callback:
|
||||
progress_callback(0.0, "Removing backup files...")
|
||||
|
||||
backup_files = []
|
||||
for i in range(1, 33): # blend1 through blend32
|
||||
pattern = f"*.blend{i}"
|
||||
backup_files.extend(directory.rglob(pattern))
|
||||
|
||||
if backup_files:
|
||||
print(f"[SheepIt Submit] Found {len(backup_files)} backup files (.blend1-.blend32), deleting...")
|
||||
deleted_count = 0
|
||||
for backup_file in backup_files:
|
||||
try:
|
||||
backup_file.unlink()
|
||||
deleted_count += 1
|
||||
except Exception as e:
|
||||
print(f"[SheepIt Submit] WARNING: Could not delete {backup_file.name}: {e}")
|
||||
print(f"[SheepIt Submit] Deleted {deleted_count}/{len(backup_files)} backup files")
|
||||
else:
|
||||
print(f"[SheepIt Submit] No backup files (.blend1-.blend32) found")
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(0.0, "Counting files...")
|
||||
|
||||
# Collect all dirs (for empty-dir entries) and files
|
||||
dir_arcs = set()
|
||||
file_list = []
|
||||
file_count = 0
|
||||
total_size = 0
|
||||
for root, dirs, files in os.walk(directory):
|
||||
root_path = Path(root)
|
||||
for d in dirs:
|
||||
dir_arcs.add(root_path.joinpath(d).relative_to(directory))
|
||||
for file in files:
|
||||
file_path = root_path / file
|
||||
if not file_path.exists():
|
||||
continue
|
||||
if exclude_video and file_path.suffix.lower() in _MEDIA_EXTENSIONS:
|
||||
continue
|
||||
file_count += 1
|
||||
total_size += file_path.stat().st_size
|
||||
file_list.append((file_path, file_path.relative_to(directory)))
|
||||
|
||||
print(f"[SheepIt Submit] Found {file_count} files, total size: {total_size / (1024*1024):.2f} MB")
|
||||
print(f"[SheepIt Submit] Creating ZIP (this may take a while)...")
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(1.0, f"Creating ZIP archive ({file_count} files, {total_size / (1024*1024):.1f} MB)...")
|
||||
|
||||
start_time = time.time()
|
||||
files_added = 0
|
||||
|
||||
with zipfile.ZipFile(output_zip, 'w', zipfile.ZIP_STORED) as zipf:
|
||||
for dir_arc in sorted(dir_arcs):
|
||||
arcname = str(dir_arc).replace("\\", "/") + "/"
|
||||
zipf.writestr(arcname, "")
|
||||
for file_path, arcname in file_list:
|
||||
if cancel_check and cancel_check():
|
||||
raise InterruptedError("ZIP creation cancelled by user")
|
||||
|
||||
if not file_path.exists():
|
||||
continue
|
||||
|
||||
try:
|
||||
zipf.write(file_path, arcname)
|
||||
files_added += 1
|
||||
|
||||
# Progress updates - more frequent for large files
|
||||
if files_added == 1:
|
||||
print(f"[SheepIt Submit] Adding files to ZIP...")
|
||||
if progress_callback:
|
||||
progress_callback(2.0, f"Adding files to ZIP... (1/{file_count})")
|
||||
elif files_added % 10 == 0 or (file_count > 0 and files_added % max(1, file_count // 100) == 0):
|
||||
elapsed = time.time() - start_time
|
||||
rate = files_added / elapsed if elapsed > 0 else 0
|
||||
progress_pct = 2.0 + (files_added / file_count * 93.0) if file_count > 0 else 2.0
|
||||
print(f"[SheepIt Submit] Progress: {files_added}/{file_count} files ({files_added*100//file_count}%), {rate:.1f} files/sec")
|
||||
if progress_callback:
|
||||
progress_callback(progress_pct, f"Creating ZIP... ({files_added}/{file_count} files, {rate:.1f} files/sec)")
|
||||
except Exception as e:
|
||||
print(f"[SheepIt Submit] WARNING: Failed to add {arcname}: {type(e).__name__}: {str(e)}")
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
print(f"[SheepIt Submit] ZIP creation completed!")
|
||||
print(f"[SheepIt Submit] Files added: {files_added}/{file_count}")
|
||||
print(f"[SheepIt Submit] Time taken: {elapsed:.2f} seconds")
|
||||
if elapsed > 0:
|
||||
print(f"[SheepIt Submit] Average rate: {files_added/elapsed:.1f} files/sec")
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(100.0, "ZIP archive created")
|
||||
|
||||
|
||||
def register():
|
||||
"""Register operators."""
|
||||
bpy.utils.register_class(SHEEPIT_OT_submit_current)
|
||||
|
||||
|
||||
def unregister():
|
||||
"""Unregister operators."""
|
||||
bpy.utils.unregister_class(SHEEPIT_OT_submit_current)
|
||||
@@ -0,0 +1,174 @@
|
||||
import bpy # type: ignore
|
||||
|
||||
RAINYS_EXTENSIONS_REPO_NAME = "Rainy's Extensions"
|
||||
RAINYS_EXTENSIONS_REPO_URL = (
|
||||
"https://raw.githubusercontent.com/RaincloudTheDragon/rainys-blender-extensions/refs/heads/main/index.json"
|
||||
)
|
||||
|
||||
_BOOTSTRAP_DONE = False
|
||||
|
||||
|
||||
def _log(message: str) -> None:
|
||||
print(f"RainysExtensionsCheck: {message}")
|
||||
|
||||
|
||||
def ensure_rainys_extensions_repo(_deferred: bool = False) -> None:
|
||||
"""
|
||||
Ensure the Rainy's Extensions repository is registered in Blender.
|
||||
|
||||
Safe to import and call from multiple add-ons; the helper guards against doing the
|
||||
work more than once per Blender session.
|
||||
"""
|
||||
global _BOOTSTRAP_DONE
|
||||
|
||||
if _BOOTSTRAP_DONE:
|
||||
return
|
||||
|
||||
_log("starting repository verification")
|
||||
|
||||
context_class_name = type(bpy.context).__name__
|
||||
if context_class_name == "_RestrictContext":
|
||||
if _deferred:
|
||||
_log("context still restricted after deferral; aborting repo check")
|
||||
return
|
||||
|
||||
_log("context restricted; scheduling repo check retry")
|
||||
|
||||
def _retry():
|
||||
ensure_rainys_extensions_repo(_deferred=True)
|
||||
return None
|
||||
|
||||
bpy.app.timers.register(_retry, first_interval=0.5)
|
||||
return
|
||||
|
||||
prefs = getattr(bpy.context, "preferences", None)
|
||||
if prefs is None:
|
||||
_log("no preferences available on context; skipping")
|
||||
return
|
||||
|
||||
preferences_changed = False
|
||||
addon_prefs = None
|
||||
addon_entry = None
|
||||
if hasattr(getattr(prefs, "addons", None), "get"):
|
||||
addon_entry = prefs.addons.get(__name__)
|
||||
elif hasattr(prefs, "addons"):
|
||||
try:
|
||||
addon_entry = prefs.addons[__name__]
|
||||
except Exception:
|
||||
addon_entry = None
|
||||
if addon_entry:
|
||||
addon_prefs = getattr(addon_entry, "preferences", None)
|
||||
addon_repo_initialized = bool(
|
||||
addon_prefs and getattr(addon_prefs, "repo_initialized", False)
|
||||
)
|
||||
|
||||
experimental = getattr(prefs, "experimental", None)
|
||||
if experimental and hasattr(experimental, "use_extension_platform"):
|
||||
if not experimental.use_extension_platform:
|
||||
experimental.use_extension_platform = True
|
||||
preferences_changed = True
|
||||
_log("enabled experimental extension platform")
|
||||
|
||||
repositories = None
|
||||
extensions_obj = getattr(prefs, "extensions", None)
|
||||
if extensions_obj:
|
||||
if hasattr(extensions_obj, "repos"):
|
||||
repositories = extensions_obj.repos
|
||||
elif hasattr(extensions_obj, "repositories"):
|
||||
repositories = extensions_obj.repositories
|
||||
|
||||
if repositories is None:
|
||||
filepaths = getattr(prefs, "filepaths", None)
|
||||
repositories = getattr(filepaths, "extension_repos", None) if filepaths else None
|
||||
|
||||
if repositories is None:
|
||||
_log("extension repositories collection missing; skipping")
|
||||
return
|
||||
|
||||
def _repo_matches(repo) -> bool:
|
||||
return getattr(repo, "remote_url", "") == RAINYS_EXTENSIONS_REPO_URL or getattr(
|
||||
repo, "url", ""
|
||||
) == RAINYS_EXTENSIONS_REPO_URL
|
||||
|
||||
matching_indices = [idx for idx, repo in enumerate(repositories) if _repo_matches(repo)]
|
||||
|
||||
target_repo = None
|
||||
if matching_indices:
|
||||
target_repo = repositories[matching_indices[0]]
|
||||
if len(matching_indices) > 1 and hasattr(repositories, "remove"):
|
||||
for dup_idx in reversed(matching_indices[1:]):
|
||||
try:
|
||||
repositories.remove(dup_idx)
|
||||
_log(f"removed duplicate repository entry at index {dup_idx}")
|
||||
except Exception as exc:
|
||||
_log(f"could not remove duplicate repository at index {dup_idx}: {exc}")
|
||||
else:
|
||||
target_repo = next(
|
||||
(
|
||||
repo
|
||||
for repo in repositories
|
||||
if getattr(repo, "name", "") == RAINYS_EXTENSIONS_REPO_NAME
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if target_repo is None:
|
||||
_log("repo missing; creating new entry")
|
||||
if hasattr(repositories, "new"):
|
||||
target_repo = repositories.new()
|
||||
elif hasattr(repositories, "add"):
|
||||
target_repo = repositories.add()
|
||||
else:
|
||||
_log("repository collection does not support creation; aborting")
|
||||
return
|
||||
else:
|
||||
_log("repo entry already present; validating fields")
|
||||
|
||||
changed = preferences_changed
|
||||
|
||||
def _ensure_attr(obj, attr, value):
|
||||
if hasattr(obj, attr) and getattr(obj, attr) != value:
|
||||
setattr(obj, attr, value)
|
||||
return True
|
||||
if not hasattr(obj, attr):
|
||||
_log(f"repository entry missing attribute '{attr}', skipping field")
|
||||
return False
|
||||
|
||||
changed |= _ensure_attr(target_repo, "name", RAINYS_EXTENSIONS_REPO_NAME)
|
||||
changed |= _ensure_attr(target_repo, "module", "rainys_extensions")
|
||||
changed |= _ensure_attr(target_repo, "use_remote_url", True)
|
||||
changed |= _ensure_attr(target_repo, "remote_url", RAINYS_EXTENSIONS_REPO_URL)
|
||||
changed |= _ensure_attr(target_repo, "use_sync_on_startup", True)
|
||||
changed |= _ensure_attr(target_repo, "use_cache", True)
|
||||
changed |= _ensure_attr(target_repo, "use_access_token", False)
|
||||
|
||||
if addon_prefs and hasattr(addon_prefs, "repo_initialized") and not addon_prefs.repo_initialized:
|
||||
addon_prefs.repo_initialized = True
|
||||
changed = True
|
||||
|
||||
if not changed:
|
||||
_log("repository already configured; skipping preference save")
|
||||
_BOOTSTRAP_DONE = True
|
||||
return
|
||||
|
||||
if hasattr(bpy.ops, "wm") and hasattr(bpy.ops.wm, "save_userpref"):
|
||||
try:
|
||||
bpy.ops.wm.save_userpref()
|
||||
_log("preferences updated and saved")
|
||||
except Exception as exc: # pragma: no cover
|
||||
print(f"RainysExtensionsCheck: could not save preferences after repo update -> {exc}")
|
||||
else:
|
||||
_log("preferences API unavailable; changes not persisted")
|
||||
|
||||
_BOOTSTRAP_DONE = True
|
||||
|
||||
|
||||
def register() -> None:
|
||||
"""Entry point for Blender add-on registration."""
|
||||
ensure_rainys_extensions_repo()
|
||||
|
||||
|
||||
def unregister() -> None:
|
||||
"""Reset bootstrap guard so next registration re-runs the checks."""
|
||||
global _BOOTSTRAP_DONE
|
||||
_BOOTSTRAP_DONE = False
|
||||
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
UI modules for SheepIt Project Submitter addon.
|
||||
"""
|
||||
|
||||
from . import output_panel
|
||||
from . import preferences_ui
|
||||
|
||||
__all__ = ["output_panel", "preferences_ui"]
|
||||
|
||||
|
||||
def register():
|
||||
"""Register all UI classes."""
|
||||
# Register preferences first so we can access variables in config.py
|
||||
preferences_ui.register()
|
||||
|
||||
# Register other UI components
|
||||
output_panel.register()
|
||||
|
||||
|
||||
def unregister():
|
||||
"""Unregister all UI classes."""
|
||||
output_panel.unregister()
|
||||
preferences_ui.unregister()
|
||||
@@ -0,0 +1,90 @@
|
||||
"""
|
||||
Output panel UI for SheepIt Project Submitter.
|
||||
Located in the Output tab, similar to Flamenco addon.
|
||||
"""
|
||||
|
||||
import bpy
|
||||
from bpy.types import Panel
|
||||
from ..utils import compat
|
||||
|
||||
|
||||
class SHEEPIT_PT_output_panel(Panel):
|
||||
"""SheepIt submission panel in Output properties."""
|
||||
bl_label = "SheepIt Render Farm"
|
||||
bl_idname = "SHEEPIT_PT_output_panel"
|
||||
bl_space_type = 'PROPERTIES'
|
||||
bl_region_type = 'WINDOW'
|
||||
bl_context = "output"
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
scene = context.scene
|
||||
submit_settings = scene.sheepit_submit
|
||||
# Do not write to scene in draw(); operators fall back to prefs.default_output_path when output_path is empty.
|
||||
|
||||
# Animation Setup Section
|
||||
box = layout.box()
|
||||
box.label(text="Animation Setup:", icon='ACTION')
|
||||
col = box.column()
|
||||
col.operator("sheepit.enable_nla", text="Disable Animation Layers", icon='ACTION')
|
||||
|
||||
layout.separator()
|
||||
|
||||
# Frame Range Section
|
||||
box = layout.box()
|
||||
box.label(text="Frame Range:", icon='RENDER_ANIMATION')
|
||||
row = box.row()
|
||||
row.prop(submit_settings, "frame_range_mode", expand=True)
|
||||
|
||||
if submit_settings.frame_range_mode == 'CUSTOM':
|
||||
col = box.column(align=True)
|
||||
col.prop(submit_settings, "frame_start")
|
||||
col.prop(submit_settings, "frame_end")
|
||||
col.prop(submit_settings, "frame_step")
|
||||
else:
|
||||
# Show current scene frame range
|
||||
row = box.row()
|
||||
row.label(text=f"Scene Range: {scene.frame_start} - {scene.frame_end} (Step: {scene.frame_step})")
|
||||
|
||||
layout.separator()
|
||||
|
||||
# Progress Bar Section (when submitting)
|
||||
if submit_settings.is_submitting:
|
||||
box = layout.box()
|
||||
box.label(text=submit_settings.submit_status_message, icon='TIME')
|
||||
box.prop(submit_settings, "submit_progress", text="Progress", slider=True)
|
||||
layout.separator()
|
||||
|
||||
# Packing Buttons
|
||||
col = layout.column()
|
||||
col.scale_y = 1.5
|
||||
|
||||
# Pack Current Blend button (first)
|
||||
op = col.operator("sheepit.submit_current", text="Pack Current Blend", icon='EXPORT')
|
||||
|
||||
op = col.operator("sheepit.pack_zip", text="Pack as ZIP (for scenes with caches)", icon='PACKAGE')
|
||||
row = layout.row()
|
||||
row.prop(submit_settings, "exclude_video_from_zip", text="Exclude video/audio from ZIP")
|
||||
|
||||
# Pack as Blend button
|
||||
op = col.operator("sheepit.pack_blend", text="Pack as Blend", icon='FILE_BLEND')
|
||||
|
||||
layout.separator()
|
||||
|
||||
# Output Path Section
|
||||
box = layout.box()
|
||||
box.label(text="Output Path:", icon='FILE_FOLDER')
|
||||
row = box.row()
|
||||
row.prop(submit_settings, "output_path", text="")
|
||||
row = box.row()
|
||||
row.prop(submit_settings, "project_size_limit_gb", text="Size limit (GB)")
|
||||
|
||||
|
||||
def register():
|
||||
"""Register panel."""
|
||||
compat.safe_register_class(SHEEPIT_PT_output_panel)
|
||||
|
||||
|
||||
def unregister():
|
||||
"""Unregister panel."""
|
||||
compat.safe_unregister_class(SHEEPIT_PT_output_panel)
|
||||
@@ -0,0 +1,106 @@
|
||||
"""
|
||||
Preferences UI for SheepIt Project Submitter.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import bpy
|
||||
from bpy.types import AddonPreferences
|
||||
from bpy.props import StringProperty
|
||||
from .. import config
|
||||
|
||||
|
||||
# Get the root module name dynamically
|
||||
def _get_addon_module_name():
|
||||
"""Get the root addon module name for bl_idname."""
|
||||
# In Blender 5.0 extensions loaded via VSCode, the module name is the full path
|
||||
# e.g., "bl_ext.vscode_development.sheepit_project_submitter"
|
||||
# We need to get it from the parent package (sheepit_project_submitter)
|
||||
try:
|
||||
# Get parent package name from __package__ (remove .ui suffix)
|
||||
if __package__:
|
||||
parent_pkg = __package__.rsplit('.', 1)[0] if '.' in __package__ else __package__
|
||||
# Get the actual module from sys.modules to get its __name__
|
||||
parent_module = sys.modules.get(parent_pkg)
|
||||
if parent_module and hasattr(parent_module, '__name__'):
|
||||
module_name = parent_module.__name__
|
||||
config.debug_print(f"[SheepIt Debug] Using parent module __name__ as bl_idname: {module_name}")
|
||||
return module_name
|
||||
else:
|
||||
# Use the package name directly
|
||||
config.debug_print(f"[SheepIt Debug] Using parent package name as bl_idname: {parent_pkg}")
|
||||
return parent_pkg
|
||||
except Exception as e:
|
||||
config.debug_print(f"[SheepIt Debug] Could not get parent module name: {e}")
|
||||
|
||||
# Last fallback
|
||||
module_name = config.ADDON_ID
|
||||
config.debug_print(f"[SheepIt Debug] Using fallback bl_idname: {module_name}")
|
||||
return module_name
|
||||
|
||||
|
||||
class SHEEPIT_AddonPreferences(AddonPreferences):
|
||||
"""Addon preferences for SheepIt Project Submitter."""
|
||||
# bl_idname must match the add-on's module name exactly
|
||||
# Get it dynamically to ensure it matches what Blender registered
|
||||
bl_idname = _get_addon_module_name()
|
||||
|
||||
default_output_path: StringProperty(
|
||||
name="Default Output Path",
|
||||
description="Default directory path where packed files will be saved",
|
||||
default="",
|
||||
subtype='DIR_PATH',
|
||||
update=lambda self, context: _sync_default_output_path(self, context),
|
||||
)
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
|
||||
# Output path settings
|
||||
box = layout.box()
|
||||
box.label(text="Output Settings:", icon='FILE_FOLDER')
|
||||
box.prop(self, "default_output_path")
|
||||
|
||||
layout.separator()
|
||||
|
||||
# Info box
|
||||
box = layout.box()
|
||||
box.label(text="About:", icon='INFO')
|
||||
box.label(text="This addon packs your Blender projects for manual upload to SheepIt.")
|
||||
box.label(text="You must manually upload and configure projects on the SheepIt website.")
|
||||
|
||||
|
||||
def _sync_default_output_path(prefs, context):
|
||||
"""Sync default output path to all scenes' output_path if they're empty."""
|
||||
if not prefs.default_output_path:
|
||||
return
|
||||
|
||||
# Update all scenes' output_path if they're empty
|
||||
for scene in bpy.data.scenes:
|
||||
if hasattr(scene, 'sheepit_submit') and scene.sheepit_submit:
|
||||
if not scene.sheepit_submit.output_path:
|
||||
scene.sheepit_submit.output_path = prefs.default_output_path
|
||||
|
||||
|
||||
reg_list = [SHEEPIT_AddonPreferences]
|
||||
|
||||
|
||||
def register():
|
||||
"""Register preferences."""
|
||||
# Register preferences class
|
||||
for cls in reg_list:
|
||||
try:
|
||||
from bpy.utils import register_class
|
||||
register_class(cls)
|
||||
config.debug_print(f"[SheepIt Debug] Registered preferences class: {cls.__name__} with bl_idname: {cls.bl_idname}")
|
||||
except Exception as e:
|
||||
print(f"[SheepIt Error] Failed to register preferences class {cls.__name__}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
def unregister():
|
||||
"""Unregister preferences."""
|
||||
# Unregister preferences class
|
||||
from ..utils import compat
|
||||
for cls in reg_list:
|
||||
compat.safe_unregister_class(cls)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""
|
||||
Utility modules for SheepIt Project Submitter addon.
|
||||
"""
|
||||
|
||||
from . import compat
|
||||
from . import version
|
||||
|
||||
# Don't import auth at module level to avoid circular imports
|
||||
# It will be imported lazily when needed
|
||||
# Users should import it as: from ..utils.auth import <function>
|
||||
# Or: from ..utils import auth (which will trigger lazy import)
|
||||
|
||||
__all__ = ["compat", "version"]
|
||||
|
||||
|
||||
def __getattr__(name):
|
||||
"""Lazy import for auth module to avoid circular imports."""
|
||||
if name == "auth":
|
||||
from . import auth
|
||||
return auth
|
||||
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
|
||||
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
Compatibility layer for handling differences across Blender versions.
|
||||
Supports full SheepIt range: 3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 4.0, 4.1, 4.2, 4.3, 4.4, 4.5, 5.0+
|
||||
Minimum supported: Blender 3.0
|
||||
"""
|
||||
|
||||
import bpy
|
||||
from bpy.utils import register_class, unregister_class
|
||||
from . import version
|
||||
|
||||
|
||||
def safe_register_class(cls):
|
||||
"""
|
||||
Safely register a class, handling any version-specific registration issues.
|
||||
|
||||
Args:
|
||||
cls: The class to register
|
||||
|
||||
Returns:
|
||||
bool: True if registration succeeded, False otherwise
|
||||
"""
|
||||
try:
|
||||
register_class(cls)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to register {cls.__name__}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def safe_unregister_class(cls):
|
||||
"""
|
||||
Safely unregister a class, handling any version-specific unregistration issues.
|
||||
|
||||
Args:
|
||||
cls: The class to unregister
|
||||
|
||||
Returns:
|
||||
bool: True if unregistration succeeded, False otherwise
|
||||
"""
|
||||
try:
|
||||
unregister_class(cls)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to unregister {cls.__name__}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def get_addon_prefs():
|
||||
"""
|
||||
Get the addon preferences instance, compatible across versions.
|
||||
|
||||
Returns:
|
||||
AddonPreferences or None: The addon preferences instance if found
|
||||
"""
|
||||
from .. import config
|
||||
prefs = bpy.context.preferences
|
||||
addon = prefs.addons.get(config.ADDON_ID, None)
|
||||
if addon and getattr(addon, "preferences", None):
|
||||
return addon.preferences
|
||||
for addon in prefs.addons.values():
|
||||
ap = getattr(addon, "preferences", None)
|
||||
if ap and hasattr(ap, "default_output_path"):
|
||||
return ap
|
||||
return None
|
||||
|
||||
|
||||
def is_library_or_override(datablock):
|
||||
"""
|
||||
Check if a datablock is library-linked or an override.
|
||||
|
||||
Args:
|
||||
datablock: The datablock to check
|
||||
|
||||
Returns:
|
||||
bool: True if the datablock is library-linked or an override, False otherwise
|
||||
"""
|
||||
# Check if datablock is linked from a library
|
||||
if hasattr(datablock, 'library') and datablock.library:
|
||||
return True
|
||||
|
||||
# Check if datablock is an override (Blender 3.0+)
|
||||
if hasattr(datablock, 'override_library') and datablock.override_library:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_file_path_map(include_libraries=False):
|
||||
"""
|
||||
Get file path map, handling version differences.
|
||||
|
||||
Args:
|
||||
include_libraries (bool): Whether to include library paths
|
||||
|
||||
Returns:
|
||||
dict: File path map
|
||||
"""
|
||||
try:
|
||||
return bpy.data.file_path_map(include_libraries=include_libraries)
|
||||
except Exception:
|
||||
# Fallback for older versions
|
||||
return {}
|
||||
|
||||
|
||||
def get_user_map():
|
||||
"""
|
||||
Get user map, handling version differences.
|
||||
|
||||
Returns:
|
||||
dict: User map
|
||||
"""
|
||||
try:
|
||||
return bpy.data.user_map()
|
||||
except Exception:
|
||||
# Fallback for older versions
|
||||
return {}
|
||||
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
Version detection and comparison utilities for multi-version Blender support.
|
||||
Supports full SheepIt range: 3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 4.0, 4.1, 4.2, 4.3, 4.4, 4.5, 5.0+
|
||||
Minimum supported: Blender 3.0
|
||||
"""
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
def get_blender_version():
|
||||
"""
|
||||
Returns the current Blender version as a tuple (major, minor, patch).
|
||||
|
||||
Returns:
|
||||
tuple: (major, minor, patch) version numbers
|
||||
"""
|
||||
return bpy.app.version
|
||||
|
||||
|
||||
def get_version_string():
|
||||
"""
|
||||
Returns the current Blender version as a string (e.g., "4.2.0").
|
||||
|
||||
Returns:
|
||||
str: Version string in format "major.minor.patch"
|
||||
"""
|
||||
version = get_blender_version()
|
||||
return f"{version[0]}.{version[1]}.{version[2]}"
|
||||
|
||||
|
||||
def is_version_at_least(major, minor=0, patch=0):
|
||||
"""
|
||||
Check if the current Blender version is at least the specified version.
|
||||
|
||||
Args:
|
||||
major (int): Major version number
|
||||
minor (int): Minor version number (default: 0)
|
||||
patch (int): Patch version number (default: 0)
|
||||
|
||||
Returns:
|
||||
bool: True if current version >= specified version
|
||||
"""
|
||||
current = get_blender_version()
|
||||
target = (major, minor, patch)
|
||||
|
||||
if current[0] != target[0]:
|
||||
return current[0] > target[0]
|
||||
if current[1] != target[1]:
|
||||
return current[1] > target[1]
|
||||
return current[2] >= target[2]
|
||||
|
||||
|
||||
def is_version_less_than(major, minor=0, patch=0):
|
||||
"""
|
||||
Check if the current Blender version is less than the specified version.
|
||||
|
||||
Args:
|
||||
major (int): Major version number
|
||||
minor (int): Minor version number (default: 0)
|
||||
patch (int): Patch version number (default: 0)
|
||||
|
||||
Returns:
|
||||
bool: True if current version < specified version
|
||||
"""
|
||||
return not is_version_at_least(major, minor, patch)
|
||||
|
||||
|
||||
def is_version_3_x():
|
||||
"""Check if running Blender 3.x."""
|
||||
version = get_blender_version()
|
||||
return version[0] == 3
|
||||
|
||||
|
||||
def is_version_4_0():
|
||||
"""Check if running Blender 4.0."""
|
||||
return is_version_at_least(4, 0, 0) and is_version_less_than(4, 1, 0)
|
||||
|
||||
|
||||
def is_version_4_1():
|
||||
"""Check if running Blender 4.1."""
|
||||
return is_version_at_least(4, 1, 0) and is_version_less_than(4, 2, 0)
|
||||
|
||||
|
||||
def is_version_4_2():
|
||||
"""Check if running Blender 4.2 LTS."""
|
||||
version = get_blender_version()
|
||||
return version[0] == 4 and version[1] == 2
|
||||
|
||||
|
||||
def is_version_4_3():
|
||||
"""Check if running Blender 4.3."""
|
||||
return is_version_at_least(4, 3, 0) and is_version_less_than(4, 4, 0)
|
||||
|
||||
|
||||
def is_version_4_4():
|
||||
"""Check if running Blender 4.4."""
|
||||
return is_version_at_least(4, 4, 0) and is_version_less_than(4, 5, 0)
|
||||
|
||||
|
||||
def is_version_4_5():
|
||||
"""Check if running Blender 4.5 LTS."""
|
||||
return is_version_at_least(4, 5, 0) and is_version_less_than(5, 0, 0)
|
||||
|
||||
|
||||
def is_version_5_0():
|
||||
"""Check if running Blender 5.0 or later."""
|
||||
return is_version_at_least(5, 0, 0)
|
||||
|
||||
|
||||
def get_version_category():
|
||||
"""
|
||||
Returns the version category string for the current Blender version.
|
||||
|
||||
Returns:
|
||||
str: Version category like '3.0', '3.x', '4.0', '4.1', '4.2', '4.3', '4.4', '4.5', or '5.0+'
|
||||
"""
|
||||
version = get_blender_version()
|
||||
major, minor = version[0], version[1]
|
||||
|
||||
if major == 3:
|
||||
return '3.x'
|
||||
elif major == 4:
|
||||
if minor == 0:
|
||||
return '4.0'
|
||||
elif minor == 1:
|
||||
return '4.1'
|
||||
elif minor == 2:
|
||||
return '4.2'
|
||||
elif minor == 3:
|
||||
return '4.3'
|
||||
elif minor == 4:
|
||||
return '4.4'
|
||||
elif minor >= 5:
|
||||
return '4.5'
|
||||
elif major >= 5:
|
||||
return '5.0+'
|
||||
|
||||
# Fallback
|
||||
return f"{major}.{minor}"
|
||||
Reference in New Issue
Block a user