diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index b6e6ed107..56ed3da33 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -27,10 +27,10 @@ jobs:
steps:
- uses: actions/checkout@v6
- - name: Prepare Python 3.10
+ - name: Prepare Python 3.14
uses: actions/setup-python@v6
with:
- python-version: '3.10'
+ python-version: '3.14'
- name: Prepare pipenv
run: |
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index f3495887b..e21d66e99 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -51,10 +51,10 @@ jobs:
fetch-depth: 0
submodules: recursive
- - name: Prepare Python 3.10
+ - name: Prepare Python 3.14
uses: actions/setup-python@v6
with:
- python-version: '3.10'
+ python-version: '3.14'
cache: 'pipenv'
- name: Prepare pipenv
@@ -84,7 +84,7 @@ jobs:
dotnet-version: 10.0.x
- name: Prepare msbuild
- uses: microsoft/setup-msbuild@v2
+ uses: microsoft/setup-msbuild@v3
- name: Prepare git
if: (github.repository == env.MainRepo)
@@ -135,7 +135,7 @@ jobs:
- name: Sign files with Trusted Signing (DLLs and EXEs)
if: (github.repository == env.MainRepo)
- uses: azure/trusted-signing-action@v1.0.0
+ uses: azure/trusted-signing-action@v1.2.0
with:
azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
@@ -145,7 +145,7 @@ jobs:
certificate-profile-name: ${{ secrets.AZURE_CERT_PROFILE_NAME }}
files-folder: bin/
- files-folder-filter: pyrevit*.exe,pyrevit*.dll, pyRevit*.dll
+ files-folder-filter: pyrevit*.exe,pyrevit*.dll,pyRevit*.dll
files-folder-recurse: true
file-digest: SHA256
timestamp-rfc3161: http://timestamp.acs.microsoft.com
@@ -157,7 +157,7 @@ jobs:
- name: Sign files with Trusted Signing (installers)
if: (github.repository == env.MainRepo)
- uses: azure/trusted-signing-action@v1.0.0
+ uses: azure/trusted-signing-action@v1.2.0
with:
azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
@@ -174,7 +174,7 @@ jobs:
timestamp-digest: SHA256
- name: Upload Installers
- uses: actions/upload-artifact@v6
+ uses: actions/upload-artifact@v7
with:
name: pyrevit-installers
path: |
diff --git a/.gitignore b/.gitignore
index ffb331b4b..f8d901780 100644
--- a/.gitignore
+++ b/.gitignore
@@ -67,4 +67,5 @@ bin/**/*.xml
*.dylib
# claude
-.claude
\ No newline at end of file
+.claude
+.vscode/settings.json
diff --git a/Pipfile b/Pipfile
index 00a290b7c..54670411c 100644
--- a/Pipfile
+++ b/Pipfile
@@ -5,7 +5,7 @@ verify_ssl = true
[dev-packages]
mypy = "*"
-pylint = "==4.0.4"
+pylint = "==4.0.5"
[packages]
docopt = "*"
@@ -13,7 +13,7 @@ requests = "*"
pygount = "*"
pyyaml = ">=5.4"
black = "*"
-setuptools = "==80.10.2"
+setuptools = "==82.0.1"
mkdocs = "*"
mkdocstrings = "*"
mkdocstrings-python = "*"
@@ -26,10 +26,10 @@ mkdocs-material = "*"
ruff = "*"
[requires]
-python_version = "3.10"
+python_version = "3.14"
[pipenv]
-allow_prereleases = true
+allow_prereleases = false
[scripts]
pyrevit = "python ./dev/pyrevit.py"
diff --git a/Pipfile.lock b/Pipfile.lock
index f34a98b48..0d9f85fe2 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,11 +1,11 @@
{
"_meta": {
"hash": {
- "sha256": "8dc4b70f3df824ac74388b44cbaf686796fc966097b1241ea7d7e713151a65f5"
+ "sha256": "2b58c8506f0011488f916b49be763691a56516b735f798fea32b123f0dad88f4"
},
"pipfile-spec": 6,
"requires": {
- "python_version": "3.10"
+ "python_version": "3.14"
},
"sources": [
{
@@ -18,66 +18,66 @@
"default": {
"babel": {
"hashes": [
- "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d",
- "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2"
+ "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d",
+ "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35"
],
"markers": "python_version >= '3.8'",
- "version": "==2.17.0"
+ "version": "==2.18.0"
},
"backrefs": {
"hashes": [
- "sha256:13eafbc9ccd5222e9c1f0bec563e6d2a6d21514962f11e7fc79872fd56cbc853",
- "sha256:2a2ccb96302337ce61ee4717ceacfbf26ba4efb1d55af86564b8bbaeda39cac1",
- "sha256:3bba1749aafe1db9b915f00e0dd166cba613b6f788ffd63060ac3485dc9be231",
- "sha256:4c9d3dc1e2e558965202c012304f33d4e0e477e1c103663fd2c3cc9bb18b0d05",
- "sha256:a9e99b8a4867852cad177a6430e31b0f6e495d65f8c6c134b68c14c3c95bf4b0",
- "sha256:c64698c8d2269343d88947c0735cb4b78745bd3ba590e10313fbf3f78c34da5a",
- "sha256:e82bba3875ee4430f4de4b6db19429a27275d95a5f3773c57e9e18abc23fd2b7"
+ "sha256:08aa7fae530c6b2361d7bdcbda1a7c454e330cc9dbcd03f5c23205e430e5c3be",
+ "sha256:0fdc7b012420b6b144410342caeb8adc54c6866cf12064abc9bb211302e496f8",
+ "sha256:12df81596ab511f783b7d87c043ce26bc5b0288cf3bb03610fe76b8189282b2b",
+ "sha256:664e33cd88c6840b7625b826ecf2555f32d491800900f5a541f772c485f7cda7",
+ "sha256:c3f4b9cb2af8cda0d87ab4f57800b57b95428488477be164dd2b47be54db0c90",
+ "sha256:e5f805ae09819caa1aa0623b4a83790e7028604aa2b8c73ba602c4454e665de7",
+ "sha256:f44ff4d48808b243b6c0cdc6231e22195c32f77046018141556c66f8bab72a49"
],
"markers": "python_version >= '3.9'",
- "version": "==6.1"
+ "version": "==6.2"
},
"black": {
"hashes": [
- "sha256:101540cb2a77c680f4f80e628ae98bd2bd8812fb9d72ade4f8995c5ff019e82c",
- "sha256:1054e8e47ebd686e078c0bb0eaf31e6ce69c966058d122f2c0c950311f9f3ede",
- "sha256:1de0f7d01cc894066a1153b738145b194414cc6eeaad8ef4397ac9abacf40f6b",
- "sha256:2b807c240b64609cb0e80d2200a35b23c7df82259f80bef1b2c96eb422b4aac9",
- "sha256:3cee1487a9e4c640dc7467aaa543d6c0097c391dc8ac74eb313f2fbf9d7a7cb5",
- "sha256:53c62883b3f999f14e5d30b5a79bd437236658ad45b2f853906c7cbe79de00af",
- "sha256:5e8e75dabb6eb83d064b0db46392b25cabb6e784ea624219736e8985a6b3675d",
- "sha256:643d27fb5facc167c0b1b59d0315f2674a6e950341aed0fc05cf307d22bf4954",
- "sha256:66912475200b67ef5a0ab665011964bf924745103f51977a78b4fb92a9fc1bf0",
- "sha256:6eeca41e70b5f5c84f2f913af857cf2ce17410847e1d54642e658e078da6544f",
- "sha256:6f3977a16e347f1b115662be07daa93137259c711e526402aa444d7a88fdc9d4",
- "sha256:7ed300200918147c963c87700ccf9966dceaefbbb7277450a8d646fc5646bf24",
- "sha256:91a68ae46bf07868963671e4d05611b179c2313301bd756a89ad4e3b3db2325b",
- "sha256:9459ad0d6cd483eacad4c6566b0f8e42af5e8b583cee917d90ffaa3778420a0a",
- "sha256:9dc8c71656a79ca49b8d3e2ce8103210c9481c57798b48deeb3a8bb02db5f115",
- "sha256:a19915ec61f3a8746e8b10adbac4a577c6ba9851fa4a9e9fbfbcf319887a5791",
- "sha256:b22b3810451abe359a964cc88121d57f7bce482b53a066de0f1584988ca36e79",
- "sha256:ba1d768fbfb6930fc93b0ecc32a43d8861ded16f47a40f14afa9bb04ab93d304",
- "sha256:be5e2fe860b9bd9edbf676d5b60a9282994c03fbbd40fe8f5e75d194f96064ca",
- "sha256:c5b7713daea9bf943f79f8c3b46f361cc5229e0e604dcef6a8bb6d1c37d9df89",
- "sha256:ca699710dece84e3ebf6e92ee15f5b8f72870ef984bf944a57a777a48357c168",
- "sha256:d294ac3340eef9c9eb5d29288e96dc719ff269a88e27b396340459dd85da4c58",
- "sha256:d62d14ca31c92adf561ebb2e5f2741bf8dea28aef6deb400d49cca011d186c68",
- "sha256:dd39eef053e58e60204f2cdf059e2442e2eb08f15989eefe259870f89614c8b6",
- "sha256:eb07665d9a907a1a645ee41a0df8a25ffac8ad9c26cdb557b7b88eeeeec934e0",
- "sha256:f016baaadc423dc960cdddf9acae679e71ee02c4c341f78f3179d7e4819c095f",
- "sha256:fb1dafbbaa3b1ee8b4550a84425aac8874e5f390200f5502cf3aee4a2acb2f14"
+ "sha256:0126ae5b7c09957da2bdbd91a9ba1207453feada9e9fe51992848658c6c8e01c",
+ "sha256:0f76ff19ec5297dd8e66eb64deda23631e642c9393ab592826fd4bdc97a4bce7",
+ "sha256:28ef38aee69e4b12fda8dba75e21f9b4f979b490c8ac0baa7cb505369ac9e1ff",
+ "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b",
+ "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07",
+ "sha256:2d6bfaf7fd0993b420bed691f20f9492d53ce9a2bcccea4b797d34e947318a78",
+ "sha256:41cd2012d35b47d589cb8a16faf8a32ef7a336f56356babd9fcf70939ad1897f",
+ "sha256:474c27574d6d7037c1bc875a81d9be0a9a4f9ee95e62800dab3cfaadbf75acd5",
+ "sha256:5602bdb96d52d2d0672f24f6ffe5218795736dd34807fd0fd55ccd6bf206168b",
+ "sha256:5e9d0d86df21f2e1677cc4bd090cd0e446278bcbbe49bf3659c308c3e402843e",
+ "sha256:5ed0ca58586c8d9a487352a96b15272b7fa55d139fc8496b519e78023a8dab0a",
+ "sha256:6c54a4a82e291a1fee5137371ab488866b7c86a3305af4026bdd4dc78642e1ac",
+ "sha256:6e131579c243c98f35bce64a7e08e87fb2d610544754675d4a0e73a070a5aa3a",
+ "sha256:855822d90f884905362f602880ed8b5df1b7e3ee7d0db2502d4388a954cc8c54",
+ "sha256:86a8b5035fce64f5dcd1b794cf8ec4d31fe458cf6ce3986a30deb434df82a1d2",
+ "sha256:8a33d657f3276328ce00e4d37fe70361e1ec7614da5d7b6e78de5426cb56332f",
+ "sha256:92c0ec1f2cc149551a2b7b47efc32c866406b6891b0ee4625e95967c8f4acfb1",
+ "sha256:9a5e9f45e5d5e1c5b5c29b3bd4265dcc90e8b92cf4534520896ed77f791f4da5",
+ "sha256:afc622538b430aa4c8c853f7f63bc582b3b8030fd8c80b70fb5fa5b834e575c2",
+ "sha256:b07fc0dab849d24a80a29cfab8d8a19187d1c4685d8a5e6385a5ce323c1f015f",
+ "sha256:b5e6f89631eb88a7302d416594a32faeee9fb8fb848290da9d0a5f2903519fc1",
+ "sha256:bf9bf162ed91a26f1adba8efda0b573bc6924ec1408a52cc6f82cb73ec2b142c",
+ "sha256:c7e72339f841b5a237ff14f7d3880ddd0fc7f98a1199e8c4327f9a4f478c1839",
+ "sha256:ddb113db38838eb9f043623ba274cfaf7d51d5b0c22ecb30afe58b1bb8322983",
+ "sha256:dfdd51fc3e64ea4f35873d1b3fb25326773d55d2329ff8449139ebaad7357efb",
+ "sha256:f1cd08e99d2f9317292a311dfe578fd2a24b15dbce97792f9c4d752275c1fa56",
+ "sha256:f89f2ab047c76a9c03f78d0d66ca519e389519902fa27e7a91117ef7611c0568"
],
"index": "pypi",
"markers": "python_version >= '3.10'",
- "version": "==26.1.0"
+ "version": "==26.3.1"
},
"certifi": {
"hashes": [
- "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b",
- "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316"
+ "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa",
+ "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7"
],
"markers": "python_version >= '3.7'",
- "version": "==2025.11.12"
+ "version": "==2026.2.25"
},
"chardet": {
"hashes": [
@@ -89,122 +89,138 @@
},
"charset-normalizer": {
"hashes": [
- "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad",
- "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93",
- "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394",
- "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89",
- "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc",
- "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86",
- "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63",
- "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d",
- "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f",
- "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8",
- "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0",
- "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505",
- "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161",
- "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af",
- "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152",
- "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318",
- "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72",
- "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4",
- "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e",
- "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3",
- "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576",
- "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c",
- "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1",
- "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8",
- "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1",
- "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2",
- "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44",
- "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26",
- "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88",
- "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016",
- "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede",
- "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf",
- "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a",
- "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc",
- "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0",
- "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84",
- "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db",
- "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1",
- "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7",
- "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed",
- "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8",
- "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133",
- "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e",
- "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef",
- "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14",
- "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2",
- "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0",
- "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d",
- "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828",
- "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f",
- "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf",
- "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6",
- "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328",
- "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090",
- "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa",
- "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381",
- "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c",
- "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb",
- "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc",
- "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a",
- "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec",
- "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc",
- "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac",
- "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e",
- "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313",
- "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569",
- "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3",
- "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d",
- "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525",
- "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894",
- "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3",
- "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9",
- "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a",
- "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9",
- "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14",
- "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25",
- "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50",
- "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf",
- "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1",
- "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3",
- "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac",
- "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e",
- "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815",
- "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c",
- "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6",
- "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6",
- "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e",
- "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4",
- "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84",
- "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69",
- "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15",
- "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191",
- "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0",
- "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897",
- "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd",
- "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2",
- "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794",
- "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d",
- "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074",
- "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3",
- "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224",
- "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838",
- "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a",
- "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d",
- "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d",
- "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f",
- "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8",
- "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490",
- "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966",
- "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9",
- "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3",
- "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e",
- "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608"
+ "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e",
+ "sha256:0c173ce3a681f309f31b87125fecec7a5d1347261ea11ebbb856fa6006b23c8c",
+ "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5",
+ "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815",
+ "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f",
+ "sha256:150b8ce8e830eb7ccb029ec9ca36022f756986aaaa7956aad6d9ec90089338c0",
+ "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484",
+ "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407",
+ "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6",
+ "sha256:1cf0a70018692f85172348fe06d3a4b63f94ecb055e13a00c644d368eb82e5b8",
+ "sha256:1ed80ff870ca6de33f4d953fda4d55654b9a2b340ff39ab32fa3adbcd718f264",
+ "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815",
+ "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2",
+ "sha256:259695e2ccc253feb2a016303543d691825e920917e31f894ca1a687982b1de4",
+ "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579",
+ "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f",
+ "sha256:2bd9d128ef93637a5d7a6af25363cf5dec3fa21cf80e68055aad627f280e8afa",
+ "sha256:2e1d8ca8611099001949d1cdfaefc510cf0f212484fe7c565f735b68c78c3c95",
+ "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab",
+ "sha256:2f7fdd9b6e6c529d6a2501a2d36b240109e78a8ceaef5687cfcfa2bbe671d297",
+ "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a",
+ "sha256:31215157227939b4fb3d740cd23fe27be0439afef67b785a1eb78a3ae69cba9e",
+ "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84",
+ "sha256:3516bbb8d42169de9e61b8520cbeeeb716f12f4ecfe3fd30a9919aa16c806ca8",
+ "sha256:3778fd7d7cd04ae8f54651f4a7a0bd6e39a0cf20f801720a4c21d80e9b7ad6b0",
+ "sha256:39f5068d35621da2881271e5c3205125cc456f54e9030d3f723288c873a71bf9",
+ "sha256:404a1e552cf5b675a87f0651f8b79f5f1e6fd100ee88dc612f89aa16abd4486f",
+ "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1",
+ "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843",
+ "sha256:4482481cb0572180b6fd976a4d5c72a30263e98564da68b86ec91f0fe35e8565",
+ "sha256:461598cd852bfa5a61b09cae2b1c02e2efcd166ee5516e243d540ac24bfa68a7",
+ "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c",
+ "sha256:48696db7f18afb80a068821504296eb0787d9ce239b91ca15059d1d3eaacf13b",
+ "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7",
+ "sha256:4d1d02209e06550bdaef34af58e041ad71b88e624f5d825519da3a3308e22687",
+ "sha256:4f41da960b196ea355357285ad1316a00099f22d0929fe168343b99b254729c9",
+ "sha256:517ad0e93394ac532745129ceabdf2696b609ec9f87863d337140317ebce1c14",
+ "sha256:51fb3c322c81d20567019778cb5a4a6f2dc1c200b886bc0d636238e364848c89",
+ "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f",
+ "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0",
+ "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9",
+ "sha256:54fae94be3d75f3e573c9a1b5402dc593de19377013c9a0e4285e3d402dd3a2a",
+ "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389",
+ "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0",
+ "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30",
+ "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd",
+ "sha256:5feb91325bbceade6afab43eb3b508c63ee53579fe896c77137ded51c6b6958e",
+ "sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9",
+ "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc",
+ "sha256:659a1e1b500fac8f2779dd9e1570464e012f43e580371470b45277a27baa7532",
+ "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d",
+ "sha256:69dd852c2f0ad631b8b60cfbe25a28c0058a894de5abb566619c205ce0550eae",
+ "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2",
+ "sha256:71be7e0e01753a89cf024abf7ecb6bca2c81738ead80d43004d9b5e3f1244e64",
+ "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f",
+ "sha256:74a2e659c7ecbc73562e2a15e05039f1e22c75b7c7618b4b574a3ea9118d1557",
+ "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e",
+ "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff",
+ "sha256:7a6967aaf043bceabab5412ed6bd6bd26603dae84d5cb75bf8d9a74a4959d398",
+ "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db",
+ "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a",
+ "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43",
+ "sha256:802168e03fba8bbc5ce0d866d589e4b1ca751d06edee69f7f3a19c5a9fe6b597",
+ "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c",
+ "sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e",
+ "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2",
+ "sha256:8761ac29b6c81574724322a554605608a9960769ea83d2c73e396f3df896ad54",
+ "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e",
+ "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4",
+ "sha256:8bc5f0687d796c05b1e28ab0d38a50e6309906ee09375dd3aff6a9c09dd6e8f4",
+ "sha256:8bea55c4eef25b0b19a0337dc4e3f9a15b00d569c77211fa8cde38684f234fb7",
+ "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6",
+ "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5",
+ "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194",
+ "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69",
+ "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f",
+ "sha256:97d0235baafca5f2b09cf332cc275f021e694e8362c6bb9c96fc9a0eb74fc316",
+ "sha256:9ca4c0b502ab399ef89248a2c84c54954f77a070f28e546a85e91da627d1301e",
+ "sha256:9cc4fc6c196d6a8b76629a70ddfcd4635a6898756e2d9cac5565cf0654605d73",
+ "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8",
+ "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923",
+ "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88",
+ "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f",
+ "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21",
+ "sha256:a9e68c9d88823b274cf1e72f28cb5dc89c990edf430b0bfd3e2fb0785bfeabf4",
+ "sha256:aa9cccf4a44b9b62d8ba8b4dd06c649ba683e4bf04eea606d2e94cfc2d6ff4d6",
+ "sha256:ab30e5e3e706e3063bc6de96b118688cb10396b70bb9864a430f67df98c61ecc",
+ "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2",
+ "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866",
+ "sha256:b35b200d6a71b9839a46b9b7fff66b6638bb52fc9658aa58796b0326595d3021",
+ "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2",
+ "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d",
+ "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8",
+ "sha256:bc72863f4d9aba2e8fd9085e63548a324ba706d2ea2c83b260da08a59b9482de",
+ "sha256:bf625105bb9eef28a56a943fec8c8a98aeb80e7d7db99bd3c388137e6eb2d237",
+ "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4",
+ "sha256:c45a03a4c69820a399f1dda9e1d8fbf3562eda46e7720458180302021b08f778",
+ "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb",
+ "sha256:c907cdc8109f6c619e6254212e794d6548373cc40e1ec75e6e3823d9135d29cc",
+ "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602",
+ "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4",
+ "sha256:d08ec48f0a1c48d75d0356cea971921848fb620fdeba805b28f937e90691209f",
+ "sha256:d1a2ee9c1499fc8f86f4521f27a973c914b211ffa87322f4ee33bb35392da2c5",
+ "sha256:d5f5d1e9def3405f60e3ca8232d56f35c98fb7bf581efcc60051ebf53cb8b611",
+ "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8",
+ "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf",
+ "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d",
+ "sha256:dad6e0f2e481fffdcf776d10ebee25e0ef89f16d691f1e5dee4b586375fdc64b",
+ "sha256:dda86aba335c902b6149a02a55b38e96287157e609200811837678214ba2b1db",
+ "sha256:df01808ee470038c3f8dc4f48620df7225c49c2d6639e38f96e6d6ac6e6f7b0e",
+ "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077",
+ "sha256:e25369dc110d58ddf29b949377a93e0716d72a24f62bad72b2b39f155949c1fd",
+ "sha256:e3c701e954abf6fc03a49f7c579cc80c2c6cc52525340ca3186c41d3f33482ef",
+ "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e",
+ "sha256:e68c14b04827dd76dcbd1aeea9e604e3e4b78322d8faf2f8132c7138efa340a8",
+ "sha256:e8aeb10fcbe92767f0fa69ad5a72deca50d0dca07fbde97848997d778a50c9fe",
+ "sha256:e985a16ff513596f217cee86c21371b8cd011c0f6f056d0920aa2d926c544058",
+ "sha256:ecbbd45615a6885fe3240eb9db73b9e62518b611850fdf8ab08bd56de7ad2b17",
+ "sha256:ee4ec14bc1680d6b0afab9aea2ef27e26d2024f18b24a2d7155a52b60da7e833",
+ "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421",
+ "sha256:f0cdaecd4c953bfae0b6bb64910aaaca5a424ad9c72d85cb88417bb9814f7550",
+ "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff",
+ "sha256:f50498891691e0864dc3da965f340fada0771f6142a378083dc4608f4ea513e2",
+ "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc",
+ "sha256:f61aa92e4aad0be58eb6eb4e0c21acf32cf8065f4b2cae5665da756c4ceef982",
+ "sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d",
+ "sha256:f820f24b09e3e779fe84c3c456cb4108a7aa639b0d1f02c28046e11bfcd088ed",
+ "sha256:f98059e4fcd3e3e4e2d632b7cf81c2faae96c43c60b569e9c621468082f1d104",
+ "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659"
],
"markers": "python_version >= '3.7'",
- "version": "==3.4.4"
+ "version": "==3.4.6"
},
"click": {
"hashes": [
@@ -247,19 +263,19 @@
},
"gitpython": {
"hashes": [
- "sha256:9e0e10cda9bed1ee64bc9a6de50e7e38a9c9943241cd7f585f6df3ed28011110",
- "sha256:c87e30b26253bf5418b01b0660f818967f3c503193838337fe5e573331249269"
+ "sha256:400124c7d0ef4ea03f7310ac2fbf7151e09ff97f2a3288d64a440c584a29c37f",
+ "sha256:79812ed143d9d25b6d176a10bb511de0f9c67b1fa641d82097b0ab90398a2058"
],
"markers": "python_version >= '3.7'",
- "version": "==3.1.44"
+ "version": "==3.1.46"
},
- "griffe": {
+ "griffelib": {
"hashes": [
- "sha256:6f6762661949411031f5fcda9593f586e6ce8340f0ba88921a0f2ef7a81eb9a3",
- "sha256:7726e3afd6f298fbc3696e67958803e7ac843c1cfe59734b6251a40cdbfb5eea"
+ "sha256:3cf20b3bc470e83763ffbf236e0076b1211bac1bc67de13daf494640f2de707e",
+ "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1"
],
"markers": "python_version >= '3.10'",
- "version": "==1.15.0"
+ "version": "==2.0.2"
},
"idna": {
"hashes": [
@@ -279,19 +295,19 @@
},
"markdown": {
"hashes": [
- "sha256:1c19c10bd5c14ac948c53d0d762a04e2fa35a6d58a6b7b1e6bfcbe6fefc0001a",
- "sha256:867d788939fe33e4b736426f5b9f651ad0c0ae0ecf89df0ca5d1176c70812fe3"
+ "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950",
+ "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36"
],
"markers": "python_version >= '3.10'",
- "version": "==3.10.1"
+ "version": "==3.10.2"
},
"markdown-it-py": {
"hashes": [
- "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1",
- "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"
+ "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147",
+ "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"
],
- "markers": "python_version >= '3.8'",
- "version": "==3.0.0"
+ "markers": "python_version >= '3.10'",
+ "version": "==4.0.0"
},
"markupsafe": {
"hashes": [
@@ -415,11 +431,11 @@
},
"mkdocs-autorefs": {
"hashes": [
- "sha256:469d85eb3114801d08e9cc55d102b3ba65917a869b893403b8987b601cf55dc9",
- "sha256:beee715b254455c4aa93b6ef3c67579c399ca092259cc41b7d9342573ff1fc75"
+ "sha256:834ef5408d827071ad1bc69e0f39704fa34c7fc05bc8e1c72b227dfdc5c76089",
+ "sha256:d54a284f27a7346b9c38f1f852177940c222da508e66edc816a0fa55fc6da197"
],
"markers": "python_version >= '3.9'",
- "version": "==1.4.3"
+ "version": "==1.4.4"
},
"mkdocs-exclude": {
"hashes": [
@@ -430,38 +446,38 @@
},
"mkdocs-gen-files": {
"hashes": [
- "sha256:52022dc14dcc0451e05e54a8f5d5e7760351b6701eff816d1e9739577ec5635e",
- "sha256:815af15f3e2dbfda379629c1b95c02c8e6f232edf2a901186ea3b204ab1135b2"
+ "sha256:57d7ff2229e23d077e46d14a33db6d37c8823f6ce1a503c874c1764a71679763",
+ "sha256:b3182bfc6219e35b8d26658cb988368659d5d023aac30c2a819247558fc12189"
],
"index": "pypi",
"markers": "python_version >= '3.9'",
- "version": "==0.6.0"
+ "version": "==0.6.1"
},
"mkdocs-get-deps": {
"hashes": [
- "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c",
- "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134"
+ "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1",
+ "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650"
],
- "markers": "python_version >= '3.8'",
- "version": "==0.2.0"
+ "markers": "python_version >= '3.9'",
+ "version": "==0.2.2"
},
"mkdocs-literate-nav": {
"hashes": [
- "sha256:0a6489a26ec7598477b56fa112056a5e3a6c15729f0214bea8a4dbc55bd5f630",
- "sha256:760e1708aa4be86af81a2b56e82c739d5a8388a0eab1517ecfd8e5aa40810a75"
+ "sha256:2c421561280fa9184f88cbf399bebbd4cc17ee507e978a31ce11fd6f3aabf233",
+ "sha256:edbaca22343f861fe4e34aac47d55a0c9955c640dbf02eea99fe631e914cf9ee"
],
"index": "pypi",
"markers": "python_version >= '3.9'",
- "version": "==0.6.2"
+ "version": "==0.6.3"
},
"mkdocs-material": {
"hashes": [
- "sha256:3f6100937d7d731f87f1e3e3b021c97f7239666b9ba1151ab476cabb96c60d5c",
- "sha256:89601b8f2c3e6c6ee0a918cc3566cb201d40bf37c3cd3c2067e26fadb8cce2b8"
+ "sha256:00bdde50574f776d328b1862fe65daeaf581ec309bd150f7bff345a098c64a69",
+ "sha256:71b84353921b8ea1ba84fe11c50912cc512da8fe0881038fcc9a0761c0e635ba"
],
"index": "pypi",
"markers": "python_version >= '3.8'",
- "version": "==9.7.1"
+ "version": "==9.7.6"
},
"mkdocs-material-extensions": {
"hashes": [
@@ -473,30 +489,30 @@
},
"mkdocs-section-index": {
"hashes": [
- "sha256:a82afbda633c82c5568f0e3b008176b9b365bf4bd8b6f919d6eff09ee146b9f8",
- "sha256:bc27c0d0dc497c0ebaee1fc72839362aed77be7318b5ec0c30628f65918e4776"
+ "sha256:26f008f4860789e6c41dce868e3e1dcd1528f8cbc1db181416c5edc18f0f15a0",
+ "sha256:81a5948af0e974bfb474f40b45aeddbb621024ff132eb8ace8854b9db6b41812"
],
"index": "pypi",
"markers": "python_version >= '3.9'",
- "version": "==0.3.10"
+ "version": "==0.3.11"
},
"mkdocstrings": {
"hashes": [
- "sha256:41897815a8026c3634fe5d51472c3a569f92ded0ad8c7a640550873eea3b6817",
- "sha256:48edd0ccbcb9e30a3121684e165261a9d6af4d63385fc4f39a54a49ac3b32ea8"
+ "sha256:0d66d18430c2201dc7fe85134277382baaa15e6b30979f3f3bdbabd6dbdb6046",
+ "sha256:ab670f55040722b49bb45865b2e93b824450fb4aef638b00d7acb493a9020434"
],
"index": "pypi",
"markers": "python_version >= '3.10'",
- "version": "==1.0.2"
+ "version": "==1.0.3"
},
"mkdocstrings-python": {
"hashes": [
- "sha256:66ecff45c5f8b71bf174e11d49afc845c2dfc7fc0ab17a86b6b337e0f24d8d90",
- "sha256:843a562221e6a471fefdd4b45cc6c22d2607ccbad632879234fa9692e9cf7732"
+ "sha256:0b83513478bdfd803ff05aa43e9b1fca9dd22bcd9471f09ca6257f009bc5ee12",
+ "sha256:c518632751cc869439b31c9d3177678ad2bfa5c21b79b863956ad68fc92c13b8"
],
"index": "pypi",
"markers": "python_version >= '3.10'",
- "version": "==2.0.1"
+ "version": "==2.0.3"
},
"mypy-extensions": {
"hashes": [
@@ -531,36 +547,44 @@
},
"platformdirs": {
"hashes": [
- "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda",
- "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31"
+ "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934",
+ "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868"
],
"markers": "python_version >= '3.10'",
- "version": "==4.5.1"
+ "version": "==4.9.4"
+ },
+ "properdocs": {
+ "hashes": [
+ "sha256:6fa0cfa2e01bf338f684892c8a506cf70ea88ae7f3479c933b6fa20168101cbd",
+ "sha256:adc7b16e562890af0e098a7e5b02e3a81c20894a87d6a28d345c9300de73c26e"
+ ],
+ "markers": "python_version >= '3.9'",
+ "version": "==1.6.7"
},
"pygments": {
"hashes": [
- "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887",
- "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"
+ "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f",
+ "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"
],
- "markers": "python_version >= '3.8'",
- "version": "==2.19.2"
+ "markers": "python_version >= '3.9'",
+ "version": "==2.20.0"
},
"pygount": {
"hashes": [
- "sha256:2f4b064ac1689ba43bdf84bef244e7a45f27e315feae6fb19d1209c56a580361",
- "sha256:974aaf7a77d7f93aefc220639a897364cdd88a77e5a102d7063f73f8b7100955"
+ "sha256:1ceffaab9a72357bcf28cb13b8b51c1399b8f9b67798008fdf3d27f8cd1d8c09",
+ "sha256:404068a91019a0d3e451dbdee4e78c39cc30a4700c2a10245337af64681a2376"
],
"index": "pypi",
- "markers": "python_version >= '3.9' and python_version < '4'",
- "version": "==3.1.0"
+ "markers": "python_version >= '3.10' and python_version < '4'",
+ "version": "==3.1.1"
},
"pymdown-extensions": {
"hashes": [
- "sha256:24af7feacbca56504b313b7b418c4f5e1317bb5fea60f03d57be7fcc40912aa0",
- "sha256:e7e39c865727338d434b55f1dd8da51febcffcaebd6e1a0b9c836243f660740a"
+ "sha256:5c0fd2a2bea14eb39af8ff284f1066d898ab2187d81b889b75d46d4348c01638",
+ "sha256:c3f55a5b8a1d0edf6699e35dcbea71d978d34ff3fa79f3d807b8a5b3fa90fbdc"
],
"markers": "python_version >= '3.9'",
- "version": "==10.20.1"
+ "version": "==10.21.2"
},
"python-dateutil": {
"hashes": [
@@ -706,157 +730,56 @@
"markers": "python_version >= '3.9'",
"version": "==1.1"
},
- "regex": {
- "hashes": [
- "sha256:02a02d2bb04fec86ad61f3ea7f49c015a0681bf76abb9857f945d26159d2968c",
- "sha256:02e28184be537f0e75c1f9b2f8847dc51e08e6e171c6bde130b2687e0c33cf60",
- "sha256:040df6fe1a5504eb0f04f048e6d09cd7c7110fef851d7c567a6b6e09942feb7d",
- "sha256:068376da5a7e4da51968ce4c122a7cd31afaaec4fccc7856c92f63876e57b51d",
- "sha256:06eb1be98df10e81ebaded73fcd51989dcf534e3c753466e4b60c4697a003b67",
- "sha256:072623554418a9911446278f16ecb398fb3b540147a7828c06e2011fa531e773",
- "sha256:086a27a0b4ca227941700e0b31425e7a28ef1ae8e5e05a33826e17e47fbfdba0",
- "sha256:08986dce1339bc932923e7d1232ce9881499a0e02925f7402fb7c982515419ef",
- "sha256:0a86e7eeca091c09e021db8eb72d54751e527fa47b8d5787caf96d9831bd02ad",
- "sha256:0c32f75920cf99fe6b6c539c399a4a128452eaf1af27f39bce8909c9a3fd8cbe",
- "sha256:0d7f453dca13f40a02b79636a339c5b62b670141e63efd511d3f8f73fba162b3",
- "sha256:1062b39a0a2b75a9c694f7a08e7183a80c63c0d62b301418ffd9c35f55aaa114",
- "sha256:13291b39131e2d002a7940fb176e120bec5145f3aeb7621be6534e46251912c4",
- "sha256:149f5008d286636e48cd0b1dd65018548944e495b0265b45e1bffecce1ef7f39",
- "sha256:164d8b7b3b4bcb2068b97428060b2a53be050085ef94eca7f240e7947f1b080e",
- "sha256:167ed4852351d8a750da48712c3930b031f6efdaa0f22fa1933716bfcd6bf4a3",
- "sha256:1c4de13f06a0d54fa0d5ab1b7138bfa0d883220965a29616e3ea61b35d5f5fc7",
- "sha256:202eb32e89f60fc147a41e55cb086db2a3f8cb82f9a9a88440dcfc5d37faae8d",
- "sha256:220902c3c5cc6af55d4fe19ead504de80eb91f786dc102fbd74894b1551f095e",
- "sha256:2b3361af3198667e99927da8b84c1b010752fa4b1115ee30beaa332cabc3ef1a",
- "sha256:2c89a8cc122b25ce6945f0423dc1352cb9593c68abd19223eebbd4e56612c5b7",
- "sha256:2d548dafee61f06ebdb584080621f3e0c23fff312f0de1afc776e2a2ba99a74f",
- "sha256:2e34b51b650b23ed3354b5a07aab37034d9f923db2a40519139af34f485f77d0",
- "sha256:32f9a4c643baad4efa81d549c2aadefaeba12249b2adc5af541759237eee1c54",
- "sha256:3a51ccc315653ba012774efca4f23d1d2a8a8f278a6072e29c7147eee7da446b",
- "sha256:3cde6e9f2580eb1665965ce9bf17ff4952f34f5b126beb509fee8f4e994f143c",
- "sha256:40291b1b89ca6ad8d3f2b82782cc33807f1406cf68c8d440861da6304d8ffbbd",
- "sha256:41758407fc32d5c3c5de163888068cfee69cb4c2be844e7ac517a52770f9af57",
- "sha256:4181b814e56078e9b00427ca358ec44333765f5ca1b45597ec7446d3a1ef6e34",
- "sha256:4f51f88c126370dcec4908576c5a627220da6c09d0bff31cfa89f2523843316d",
- "sha256:50153825ee016b91549962f970d6a4442fa106832e14c918acd1c8e479916c4f",
- "sha256:5056b185ca113c88e18223183aa1a50e66507769c9640a6ff75859619d73957b",
- "sha256:5071b2093e793357c9d8b2929dfc13ac5f0a6c650559503bb81189d0a3814519",
- "sha256:525eab0b789891ac3be914d36893bdf972d483fe66551f79d3e27146191a37d4",
- "sha256:52fb28f528778f184f870b7cf8f225f5eef0a8f6e3778529bdd40c7b3920796a",
- "sha256:5478c6962ad548b54a591778e93cd7c456a7a29f8eca9c49e4f9a806dcc5d638",
- "sha256:5670bce7b200273eee1840ef307bfa07cda90b38ae56e9a6ebcc9f50da9c469b",
- "sha256:5704e174f8ccab2026bd2f1ab6c510345ae8eac818b613d7d73e785f1310f839",
- "sha256:59dfe1ed21aea057a65c6b586afd2a945de04fc7db3de0a6e3ed5397ad491b07",
- "sha256:5e7e351589da0850c125f1600a4c4ba3c722efefe16b297de54300f08d734fbf",
- "sha256:63b13cfd72e9601125027202cad74995ab26921d8cd935c25f09c630436348ff",
- "sha256:658f90550f38270639e83ce492f27d2c8d2cd63805c65a13a14d36ca126753f0",
- "sha256:684d7a212682996d21ca12ef3c17353c021fe9de6049e19ac8481ec35574a70f",
- "sha256:69ab78f848845569401469da20df3e081e6b5a11cb086de3eed1d48f5ed57c95",
- "sha256:6f44ec28b1f858c98d3036ad5d7d0bfc568bdd7a74f9c24e25f41ef1ebfd81a4",
- "sha256:70b7fa6606c2881c1db9479b0eaa11ed5dfa11c8d60a474ff0e095099f39d98e",
- "sha256:764e71f22ab3b305e7f4c21f1a97e1526a25ebdd22513e251cf376760213da13",
- "sha256:7ab159b063c52a0333c884e4679f8d7a85112ee3078fe3d9004b2dd875585519",
- "sha256:805e6b60c54bf766b251e94526ebad60b7de0c70f70a4e6210ee2891acb70bf2",
- "sha256:8447d2d39b5abe381419319f942de20b7ecd60ce86f16a23b0698f22e1b70008",
- "sha256:86fddba590aad9208e2fa8b43b4c098bb0ec74f15718bb6a704e3c63e2cef3e9",
- "sha256:89d75e7293d2b3e674db7d4d9b1bee7f8f3d1609428e293771d1a962617150cc",
- "sha256:93c0b12d3d3bc25af4ebbf38f9ee780a487e8bf6954c115b9f015822d3bb8e48",
- "sha256:94d87b689cdd831934fa3ce16cc15cd65748e6d689f5d2b8f4f4df2065c9fa20",
- "sha256:9714398225f299aa85267fd222f7142fcb5c769e73d7733344efc46f2ef5cf89",
- "sha256:982e6d21414e78e1f51cf595d7f321dcd14de1f2881c5dc6a6e23bbbbd68435e",
- "sha256:997d6a487ff00807ba810e0f8332c18b4eb8d29463cfb7c820dc4b6e7562d0cf",
- "sha256:a03e02f48cd1abbd9f3b7e3586d97c8f7a9721c436f51a5245b3b9483044480b",
- "sha256:a36fdf2af13c2b14738f6e973aba563623cb77d753bbbd8d414d18bfaa3105dd",
- "sha256:a6ba92c0bcdf96cbf43a12c717eae4bc98325ca3730f6b130ffa2e3c3c723d84",
- "sha256:a7c2155f790e2fb448faed6dd241386719802296ec588a8b9051c1f5c481bc29",
- "sha256:a93c194e2df18f7d264092dc8539b8ffb86b45b899ab976aa15d48214138e81b",
- "sha256:abfa5080c374a76a251ba60683242bc17eeb2c9818d0d30117b4486be10c59d3",
- "sha256:ac10f2c4184420d881a3475fb2c6f4d95d53a8d50209a2500723d831036f7c45",
- "sha256:ad182d02e40de7459b73155deb8996bbd8e96852267879396fb274e8700190e3",
- "sha256:b2837718570f95dd41675328e111345f9b7095d821bac435aac173ac80b19983",
- "sha256:b489578720afb782f6ccf2840920f3a32e31ba28a4b162e13900c3e6bd3f930e",
- "sha256:b583904576650166b3d920d2bcce13971f6f9e9a396c673187f49811b2769dc7",
- "sha256:b85c2530be953a890eaffde05485238f07029600e8f098cdf1848d414a8b45e4",
- "sha256:b97c1e0bd37c5cd7902e65f410779d39eeda155800b65fc4d04cc432efa9bc6e",
- "sha256:ba9b72e5643641b7d41fa1f6d5abda2c9a263ae835b917348fc3c928182ad467",
- "sha256:bb26437975da7dc36b7efad18aa9dd4ea569d2357ae6b783bf1118dabd9ea577",
- "sha256:bb8f74f2f10dbf13a0be8de623ba4f9491faf58c24064f32b65679b021ed0001",
- "sha256:bde01f35767c4a7899b7eb6e823b125a64de314a8ee9791367c9a34d56af18d0",
- "sha256:bec9931dfb61ddd8ef2ebc05646293812cb6b16b60cf7c9511a832b6f1854b55",
- "sha256:c36f9b6f5f8649bb251a5f3f66564438977b7ef8386a52460ae77e6070d309d9",
- "sha256:cdf58d0e516ee426a48f7b2c03a332a4114420716d55769ff7108c37a09951bf",
- "sha256:d1cee317bfc014c2419a76bcc87f071405e3966da434e03e13beb45f8aced1a6",
- "sha256:d22326fcdef5e08c154280b71163ced384b428343ae16a5ab2b3354aed12436e",
- "sha256:d3660c82f209655a06b587d55e723f0b813d3a7db2e32e5e7dc64ac2a9e86fde",
- "sha256:da8f5fc57d1933de22a9e23eec290a0d8a5927a5370d24bda9a6abe50683fe62",
- "sha256:df951c5f4a1b1910f1a99ff42c473ff60f8225baa1cdd3539fe2819d9543e9df",
- "sha256:e5364a4502efca094731680e80009632ad6624084aff9a23ce8c8c6820de3e51",
- "sha256:ea1bfda2f7162605f6e8178223576856b3d791109f15ea99a9f95c16a7636fb5",
- "sha256:f02f93b92358ee3f78660e43b4b0091229260c5d5c408d17d60bf26b6c900e86",
- "sha256:f056bf21105c2515c32372bbc057f43eb02aae2fda61052e2f7622c801f0b4e2",
- "sha256:f1ac758ef6aebfc8943560194e9fd0fa18bcb34d89fd8bd2af18183afd8da3a2",
- "sha256:f2a19f302cd1ce5dd01a9099aaa19cae6173306d1302a43b627f62e21cf18ac0",
- "sha256:f654882311409afb1d780b940234208a252322c24a93b442ca714d119e68086c",
- "sha256:f65557897fc977a44ab205ea871b690adaef6b9da6afda4790a2484b04293a5f",
- "sha256:f9d1e379028e0fc2ae3654bac3cbbef81bf3fd571272a42d56c24007979bafb6",
- "sha256:fdabbfc59f2c6edba2a6622c647b716e34e8e3867e0ab975412c5c2f79b82da2",
- "sha256:fdd6028445d2460f33136c55eeb1f601ab06d74cb3347132e1c24250187500d9",
- "sha256:ff590880083d60acc0433f9c3f713c51f7ac6ebb9adf889c79a261ecf541aa91"
- ],
- "markers": "python_version >= '3.8'",
- "version": "==2024.11.6"
- },
"requests": {
"hashes": [
- "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6",
- "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"
+ "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517",
+ "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a"
],
"index": "pypi",
- "markers": "python_version >= '3.9'",
- "version": "==2.32.5"
+ "markers": "python_version >= '3.10'",
+ "version": "==2.33.1"
},
"rich": {
"hashes": [
- "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0",
- "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725"
+ "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d",
+ "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b"
],
"markers": "python_full_version >= '3.8.0'",
- "version": "==14.0.0"
+ "version": "==14.3.3"
},
"ruff": {
"hashes": [
- "sha256:01ff589aab3f5b539e35db38425da31a57521efd1e4ad1ae08fc34dbe30bd7df",
- "sha256:026c1d25996818f0bf498636686199d9bd0d9d6341c9c2c3b62e2a0198b758de",
- "sha256:14649acb1cf7b5d2d283ebd2f58d56b75836ed8c6f329664fa91cdea19e76e66",
- "sha256:1629e67489c2dea43e8658c3dba659edbfd87361624b4040d1df04c9740ae906",
- "sha256:16bc890fb4cc9781bb05beb5ab4cd51be9e7cb376bf1dd3580512b24eb3fda2b",
- "sha256:1cc12d74eef0f29f51775f5b755913eb523546b88e2d733e1d701fe65144e89b",
- "sha256:27493a2131ea0f899057d49d303e4292b2cae2bb57253c1ed1f256fbcd1da480",
- "sha256:2d0f819c9a90205f3a867dbbd0be083bee9912e170fd7d9704cc8ae45824896b",
- "sha256:3c0f18b922c6d2ff9a5e6c3ee16259adc513ca775bcf82c67ebab7cbd9da5bc8",
- "sha256:3dde1435e6b6fe5b66506c1dff67a421d0b7f6488d466f651c07f4cab3bf20fd",
- "sha256:56e6981a98b13a32236a72a8da421d7839221fa308b223b9283312312e5ac76c",
- "sha256:6006a0082336e7920b9573ef8a7f52eec837add1265cc74e04ea8a4368cd704c",
- "sha256:7cfe36b56e8489dee8fbc777c61959f60ec0f1f11817e8f2415f429552846aed",
- "sha256:b530c191970b143375b6a68e6f743800b2b786bbcf03a7965b06c4bf04568167",
- "sha256:bb8481604b7a9e75eff53772496201690ce2687067e038b3cc31aaf16aa0b974",
- "sha256:cc8b22da8d9d6fdd844a68ae937e2a0adf9b16514e9a97cc60355e2d4b219fc3",
- "sha256:e651e977a79e4c758eb807f0481d673a67ffe53cfa92209781dfa3a996cf8412",
- "sha256:e8058d2145566510790eab4e2fad186002e288dec5e0d343a92fe7b0bc1b3e13",
- "sha256:f666445819d31210b71e0a6d1c01e24447a20b85458eea25a25fe8142210ae0e"
+ "sha256:04f79eff02a72db209d47d665ba7ebcad609d8918a134f86cb13dd132159fc89",
+ "sha256:0f29b989a55572fb885b77464cf24af05500806ab4edf9a0fd8977f9759d85b1",
+ "sha256:12e617fc01a95e5821648a6df341d80456bd627bfab8a829f7cfc26a14a4b4a3",
+ "sha256:2033f963c43949d51e6fdccd3946633c6b37c484f5f98c3035f49c27395a8ab8",
+ "sha256:432701303b26416d22ba696c39f2c6f12499b89093b61360abc34bcc9bf07762",
+ "sha256:6ee3ae5c65a42f273f126686353f2e08ff29927b7b7e203b711514370d500de3",
+ "sha256:75e5cd06b1cf3f47a3996cfc999226b19aa92e7cce682dcd62f80d7035f98f49",
+ "sha256:8d9a5b8ea13f26ae90838afc33f91b547e61b794865374f114f349e9036835fb",
+ "sha256:995f11f63597ee362130d1d5a327a87cb6f3f5eae3094c620bcc632329a4d26e",
+ "sha256:ac51d486bf457cdc985a412fb1801b2dfd1bd8838372fc55de64b1510eff4bec",
+ "sha256:bc1f0a51254ba21767bfa9a8b5013ca8149dcf38092e6a9eb704d876de94dc34",
+ "sha256:c2a33a529fb3cbc23a7124b5c6ff121e4d6228029cba374777bd7649cc8598b8",
+ "sha256:c9861eb959edab053c10ad62c278835ee69ca527b6dcd72b47d5c1e5648964f6",
+ "sha256:cbe05adeba76d58162762d6b239c9056f1a15a55bd4b346cfd21e26cd6ad7bc7",
+ "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2",
+ "sha256:d3e3d0b6ba8dca1b7ef9ab80a28e840a20070c4b62e56d675c24f366ef330570",
+ "sha256:d910ae974b7a06a33a057cb87d2a10792a3b2b3b35e33d2699fdf63ec8f6b17a",
+ "sha256:fdce027ada77baa448077ccc6ebb2fa9c3c62fd110d8659d601cf2f475858d94"
],
"index": "pypi",
"markers": "python_version >= '3.7'",
- "version": "==0.14.14"
+ "version": "==0.15.8"
},
"setuptools": {
"hashes": [
- "sha256:8b0e9d10c784bf7d262c4e5ec5d4ec94127ce206e8738f29a437945fbc219b70",
- "sha256:95b30ddfb717250edb492926c92b5221f7ef3fbcc2b07579bcd4a27da21d0173"
+ "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9",
+ "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb"
],
"index": "pypi",
"markers": "python_version >= '3.9'",
- "version": "==80.10.2"
+ "version": "==82.0.1"
},
"six": {
"hashes": [
@@ -868,79 +791,17 @@
},
"smmap": {
"hashes": [
- "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5",
- "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e"
+ "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c",
+ "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f"
],
"markers": "python_version >= '3.7'",
- "version": "==5.0.2"
- },
- "tomli": {
- "hashes": [
- "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729",
- "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b",
- "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d",
- "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df",
- "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576",
- "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d",
- "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1",
- "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a",
- "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e",
- "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc",
- "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702",
- "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6",
- "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd",
- "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4",
- "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776",
- "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a",
- "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66",
- "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87",
- "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2",
- "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f",
- "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475",
- "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f",
- "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95",
- "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9",
- "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3",
- "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9",
- "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76",
- "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da",
- "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8",
- "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51",
- "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86",
- "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8",
- "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0",
- "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b",
- "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1",
- "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e",
- "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d",
- "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c",
- "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867",
- "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a",
- "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c",
- "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0",
- "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4",
- "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614",
- "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132",
- "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa",
- "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087"
- ],
- "markers": "python_version >= '3.8'",
- "version": "==2.4.0"
- },
- "typing-extensions": {
- "hashes": [
- "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466",
- "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"
- ],
- "markers": "python_version >= '3.9'",
- "version": "==4.15.0"
+ "version": "==5.0.3"
},
"urllib3": {
"hashes": [
"sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed",
"sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"
],
- "index": "pypi",
"markers": "python_version >= '3.9'",
"version": "==2.6.3"
},
@@ -984,118 +845,123 @@
"develop": {
"astroid": {
"hashes": [
- "sha256:ac8fb7ca1c08eb9afec91ccc23edbd8ac73bb22cbdd7da1d488d9fb8d6579070",
- "sha256:d7546c00a12efc32650b19a2bb66a153883185d3179ab0d4868086f807338b9b"
+ "sha256:52f39653876c7dec3e3afd4c2696920e05c83832b9737afc21928f2d2eb7a753",
+ "sha256:986fed8bcf79fb82c78b18a53352a0b287a73817d6dbcfba3162da36667c49a0"
],
"markers": "python_full_version >= '3.10.0'",
- "version": "==4.0.2"
- },
- "colorama": {
- "hashes": [
- "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44",
- "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"
- ],
- "index": "pypi",
- "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6'",
- "version": "==0.4.6"
+ "version": "==4.0.4"
},
"dill": {
"hashes": [
- "sha256:0633f1d2df477324f53a895b02c901fb961bdbf65a17122586ea7019292cbcf0",
- "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049"
+ "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d",
+ "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa"
],
- "markers": "python_version >= '3.8'",
- "version": "==0.4.0"
+ "markers": "python_version >= '3.9'",
+ "version": "==0.4.1"
},
"isort": {
"hashes": [
- "sha256:1bcabac8bc3c36c7fb7b98a76c8abb18e0f841a3ba81decac7691008592499c1",
- "sha256:5513527951aadb3ac4292a41a16cbc50dd1642432f5e8c20057d414bdafb4187"
+ "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d",
+ "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75"
],
"markers": "python_full_version >= '3.10.0'",
- "version": "==7.0.0"
+ "version": "==8.0.1"
},
"librt": {
"hashes": [
- "sha256:060bde69c3604f694bd8ae21a780fe8be46bb3dbb863642e8dfc75c931ca8eee",
- "sha256:08153ea537609d11f774d2bfe84af39d50d5c9ca3a4d061d946e0c9d8bce04a1",
- "sha256:0b4791202296ad51ac09a3ff58eb49d9da8e3a4009167a6d76ac418a974e5fd4",
- "sha256:0b7f080ba30601dfa3e3deed3160352273e1b9bc92e652f51103c3e9298f7899",
- "sha256:0e2bf8f91093fac43e3eaebacf777f12fd539dce9ec5af3efc6d8424e96ccd49",
- "sha256:118716de5ad6726332db1801bc90fa6d94194cd2e07c1a7822cebf12c496714d",
- "sha256:168e04663e126416ba712114050f413ac306759a1791d87b7c11d4428ba75760",
- "sha256:191cbd42660446d67cf7a95ac7bfa60f49b8b3b0417c64f216284a1d86fc9335",
- "sha256:263f4fae9eba277513357c871275b18d14de93fd49bf5e43dc60a97b81ad5eb8",
- "sha256:265729b551a2dd329cc47b323a182fb7961af42abf21e913c9dd7d3331b2f3c2",
- "sha256:26b46620e1e0e45af510d9848ea0915e7040605dd2ae94ebefb6c962cbb6f7ec",
- "sha256:29c8d2fae11d4379ea207ba7fc69d43237e42cf8a9f90ec6e05993687e6d648b",
- "sha256:2fcbf2e135c11f721193aa5f42ba112bb1046afafbffd407cbc81d8d735c74d0",
- "sha256:39183abee670bc37b85f11e86c44a9cad1ed6efa48b580083e89ecee13dd9717",
- "sha256:3919c9407faeeee35430ae135e3a78acd4ecaaaa73767529e2c15ca1d73ba325",
- "sha256:3c98a8d0ac9e2a7cb8ff8c53e5d6e8d82bfb2839abf144fdeaaa832f2a12aa45",
- "sha256:3dd58f7ce20360c6ce0c04f7bd9081c7f9c19fc6129a3c705d0c5a35439f201d",
- "sha256:46aa91813c267c3f60db75d56419b42c0c0b9748ec2c568a0e3588e543fb4233",
- "sha256:49c596cd18e90e58b7caa4d7ca7606049c1802125fcff96b8af73fa5c3870e4d",
- "sha256:532ddc6a8a6ca341b1cd7f4d999043e4c71a212b26fe9fd2e7f1e8bb4e873544",
- "sha256:54d2aef0b0f5056f130981ad45081b278602ff3657fe16c88529f5058038e802",
- "sha256:553dc58987d1d853adda8aeadf4db8e29749f0b11877afcc429a9ad892818ae2",
- "sha256:584cb3e605ec45ba350962cec853e17be0a25a772f21f09f1e422f7044ae2a7d",
- "sha256:5cd3afd71e9bc146203b6c8141921e738364158d4aa7cdb9a874e2505163770f",
- "sha256:63055d3dda433ebb314c9f1819942f16a19203c454508fdb2d167613f7017169",
- "sha256:654fdc971c76348a73af5240d8e2529265b9a7ba6321e38dd5bae7b0d4ab3abe",
- "sha256:6b7b58913d475911f6f33e8082f19dd9b120c4f4a5c911d07e395d67b81c6982",
- "sha256:6e860909fea75baef941ee6436e0453612505883b9d0d87924d4fda27865b9a2",
- "sha256:730be847daad773a3c898943cf67fb9845a3961d06fb79672ceb0a8cd8624cfa",
- "sha256:732e0aa0385b59a1b2545159e781c792cc58ce9c134249233a7c7250a44684c4",
- "sha256:75965c1f4efb7234ff52a58b729d245a21e87e4b6a26a0ec08052f02b16274e4",
- "sha256:7a488908a470451338607650f1c064175094aedebf4a4fa37890682e30ce0b57",
- "sha256:81056e01bba1394f1d92904ec61a4078f66df785316275edbaf51d90da8c6e26",
- "sha256:82f3f088482e2229387eadf8215c03f7726d56f69cce8c0c40f0795aebc9b361",
- "sha256:84d4a6b9efd6124f728558a18e79e7cc5c5d4efc09b2b846c910de7e564f5bad",
- "sha256:85f485b7471571e99fab4f44eeb327dc0e1f814ada575f3fa85e698417d8a54e",
- "sha256:8a3cfb15961e7333ea6ef033dc574af75153b5c230d5ad25fbcd55198f21e0cf",
- "sha256:8bb7883c1e94ceb87c2bf81385266f032da09cd040e804cc002f2c9d6b842e2f",
- "sha256:8dcae24de1bc9da93aa689cb6313c70e776d7cea2fcf26b9b6160fedfe6bd9af",
- "sha256:9937574e6d842f359b8585903d04f5b4ab62277a091a93e02058158074dc52f2",
- "sha256:9bbb8facc5375476d392990dd6a71f97e4cb42e2ac66f32e860f6e47299d5e89",
- "sha256:9c08527055fbb03c641c15bbc5b79dd2942fb6a3bd8dabf141dd7e97eeea4904",
- "sha256:9cffa3ef0af29687455161cb446eff059bf27607f95163d6a37e27bcb37180f6",
- "sha256:9f85f9b5db87b0f52e53c68ad2a0c5a53e00afa439bd54a1723742a2b1021276",
- "sha256:a82d5a0ee43aeae2116d7292c77cc8038f4841830ade8aa922e098933b468b9e",
- "sha256:a9eacbf983319b26b5f340a2e0cd47ac1ee4725a7f3a72fd0f15063c934b69d6",
- "sha256:ab4b0d3bee6f6ff7017e18e576ac7e41a06697d8dea4b8f3ab9e0c8e1300c409",
- "sha256:af69d9e159575e877c7546d1ee817b4ae089aa221dd1117e20c24ad8dc8659c7",
- "sha256:b1795c4b2789b458fa290059062c2f5a297ddb28c31e704d27e161386469691a",
- "sha256:b591c094afd0ffda820e931148c9e48dc31a556dc5b2b9b3cc552fa710d858e4",
- "sha256:b8e0fd344bad57026a8f4ccfaf406486c2fc991838050c2fef156170edc3b775",
- "sha256:ba1077c562a046208a2dc6366227b3eeae8f2c2ab4b41eaf4fd2fa28cece4203",
- "sha256:bb41f04046b4f22b1e7ba5ef513402cd2e3477ec610e5f92d38fe2bba383d419",
- "sha256:c039bbf79a9a2498404d1ae7e29a6c175e63678d7a54013a97397c40aee026c5",
- "sha256:c566a4672564c5d54d8ab65cdaae5a87ee14c1564c1a2ddc7a9f5811c750f023",
- "sha256:cda8b025875946ffff5a9a7590bf9acde3eb02cb6200f06a2d3e691ef3d9955b",
- "sha256:cdb001a1a0e4f41e613bca2c0fc147fc8a7396f53fc94201cbfd8ec7cd69ca4b",
- "sha256:cdde31759bd8888f3ef0eebda80394a48961328a17c264dce8cc35f4b9cde35d",
- "sha256:d2cc7d187e8c6e9b7bdbefa9697ce897a704ea7a7ce844f2b4e0e2aa07ae51d3",
- "sha256:d7aa33153a5bb0bac783d2c57885889b1162823384e8313d47800a0e10d0070e",
- "sha256:d7c72c8756eeb3aefb1b9e3dac7c37a4a25db63640cac0ab6fc18e91a0edf05a",
- "sha256:dd810f2d39c526c42ea205e0addad5dc08ef853c625387806a29d07f9d150d9b",
- "sha256:ddc0ab9dbc5f9ceaf2bf7a367bf01f2697660e908f6534800e88f43590b271db",
- "sha256:ddc4a16207f88f9597b397fc1f60781266d13b13de922ff61c206547a29e4bbd",
- "sha256:de4221a1181fa9c8c4b5f35506ed6f298948f44003d84d2a8b9885d7e01e6cfa",
- "sha256:df2e210400b28e50994477ebf82f055698c79797b6ee47a1669d383ca33263e1",
- "sha256:df3146d52465b3b6397d25d513f428cb421c18df65b7378667bb5f1e3cc45805",
- "sha256:e47fc52602ffc374e69bf1b76536dc99f7f6dd876bd786c8213eaa3598be030a",
- "sha256:e9e9c988b5ffde7be02180f864cbd17c0b0c1231c235748912ab2afa05789c25",
- "sha256:ea1b60b86595a5dc1f57b44a801a1c4d8209c0a69518391d349973a4491408e6",
- "sha256:edf6b465306215b19dbe6c3fb63cf374a8f3e1ad77f3b4c16544b83033bbb67b",
- "sha256:f02c4337bf271c4f06637f5ff254fad2238c0b8e32a3a480ebb2fc5e26f754a5",
- "sha256:f7f51ffe59f4556243d3cc82d827bde74765f594fa3ceb80ec4de0c13ccd3416",
- "sha256:f952e1a78c480edee8fb43aa2bf2e84dcd46c917d44f8065b883079d3893e8fc",
- "sha256:fb565b4219abc8ea2402e61c7ba648a62903831059ed3564fa1245cc245d58d7",
- "sha256:fee15c2a190ef389f14928135c6fb2d25cd3fdb7887bfd9a7b444bbdc8c06b96"
+ "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6",
+ "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d",
+ "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440",
+ "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a",
+ "sha256:08eec3a1fc435f0d09c87b6bf1ec798986a3544f446b864e4099633a56fcd9ed",
+ "sha256:0bf69d79a23f4f40b8673a947a234baeeb133b5078b483b7297c5916539cf5d5",
+ "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04",
+ "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3",
+ "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d",
+ "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14",
+ "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972",
+ "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c",
+ "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78",
+ "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732",
+ "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c",
+ "sha256:22b46eabd76c1986ee7d231b0765ad387d7673bbd996aa0d0d054b38ac65d8f6",
+ "sha256:237796479f4d0637d6b9cbcb926ff424a97735e68ade6facf402df4ec93375ed",
+ "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1",
+ "sha256:2cc68eeeef5e906839c7bb0815748b5b0a974ec27125beefc0f942715785b551",
+ "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7",
+ "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382",
+ "sha256:3dff3d3ca8db20e783b1bc7de49c0a2ab0b8387f31236d6a026597d07fcd68ac",
+ "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a",
+ "sha256:439352ba9373f11cb8e1933da194dcc6206daf779ff8df0ed69c5e39113e6a99",
+ "sha256:4998009e7cb9e896569f4be7004f09d0ed70d386fa99d42b6d363f6d200501ac",
+ "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb",
+ "sha256:4beb04b8c66c6ae62f8c1e0b2f097c1ebad9295c929a8d5286c05eae7c2fc7dc",
+ "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7",
+ "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0",
+ "sha256:52c224e14614b750c0a6d97368e16804a98c684657c7518752c356834fff83bb",
+ "sha256:56e04c14b696300d47b3bc5f1d10a00e86ae978886d0cee14e5714fafb5df5d2",
+ "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7",
+ "sha256:5cdc0f588ff4b663ea96c26d2a230c525c6fc62b28314edaaaca8ed5af931ad0",
+ "sha256:5db05697c82b3a2ec53f6e72b2ed373132b0c2e05135f0696784e97d7f5d48e7",
+ "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363",
+ "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624",
+ "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9",
+ "sha256:64548cde61b692dc0dc379f4b5f59a2f582c2ebe7890d09c1ae3b9e66fa015b7",
+ "sha256:681dc2451d6d846794a828c16c22dc452d924e9f700a485b7ecb887a30aad1fd",
+ "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3",
+ "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f",
+ "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a",
+ "sha256:747328be0c5b7075cde86a0e09d7a9196029800ba75a1689332348e998fb85c0",
+ "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb",
+ "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e",
+ "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc",
+ "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071",
+ "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730",
+ "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35",
+ "sha256:81fd938344fecb9373ba1b155968c8a329491d2ce38e7ddb76f30ffb938f12dc",
+ "sha256:82210adabbc331dbb65d7868b105185464ef13f56f7f76688565ad79f648b0fe",
+ "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4",
+ "sha256:8f1125e6bbf2f1657d9a2f3ccc4a2c9b0c8b176965bb565dd4d86be67eddb4b6",
+ "sha256:8f4bb453f408137d7581be309b2fbc6868a80e7ef60c88e689078ee3a296ae71",
+ "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0",
+ "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d",
+ "sha256:97c2b54ff6717a7a563b72627990bec60d8029df17df423f0ed37d56a17a176b",
+ "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040",
+ "sha256:9e2c6f77b9ad48ce5603b83b7da9ee3e36b3ab425353f695cba13200c5d96596",
+ "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a",
+ "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee",
+ "sha256:a3b4350b13cc0e6f5bec8fa7caf29a8fb8cdc051a3bae45cfbfd7ce64f009965",
+ "sha256:aaab0e307e344cb28d800957ef3ec16605146ef0e59e059a60a176d19543d1b7",
+ "sha256:ac1e7817fd0ed3d14fd7c5df91daed84c48e4c2a11ee99c0547f9f62fdae13da",
+ "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9",
+ "sha256:b6d7ab1f01aa753188605b09a51faa44a3327400b00b8cce424c71910fc0a128",
+ "sha256:bacdb58d9939d95cc557b4dbaa86527c9db2ac1ed76a18bc8d26f6dc8647d851",
+ "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73",
+ "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61",
+ "sha256:c00e5c884f528c9932d278d5c9cbbea38a6b81eb62c02e06ae53751a83a4d52b",
+ "sha256:c25d9e338d5bed46c1632f851babf3d13c78f49a225462017cf5e11e845c5891",
+ "sha256:c336d61d2fe74a3195edc1646d53ff1cddd3a9600b09fa6ab75e5514ba4862a7",
+ "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994",
+ "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583",
+ "sha256:d0ee06b5b5291f609ddb37b9750985b27bc567791bc87c76a569b3feed8481ac",
+ "sha256:d480de377f5b687b6b1bc0c0407426da556e2a757633cc7e4d2e1a057aa688f3",
+ "sha256:d56bc4011975f7460bea7b33e1ff425d2f1adf419935ff6707273c77f8a4ada6",
+ "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921",
+ "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0",
+ "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79",
+ "sha256:e3f0a41487fd5fad7e760b9e8a90e251e27c2816fbc2cff36a22a0e6bcbbd9dd",
+ "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012",
+ "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023",
+ "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4",
+ "sha256:eb5656019db7c4deacf0c1a55a898c5bb8f989be904597fcb5232a2f4828fa05",
+ "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c",
+ "sha256:f0af2bd2bc204fa27f3d6711d0f360e6b8c684a035206257a81673ab924aa11e",
+ "sha256:f7cdf7f26c2286ffb02e46d7bac56c94655540b26347673bea15fa52a6af17e9",
+ "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b",
+ "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444"
],
"markers": "python_version >= '3.9'",
- "version": "==0.7.5"
+ "version": "==0.8.1"
},
"mccabe": {
"hashes": [
@@ -1107,48 +973,54 @@
},
"mypy": {
"hashes": [
- "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd",
- "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b",
- "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1",
- "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba",
- "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b",
- "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045",
- "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac",
- "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6",
- "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a",
- "sha256:409088884802d511ee52ca067707b90c883426bd95514e8cfda8281dc2effe24",
- "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957",
- "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042",
- "sha256:4f28f99c824ecebcdaa2e55d82953e38ff60ee5ec938476796636b86afa3956e",
- "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec",
- "sha256:7bcfc336a03a1aaa26dfce9fff3e287a3ba99872a157561cbfcebe67c13308e3",
- "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718",
- "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f",
- "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331",
- "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1",
- "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1",
- "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13",
- "sha256:b13cfdd6c87fc3efb69ea4ec18ef79c74c3f98b4e5498ca9b85ab3b2c2329a67",
- "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2",
- "sha256:b7951a701c07ea584c4fe327834b92a30825514c868b1f69c30445093fdd9d5a",
- "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b",
- "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8",
- "sha256:c608937067d2fc5a4dd1a5ce92fd9e1398691b8c5d012d66e1ddd430e9244376",
- "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef",
- "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288",
- "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75",
- "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74",
- "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250",
- "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab",
- "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6",
- "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247",
- "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925",
- "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e",
- "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e"
+ "sha256:002b613ae19f4ac7d18b7e168ffe1cb9013b37c57f7411984abbd3b817b0a214",
+ "sha256:00e047c74d3ec6e71a2eb88e9ea551a2edb90c21f993aefa9e0d2a898e0bb732",
+ "sha256:02cca0761c75b42a20a2757ae58713276605eb29a08dd8a6e092aa347c4115ca",
+ "sha256:0ecd63f75fdd30327e4ad8b5704bd6d91fc6c1b2e029f8ee14705e1207212489",
+ "sha256:0f42dfaab7ec1baff3b383ad7af562ab0de573c5f6edb44b2dab016082b89948",
+ "sha256:1973868d2adbb4584a3835780b27436f06d1dc606af5be09f187aaa25be1070f",
+ "sha256:26c8b52627b6552f47ff11adb4e1509605f094e29815323e487fc0053ebe93d1",
+ "sha256:2721f0ce49cb74a38f00c50da67cb7d36317b5eda38877a49614dc018e91c787",
+ "sha256:2fcedb16d456106e545b2bfd7ef9d24e70b38ec252d2a629823a4d07ebcdb69e",
+ "sha256:31b5dbb55293c1bd27c0fc813a0d2bb5ceef9d65ac5afa2e58f829dab7921fd5",
+ "sha256:34506397dbf40c15dc567635d18a21d33827e9ab29014fb83d292a8f4f8953b6",
+ "sha256:367e5c993ba34d5054d11937d0485ad6dfc60ba760fa326c01090fc256adf15c",
+ "sha256:379edf079ce44ac8d2805bcf9b3dd7340d4f97aad3a5e0ebabbf9d125b84b442",
+ "sha256:39362cdb4ba5f916e7976fccecaab1ba3a83e35f60fa68b64e9a70e221bb2436",
+ "sha256:4525e7010b1b38334516181c5b81e16180b8e149e6684cee5a727c78186b4e3b",
+ "sha256:47781555a7aa5fedcc2d16bcd72e0dc83eb272c10dd657f9fb3f9cc08e2e6abb",
+ "sha256:49d11c6f573a5a08f77fad13faff2139f6d0730ebed2cfa9b3d2702671dd7188",
+ "sha256:555493c44a4f5a1b58d611a43333e71a9981c6dbe26270377b6f8174126a0526",
+ "sha256:555658c611099455b2da507582ea20d2043dfdfe7f5ad0add472b1c6238b433f",
+ "sha256:697f102c5c1d526bdd761a69f17c6070f9892eebcb94b1a5963d679288c09e78",
+ "sha256:76a70bf840495729be47510856b978f1b0ec7d08f257ca38c9d932720bf6b43e",
+ "sha256:7d3243c406773185144527f83be0e0aefc7bf4601b0b2b956665608bf7c98a83",
+ "sha256:931a7630bba591593dcf6e97224a21ff80fb357e7982628d25e3c618e7f598ef",
+ "sha256:9804c3ad27f78e54e58b32e7cb532d128b43dbfb9f3f9f06262b821a0f6bd3f5",
+ "sha256:a17c5d0bdcca61ce24a35beb828a2d0d323d3fcf387d7512206888c900193367",
+ "sha256:a6e0641147cbfa7e4e94efdb95c2dab1aff8cfc159ded13e07f308ddccc8c48e",
+ "sha256:a79c1eba7ac4209f2d850f0edd0a2f8bba88cbfdfefe6fb76a19e9d4fe5e71a2",
+ "sha256:a9336b5e6712f4adaf5afc3203a99a40b379049104349d747eb3e5a3aa23ac2e",
+ "sha256:b20c8b0fd5877abdf402e79a3af987053de07e6fb208c18df6659f708b535134",
+ "sha256:b3a49064504be59e59da664c5e149edc1f26c67c4f8e8456f6ba6aba55033018",
+ "sha256:b503ab55a836136b619b5fc21c8803d810c5b87551af8600b72eecafb0059cb0",
+ "sha256:bd0212976dc57a5bfeede7c219e7cd66568a32c05c9129686dd487c059c1b88a",
+ "sha256:c70380fe5d64010f79fb863b9081c7004dd65225d2277333c219d93a10dad4dd",
+ "sha256:d99f515f95fd03a90875fdb2cca12ff074aa04490db4d190905851bdf8a549a8",
+ "sha256:e80cf77847d0d3e6e3111b7b25db32a7f8762fd4b9a3a72ce53fe16a2863b281",
+ "sha256:eb96c84efcc33f0b5e0e04beacf00129dd963b67226b01c00b9dfc8affb464c3",
+ "sha256:ebea00201737ad4391142808ed16e875add5c17f676e0912b387739f84991e13",
+ "sha256:efe8d70949c3023698c3fca1e94527e7e790a361ab8116f90d11221421cd8726",
+ "sha256:f13b3e41bce9d257eded794c0f12878af3129d80aacd8a3ee0dee51f3a978651",
+ "sha256:f194db59657c58593a3c47c6dfd7bad4ef4ac12dbc94d01b3a95521f78177e33",
+ "sha256:f49590891d2c2f8a9de15614e32e459a794bcba84693c2394291a2038bbaaa69",
+ "sha256:f75ff57defcd0f1d6e006d721ccdec6c88d4f6a7816eb92f1c4890d979d9ee62",
+ "sha256:f799d9db89fc00446f03281f84a221e50018fc40113a3ba9864b132895619ebe",
+ "sha256:f8426d4d75d68714abc17a4292d922f6ba2cfb984b72c2278c437f6dae797865"
],
"index": "pypi",
- "markers": "python_version >= '3.9'",
- "version": "==1.19.1"
+ "markers": "python_version >= '3.10'",
+ "version": "==1.20.0"
},
"mypy-extensions": {
"hashes": [
@@ -1160,84 +1032,36 @@
},
"pathspec": {
"hashes": [
- "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08",
- "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"
+ "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645",
+ "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723"
],
- "markers": "python_version >= '3.8'",
- "version": "==0.12.1"
+ "markers": "python_version >= '3.9'",
+ "version": "==1.0.4"
},
"platformdirs": {
"hashes": [
- "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312",
- "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3"
+ "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934",
+ "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868"
],
"markers": "python_version >= '3.10'",
- "version": "==4.5.0"
+ "version": "==4.9.4"
},
"pylint": {
"hashes": [
- "sha256:63e06a37d5922555ee2c20963eb42559918c20bd2b21244e4ef426e7c43b92e0",
- "sha256:d9b71674e19b1c36d79265b5887bf8e55278cbe236c9e95d22dc82cf044fdbd2"
+ "sha256:00f51c9b14a3b3ae08cff6b2cdd43f28165c78b165b628692e428fb1f8dc2cf2",
+ "sha256:8cd6a618df75deb013bd7eb98327a95f02a6fb839205a6bbf5456ef96afb317c"
],
"index": "pypi",
"markers": "python_full_version >= '3.10.0'",
- "version": "==4.0.4"
- },
- "tomli": {
- "hashes": [
- "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456",
- "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845",
- "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999",
- "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0",
- "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878",
- "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf",
- "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3",
- "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be",
- "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52",
- "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b",
- "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67",
- "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549",
- "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba",
- "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22",
- "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c",
- "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f",
- "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6",
- "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba",
- "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45",
- "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f",
- "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77",
- "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606",
- "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441",
- "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0",
- "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f",
- "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530",
- "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05",
- "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8",
- "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005",
- "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879",
- "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae",
- "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc",
- "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b",
- "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b",
- "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e",
- "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf",
- "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac",
- "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8",
- "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b",
- "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf",
- "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463",
- "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876"
- ],
- "markers": "python_version >= '3.8'",
- "version": "==2.3.0"
+ "version": "==4.0.5"
},
"tomlkit": {
"hashes": [
- "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1",
- "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0"
+ "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680",
+ "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064"
],
- "markers": "python_version >= '3.8'",
- "version": "==0.13.3"
+ "markers": "python_version >= '3.9'",
+ "version": "==0.14.0"
},
"typing-extensions": {
"hashes": [
diff --git a/bin/netcore/engines/IPY2712PR/pyRevitAssemblyBuilder.dll b/bin/netcore/engines/IPY2712PR/pyRevitAssemblyBuilder.dll
index 4bde66485..02bc37eba 100644
Binary files a/bin/netcore/engines/IPY2712PR/pyRevitAssemblyBuilder.dll and b/bin/netcore/engines/IPY2712PR/pyRevitAssemblyBuilder.dll differ
diff --git a/bin/netcore/engines/IPY2712PR/pyRevitExtensionParser.dll b/bin/netcore/engines/IPY2712PR/pyRevitExtensionParser.dll
index b7f30a925..a0465d9c4 100644
Binary files a/bin/netcore/engines/IPY2712PR/pyRevitExtensionParser.dll and b/bin/netcore/engines/IPY2712PR/pyRevitExtensionParser.dll differ
diff --git a/bin/netcore/engines/IPY2712PR/pyRevitLabs.PyRevit.Runtime.2025.dll b/bin/netcore/engines/IPY2712PR/pyRevitLabs.PyRevit.Runtime.2025.dll
index 9a572a80d..ed536c3a3 100644
Binary files a/bin/netcore/engines/IPY2712PR/pyRevitLabs.PyRevit.Runtime.2025.dll and b/bin/netcore/engines/IPY2712PR/pyRevitLabs.PyRevit.Runtime.2025.dll differ
diff --git a/bin/netcore/engines/IPY2712PR/pyRevitLabs.PyRevit.Runtime.2026.dll b/bin/netcore/engines/IPY2712PR/pyRevitLabs.PyRevit.Runtime.2026.dll
index 188af6494..8b98b5f2f 100644
Binary files a/bin/netcore/engines/IPY2712PR/pyRevitLabs.PyRevit.Runtime.2026.dll and b/bin/netcore/engines/IPY2712PR/pyRevitLabs.PyRevit.Runtime.2026.dll differ
diff --git a/bin/netcore/engines/IPY2712PR/pyRevitLabs.PyRevit.Runtime.2027.dll b/bin/netcore/engines/IPY2712PR/pyRevitLabs.PyRevit.Runtime.2027.dll
index 39a53c65c..eeeb7e484 100644
Binary files a/bin/netcore/engines/IPY2712PR/pyRevitLabs.PyRevit.Runtime.2027.dll and b/bin/netcore/engines/IPY2712PR/pyRevitLabs.PyRevit.Runtime.2027.dll differ
diff --git a/bin/netcore/engines/IPY2712PR/pyRevitLoader.dll b/bin/netcore/engines/IPY2712PR/pyRevitLoader.dll
index 4757afb85..c115e6d6d 100644
Binary files a/bin/netcore/engines/IPY2712PR/pyRevitLoader.dll and b/bin/netcore/engines/IPY2712PR/pyRevitLoader.dll differ
diff --git a/bin/netcore/engines/IPY2712PR/pyRevitRunner.dll b/bin/netcore/engines/IPY2712PR/pyRevitRunner.dll
index e513eaebb..5dacf3f60 100644
Binary files a/bin/netcore/engines/IPY2712PR/pyRevitRunner.dll and b/bin/netcore/engines/IPY2712PR/pyRevitRunner.dll differ
diff --git a/bin/netcore/engines/IPY342/pyRevitAssemblyBuilder.dll b/bin/netcore/engines/IPY342/pyRevitAssemblyBuilder.dll
index 4bde66485..02bc37eba 100644
Binary files a/bin/netcore/engines/IPY342/pyRevitAssemblyBuilder.dll and b/bin/netcore/engines/IPY342/pyRevitAssemblyBuilder.dll differ
diff --git a/bin/netcore/engines/IPY342/pyRevitExtensionParser.dll b/bin/netcore/engines/IPY342/pyRevitExtensionParser.dll
index b7f30a925..a0465d9c4 100644
Binary files a/bin/netcore/engines/IPY342/pyRevitExtensionParser.dll and b/bin/netcore/engines/IPY342/pyRevitExtensionParser.dll differ
diff --git a/bin/netcore/engines/IPY342/pyRevitLabs.PyRevit.Runtime.2025.dll b/bin/netcore/engines/IPY342/pyRevitLabs.PyRevit.Runtime.2025.dll
index 1be0e13b4..dbb70e957 100644
Binary files a/bin/netcore/engines/IPY342/pyRevitLabs.PyRevit.Runtime.2025.dll and b/bin/netcore/engines/IPY342/pyRevitLabs.PyRevit.Runtime.2025.dll differ
diff --git a/bin/netcore/engines/IPY342/pyRevitLabs.PyRevit.Runtime.2026.dll b/bin/netcore/engines/IPY342/pyRevitLabs.PyRevit.Runtime.2026.dll
index 58e473335..bc99c7268 100644
Binary files a/bin/netcore/engines/IPY342/pyRevitLabs.PyRevit.Runtime.2026.dll and b/bin/netcore/engines/IPY342/pyRevitLabs.PyRevit.Runtime.2026.dll differ
diff --git a/bin/netcore/engines/IPY342/pyRevitLabs.PyRevit.Runtime.2027.dll b/bin/netcore/engines/IPY342/pyRevitLabs.PyRevit.Runtime.2027.dll
index e654dfaef..4d76f285a 100644
Binary files a/bin/netcore/engines/IPY342/pyRevitLabs.PyRevit.Runtime.2027.dll and b/bin/netcore/engines/IPY342/pyRevitLabs.PyRevit.Runtime.2027.dll differ
diff --git a/bin/netcore/engines/IPY342/pyRevitLoader.dll b/bin/netcore/engines/IPY342/pyRevitLoader.dll
index d08c53a8a..d810cfb86 100644
Binary files a/bin/netcore/engines/IPY342/pyRevitLoader.dll and b/bin/netcore/engines/IPY342/pyRevitLoader.dll differ
diff --git a/bin/netcore/engines/IPY342/pyRevitRunner.dll b/bin/netcore/engines/IPY342/pyRevitRunner.dll
index d1036a80c..da2e6bda1 100644
Binary files a/bin/netcore/engines/IPY342/pyRevitRunner.dll and b/bin/netcore/engines/IPY342/pyRevitRunner.dll differ
diff --git a/bin/netcore/pyRevitLabs.Common.dll b/bin/netcore/pyRevitLabs.Common.dll
index 1fd0ac65e..dfc79b760 100644
Binary files a/bin/netcore/pyRevitLabs.Common.dll and b/bin/netcore/pyRevitLabs.Common.dll differ
diff --git a/bin/netcore/pyRevitLabs.CommonCLI.dll b/bin/netcore/pyRevitLabs.CommonCLI.dll
index 0020cb299..76bfe66d0 100644
Binary files a/bin/netcore/pyRevitLabs.CommonCLI.dll and b/bin/netcore/pyRevitLabs.CommonCLI.dll differ
diff --git a/bin/netcore/pyRevitLabs.CommonWPF.dll b/bin/netcore/pyRevitLabs.CommonWPF.dll
index dbe3f4137..fc9f940bb 100644
Binary files a/bin/netcore/pyRevitLabs.CommonWPF.dll and b/bin/netcore/pyRevitLabs.CommonWPF.dll differ
diff --git a/bin/netcore/pyRevitLabs.DeffrelDB.dll b/bin/netcore/pyRevitLabs.DeffrelDB.dll
index d3b05d08a..cd98d49f3 100644
Binary files a/bin/netcore/pyRevitLabs.DeffrelDB.dll and b/bin/netcore/pyRevitLabs.DeffrelDB.dll differ
diff --git a/bin/netcore/pyRevitLabs.Emojis.dll b/bin/netcore/pyRevitLabs.Emojis.dll
index 7dce420e4..f3e93be86 100644
Binary files a/bin/netcore/pyRevitLabs.Emojis.dll and b/bin/netcore/pyRevitLabs.Emojis.dll differ
diff --git a/bin/netcore/pyRevitLabs.Language.dll b/bin/netcore/pyRevitLabs.Language.dll
index 6b307503f..124097474 100644
Binary files a/bin/netcore/pyRevitLabs.Language.dll and b/bin/netcore/pyRevitLabs.Language.dll differ
diff --git a/bin/netcore/pyRevitLabs.NLog.dll b/bin/netcore/pyRevitLabs.NLog.dll
index 4dc3c0c86..8e061298e 100644
Binary files a/bin/netcore/pyRevitLabs.NLog.dll and b/bin/netcore/pyRevitLabs.NLog.dll differ
diff --git a/bin/netcore/pyRevitLabs.PyRevit.Runtime.Shared.dll b/bin/netcore/pyRevitLabs.PyRevit.Runtime.Shared.dll
index 88b19d16e..8efd6e071 100644
Binary files a/bin/netcore/pyRevitLabs.PyRevit.Runtime.Shared.dll and b/bin/netcore/pyRevitLabs.PyRevit.Runtime.Shared.dll differ
diff --git a/bin/netcore/pyRevitLabs.PyRevit.dll b/bin/netcore/pyRevitLabs.PyRevit.dll
index 58bda67c1..4a19a1a2c 100644
Binary files a/bin/netcore/pyRevitLabs.PyRevit.dll and b/bin/netcore/pyRevitLabs.PyRevit.dll differ
diff --git a/bin/netcore/pyRevitLabs.TargetApps.AutoCAD.dll b/bin/netcore/pyRevitLabs.TargetApps.AutoCAD.dll
index 388254c18..586b9f75d 100644
Binary files a/bin/netcore/pyRevitLabs.TargetApps.AutoCAD.dll and b/bin/netcore/pyRevitLabs.TargetApps.AutoCAD.dll differ
diff --git a/bin/netcore/pyRevitLabs.TargetApps.Navisworks.dll b/bin/netcore/pyRevitLabs.TargetApps.Navisworks.dll
index 6af63b101..4342b3fec 100644
Binary files a/bin/netcore/pyRevitLabs.TargetApps.Navisworks.dll and b/bin/netcore/pyRevitLabs.TargetApps.Navisworks.dll differ
diff --git a/bin/netcore/pyRevitLabs.TargetApps.Revit.dll b/bin/netcore/pyRevitLabs.TargetApps.Revit.dll
index 9fd5b030a..2840a7569 100644
Binary files a/bin/netcore/pyRevitLabs.TargetApps.Revit.dll and b/bin/netcore/pyRevitLabs.TargetApps.Revit.dll differ
diff --git a/bin/netcore/pyRevitLabs.UnitTests.dll b/bin/netcore/pyRevitLabs.UnitTests.dll
index 18bcdddd0..648d939bf 100644
Binary files a/bin/netcore/pyRevitLabs.UnitTests.dll and b/bin/netcore/pyRevitLabs.UnitTests.dll differ
diff --git a/bin/netfx/engines/IPY2712PR/pyRevitAssemblyBuilder.dll b/bin/netfx/engines/IPY2712PR/pyRevitAssemblyBuilder.dll
index 35496b1ac..fb3ce2565 100644
Binary files a/bin/netfx/engines/IPY2712PR/pyRevitAssemblyBuilder.dll and b/bin/netfx/engines/IPY2712PR/pyRevitAssemblyBuilder.dll differ
diff --git a/bin/netfx/engines/IPY2712PR/pyRevitExtensionParser.dll b/bin/netfx/engines/IPY2712PR/pyRevitExtensionParser.dll
index 0091d46df..6af0a0d3e 100644
Binary files a/bin/netfx/engines/IPY2712PR/pyRevitExtensionParser.dll and b/bin/netfx/engines/IPY2712PR/pyRevitExtensionParser.dll differ
diff --git a/bin/netfx/engines/IPY2712PR/pyRevitLabs.PyRevit.Runtime.2017.dll b/bin/netfx/engines/IPY2712PR/pyRevitLabs.PyRevit.Runtime.2017.dll
index 25f012fd6..ea8195b2b 100644
Binary files a/bin/netfx/engines/IPY2712PR/pyRevitLabs.PyRevit.Runtime.2017.dll and b/bin/netfx/engines/IPY2712PR/pyRevitLabs.PyRevit.Runtime.2017.dll differ
diff --git a/bin/netfx/engines/IPY2712PR/pyRevitLabs.PyRevit.Runtime.2018.dll b/bin/netfx/engines/IPY2712PR/pyRevitLabs.PyRevit.Runtime.2018.dll
index 56c3419c4..e34edbcbc 100644
Binary files a/bin/netfx/engines/IPY2712PR/pyRevitLabs.PyRevit.Runtime.2018.dll and b/bin/netfx/engines/IPY2712PR/pyRevitLabs.PyRevit.Runtime.2018.dll differ
diff --git a/bin/netfx/engines/IPY2712PR/pyRevitLabs.PyRevit.Runtime.2019.dll b/bin/netfx/engines/IPY2712PR/pyRevitLabs.PyRevit.Runtime.2019.dll
index 930739924..a0c7afd55 100644
Binary files a/bin/netfx/engines/IPY2712PR/pyRevitLabs.PyRevit.Runtime.2019.dll and b/bin/netfx/engines/IPY2712PR/pyRevitLabs.PyRevit.Runtime.2019.dll differ
diff --git a/bin/netfx/engines/IPY2712PR/pyRevitLabs.PyRevit.Runtime.2020.dll b/bin/netfx/engines/IPY2712PR/pyRevitLabs.PyRevit.Runtime.2020.dll
index fbc592f6d..6772eddbb 100644
Binary files a/bin/netfx/engines/IPY2712PR/pyRevitLabs.PyRevit.Runtime.2020.dll and b/bin/netfx/engines/IPY2712PR/pyRevitLabs.PyRevit.Runtime.2020.dll differ
diff --git a/bin/netfx/engines/IPY2712PR/pyRevitLabs.PyRevit.Runtime.2021.dll b/bin/netfx/engines/IPY2712PR/pyRevitLabs.PyRevit.Runtime.2021.dll
index dc51f7847..b08b7d7f1 100644
Binary files a/bin/netfx/engines/IPY2712PR/pyRevitLabs.PyRevit.Runtime.2021.dll and b/bin/netfx/engines/IPY2712PR/pyRevitLabs.PyRevit.Runtime.2021.dll differ
diff --git a/bin/netfx/engines/IPY2712PR/pyRevitLabs.PyRevit.Runtime.2022.dll b/bin/netfx/engines/IPY2712PR/pyRevitLabs.PyRevit.Runtime.2022.dll
index ccb1c593e..c6051f21d 100644
Binary files a/bin/netfx/engines/IPY2712PR/pyRevitLabs.PyRevit.Runtime.2022.dll and b/bin/netfx/engines/IPY2712PR/pyRevitLabs.PyRevit.Runtime.2022.dll differ
diff --git a/bin/netfx/engines/IPY2712PR/pyRevitLabs.PyRevit.Runtime.2023.dll b/bin/netfx/engines/IPY2712PR/pyRevitLabs.PyRevit.Runtime.2023.dll
index 5622366c0..2a2529e74 100644
Binary files a/bin/netfx/engines/IPY2712PR/pyRevitLabs.PyRevit.Runtime.2023.dll and b/bin/netfx/engines/IPY2712PR/pyRevitLabs.PyRevit.Runtime.2023.dll differ
diff --git a/bin/netfx/engines/IPY2712PR/pyRevitLabs.PyRevit.Runtime.2024.dll b/bin/netfx/engines/IPY2712PR/pyRevitLabs.PyRevit.Runtime.2024.dll
index aa98d5cab..0a992aee4 100644
Binary files a/bin/netfx/engines/IPY2712PR/pyRevitLabs.PyRevit.Runtime.2024.dll and b/bin/netfx/engines/IPY2712PR/pyRevitLabs.PyRevit.Runtime.2024.dll differ
diff --git a/bin/netfx/engines/IPY2712PR/pyRevitLoader.dll b/bin/netfx/engines/IPY2712PR/pyRevitLoader.dll
index b8fe8eddf..2de0ce417 100644
Binary files a/bin/netfx/engines/IPY2712PR/pyRevitLoader.dll and b/bin/netfx/engines/IPY2712PR/pyRevitLoader.dll differ
diff --git a/bin/netfx/engines/IPY2712PR/pyRevitRunner.dll b/bin/netfx/engines/IPY2712PR/pyRevitRunner.dll
index df0db993a..311e03374 100644
Binary files a/bin/netfx/engines/IPY2712PR/pyRevitRunner.dll and b/bin/netfx/engines/IPY2712PR/pyRevitRunner.dll differ
diff --git a/bin/netfx/engines/IPY342/pyRevitAssemblyBuilder.dll b/bin/netfx/engines/IPY342/pyRevitAssemblyBuilder.dll
index 35496b1ac..fb3ce2565 100644
Binary files a/bin/netfx/engines/IPY342/pyRevitAssemblyBuilder.dll and b/bin/netfx/engines/IPY342/pyRevitAssemblyBuilder.dll differ
diff --git a/bin/netfx/engines/IPY342/pyRevitExtensionParser.dll b/bin/netfx/engines/IPY342/pyRevitExtensionParser.dll
index 0091d46df..6af0a0d3e 100644
Binary files a/bin/netfx/engines/IPY342/pyRevitExtensionParser.dll and b/bin/netfx/engines/IPY342/pyRevitExtensionParser.dll differ
diff --git a/bin/netfx/engines/IPY342/pyRevitLabs.PyRevit.Runtime.2017.dll b/bin/netfx/engines/IPY342/pyRevitLabs.PyRevit.Runtime.2017.dll
index 0545730d0..b1bba620c 100644
Binary files a/bin/netfx/engines/IPY342/pyRevitLabs.PyRevit.Runtime.2017.dll and b/bin/netfx/engines/IPY342/pyRevitLabs.PyRevit.Runtime.2017.dll differ
diff --git a/bin/netfx/engines/IPY342/pyRevitLabs.PyRevit.Runtime.2018.dll b/bin/netfx/engines/IPY342/pyRevitLabs.PyRevit.Runtime.2018.dll
index 150395af1..7d82c11d4 100644
Binary files a/bin/netfx/engines/IPY342/pyRevitLabs.PyRevit.Runtime.2018.dll and b/bin/netfx/engines/IPY342/pyRevitLabs.PyRevit.Runtime.2018.dll differ
diff --git a/bin/netfx/engines/IPY342/pyRevitLabs.PyRevit.Runtime.2019.dll b/bin/netfx/engines/IPY342/pyRevitLabs.PyRevit.Runtime.2019.dll
index 298219c86..6596df9ad 100644
Binary files a/bin/netfx/engines/IPY342/pyRevitLabs.PyRevit.Runtime.2019.dll and b/bin/netfx/engines/IPY342/pyRevitLabs.PyRevit.Runtime.2019.dll differ
diff --git a/bin/netfx/engines/IPY342/pyRevitLabs.PyRevit.Runtime.2020.dll b/bin/netfx/engines/IPY342/pyRevitLabs.PyRevit.Runtime.2020.dll
index 1a33bee73..22477c19f 100644
Binary files a/bin/netfx/engines/IPY342/pyRevitLabs.PyRevit.Runtime.2020.dll and b/bin/netfx/engines/IPY342/pyRevitLabs.PyRevit.Runtime.2020.dll differ
diff --git a/bin/netfx/engines/IPY342/pyRevitLabs.PyRevit.Runtime.2021.dll b/bin/netfx/engines/IPY342/pyRevitLabs.PyRevit.Runtime.2021.dll
index 2b56f2f41..a15f3badf 100644
Binary files a/bin/netfx/engines/IPY342/pyRevitLabs.PyRevit.Runtime.2021.dll and b/bin/netfx/engines/IPY342/pyRevitLabs.PyRevit.Runtime.2021.dll differ
diff --git a/bin/netfx/engines/IPY342/pyRevitLabs.PyRevit.Runtime.2022.dll b/bin/netfx/engines/IPY342/pyRevitLabs.PyRevit.Runtime.2022.dll
index 42e00aab7..bce8c3dca 100644
Binary files a/bin/netfx/engines/IPY342/pyRevitLabs.PyRevit.Runtime.2022.dll and b/bin/netfx/engines/IPY342/pyRevitLabs.PyRevit.Runtime.2022.dll differ
diff --git a/bin/netfx/engines/IPY342/pyRevitLabs.PyRevit.Runtime.2023.dll b/bin/netfx/engines/IPY342/pyRevitLabs.PyRevit.Runtime.2023.dll
index 43935867d..d1a56db99 100644
Binary files a/bin/netfx/engines/IPY342/pyRevitLabs.PyRevit.Runtime.2023.dll and b/bin/netfx/engines/IPY342/pyRevitLabs.PyRevit.Runtime.2023.dll differ
diff --git a/bin/netfx/engines/IPY342/pyRevitLabs.PyRevit.Runtime.2024.dll b/bin/netfx/engines/IPY342/pyRevitLabs.PyRevit.Runtime.2024.dll
index 972f7006a..dc26656a1 100644
Binary files a/bin/netfx/engines/IPY342/pyRevitLabs.PyRevit.Runtime.2024.dll and b/bin/netfx/engines/IPY342/pyRevitLabs.PyRevit.Runtime.2024.dll differ
diff --git a/bin/netfx/engines/IPY342/pyRevitLoader.dll b/bin/netfx/engines/IPY342/pyRevitLoader.dll
index 7d051340e..d22ff4e90 100644
Binary files a/bin/netfx/engines/IPY342/pyRevitLoader.dll and b/bin/netfx/engines/IPY342/pyRevitLoader.dll differ
diff --git a/bin/netfx/engines/IPY342/pyRevitRunner.dll b/bin/netfx/engines/IPY342/pyRevitRunner.dll
index d6c65a2b5..cf02d63c3 100644
Binary files a/bin/netfx/engines/IPY342/pyRevitRunner.dll and b/bin/netfx/engines/IPY342/pyRevitRunner.dll differ
diff --git a/bin/netfx/pyRevitLabs.Common.dll b/bin/netfx/pyRevitLabs.Common.dll
index 987ed9619..d5882143c 100644
Binary files a/bin/netfx/pyRevitLabs.Common.dll and b/bin/netfx/pyRevitLabs.Common.dll differ
diff --git a/bin/netfx/pyRevitLabs.CommonCLI.dll b/bin/netfx/pyRevitLabs.CommonCLI.dll
index 53a8a95cb..af7ebecd7 100644
Binary files a/bin/netfx/pyRevitLabs.CommonCLI.dll and b/bin/netfx/pyRevitLabs.CommonCLI.dll differ
diff --git a/bin/netfx/pyRevitLabs.CommonWPF.dll b/bin/netfx/pyRevitLabs.CommonWPF.dll
index f3e88b1ab..aea318e57 100644
Binary files a/bin/netfx/pyRevitLabs.CommonWPF.dll and b/bin/netfx/pyRevitLabs.CommonWPF.dll differ
diff --git a/bin/netfx/pyRevitLabs.DeffrelDB.dll b/bin/netfx/pyRevitLabs.DeffrelDB.dll
index b22af6fd0..9d5d103d8 100644
Binary files a/bin/netfx/pyRevitLabs.DeffrelDB.dll and b/bin/netfx/pyRevitLabs.DeffrelDB.dll differ
diff --git a/bin/netfx/pyRevitLabs.Emojis.dll b/bin/netfx/pyRevitLabs.Emojis.dll
index 25b68da41..5f9022b77 100644
Binary files a/bin/netfx/pyRevitLabs.Emojis.dll and b/bin/netfx/pyRevitLabs.Emojis.dll differ
diff --git a/bin/netfx/pyRevitLabs.Language.dll b/bin/netfx/pyRevitLabs.Language.dll
index 0d4472cd8..4dde85a2c 100644
Binary files a/bin/netfx/pyRevitLabs.Language.dll and b/bin/netfx/pyRevitLabs.Language.dll differ
diff --git a/bin/netfx/pyRevitLabs.NLog.dll b/bin/netfx/pyRevitLabs.NLog.dll
index ff6f3096b..08eb6d05c 100644
Binary files a/bin/netfx/pyRevitLabs.NLog.dll and b/bin/netfx/pyRevitLabs.NLog.dll differ
diff --git a/bin/netfx/pyRevitLabs.PyRevit.Runtime.Shared.dll b/bin/netfx/pyRevitLabs.PyRevit.Runtime.Shared.dll
index 153c73d81..c7d620e02 100644
Binary files a/bin/netfx/pyRevitLabs.PyRevit.Runtime.Shared.dll and b/bin/netfx/pyRevitLabs.PyRevit.Runtime.Shared.dll differ
diff --git a/bin/netfx/pyRevitLabs.PyRevit.dll b/bin/netfx/pyRevitLabs.PyRevit.dll
index b663e5d96..cada4799f 100644
Binary files a/bin/netfx/pyRevitLabs.PyRevit.dll and b/bin/netfx/pyRevitLabs.PyRevit.dll differ
diff --git a/bin/netfx/pyRevitLabs.TargetApps.AutoCAD.dll b/bin/netfx/pyRevitLabs.TargetApps.AutoCAD.dll
index 2946d30d3..2760c4abb 100644
Binary files a/bin/netfx/pyRevitLabs.TargetApps.AutoCAD.dll and b/bin/netfx/pyRevitLabs.TargetApps.AutoCAD.dll differ
diff --git a/bin/netfx/pyRevitLabs.TargetApps.Navisworks.dll b/bin/netfx/pyRevitLabs.TargetApps.Navisworks.dll
index 3788f95eb..cb7b9b2af 100644
Binary files a/bin/netfx/pyRevitLabs.TargetApps.Navisworks.dll and b/bin/netfx/pyRevitLabs.TargetApps.Navisworks.dll differ
diff --git a/bin/netfx/pyRevitLabs.TargetApps.Revit.dll b/bin/netfx/pyRevitLabs.TargetApps.Revit.dll
index abf36dbab..90d23d434 100644
Binary files a/bin/netfx/pyRevitLabs.TargetApps.Revit.dll and b/bin/netfx/pyRevitLabs.TargetApps.Revit.dll differ
diff --git a/bin/netfx/pyRevitLabs.UnitTests.dll b/bin/netfx/pyRevitLabs.UnitTests.dll
index 7d8427c8a..f0e620d75 100644
Binary files a/bin/netfx/pyRevitLabs.UnitTests.dll and b/bin/netfx/pyRevitLabs.UnitTests.dll differ
diff --git a/bin/pyRevitLabs.Common.dll b/bin/pyRevitLabs.Common.dll
index 1fd0ac65e..dfc79b760 100644
Binary files a/bin/pyRevitLabs.Common.dll and b/bin/pyRevitLabs.Common.dll differ
diff --git a/bin/pyRevitLabs.CommonCLI.dll b/bin/pyRevitLabs.CommonCLI.dll
index 0020cb299..76bfe66d0 100644
Binary files a/bin/pyRevitLabs.CommonCLI.dll and b/bin/pyRevitLabs.CommonCLI.dll differ
diff --git a/bin/pyRevitLabs.CommonWPF.dll b/bin/pyRevitLabs.CommonWPF.dll
index dbe3f4137..fc9f940bb 100644
Binary files a/bin/pyRevitLabs.CommonWPF.dll and b/bin/pyRevitLabs.CommonWPF.dll differ
diff --git a/bin/pyRevitLabs.Language.dll b/bin/pyRevitLabs.Language.dll
index 6b307503f..124097474 100644
Binary files a/bin/pyRevitLabs.Language.dll and b/bin/pyRevitLabs.Language.dll differ
diff --git a/bin/pyRevitLabs.NLog.dll b/bin/pyRevitLabs.NLog.dll
index 4dc3c0c86..8e061298e 100644
Binary files a/bin/pyRevitLabs.NLog.dll and b/bin/pyRevitLabs.NLog.dll differ
diff --git a/bin/pyRevitLabs.PyRevit.dll b/bin/pyRevitLabs.PyRevit.dll
index 58bda67c1..4a19a1a2c 100644
Binary files a/bin/pyRevitLabs.PyRevit.dll and b/bin/pyRevitLabs.PyRevit.dll differ
diff --git a/bin/pyRevitLabs.TargetApps.Revit.dll b/bin/pyRevitLabs.TargetApps.Revit.dll
index 9fd5b030a..2840a7569 100644
Binary files a/bin/pyRevitLabs.TargetApps.Revit.dll and b/bin/pyRevitLabs.TargetApps.Revit.dll differ
diff --git a/bin/pyrevit-autocomplete.exe b/bin/pyrevit-autocomplete.exe
index 5f530c045..63d35ac08 100644
Binary files a/bin/pyrevit-autocomplete.exe and b/bin/pyrevit-autocomplete.exe differ
diff --git a/bin/pyrevit-doctor.dll b/bin/pyrevit-doctor.dll
index 28693c18c..254e812de 100644
Binary files a/bin/pyrevit-doctor.dll and b/bin/pyrevit-doctor.dll differ
diff --git a/bin/pyrevit-doctor.exe b/bin/pyrevit-doctor.exe
index 5226ee47c..5e8f4202e 100644
Binary files a/bin/pyrevit-doctor.exe and b/bin/pyrevit-doctor.exe differ
diff --git a/bin/pyrevit-hosts.json b/bin/pyrevit-hosts.json
index e6a9c1027..101d0288c 100644
--- a/bin/pyrevit-hosts.json
+++ b/bin/pyrevit-hosts.json
@@ -1883,6 +1883,18 @@
"target": "x64",
"version": "23.1.80.30"
},
+ {
+ "build": "20260220_1515",
+ "meta": {
+ "schema": "1.0",
+ "source": "https://help.autodesk.com/view/RVT/2023/ENU/?guid=RevitReleaseNotes_2023updates_2023_1_9_html"
+ },
+ "notes": "https://help.autodesk.com/view/RVT/2023/ENU/?guid=RevitReleaseNotes_2023updates_2023_1_9_html",
+ "product": "Autodesk Revit",
+ "release": "2023.1.9",
+ "target": "x64",
+ "version": "23.1.90.15"
+ },
{
"build": "20230308_1635",
"meta": {
@@ -2147,6 +2159,18 @@
"target": "x64",
"version": "25.4.30.29"
},
+ {
+ "build": "20251111_1515",
+ "meta": {
+ "schema": "1.0",
+ "source": "https://help.autodesk.com/view/RVT/2025/ENU/?guid=RevitReleaseNotes_2025updates_2025_4_4_html"
+ },
+ "notes": "https://help.autodesk.com/view/RVT/2025/ENU/?guid=RevitReleaseNotes_2025updates_2025_4_4_html",
+ "product": "Autodesk Revit",
+ "release": "2025.4.4",
+ "target": "x64",
+ "version": "25.4.41.14"
+ },
{
"build": "20250227_1515",
"meta": {
diff --git a/bin/pyrevit.dll b/bin/pyrevit.dll
index f38a48c66..87ad434c1 100644
Binary files a/bin/pyrevit.dll and b/bin/pyrevit.dll differ
diff --git a/bin/pyrevit.exe b/bin/pyrevit.exe
index 202eb9621..4a83e51fd 100644
Binary files a/bin/pyrevit.exe and b/bin/pyrevit.exe differ
diff --git a/bin/pyrevit.runtimeconfig.json b/bin/pyrevit.runtimeconfig.json
index 9e04ed2cc..910ec3c92 100644
--- a/bin/pyrevit.runtimeconfig.json
+++ b/bin/pyrevit.runtimeconfig.json
@@ -12,7 +12,6 @@
}
],
"configProperties": {
- "System.Reflection.Metadata.MetadataUpdater.IsSupported": false,
"System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false,
"CSWINRT_USE_WINDOWS_UI_XAML_PROJECTIONS": false
}
diff --git a/dev/Directory.Build.props b/dev/Directory.Build.props
index ff7231398..da1eee1ac 100644
--- a/dev/Directory.Build.props
+++ b/dev/Directory.Build.props
@@ -17,7 +17,7 @@
- 6.1.0.26047+2349
+ 6.2.0.26090+1754
Copyright © 2014-2025
pyRevitLabs.io
diff --git a/dev/libs/netcore/pyRevitLabs.NLog.dll b/dev/libs/netcore/pyRevitLabs.NLog.dll
index 4dc3c0c86..8e061298e 100644
Binary files a/dev/libs/netcore/pyRevitLabs.NLog.dll and b/dev/libs/netcore/pyRevitLabs.NLog.dll differ
diff --git a/dev/libs/netfx/pyRevitLabs.NLog.dll b/dev/libs/netfx/pyRevitLabs.NLog.dll
index ff6f3096b..08eb6d05c 100644
Binary files a/dev/libs/netfx/pyRevitLabs.NLog.dll and b/dev/libs/netfx/pyRevitLabs.NLog.dll differ
diff --git a/dev/pyRevitLabs.PyRevit.Runtime/Directory.Build.targets b/dev/pyRevitLabs.PyRevit.Runtime/Directory.Build.targets
index 063b4147a..799c10cf5 100644
--- a/dev/pyRevitLabs.PyRevit.Runtime/Directory.Build.targets
+++ b/dev/pyRevitLabs.PyRevit.Runtime/Directory.Build.targets
@@ -51,10 +51,10 @@
-
+
-
-
+
+
diff --git a/dev/pyRevitLabs.PyRevit.Runtime/EnvVariables.cs b/dev/pyRevitLabs.PyRevit.Runtime/EnvVariables.cs
index a03e22f32..d73d7b3d3 100644
--- a/dev/pyRevitLabs.PyRevit.Runtime/EnvVariables.cs
+++ b/dev/pyRevitLabs.PyRevit.Runtime/EnvVariables.cs
@@ -169,5 +169,28 @@ public EnvDictionary()
public void ResetEventHooks() {
((Dictionary>)_envData[EnvDictionaryKeys.Hooks]).Clear();
}
+
+ ///
+ /// Seeds the AppDomain environment dictionary with session values supplied by the C# loader.
+ /// Called via reflection by EnvDictionarySeeder in pyRevitAssemblyBuilder (which has no
+ /// compile-time reference to IronPython), so the PythonDictionary is created here where
+ /// IronPython is already available.
+ ///
+ ///
+ /// Key/value pairs to store. Keys must match the string values of .
+ /// Values must be plain CLR primitives (string, bool, int) — IronPython coerces them correctly.
+ ///
+ public static void Seed(Dictionary values) {
+ var envData = AppDomain.CurrentDomain.GetData(DomainStorageKeys.EnvVarsDictKey) as PythonDictionary
+ ?? new PythonDictionary();
+
+ foreach (var kv in values)
+ envData[kv.Key] = kv.Value;
+
+ if (!envData.Contains(EnvDictionaryKeys.Hooks))
+ envData[EnvDictionaryKeys.Hooks] = new Dictionary>();
+
+ AppDomain.CurrentDomain.SetData(DomainStorageKeys.EnvVarsDictKey, envData);
+ }
}
}
diff --git a/dev/pyRevitLabs.PyRevit.Runtime/EventHandling.cs b/dev/pyRevitLabs.PyRevit.Runtime/EventHandling.cs
index a65b81162..2b5dbd134 100644
--- a/dev/pyRevitLabs.PyRevit.Runtime/EventHandling.cs
+++ b/dev/pyRevitLabs.PyRevit.Runtime/EventHandling.cs
@@ -278,10 +278,11 @@ private static void ActivateUpdaterListener() {
if (updaterListener == null) {
updaterListener = new UpdaterListener();
UpdaterRegistry.RegisterUpdater(updaterListener);
- UpdaterRegistry.AddTrigger(
- updaterListener.GetUpdaterId(),
- new ElementCategoryFilter(BuiltInCategory.INVALID, inverted: true),
- Element.GetChangeTypeAny());
+ var updaterId = updaterListener.GetUpdaterId();
+ var categoryFilter = new ElementCategoryFilter(BuiltInCategory.INVALID, inverted: true);
+ UpdaterRegistry.AddTrigger(updaterId, categoryFilter, Element.GetChangeTypeAny());
+ UpdaterRegistry.AddTrigger(updaterId, categoryFilter, Element.GetChangeTypeElementAddition());
+ UpdaterRegistry.AddTrigger(updaterId, categoryFilter, Element.GetChangeTypeElementDeletion());
}
}
@@ -1637,4 +1638,79 @@ public static void SetTabFlowDirection() {
}
}
}
+ ///
+ /// Hides ribbon tabs by intercepting PropertyChanged on each target
+ /// RibbonTab data object. When Revit sets IsVisible=true during a
+ /// view switch, the callback fires synchronously and overrides it
+ /// back to false — before the WPF layout pass renders.
+ ///
+ /// Called from MinifyUI smartbutton via pyrevit.runtime.types.
+ ///
+ public static class RibbonTabVisibilityUtils
+ {
+ public static bool IsHidingTabs { get; private set; }
+
+ private static HashSet _hiddenTabTitles = new HashSet();
+ private static List _hookedTabs
+ = new List();
+
+ public static void StartHidingTabs(IEnumerable tabTitles)
+ {
+ StopHidingTabs();
+
+ _hiddenTabTitles = new HashSet(tabTitles);
+ if (_hiddenTabTitles.Count == 0)
+ return;
+
+ IsHidingTabs = true;
+
+ foreach (var tab in Autodesk.Windows.ComponentManager.Ribbon.Tabs)
+ {
+ if (_hiddenTabTitles.Contains(tab.Title))
+ {
+ var inpc = tab as System.ComponentModel.INotifyPropertyChanged;
+ if (inpc != null)
+ {
+ inpc.PropertyChanged += OnTabPropertyChanged;
+ _hookedTabs.Add(tab);
+ }
+ tab.IsVisible = false;
+ }
+ }
+ }
+
+ public static void StopHidingTabs()
+ {
+ foreach (var tab in _hookedTabs)
+ {
+ try
+ {
+ var inpc = tab as System.ComponentModel.INotifyPropertyChanged;
+ if (inpc != null)
+ inpc.PropertyChanged -= OnTabPropertyChanged;
+ tab.IsVisible = true;
+ }
+ catch { }
+ }
+ _hookedTabs.Clear();
+ _hiddenTabTitles.Clear();
+ IsHidingTabs = false;
+ }
+
+ private static void OnTabPropertyChanged(
+ object sender,
+ System.ComponentModel.PropertyChangedEventArgs e)
+ {
+ if (!IsHidingTabs) return;
+ if (e.PropertyName != "IsVisible") return;
+
+ var tab = sender as Autodesk.Windows.RibbonTab;
+ if (tab == null) return;
+ if (!_hiddenTabTitles.Contains(tab.Title)) return;
+
+ if (tab.IsVisible)
+ tab.IsVisible = false;
+ }
+ }
}
+
diff --git a/dev/pyRevitLabs/pyRevitCLI/pyRevitCLI.csproj b/dev/pyRevitLabs/pyRevitCLI/pyRevitCLI.csproj
index 5c12e6273..fbbaff8a0 100644
--- a/dev/pyRevitLabs/pyRevitCLI/pyRevitCLI.csproj
+++ b/dev/pyRevitLabs/pyRevitCLI/pyRevitCLI.csproj
@@ -20,7 +20,7 @@
-
+
diff --git a/dev/pyRevitLabs/pyRevitCLIAutoComplete/go.mod b/dev/pyRevitLabs/pyRevitCLIAutoComplete/go.mod
index 4ed7358da..9de64d003 100644
--- a/dev/pyRevitLabs/pyRevitCLIAutoComplete/go.mod
+++ b/dev/pyRevitLabs/pyRevitCLIAutoComplete/go.mod
@@ -2,7 +2,10 @@ module pyrevit-autocomplete
go 1.17
-require github.com/posener/complete v1.2.3
+require (
+ github.com/posener/complete v1.2.3
+ github.com/posener/complete/v2 v2.1.0
+)
require (
github.com/hashicorp/errwrap v1.1.0 // indirect
diff --git a/dev/pyRevitLabs/pyRevitCLIAutoComplete/go.sum b/dev/pyRevitLabs/pyRevitCLIAutoComplete/go.sum
index 3d8404254..409493294 100644
--- a/dev/pyRevitLabs/pyRevitCLIAutoComplete/go.sum
+++ b/dev/pyRevitLabs/pyRevitCLIAutoComplete/go.sum
@@ -10,6 +10,7 @@ github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo=
github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
+github.com/posener/complete/v2 v2.1.0/go.mod h1:AkzsSVGx4ysH/4OhZf57dr4yszGXgFmXsP/VNwlaW7U=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/dev/pyRevitLabs/pyRevitCLIAutoComplete/pyrevit-autocomplete.go b/dev/pyRevitLabs/pyRevitCLIAutoComplete/pyrevit-autocomplete.go
index 1bf216641..d0e33fcd6 100644
--- a/dev/pyRevitLabs/pyRevitCLIAutoComplete/pyrevit-autocomplete.go
+++ b/dev/pyRevitLabs/pyRevitCLIAutoComplete/pyrevit-autocomplete.go
@@ -34,9 +34,9 @@ func main() {
"env": complete.Command{
Sub: complete.Commands{},
Flags: complete.Flags{
- "--log": complete.PredictNothing,
"--help": complete.PredictNothing,
"--json": complete.PredictNothing,
+ "--log": complete.PredictNothing,
},
},
"update": complete.Command{
@@ -48,13 +48,13 @@ func main() {
"clone": complete.Command{
Sub: complete.Commands{},
Flags: complete.Flags{
- "--branch": complete.PredictNothing,
- "--password": complete.PredictNothing,
- "--token": complete.PredictNothing,
- "--image": complete.PredictNothing,
"--log": complete.PredictNothing,
"--help": complete.PredictNothing,
+ "--image": complete.PredictNothing,
+ "--branch": complete.PredictNothing,
+ "--token": complete.PredictNothing,
"--dest": complete.PredictNothing,
+ "--password": complete.PredictNothing,
},
},
"clones": complete.Command{
@@ -119,16 +119,16 @@ func main() {
"origin": complete.Command{
Sub: complete.Commands{},
Flags: complete.Flags{
- "--reset": complete.PredictNothing,
"--log": complete.PredictNothing,
+ "--reset": complete.PredictNothing,
},
},
"update": complete.Command{
Sub: complete.Commands{},
Flags: complete.Flags{
- "--password": complete.PredictNothing,
- "--log": complete.PredictNothing,
"--token": complete.PredictNothing,
+ "--log": complete.PredictNothing,
+ "--password": complete.PredictNothing,
},
},
"deployments": complete.Command{
@@ -150,16 +150,16 @@ func main() {
Sub: complete.Commands{},
Flags: complete.Flags{
"--installed": complete.PredictNothing,
- "--allusers": complete.PredictNothing,
"--attached": complete.PredictNothing,
+ "--allusers": complete.PredictNothing,
},
},
},
Flags: complete.Flags{
+ "--help": complete.PredictNothing,
"--installed": complete.PredictNothing,
- "--allusers": complete.PredictNothing,
"--attached": complete.PredictNothing,
- "--help": complete.PredictNothing,
+ "--allusers": complete.PredictNothing,
},
},
"attached": complete.Command{
@@ -177,8 +177,8 @@ func main() {
"detach": complete.Command{
Sub: complete.Commands{},
Flags: complete.Flags{
- "--log": complete.PredictNothing,
"--help": complete.PredictNothing,
+ "--log": complete.PredictNothing,
},
},
"extend": complete.Command{
@@ -186,28 +186,28 @@ func main() {
"ui": complete.Command{
Sub: complete.Commands{},
Flags: complete.Flags{
- "--password": complete.PredictNothing,
- "--log": complete.PredictNothing,
"--token": complete.PredictNothing,
"--dest": complete.PredictNothing,
+ "--log": complete.PredictNothing,
+ "--password": complete.PredictNothing,
},
},
"lib": complete.Command{
Sub: complete.Commands{},
Flags: complete.Flags{
- "--password": complete.PredictNothing,
- "--log": complete.PredictNothing,
"--token": complete.PredictNothing,
"--dest": complete.PredictNothing,
+ "--log": complete.PredictNothing,
+ "--password": complete.PredictNothing,
},
},
},
Flags: complete.Flags{
- "--password": complete.PredictNothing,
- "--token": complete.PredictNothing,
"--log": complete.PredictNothing,
"--help": complete.PredictNothing,
+ "--token": complete.PredictNothing,
"--dest": complete.PredictNothing,
+ "--password": complete.PredictNothing,
},
},
"extensions": complete.Command{
@@ -237,8 +237,8 @@ func main() {
"origin": complete.Command{
Sub: complete.Commands{},
Flags: complete.Flags{
- "--reset": complete.PredictNothing,
"--log": complete.PredictNothing,
+ "--reset": complete.PredictNothing,
},
},
"paths": complete.Command{
@@ -246,8 +246,8 @@ func main() {
"forget": complete.Command{
Sub: complete.Commands{},
Flags: complete.Flags{
- "--log": complete.PredictNothing,
"--all": complete.PredictNothing,
+ "--log": complete.PredictNothing,
},
},
"add": complete.Command{
@@ -258,8 +258,8 @@ func main() {
},
},
Flags: complete.Flags{
- "--log": complete.PredictNothing,
"--help": complete.PredictNothing,
+ "--log": complete.PredictNothing,
},
},
"enable": complete.Command{
@@ -279,8 +279,8 @@ func main() {
"forget": complete.Command{
Sub: complete.Commands{},
Flags: complete.Flags{
- "--log": complete.PredictNothing,
"--all": complete.PredictNothing,
+ "--log": complete.PredictNothing,
},
},
"add": complete.Command{
@@ -291,22 +291,22 @@ func main() {
},
},
Flags: complete.Flags{
- "--log": complete.PredictNothing,
"--help": complete.PredictNothing,
+ "--log": complete.PredictNothing,
},
},
"update": complete.Command{
Sub: complete.Commands{},
Flags: complete.Flags{
- "--password": complete.PredictNothing,
- "--log": complete.PredictNothing,
"--token": complete.PredictNothing,
+ "--log": complete.PredictNothing,
+ "--password": complete.PredictNothing,
},
},
},
Flags: complete.Flags{
- "--log": complete.PredictNothing,
"--help": complete.PredictNothing,
+ "--log": complete.PredictNothing,
},
},
"releases": complete.Command{
@@ -351,8 +351,8 @@ func main() {
},
},
Flags: complete.Flags{
- "--pre": complete.PredictNothing,
"--help": complete.PredictNothing,
+ "--pre": complete.PredictNothing,
},
},
"revits": complete.Command{
@@ -373,9 +373,9 @@ func main() {
},
},
Flags: complete.Flags{
+ "--help": complete.PredictNothing,
"--installed": complete.PredictNothing,
"--supported": complete.PredictNothing,
- "--help": complete.PredictNothing,
},
},
"run": complete.Command{
@@ -386,12 +386,12 @@ func main() {
},
},
Flags: complete.Flags{
- "--revit": complete.PredictNothing,
- "--import": complete.PredictNothing,
"--models": complete.PredictNothing,
- "--allowdialogs": complete.PredictNothing,
- "--purge": complete.PredictNothing,
"--help": complete.PredictNothing,
+ "--import": complete.PredictNothing,
+ "--revit": complete.PredictNothing,
+ "--purge": complete.PredictNothing,
+ "--allowdialogs": complete.PredictNothing,
},
},
"caches": complete.Command{
@@ -415,8 +415,8 @@ func main() {
"config": complete.Command{
Sub: complete.Commands{},
Flags: complete.Flags{
- "--from": complete.PredictNothing,
"--help": complete.PredictNothing,
+ "--from": complete.PredictNothing,
},
},
"configs": complete.Command{
@@ -708,8 +708,8 @@ func main() {
},
},
Flags: complete.Flags{
- "--log": complete.PredictNothing,
"--help": complete.PredictNothing,
+ "--log": complete.PredictNothing,
},
},
"telemetry": complete.Command{
@@ -778,8 +778,8 @@ func main() {
},
},
Flags: complete.Flags{
- "--log": complete.PredictNothing,
"--help": complete.PredictNothing,
+ "--log": complete.PredictNothing,
},
},
"apptelemetry": complete.Command{
@@ -839,25 +839,25 @@ func main() {
},
},
Flags: complete.Flags{
- "--log": complete.PredictNothing,
"--help": complete.PredictNothing,
+ "--log": complete.PredictNothing,
},
},
"doctor": complete.Command{
Sub: complete.Commands{},
Flags: complete.Flags{
- "--dryrun": complete.PredictNothing,
- "--list": complete.PredictNothing,
"--help": complete.PredictNothing,
+ "--list": complete.PredictNothing,
+ "--dryrun": complete.PredictNothing,
},
},
},
Flags: complete.Flags{
- "--verbose": complete.PredictNothing,
- "--debug": complete.PredictNothing,
"--usage": complete.PredictNothing,
- "--version": complete.PredictNothing,
"--help": complete.PredictNothing,
+ "--debug": complete.PredictNothing,
+ "--verbose": complete.PredictNothing,
+ "--version": complete.PredictNothing,
},
}
complete.New("pyrevit", pyrevit).Run()
diff --git a/dev/pyRevitLabs/pyRevitLabs.Common/CommonUtils.cs b/dev/pyRevitLabs/pyRevitLabs.Common/CommonUtils.cs
index 7f6a55c21..8115ada2a 100644
--- a/dev/pyRevitLabs/pyRevitLabs.Common/CommonUtils.cs
+++ b/dev/pyRevitLabs/pyRevitLabs.Common/CommonUtils.cs
@@ -1,4 +1,4 @@
-using OpenMcdf;
+using OpenMcdf;
using System;
using System.Text;
using System.Diagnostics;
@@ -211,19 +211,39 @@ public static bool CheckInternetConnection() {
}
public static byte[] GetStructuredStorageStream(string filePath, string streamName) {
- logger.Debug(string.Format("Attempting to read \"{0}\" stream from structured storage file at \"{1}\"",
- streamName, filePath));
+ logger.Debug("Attempting to read \"{0}\" stream from structured storage file at \"{1}\"",
+ streamName, filePath);
int res = StgIsStorageFile(filePath);
if (res == 0) {
- CompoundFile cf = new CompoundFile(filePath);
- logger.Debug($"Found CF Root: {cf.RootStorage}");
- if (cf.RootStorage.TryGetStream(streamName, out var foundStream)) {
- byte[] streamData = foundStream.GetData();
- cf.Close();
- return streamData;
+ using (var root = RootStorage.OpenRead(filePath)) {
+ logger.Debug("Opened structured storage root at \"{0}\"", filePath);
+ if (root.TryOpenStream(streamName, out CfbStream foundStream)) {
+ using (foundStream) {
+ long len = foundStream.Length;
+ if (len > int.MaxValue)
+ throw new NotSupportedException(
+ string.Format("Structured storage stream \"{0}\" in \"{1}\" exceeds maximum supported size.",
+ streamName, filePath));
+ foundStream.Seek(0, SeekOrigin.Begin);
+ byte[] buffer = new byte[(int)len];
+ int offset = 0;
+ int remaining = buffer.Length;
+ while (remaining > 0) {
+ int read = foundStream.Read(buffer, offset, remaining);
+ if (read == 0)
+ throw new InvalidDataException(
+ string.Format("Structured storage stream \"{0}\" in \"{1}\" ended before declared length.",
+ streamName, filePath));
+ offset += read;
+ remaining -= read;
+ }
+ return buffer;
+ }
+ }
+ logger.Debug("Stream \"{0}\" not found in structured storage file \"{1}\"", streamName, filePath);
+ return null;
}
- return null;
}
else {
throw new NotSupportedException("File is not a structured storage file");
diff --git a/dev/pyRevitLabs/pyRevitLabs.Common/pyRevitLabs.Common.csproj b/dev/pyRevitLabs/pyRevitLabs.Common/pyRevitLabs.Common.csproj
index c9bf31cf1..3858b0bb3 100644
--- a/dev/pyRevitLabs/pyRevitLabs.Common/pyRevitLabs.Common.csproj
+++ b/dev/pyRevitLabs/pyRevitLabs.Common/pyRevitLabs.Common.csproj
@@ -11,17 +11,17 @@
-
-
-
-
-
+
+
+
+
+
-
+
diff --git a/dev/pyRevitLabs/pyRevitLabs.PyRevit/pyRevitLabs.PyRevit.csproj b/dev/pyRevitLabs/pyRevitLabs.PyRevit/pyRevitLabs.PyRevit.csproj
index 5370f8149..b8688e097 100644
--- a/dev/pyRevitLabs/pyRevitLabs.PyRevit/pyRevitLabs.PyRevit.csproj
+++ b/dev/pyRevitLabs/pyRevitLabs.PyRevit/pyRevitLabs.PyRevit.csproj
@@ -16,7 +16,7 @@
-
+
diff --git a/dev/pyRevitLabs/pyRevitLabs.UnitTests/pyRevitLabs.UnitTests.csproj b/dev/pyRevitLabs/pyRevitLabs.UnitTests/pyRevitLabs.UnitTests.csproj
index b6275ef2c..a92d0499d 100644
--- a/dev/pyRevitLabs/pyRevitLabs.UnitTests/pyRevitLabs.UnitTests.csproj
+++ b/dev/pyRevitLabs/pyRevitLabs.UnitTests/pyRevitLabs.UnitTests.csproj
@@ -11,8 +11,8 @@
-
-
+
+
diff --git a/dev/pyRevitLoader/Directory.Build.targets b/dev/pyRevitLoader/Directory.Build.targets
index b4b7451f9..8451412e1 100644
--- a/dev/pyRevitLoader/Directory.Build.targets
+++ b/dev/pyRevitLoader/Directory.Build.targets
@@ -72,7 +72,7 @@
-
+
diff --git a/dev/pyRevitLoader/Source/PyRevitRunnerCommand.cs b/dev/pyRevitLoader/Source/PyRevitRunnerCommand.cs
index 7733023a5..7006925c9 100644
--- a/dev/pyRevitLoader/Source/PyRevitRunnerCommand.cs
+++ b/dev/pyRevitLoader/Source/PyRevitRunnerCommand.cs
@@ -231,7 +231,9 @@ private static void SeedEnvDictionary(UIApplication uiApp) {
var ipyVersion = attachment?.Engine != null ? attachment.Engine.Version.Version.ToString() : "0";
var cpyVersion = PyRevitConfigs.GetCpythonEngineVersion().ToString();
- envData[EnvDictionaryKeys.SessionUUID] = Guid.NewGuid().ToString();
+ if (!envData.Contains(EnvDictionaryKeys.SessionUUID)
+ || string.IsNullOrWhiteSpace(envData[EnvDictionaryKeys.SessionUUID] as string))
+ envData[EnvDictionaryKeys.SessionUUID] = Guid.NewGuid().ToString();
envData[EnvDictionaryKeys.RevitVersion] = revitVersion;
envData[EnvDictionaryKeys.Version] = pyRevitVersion;
envData[EnvDictionaryKeys.Clone] = cloneName;
diff --git a/dev/pyRevitLoader/pyRevitAssemblyBuilder/AssemblyMaker/AssemblyBuilderService.cs b/dev/pyRevitLoader/pyRevitAssemblyBuilder/AssemblyMaker/AssemblyBuilderService.cs
index d06d1c0ec..ea29c390f 100644
--- a/dev/pyRevitLoader/pyRevitAssemblyBuilder/AssemblyMaker/AssemblyBuilderService.cs
+++ b/dev/pyRevitLoader/pyRevitAssemblyBuilder/AssemblyMaker/AssemblyBuilderService.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.IO;
using System.Collections.Generic;
using System.Linq;
@@ -52,10 +52,11 @@ public AssemblyBuilderService(string revitVersion, AssemblyBuildStrategy buildSt
///
/// The parsed extension to build an assembly for.
/// Optional collection of library extensions to include as references.
+ /// Whether rocket mode is enabled globally.
/// Information about the built assembly.
/// Thrown when extension is null.
/// Thrown when assembly building fails.
- public ExtensionAssemblyInfo BuildExtensionAssembly(ParsedExtension extension, IEnumerable libraryExtensions = null)
+ public ExtensionAssemblyInfo BuildExtensionAssembly(ParsedExtension extension, IEnumerable libraryExtensions = null, bool rocketMode = false)
{
if (extension == null)
throw new ArgumentNullException(nameof(extension));
@@ -74,9 +75,13 @@ public ExtensionAssemblyInfo BuildExtensionAssembly(ParsedExtension extension, I
// so they're available in the AppDomain for CLREngine to reference
LoadExtensionModules(extension);
- // Use build strategy as seed to differentiate DLLs built with different strategies
- // This ensures DLLs are only regenerated when extension structure changes or build strategy changes
- string strategySeed = _buildStrategy.ToString();
+ // Include generation-time inputs that affect emitted command types, so cache invalidates
+ // when runtime behavior changes (e.g. rocket mode toggles or loader binary updates).
+ string strategySeed = string.Join("|",
+ _buildStrategy.ToString(),
+ $"rocket:{rocketMode}",
+ $"rocket_compat:{extension.RocketModeCompatible}",
+ $"builder:{GetAssemblyBuildFingerprint()}");
string hash = GetStableHash(extension.GetHash(strategySeed) + _revitVersion).Substring(0, 16);
string fileName = $"pyRevit_{_revitVersion}_{hash}_{extension.Name}.dll";
@@ -117,7 +122,7 @@ public ExtensionAssemblyInfo BuildExtensionAssembly(ParsedExtension extension, I
try
{
- BuildWithRoslyn(extension, outputPath, libraryExtensions);
+ BuildWithRoslyn(extension, outputPath, libraryExtensions, rocketMode);
return new ExtensionAssemblyInfo(extension.Name, outputPath, isReloading);
}
@@ -260,11 +265,12 @@ private void UpdateReferencedAssemblies(HashSet newModulePaths)
/// The parsed extension to build.
/// The output path for the compiled assembly.
/// Optional library extensions to reference.
+ /// Whether rocket mode is enabled globally.
/// Thrown when Roslyn compilation fails.
- private void BuildWithRoslyn(ParsedExtension extension, string outputPath, IEnumerable libraryExtensions)
+ private void BuildWithRoslyn(ParsedExtension extension, string outputPath, IEnumerable libraryExtensions, bool rocketMode)
{
var generator = new RoslynCommandTypeGenerator();
- string code = generator.GenerateExtensionCode(extension, _revitVersion, libraryExtensions);
+ string code = generator.GenerateExtensionCode(extension, _revitVersion, libraryExtensions, rocketMode);
var csPath = Path.Combine(Path.GetDirectoryName(outputPath), $"{extension.Name}.cs");
File.WriteAllText(csPath, code);
_logger.Debug($"Generated C# code file: {csPath}");
@@ -335,6 +341,23 @@ private static string GetStableHash(string input)
return BitConverter.ToString(hash).Replace("-", string.Empty).ToLowerInvariant();
}
+ private static string GetAssemblyBuildFingerprint()
+ {
+ try
+ {
+ var asmPath = Assembly.GetExecutingAssembly().Location;
+ var writeTime = File.Exists(asmPath)
+ ? File.GetLastWriteTimeUtc(asmPath).Ticks.ToString()
+ : "0";
+ var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "0";
+ return string.Join("-", version, writeTime);
+ }
+ catch
+ {
+ return "0";
+ }
+ }
+
///
/// Loads an assembly into the current AppDomain.
///
diff --git a/dev/pyRevitLoader/pyRevitAssemblyBuilder/AssemblyMaker/CommandTypeGenerator.cs b/dev/pyRevitLoader/pyRevitAssemblyBuilder/AssemblyMaker/CommandTypeGenerator.cs
index 85a8c4471..2887306ba 100644
--- a/dev/pyRevitLoader/pyRevitAssemblyBuilder/AssemblyMaker/CommandTypeGenerator.cs
+++ b/dev/pyRevitLoader/pyRevitAssemblyBuilder/AssemblyMaker/CommandTypeGenerator.cs
@@ -49,8 +49,8 @@ private static string GetPyRevitRoot()
var dllDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
return Path.GetFullPath(Path.Combine(dllDir, "..", "..", "..", ".."));
}
-
- public string GenerateExtensionCode(ParsedExtension extension, string revitVersion, IEnumerable libraryExtensions = null)
+
+ public string GenerateExtensionCode(ParsedExtension extension, string revitVersion, IEnumerable libraryExtensions = null, bool rocketMode = false)
{
var sb = new StringBuilder();
sb.AppendLine("#nullable disable");
@@ -58,11 +58,32 @@ public string GenerateExtensionCode(ParsedExtension extension, string revitVersi
sb.AppendLine("using PyRevitLabs.PyRevit.Runtime;");
sb.AppendLine();
+ // Fix for #3140: Track emitted class names to prevent Roslyn CS0101
+ // (duplicate type definition) which kills the entire extension assembly.
+ // The legacy loader isolates failures per-command via try/except in
+ // typemaker.make_bundle_types(); this HashSet provides equivalent safety.
+ var emittedClassNames = new HashSet(StringComparer.Ordinal);
+
foreach (var cmd in extension.CollectCommandComponents())
{
string safeClassName = SanitizeClassName(cmd.UniqueId);
+
+ if (!emittedClassNames.Add(safeClassName))
+ {
+ // Duplicate — skip this command to avoid CS0101.
+ // Emit a comment in the generated .cs so the user can diagnose
+ // which script was dropped (the .cs file is saved alongside the DLL).
+ sb.AppendLine($"// WARNING [#3140]: Skipped duplicate class '{safeClassName}'");
+ sb.AppendLine($"// Script: {cmd.ScriptPath ?? "(no script)"}");
+ sb.AppendLine($"// UniqueId: {cmd.UniqueId}");
+ sb.AppendLine($"// Two bundle directories produced the same UniqueId.");
+ sb.AppendLine($"// Rename one directory to fix this.");
+ sb.AppendLine();
+ continue;
+ }
+
string scriptPath = cmd.ScriptPath;
-
+
// Build search paths matching Python's behavior:
// 1. Script's own directory
// 2. Component hierarchy lib/ folders (button -> panel -> tab -> extension)
@@ -112,7 +133,7 @@ public string GenerateExtensionCode(ParsedExtension extension, string revitVersi
string ctrlId = cmd.ControlId ?? $"CustomCtrl_%CustomCtrl_%{extName}%{bundle}%{cmd.Name}";
// Build engine configs based on bundle configuration or script type
- string engineCfgs = CommandGenerationUtilities.BuildEngineConfigs(cmd, scriptPath);
+ string engineCfgs = CommandGenerationUtilities.BuildEngineConfigs(cmd, scriptPath, extension, rocketMode);
// Get context from component - only use if explicitly defined
string context = cmd.Context ?? string.Empty;
@@ -169,6 +190,14 @@ private static string SanitizeClassName(string name)
var sb = new StringBuilder();
foreach (char c in name)
sb.Append(char.IsLetterOrDigit(c) ? c : '_');
+
+ // Fix for #3107: C# identifiers cannot start with a digit.
+ // The legacy loader used Reflection.Emit (IL-level) where leading digits
+ // were valid. Roslyn compiles C# source, which requires a letter or '_'
+ // as the first character. Prepend '_' to make it a valid identifier.
+ if (sb.Length > 0 && char.IsDigit(sb[0]))
+ sb.Insert(0, '_');
+
return sb.ToString();
}
@@ -181,7 +210,7 @@ private static string Escape(string str) =>
.Replace("\"", "\\\"");
}
- internal static class CommandGenerationUtilities
+ public static class CommandGenerationUtilities
{
public static string BuildCommandArguments(ParsedExtension extension, ParsedComponent component, string revitVersion)
{
@@ -206,17 +235,25 @@ public static string BuildCommandArguments(ParsedExtension extension, ParsedComp
///
/// Builds the engine configuration JSON string based on script type and bundle settings
///
- public static string BuildEngineConfigs(ParsedComponent cmd, string scriptPath)
+ /// The command component to build configs for
+ /// Path to the script file
+ /// The parent extension (for rocket mode compatibility check)
+ /// Whether rocket mode is enabled globally
+ public static string BuildEngineConfigs(ParsedComponent cmd, string scriptPath, ParsedExtension extension = null, bool rocketMode = false)
{
var configs = new Dictionary();
// Check if this is a Dynamo script
bool isDynamoScript = scriptPath != null &&
scriptPath.EndsWith(".dyn", StringComparison.OrdinalIgnoreCase);
-
- // Core engine settings (apply to all script types)
- configs["clean"] = cmd.Engine?.Clean ?? false;
-
+
+ // Determine clean engine setting.
+ // Default is false (metadata-driven; matches legacy cached-engine behavior)
+ // In rocket mode with compatible extension, use cached engine (clean = false).
+ bool useCleanEngine = cmd.Engine?.Clean ?? false;
+ // No rocket-mode override needed — the logic is now purely metadata-driven
+ configs["clean"] = useCleanEngine;
+
// Add engine type only when explicitly specified in metadata.
// Do not force the default IronPython value into configs,
// otherwise runtime shebang detection (#! python3) is bypassed.
@@ -228,9 +265,8 @@ public static string BuildEngineConfigs(ParsedComponent cmd, string scriptPath)
if (isDynamoScript)
{
- // For Dynamo scripts, use appropriate settings
- // Use automate or mainthread setting (automate is Dynamo-specific synonym)
- bool requiresMainThread = (cmd.Engine?.MainThread ?? false) || (cmd.Engine?.Automate ?? true);
+ // Use EngineConfig.RequiresMainThread which already has the correct defaults.
+ bool requiresMainThread = cmd.Engine?.RequiresMainThread ?? false;
configs["automate"] = requiresMainThread;
// Add Dynamo-specific settings
diff --git a/dev/pyRevitLoader/pyRevitAssemblyBuilder/AssemblyMaker/IAssemblyBuilderService.cs b/dev/pyRevitLoader/pyRevitAssemblyBuilder/AssemblyMaker/IAssemblyBuilderService.cs
index 2b1be75c7..ec0cc2a5e 100644
--- a/dev/pyRevitLoader/pyRevitAssemblyBuilder/AssemblyMaker/IAssemblyBuilderService.cs
+++ b/dev/pyRevitLoader/pyRevitAssemblyBuilder/AssemblyMaker/IAssemblyBuilderService.cs
@@ -14,8 +14,9 @@ public interface IAssemblyBuilderService
///
/// The parsed extension to build an assembly for.
/// Optional collection of library extensions to include as references.
+ /// Whether rocket mode is enabled globally.
/// Information about the built assembly, or null if building fails.
- ExtensionAssemblyInfo? BuildExtensionAssembly(ParsedExtension extension, IEnumerable? libraryExtensions = null);
+ ExtensionAssemblyInfo? BuildExtensionAssembly(ParsedExtension extension, IEnumerable? libraryExtensions = null, bool rocketMode = false);
///
/// Loads an assembly into the current AppDomain.
diff --git a/dev/pyRevitLoader/pyRevitAssemblyBuilder/SessionManager/EnvDictionarySeeder.cs b/dev/pyRevitLoader/pyRevitAssemblyBuilder/SessionManager/EnvDictionarySeeder.cs
new file mode 100644
index 000000000..9522513ff
--- /dev/null
+++ b/dev/pyRevitLoader/pyRevitAssemblyBuilder/SessionManager/EnvDictionarySeeder.cs
@@ -0,0 +1,179 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Reflection;
+using Autodesk.Revit.UI;
+using pyRevitExtensionParser;
+
+namespace pyRevitAssemblyBuilder.SessionManager
+{
+ ///
+ /// Seeds the AppDomain environment dictionary consumed by the pyRevit Runtime.
+ ///
+ /// The Runtime's EnvDictionary class reads session state (UUID, versions, telemetry
+ /// settings, etc.) from an IronPython.Runtime.PythonDictionary stored in the AppDomain
+ /// under the key "PYREVITEnvVarsDict". Because this loader project has no compile-time
+ /// reference to IronPython (UseIronPython=false), we delegate the actual
+ /// PythonDictionary creation to the Runtime via EnvDictionary.Seed(), which is
+ /// invoked here through reflection — the same pattern already used for
+ /// ScriptExecutor.Initialize() and ScriptExecutor.ExecuteScript().
+ ///
+ ///
+ internal static class EnvDictionarySeeder
+ {
+ // Env-dict key string values. These must match EnvDictionaryKeys in the Runtime
+ // (dev/pyRevitLabs.PyRevit.Runtime/EnvVariables.cs). The prefix is "PYREVIT" because
+ // PyRevitLabsConsts.ProductName = "PYREVIT".
+ private const string KeySessionUUID = "PYREVIT_UUID";
+ private const string KeyRevitVersion = "PYREVIT_APPVERSION";
+ private const string KeyVersion = "PYREVIT_VERSION";
+ private const string KeyClone = "PYREVIT_CLONE";
+ private const string KeyIPYVersion = "PYREVIT_IPYVERSION";
+ private const string KeyCPYVersion = "PYREVIT_CPYVERSION";
+ private const string KeyLoggingLevel = "PYREVIT_LOGGINGLEVEL";
+ private const string KeyFileLogging = "PYREVIT_FILELOGGING";
+ private const string KeyTelemetryState = "PYREVIT_TELEMETRYSTATE";
+ private const string KeyTelemetryUTC = "PYREVIT_TELEMETRYUTCTIMESTAMPS";
+ private const string KeyTelemetryFileDir = "PYREVIT_TELEMETRYDIR";
+ private const string KeyTelemetryFile = "PYREVIT_TELEMETRYFILE";
+ private const string KeyTelemetryServer = "PYREVIT_TELEMETRYSERVER";
+ private const string KeyTelemetryHooks = "PYREVIT_TELEMETRYINCLUDEHOOKS";
+ private const string KeyAppTelemetryState = "PYREVIT_APPTELEMETRYSTATE";
+ private const string KeyAppTelemetryServer = "PYREVIT_APPTELEMETRYSERVER";
+ private const string KeyAppTelemetryFlags = "PYREVIT_APPTELEMETRYEVENTFLAGS";
+ private const string KeyAutoUpdating = "PYREVIT_AUTOUPDATE";
+ private const string KeyOutputStyleSheet = "PYREVIT_STYLESHEET";
+
+ ///
+ /// Builds the session environment dictionary and stores it in the AppDomain via a reflection
+ /// call to EnvDictionary.Seed() in the Runtime assembly.
+ ///
+ /// The active Revit UIApplication (provides version number).
+ ///
+ /// The already-loaded pyRevitLabs.PyRevit.Runtime assembly.
+ ///
+ ///
+ /// Root directory of the pyRevit repository (used to read the version file and locate engine
+ /// binaries). May be empty — the seeder degrades gracefully to "Unknown" where needed.
+ ///
+ public static void Seed(UIApplication uiApp, Assembly runtimeAssembly, string pyRevitRoot)
+ {
+ var config = PyRevitConfig.Load();
+
+ var values = new Dictionary
+ {
+ [KeySessionUUID] = Guid.NewGuid().ToString(),
+ [KeyRevitVersion] = uiApp?.Application?.VersionNumber ?? string.Empty,
+ [KeyVersion] = ReadPyRevitVersion(pyRevitRoot),
+ [KeyClone] = "Unknown",
+ [KeyIPYVersion] = ReadIPYVersion(pyRevitRoot),
+ [KeyCPYVersion] = "3.12.3", // Known default for the bundled CPython engine
+
+ // Fix for #3203: PyRevitConfig.LoggingLevel returns a pyRevit enum
+ // (0=Quiet, 1=Verbose, 2=Debug) but the Python logger reads this
+ // env var as a Python logging module level (10=DEBUG, 20=INFO, 30=WARNING).
+ // Translate to avoid corrupting the Python logging threshold.
+ [KeyLoggingLevel] = ToPythonLoggingLevel(config.LoggingLevel),
+ [KeyFileLogging] = config.FileLogging,
+
+ [KeyTelemetryState] = config.TelemetryState,
+ [KeyTelemetryUTC] = config.TelemetryUTCTimeStamps,
+ [KeyTelemetryFileDir] = config.TelemetryFilePath,
+ [KeyTelemetryFile] = string.Empty,
+ [KeyTelemetryServer] = config.TelemetryServerUrl,
+ [KeyTelemetryHooks] = config.TelemetryIncludeHooks,
+
+ [KeyAppTelemetryState] = config.AppTelemetryState,
+ [KeyAppTelemetryServer] = config.AppTelemetryServerUrl,
+ [KeyAppTelemetryFlags] = config.AppTelemetryEventFlags,
+
+ [KeyAutoUpdating] = config.AutoUpdate,
+ [KeyOutputStyleSheet] = config.OutputStyleSheet,
+ };
+
+ // Delegate to EnvDictionary.Seed() in the Runtime, which owns PythonDictionary creation.
+ var envDictType = runtimeAssembly.GetType("PyRevitLabs.PyRevit.Runtime.EnvDictionary")
+ ?? throw new InvalidOperationException("Cannot find type PyRevitLabs.PyRevit.Runtime.EnvDictionary in runtime assembly.");
+
+ var seedMethod = envDictType.GetMethod(
+ "Seed",
+ BindingFlags.Public | BindingFlags.Static,
+ null,
+ new[] { typeof(Dictionary) },
+ null)
+ ?? throw new InvalidOperationException("Cannot find EnvDictionary.Seed(Dictionary) method.");
+
+ seedMethod.Invoke(null, new object[] { values });
+ }
+
+ ///
+ /// Converts PyRevitConfig's logging level enum (0=Quiet, 1=Verbose, 2=Debug)
+ /// to Python's logging module level (30=WARNING, 20=INFO, 10=DEBUG).
+ ///
+ /// Python's logger (pyrevitlib/pyrevit/coreutils/logger.py) reads PYREVIT_LOGGINGLEVEL
+ /// and compares it directly: record.levelno >= _curlevel. The Python logging
+ /// constants are DEBUG=10, INFO=20, WARNING=30. If we store 0 (pyRevit Quiet) the
+ /// comparison 10 >= 0 is always true — every message passes, which forces the
+ /// console window open.
+ ///
+ ///
+ internal static int ToPythonLoggingLevel(int pyrevitLevel)
+ {
+ switch (pyrevitLevel)
+ {
+ case 2: return 10; // Debug → logging.DEBUG
+ case 1: return 20; // Verbose → logging.INFO
+ default: return 30; // Quiet → logging.WARNING (Python default)
+ }
+ }
+
+ private static string ReadPyRevitVersion(string pyRevitRoot)
+ {
+ if (string.IsNullOrEmpty(pyRevitRoot))
+ return "Unknown";
+
+ // pyRevit version is stored as a bare version string in pyrevitlib/pyrevit/version
+ var versionFile = Path.Combine(pyRevitRoot, "pyrevitlib", "pyrevit", "version");
+ if (File.Exists(versionFile))
+ {
+ try { return File.ReadAllText(versionFile).Trim(); }
+ catch { /* fall through to Unknown */ }
+ }
+
+ return "Unknown";
+ }
+
+ private static string ReadIPYVersion(string pyRevitRoot)
+ {
+ // IronPython engines live under bin/ inside the repo root as well as beside this DLL.
+ // Check both the bin/ folder of the repo and the directory of the executing assembly.
+ var candidateDirs = new List();
+
+ if (!string.IsNullOrEmpty(pyRevitRoot))
+ {
+ candidateDirs.Add(Path.Combine(pyRevitRoot, "bin", "IPY342"));
+ candidateDirs.Add(Path.Combine(pyRevitRoot, "bin", "IPY2712PR"));
+ candidateDirs.Add(Path.Combine(pyRevitRoot, "bin"));
+ }
+
+ var selfDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
+ if (!string.IsNullOrEmpty(selfDir))
+ candidateDirs.Add(selfDir);
+
+ foreach (var dir in candidateDirs)
+ {
+ var dll = Path.Combine(dir, "IronPython.dll");
+ if (!File.Exists(dll)) continue;
+ try
+ {
+ var ver = AssemblyName.GetAssemblyName(dll).Version;
+ if (ver != null) return ver.ToString();
+ }
+ catch { /* try next candidate */ }
+ }
+
+ return "Unknown";
+ }
+ }
+}
diff --git a/dev/pyRevitLoader/pyRevitAssemblyBuilder/SessionManager/ExtensionManagerService.cs b/dev/pyRevitLoader/pyRevitAssemblyBuilder/SessionManager/ExtensionManagerService.cs
index aa028fbec..194463689 100644
--- a/dev/pyRevitLoader/pyRevitAssemblyBuilder/SessionManager/ExtensionManagerService.cs
+++ b/dev/pyRevitLoader/pyRevitAssemblyBuilder/SessionManager/ExtensionManagerService.cs
@@ -2,7 +2,6 @@
using System.Collections.Generic;
using System.Linq;
using pyRevitExtensionParser;
-using static pyRevitExtensionParser.ExtensionParser;
namespace pyRevitAssemblyBuilder.SessionManager
{
@@ -11,14 +10,20 @@ namespace pyRevitAssemblyBuilder.SessionManager
///
public class ExtensionManagerService : IExtensionManagerService
{
+ private readonly int _revitYear;
private List? _cachedExtensions;
+ public ExtensionManagerService(int revitYear = 0)
+ {
+ _revitYear = revitYear;
+ }
+
///
/// Gets all parsed extensions (cached).
///
private List GetAllExtensionsCached()
{
- return _cachedExtensions ??= ExtensionParser.ParseInstalledExtensions().ToList();
+ return _cachedExtensions ??= ExtensionParser.ParseInstalledExtensions(_revitYear).ToList();
}
///
diff --git a/dev/pyRevitLoader/pyRevitAssemblyBuilder/SessionManager/ServiceFactory.cs b/dev/pyRevitLoader/pyRevitAssemblyBuilder/SessionManager/ServiceFactory.cs
index bea46bdd1..adb316d45 100644
--- a/dev/pyRevitLoader/pyRevitAssemblyBuilder/SessionManager/ServiceFactory.cs
+++ b/dev/pyRevitLoader/pyRevitAssemblyBuilder/SessionManager/ServiceFactory.cs
@@ -44,9 +44,9 @@ public static IAssemblyBuilderService CreateAssemblyBuilderService(string revitV
/// Creates an ExtensionManagerService instance.
///
/// A new IExtensionManagerService instance.
- public static IExtensionManagerService CreateExtensionManagerService()
+ public static IExtensionManagerService CreateExtensionManagerService(int revitYear = 0)
{
- return new ExtensionManagerService();
+ return new ExtensionManagerService(revitYear);
}
///
@@ -250,7 +250,8 @@ public static ISessionManagerService CreateSessionManagerService(
// Create core services
var assemblyBuilder = CreateAssemblyBuilderService(revitVersion, buildStrategy, logger);
- var extensionManager = CreateExtensionManagerService();
+ int.TryParse(revitVersion, out int revitYear);
+ var extensionManager = CreateExtensionManagerService(revitYear);
var hookManager = CreateHookManager(logger);
// Create icon and tooltip managers
diff --git a/dev/pyRevitLoader/pyRevitAssemblyBuilder/UIManager/Builders/ITabBuilder.cs b/dev/pyRevitLoader/pyRevitAssemblyBuilder/UIManager/Builders/ITabBuilder.cs
index bb58b3dd4..611824485 100644
--- a/dev/pyRevitLoader/pyRevitAssemblyBuilder/UIManager/Builders/ITabBuilder.cs
+++ b/dev/pyRevitLoader/pyRevitAssemblyBuilder/UIManager/Builders/ITabBuilder.cs
@@ -11,10 +11,16 @@ namespace pyRevitAssemblyBuilder.UIManager.Builders
public interface ITabBuilder
{
///
- /// Creates a ribbon tab from the specified component.
+ /// Creates a ribbon tab from the specified component, or finds and re-enables
+ /// an existing one (including tabs whose Title was renamed by a script).
///
/// The tab component to create.
- void CreateTab(ParsedComponent component);
+ ///
+ /// The tab's current display Title if it differs from the requested name
+ /// (i.e. the tab was renamed by a script), or null if no rename was detected.
+ /// Callers can use this to dual-mark the scanner registry under both names.
+ ///
+ string? CreateTab(ParsedComponent component);
///
/// Tags a ribbon tab with the pyRevit identifier for runtime icon toggling.
diff --git a/dev/pyRevitLoader/pyRevitAssemblyBuilder/UIManager/Builders/StackBuilder.cs b/dev/pyRevitLoader/pyRevitAssemblyBuilder/UIManager/Builders/StackBuilder.cs
index daafa79d0..c29b323e7 100644
--- a/dev/pyRevitLoader/pyRevitAssemblyBuilder/UIManager/Builders/StackBuilder.cs
+++ b/dev/pyRevitLoader/pyRevitAssemblyBuilder/UIManager/Builders/StackBuilder.cs
@@ -364,6 +364,11 @@ private static string SanitizeClassName(string name)
var sb = new System.Text.StringBuilder();
foreach (char c in name)
sb.Append(char.IsLetterOrDigit(c) ? c : '_');
+
+ // Fix for #3107: C# class names cannot start with a digit.
+ if (sb.Length > 0 && char.IsDigit(sb[0]))
+ sb.Insert(0, '_');
+
return sb.ToString();
}
diff --git a/dev/pyRevitLoader/pyRevitAssemblyBuilder/UIManager/Builders/TabBuilder.cs b/dev/pyRevitLoader/pyRevitAssemblyBuilder/UIManager/Builders/TabBuilder.cs
index 5564633bf..a10232b3e 100644
--- a/dev/pyRevitLoader/pyRevitAssemblyBuilder/UIManager/Builders/TabBuilder.cs
+++ b/dev/pyRevitLoader/pyRevitAssemblyBuilder/UIManager/Builders/TabBuilder.cs
@@ -28,17 +28,17 @@ public TabBuilder(UIApplication uiApp, ILogger logger)
}
///
- public void CreateTab(ParsedComponent component)
+ public string? CreateTab(ParsedComponent component)
{
if (component == null)
{
_logger.Warning("Cannot create tab: component is null.");
- return;
+ return null;
}
// Use localized title which handles fallback to DisplayName
var tabText = ExtensionParser.GetComponentTitle(component);
-
+
try
{
_uiApp.CreateRibbonTab(tabText);
@@ -50,24 +50,56 @@ public void CreateTab(ParsedComponent component)
_logger.Debug($"Failed to create ribbon tab '{tabText}'. Tab may already exist. Exception: {ex.Message}");
}
- // Mark the tab as a pyRevit tab so toggle_icon can find it at runtime
- TagTabAsPyRevit(tabText);
-
- // Ensure existing tab is visible/enabled on reload
+ // Find the tab, tag it as pyRevit, ensure it's visible, and detect renames.
+ // Done in a single ribbon scan to avoid the circular dependency where
+ // TagTabAsPyRevit would need the Tag to find renamed tabs but is itself
+ // the method that sets the Tag.
+ string? renamedTitle = null;
try
{
var ribbon = ComponentManager.Ribbon;
- var existingTab = ribbon?.Tabs?.FirstOrDefault(t => t.Title == tabText || t.Id == tabText);
+ if (ribbon?.Tabs == null) return null;
+
+ // Primary search: exact Title or Id match
+ var existingTab = ribbon.Tabs.FirstOrDefault(t =>
+ t.Title == tabText || t.Id == tabText);
+
+ // Fallback: tab was renamed by a script (e.g. translation) so Title no
+ // longer matches. Search by pyRevit Tag (set during the previous session)
+ // combined with exact Id match. The Tag persists on the AdWindows object
+ // across reloads within the same Revit session.
+ if (existingTab == null)
+ {
+ existingTab = ribbon.Tabs.FirstOrDefault(t =>
+ (t.Tag as string) == UIManagerConstants.PyRevitTabIdentifier
+ && string.Equals(t.Id, tabText, StringComparison.OrdinalIgnoreCase));
+ }
+
if (existingTab != null)
{
+ existingTab.Tag = UIManagerConstants.PyRevitTabIdentifier;
existingTab.IsVisible = true;
existingTab.IsEnabled = true;
+
+ // Detect rename: if the current Title differs, return it so the
+ // caller can dual-mark the scanner registry under both names.
+ if (existingTab.Title != tabText)
+ {
+ renamedTitle = existingTab.Title;
+ _logger.Debug($"Tab '{tabText}' found with renamed Title '{renamedTitle}'.");
+ }
+ else
+ {
+ _logger.Debug($"Found and enabled tab '{tabText}'.");
+ }
}
}
catch (Exception ex)
{
- _logger.Debug($"Failed to re-enable tab '{tabText}'. Exception: {ex.Message}");
+ _logger.Debug($"Failed to find/enable tab '{tabText}'. Exception: {ex.Message}");
}
+
+ return renamedTitle;
}
///
@@ -85,8 +117,18 @@ public void TagTabAsPyRevit(string tabName)
if (ribbon?.Tabs == null)
return;
- // Find the tab by Title (display name) since that's how tabs are identified
- var tab = ribbon.Tabs.FirstOrDefault(t => t.Title == tabName || t.Id == tabName);
+ // Primary: find by Title or Id
+ var tab = ribbon.Tabs.FirstOrDefault(t =>
+ t.Title == tabName || t.Id == tabName);
+
+ // Fallback: renamed tab — match by existing Tag + exact Id
+ if (tab == null)
+ {
+ tab = ribbon.Tabs.FirstOrDefault(t =>
+ (t.Tag as string) == UIManagerConstants.PyRevitTabIdentifier
+ && string.Equals(t.Id, tabName, StringComparison.OrdinalIgnoreCase));
+ }
+
if (tab != null)
{
tab.Tag = UIManagerConstants.PyRevitTabIdentifier;
diff --git a/dev/pyRevitLoader/pyRevitAssemblyBuilder/UIManager/Buttons/ButtonBuilderBase.cs b/dev/pyRevitLoader/pyRevitAssemblyBuilder/UIManager/Buttons/ButtonBuilderBase.cs
index c72d4615d..918d3810a 100644
--- a/dev/pyRevitLoader/pyRevitAssemblyBuilder/UIManager/Buttons/ButtonBuilderBase.cs
+++ b/dev/pyRevitLoader/pyRevitAssemblyBuilder/UIManager/Buttons/ButtonBuilderBase.cs
@@ -78,6 +78,11 @@ protected static string SanitizeClassName(string name)
var sb = new System.Text.StringBuilder();
foreach (char c in name)
sb.Append(char.IsLetterOrDigit(c) ? c : '_');
+
+ // Fix for #3107: C# class names cannot start with a digit.
+ if (sb.Length > 0 && char.IsDigit(sb[0]))
+ sb.Insert(0, '_');
+
return sb.ToString();
}
diff --git a/dev/pyRevitLoader/pyRevitAssemblyBuilder/UIManager/IUIManagerService.cs b/dev/pyRevitLoader/pyRevitAssemblyBuilder/UIManager/IUIManagerService.cs
index d05030080..bbe95a229 100644
--- a/dev/pyRevitLoader/pyRevitAssemblyBuilder/UIManager/IUIManagerService.cs
+++ b/dev/pyRevitLoader/pyRevitAssemblyBuilder/UIManager/IUIManagerService.cs
@@ -15,6 +15,12 @@ public interface IUIManagerService
///
UIApplication UIApplication { get; }
+ ///
+ /// Gets whether rocket mode is enabled.
+ /// When true, non-critical startup work is skipped and engine caching is used for compatible extensions.
+ ///
+ bool RocketMode { get; }
+
///
/// Builds the UI for the specified extension using the provided assembly information.
///
diff --git a/dev/pyRevitLoader/pyRevitAssemblyBuilder/UIManager/IconsHandling/IconManager.cs b/dev/pyRevitLoader/pyRevitAssemblyBuilder/UIManager/IconsHandling/IconManager.cs
index b851b42b2..a81ec7d2a 100644
--- a/dev/pyRevitLoader/pyRevitAssemblyBuilder/UIManager/IconsHandling/IconManager.cs
+++ b/dev/pyRevitLoader/pyRevitAssemblyBuilder/UIManager/IconsHandling/IconManager.cs
@@ -180,6 +180,14 @@ private void SetIconsOnItem(object item, BitmapSource largeImage, BitmapSource s
///
public BitmapSource LoadBitmapSource(string imagePath, int targetSize = 0)
{
+ if (string.IsNullOrEmpty(imagePath) || !File.Exists(imagePath))
+ return null;
+ if (imagePath.EndsWith(".svg", StringComparison.OrdinalIgnoreCase))
+ {
+ _logger.Debug($"Skipping SVG icon ...");
+ return null;
+ }
+
if (string.IsNullOrEmpty(imagePath) || !File.Exists(imagePath))
return null;
@@ -236,24 +244,29 @@ private ComponentIcon GetBestIconForSizeWithTheme(ParsedComponent component, int
if (!component.HasValidIcons)
return null;
- // Return the appropriate icon based on theme preference
+ // Fix for #3173: SVG files are discovered as valid icons but cannot be rendered
+ // by WPF BitmapImage. Filter to renderable (raster) formats only.
+ bool IsRenderable(ComponentIcon icon) =>
+ icon?.IsValid == true && !IsSvgIcon(icon);
+
if (isDarkTheme)
{
- // In dark theme, prefer dark icon, fall back to light
var darkIcon = component.Icons.PrimaryDarkIcon;
- if (darkIcon?.IsValid == true)
+ if (IsRenderable(darkIcon))
return darkIcon;
}
- // Use light icon (either because we're in light theme, or as fallback)
var lightIcon = component.Icons.PrimaryIcon;
- if (lightIcon?.IsValid == true)
+ if (IsRenderable(lightIcon))
return lightIcon;
- // Final fallback - use any valid icon
- return component.Icons.FirstOrDefault(i => i.IsValid);
+ // Final fallback - use any valid renderable icon
+ return component.Icons.FirstOrDefault(i => IsRenderable(i));
}
+ private static bool IsSvgIcon(ComponentIcon icon) =>
+ icon != null && string.Equals(icon.Extension, ".svg", StringComparison.OrdinalIgnoreCase);
+
///
public void PreloadExtensionIcons(ParsedExtension extension)
{
diff --git a/dev/pyRevitLoader/pyRevitAssemblyBuilder/UIManager/SessionManagerService.cs b/dev/pyRevitLoader/pyRevitAssemblyBuilder/UIManager/SessionManagerService.cs
index 55fadac26..329abbd0c 100644
--- a/dev/pyRevitLoader/pyRevitAssemblyBuilder/UIManager/SessionManagerService.cs
+++ b/dev/pyRevitLoader/pyRevitAssemblyBuilder/UIManager/SessionManagerService.cs
@@ -1,4 +1,4 @@
-#nullable enable
+#nullable enable
using System;
using System.Collections.Generic;
using System.Diagnostics;
@@ -106,7 +106,14 @@ public void LoadSession()
stepStopwatch.Restart();
InitializeScriptExecutor();
_logger.Debug($"[PERF] InitializeScriptExecutor: {stepStopwatch.ElapsedMilliseconds}ms");
-
+
+ // Seed the AppDomain environment dictionary. Must run after InitializeScriptExecutor()
+ // (which loads _runtimeAssembly) and before any extension startup script (which may call
+ // pyrevit.sessioninfo, pyrevit.telemetry, etc.).
+ stepStopwatch.Restart();
+ SeedEnvironmentDictionary();
+ _logger.Debug($"[PERF] SeedEnvironmentDictionary: {stepStopwatch.ElapsedMilliseconds}ms");
+
// Get all library extensions first - they need to be available to all UI extensions
stepStopwatch.Restart();
var libraryExtensions = _extensionManager?.GetInstalledLibraryExtensions()?.ToList() ?? new List();
@@ -122,46 +129,67 @@ public void LoadSession()
_logger.Warning("No UI extensions found or extension manager is null.");
return;
}
-
+
+ // ── PASS 1: Build and load ALL assemblies ──────────────────────────
+ // Fix for #3108: Legacy _new_session() uses separate loops to guarantee
+ // all assemblies exist in the AppDomain before any startup script runs.
+ // Cross-extension imports in startup scripts fail without this.
+ var assembledExtensions = new List<(ParsedExtension ext, ExtensionAssemblyInfo assmInfo)>();
+
foreach (var ext in uiExtensions)
{
- if (ext == null)
- {
- _logger.Warning("Skipping null extension.");
- continue;
- }
-
- var extStopwatch = Stopwatch.StartNew();
-
+ if (ext == null) { _logger.Warning("Skipping null extension."); continue; }
try
{
stepStopwatch.Restart();
- var assmInfo = _assemblyBuilder?.BuildExtensionAssembly(ext, libraryExtensions);
+ var rocketMode = _uiManager?.RocketMode ?? false;
+ var assmInfo = _assemblyBuilder?.BuildExtensionAssembly(ext, libraryExtensions, rocketMode);
var buildTime = stepStopwatch.ElapsedMilliseconds;
-
+
if (assmInfo == null)
{
_logger.Error($"Failed to build assembly for extension '{ext.Name}'.");
continue;
}
-
+
_logger.Info($"Extension assembly created: {ext.Name}");
stepStopwatch.Restart();
_assemblyBuilder?.LoadAssembly(assmInfo);
- var loadTime = stepStopwatch.ElapsedMilliseconds;
-
- _logger.Debug($"[PERF] {ext.Name} - Build: {buildTime}ms, Load: {loadTime}ms");
-
- // Execute startup script after building assembly but before creating UI
- // This matches the Python loader flow
- if (!string.IsNullOrEmpty(ext.StartupScript))
+ _logger.Debug($"[PERF] {ext.Name} - Build: {buildTime}ms, Load: {stepStopwatch.ElapsedMilliseconds}ms");
+
+ assembledExtensions.Add((ext, assmInfo));
+ }
+ catch (Exception ex)
+ {
+ _logger.Error($"Error building/loading extension '{ext?.Name ?? "unknown"}': {ex}");
+ }
+ }
+
+ // ── PASS 2: Run ALL startup scripts ────────────────────────────────
+ // All assemblies are now loaded, so cross-extension imports work.
+ foreach (var (ext, _) in assembledExtensions)
+ {
+ if (!string.IsNullOrEmpty(ext.StartupScript))
+ {
+ try
{
_logger.Info($"Running startup tasks for {ext.Name}");
stepStopwatch.Restart();
ExecuteExtensionStartupScript(ext, libraryExtensions);
_logger.Debug($"[PERF] {ext.Name} - StartupScript: {stepStopwatch.ElapsedMilliseconds}ms");
}
+ catch (Exception ex)
+ {
+ _logger.Error($"Startup script error for '{ext.Name}': {ex}");
+ }
+ }
+ }
+ // ── PASS 3: Build ALL UI ───────────────────────────────────────────
+ foreach (var (ext, assmInfo) in assembledExtensions)
+ {
+ try
+ {
stepStopwatch.Restart();
_uiManager?.BuildUI(ext, assmInfo);
_logger.Debug($"[PERF] {ext.Name} - BuildUI: {stepStopwatch.ElapsedMilliseconds}ms");
@@ -169,10 +197,10 @@ public void LoadSession()
}
catch (Exception ex)
{
- _logger.Error($"Error processing extension '{ext?.Name ?? "unknown"}': {ex.Message}");
+ _logger.Error($"UI build error for '{ext?.Name ?? "unknown"}': {ex}");
}
}
-
+
// STEP 3: Apply external layout directives (panel reordering)
// This applies directives that reference external targets (native Revit panels or panels from other extensions)
// Must be called after ALL UI is built so all panels exist
@@ -200,6 +228,26 @@ public void LoadSession()
_logger.Info($"Session loaded in {totalStopwatch.ElapsedMilliseconds}ms");
}
+ private void SeedEnvironmentDictionary()
+ {
+ try
+ {
+ if (_runtimeAssembly == null)
+ {
+ _logger.Warning("Cannot seed environment dictionary: runtime assembly not loaded.");
+ return;
+ }
+
+ EnvDictionarySeeder.Seed(_uiApp, _runtimeAssembly, _pyRevitRoot ?? string.Empty);
+ _logger.Debug("Session environment dictionary seeded successfully.");
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning($"Failed to seed environment dictionary: {ex}");
+ throw;
+ }
+ }
+
private void InitializeScriptExecutor()
{
// Cache runtime assembly lookup - it's used by every extension
diff --git a/dev/pyRevitLoader/pyRevitAssemblyBuilder/UIManager/UIManagerService.cs b/dev/pyRevitLoader/pyRevitAssemblyBuilder/UIManager/UIManagerService.cs
index ed95df0ba..93c8b18b2 100644
--- a/dev/pyRevitLoader/pyRevitAssemblyBuilder/UIManager/UIManagerService.cs
+++ b/dev/pyRevitLoader/pyRevitAssemblyBuilder/UIManager/UIManagerService.cs
@@ -27,13 +27,28 @@ public class UIManagerService : IUIManagerService
private readonly IUIRibbonScanner? _ribbonScanner;
private readonly UIApplication _uiApp;
private ParsedExtension? _currentExtension;
- private readonly bool _loadBeta;
+ ///
+ /// Cached Load Beta setting. Re-read at start of each BuildUI so reload picks up settings changes.
+ ///
+ private bool _loadBeta;
+
+ ///
+ /// Cached Rocket Mode setting. Re-read at start of each BuildUI so reload picks up settings changes.
+ /// When true, non-critical startup work (e.g. icon pre-loading) is skipped to reduce load time.
+ ///
+ private bool _rocketMode;
///
/// Gets the UIApplication instance used by this service.
///
public UIApplication UIApplication => _uiApp;
+ ///
+ /// Gets whether rocket mode is enabled.
+ /// When true, non-critical startup work is skipped and engine caching is used for compatible extensions.
+ ///
+ public bool RocketMode => _rocketMode;
+
///
/// Initializes a new instance of the class.
///
@@ -67,17 +82,20 @@ public UIManagerService(
_comboBoxBuilder = comboBoxBuilder ?? throw new ArgumentNullException(nameof(comboBoxBuilder));
_ribbonScanner = ribbonScanner;
- // Load beta settings from config
+ // Load beta and rocket mode settings from config
try
{
var config = PyRevitConfig.Load();
_loadBeta = config.LoadBeta;
+ _rocketMode = config.RocketMode;
_logger.Debug($"Beta tools loading: {_loadBeta}");
+ _logger.Debug($"Rocket mode: {_rocketMode}");
}
catch (Exception ex)
{
- _logger.Debug($"Failed to load beta config, defaulting to false: {ex.Message}");
+ _logger.Debug($"Failed to load config, defaulting to false: {ex.Message}");
_loadBeta = false;
+ _rocketMode = false;
}
}
@@ -94,6 +112,19 @@ public void BuildUI(ParsedExtension extension, ExtensionAssemblyInfo assemblyInf
return;
}
+ // Re-read Load Beta and Rocket Mode so toggling in settings is applied on next reload (#3109).
+ try
+ {
+ var config = PyRevitConfig.Load();
+ _loadBeta = config.LoadBeta;
+ _rocketMode = config.RocketMode;
+ _logger.Debug($"Re-read config - Beta tools loading: {_loadBeta}, Rocket mode: {_rocketMode}");
+ }
+ catch (Exception ex)
+ {
+ _logger.Debug($"Failed to re-read config: {ex.Message}");
+ }
+
if (assemblyInfo == null)
{
_logger.Warning($"Cannot build UI for extension '{extension.Name}': assemblyInfo is null.");
@@ -106,8 +137,11 @@ public void BuildUI(ParsedExtension extension, ExtensionAssemblyInfo assemblyInf
return;
}
- // Pre-load icon files in parallel to warm OS file cache
- _buttonPostProcessor.IconManager.PreloadExtensionIcons(extension);
+ // Pre-load icon files in parallel to warm OS file cache (skipped in Rocket Mode)
+ if (!_rocketMode)
+ _buttonPostProcessor.IconManager.PreloadExtensionIcons(extension);
+ else
+ _logger.Debug($"Rocket mode: skipping icon pre-load for extension '{extension.Name}'.");
_currentExtension = extension;
foreach (var component in extension.Children)
@@ -212,7 +246,8 @@ private void RecursivelyBuildUI(
ParsedComponent? parentComponent,
RibbonPanel? parentPanel,
string tabName,
- ExtensionAssemblyInfo assemblyInfo)
+ ExtensionAssemblyInfo assemblyInfo,
+ string? renamedTabTitle = null)
{
if (component == null)
{
@@ -246,7 +281,7 @@ private void RecursivelyBuildUI(
break;
case CommandComponentType.Panel:
- HandlePanel(component, tabName, assemblyInfo);
+ HandlePanel(component, tabName, assemblyInfo, renamedTabTitle);
break;
default:
@@ -266,8 +301,10 @@ private void RecursivelyBuildUI(
private void HandleTab(ParsedComponent component, ExtensionAssemblyInfo assemblyInfo)
{
- // Use TabBuilder to create the tab
- _tabBuilder.CreateTab(component);
+ // CreateTab handles find → tag → re-enable in a single ribbon scan.
+ // Returns the tab's current Title if it was renamed (e.g. by a translation
+ // script), or null if no rename detected.
+ var renamedTabTitle = _tabBuilder.CreateTab(component);
// Get tab name for children using localized title
var tabText = ExtensionParser.GetComponentTitle(component);
@@ -275,12 +312,21 @@ private void HandleTab(ParsedComponent component, ExtensionAssemblyInfo assembly
// Mark tab as touched in the registry (matching Python's set_dirty_flag behavior)
_ribbonScanner?.MarkElementTouched("tab", tabText);
- // Recursively build children
+ // If CreateTab detected a rename, also mark the current (renamed) Title
+ // so CleanupOrphanedElements() doesn't deactivate the tab (#3167).
+ if (!string.IsNullOrEmpty(renamedTabTitle))
+ {
+ _ribbonScanner?.MarkElementTouched("tab", renamedTabTitle);
+ _logger.Debug($"Tab '{tabText}' has current Title '{renamedTabTitle}' — marked both as touched.");
+ }
+
+ // Recursively build children, passing the renamed title so panels can dual-mark too
foreach (var child in component.Children ?? Enumerable.Empty())
- RecursivelyBuildUI(child, component, null, tabText, assemblyInfo);
+ RecursivelyBuildUI(child, component, null, tabText, assemblyInfo, renamedTabTitle);
}
- private void HandlePanel(ParsedComponent component, string tabName, ExtensionAssemblyInfo assemblyInfo)
+ private void HandlePanel(ParsedComponent component, string tabName,
+ ExtensionAssemblyInfo assemblyInfo, string? renamedTabTitle = null)
{
// Use PanelBuilder to create the panel
var panel = _panelBuilder.CreatePanel(component, tabName);
@@ -291,12 +337,21 @@ private void HandlePanel(ParsedComponent component, string tabName, ExtensionAss
// Mark panel as touched in the registry (matching Python's set_dirty_flag behavior)
_ribbonScanner?.MarkElementTouched("panel", panelText, tabName);
+ // If the parent tab was renamed (e.g. by a translation script), the scanner
+ // registered this panel under "panel:{renamedTab}:{panelText}". Mark that
+ // key as touched too so cleanup doesn't hide the panel.
+ if (!string.IsNullOrEmpty(renamedTabTitle))
+ {
+ _ribbonScanner?.MarkElementTouched("panel", panelText, renamedTabTitle);
+ }
+
// Apply background colors if specified
_panelBuilder.ApplyPanelBackgroundColors(panel, component, tabName);
- // Recursively build children
+ // Recursively build children — propagate renamedTabTitle so any nested
+ // components that depend on the tab name for registry keys stay consistent.
foreach (var child in component.Children ?? Enumerable.Empty())
- RecursivelyBuildUI(child, component, panel, tabName, assemblyInfo);
+ RecursivelyBuildUI(child, component, panel, tabName, assemblyInfo, renamedTabTitle);
}
private void EnsureSlideOutApplied(ParsedComponent? parentComponent, RibbonPanel? parentPanel)
diff --git a/dev/pyRevitLoader/pyRevitAssemblyBuilder/pyRevitAssemblyBuilder.csproj b/dev/pyRevitLoader/pyRevitAssemblyBuilder/pyRevitAssemblyBuilder.csproj
index d329e7421..abb4dc525 100644
--- a/dev/pyRevitLoader/pyRevitAssemblyBuilder/pyRevitAssemblyBuilder.csproj
+++ b/dev/pyRevitLoader/pyRevitAssemblyBuilder/pyRevitAssemblyBuilder.csproj
@@ -17,6 +17,10 @@
true
+
+
+
+
diff --git a/dev/pyRevitLoader/pyRevitExtensionParser/ExtensionParser.cs b/dev/pyRevitLoader/pyRevitExtensionParser/ExtensionParser.cs
index 087e39d0c..b1a361eec 100644
--- a/dev/pyRevitLoader/pyRevitExtensionParser/ExtensionParser.cs
+++ b/dev/pyRevitLoader/pyRevitExtensionParser/ExtensionParser.cs
@@ -79,6 +79,59 @@ private static void LogParseException(string parsedFile, Exception ex)
LogError(msg);
}
+ ///
+ /// Returns true if the given revitYear falls within the declared min/max version range.
+ /// A null or empty constraint is treated as no restriction (open-ended).
+ /// A non-empty value that cannot be parsed to an integer is treated as a hard fail.
+ ///
+ /// The minimum Revit version.
+ /// The maximum Revit version.
+ /// The Revit year of the running Revit instance.
+ /// The component or extension name, used in log messages.
+ private static bool IsRevitVersionCompatible(string minRevitVersion, string maxRevitVersion, int revitYear, string name)
+ {
+ // Early exit when Revit version is unknown — skip version filtering entirely
+ if (revitYear <= 0)
+ {
+ LogWarning("Skipping min / max version test, since Revit version is unknown");
+ return true;
+ }
+
+ bool compatible = true;
+
+ // Parse and validate min_revit_version
+ if (!string.IsNullOrEmpty(minRevitVersion))
+ {
+ if (!int.TryParse(minRevitVersion, out var min))
+ {
+ LogWarning($"'{name}': min_revit_version value '{minRevitVersion}' is not a valid integer - skipping.");
+ compatible = false;
+ }
+ else if (revitYear < min)
+ {
+ LogInfo($"'{name}': skipped - requires Revit {min} or later (running {revitYear}).");
+ compatible = false;
+ }
+ }
+
+ // Parse and validate max_revit_version
+ if (!string.IsNullOrEmpty(maxRevitVersion))
+ {
+ if (!int.TryParse(maxRevitVersion, out var max))
+ {
+ LogWarning($"'{name}': max_revit_version value '{maxRevitVersion}' is not a valid integer - skipping.");
+ compatible = false;
+ }
+ else if (revitYear > max)
+ {
+ LogInfo($"'{name}': skipped - requires Revit {max} or earlier (running {revitYear}).");
+ compatible = false;
+ }
+ }
+
+ return compatible;
+ }
+
private static int GetExceptionInt(Exception ex, params string[] keys)
{
if (ex?.Data == null)
@@ -106,21 +159,21 @@ private static string GetExceptionString(Exception ex, params string[] keys)
return string.Empty;
}
-
+
// Cache file existence checks to avoid repeated file system calls
private static Dictionary _fileExistsCache = new Dictionary();
-
+
// Cache directory file listings to avoid repeated Directory.GetFiles calls
private static Dictionary _directoryFilesCache = new Dictionary();
-
+
// Cache icon parsing results per component directory
private static Dictionary _iconCache = new Dictionary();
-
+
private static bool FileExists(string path)
{
if (string.IsNullOrEmpty(path))
return false;
-
+
if (!_fileExistsCache.TryGetValue(path, out bool exists))
{
exists = File.Exists(path);
@@ -128,7 +181,7 @@ private static bool FileExists(string path)
}
return exists;
}
-
+
private static string[] GetFilesInDirectory(string directory, string searchPattern = "*", SearchOption searchOption = SearchOption.TopDirectoryOnly)
{
if (string.IsNullOrEmpty(directory) || !Directory.Exists(directory))
@@ -153,17 +206,17 @@ private static string[] GetFilesInDirectory(string directory, string searchPatte
// Cache extension roots to avoid repeated directory traversal and config reading
private static List _cachedExtensionRoots;
-
+
///
/// Flag to track if locale has been initialized from config
///
private static bool _localeInitialized = false;
-
+
///
/// Cached locale value for cache invalidation when locale changes
///
private static string _cachedLocale = null;
-
+
///
/// Clears all static caches to force re-parsing of extensions.
/// This should be called before reloading pyRevit to ensure newly installed
@@ -178,11 +231,11 @@ public static void ClearAllCaches()
_cachedConfig = null;
_pythonScriptCache.Clear();
_localeInitialized = false;
-
+
// Also clear the BundleParser cache
BundleParser.BundleYamlParser.ClearCache();
}
-
+
///
/// Initializes the DefaultLocale from user configuration if not already set.
/// Should be called before parsing extensions to ensure locale-aware localization.
@@ -192,7 +245,7 @@ private static void InitializeLocaleFromConfig()
{
var config = GetConfig();
var userLocale = config.UserLocale;
-
+
// Check if locale has changed since last initialization
// If locale changed, we need to invalidate all caches to force re-parsing
if (_localeInitialized && userLocale != _cachedLocale)
@@ -200,7 +253,7 @@ private static void InitializeLocaleFromConfig()
logger.Debug("Locale changed from '{0}' to '{1}'. Clearing caches...", _cachedLocale, userLocale);
ClearAllCaches();
}
-
+
if (!string.IsNullOrEmpty(userLocale))
{
DefaultLocale = userLocale;
@@ -208,7 +261,7 @@ private static void InitializeLocaleFromConfig()
_cachedLocale = userLocale;
_localeInitialized = true;
}
-
+
private static List GetCachedExtensionRoots()
{
if (_cachedExtensionRoots == null)
@@ -222,7 +275,7 @@ private static List GetCachedExtensionRoots()
return _cachedExtensionRoots;
}
- public static IEnumerable ParseInstalledExtensions()
+ public static IEnumerable ParseInstalledExtensions(int revitYear = 0)
{
var extensionRoots = GetCachedExtensionRoots();
@@ -253,7 +306,9 @@ public static IEnumerable ParseInstalledExtensions()
var fullPath = Path.GetFullPath(extDir);
if (discoveredExtensions.Add(fullPath))
{
- yield return ParseExtension(extDir);
+ var parsed = ParseExtension(extDir, revitYear);
+ if (parsed != null)
+ yield return parsed;
}
}
@@ -273,7 +328,9 @@ public static IEnumerable ParseInstalledExtensions()
var fullPath = Path.GetFullPath(libDir);
if (discoveredExtensions.Add(fullPath))
{
- yield return ParseExtension(libDir);
+ var parsed = ParseExtension(libDir, revitYear);
+ if (parsed != null)
+ yield return parsed;
}
}
}
@@ -283,8 +340,9 @@ public static IEnumerable ParseInstalledExtensions()
/// Parses a specific extension from the given extension path
///
/// The full path to the .extension or .lib directory
+ /// The running Revit version year (e.g. 2024). Pass 0 to skip version filtering.
/// A single ParsedExtension if the path is valid and contains an extension, otherwise empty
- public static IEnumerable ParseInstalledExtensions(string extensionPath)
+ public static IEnumerable ParseInstalledExtensions(string extensionPath, int revitYear = 0)
{
if (string.IsNullOrWhiteSpace(extensionPath) || !Directory.Exists(extensionPath))
yield break;
@@ -294,15 +352,18 @@ public static IEnumerable ParseInstalledExtensions(string exten
!extensionPath.EndsWith(".lib", StringComparison.OrdinalIgnoreCase))
yield break;
- yield return ParseExtension(extensionPath);
+ var parsed = ParseExtension(extensionPath, revitYear);
+ if (parsed != null)
+ yield return parsed;
}
///
/// Parses specific extensions from the given extension paths
///
/// The full paths to the .extension or .lib directories
+ /// The running Revit version year (e.g. 2024). Pass 0 to skip version filtering.
/// ParsedExtensions for valid paths that contain extensions
- public static IEnumerable ParseInstalledExtensions(IEnumerable extensionPaths)
+ public static IEnumerable ParseInstalledExtensions(IEnumerable extensionPaths, int revitYear = 0)
{
if (extensionPaths == null)
yield break;
@@ -317,7 +378,9 @@ public static IEnumerable ParseInstalledExtensions(IEnumerable<
!extensionPath.EndsWith(".lib", StringComparison.OrdinalIgnoreCase))
continue;
- yield return ParseExtension(extensionPath);
+ var parsed = ParseExtension(extensionPath, revitYear);
+ if (parsed != null)
+ yield return parsed;
}
}
@@ -334,11 +397,12 @@ private static PyRevitConfig GetConfig()
/// Parses a single extension from the given extension directory path
///
/// The path to the .extension directory
- /// A ParsedExtension object
- private static ParsedExtension ParseExtension(string extDir)
+ /// The running Revit version year (e.g. 2024). Pass 0 to skip version filtering.
+ /// A ParsedExtension object, or null if the extension is incompatible with the given Revit version
+ private static ParsedExtension ParseExtension(string extDir, int revitYear = 0)
{
var extName = Path.GetFileNameWithoutExtension(extDir);
-
+
var bundlePath = Path.Combine(extDir, "bundle.yaml");
ParsedBundle parsedBundle = null;
if (FileExists(bundlePath))
@@ -353,19 +417,24 @@ private static ParsedExtension ParseExtension(string extDir)
}
}
+ // Extension-level version gate: skip the entire extension (and its directory tree)
+ // if it declares a version range that doesn't include the running Revit year.
+ if (!IsRevitVersionCompatible(parsedBundle?.MinRevitVersion, parsedBundle?.MaxRevitVersion, revitYear, extName))
+ return null;
+
// Pass extension-level templates to child components
// Include author as a template if it exists
- var extensionTemplates = parsedBundle?.Templates != null
+ var extensionTemplates = parsedBundle?.Templates != null
? new Dictionary(parsedBundle.Templates)
: new Dictionary();
-
+
// If extension has an author, add it as a template for children to inherit
if (!string.IsNullOrEmpty(parsedBundle?.Author))
{
extensionTemplates["author"] = parsedBundle.Author;
}
-
- // Read extension.json for additional templates
+ // Read extension.json for additional templates and rocket_mode_compatible
+ bool rocketModeCompatible = false;
var extensionJsonPath = Path.Combine(extDir, "extension.json");
if (FileExists(extensionJsonPath))
{
@@ -394,6 +463,13 @@ private static ParsedExtension ParseExtension(string extDir)
extensionTemplates["author"] = author;
}
}
+
+ // Read rocket_mode_compatible setting
+ var rocketModeValue = json["rocket_mode_compatible"]?.ToString();
+ if (!string.IsNullOrEmpty(rocketModeValue))
+ {
+ rocketModeCompatible = rocketModeValue.Equals("true", StringComparison.OrdinalIgnoreCase);
+ }
}
catch (Exception ex)
{
@@ -401,7 +477,16 @@ private static ParsedExtension ParseExtension(string extDir)
}
}
- var children = ParseComponents(extDir, extName, null, extensionTemplates.Count > 0 ? extensionTemplates : null);
+ // pyRevitCore is always rocket mode compatible (hardcoded, matches Python behavior)
+ if (string.Equals(extName, "pyRevitCore", StringComparison.OrdinalIgnoreCase))
+ {
+ rocketModeCompatible = true;
+ }
+
+ // FIXED — pass revitYear through:
+ var children = ParseComponents(extDir, extName, null,
+ extensionTemplates.Count > 0 ? extensionTemplates : null,
+ revitYear);
// Read extension config from pyRevit config file (cached).
// Config is keyed by folder name (e.g. [extension_test.extension]) so it matches install and Python.
@@ -422,7 +507,8 @@ private static ParsedExtension ParseExtension(string extDir)
MaxRevitVersion = parsedBundle?.MaxRevitVersion,
Context = parsedBundle?.GetFormattedContext(),
Engine = parsedBundle?.Engine,
- Config = extConfig
+ Config = extConfig,
+ RocketModeCompatible = rocketModeCompatible
};
ReorderByLayout(parsedExtension, parsedExtension, null);
@@ -534,7 +620,7 @@ private static void ReorderByLayout(ParsedComponent component, ParsedExtension e
}
}
}
-
+
///
/// Applies layout directives (before, after, beforeall, afterall) to reorder components.
/// Directives that reference external components (not found in children) are stored
@@ -674,7 +760,7 @@ private static List GetExtensionRoots()
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"pyRevit",
"Extensions");
-
+
if (Directory.Exists(thirdPartyExtensionsPath))
{
roots.Add(thirdPartyExtensionsPath);
@@ -694,7 +780,8 @@ private static List GetExtensionRoots()
try
{
- var normalizedPath = Path.GetFullPath(extPath);
+ var expandedPath = Environment.ExpandEnvironmentVariables(extPath);
+ var normalizedPath = Path.GetFullPath(expandedPath);
if (Directory.Exists(normalizedPath))
{
roots.Add(normalizedPath);
@@ -758,7 +845,7 @@ private static string SubstituteTemplates(string input, Dictionary
private static Dictionary SubstituteTemplatesInDict(
- Dictionary localizedValues,
+ Dictionary localizedValues,
Dictionary templates)
{
if (localizedValues == null || templates == null || templates.Count == 0)
@@ -776,7 +863,8 @@ private static List ParseComponents(
string baseDir,
string extensionName,
string parentPath = null,
- Dictionary inheritedTemplates = null)
+ Dictionary inheritedTemplates = null,
+ int revitYear = 0)
{
var components = new List();
@@ -817,19 +905,23 @@ private static List ParseComponents(
{
// Look for script files in order of preference: .py, .cs, .vb, .rb, .dyn, .gh, .ghx, .rfa
// Use cached file listing instead of EnumerateFiles
- var dirFiles = GetFilesInDirectory(dir, "script.*", SearchOption.TopDirectoryOnly);
+ var dirFiles = GetFilesInDirectory(dir, "*script.*", SearchOption.TopDirectoryOnly);
+ var validEndings = new[] { "script", "_script", "-script", ".script" };
+ dirFiles = dirFiles.Where(f =>
+ validEndings.Any(end => Path.GetFileNameWithoutExtension(f).EndsWith(end, StringComparison.OrdinalIgnoreCase))
+ ).ToArray();
// Check for scripts in priority order
var scriptExtensions = new[] { ".py", ".cs", ".vb", ".rb", ".dyn", ".gh", ".ghx", ".rfa" };
foreach (var scriptExt in scriptExtensions)
{
var scriptFile = $"script{scriptExt}";
- scriptPath = dirFiles.FirstOrDefault(f =>
+ scriptPath = dirFiles.FirstOrDefault(f =>
f.EndsWith(scriptFile, StringComparison.OrdinalIgnoreCase));
if (scriptPath != null)
break;
}
-
+
// If no script.* file found, look for any file with the target extensions
// This handles cases like BIM1_ArrowHeadSwitcher_script.dyn
if (scriptPath == null)
@@ -838,9 +930,9 @@ private static List ParseComponents(
foreach (var scriptExt in scriptExtensions)
{
// Look for any file ending with _script{ext} or just {ext}
- scriptPath = allFiles.FirstOrDefault(f =>
+ scriptPath = allFiles.FirstOrDefault(f =>
(f.EndsWith($"_script{scriptExt}", StringComparison.OrdinalIgnoreCase) ||
- (f.EndsWith(scriptExt, StringComparison.OrdinalIgnoreCase) &&
+ (f.EndsWith(scriptExt, StringComparison.OrdinalIgnoreCase) &&
!f.EndsWith($"_config{scriptExt}", StringComparison.OrdinalIgnoreCase))));
if (scriptPath != null)
break;
@@ -908,13 +1000,13 @@ private static List ParseComponents(
LogParseException(bundleYaml, ex);
}
}
-
+
// Try to get content from bundle.yaml metadata first
if (tempBundle != null && !string.IsNullOrEmpty(tempBundle.Content))
{
scriptPath = ResolveContentPath(dir, tempBundle.Content);
}
-
+
// If no content in metadata, use naming convention
if (scriptPath == null)
{
@@ -945,7 +1037,7 @@ private static List ParseComponents(
}
}
}
-
+
// Handle alternative content (CTRL+Click)
if (tempBundle != null && !string.IsNullOrEmpty(tempBundle.ContentAlt))
{
@@ -976,10 +1068,10 @@ private static List ParseComponents(
}
}
}
-
+
// Look for on/off icons for smartbuttons and toggle buttons
string onIconPath = null, onIconDarkPath = null, offIconPath = null, offIconDarkPath = null;
- if (componentType == CommandComponentType.SmartButton ||
+ if (componentType == CommandComponentType.SmartButton ||
componentType == CommandComponentType.PushButton)
{
// Parse on/off icons with theme support
@@ -990,7 +1082,7 @@ private static List ParseComponents(
var mediaFile = FindMediaFile(dir);
var bundleFile = Path.Combine(dir, "bundle.yaml");
-
+
// Then parse bundle and override with bundle values if they exist
ParsedBundle bundleInComponent = null;
if (FileExists(bundleFile))
@@ -1033,7 +1125,7 @@ private static List ParseComponents(
}
// Pass merged templates to child components
- var children = ParseComponents(dir, extensionName, fullPath, mergedTemplates);
+ var children = ParseComponents(dir, extensionName, fullPath, mergedTemplates, revitYear);
// First, get values from Python script
string title = null, author = null, doc = null;
@@ -1043,7 +1135,7 @@ private static List ParseComponents(
Dictionary scriptLocalizedTitles = null;
Dictionary scriptLocalizedTooltips = null;
Dictionary scriptLocalizedHelpUrls = null;
-
+
if (scriptPath != null && scriptPath.EndsWith(".py", StringComparison.OrdinalIgnoreCase))
{
var scriptConstants = ReadPythonScriptConstants(scriptPath);
@@ -1083,13 +1175,13 @@ private static List ParseComponents(
{
bundleTooltip = bundleTooltipEnUs;
}
-
+
if (!string.IsNullOrEmpty(bundleTitle))
title = bundleTitle;
-
+
if (!string.IsNullOrEmpty(bundleTooltip))
doc = bundleTooltip;
-
+
if (!string.IsNullOrEmpty(bundleInComponent.Author))
author = bundleInComponent.Author;
}
@@ -1098,7 +1190,7 @@ private static List ParseComponents(
var finalLocalizedTitles = scriptLocalizedTitles ?? new Dictionary();
var finalLocalizedTooltips = scriptLocalizedTooltips ?? new Dictionary();
var finalLocalizedHelpUrls = scriptLocalizedHelpUrls ?? new Dictionary();
-
+
// If bundle has localized values, they override script values
if (bundleInComponent?.Titles != null)
{
@@ -1107,7 +1199,7 @@ private static List ParseComponents(
finalLocalizedTitles[kvp.Key] = kvp.Value;
}
}
-
+
if (bundleInComponent?.Tooltips != null)
{
foreach (var kvp in bundleInComponent.Tooltips)
@@ -1115,7 +1207,7 @@ private static List ParseComponents(
finalLocalizedTooltips[kvp.Key] = kvp.Value;
}
}
-
+
if (bundleInComponent?.HelpUrls != null)
{
foreach (var kvp in bundleInComponent.HelpUrls)
@@ -1130,7 +1222,7 @@ private static List ParseComponents(
author = SubstituteTemplates(author, mergedTemplates);
var hyperlink = SubstituteTemplates(bundleInComponent?.Hyperlink, mergedTemplates);
scriptHelpUrl = SubstituteTemplates(scriptHelpUrl, mergedTemplates);
-
+
// Apply template substitution to localized values
finalLocalizedTitles = SubstituteTemplatesInDict(finalLocalizedTitles, mergedTemplates);
finalLocalizedTooltips = SubstituteTemplatesInDict(finalLocalizedTooltips, mergedTemplates);
@@ -1141,8 +1233,8 @@ private static List ParseComponents(
// so we need to check if there's actually a context defined in the bundle
string finalContext;
var bundleContext = bundleInComponent?.GetFormattedContext();
- if (bundleInComponent != null &&
- (bundleInComponent.ContextItems?.Count > 0 ||
+ if (bundleInComponent != null &&
+ (bundleInComponent.ContextItems?.Count > 0 ||
bundleInComponent.ContextRules?.Count > 0 ||
!string.IsNullOrEmpty(bundleInComponent.Context)))
{
@@ -1161,8 +1253,8 @@ private static List ParseComponents(
}
// Determine final highlight: bundle takes precedence over script
- string finalHighlight = !string.IsNullOrEmpty(bundleInComponent?.Highlight)
- ? bundleInComponent.Highlight
+ string finalHighlight = !string.IsNullOrEmpty(bundleInComponent?.Highlight)
+ ? bundleInComponent.Highlight
: scriptHighlight;
// Determine final help URL: bundle helpurl takes precedence over script helpurl
@@ -1184,8 +1276,8 @@ private static List ParseComponents(
: scriptMaxRevitVersion;
// Determine final beta status: bundle takes precedence over script
- bool finalIsBeta = bundleInComponent != null && bundleInComponent.IsBeta
- ? bundleInComponent.IsBeta
+ bool finalIsBeta = bundleInComponent != null && bundleInComponent.IsBeta
+ ? bundleInComponent.IsBeta
: scriptIsBeta;
// Determine final engine config: bundle takes precedence, but script can add flags
@@ -1194,6 +1286,11 @@ private static List ParseComponents(
if (scriptFullFrameEngine) finalEngine.FullFrame = true;
if (scriptPersistentEngine) finalEngine.Persistent = true;
+ // Component-level version gate: skip this component (and its children) if it declares
+ // a version range that doesn't include the running Revit year.
+ if (!IsRevitVersionCompatible(finalMinRevitVersion, finalMaxRevitVersion, revitYear, displayName))
+ continue;
+
components.Add(new ParsedComponent
{
Name = namePart,
@@ -1264,7 +1361,7 @@ public static string GetComponentTitle(ParsedComponent component)
{
if (component == null)
return string.Empty;
-
+
// First try localized titles
if (component.LocalizedTitles != null && component.LocalizedTitles.Count > 0)
{
@@ -1272,7 +1369,7 @@ public static string GetComponentTitle(ParsedComponent component)
if (!string.IsNullOrEmpty(localizedTitle))
return localizedTitle;
}
-
+
// Fall back to pre-resolved Title or DisplayName
return !string.IsNullOrEmpty(component.Title) ? component.Title : component.DisplayName;
}
@@ -1286,7 +1383,7 @@ public static string GetComponentTooltip(ParsedComponent component)
{
if (component == null)
return string.Empty;
-
+
// First try localized tooltips
if (component.LocalizedTooltips != null && component.LocalizedTooltips.Count > 0)
{
@@ -1294,7 +1391,7 @@ public static string GetComponentTooltip(ParsedComponent component)
if (!string.IsNullOrEmpty(localizedTooltip))
return localizedTooltip;
}
-
+
// Fall back to pre-resolved Tooltip
return component.Tooltip ?? string.Empty;
}
@@ -1335,7 +1432,7 @@ private static string ResolveContentPath(string bundleDir, string contentPath)
// Check if it's an absolute path
if (Path.IsPathRooted(contentPath))
{
- if (FileExists(contentPath) &&
+ if (FileExists(contentPath) &&
contentPath.EndsWith(".rfa", StringComparison.OrdinalIgnoreCase))
{
return contentPath;
@@ -1346,7 +1443,7 @@ private static string ResolveContentPath(string bundleDir, string contentPath)
// Treat as relative to bundle directory
// Normalize the path to handle .. and . properly
var resolvedPath = Path.GetFullPath(Path.Combine(bundleDir, contentPath));
- if (FileExists(resolvedPath) &&
+ if (FileExists(resolvedPath) &&
resolvedPath.EndsWith(".rfa", StringComparison.OrdinalIgnoreCase))
{
return resolvedPath;
@@ -1355,11 +1452,62 @@ private static string ResolveContentPath(string bundleDir, string contentPath)
return null;
}
+ ///
+ /// Sanitizes a string for use as a unique command identifier / C# class name.
+ /// Replicates the legacy Python coreutils.cleanup_string() behavior with
+ /// the SPECIAL_CHARS replacement table and skip=['_'] (the separator).
+ /// See: pyrevitlib/pyrevit/coreutils/__init__.py lines 295-344
+ /// Fix for #3164: Unique ID generation must match legacy Python loader.
+ ///
private static string SanitizeClassName(string name)
{
- var sb = new StringBuilder();
- foreach (char c in name)
- sb.Append(char.IsLetterOrDigit(c) ? c : '_');
+ var result = name
+ .Replace(" ", "")
+ .Replace("~", "")
+ .Replace("!", "EXCLAM")
+ .Replace("@", "AT")
+ .Replace("#", "SHARP")
+ .Replace("$", "DOLLAR")
+ .Replace("%", "PERCENT")
+ .Replace("^", "")
+ .Replace("&", "AND")
+ .Replace("*", "STAR")
+ .Replace("+", "PLUS")
+ .Replace(";", "")
+ .Replace(":", "")
+ .Replace(",", "")
+ .Replace("\"", "")
+ .Replace("{", "")
+ .Replace("}", "")
+ .Replace("[", "")
+ .Replace("]", "")
+ .Replace("\\(", "")
+ .Replace("\\)", "")
+ .Replace("(", "")
+ .Replace(")", "")
+ .Replace("-", "MINUS")
+ .Replace("=", "EQUALS")
+ .Replace("<", "")
+ .Replace(">", "")
+ .Replace("?", "QMARK")
+ .Replace(".", "DOT")
+ // '_' is intentionally NOT replaced — it is the separator (skip=['_'])
+ .Replace("|", "VERT")
+ .Replace("\\/", "")
+ .Replace("\\", "");
+
+ // Final safety pass: strip any character not valid in a C# identifier
+ var sb = new StringBuilder(result.Length);
+ foreach (char c in result)
+ if (char.IsLetterOrDigit(c) || c == '_')
+ sb.Append(c);
+
+ // Fix for #3107: Ensure UniqueId is a valid C# identifier.
+ // Leading digits are invalid in C# class names generated by Roslyn.
+ // The legacy loader used Reflection.Emit which accepted leading digits.
+ if (sb.Length > 0 && char.IsDigit(sb[0]))
+ sb.Insert(0, '_');
+
return sb.ToString();
}
@@ -1387,7 +1535,7 @@ private struct PythonScriptConstants
}
// Cache Python script constant parsing to avoid re-reading files
- private static Dictionary _pythonScriptCache =
+ private static Dictionary _pythonScriptCache =
new Dictionary();
private static PythonScriptConstants ReadPythonScriptConstants(string scriptPath)
@@ -1395,7 +1543,7 @@ private static PythonScriptConstants ReadPythonScriptConstants(string scriptPath
// Check cache first
if (_pythonScriptCache.TryGetValue(scriptPath, out var cached))
return cached;
-
+
var result = new PythonScriptConstants();
try
@@ -1403,11 +1551,11 @@ private static PythonScriptConstants ReadPythonScriptConstants(string scriptPath
// Read all lines to handle multiline strings properly
var allLines = File.ReadAllLines(scriptPath);
var lineIndex = 0;
-
+
foreach (var line in allLines)
{
var trimmedLine = line.TrimStart();
-
+
if (trimmedLine.StartsWith("__title__"))
{
// Check if it's a dictionary
@@ -1531,7 +1679,7 @@ private static PythonScriptConstants ReadPythonScriptConstants(string scriptPath
{
result.PersistentEngine = ExtractPythonBoolValue(trimmedLine);
}
-
+
lineIndex++;
}
}
@@ -1559,16 +1707,16 @@ private static List ExtractPythonList(string line)
var items = new List();
// Remove outer brackets
value = value.Substring(1, value.Length - 2);
-
+
// Split by comma, handling quoted strings
var currentItem = "";
var inQuote = false;
var quoteChar = '\0';
-
+
for (int i = 0; i < value.Length; i++)
{
var ch = value[i];
-
+
if (!inQuote && (ch == '"' || ch == '\''))
{
inQuote = true;
@@ -1591,12 +1739,12 @@ private static List ExtractPythonList(string line)
currentItem += ch;
}
}
-
+
// Add last item
var lastTrimmed = currentItem.Trim().Trim('\'', '"');
if (!string.IsNullOrWhiteSpace(lastTrimmed))
items.Add(lastTrimmed);
-
+
return items.Count > 0 ? items : null;
}
}
@@ -1626,10 +1774,10 @@ private static string ExtractPythonMultilineString(string firstLine, IEnumerable
int firstQuotePos = firstLineTrimmed.IndexOf("\"\"\"");
if (firstQuotePos == -1)
return null;
-
+
int contentStart = firstQuotePos + 3;
string partialContent = firstLineTrimmed.Substring(contentStart);
-
+
// Check if the closing quote is on the same line
int closingQuotePos = partialContent.IndexOf("\"\"\"");
if (closingQuotePos != -1)
@@ -1637,17 +1785,17 @@ private static string ExtractPythonMultilineString(string firstLine, IEnumerable
// Single-line multiline string
return partialContent.Substring(0, closingQuotePos);
}
-
+
// Need to read more lines to find the closing triple quote
var content = new StringBuilder();
content.Append(partialContent);
content.Append("\n");
-
+
foreach (var line in remainingLines)
{
content.Append(line);
content.Append("\n");
-
+
// Check if this line contains the closing triple quote
if (line.Contains("\"\"\""))
{
@@ -1664,7 +1812,7 @@ private static string ExtractPythonMultilineString(string firstLine, IEnumerable
break;
}
}
-
+
// Process escape sequences in the collected content
return ProcessPythonEscapeSequences(content.ToString());
}
@@ -1691,18 +1839,18 @@ private static string ExtractPythonValue(string line)
if (parts.Length == 2)
{
var value = parts[1].Trim();
-
+
// Try to extract quoted string first
var quotedValue = ExtractPythonStringContent(value);
if (quotedValue != null)
return ProcessPythonEscapeSequences(quotedValue);
-
+
// If no quotes, return the value as-is (for unquoted numbers, etc.)
// Remove any trailing comments
var commentIndex = value.IndexOf('#');
if (commentIndex >= 0)
value = value.Substring(0, commentIndex).Trim();
-
+
return string.IsNullOrEmpty(value) ? null : value;
}
return null;
@@ -1735,11 +1883,11 @@ private static string ExtractPythonStringContent(string value)
return null;
var trimmedValue = value.TrimStart();
-
+
// Find the first quote (either single or double)
int startIndex = -1;
char quoteChar = '\0';
-
+
for (int i = 0; i < trimmedValue.Length; i++)
{
if (trimmedValue[i] == '"' || trimmedValue[i] == '\'')
@@ -1763,13 +1911,13 @@ private static string ExtractPythonStringContent(string value)
endIndex += 2;
continue;
}
-
+
if (trimmedValue[endIndex] == quoteChar)
{
// Found the closing quote
return trimmedValue.Substring(startIndex + 1, endIndex - startIndex - 1);
}
-
+
endIndex++;
}
@@ -1841,17 +1989,17 @@ private static Dictionary ExtractPythonDictionary(string line)
var dict = new Dictionary();
// Remove outer braces
value = value.Substring(1, value.Length - 2);
-
+
// Split by comma, but handle commas within quoted strings
var items = new List();
var currentItem = "";
var inQuote = false;
var quoteChar = '\0';
-
+
for (int i = 0; i < value.Length; i++)
{
var ch = value[i];
-
+
if (!inQuote && (ch == '"' || ch == '\''))
{
inQuote = true;
@@ -1875,10 +2023,10 @@ private static Dictionary ExtractPythonDictionary(string line)
currentItem += ch;
}
}
-
+
if (!string.IsNullOrWhiteSpace(currentItem))
items.Add(currentItem.Trim());
-
+
// Parse each key-value pair
foreach (var item in items)
{
@@ -1891,7 +2039,7 @@ private static Dictionary ExtractPythonDictionary(string line)
dict[key] = val;
}
}
-
+
return dict.Count > 0 ? dict : null;
}
}
@@ -1908,7 +2056,7 @@ private static ComponentIconCollection ParseIconsForComponent(string componentDi
// Check cache first
if (_iconCache.TryGetValue(componentDirectory, out var cached))
return cached;
-
+
var icons = new ComponentIconCollection();
if (!Directory.Exists(componentDirectory))
@@ -1949,7 +2097,7 @@ private static ComponentIconCollection ParseIconsForComponent(string componentDi
// Cache the result
_iconCache[componentDirectory] = icons;
-
+
return icons;
}
@@ -2051,11 +2199,11 @@ private static (string onIconPath, string onIconDarkPath, string offIconPath, st
try
{
var files = GetFilesInDirectory(componentDirectory, "*", SearchOption.TopDirectoryOnly);
-
+
foreach (var file in files)
{
var fileName = Path.GetFileName(file).ToLowerInvariant();
-
+
// Check for on icons
if (fileName == "on.png" || fileName == "on.ico")
onIconPath = file;
@@ -2090,12 +2238,12 @@ private static string FindMediaFile(string componentDirectory)
try
{
var files = GetFilesInDirectory(componentDirectory, "*", SearchOption.TopDirectoryOnly);
-
+
foreach (var file in files)
{
var fileName = Path.GetFileName(file).ToLowerInvariant();
var fileNameWithoutExt = Path.GetFileNameWithoutExtension(fileName);
-
+
// Match by name 'tooltip' (like Python's finder='name' mode)
// Supports: tooltip.mp4, tooltip.swf, tooltip.png
if (fileNameWithoutExt == "tooltip")
diff --git a/dev/pyRevitLoader/pyRevitExtensionParser/ParsedExtension.cs b/dev/pyRevitLoader/pyRevitExtensionParser/ParsedExtension.cs
index 193d78d27..1f5fa195e 100644
--- a/dev/pyRevitLoader/pyRevitExtensionParser/ParsedExtension.cs
+++ b/dev/pyRevitLoader/pyRevitExtensionParser/ParsedExtension.cs
@@ -17,6 +17,13 @@ public class ParsedExtension : ParsedComponent
public new string MaxRevitVersion { get; set; }
public ExtensionConfig Config { get; set; }
+ ///
+ /// Gets or sets whether this extension is compatible with rocket mode.
+ /// When rocket mode is enabled and the extension is compatible, the engine
+ /// will be reused between command executions for better performance.
+ ///
+ public bool RocketModeCompatible { get; set; } = false;
+
///
/// Layout directives that reference external components (from other extensions or native Revit).
/// These must be applied after the full UI is built using the Revit ribbon API.
diff --git a/dev/pyRevitLoader/pyRevitExtensionParser/PyRevitConfig.cs b/dev/pyRevitLoader/pyRevitExtensionParser/PyRevitConfig.cs
index f85cb6b24..8096bc886 100644
--- a/dev/pyRevitLoader/pyRevitExtensionParser/PyRevitConfig.cs
+++ b/dev/pyRevitLoader/pyRevitExtensionParser/PyRevitConfig.cs
@@ -94,6 +94,26 @@ public bool NewLoader
}
}
+ ///
+ /// Gets or sets whether Rocket Mode is enabled.
+ ///
+ ///
+ /// When true, pyRevit skips non-critical startup work (e.g. icon pre-loading)
+ /// to reduce session load time. Defaults to false.
+ ///
+ public bool RocketMode
+ {
+ get
+ {
+ var value = _ini.IniReadValue("core", "rocketmode");
+ return bool.TryParse(value, out var result) && result;
+ }
+ set
+ {
+ _ini.IniWriteValue("core", "rocketmode", value ? TrueString : FalseString);
+ }
+ }
+
///
/// Gets or sets whether to load beta/experimental commands.
///
@@ -115,6 +135,211 @@ public bool LoadBeta
}
}
+ ///
+ /// Gets or sets the logging verbosity level.
+ ///
+ ///
+ /// 0 = Quiet (default), 1 = Verbose, 2 = Debug.
+ /// Derived from the [core] verbose and debug keys, matching PyRevitLogLevels in the CLI library.
+ /// Read-only; change level by setting the [core] verbose or debug INI keys.
+ ///
+ public int LoggingLevel
+ {
+ get
+ {
+ var verbose = _ini.IniReadValue("core", "verbose");
+ var debug = _ini.IniReadValue("core", "debug");
+ bool isDebug = bool.TryParse(debug, out var d) && d;
+ bool isVerbose = bool.TryParse(verbose, out var v) && v;
+ if (isDebug) return 2;
+ if (isVerbose) return 1;
+ return 0;
+ }
+ }
+
+ ///
+ /// Gets or sets whether to write log output to a file.
+ ///
+ public bool FileLogging
+ {
+ get
+ {
+ var value = _ini.IniReadValue("core", "filelogging");
+ return bool.TryParse(value, out var result) && result;
+ }
+ set
+ {
+ _ini.IniWriteValue("core", "filelogging", value ? TrueString : FalseString);
+ }
+ }
+
+ ///
+ /// Gets or sets whether pyRevit should auto-update on startup.
+ ///
+ public bool AutoUpdate
+ {
+ get
+ {
+ var value = _ini.IniReadValue("core", "autoupdate");
+ return bool.TryParse(value, out var result) && result;
+ }
+ set
+ {
+ _ini.IniWriteValue("core", "autoupdate", value ? TrueString : FalseString);
+ }
+ }
+
+ ///
+ /// Gets or sets the path to a custom CSS stylesheet for pyRevit output windows.
+ ///
+ public string OutputStyleSheet
+ {
+ get
+ {
+ var value = _ini.IniReadValue("core", "outputstylesheet");
+ return string.IsNullOrEmpty(value) ? string.Empty : value.Trim();
+ }
+ set
+ {
+ _ini.IniWriteValue("core", "outputstylesheet", value ?? string.Empty);
+ }
+ }
+
+ // ── Telemetry ────────────────────────────────────────────────────────────
+
+ ///
+ /// Gets or sets whether script-execution telemetry is enabled.
+ ///
+ public bool TelemetryState
+ {
+ get
+ {
+ var value = _ini.IniReadValue("telemetry", "active");
+ return bool.TryParse(value, out var result) && result;
+ }
+ set
+ {
+ _ini.IniWriteValue("telemetry", "active", value ? TrueString : FalseString);
+ }
+ }
+
+ ///
+ /// Gets or sets whether telemetry timestamps are recorded in UTC.
+ ///
+ public bool TelemetryUTCTimeStamps
+ {
+ get
+ {
+ var value = _ini.IniReadValue("telemetry", "utc_timestamps");
+ if (bool.TryParse(value, out var result))
+ return result;
+
+ // Default to true when the value is missing or unparseable so that
+ // loader behavior matches CLI/user_config defaults.
+ return true;
+ }
+ set
+ {
+ _ini.IniWriteValue("telemetry", "utc_timestamps", value ? TrueString : FalseString);
+ }
+ }
+
+ ///
+ /// Gets or sets the directory path for telemetry log files.
+ ///
+ public string TelemetryFilePath
+ {
+ get
+ {
+ var value = _ini.IniReadValue("telemetry", "telemetry_file_dir");
+ return string.IsNullOrEmpty(value) ? string.Empty : value.Trim();
+ }
+ set
+ {
+ _ini.IniWriteValue("telemetry", "telemetry_file_dir", value ?? string.Empty);
+ }
+ }
+
+ ///
+ /// Gets or sets the URL of the telemetry server.
+ ///
+ public string TelemetryServerUrl
+ {
+ get
+ {
+ var value = _ini.IniReadValue("telemetry", "telemetry_server_url");
+ return string.IsNullOrEmpty(value) ? string.Empty : value.Trim();
+ }
+ set
+ {
+ _ini.IniWriteValue("telemetry", "telemetry_server_url", value ?? string.Empty);
+ }
+ }
+
+ ///
+ /// Gets or sets whether hook script executions are included in telemetry.
+ ///
+ public bool TelemetryIncludeHooks
+ {
+ get
+ {
+ var value = _ini.IniReadValue("telemetry", "include_hooks");
+ return bool.TryParse(value, out var result) && result;
+ }
+ set
+ {
+ _ini.IniWriteValue("telemetry", "include_hooks", value ? TrueString : FalseString);
+ }
+ }
+
+ ///
+ /// Gets or sets whether application-event telemetry is enabled.
+ ///
+ public bool AppTelemetryState
+ {
+ get
+ {
+ var value = _ini.IniReadValue("telemetry", "active_app");
+ return bool.TryParse(value, out var result) && result;
+ }
+ set
+ {
+ _ini.IniWriteValue("telemetry", "active_app", value ? TrueString : FalseString);
+ }
+ }
+
+ ///
+ /// Gets or sets the URL of the application-event telemetry server.
+ ///
+ public string AppTelemetryServerUrl
+ {
+ get
+ {
+ var value = _ini.IniReadValue("telemetry", "apptelemetry_server_url");
+ return string.IsNullOrEmpty(value) ? string.Empty : value.Trim();
+ }
+ set
+ {
+ _ini.IniWriteValue("telemetry", "apptelemetry_server_url", value ?? string.Empty);
+ }
+ }
+
+ ///
+ /// Gets or sets the event-flags bitmask for application telemetry.
+ ///
+ public string AppTelemetryEventFlags
+ {
+ get
+ {
+ var value = _ini.IniReadValue("telemetry", "apptelemetry_event_flags");
+ return string.IsNullOrEmpty(value) ? string.Empty : value.Trim();
+ }
+ set
+ {
+ _ini.IniWriteValue("telemetry", "apptelemetry_event_flags", value ?? string.Empty);
+ }
+ }
+
///
/// Gets or sets the timeout (in seconds) for displaying startup log messages.
///
diff --git a/dev/pyRevitLoader/pyRevitExtensionParser/pyRevitExtensionParser.csproj b/dev/pyRevitLoader/pyRevitExtensionParser/pyRevitExtensionParser.csproj
index e583b1576..a7869a7b5 100644
--- a/dev/pyRevitLoader/pyRevitExtensionParser/pyRevitExtensionParser.csproj
+++ b/dev/pyRevitLoader/pyRevitExtensionParser/pyRevitExtensionParser.csproj
@@ -17,7 +17,7 @@
-
+
diff --git a/dev/pyRevitLoader/pyRevitExtensionParserTester/ComponentValidationTests.cs b/dev/pyRevitLoader/pyRevitExtensionParserTester/ComponentValidationTests.cs
index e1379315e..382972321 100644
--- a/dev/pyRevitLoader/pyRevitExtensionParserTester/ComponentValidationTests.cs
+++ b/dev/pyRevitLoader/pyRevitExtensionParserTester/ComponentValidationTests.cs
@@ -445,7 +445,12 @@ private bool IsValidUniqueId(string uniqueId)
{
if (string.IsNullOrEmpty(uniqueId))
return false;
-
+
+ // Fix for #3107: UniqueId must also be a valid C# identifier,
+ // which means it cannot start with a digit.
+ if (char.IsDigit(uniqueId[0]))
+ return false;
+
return uniqueId.All(c => char.IsLetterOrDigit(c) || c == '_');
}
}
diff --git a/dev/pyRevitLoader/pyRevitExtensionParserTester/DynamoScriptTests.cs b/dev/pyRevitLoader/pyRevitExtensionParserTester/DynamoScriptTests.cs
index 897f9aa7d..c04e4d988 100644
--- a/dev/pyRevitLoader/pyRevitExtensionParserTester/DynamoScriptTests.cs
+++ b/dev/pyRevitLoader/pyRevitExtensionParserTester/DynamoScriptTests.cs
@@ -67,21 +67,15 @@ public void Parser_Should_Find_TestDynamoBIMGUI_With_Custom_Named_Script()
var extension = parsedExtensions.First();
- // Look for Test DynamoBIM GUI button (has BIM1_ArrowHeadSwitcher_script.dyn)
+ // Look for Test DynamoBIM GUI button (has custom-named folie_architecturale_script.dyn)
var dynamoGuiButton = FindComponentRecursively(extension, "TestDynamoBIMGUI");
-
+
Assert.That(dynamoGuiButton, Is.Not.Null, "Should find TestDynamoBIMGUI button");
- Assert.That(dynamoGuiButton.ScriptPath, Does.EndWith("BIM1_ArrowHeadSwitcher_script.dyn"),
- "TestDynamoBIMGUI should have BIM1_ArrowHeadSwitcher_script.dyn");
- Assert.That(File.Exists(dynamoGuiButton.ScriptPath), Is.True,
- "BIM1_ArrowHeadSwitcher_script.dyn file should exist");
-
- // Also verify the config file exists
- var configPath = Path.Combine(Path.GetDirectoryName(dynamoGuiButton.ScriptPath)!,
- "BIM1_DeleteUnusedViewTemplates_config.dyn");
- Assert.That(File.Exists(configPath), Is.True,
- "BIM1_DeleteUnusedViewTemplates_config.dyn should also exist");
-
+ Assert.That(dynamoGuiButton.ScriptPath, Does.EndWith("folie_architecturale_script.dyn"),
+ "TestDynamoBIMGUI should have custom-named folie_architecturale_script.dyn");
+ Assert.That(File.Exists(dynamoGuiButton.ScriptPath), Is.True,
+ "folie_architecturale_script.dyn file should exist");
+
TestContext.WriteLine($"Found TestDynamoBIMGUI script at: {dynamoGuiButton.ScriptPath}");
}
diff --git a/dev/pyRevitLoader/pyRevitExtensionParserTester/EnvDictionarySeederTests.cs b/dev/pyRevitLoader/pyRevitExtensionParserTester/EnvDictionarySeederTests.cs
new file mode 100644
index 000000000..4224cf0bf
--- /dev/null
+++ b/dev/pyRevitLoader/pyRevitExtensionParserTester/EnvDictionarySeederTests.cs
@@ -0,0 +1,49 @@
+using NUnit.Framework;
+using pyRevitAssemblyBuilder.SessionManager;
+
+namespace pyRevitExtensionParserTester
+{
+ ///
+ /// Tests for EnvDictionarySeeder logging level translation.
+ /// Verifies that pyRevit's logging level enum (0/1/2) is correctly
+ /// mapped to Python's logging module scale (30/20/10).
+ /// See: issue #3203
+ ///
+ [TestFixture]
+ public class EnvDictionarySeederTests
+ {
+ [Test]
+ public void ToPythonLoggingLevel_Quiet_ReturnsWarning30()
+ {
+ // pyRevit Quiet (0) → Python logging.WARNING (30)
+ Assert.AreEqual(30, EnvDictionarySeeder.ToPythonLoggingLevel(0),
+ "Quiet (0) must map to logging.WARNING (30) to suppress DEBUG/INFO output");
+ }
+
+ [Test]
+ public void ToPythonLoggingLevel_Verbose_ReturnsInfo20()
+ {
+ // pyRevit Verbose (1) → Python logging.INFO (20)
+ Assert.AreEqual(20, EnvDictionarySeeder.ToPythonLoggingLevel(1),
+ "Verbose (1) must map to logging.INFO (20)");
+ }
+
+ [Test]
+ public void ToPythonLoggingLevel_Debug_ReturnsDebug10()
+ {
+ // pyRevit Debug (2) → Python logging.DEBUG (10)
+ Assert.AreEqual(10, EnvDictionarySeeder.ToPythonLoggingLevel(2),
+ "Debug (2) must map to logging.DEBUG (10)");
+ }
+
+ [Test]
+ public void ToPythonLoggingLevel_UnexpectedValue_DefaultsToWarning30()
+ {
+ // Any unrecognized value should fall through to WARNING (safe default)
+ Assert.AreEqual(30, EnvDictionarySeeder.ToPythonLoggingLevel(-1),
+ "Negative values should default to WARNING (30)");
+ Assert.AreEqual(30, EnvDictionarySeeder.ToPythonLoggingLevel(99),
+ "Out-of-range values should default to WARNING (30)");
+ }
+ }
+}
diff --git a/dev/pyRevitLoader/pyRevitExtensionParserTester/ParsedBundleTests.cs b/dev/pyRevitLoader/pyRevitExtensionParserTester/ParsedBundleTests.cs
index 890c63bb9..f857663be 100644
--- a/dev/pyRevitLoader/pyRevitExtensionParserTester/ParsedBundleTests.cs
+++ b/dev/pyRevitLoader/pyRevitExtensionParserTester/ParsedBundleTests.cs
@@ -526,6 +526,11 @@ private static string SanitizeClassName(string name)
var sb = new System.Text.StringBuilder();
foreach (char c in name)
sb.Append(char.IsLetterOrDigit(c) ? c : '_');
+
+ // Fix for #3107: Match production code — C# class names cannot start with a digit.
+ if (sb.Length > 0 && char.IsDigit(sb[0]))
+ sb.Insert(0, '_');
+
return sb.ToString();
}
diff --git a/dev/pyRevitLoader/pyRevitExtensionParserTester/ScriptMetadataParsingTest.cs b/dev/pyRevitLoader/pyRevitExtensionParserTester/ScriptMetadataParsingTest.cs
index 9759fbeba..a6a7c79fc 100644
--- a/dev/pyRevitLoader/pyRevitExtensionParserTester/ScriptMetadataParsingTest.cs
+++ b/dev/pyRevitLoader/pyRevitExtensionParserTester/ScriptMetadataParsingTest.cs
@@ -1,6 +1,7 @@
using pyRevitExtensionParser;
using pyRevitExtensionParserTest;
using pyRevitExtensionParserTest.TestHelpers;
+using pyRevitAssemblyBuilder.AssemblyMaker;
using System.IO;
using System.Text;
using NUnit.Framework;
@@ -211,6 +212,111 @@ public void TestBundleOverridesScriptMetadata()
Assert.Pass("Bundle override of script metadata validated successfully.");
}
+ [Test]
+ public void TestLoggingLevelConfigFromIni()
+ {
+ var configPath = Path.Combine(TestTempDir, "pyRevit_config_logging.ini");
+
+ // Default: Quiet (0) when nothing is set
+ File.WriteAllText(configPath, "");
+ Assert.AreEqual(0, PyRevitConfig.Load(configPath).LoggingLevel, "Default should be 0 (Quiet)");
+
+ // Verbose only → 1
+ File.WriteAllText(configPath, "[core]\nverbose = true\ndebug = false");
+ Assert.AreEqual(1, PyRevitConfig.Load(configPath).LoggingLevel, "verbose=true should give 1");
+
+ // Debug → 2 (takes priority)
+ File.WriteAllText(configPath, "[core]\nverbose = true\ndebug = true");
+ Assert.AreEqual(2, PyRevitConfig.Load(configPath).LoggingLevel, "debug=true should give 2");
+
+ // Explicit Quiet
+ File.WriteAllText(configPath, "[core]\nverbose = false\ndebug = false");
+ Assert.AreEqual(0, PyRevitConfig.Load(configPath).LoggingLevel, "Both false should give 0");
+
+ Assert.Pass("LoggingLevel config parsing validated successfully.");
+ }
+
+ [Test]
+ public void TestTelemetryConfigFromIni()
+ {
+ var configPath = Path.Combine(TestTempDir, "pyRevit_config_telem.ini");
+
+ // Defaults: all false / empty when section is absent
+ File.WriteAllText(configPath, "");
+ var cfg0 = PyRevitConfig.Load(configPath);
+ Assert.IsFalse(cfg0.TelemetryState, "Default TelemetryState should be false");
+ Assert.IsTrue(cfg0.TelemetryUTCTimeStamps, "Default TelemetryUTCTimeStamps should be true");
+ Assert.AreEqual(string.Empty, cfg0.TelemetryFilePath, "Default TelemetryFilePath should be empty");
+ Assert.AreEqual(string.Empty, cfg0.TelemetryServerUrl, "Default TelemetryServerUrl should be empty");
+ Assert.IsFalse(cfg0.TelemetryIncludeHooks, "Default TelemetryIncludeHooks should be false");
+ Assert.IsFalse(cfg0.AppTelemetryState, "Default AppTelemetryState should be false");
+ Assert.AreEqual(string.Empty, cfg0.AppTelemetryServerUrl, "Default AppTelemetryServerUrl should be empty");
+ Assert.AreEqual(string.Empty, cfg0.AppTelemetryEventFlags, "Default AppTelemetryEventFlags should be empty");
+
+ // Set values and verify read-back
+ var iniContent = string.Join("\n", new[] {
+ "[telemetry]",
+ "active = true",
+ "utc_timestamps = true",
+ "telemetry_file_dir = C:\\logs",
+ "telemetry_server_url = https://telem.example.com",
+ "include_hooks = true",
+ "active_app = true",
+ "apptelemetry_server_url = https://apptelm.example.com",
+ "apptelemetry_event_flags = 255",
+ });
+ File.WriteAllText(configPath, iniContent);
+ var cfg1 = PyRevitConfig.Load(configPath);
+ Assert.IsTrue(cfg1.TelemetryState);
+ Assert.IsTrue(cfg1.TelemetryUTCTimeStamps);
+ Assert.AreEqual("C:\\logs", cfg1.TelemetryFilePath);
+ Assert.AreEqual("https://telem.example.com", cfg1.TelemetryServerUrl);
+ Assert.IsTrue(cfg1.TelemetryIncludeHooks);
+ Assert.IsTrue(cfg1.AppTelemetryState);
+ Assert.AreEqual("https://apptelm.example.com", cfg1.AppTelemetryServerUrl);
+ Assert.AreEqual("255", cfg1.AppTelemetryEventFlags);
+
+ // Write-then-read round-trip
+ var configPath2 = Path.Combine(TestTempDir, "pyRevit_config_telem_rw.ini");
+ File.WriteAllText(configPath2, "");
+ var cfgRw = PyRevitConfig.Load(configPath2);
+ cfgRw.TelemetryState = true;
+ cfgRw.TelemetryServerUrl = "https://rw.example.com";
+ Assert.IsTrue(PyRevitConfig.Load(configPath2).TelemetryState);
+ Assert.AreEqual("https://rw.example.com", PyRevitConfig.Load(configPath2).TelemetryServerUrl);
+
+ Assert.Pass("Telemetry config parsing validated successfully.");
+ }
+
+ [Test]
+ public void TestFileLoggingAndAutoUpdateConfigFromIni()
+ {
+ var configPath = Path.Combine(TestTempDir, "pyRevit_config_misc.ini");
+
+ // Defaults
+ File.WriteAllText(configPath, "");
+ var cfg0 = PyRevitConfig.Load(configPath);
+ Assert.IsFalse(cfg0.FileLogging, "Default FileLogging should be false");
+ Assert.IsFalse(cfg0.AutoUpdate, "Default AutoUpdate should be false");
+ Assert.AreEqual(string.Empty, cfg0.OutputStyleSheet, "Default OutputStyleSheet should be empty");
+
+ // Set values
+ File.WriteAllText(configPath, "[core]\nfilelogging = true\nautoupdate = true\noutputstylesheet = C:\\style.css");
+ var cfg1 = PyRevitConfig.Load(configPath);
+ Assert.IsTrue(cfg1.FileLogging);
+ Assert.IsTrue(cfg1.AutoUpdate);
+ Assert.AreEqual("C:\\style.css", cfg1.OutputStyleSheet);
+
+ // Write-then-read round-trip for OutputStyleSheet
+ var configPath2 = Path.Combine(TestTempDir, "pyRevit_config_misc_rw.ini");
+ File.WriteAllText(configPath2, "");
+ var cfgRw = PyRevitConfig.Load(configPath2);
+ cfgRw.OutputStyleSheet = "C:\\custom.css";
+ Assert.AreEqual("C:\\custom.css", PyRevitConfig.Load(configPath2).OutputStyleSheet);
+
+ Assert.Pass("FileLogging / AutoUpdate / OutputStyleSheet config parsing validated successfully.");
+ }
+
[Test]
public void TestLoadBetaConfigFromIni()
{
@@ -240,6 +346,101 @@ public void TestLoadBetaConfigFromIni()
Assert.Pass("LoadBeta config parsing validated successfully.");
}
+ [Test]
+ public void TestRocketModeEngineConfigs()
+ {
+ var testCases = new[]
+ {
+ new { Name = "RocketMode_Off_CompatibleExt", RocketMode = false, Compatible = true, ExplicitClean = false, ExpectedClean = true },
+ new { Name = "RocketMode_On_CompatibleExt", RocketMode = true, Compatible = true, ExplicitClean = false, ExpectedClean = false },
+ new { Name = "RocketMode_On_IncompatibleExt", RocketMode = true, Compatible = false, ExplicitClean = false, ExpectedClean = true },
+ new { Name = "RocketMode_On_CompatibleExt_ExplicitClean", RocketMode = true, Compatible = true, ExplicitClean = true, ExpectedClean = true },
+ new { Name = "RocketMode_Off_IncompatibleExt", RocketMode = false, Compatible = false, ExplicitClean = false, ExpectedClean = true },
+ };
+
+ foreach (var tc in testCases)
+ {
+ var extensionDir = Path.Combine(TestTempDir, $"{tc.Name}.extension");
+ var bundleDir = Path.Combine(extensionDir, "TestPanel.panel", "TestButton.pushbutton");
+ Directory.CreateDirectory(bundleDir);
+
+ var scriptContent = new StringBuilder();
+ scriptContent.AppendLine("__title__ = 'Test Button'");
+ if (tc.ExplicitClean)
+ {
+ scriptContent.AppendLine("__cleanengine__ = True");
+ }
+ File.WriteAllText(Path.Combine(bundleDir, "script.py"), scriptContent.ToString());
+
+ if (tc.Compatible)
+ {
+ var extensionJson = "{ \"rocket_mode_compatible\": \"True\" }";
+ File.WriteAllText(Path.Combine(extensionDir, "extension.json"), extensionJson);
+ }
+
+ var extensions = ParseInstalledExtensions(extensionDir).ToList();
+ Assert.AreEqual(1, extensions.Count, $"{tc.Name}: Expected 1 extension");
+ var extension = extensions.First();
+
+ Assert.AreEqual(tc.Compatible, extension.RocketModeCompatible,
+ $"{tc.Name}: RocketModeCompatible should be {tc.Compatible}");
+
+ var button = FindComponentRecursively(extension, "TestButton");
+ Assert.IsNotNull(button, $"{tc.Name}: TestButton not found");
+
+ var engineCfgs = pyRevitAssemblyBuilder.AssemblyMaker.CommandGenerationUtilities.BuildEngineConfigs(
+ button, button.ScriptPath, extension, tc.RocketMode);
+
+ TestContext.Out.WriteLine($"{tc.Name}: RocketMode={tc.RocketMode}, Compatible={tc.Compatible}, ExplicitClean={tc.ExplicitClean}");
+ TestContext.Out.WriteLine($" Engine Configs: {engineCfgs}");
+
+ Assert.IsTrue(engineCfgs.Contains($"\"clean\":{tc.ExpectedClean.ToString().ToLower()}"),
+ $"{tc.Name}: Expected clean={tc.ExpectedClean}, got: {engineCfgs}");
+
+ Directory.Delete(extensionDir, true);
+ }
+
+ Assert.Pass("Rocket mode engine configs validated successfully.");
+ }
+
+ [Test]
+ public void TestRocketModeCompatibilityFromExtensionJson()
+ {
+ var extensionDir = Path.Combine(TestTempDir, "RocketModeCompatibilityTest.extension");
+ var bundleDir = Path.Combine(extensionDir, "TestPanel.panel", "TestButton.pushbutton");
+ Directory.CreateDirectory(bundleDir);
+
+ File.WriteAllText(Path.Combine(bundleDir, "script.py"), "__title__ = 'Test'");
+ File.WriteAllText(Path.Combine(extensionDir, "extension.json"), "{ \"rocket_mode_compatible\": \"True\" }");
+
+ var extensions = ParseInstalledExtensions(extensionDir).ToList();
+ Assert.AreEqual(1, extensions.Count);
+ var extension = extensions.First();
+
+ Assert.IsTrue(extension.RocketModeCompatible, "Extension should be rocket mode compatible");
+
+ Directory.Delete(extensionDir, true);
+ }
+
+ [Test]
+ public void TestPyRevitCoreAlwaysRocketModeCompatible()
+ {
+ var extensionDir = Path.Combine(TestTempDir, "pyRevitCore.extension");
+ var bundleDir = Path.Combine(extensionDir, "TestPanel.panel", "TestButton.pushbutton");
+ Directory.CreateDirectory(bundleDir);
+
+ File.WriteAllText(Path.Combine(bundleDir, "script.py"), "__title__ = 'Test'");
+ // No extension.json - pyRevitCore should still be compatible
+
+ var extensions = ParseInstalledExtensions(extensionDir).ToList();
+ Assert.AreEqual(1, extensions.Count);
+ var extension = extensions.First();
+
+ Assert.IsTrue(extension.RocketModeCompatible, "pyRevitCore should always be rocket mode compatible");
+
+ Directory.Delete(extensionDir, true);
+ }
+
private ParsedComponent? FindComponentRecursively(ParsedComponent? component, string targetName)
{
if (component == null)
diff --git a/dev/pyRevitLoader/pyRevitExtensionParserTester/VersionFilteringTests.cs b/dev/pyRevitLoader/pyRevitExtensionParserTester/VersionFilteringTests.cs
new file mode 100644
index 000000000..0d9f0c8c5
--- /dev/null
+++ b/dev/pyRevitLoader/pyRevitExtensionParserTester/VersionFilteringTests.cs
@@ -0,0 +1,156 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using NUnit.Framework;
+using pyRevitExtensionParser;
+using pyRevitExtensionParserTest.TestHelpers;
+using static pyRevitExtensionParser.ExtensionParser;
+
+namespace pyRevitExtensionParserTest
+{
+ ///
+ /// Tests for min_revit_version / max_revit_version filtering in ExtensionParser.
+ /// All tests use temporary on-disk extension structures and call ParseInstalledExtensions
+ /// directly so filtering behaviour is verified at the parser level, independently of
+ /// ExtensionManagerService or any real installed extensions.
+ ///
+ [TestFixture]
+ public class VersionFilteringTests : TempFileTestBase
+ {
+ // -------------------------------------------------------------------------
+ // Test 1 — Extension excluded by min_revit_version
+ // -------------------------------------------------------------------------
+
+ [Test]
+ public void Extension_WithMinVersion_IsExcluded_WhenRevitYearIsTooLow()
+ {
+ // Arrange — extension requires Revit 2025, running 2024
+ var builder = new TestExtensionBuilder(TestTempDir, "MinVersionExtension");
+ builder.Create();
+ TestExtensionBuilder.WriteBundleYaml(builder.ExtensionPath, "min_revit_version: \"2025\"");
+ builder.AddTab("Tab").AddPanel("Panel").AddPushButton("Button", "pass");
+
+ // Act
+ var results = ParseInstalledExtensions(new[] { builder.ExtensionPath }, revitYear: 2024).ToList();
+
+ // Assert
+ Assert.IsEmpty(results, "Extension should be excluded when running Revit version is below min_revit_version");
+ }
+
+ // -------------------------------------------------------------------------
+ // Test 2 — Extension excluded by max_revit_version
+ // -------------------------------------------------------------------------
+
+ [Test]
+ public void Extension_WithMaxVersion_IsExcluded_WhenRevitYearIsTooHigh()
+ {
+ // Arrange — extension supports up to Revit 2022, running 2024
+ var builder = new TestExtensionBuilder(TestTempDir, "MaxVersionExtension");
+ builder.Create();
+ TestExtensionBuilder.WriteBundleYaml(builder.ExtensionPath, "max_revit_version: \"2022\"");
+ builder.AddTab("Tab").AddPanel("Panel").AddPushButton("Button", "pass");
+
+ // Act
+ var results = ParseInstalledExtensions(new[] { builder.ExtensionPath }, revitYear: 2024).ToList();
+
+ // Assert
+ Assert.IsEmpty(results, "Extension should be excluded when running Revit version is above max_revit_version");
+ }
+
+ // -------------------------------------------------------------------------
+ // Test 3 — Incompatible button excluded while compatible sibling survives
+ // -------------------------------------------------------------------------
+
+ [Test]
+ public void Button_WithMinVersion_IsExcluded_WhileCompatibleSiblingSurvives()
+ {
+ // Arrange — two buttons in the same panel; one requires Revit 2025, one has no constraint
+ var builder = new TestExtensionBuilder(TestTempDir, "MixedButtonExtension");
+ builder.Create();
+ var panel = builder.AddTab("Tab").AddPanel("Panel");
+
+ // Button that should be filtered out
+ panel.AddPushButton("FutureButton", "pass", "min_revit_version: \"2025\"");
+
+ // Button that should survive
+ panel.AddPushButton("CompatibleButton", "pass");
+
+ // Act
+ var extension = ParseInstalledExtensions(new[] { builder.ExtensionPath }, revitYear: 2024)
+ .Single();
+ var allButtons = GetAllButtons(extension);
+
+ // Assert
+ Assert.IsFalse(allButtons.Any(b => b.Name == "FutureButton"),
+ "Button with min_revit_version: 2025 should be excluded when running Revit 2024");
+ Assert.IsTrue(allButtons.Any(b => b.Name == "CompatibleButton"),
+ "Button with no version constraint should survive");
+ }
+
+ // -------------------------------------------------------------------------
+ // Test 4 — Invalid version string is handled gracefully (no exception)
+ // -------------------------------------------------------------------------
+
+ [Test]
+ public void Extension_WithInvalidVersionString_IsExcluded_WithoutThrowing()
+ {
+ // Arrange — min_revit_version contains a non-integer value
+ var builder = new TestExtensionBuilder(TestTempDir, "InvalidVersionExtension");
+ builder.Create();
+ TestExtensionBuilder.WriteBundleYaml(builder.ExtensionPath, "min_revit_version: \"not_a_year\"");
+ builder.AddTab("Tab").AddPanel("Panel").AddPushButton("Button", "pass");
+
+ // Act & Assert — should not throw, and the extension should be excluded
+ List results = null;
+ Assert.DoesNotThrow(() =>
+ {
+ results = ParseInstalledExtensions(new[] { builder.ExtensionPath }, revitYear: 2024).ToList();
+ }, "Parsing an invalid version string should not throw an exception");
+
+ Assert.IsEmpty(results, "Extension with an unparseable version string should be excluded");
+ }
+
+ // -------------------------------------------------------------------------
+ // Test 5 — revitYear = 0 bypasses all filtering
+ // -------------------------------------------------------------------------
+
+ [Test]
+ public void Extension_WithAnyVersionConstraint_IsIncluded_WhenRevitYearIsZero()
+ {
+ // Arrange — extension nominally requires a future Revit version
+ var builder = new TestExtensionBuilder(TestTempDir, "FarFutureExtension");
+ builder.Create();
+ TestExtensionBuilder.WriteBundleYaml(builder.ExtensionPath, "min_revit_version: \"2099\"");
+ builder.AddTab("Tab").AddPanel("Panel").AddPushButton("Button", "pass");
+
+ // Act — revitYear: 0 means Revit version is unknown; filtering should be skipped
+ var results = ParseInstalledExtensions(new[] { builder.ExtensionPath }, revitYear: 0).ToList();
+
+ // Assert
+ Assert.IsNotEmpty(results, "Extension should be included when revitYear is 0 (version unknown, filtering disabled)");
+ }
+
+ // -------------------------------------------------------------------------
+ // Helper
+ // -------------------------------------------------------------------------
+
+ ///
+ /// Recursively collects all pushbutton-level components from an extension.
+ ///
+ private static List GetAllButtons(ParsedComponent root)
+ {
+ var result = new List();
+ if (root.Children == null) return result;
+
+ foreach (var child in root.Children)
+ {
+ if (child.Type == CommandComponentType.PushButton)
+ result.Add(child);
+ else
+ result.AddRange(GetAllButtons(child));
+ }
+
+ return result;
+ }
+ }
+}
diff --git a/dev/pyRevitLoader/pyRevitExtensionParserTester/pyRevitExtensionParserTest.csproj b/dev/pyRevitLoader/pyRevitExtensionParserTester/pyRevitExtensionParserTest.csproj
index 49b3e1576..23a1e68f8 100644
--- a/dev/pyRevitLoader/pyRevitExtensionParserTester/pyRevitExtensionParserTest.csproj
+++ b/dev/pyRevitLoader/pyRevitExtensionParserTester/pyRevitExtensionParserTest.csproj
@@ -60,15 +60,17 @@
+
+
-
-
-
-
-
-
+
+
+
+
+
+
@@ -94,6 +96,7 @@
+
diff --git a/dev/pyRevitTelemetryServer/go.mod b/dev/pyRevitTelemetryServer/go.mod
index e0471950f..24cbcc506 100644
--- a/dev/pyRevitTelemetryServer/go.mod
+++ b/dev/pyRevitTelemetryServer/go.mod
@@ -3,36 +3,28 @@ module pyrevittelemetryserver
go 1.24.4
require (
- github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d
- github.com/denisenkom/go-mssqldb v0.11.0
+ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2
+ github.com/denisenkom/go-mssqldb v0.12.3
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815
- github.com/go-sql-driver/mysql v1.6.0
- github.com/gofrs/uuid v4.3.1+incompatible
- github.com/gorilla/mux v1.8.0
- github.com/lib/pq v1.10.3
- github.com/mattn/go-sqlite3 v1.14.8
+ github.com/go-sql-driver/mysql v1.9.3
+ github.com/gofrs/uuid v4.4.0+incompatible
+ github.com/gorilla/mux v1.8.1
+ github.com/lib/pq v1.12.1
+ github.com/mattn/go-sqlite3 v1.14.38
github.com/pkg/errors v0.9.1
github.com/satori/go.uuid v1.2.0
- go.mongodb.org/mongo-driver/v2 v2.5.0
pkg.re/essentialkaos/ek.v10 v12.32.0+incompatible
)
require (
- github.com/montanaflynn/stats v0.7.1 // indirect
- github.com/xdg-go/pbkdf2 v1.0.0 // indirect
- github.com/xdg-go/scram v1.2.0 // indirect
- github.com/xdg-go/stringprep v1.0.4 // indirect
- github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
+ filippo.io/edwards25519 v1.1.1 // indirect
+ github.com/golang-sql/sqlexp v0.1.0 // indirect
)
require (
// Indirect dependencies
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe // indirect
- github.com/golang/snappy v0.0.4 // indirect
- github.com/klauspost/compress v1.17.6 // indirect
golang.org/x/crypto v0.45.0 // indirect
- golang.org/x/sync v0.18.0 // indirect
- golang.org/x/text v0.31.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
pkg.re/essentialkaos/check.v1 v1.0.0 // indirect
)
diff --git a/dev/pyRevitTelemetryServer/go.sum b/dev/pyRevitTelemetryServer/go.sum
index 8adbdf436..8594e8437 100644
--- a/dev/pyRevitTelemetryServer/go.sum
+++ b/dev/pyRevitTelemetryServer/go.sum
@@ -1,36 +1,74 @@
-github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d h1:Byv0BzEl3/e6D5CLfI0j/7hiIEtvGVFPCZ7Ei2oq8iQ=
-github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
-github.com/denisenkom/go-mssqldb v0.11.0 h1:9rHa233rhdOyrz2GcP9NM+gi2psgJZ4GWDpL/7ND8HI=
-github.com/denisenkom/go-mssqldb v0.11.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
+filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw=
+filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v0.19.0/go.mod h1:h6H6c8enJmmocHUbLiiGY6sx7f9i+X3m1CHdd5c6Rdw=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity v0.11.0/go.mod h1:HcM1YX14R7CJcghJGOYCgdezslRSVzqwLf/q+4Y2r/0=
+github.com/Azure/azure-sdk-for-go/sdk/internal v0.7.0/go.mod h1:yqy467j36fJxcRV2TzfVZ1pCb5vxm4BtZPUdYWe/Xo8=
+github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
+github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/denisenkom/go-mssqldb v0.12.3 h1:pBSGx9Tq67pBOTLmxNuirNTeB8Vjmf886Kx+8Y+8shw=
+github.com/denisenkom/go-mssqldb v0.12.3/go.mod h1:k0mtMFOnU+AihqFxPMiF05rtiDrorD1Vrm1KEz5hxDo=
+github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 h1:bWDMxwH3px2JBh6AyO7hdCn/PkvCZXii8TGj7sbtEbQ=
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
-github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
-github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
-github.com/gofrs/uuid v4.3.1+incompatible h1:0/KbAdpx3UXAx1kEOWHJeOkpbgRFGHVgv+CFIY7dBJI=
-github.com/gofrs/uuid v4.3.1+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
+github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
+github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
+github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
+github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
+github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
+github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
+github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
+github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
+github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
-github.com/lib/pq v1.10.3 h1:v9QZf2Sn6AmjXtQeFpdoq/eaNtYP6IN+7lcrygsIAtg=
-github.com/lib/pq v1.10.3/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
+github.com/lib/pq v1.12.1 h1:x1nbl/338GLqeDJ/FAiILallhAsqubLzEZu/pXtHUow=
+github.com/lib/pq v1.12.1/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/mattn/go-sqlite3 v1.14.8 h1:gDp86IdQsN/xWjIEmr9MF6o9mpksUgh0fu+9ByFxzIU=
github.com/mattn/go-sqlite3 v1.14.8/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
+github.com/mattn/go-sqlite3 v1.14.38 h1:tDUzL85kMvOrvpCt8P64SbGgVFtJB11GPi2AdmITgb4=
+github.com/mattn/go-sqlite3 v1.14.38/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8=
+github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4/go.mod h1:4OwLy04Bl9Ef3GJJCoec+30X3LQs/0/m4HFRt/2LUSA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
-golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20210610132358-84b48f89b13b/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
pkg.re/essentialkaos/check.v1 v1.0.0 h1:2V++mhtm9yHqvW7gtXqcU1D+98vTICGnXmaZloLsZVY=
pkg.re/essentialkaos/check.v1 v1.0.0/go.mod h1:B7CoMnGFRnruw7X2Z45kWNvoCW+5OhUsLUm1EBM1aJs=
pkg.re/essentialkaos/ek.v10 v12.32.0+incompatible h1:MSnAZgf9WxV/kBpmPpD7md3ajOSXrugvbGIqRd9AWTI=
diff --git a/extensions/pyRevitCore.extension/pyRevit.tab/pyRevit.panel/Extensions.smartbutton/script.py b/extensions/pyRevitCore.extension/pyRevit.tab/pyRevit.panel/Extensions.smartbutton/script.py
index 6777d6de0..e0bcdb92c 100644
--- a/extensions/pyRevitCore.extension/pyRevit.tab/pyRevit.panel/Extensions.smartbutton/script.py
+++ b/extensions/pyRevitCore.extension/pyRevit.tab/pyRevit.panel/Extensions.smartbutton/script.py
@@ -1,3 +1,4 @@
+# -*- coding: utf-8 -*-
"""Add or remove pyRevit extensions."""
# pylint: disable=E0401,W0703,W0613,C0103,C0111
@@ -18,6 +19,65 @@
logger = script.get_logger()
+def _ensure_path_registered(dest_path):
+ """Add dest_path to the Custom Extension Directories list if not already there.
+
+ The old 5.x Install dropdown only offered pre-registered directories, so this
+ was never needed. The new 'Pick' button allows arbitrary folders, so we must
+ register them or pyRevit won't discover the installed extension after reload.
+ Fix for #3193.
+
+ Avoid explicitly registering the implicit default third-party extensions
+ directory in the Custom Extension Directories list.
+ """
+ # Normalize destination for reliable comparison across platforms
+ norm_dest = os.path.normcase(os.path.normpath(dest_path))
+
+ # Skip registering the default third-party extensions directory, which is
+ # meant to be implicit and not stored as a custom extension root.
+ try:
+ from pyrevit import THIRDPARTY_EXTENSIONS_DEFAULT_DIR
+
+ default_norm = os.path.normcase(
+ os.path.normpath(THIRDPARTY_EXTENSIONS_DEFAULT_DIR)
+ )
+ if norm_dest == default_norm:
+ return
+ except Exception:
+ # If the default directory cannot be resolved for any reason,
+ # fall back to normal registration logic.
+ pass
+
+
+ Note:
+ This function must *not* use get_thirdparty_ext_root_dirs() as the source
+ of truth, since that helper may filter out non-existent paths (e.g. network
+ locations that are temporarily offline). We therefore read the raw configured
+ list from user_config when available, and only fall back to the helper for
+ backward compatibility.
+ """
+ norm_dest = os.path.normpath(dest_path)
+
+ # Prefer the raw configured list to avoid silently dropping offline paths.
+ try:
+ raw_dirs = list(user_config.thirdparty_ext_root_dirs or [])
+ except AttributeError:
+ # Fallback for older configs: use existing helper (may filter non-existent).
+ raw_dirs = user_config.get_thirdparty_ext_root_dirs(include_default=False)
+
+ normalized_existing = [os.path.normpath(d) for d in raw_dirs]
+ if norm_dest not in normalized_existing:
+ raw_dirs.append(norm_dest)
+ user_config.set_thirdparty_ext_root_dirs(raw_dirs)
+ user_config.save_changes()
+
+def _get_default_ext_dir():
+ """Return the best default extension installation directory."""
+ dirs = user_config.get_thirdparty_ext_root_dirs(include_default=True)
+ if dirs:
+ return dirs[0]
+ from pyrevit import THIRDPARTY_EXTENSIONS_DEFAULT_DIR
+ return THIRDPARTY_EXTENSIONS_DEFAULT_DIR
def _repo_name_from_git_url(git_url):
"""Derive repo/folder name from a Git URL (e.g. .../owner/repo.git -> repo)."""
@@ -340,22 +400,25 @@ def update_ext_info(self, sender, args):
self._update_add_custom_section_for_new()
def _update_add_custom_section_for_selection(self, ext_pkg_item):
- """Populate Add Custom section from selected extension; disable Pick, show Install only if not installed."""
+ """Populate Add Custom section from selected extension."""
self.custom_git_url_tb.Text = ext_pkg_item.GitURL or ""
if getattr(self, "custom_ext_name_tb", None):
self.custom_ext_name_tb.Text = ext_pkg_item.Name or ""
+ # Git URL and name come from the catalog — make read-only
self.custom_git_url_tb.IsReadOnly = True
if getattr(self, "custom_ext_name_tb", None):
self.custom_ext_name_tb.IsReadOnly = True
- self.path_custom_ext_b.IsEnabled = False
if ext_pkg_item.ext_pkg.is_installed:
+ # Already installed — show where it lives, disable path change
self.custom_ext_install_path_tb.Text = ext_pkg_item.ext_pkg.is_installed
- else:
- default_path = user_config.get_thirdparty_ext_root_dirs(include_default=True)[0]
- self.custom_ext_install_path_tb.Text = default_path
- if ext_pkg_item.ext_pkg.is_installed:
+ self.path_custom_ext_b.IsEnabled = False
self.hide_element(self.install_custom_ext_b)
else:
+ # Not yet installed — let user pick where to install
+ # Fix for #3193: Keep "Pick installation path" enabled so user can choose
+ self.path_custom_ext_b.IsEnabled = True
+ default_path = _get_default_ext_dir()
+ self.custom_ext_install_path_tb.Text = default_path
self.show_element(self.install_custom_ext_b)
self.install_custom_ext_b.Content = self.get_locale_string("Buttons.InstallExtension")
@@ -436,6 +499,7 @@ def install_custom_extension(self, sender, args):
self.selected_pkg.ext_pkg.config.private_repo = True
self.selected_pkg.ext_pkg.config.token = token
extpkgs.install(self.selected_pkg.ext_pkg, dest_path)
+ _ensure_path_registered(dest_path)
self._refresh_extension_list()
self.Close()
call_reload()
@@ -512,6 +576,7 @@ def install_custom_extension(self, sender, args):
user_config.save_changes() # i don't like it - drop this later
extpkgs.install(temp_pkg, dest_path)
+ _ensure_path_registered(dest_path)
self._refresh_extension_list()
forms.alert(
diff --git a/extensions/pyRevitCore.extension/pyRevit.tab/pyRevit.panel/Settings.smartbutton/script.py b/extensions/pyRevitCore.extension/pyRevit.tab/pyRevit.panel/Settings.smartbutton/script.py
index bc4429ca5..8946e4379 100644
--- a/extensions/pyRevitCore.extension/pyRevit.tab/pyRevit.panel/Settings.smartbutton/script.py
+++ b/extensions/pyRevitCore.extension/pyRevit.tab/pyRevit.panel/Settings.smartbutton/script.py
@@ -1028,7 +1028,7 @@ def __selfinit__(script_cmp, ui_button_cmp, __rvt__):
# windows explorer
# otherwise, will show the Settings user interface
if __name__ == "__main__":
- if __shiftclick__: # pylint: disable=E0602
+ if EXEC_PARAMS.config_mode:
script.show_file_in_explorer(user_config.config_file)
elif user_config.is_readonly:
forms.alert("pyRevit settings are set by your admin.", exitscript=True)
diff --git a/extensions/pyRevitDevTools.extension/SamplePanel.ResourceDictionary.en_us.xaml b/extensions/pyRevitDevTools.extension/SamplePanel.ResourceDictionary.en_us.xaml
new file mode 100644
index 000000000..cda59cf25
--- /dev/null
+++ b/extensions/pyRevitDevTools.extension/SamplePanel.ResourceDictionary.en_us.xaml
@@ -0,0 +1,13 @@
+
+
+
+ Hello from the English ResourceDictionary!
+
+
\ No newline at end of file
diff --git a/extensions/pyRevitDevTools.extension/SamplePanel.xaml b/extensions/pyRevitDevTools.extension/SamplePanel.xaml
new file mode 100644
index 000000000..80e386b5e
--- /dev/null
+++ b/extensions/pyRevitDevTools.extension/SamplePanel.xaml
@@ -0,0 +1,90 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ pyRevit on GitHub
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Debug.panel/Bundle Tests.pulldown/Test DynamoBIM GUI.pushbutton/script.dyn b/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Debug.panel/Bundle Tests.pulldown/Test DynamoBIM GUI.pushbutton/folie_architecturale_script.dyn
similarity index 99%
rename from extensions/pyRevitDevTools.extension/pyRevitDev.tab/Debug.panel/Bundle Tests.pulldown/Test DynamoBIM GUI.pushbutton/script.dyn
rename to extensions/pyRevitDevTools.extension/pyRevitDev.tab/Debug.panel/Bundle Tests.pulldown/Test DynamoBIM GUI.pushbutton/folie_architecturale_script.dyn
index 9df031ce2..62e262742 100644
--- a/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Debug.panel/Bundle Tests.pulldown/Test DynamoBIM GUI.pushbutton/script.dyn
+++ b/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Debug.panel/Bundle Tests.pulldown/Test DynamoBIM GUI.pushbutton/folie_architecturale_script.dyn
@@ -2,7 +2,7 @@
"Uuid": "14461aef-24c5-4d59-a473-4d84cbdf4fe6",
"IsCustomNode": false,
"Description": "Ce graphique crée une folie architecturale en utilisant une série de lignes et leur déplacement pour positionner les éléments d'ossature et de sol dans Revit.",
- "Name": "BIM1_ArrowHeadSwitcher_script",
+ "Name": "folie_architecturale_script",
"ElementResolver": {
"ResolutionMap": {}
},
diff --git a/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Debug.panel/Unit Tests.pulldown/Query get_name Tests.pushbutton/script.py b/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Debug.panel/Unit Tests.pulldown/Query get_name Tests.pushbutton/script.py
new file mode 100644
index 000000000..067beba14
--- /dev/null
+++ b/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Debug.panel/Unit Tests.pulldown/Query get_name Tests.pushbutton/script.py
@@ -0,0 +1,9 @@
+"""Run query.get_name unit tests (requires open project document)."""
+
+__context__ = "doc-project"
+
+from pyrevit.unittests import test_query_get_name
+from pyrevit.unittests.runner import run_module_tests
+
+
+run_module_tests(test_query_get_name)
diff --git a/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Debug.panel/Unit Tests.pulldown/Routes Module Tests.pushbutton/script.py b/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Debug.panel/Unit Tests.pulldown/Routes Module Tests.pushbutton/script.py
new file mode 100644
index 000000000..3f47d77c4
--- /dev/null
+++ b/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Debug.panel/Unit Tests.pulldown/Routes Module Tests.pushbutton/script.py
@@ -0,0 +1,78 @@
+"""Run all routes unit tests from pyrevit.unittests."""
+
+import pkgutil
+import traceback
+
+import pyrevit.unittests as tests_pkg
+from pyrevit.unittests.runner import run_module_tests
+
+
+__context__ = 'zero-doc'
+
+
+TEST_MODULE_PREFIX = 'test_routes_'
+
+
+def _discover_routes_modules():
+ module_names = []
+ for _, module_name, is_package in pkgutil.iter_modules(tests_pkg.__path__):
+ if not is_package and module_name.startswith(TEST_MODULE_PREFIX):
+ module_names.append(module_name)
+ return sorted(module_names)
+
+
+def _import_module(qualified_name):
+ return __import__(qualified_name, fromlist=['*'])
+
+
+def _format_exception_info(exc_info):
+ if isinstance(exc_info, tuple) and len(exc_info) == 3:
+ try:
+ return ''.join(
+ traceback.format_exception(exc_info[0], exc_info[1], exc_info[2])
+ )
+ except Exception:
+ return str(exc_info)
+ return str(exc_info)
+
+
+def _print_result_details(module_name, result):
+ for label, issues in (("ERROR", result.errors), ("FAILURE", result.failures)):
+ if not issues:
+ continue
+
+ print("\n{} details for {}:".format(label, module_name))
+ for test_obj, exc_info in issues:
+ print(" - {} {}".format(label, test_obj))
+ print(_format_exception_info(exc_info))
+
+
+routes_test_modules = _discover_routes_modules()
+if not routes_test_modules:
+ raise RuntimeError(
+ "No routes unit test modules found with prefix '{}' in {}".format(
+ TEST_MODULE_PREFIX,
+ tests_pkg.__name__,
+ )
+ )
+
+print('Discovered routes unit test modules:')
+for module_name in routes_test_modules:
+ print(' - {}'.format(module_name))
+
+failures = []
+for module_name in routes_test_modules:
+ qualified_name = '{}.{}'.format(tests_pkg.__name__, module_name)
+ print('\nRunning {}'.format(qualified_name))
+ module = _import_module(qualified_name)
+ result = run_module_tests(module)
+ if not result.wasSuccessful():
+ _print_result_details(qualified_name, result)
+ failures.append(qualified_name)
+
+if failures:
+ raise AssertionError(
+ 'Routes unit test failures: {}'.format(', '.join(failures))
+ )
+
+print('\nAll routes unit tests passed.')
diff --git a/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Debug.panel/Unit Tests.pulldown/bundle.yaml b/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Debug.panel/Unit Tests.pulldown/bundle.yaml
index 3e8678d1f..6b1c9880c 100644
--- a/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Debug.panel/Unit Tests.pulldown/bundle.yaml
+++ b/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Debug.panel/Unit Tests.pulldown/bundle.yaml
@@ -8,6 +8,7 @@ layout:
- Forms Module Tests
- Revit Module Tests
- Script Module Tests
+ - Routes Module Tests
- -----
- Test Output Stream
- Test Input Stream
@@ -27,5 +28,6 @@ layout:
- Test Toast
- Test FillPatternViewer
- Test Family API
+ - Query get_name Tests
- -----
- Test Project Parameters
diff --git a/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Developer Examples.panel/DirectContext3D.pushbutton/dc3dtest_script.py b/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Developer Examples.panel/DirectContext3D.pushbutton/dc3dtest_script.py
index 5246e8699..ea014e48b 100644
--- a/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Developer Examples.panel/DirectContext3D.pushbutton/dc3dtest_script.py
+++ b/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Developer Examples.panel/DirectContext3D.pushbutton/dc3dtest_script.py
@@ -68,6 +68,18 @@ def button_apply(self, sender, args):
uidoc.RefreshActiveView()
def button_select(self, sender, args):
+ override_color = None
+ if self.chkOverrideColor.IsChecked:
+ try:
+ override_color = DB.ColorWithTransparency(
+ int(self.txtRed.Text),
+ int(self.txtGreen.Text),
+ int(self.txtBlue.Text),
+ int(self.txtTransp.Text),
+ )
+ except ValueError:
+ forms.alert("Color input invalid!", sub_msg=traceback.format_exc())
+ return
element = revit.pick_element()
geometry = revit.query.get_geometry(element)
@@ -75,7 +87,7 @@ def button_select(self, sender, args):
for geo in geometry:
if not isinstance(geo, DB.Solid) or geo.Volume == 0:
continue
- solid_mesh = revit.dc3dserver.Mesh.from_solid(doc, geo)
+ solid_mesh = revit.dc3dserver.Mesh.from_solid(doc, geo, color=override_color)
if not solid_mesh:
continue
mesh.append(solid_mesh)
diff --git a/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Developer Examples.panel/DirectContext3D.pushbutton/ui.xaml b/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Developer Examples.panel/DirectContext3D.pushbutton/ui.xaml
index 4062dbcbc..d779fba49 100644
--- a/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Developer Examples.panel/DirectContext3D.pushbutton/ui.xaml
+++ b/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Developer Examples.panel/DirectContext3D.pushbutton/ui.xaml
@@ -18,6 +18,7 @@
+
@@ -45,6 +46,7 @@
STL Scale
+
diff --git a/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Developer Examples.panel/DockablePane.smartbutton/off.png b/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Developer Examples.panel/DockablePane.smartbutton/off.png
new file mode 100644
index 000000000..35c4df7f9
Binary files /dev/null and b/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Developer Examples.panel/DockablePane.smartbutton/off.png differ
diff --git a/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Developer Examples.panel/DockablePane.smartbutton/on.png b/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Developer Examples.panel/DockablePane.smartbutton/on.png
new file mode 100644
index 000000000..8f7d8ee63
Binary files /dev/null and b/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Developer Examples.panel/DockablePane.smartbutton/on.png differ
diff --git a/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Developer Examples.panel/DockablePane.smartbutton/script.py b/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Developer Examples.panel/DockablePane.smartbutton/script.py
new file mode 100644
index 000000000..812bd1e54
--- /dev/null
+++ b/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Developer Examples.panel/DockablePane.smartbutton/script.py
@@ -0,0 +1,37 @@
+# -*- coding: utf-8 -*-
+"""Developer sample: WPFPanel smartbutton.
+
+Exercises every WPFPanel / _WPFMixin feature.
+The toolbar icon mirrors the panel's open/closed state.
+"""
+
+from pyrevit import forms, script, PyRevitException
+
+
+dev_sample_guid = "759a2751-290a-4f7a-8f2d-9d900b2547b8"
+
+_NOT_REGISTERED_MSG = (
+ "The sample dockable pane is not registered. Register it from this "
+ "extension's startup.py on UIControlledApplication (before running "
+ "this button)."
+)
+
+
+def __selfinit__(script_cmp, ui_button_cmp, __rvt__):
+
+ is_shown = False
+ try:
+ _panel = forms.get_dockable_panel(dev_sample_guid)
+ is_shown = _panel.IsShown()
+ except Exception:
+ is_shown = False
+ # TODO FIXME: toggle_icon doesn't work. no idea why. debug shows it can't find the ui_button.
+ script.toggle_icon(is_shown)
+
+
+try:
+ _panel = forms.get_dockable_panel(dev_sample_guid)
+ forms.toggle_dockable_panel(dev_sample_guid, not _panel.IsShown())
+ script.toggle_icon(_panel.IsShown())
+except PyRevitException:
+ forms.alert(_NOT_REGISTERED_MSG)
diff --git a/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Developer Examples.panel/ExportIFC.pushbutton/IFC_config_sample.json b/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Developer Examples.panel/ExportIFC.pushbutton/IFC_config_sample.json
new file mode 100644
index 000000000..b7793d00f
--- /dev/null
+++ b/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Developer Examples.panel/ExportIFC.pushbutton/IFC_config_sample.json
@@ -0,0 +1,58 @@
+{
+ "IFCVersion": 21,
+ "ExchangeRequirement": 3,
+ "IFCFileType": 0,
+ "ActivePhaseId": -1,
+ "SpaceBoundaries": 0,
+ "SplitWallsAndColumns": false,
+ "IncludeSteelElements": true,
+ "ProjectAddress": {
+ "UpdateProjectInformation": false,
+ "AssignAddressToSite": false,
+ "AssignAddressToBuilding": true
+ },
+ "Export2DElements": false,
+ "ExportLinkedFiles": 0,
+ "VisibleElementsOfCurrentView": false,
+ "ExportRoomsInView": false,
+ "ExportInternalRevitPropertySets": false,
+ "ExportIFCCommonPropertySets": true,
+ "ExportBaseQuantities": false,
+ "ExportMaterialPsets": false,
+ "ExportSchedulesAsPsets": false,
+ "ExportSpecificSchedules": false,
+ "ExportUserDefinedPsets": false,
+ "ExportUserDefinedParameterMapping": false,
+ "ExportUserDefinedParameterMappingFileName": "",
+ "ClassificationSettings": {
+ "ClassificationName": null,
+ "ClassificationEdition": null,
+ "ClassificationSource": null,
+ "ClassificationEditionDate": "\/Date(-62135596800000)\/",
+ "ClassificationLocation": null,
+ "ClassificationFieldName": null
+ },
+ "TessellationLevelOfDetail": 0.5,
+ "ExportPartsAsBuildingElements": false,
+ "ExportSolidModelRep": false,
+ "UseActiveViewGeometry": false,
+ "UseFamilyAndTypeNameForReference": false,
+ "Use2DRoomBoundaryForVolume": false,
+ "IncludeSiteElevation": false,
+ "StoreIFCGUID": false,
+ "ExportBoundingBox": false,
+ "UseOnlyTriangulation": false,
+ "UseTypeNameOnlyForIfcType": false,
+ "UseVisibleRevitNameAsEntityName": false,
+ "SelectedSite": "Default Site",
+ "SitePlacement": 0,
+ "GeoRefCRSName": "",
+ "GeoRefCRSDesc": "",
+ "GeoRefEPSGCode": "",
+ "GeoRefGeodeticDatum": "",
+ "GeoRefMapUnit": "",
+ "ExcludeFilter": "",
+ "COBieCompanyInfo": "",
+ "COBieProjectInfo": "",
+ "Name": "IFC Configuration_sample"
+}
\ No newline at end of file
diff --git a/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Developer Examples.panel/ExportIFC.pushbutton/icon.dark.png b/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Developer Examples.panel/ExportIFC.pushbutton/icon.dark.png
new file mode 100644
index 000000000..7e2ff5f6e
Binary files /dev/null and b/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Developer Examples.panel/ExportIFC.pushbutton/icon.dark.png differ
diff --git a/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Developer Examples.panel/ExportIFC.pushbutton/icon.png b/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Developer Examples.panel/ExportIFC.pushbutton/icon.png
new file mode 100644
index 000000000..cece066b4
Binary files /dev/null and b/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Developer Examples.panel/ExportIFC.pushbutton/icon.png differ
diff --git a/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Developer Examples.panel/ExportIFC.pushbutton/script.py b/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Developer Examples.panel/ExportIFC.pushbutton/script.py
new file mode 100644
index 000000000..d77b994e5
--- /dev/null
+++ b/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Developer Examples.panel/ExportIFC.pushbutton/script.py
@@ -0,0 +1,101 @@
+# -*- coding: utf-8 -*-
+"""
+IFC Export Demo
+---------------
+Demonstrates ifc_export.py from the pyRevit developer tab.
+Run from within an open Revit document.
+"""
+
+import os
+from pyrevit import forms, script, revit, EXEC_PARAMS
+from pyrevit.interop.ifc import IFCExporter
+
+output = script.get_output()
+doc = revit.doc
+
+# ---------------------------------------------------------------------------
+# Pick an output folder
+# ---------------------------------------------------------------------------
+folder = forms.pick_folder(title="Select IFC output folder")
+if not folder:
+ script.exit()
+
+filename = "{}.ifc".format(doc.Title)
+
+# ---------------------------------------------------------------------------
+# Optionally load a config file - skip to use defaults
+# ---------------------------------------------------------------------------
+use_config = forms.alert(
+ "Load a Revit IFC configuration JSON file?\n\n"
+ "Click Yes to browse, No to use built-in defaults.",
+ yes=True,
+ no=True,
+)
+
+config_path = None
+if use_config:
+ config_path = forms.pick_file(
+ file_ext="json",
+ init_dir=EXEC_PARAMS.command_path,
+ title="Select IFC Configuration JSON",
+ )
+
+# ---------------------------------------------------------------------------
+# Runtime overrides - edit these to test specific options
+# ---------------------------------------------------------------------------
+overrides = {
+ "SplitWallsAndColumns": False,
+ "ExportIFCCommonPropertySets": True,
+ "ExportBaseQuantities": False,
+ "TessellationLevelOfDetail": 0.5, # comma bug handled automatically
+ "VisibleElementsOfCurrentView": False,
+ "SitePlacement": 0, # 0=SharedCoordinates
+}
+
+# ---------------------------------------------------------------------------
+# Preview what options will be applied
+# ---------------------------------------------------------------------------
+output.print_md("## IFC Export Demo")
+output.print_md("**Document:** `{}`".format(doc.Title))
+output.print_md("**Output:** `{}`".format(os.path.join(folder, filename)))
+if config_path:
+ output.print_md("**Config:** `{}`".format(config_path))
+else:
+ output.print_md("**Config:** defaults only")
+
+output.print_md("**Runtime overrides:**")
+for k, v in sorted(overrides.items()):
+ output.print_md("- `{}` = `{}`".format(k, v))
+
+# ---------------------------------------------------------------------------
+# Run the export
+# ---------------------------------------------------------------------------
+exporter = IFCExporter(doc)
+
+try:
+ # IFC Exports have to happen in a Transaction. To avoid changes it can also be rolled back afterwards.
+ with revit.Transaction("IFC Export"):
+ success = exporter.export(
+ folder=folder,
+ filename=filename,
+ config_path=config_path,
+ overrides=overrides,
+ )
+ if success:
+ output.print_md("---\n**Export complete.**")
+ forms.alert(
+ "IFC exported successfully.\n\n{}".format(os.path.join(folder, filename)),
+ title="Done",
+ )
+ else:
+ output.print_md("---\n**Export returned False — check Revit journal.**")
+ forms.alert(
+ "Export returned False.\nCheck the Revit journal file for details.",
+ title="Export failed",
+ warn_icon=True,
+ )
+
+except Exception as ex:
+ output.print_md("---\n**Error:** `{}`".format(str(ex)))
+ forms.alert("Export error:\n\n{}".format(str(ex)), title="Error", warn_icon=True)
+ raise
diff --git a/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Developer Examples.panel/Settings Window.pushbutton/script.py b/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Developer Examples.panel/Settings Window.pushbutton/script.py
index 838884466..4daab6b5d 100644
--- a/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Developer Examples.panel/Settings Window.pushbutton/script.py
+++ b/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Developer Examples.panel/Settings Window.pushbutton/script.py
@@ -18,6 +18,16 @@
"label": "Show Warnings",
"default": True,
},
+ # Slider settings
+ {
+ "name": "transparency",
+ "type": "slider",
+ "label": "Transparency (%)",
+ "default": 10,
+ "min": 0,
+ "max": 100,
+ "step": 5,
+ },
# Choice settings (dropdown)
{
"name": "view_discipline",
@@ -33,6 +43,8 @@
"options": ["Coarse", "Medium", "Fine"],
"default": "Medium",
},
+ # Separator Line
+ {"type": "separator"},
# Integer settings with min/max validation
{
"name": "tolerance",
@@ -59,6 +71,8 @@
"min": 0.1,
"max": 10.0,
},
+ # Section with Label
+ {"type": "section", "label": "Display"},
# String settings
{"name": "prefix", "type": "string", "label": "Element Prefix", "default": ""},
{
diff --git a/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Developer Examples.panel/bundle.yaml b/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Developer Examples.panel/bundle.yaml
index 398b4ff59..f3a59ef6f 100644
--- a/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Developer Examples.panel/bundle.yaml
+++ b/extensions/pyRevitDevTools.extension/pyRevitDev.tab/Developer Examples.panel/bundle.yaml
@@ -11,4 +11,6 @@ layout:
- Inspect IExporter
- DirectContext3D
- Settings Window
- - Modeless
\ No newline at end of file
+ - Modeless
+ - DockablePane
+ - ExportIFC
\ No newline at end of file
diff --git a/extensions/pyRevitDevTools.extension/startup.py b/extensions/pyRevitDevTools.extension/startup.py
index d0fd3d8b4..2fa1a7695 100644
--- a/extensions/pyRevitDevTools.extension/startup.py
+++ b/extensions/pyRevitDevTools.extension/startup.py
@@ -27,25 +27,26 @@
# add your module paths to the sys.path here
# sys.path.append(r'path/to/your/module')
-print('Startup script execution test.')
-print('\n'.join(sys.path))
+print("Startup script execution test.")
+print("\n".join(sys.path))
# test imports from same directory and exensions lib
import startuplibimport
-print('lib/ import works in startup.py')
+
+print("lib/ import works in startup.py")
# test code for creating event handlers =======================================
# define event handler
def docopen_eventhandler(sender, args):
- forms.alert('Document Opened: {}'.format(args.PathName))
+ forms.alert("Document Opened: {}".format(args.PathName))
+
# add to DocumentOpening
# type is EventHandler[DocumentOpeningEventArgs] so create that correctly
-HOST_APP.app.DocumentOpening += \
- framework.EventHandler[DB.Events.DocumentOpeningEventArgs](
- docopen_eventhandler
- )
+HOST_APP.app.DocumentOpening += framework.EventHandler[
+ DB.Events.DocumentOpeningEventArgs
+](docopen_eventhandler)
# test code routes module =====================================================
@@ -53,95 +54,80 @@ def docopen_eventhandler(sender, args):
api = routes.API("pyrevit-dev")
-@api.route('/forms-block', methods=['POST'])
+@api.route("/forms-block", methods=["POST"])
def forms_blocking(doc):
"""Test blocking GUI"""
forms.alert("Routes works!")
- return 'Routes works!'
+ return "Routes works!"
-@api.route('/doc')
+@api.route("/doc")
def get_doc(doc):
"""Test API access: get active document title"""
- return {
- "title": doc.Title if doc else ""
- }
+ return {"title": doc.Title if doc else ""}
-@api.route('/doors/')
+@api.route("/doors/")
def get_doors(uiapp):
"""Test API access: find doors in active model"""
time.sleep(3)
- doors = revit.query.get_elements_by_categories(
- [DB.BuiltInCategory.OST_Doors]
- )
+ doors = revit.query.get_elements_by_categories([DB.BuiltInCategory.OST_Doors])
get_elementid_value = get_elementid_value_func()
doors_data = [get_elementid_value(x.Id) for x in doors]
- return routes.make_response(
- data=doors_data,
- headers={"pyRevit": "v4.6.7"}
- )
+ return routes.make_response(data=doors_data, headers={"pyRevit": "v4.6.7"})
-@api.route('/except')
+@api.route("/except")
def raise_except():
"""Test handler exception"""
- m = 12 / 0 #pylint: disable=unused-variable
+ m = 12 / 0 # pylint: disable=unused-variable
-@api.route('/reflect', methods=['POST'])
+@api.route("/reflect", methods=["POST"])
def reflect_request(request):
- return {
- "path": request.path,
- "method": request.method,
- "data": request.data
- }
+ return {"path": request.path, "method": request.method, "data": request.data}
-@api.route('/posts/')
+@api.route("/posts/")
def invalid_pattern():
# this must throw an error in routes
pass
-@api.route('/posts/')
+@api.route("/posts/")
def post_id(request, pid):
return {
"path": request.path,
"method": request.method,
- "data": {
- "post_id": pid,
- "post_id_type": type(pid).__name__
- }
+ "data": {"post_id": pid, "post_id_type": type(pid).__name__},
}
-@api.route('/posts/')
+@api.route("/posts/")
def post_uuid(request, pid):
return {
"path": request.path,
"method": request.method,
- "data": {
- "post_id": str(pid),
- "post_id_type": type(pid).__name__
- }
+ "data": {"post_id": str(pid), "post_id_type": type(pid).__name__},
}
-@api.route('/archive////posts/')
+
+@api.route("/archive////posts/")
def post_date_id(request, year, month, day, pid):
return {
"path": request.path,
"method": request.method,
"data": {
- "date": '{}/{}/{}'.format(year, month, day),
+ "date": "{}/{}/{}".format(year, month, day),
"post_id": pid,
- "post_id_type": type(pid).__name__
- }
+ "post_id_type": type(pid).__name__,
+ },
}
# test dockable panel =========================================================
+
class DockableExample(forms.WPFPanel):
panel_title = "pyRevit Dockable Panel Title"
panel_id = "3110e336-f81c-4927-87da-4e0d30d4d64a"
@@ -155,3 +141,77 @@ def do_something(self, sender, args):
forms.register_dockable_panel(DockableExample)
else:
print("Skipped registering dockable pane. Already exists.")
+
+
+# extended sample =========================================================
+
+
+class DeveloperSamplePanel(forms.WPFPanel):
+ """Sample dockable panel - one handler per API feature."""
+
+ panel_id = "759a2751-290a-4f7a-8f2d-9d900b2547b8"
+ panel_source = op.join(op.dirname(__file__), "SamplePanel.xaml")
+ panel_title = "pyRevit Sample Panel"
+
+ def __init__(self):
+ forms.WPFPanel.__init__(self)
+
+ # ---- pyrevit_version property ------------------------------------
+ self.version_label.Text = self.pyrevit_version
+
+ # ---- get_locale_string -------------------------------------------
+ # Populated automatically when a ResourceDictionary file is found
+ # (e.g. SamplePanel.ResourceDictionary.en_us.xaml).
+ try:
+ self.locale_label.Text = self.get_locale_string("SampleGreeting")
+ except Exception: # resource key absent - leave placeholder text
+ pass
+
+ # ---- hide_element / show_element / toggle_element --------------------
+
+ def hide_btn_clicked(self, sender, args):
+ """Collapse the target label - it no longer occupies layout space."""
+ self.hide_element(self.toggle_target)
+
+ def show_btn_clicked(self, sender, args):
+ """Restore the collapsed label."""
+ self.show_element(self.toggle_target)
+
+ def toggle_btn_clicked(self, sender, args):
+ """Flip visibility on every click."""
+ self.toggle_element(self.toggle_target)
+
+ # ---- disable_element / enable_element --------------------------------
+
+ def disable_btn_clicked(self, sender, args):
+ self.disable_element(self.target_button)
+
+ def enable_btn_clicked(self, sender, args):
+ self.enable_element(self.target_button)
+
+ # ---- dispatch --------------------------------------------------------
+ # Pattern: UI thread -> dispatch() -> background thread
+ # background thread -> dispatch() -> UI thread (label update)
+
+ def dispatch_btn_clicked(self, sender, args):
+ """Kick off background work without blocking the UI."""
+ self.dispatch_status.Text = "Working..."
+ self.dispatch(self._background_work) # called from UI thread -> spawns thread
+
+ def _background_work(self):
+ # Running on a background thread - never touch WPF elements here.
+ time.sleep(2)
+ # Hand the UI update back to the UI thread.
+ self.dispatch(self._finish_dispatch, "Done!") # called from bg thread -> Invoke
+
+ def _finish_dispatch(self, text):
+ # Back on the UI thread - safe to update elements.
+ self.dispatch_status.Text = text
+
+ # ---- handle_url_click ------------------------------------------------
+ # Inherited from _WPFMixin - no Python needed.
+ # Wire up in XAML: RequestNavigate="handle_url_click"
+
+
+if not forms.is_registered_dockable_panel(DeveloperSamplePanel):
+ forms.register_dockable_panel(DeveloperSamplePanel)
diff --git a/extensions/pyRevitTools.extension/lib/match/__init__.py b/extensions/pyRevitTools.extension/lib/match/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/extensions/pyRevitTools.extension/lib/match/clipboard_pane_ui.ResourceDictionary.chinese_s.xaml b/extensions/pyRevitTools.extension/lib/match/clipboard_pane_ui.ResourceDictionary.chinese_s.xaml
new file mode 100644
index 000000000..e1e261803
--- /dev/null
+++ b/extensions/pyRevitTools.extension/lib/match/clipboard_pane_ui.ResourceDictionary.chinese_s.xaml
@@ -0,0 +1,38 @@
+
+
+
+ 切换正则表达式/子字符串搜索
+
+
+ 全选
+ 取消全选
+ 全部切换
+
+
+ 参数
+ 值
+ 类别
+
+
+ 限制选取到元素类别
+ 启用时,矩形选取仅选择类别与选中参数匹配的元素
+
+
+ 元素参数
+ 选取元素并交互选择要复制的参数
+ 视图过滤器
+ 从活动视图读取所有等值过滤器参数值
+ 过滤器+元素
+ 从选取的元素读取最常见的过滤器参数值
+
+
+ 粘贴到一个
+ 反复选取单个元素并粘贴选中参数
+ 矩形粘贴
+ 反复绘制矩形并将选中参数粘贴到框选元素
+ 粘贴到选择集
+ 将选中参数粘贴到当前Revit选择集(单次)
+
+
diff --git a/extensions/pyRevitTools.extension/lib/match/clipboard_pane_ui.ResourceDictionary.de_de.xaml b/extensions/pyRevitTools.extension/lib/match/clipboard_pane_ui.ResourceDictionary.de_de.xaml
new file mode 100644
index 000000000..d524bd79a
--- /dev/null
+++ b/extensions/pyRevitTools.extension/lib/match/clipboard_pane_ui.ResourceDictionary.de_de.xaml
@@ -0,0 +1,38 @@
+
+
+
+ Regex / Teilstring-Suche umschalten
+
+
+ Alle markieren
+ Alle demarkieren
+ Alle umschalten
+
+
+ Parameter
+ Wert
+ Kategorie
+
+
+ Auswahl auf Elementkategorien beschränken
+ Wenn aktiviert, wählt die Rechteck-Auswahl nur Elemente, deren Kategorie mit den markierten Parametern übereinstimmt
+
+
+ Elem.-Param.
+ Element auswählen und Parameter interaktiv zum Kopieren wählen
+ Ansichtsfilter
+ Alle Gleichheitsfilter-Parameterwerte aus der aktiven Ansicht lesen
+ Filter+Elem.
+ Den häufigsten Filterparameterwert aus einem ausgewählten Element lesen
+
+
+ Einfügen (1)
+ Ein Element wiederholt auswählen und markierte Parameter einfügen
+ Einfügen (Rect)
+ Rechteck wiederholt zeichnen und markierte Parameter in enthaltene Elemente einfügen
+ Einfügen (Sel.)
+ Markierte Parameter in die aktuelle Revit-Auswahl einfügen (einmalig)
+
+
diff --git a/extensions/pyRevitTools.extension/lib/match/clipboard_pane_ui.ResourceDictionary.en_us.xaml b/extensions/pyRevitTools.extension/lib/match/clipboard_pane_ui.ResourceDictionary.en_us.xaml
new file mode 100644
index 000000000..c7336a6f4
--- /dev/null
+++ b/extensions/pyRevitTools.extension/lib/match/clipboard_pane_ui.ResourceDictionary.en_us.xaml
@@ -0,0 +1,38 @@
+
+
+
+ Toggle regex / substring search
+
+
+ Check All
+ Uncheck All
+ Toggle All
+
+
+ Parameter
+ Value
+ Category
+
+
+ Restrict pick to element categories
+ When enabled, rectangle-pick only selects elements whose category matches the checked parameters
+
+
+ Elem Params
+ Pick element and interactively choose parameters to copy
+ View Filters
+ Read all equals-filter parameter values from the active view
+ Filter+Elem
+ Read the most-common filter parameter value from a picked element
+
+
+ Paste One
+ Pick one element repeatedly and paste checked parameters
+ Paste Box
+ Draw rectangle repeatedly and paste checked parameters to enclosed elements
+ Paste Sel.
+ Paste checked parameters to the current Revit selection (one-shot)
+
+
diff --git a/extensions/pyRevitTools.extension/lib/match/clipboard_pane_ui.ResourceDictionary.es_es.xaml b/extensions/pyRevitTools.extension/lib/match/clipboard_pane_ui.ResourceDictionary.es_es.xaml
new file mode 100644
index 000000000..9a084f06d
--- /dev/null
+++ b/extensions/pyRevitTools.extension/lib/match/clipboard_pane_ui.ResourceDictionary.es_es.xaml
@@ -0,0 +1,38 @@
+
+
+
+ Alternar búsqueda regex / subcadena
+
+
+ Marcar todo
+ Desmarcar todo
+ Alternar todo
+
+
+ Parámetro
+ Valor
+ Categoría
+
+
+ Restringir selección a categorías de elemento
+ Cuando está activado, la selección rectangular solo selecciona elementos cuya categoría coincide con los parámetros marcados
+
+
+ Param. Elem.
+ Seleccionar elemento y elegir parámetros a copiar de forma interactiva
+ Filtros Vista
+ Leer todos los valores de parámetro de filtro de igualdad de la vista activa
+ Filtro+Elem.
+ Leer el valor de parámetro de filtro más común de un elemento seleccionado
+
+
+ Pegar uno
+ Seleccionar un elemento repetidamente y pegar los parámetros marcados
+ Pegar rect.
+ Dibujar rectángulo repetidamente y pegar parámetros marcados en los elementos contenidos
+ Pegar sel.
+ Pegar parámetros marcados en la selección actual de Revit (una sola vez)
+
+
diff --git a/extensions/pyRevitTools.extension/lib/match/clipboard_pane_ui.ResourceDictionary.fr_fr.xaml b/extensions/pyRevitTools.extension/lib/match/clipboard_pane_ui.ResourceDictionary.fr_fr.xaml
new file mode 100644
index 000000000..735f9f446
--- /dev/null
+++ b/extensions/pyRevitTools.extension/lib/match/clipboard_pane_ui.ResourceDictionary.fr_fr.xaml
@@ -0,0 +1,38 @@
+
+
+
+ Basculer recherche regex / sous-chaîne
+
+
+ Tout cocher
+ Tout décocher
+ Tout basculer
+
+
+ Paramètre
+ Valeur
+ Catégorie
+
+
+ Restreindre la sélection aux catégories d'éléments
+ Lorsqu'activé, la sélection rectangle sélectionne uniquement les éléments dont la catégorie correspond aux paramètres cochés
+
+
+ Param. Élém.
+ Sélectionner un élément et choisir interactivement les paramètres à copier
+ Filtres Vue
+ Lire toutes les valeurs de paramètre de filtre d'égalité depuis la vue active
+ Filtre+Élém.
+ Lire la valeur de paramètre de filtre la plus commune depuis un élément sélectionné
+
+
+ Coller un
+ Sélectionner un élément répétitivement et coller les paramètres cochés
+ Coller rect.
+ Dessiner un rectangle répétitivement et coller les paramètres cochés dans les éléments contenus
+ Coller sél.
+ Coller les paramètres cochés dans la sélection Revit actuelle (une seule fois)
+
+
diff --git a/extensions/pyRevitTools.extension/lib/match/clipboard_pane_ui.ResourceDictionary.pt_br.xaml b/extensions/pyRevitTools.extension/lib/match/clipboard_pane_ui.ResourceDictionary.pt_br.xaml
new file mode 100644
index 000000000..7c8014aad
--- /dev/null
+++ b/extensions/pyRevitTools.extension/lib/match/clipboard_pane_ui.ResourceDictionary.pt_br.xaml
@@ -0,0 +1,38 @@
+
+
+
+ Alternar pesquisa regex / substring
+
+
+ Marcar tudo
+ Desmarcar tudo
+ Alternar tudo
+
+
+ Parâmetro
+ Valor
+ Categoria
+
+
+ Restringir seleção às categorias de elemento
+ Quando ativado, a seleção retangular seleciona apenas elementos cuja categoria corresponde aos parâmetros marcados
+
+
+ Param. Elem.
+ Selecionar elemento e escolher parâmetros a copiar interativamente
+ Filtros Vista
+ Ler todos os valores de parâmetro de filtro de igualdade da vista ativa
+ Filtro+Elem.
+ Ler o valor de parâmetro de filtro mais comum de um elemento selecionado
+
+
+ Colar um
+ Selecionar um elemento repetidamente e colar os parâmetros marcados
+ Colar ret.
+ Desenhar retângulo repetidamente e colar parâmetros marcados nos elementos contidos
+ Colar seleção
+ Colar parâmetros marcados na seleção atual do Revit (uma vez)
+
+
diff --git a/extensions/pyRevitTools.extension/lib/match/clipboard_pane_ui.ResourceDictionary.ru.xaml b/extensions/pyRevitTools.extension/lib/match/clipboard_pane_ui.ResourceDictionary.ru.xaml
new file mode 100644
index 000000000..df07d2103
--- /dev/null
+++ b/extensions/pyRevitTools.extension/lib/match/clipboard_pane_ui.ResourceDictionary.ru.xaml
@@ -0,0 +1,38 @@
+
+
+
+ Переключить режим: регулярное / подстроковое выражение
+
+
+ Выбрать все
+ Снять все
+ Переключить все
+
+
+ Параметр
+ Значение
+ Категория
+
+
+ Ограничить выбор категориями элементов
+ Если включено, прямоугольный выбор захватывает только элементы, чья категория соответствует отмеченным параметрам
+
+
+ Пар. элемента
+ Выбрать элемент и интерактивно указать параметры для копирования
+ Фильтры вида
+ Считать все значения параметров фильтра равенства из активного вида
+ Фильтр+Элем.
+ Считать наиболее распространённое значение параметра фильтра из выбранного элемента
+
+
+ Вставить 1
+ Повторно выбирать один элемент и вставлять отмеченные параметры
+ Вставить рамкой
+ Повторно рисовать прямоугольник и вставлять параметры во все захваченные элементы
+ Вставить (выб.)
+ Вставить отмеченные параметры в текущий выбор Revit (однократно)
+
+
diff --git a/extensions/pyRevitTools.extension/lib/match/clipboard_pane_ui.xaml b/extensions/pyRevitTools.extension/lib/match/clipboard_pane_ui.xaml
new file mode 100644
index 000000000..ffe5462db
--- /dev/null
+++ b/extensions/pyRevitTools.extension/lib/match/clipboard_pane_ui.xaml
@@ -0,0 +1,242 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/extensions/pyRevitTools.extension/lib/match/filter_utils.py b/extensions/pyRevitTools.extension/lib/match/filter_utils.py
new file mode 100644
index 000000000..cd8dd1992
--- /dev/null
+++ b/extensions/pyRevitTools.extension/lib/match/filter_utils.py
@@ -0,0 +1,171 @@
+# -*- coding: utf-8 -*-
+from pyrevit.revit import query
+from pyrevit import DB
+from pyrevit.compat import get_elementid_value_func
+
+get_elementid_value = get_elementid_value_func()
+
+
+def dissect_parameter_filter(doc, filter_element):
+ """
+ Extract information from a simple equals ParameterFilterElement.
+
+ Returns a dict, or None if the filter is not a single equals rule.
+
+ Keys:
+ parameter_id – DB.ElementId of the parameter
+ parameter_name – human-readable name string
+ categories – list of category name strings
+ storage_type – DB.StorageType enum value
+ value – raw value (int / float / str / DB.ElementId)
+ display_value – formatted string for display
+ rule – the raw Revit filter rule object
+ """
+
+ result = {
+ "parameter_id": None,
+ "parameter_name": None,
+ "categories": [],
+ "storage_type": None,
+ "value": None,
+ "display_value": None,
+ "rule": None,
+ }
+
+ # ── categories ────────────────────────────────────────────────────
+ try:
+ for cid in filter_element.GetCategories():
+ bic = DB.BuiltInCategory(get_elementid_value(cid))
+ cat = doc.Settings.Categories.get_Item(bic)
+ if cat:
+ result["categories"].append(cat.Name)
+ except Exception:
+ pass
+
+ # ── unwrap to ElementParameterFilter ─────────────────────────────
+ element_filter = filter_element.GetElementFilter()
+
+ if isinstance(element_filter, (DB.LogicalAndFilter, DB.LogicalOrFilter)):
+ sub_filters = element_filter.GetFilters()
+ if len(sub_filters) != 1:
+ return None
+ element_filter = sub_filters[0]
+
+ if not isinstance(element_filter, DB.ElementParameterFilter):
+ return None
+
+ rules = element_filter.GetRules()
+ if len(rules) != 1:
+ return None
+
+ rule = rules[0]
+ result["rule"] = rule
+
+ # ── parameter id / name ───────────────────────────────────────────
+ param_id = rule.GetRuleParameter()
+ result["parameter_id"] = param_id
+
+ param_elem = doc.GetElement(param_id)
+ if param_elem:
+ result["parameter_name"] = param_elem.Name
+ else:
+ try:
+ bip = DB.BuiltInParameter(get_elementid_value(param_id))
+ result["parameter_name"] = DB.LabelUtils.GetLabelFor(bip)
+ except Exception:
+ result["parameter_name"] = str(get_elementid_value(param_id))
+
+ # ── evaluator must be equals ──────────────────────────────────────
+ evaluator = rule.GetEvaluator()
+ if not isinstance(evaluator, (DB.FilterNumericEquals, DB.FilterStringEquals)):
+ return None
+
+ # ── value extraction (sets storage_type as DB.StorageType enum) ───
+ if isinstance(rule, DB.FilterStringRule):
+ val = rule.RuleString
+ result["storage_type"] = DB.StorageType.String
+ result["value"] = val
+ result["display_value"] = val
+
+ elif isinstance(rule, DB.FilterDoubleRule):
+ val = rule.RuleValue
+ result["storage_type"] = DB.StorageType.Double
+ result["value"] = val
+ try:
+ spec = None
+ if param_elem:
+ spec = param_elem.GetDataType()
+ else:
+ try:
+ bip = DB.BuiltInParameter(get_elementid_value(param_id))
+ bics = [query.get_builtincategory(bic_name) for bic_name in result["categories"]]
+ collector = query.get_elements_by_categories(bics)
+ elem = next(iter(collector), None)
+ param = elem.get_Parameter(bip) if elem else None
+ if param:
+ spec = param.Definition.GetDataType()
+ except Exception:
+ pass
+ display = DB.UnitFormatUtils.Format(
+ doc.GetUnits(), spec, val, False
+ )
+ except Exception:
+ display = str(val)
+ result["display_value"] = display
+
+ elif isinstance(rule, DB.FilterIntegerRule):
+ val = rule.RuleValue
+ result["storage_type"] = DB.StorageType.Integer
+ result["value"] = val
+ # special case: workset parameter
+ if get_elementid_value(param_id) == int(
+ DB.BuiltInParameter.ELEM_PARTITION_PARAM
+ ):
+ try:
+ ws = doc.GetWorksetTable().GetWorkset(DB.WorksetId(val))
+ result["display_value"] = ws.Name if ws else str(val)
+ except Exception:
+ result["display_value"] = str(val)
+ else:
+ result["display_value"] = str(val)
+
+ elif isinstance(rule, DB.FilterElementIdRule):
+ val = rule.RuleValue
+ result["storage_type"] = DB.StorageType.ElementId
+ result["value"] = val
+ elem = doc.GetElement(val)
+ if elem:
+ try:
+ result["display_value"] = elem.Name
+ except Exception:
+ result["display_value"] = str(get_elementid_value(val))
+ else:
+ result["display_value"] = str(get_elementid_value(val))
+
+ else:
+ return None
+
+ return result
+
+
+def get_most_common_filter_parameter(doc, view):
+ """
+ Return the ElementId of the parameter used most often in simple
+ equals filters on the given view. Returns None if none found.
+ """
+ param_count = {}
+
+ for fid in view.GetFilters():
+ filter_elem = doc.GetElement(fid)
+ if not isinstance(filter_elem, DB.ParameterFilterElement):
+ continue
+ info = dissect_parameter_filter(doc, filter_elem)
+ if not info:
+ continue
+ pid = info["parameter_id"]
+ param_count[pid] = param_count.get(pid, 0) + 1
+
+ if not param_count:
+ return None
+
+ return max(param_count, key=param_count.get)
diff --git a/extensions/pyRevitTools.extension/lib/match/match_utils.py b/extensions/pyRevitTools.extension/lib/match/match_utils.py
new file mode 100644
index 000000000..8e97d21bf
--- /dev/null
+++ b/extensions/pyRevitTools.extension/lib/match/match_utils.py
@@ -0,0 +1,224 @@
+# -*- coding: utf-8 -*-
+from pyrevit import script, forms, revit
+from pyrevit import DB, UI
+from pyrevit.compat import get_elementid_value_func
+
+get_elementid_value = get_elementid_value_func()
+
+logger = script.get_logger()
+
+
+def safe_get_parameter(elem, param_id):
+ if not param_id:
+ return None
+
+ try:
+ pid_val = get_elementid_value(param_id)
+
+ # BuiltInParameter (negative ids)
+ if pid_val < 0:
+ bip = DB.BuiltInParameter(pid_val)
+ return elem.get_Parameter(bip)
+
+ # Shared / Project Parameter
+ doc = elem.Document
+ param_el = doc.GetElement(param_id)
+
+ if not param_el:
+ return None
+
+ # Prefer GUID
+ if hasattr(param_el, "GuidValue"):
+ guid = param_el.GuidValue
+ if guid:
+ return elem.get_Parameter(guid)
+
+ # Fallback - this should not be entered, as this would mean a non-filterable parameter
+ definition = (
+ param_el.GetDefinition() if hasattr(param_el, "GetDefinition") else None
+ )
+ if definition:
+ return param_el.get_Parameter(definition)
+
+ except Exception:
+ return None
+
+
+class PickByCategorySelectionFilter(UI.Selection.ISelectionFilter):
+ def __init__(self, category_ids):
+ self.category_ids = category_ids
+
+ # standard API override function
+ def AllowElement(self, element):
+ if element.Category and element.Category.Id in self.category_ids:
+ return True
+ else:
+ return False
+
+ # standard API override function
+ def AllowReference(self, refer, point): # pylint: disable=W0613
+ return False
+
+
+class PropKeyValue(object):
+ """Storage class for matched property info and value."""
+
+ def __init__(
+ self, name, datatype, value, istype, display_value=None, categories=None
+ ):
+ self.name = name
+ self.datatype = datatype
+ self.value = value
+ self.istype = istype
+ self.display_value = display_value or name
+ self.categories = categories if categories is not None else []
+
+ def __repr__(self):
+ return str(self.__dict__)
+
+
+def match_prop(dest_inst, dest_type, src_props):
+ """Match given properties on target instance or type"""
+ for pkv in src_props:
+ logger.debug("Applying %s", pkv.name)
+
+ # determine target
+ target = dest_type if pkv.istype else dest_inst
+ # ensure target is valid if it is type
+ if pkv.istype and not target:
+ logger.warning("Element type is not accessible.")
+ continue
+ logger.debug("Target is %s", target)
+
+ # find parameter
+ dparam = target.LookupParameter(pkv.name)
+ if dparam and pkv.datatype == dparam.StorageType:
+ try:
+ if dparam.StorageType == DB.StorageType.Integer:
+ dparam.Set(pkv.value or 0)
+ elif dparam.StorageType == DB.StorageType.Double:
+ dparam.Set(pkv.value or 0.0)
+ elif dparam.StorageType == DB.StorageType.ElementId:
+ if not isinstance(pkv.value, DB.ElementId):
+ dparam.Set(DB.ElementId(pkv.value))
+ else:
+ dparam.Set(pkv.value)
+ else:
+ dparam.Set(pkv.value or "")
+ except Exception as setex:
+ logger.warning("Error applying value to: %s | %s", pkv.name, setex)
+ continue
+ else:
+ logger.debug('Parameter "%s"not found on target.', pkv.name)
+
+
+def get_source_properties(src_element, simple=False):
+ """Return info on selected properties."""
+ props = []
+
+ src_type = revit.query.get_type(src_element)
+
+ selected_params = (
+ forms.select_parameters(
+ src_element,
+ title="Select Parameters",
+ multiple=True,
+ include_instance=True,
+ include_type=True,
+ )
+ or []
+ )
+
+ logger.debug("Selected parameters: %s", [x.name for x in selected_params])
+
+ for sparam in selected_params:
+ logger.debug("Reading %s", sparam.name)
+ target = src_type if sparam.istype else src_element
+ tparam = target.LookupParameter(sparam.name)
+ if tparam:
+ if tparam.StorageType == DB.StorageType.Integer:
+ value = tparam.AsInteger()
+ elif tparam.StorageType == DB.StorageType.Double:
+ value = tparam.AsDouble()
+ elif tparam.StorageType == DB.StorageType.ElementId:
+ value = get_elementid_value(tparam.AsElementId())
+ else:
+ value = tparam.AsString()
+
+ props.append(
+ PropKeyValue(
+ name=sparam.name,
+ datatype=tparam.StorageType,
+ value=value,
+ istype=sparam.istype,
+ display_value=tparam.AsValueString() if not simple else None,
+ categories=[src_element.Category] if not simple else [],
+ )
+ )
+
+ return props
+
+
+def paste_props(source_props, paste_mode, category_filter=False):
+ """
+ Core paste routine — runs inside an ExternalEvent (Revit API context).
+ paste_mode: "single" | "rectangle" | "selection"
+ """
+ # Build category filter if the checkbox is ticked and categories are known
+ pick_filter = None
+ if category_filter:
+ cat_ids = set()
+ for p in source_props:
+ for c in p.categories or []:
+ if hasattr(c, "Id"):
+ cat_ids.add(c.Id)
+ if cat_ids:
+ pick_filter = PickByCategorySelectionFilter(list(cat_ids))
+
+ # Status-bar message shown to the user while picking
+ if len(source_props) == 1:
+ title = "Match: {} = {}".format(
+ source_props[0].name,
+ source_props[0].display_value or str(source_props[0].value),
+ )
+ else:
+ title = "Pick elements to match {} parameter(s):".format(len(source_props))
+
+ with forms.WarningBar(title=title):
+ while True:
+ dest_elements = []
+
+ if paste_mode == "single":
+ elem = revit.pick_element(pick_filter=pick_filter)
+ if elem:
+ dest_elements = [elem]
+
+ elif paste_mode == "rectangle":
+ try:
+ dest_elements = revit.pick_rectangle(pick_filter=pick_filter)
+ except Exception:
+ # To handle esc by user, this would throw a OperationCanceledException
+ pass
+
+ elif paste_mode == "selection":
+ dest_elements = list(revit.get_selection())
+ if category_filter:
+ dest_elements = [
+ el for el in dest_elements
+ if el.Category and el.Category.Id in cat_ids
+ ]
+
+ if not dest_elements:
+ break # user cancelled / nothing selected
+
+ for dest in dest_elements:
+ dest_type = revit.query.get_type(dest)
+ with revit.Transaction("Match Properties"):
+ # type parameters first so instance params can reference them
+ match_prop(dest, dest_type, [p for p in source_props if p.istype])
+ match_prop(
+ dest, dest_type, [p for p in source_props if not p.istype]
+ )
+
+ if paste_mode == "selection":
+ break # selection is one-shot, not a pick loop
diff --git a/extensions/pyRevitTools.extension/lib/match/panel.py b/extensions/pyRevitTools.extension/lib/match/panel.py
new file mode 100644
index 000000000..b0246c585
--- /dev/null
+++ b/extensions/pyRevitTools.extension/lib/match/panel.py
@@ -0,0 +1,342 @@
+# -*- coding: utf-8 -*-
+import re
+
+from pyrevit import script, forms, revit, op
+from pyrevit import UI
+from pyrevit.revit.events import _GenericExternalEventHandler
+from pyrevit.framework import ComponentModel
+
+from match_utils import (
+ PropKeyValue,
+ get_source_properties,
+ safe_get_parameter,
+ paste_props
+)
+from filter_utils import get_most_common_filter_parameter, dissect_parameter_filter
+
+logger = script.get_logger()
+
+MAX_HISTORY_ITEMS = 50
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# INotifyPropertyChanged base — required for two-way checkbox binding in WPF
+# ─────────────────────────────────────────────────────────────────────────────
+
+
+class _INotifyBase(ComponentModel.INotifyPropertyChanged):
+ def __init__(self):
+ self._handlers = []
+
+ def add_PropertyChanged(self, handler):
+ self._handlers.append(handler)
+
+ def remove_PropertyChanged(self, handler):
+ if handler in self._handlers:
+ self._handlers.remove(handler)
+
+ def _notify(self, prop_name):
+ ev_args = ComponentModel.PropertyChangedEventArgs(prop_name)
+ for h in self._handlers:
+ h(self, ev_args)
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# WPF-bindable list-view row backed by a PropKeyValue
+# ─────────────────────────────────────────────────────────────────────────────
+
+
+class ParameterItem(_INotifyBase):
+ """One row in the history list view."""
+
+ def __init__(self, pkv):
+ _INotifyBase.__init__(self)
+ self._pkv = pkv
+ self._selected = False
+
+ # -- display properties (read by WPF bindings) --
+
+ @property
+ def Name(self):
+ return self._pkv.name or ""
+
+ @property
+ def DisplayValue(self):
+ dv = self._pkv.display_value
+ if dv is None:
+ return str(self._pkv.value) if self._pkv.value is not None else ""
+ return dv
+
+ @property
+ def Category(self):
+ """Single category name, 'multiple', or em-dash when unknown."""
+ cats = self._pkv.categories or []
+ if not cats:
+ return "unknown"
+ if len(cats) == 1:
+ c = cats[0]
+ return c.Name if hasattr(c, "Name") else str(c)
+ return "multiple"
+
+ # -- checkable state with WPF change notification --
+
+ @property
+ def IsSelected(self):
+ return self._selected
+
+ @IsSelected.setter
+ def IsSelected(self, value):
+ if self._selected != value:
+ self._selected = value
+ self._notify("IsSelected")
+
+ # -- access to the underlying PropKeyValue for paste operations --
+
+ @property
+ def source_prop(self):
+ return self._pkv
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Dockable pane
+# ─────────────────────────────────────────────────────────────────────────────
+
+
+class MatchHistoryClipboard(forms.WPFPanel):
+ panel_title = "pyRevit MatchHistory Clipboard"
+ panel_id = "0f3a0866-0123-4178-9f2c-121961bd292c"
+ panel_source = op.join(op.dirname(__file__), "clipboard_pane_ui.xaml")
+
+ def __init__(self):
+ forms.WPFPanel.__init__(self)
+ self._handler = _GenericExternalEventHandler()
+ self._ext_event = UI.ExternalEvent.Create(self._handler)
+ self._items = [] # ordered list of ParameterItem (full history)
+
+ # ── external-event plumbing ──────────────────────────────────────────────
+
+ def _run_in_revit(self, func, *args, **kwargs):
+ """Schedule func(*args, **kwargs) to run in the next Revit event loop."""
+ self._handler.func = func
+ self._handler.args = args
+ self._handler.kwargs = kwargs
+ self._ext_event.Raise()
+
+ # ── history management ───────────────────────────────────────────────────
+
+ def _add_to_history(self, props):
+ """
+ Prepend props to history, uncheck everything, enforce MAX_HISTORY_ITEMS.
+ Called after any of the three load-source actions.
+ """
+ if not props:
+ return
+ new_items = [ParameterItem(p) for p in props]
+ self._items = (new_items + self._items)[:MAX_HISTORY_ITEMS]
+ for item in self._items:
+ item.IsSelected = False
+ self._refresh_list()
+ self._update_ui_state()
+
+ def _selected_props(self):
+ """Return PropKeyValue objects for every checked history row."""
+ return [item.source_prop for item in self._items if item.IsSelected]
+
+ # ── list display / search filtering ─────────────────────────────────────
+
+ def _refresh_list(self, search_text=None):
+ """
+ Rebuild ListView.ItemsSource.
+ With no search_text the full history is shown.
+ With search_text either regex or substring match is applied to
+ both the parameter name and display value.
+ """
+ if not search_text:
+ self.paramListView.ItemsSource = list(self._items)
+ return
+
+ use_regex = bool(self.regexToggle_b.IsChecked)
+ if use_regex:
+ try:
+ pat = re.compile(search_text, re.IGNORECASE)
+ items = [
+ i
+ for i in self._items
+ if pat.search(i.Name) or pat.search(i.DisplayValue)
+ ]
+ except re.error:
+ items = list(self._items) # invalid pattern → show all
+ else:
+ low = search_text.lower()
+ items = [
+ i
+ for i in self._items
+ if low in i.Name.lower() or low in i.DisplayValue.lower()
+ ]
+
+ self.paramListView.ItemsSource = items
+
+ def _set_check_states(self, state=None, flip=False):
+ """
+ Apply a uniform check state to all visible rows.
+ Deduplication: only the first occurrence of each parameter name
+ is checked; later duplicates are forced unchecked.
+ """
+ seen = set()
+ source = self.paramListView.ItemsSource or []
+ for item in source:
+ if item.Name not in seen:
+ seen.add(item.Name)
+ item.IsSelected = (not item.IsSelected) if flip else state
+ else:
+ item.IsSelected = False
+ self._update_ui_state()
+
+ def _update_ui_state(self):
+ """Enable paste buttons only when at least one row is checked."""
+ has_checked = any(i.IsSelected for i in self._items)
+ self.pasteSingleBtn.IsEnabled = has_checked
+ self.pasteRectBtn.IsEnabled = has_checked
+ self.pasteSelBtn.IsEnabled = has_checked
+
+ # ── load-source handlers (Button Click events from XAML) ─────────────────
+ # NOTE: pick_element / get_source_properties are called directly here
+ # (not via _run_in_revit) because pyrevit's WPFPanel allows Revit picks
+ # from WPF event handlers. Only write-operations (Transactions) require
+ # the ExternalEvent mechanism.
+
+ def load_from_element(self, sender, args):
+ """Pick an element, choose parameters interactively, add to history."""
+ sel = revit.get_selection()
+ elem = sel[0] if len(sel) == 1 else revit.pick_element()
+ if not elem:
+ return
+ props = get_source_properties(elem) # opens pyrevit parameter-picker dialog
+ count = len(props)
+ self._add_to_history(props)
+ for i in range(min(count, len(self._items))):
+ self._items[i].IsSelected = True
+ self._update_ui_state()
+
+ def load_from_view_filters(self, sender, args):
+ """Read all equals-filter parameter values from the active view."""
+ view_filters = revit.query.get_view_filters(revit.active_view)
+ props = []
+ for f in view_filters:
+ info = dissect_parameter_filter(revit.doc, f)
+ if not info:
+ continue
+ props.append(
+ PropKeyValue(
+ name=info["parameter_name"],
+ datatype=info["storage_type"],
+ value=info["value"],
+ istype=False,
+ display_value=info["display_value"],
+ categories=info["categories"],
+ )
+ )
+ self._add_to_history(props)
+
+ def load_from_filter_and_element(self, sender, args):
+ """
+ Read the value of the most-common filter parameter from a picked element.
+ Useful for quickly setting up a match from a 'key' parameter.
+ """
+ param_id = get_most_common_filter_parameter(revit.doc, revit.active_view)
+ if not param_id:
+ logger.warning("No simple equals filter found on active view.")
+ return
+ sel = revit.get_selection()
+ elem = sel[0] if len(sel) == 1 else revit.pick_element()
+ if not elem:
+ return
+ try:
+ tparam = safe_get_parameter(elem, param_id)
+ if not tparam:
+ return
+ value = revit.query.get_param_value(tparam)
+ props = [
+ PropKeyValue(
+ name=tparam.Definition.Name,
+ datatype=tparam.StorageType,
+ value=value,
+ istype=False,
+ display_value=tparam.AsValueString() or str(value),
+ categories=[elem.Category]
+ )
+ ]
+ self._add_to_history(props)
+ self._items[0].IsSelected = True
+ self._update_ui_state()
+ except Exception as ex:
+ logger.warning("load_from_filter_and_element: %s", ex)
+
+ # ── paste handlers ───────────────────────────────────────────────────────
+
+ def paste_single(self, sender, args):
+ """Paste checked parameters by picking elements one at a time (loops)."""
+ props = self._selected_props()
+ if props:
+ self._run_in_revit(paste_props, props, "single", bool(self.categoryFilterCheck.IsChecked))
+
+ def paste_rectangle(self, sender, args):
+ """Paste checked parameters to elements inside a drawn rectangle (loops)."""
+ props = self._selected_props()
+ if props:
+ self._run_in_revit(paste_props, props, "rectangle", bool(self.categoryFilterCheck.IsChecked))
+
+ def paste_selection(self, sender, args):
+ """Paste checked parameters to the current Revit selection (one-shot)."""
+ props = self._selected_props()
+ if props:
+ self._run_in_revit(paste_props, props, "selection", bool(self.categoryFilterCheck.IsChecked))
+
+ # ── check / search UI handlers ───────────────────────────────────────────
+
+ def check_all(self, sender, args):
+ self._set_check_states(state=True)
+
+ def uncheck_all(self, sender, args):
+ self._set_check_states(state=False)
+
+ def toggle_all(self, sender, args):
+ self._set_check_states(flip=True)
+
+ def toggle_regex(self, sender, args):
+ """Switch between substring and regex search; swap the button icon."""
+ if bool(self.regexToggle_b.IsChecked):
+ self.regexToggle_b.Content = self.Resources["regexIcon"]
+ else:
+ self.regexToggle_b.Content = self.Resources["filterIcon"]
+ text = self.search_tb.Text.strip()
+ self._refresh_list(search_text=text if text else None)
+ self.search_tb.Focus()
+
+ def clear_search(self, sender, args):
+ self.search_tb.Text = ""
+ self.search_tb.Focus()
+
+ def search_changed(self, sender, args):
+ """TextChanged handler — show/hide the clear button, refresh list."""
+ text = self.search_tb.Text
+ if text:
+ self.show_element(self.clrsearch_b)
+ else:
+ self.hide_element(self.clrsearch_b)
+ stripped = text.strip()
+ self._refresh_list(search_text=stripped if stripped else None)
+
+ def checkbox_click(self, sender, args):
+ """
+ When a row is checked, uncheck all other rows that share the same
+ parameter name — prevents duplicate parameters being applied twice.
+ """
+ clicked = sender.DataContext
+ if not clicked:
+ return
+ if clicked.IsSelected:
+ for item in self._items:
+ if item is not clicked and item.Name == clicked.Name:
+ item.IsSelected = False
+ self._update_ui_state()
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Analysis.panel/Tools.stack/Inspect.pulldown/Compare Detail Views.deprecate/script.py b/extensions/pyRevitTools.extension/pyRevit.tab/Analysis.panel/Tools.stack/Inspect.pulldown/Compare Detail Views.deprecate/script.py
index 51ef99e46..34f3122b3 100644
--- a/extensions/pyRevitTools.extension/pyRevit.tab/Analysis.panel/Tools.stack/Inspect.pulldown/Compare Detail Views.deprecate/script.py
+++ b/extensions/pyRevitTools.extension/pyRevit.tab/Analysis.panel/Tools.stack/Inspect.pulldown/Compare Detail Views.deprecate/script.py
@@ -1,4 +1,4 @@
-from pyrevit import revit, DB, UI
+from pyrevit import revit, DB, EXEC_PARAMS
from pyrevit import forms
import diffutils
@@ -16,7 +16,7 @@
comp = diffutils.compare_views(revit.doc,
view_list[0],
view_list[1],
- compare_types=__shiftclick__,
+ compare_types=EXEC_PARAMS.config_mode,
diff_results=res)
forms.alert('Views are smiliar (not identical).'
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Analysis.panel/Tools.stack/Inspect.pulldown/LinesPerViewCounter.pushbutton/LinesPerViewCounter_script.py b/extensions/pyRevitTools.extension/pyRevit.tab/Analysis.panel/Tools.stack/Inspect.pulldown/LinesPerViewCounter.pushbutton/LinesPerViewCounter_script.py
index d1822019a..9a4fa8a65 100644
--- a/extensions/pyRevitTools.extension/pyRevit.tab/Analysis.panel/Tools.stack/Inspect.pulldown/LinesPerViewCounter.pushbutton/LinesPerViewCounter_script.py
+++ b/extensions/pyRevitTools.extension/pyRevit.tab/Analysis.panel/Tools.stack/Inspect.pulldown/LinesPerViewCounter.pushbutton/LinesPerViewCounter_script.py
@@ -2,7 +2,7 @@
from collections import defaultdict
from pyrevit import script
-from pyrevit import revit, DB
+from pyrevit import revit, DB, EXEC_PARAMS
from pyrevit.compat import get_elementid_value_func
@@ -62,7 +62,7 @@ def line_count(document=doc):
if __name__ == '__main__':
output.print_md("\n\n# LINES PER VIEW IN CURRENT DOCUMENT\n___\n\n")
line_count()
- if __shiftclick__:
+ if EXEC_PARAMS.config_mode:
output.print_md("\n\n# LINES PER VIEW IN LINKS\n___\n\n")
revit_links = DB.FilteredElementCollector(doc).OfClass(DB.RevitLinkInstance).ToElements()
for link in revit_links:
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Drawing Set.panel/Keynotes.pushbutton/EditRecord.xaml b/extensions/pyRevitTools.extension/pyRevit.tab/Drawing Set.panel/Keynotes.pushbutton/EditRecord.xaml
index 44d954204..383621d40 100644
--- a/extensions/pyRevitTools.extension/pyRevit.tab/Drawing Set.panel/Keynotes.pushbutton/EditRecord.xaml
+++ b/extensions/pyRevitTools.extension/pyRevit.tab/Drawing Set.panel/Keynotes.pushbutton/EditRecord.xaml
@@ -1,85 +1,85 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Drawing Set.panel/Keynotes.pushbutton/KeynoteManagerWindow.xaml b/extensions/pyRevitTools.extension/pyRevit.tab/Drawing Set.panel/Keynotes.pushbutton/KeynoteManagerWindow.xaml
index a56816a84..a64c7d307 100644
--- a/extensions/pyRevitTools.extension/pyRevit.tab/Drawing Set.panel/Keynotes.pushbutton/KeynoteManagerWindow.xaml
+++ b/extensions/pyRevitTools.extension/pyRevit.tab/Drawing Set.panel/Keynotes.pushbutton/KeynoteManagerWindow.xaml
@@ -1,470 +1,496 @@
+ Title="Keynote Manager"
+ Closing="window_closing"
+ KeyDown="window_keydown">
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
-
-
-
+
+
-
-
+
+
+
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ ItemTemplate="{StaticResource KeynoteTreeItem}"
+ ItemContainerStyle="{StaticResource TreeItemContainer}"
+ SelectedItemChanged="selected_keynote_changed"
+ PreviewMouseLeftButtonDown="tree_preview_mouse_down"
+ PreviewMouseMove="tree_preview_mouse_move"
+ PreviewMouseDoubleClick="tree_double_click"
+ AllowDrop="True"
+ DragOver="tree_drag_over"
+ Drop="tree_drop"/>
+
+
+
+
+
+
+
+
+
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Drawing Set.panel/Keynotes.pushbutton/bundle.yaml b/extensions/pyRevitTools.extension/pyRevit.tab/Drawing Set.panel/Keynotes.pushbutton/bundle.yaml
index f7c0d19ed..9eb60054f 100644
--- a/extensions/pyRevitTools.extension/pyRevit.tab/Drawing Set.panel/Keynotes.pushbutton/bundle.yaml
+++ b/extensions/pyRevitTools.extension/pyRevit.tab/Drawing Set.panel/Keynotes.pushbutton/bundle.yaml
@@ -1,59 +1,61 @@
-help_url: >-
- https://www.notion.so/pyrevitlabs/Manage-Keynotes-6f083d6f66fe43d68dc5d5407c8e19da
-tooltip:
- en_us: |-
- Manage project keynotes.
-
- Shift+Click
-
- Reset window configurations and open.
- fr_fr: |-
- Gérer les notes d'identification du projet.
- Shift+Click
- Remise à zéro des fenêtre de configurations et ouvrir.
- ru: |-
- Управление ключевыми пометками проекта.
-
- Shift+Click
-
- Сбрасывает настройки и открывает окно.
- chinese_s: "管理项目注释\n\nShift+单击\n\n重置窗口配置并打开"
- es_es: |-
- Gestionar las notas clave del proyecto.
-
- Shift+Clic
-
- Restablecer configuraciones de ventana y abrir.
- de_de: |-
- Bauelementschlüssel vom Projekt verwalten.
-
- Shift+Click
-
- Fenster Konfiguration zurücksetzen und öffnen.
- pt_br: |-
- Gerencie as notas-chave do projeto.
-
- Shift+Clique
-
- Redefinir configurações da janela e abrir.
-title:
- en_us: |-
- Manage
- Keynotes
- fr_fr: |-
- Notes
- d'identification
- ru: |-
- Править КП
- chinese_s: 管理注释
- es_es: |-
- Gestionar
- Notas clave
- de_de: |-
- Bauelementschlüssel
- verwalten
- pt_br: |-
- Gerenciar
- Notas-Chave
-min_revit_version: 2014
-author: '{{author}}'
+help_url: >-
+ https://www.notion.so/pyrevitlabs/Manage-Keynotes-6f083d6f66fe43d68dc5d5407c8e19da
+tooltip:
+ en_us: |-
+ Manage project keynotes.
+
+ Shift+Click
+
+ Reset window configurations and open.
+ fr_fr: |-
+ Gérer les notes d'identification du projet.
+ Shift+Click
+ Remise à zéro des fenêtre de configurations et ouvrir.
+ ru: |-
+ Управление ключевыми пометками проекта.
+
+ Shift+Click
+
+ Сбрасывает настройки и открывает окно.
+ chinese_s: "管理项目注释\n\nShift+单击\n\n重置窗口配置并打开"
+ es_es: |-
+ Gestionar las notas clave del proyecto.
+
+ Shift+Clic
+
+ Restablecer configuraciones de ventana y abrir.
+ de_de: |-
+ Bauelementschlüssel vom Projekt verwalten.
+
+ Shift+Click
+
+ Fenster Konfiguration zurücksetzen und öffnen.
+ pt_br: |-
+ Gerencie as notas-chave do projeto.
+
+ Shift+Clique
+
+ Redefinir configurações da janela e abrir.
+title:
+ en_us: |-
+ Manage
+ Keynotes
+ fr_fr: |-
+ Notes
+ d'identification
+ ru: |-
+ Править КП
+ chinese_s: 管理注释
+ es_es: |-
+ Gestionar
+ Notas clave
+ de_de: |-
+ Bauelementschlüssel
+ verwalten
+ pt_br: |-
+ Gerenciar
+ Notas-Chave
+min_revit_version: 2014
+author: |-
+ Ehsan Iran-Nejad (Original Author)
+ Tay Othman (Maintainer + UI)
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Drawing Set.panel/Keynotes.pushbutton/keynotesdb.py b/extensions/pyRevitTools.extension/pyRevit.tab/Drawing Set.panel/Keynotes.pushbutton/keynotesdb.py
index aa6c90d3a..c74b1a52b 100644
--- a/extensions/pyRevitTools.extension/pyRevit.tab/Drawing Set.panel/Keynotes.pushbutton/keynotesdb.py
+++ b/extensions/pyRevitTools.extension/pyRevit.tab/Drawing Set.panel/Keynotes.pushbutton/keynotesdb.py
@@ -241,7 +241,10 @@ def update_used(self, used_keysdict, doc=None):
self.used = True
self.used_count = len(used_keysdict[self.key])
for keyid in used_keysdict[self.key]:
- owner_view = doc.GetElement(doc.GetElement(keyid).OwnerViewId)
+ kel = doc.GetElement(keyid)
+ if not kel:
+ continue
+ owner_view = doc.GetElement(kel.OwnerViewId)
view_name = revit.query.get_name(owner_view)
self.tooltip += '\n' + view_name
@@ -576,7 +579,7 @@ def import_legacy_keynotes(conn, src_legacy_keynotes_file, skip_dup=False):
else:
continue
- if legacy_kfile:
+ if knote_lines:
conn.BEGIN(KEYNOTES_DB)
try:
mlogger.debug('Importing categories and keynotes...')
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Drawing Set.panel/Keynotes.pushbutton/script.py b/extensions/pyRevitTools.extension/pyRevit.tab/Drawing Set.panel/Keynotes.pushbutton/script.py
index 1919114fa..1cac07888 100644
--- a/extensions/pyRevitTools.extension/pyRevit.tab/Drawing Set.panel/Keynotes.pushbutton/script.py
+++ b/extensions/pyRevitTools.extension/pyRevit.tab/Drawing Set.panel/Keynotes.pushbutton/script.py
@@ -1,4 +1,13 @@
-"""Manage project keynotes.
+# -*- coding: utf-8 -*-
+"""Manage project keynotes — unified tree with hierarchy controls.
+
+Features:
+- Single hierarchical tree (no separate category sidebar)
+- Indent / Outdent to promote or demote nodes (Tab / Shift+Tab)
+- Move Up / Move Down to reorder siblings (Ctrl+Up / Ctrl+Down)
+- Drag-and-drop to reparent across the tree
+- Search with smart filters
+- Keyboard shortcuts (F2, F5, Ctrl+N, Ctrl+D, Del, Tab, Shift+Tab)
Shift+Click:
Reset window configurations and open.
@@ -9,49 +18,221 @@
import os.path as op
import shutil
import math
-from collections import defaultdict
+import uuid
+from collections import defaultdict, OrderedDict
from natsort import natsorted
from pyrevit import HOST_APP
from pyrevit import framework
-from pyrevit.framework import System
from pyrevit import coreutils
from pyrevit import revit, DB, UI
from pyrevit import forms
from pyrevit import script
+from pyrevit.framework import System, Windows
+from System.Windows.Interop import WindowInteropHelper, HwndSource
+from System.Diagnostics import Process as SysProcess
+from System.Windows.Threading import DispatcherTimer
+from System import TimeSpan
+
from pyrevit.runtime.types import DocumentEventUtils
from pyrevit.interop import adc
import keynotesdb as kdb
+__persistentengine__ = True
+
logger = script.get_logger()
output = script.get_output()
-HELP_URL = "https://www.notion.so/pyrevitlabs/Manage-Keynotes-6f083d6f66fe43d68dc5d5407c8e19da"
+# =============================================================================
+# ADC MONKEY-PATCH — fix ReadOnlyList subscripting on .NET Framework
+# =============================================================================
+# pyRevit's adc.py uses [0] on .NET ReadOnlyList objects returned by the
+# Desktop Connector API. This works on .NET 8 (Revit 2025+) but fails on
+# .NET Framework 4.x (Revit 2023/2024) because IronPython can't subscript
+# ReadOnlyList[T] with []. The fix: iterate or use .Item[0] / LINQ First().
+
+def _safe_first(collection):
+ """Safely get first element from a .NET collection that may not
+ support Python [] subscripting (ReadOnlyList, IList, etc.)."""
+ if collection is None:
+ return None
+ # Try normal indexing first (.NET 8 / CPython)
+ try:
+ return collection[0]
+ except TypeError:
+ pass
+ # Try .Item[] indexer (.NET Framework generic collections)
+ try:
+ return collection.Item[0]
+ except (TypeError, AttributeError):
+ pass
+ # Fall back to iteration
+ try:
+ for item in collection:
+ return item
+ except TypeError:
+ pass
+ return None
+
+
+def _patched_get_item(adc_svc, path):
+ """Patched version of adc._get_item that handles ReadOnlyList."""
+ import os.path as _op
+ path = adc._ensure_local_path(adc_svc, path)
+ if not _op.isfile(path):
+ raise Exception("Path does not point to a file")
+ res = adc_svc.GetItemsByWorkspacePaths([path])
+ if not res:
+ raise Exception("Cannot find item in any ADC drive")
+ first = _safe_first(res)
+ if first is None:
+ raise Exception("ADC returned empty result for path")
+ return first.Item
+
+
+def _patched_get_item_lockstatus(adc_svc, item):
+ """Patched version of adc._get_item_lockstatus."""
+ res = adc_svc.GetLockStatus([item.Id])
+ if res and res.Status:
+ return _safe_first(res.Status)
+ return None
+
+
+def _patched_get_item_property_value(adc_svc, drive, item, prop_name):
+ """Patched version of adc._get_item_property_value."""
+ for prop_def in adc._get_drive_properties(adc_svc, drive):
+ if prop_def.DisplayName == prop_name:
+ res = adc_svc.GetProperties([item.Id], [prop_def.Id])
+ if res:
+ return _safe_first(res.Values)
+ return None
+
+
+def _patched_get_item_property_id_value(adc_svc, drive, item, prop_id):
+ """Patched version of adc._get_item_property_id_value."""
+ for prop_def in adc._get_drive_properties(adc_svc, drive):
+ if prop_def.Id == prop_id:
+ res = adc_svc.GetProperties([item.Id], [prop_def.Id])
+ if res:
+ return _safe_first(res.Values)
+ return None
+
+
+# Apply patches (only on .NET Framework, only once per engine session)
+if not HOST_APP.is_newer_than("2024") \
+ and not getattr(adc, '_readonlylist_patched', False):
+ logger.debug('Applying ADC ReadOnlyList patches for .NET Framework')
+ adc._get_item = _patched_get_item
+ adc._get_item_lockstatus = _patched_get_item_lockstatus
+ adc._get_item_property_value = _patched_get_item_property_value
+ adc._get_item_property_id_value = _patched_get_item_property_id_value
+ adc._readonlylist_patched = True
+
+
+# =============================================================================
+# EXTERNAL EVENT HANDLER (for modeless window Revit API access)
+# =============================================================================
+# Modeless WPF windows cannot start Revit transactions directly.
+# All write operations (transactions, PostCommand) are queued here and
+# executed on Revit's main thread via ExternalEvent.
+
+class RevitActionHandler(UI.IExternalEventHandler):
+ """Queues callables and runs them inside Revit's valid API context."""
+
+ def __init__(self):
+ self._queue = []
+
+ def queue(self, action, callback=None, window=None):
+ """Add an action (and optional WPF-thread callback) to the queue."""
+ self._queue.append((action, callback, window))
+
+ def Execute(self, app):
+ """Called by Revit on the main thread when the event fires."""
+ while self._queue:
+ action, callback, window = self._queue.pop(0)
+ try:
+ action()
+ except Exception as ex:
+ logger.error('RevitActionHandler | %s' % ex)
+ try:
+ if window and window.IsLoaded:
+ window.Dispatcher.Invoke(
+ System.Action(
+ lambda e=str(ex): forms.alert(e)))
+ except Exception as disp_ex:
+ logger.debug(
+ 'Failed to display error in window | %s' % disp_ex)
+ if callback:
+ try:
+ if window and window.IsLoaded:
+ window.Dispatcher.Invoke(System.Action(callback))
+ else:
+ callback()
+ except Exception as cbex:
+ logger.debug('Callback failed | %s' % cbex)
+
+ def GetName(self):
+ return "KeynoteManagerHandler"
+# Module-level handler + event (persist across window open/close)
+_ext_handler = RevitActionHandler()
+_ext_event = UI.ExternalEvent.Create(_ext_handler)
+
+# Singleton — only one keynote manager window at a time
+_active_window = None
+
+
+# =============================================================================
+# HELPERS
+# =============================================================================
+
def get_keynote_pcommands():
return list(reversed(
[x for x in coreutils.get_enum_values(UI.PostableCommand)
if str(x).endswith('Keynote')]))
+def _find_siblings(flat_keynotes, target_parent_key):
+ """Return natsorted list of keynotes sharing the same parent_key."""
+ return natsorted(
+ [k for k in flat_keynotes if k.parent_key == target_parent_key],
+ key=lambda x: x.key)
+
+
+def _find_parent_of(all_categories, all_keynotes, child):
+ """Find the RKeynote/category object that is the parent of 'child'."""
+ pkey = child.parent_key
+ if not pkey:
+ return None
+ for cat in all_categories:
+ if cat.key == pkey:
+ return cat
+ for kn in all_keynotes:
+ if kn.key == pkey:
+ return kn
+ return None
+
+
+# =============================================================================
+# EDIT RECORD WINDOW (unchanged from pyRevit — works with EditRecord.xaml)
+# =============================================================================
+
class EditRecordWindow(forms.WPFWindow):
- def __init__(self,
- owner,
- conn, mode,
- rkeynote=None,
- rkey=None, text=None, pkey=None):
+ """Dialog for adding/editing a single keynote or category record."""
+
+ def __init__(self, owner, conn, mode,
+ rkeynote=None, rkey=None, text=None, pkey=None):
forms.WPFWindow.__init__(self, 'EditRecord.xaml')
self.Owner = owner
self._res = None
self._commited = False
self._reserved_key = None
- # connection
self._conn = conn
self._mode = mode
self._cat = False
@@ -60,54 +241,43 @@ def __init__(self,
self._text = text
self._pkey = pkey
- # prepare gui for various edit modes
if self._mode == kdb.EDIT_MODE_ADD_CATEG:
self._cat = True
self.hide_element(self.recordParentInput)
- self.Title = self.get_locale_string("AddCategoryTitle")
- self.recordKeyTitle.Text = self.get_locale_string("CreateCategoryKey")
- self.applyChanges.Content = self.get_locale_string("AddCategoryApply")
+ self.Title = "Add Group"
+ self.recordKeyTitle.Text = "Create a unique group key"
+ self.applyChanges.Content = "Add Group"
elif self._mode == kdb.EDIT_MODE_EDIT_CATEG:
self._cat = True
self.hide_element(self.recordParentInput)
- self.Title = self.get_locale_string("EditCategoryTitle")
- self.recordKeyTitle.Text = self.get_locale_string("EditCategoryKey")
- self.applyChanges.Content = self.get_locale_string("EditCategoryApply")
+ self.Title = "Edit Group"
+ self.recordKeyTitle.Text = "Group key (read-only)"
+ self.applyChanges.Content = "Save Changes"
self.recordKey.IsEnabled = False
- if self._rkeynote:
- if self._rkeynote.key:
- kdb.begin_edit(self._conn,
- self._rkeynote.key,
- category=True)
+ if self._rkeynote and self._rkeynote.key:
+ kdb.begin_edit(self._conn, self._rkeynote.key, category=True)
elif self._mode == kdb.EDIT_MODE_ADD_KEYNOTE:
self.show_element(self.recordParentInput)
- self.Title = self.get_locale_string("AddKeynoteTitle")
- self.recordKeyTitle.Text = self.get_locale_string("CreateKeynoteKey")
- self.applyChanges.Content = self.get_locale_string("AddKeynoteApply")
+ self.Title = "Add Keynote"
+ self.recordKeyTitle.Text = "Create a unique keynote key"
+ self.applyChanges.Content = "Add Keynote"
elif self._mode == kdb.EDIT_MODE_EDIT_KEYNOTE:
self.show_element(self.recordParentInput)
- self.Title = self.get_locale_string("EditKeynoteTitle")
- self.recordKeyTitle.Text = self.get_locale_string("EditKeynoteKey")
- self.applyChanges.Content = self.get_locale_string("EditKeynoteApply")
+ self.Title = "Edit Keynote"
+ self.recordKeyTitle.Text = "Keynote key (read-only)"
+ self.applyChanges.Content = "Save Changes"
self.recordKey.IsEnabled = False
- #allow changing Parent key
self.recordParent.IsEnabled = True
- if self._rkeynote:
- # start edit
- if self._rkeynote.key:
- kdb.begin_edit(self._conn,
- self._rkeynote.key,
- category=False)
-
- # update gui with overrides if any
+ if self._rkeynote and self._rkeynote.key:
+ kdb.begin_edit(self._conn, self._rkeynote.key, category=False)
+
if self._rkeynote:
self.active_key = self._rkeynote.key
self.active_text = self._rkeynote.text
self.active_parent_key = self._rkeynote.parent_key
-
if self._rkey:
self.active_key = self._rkey
if self._text:
@@ -115,7 +285,6 @@ def __init__(self,
if self._pkey:
self.active_parent_key = self._pkey
- # select text in textbox for easy editing
self.recordText.Focus()
self.recordText.SelectAll()
@@ -147,76 +316,55 @@ def active_parent_key(self, value):
def commit(self):
if self._mode == kdb.EDIT_MODE_ADD_CATEG:
if not self.active_key:
- forms.alert(self.get_locale_string("CategoryKeyValidate"))
- return False
- elif not self.active_text.strip():
- forms.alert(self.get_locale_string("CategoryTitleValidate"))
- return False
- logger.debug('Adding category: {} {}'
- .format(self.active_key, self.active_text))
+ forms.alert("Please provide a unique key."); return False
+ if not self.active_text.strip():
+ forms.alert("Please provide a title."); return False
try:
- self._res = kdb.add_category(self._conn,
- self.active_key,
- self.active_text)
+ self._res = kdb.add_category(
+ self._conn, self.active_key, self.active_text)
kdb.end_edit(self._conn)
except System.TimeoutException as toutex:
- forms.alert(toutex.Message)
- return False
+ forms.alert(toutex.Message); return False
elif self._mode == kdb.EDIT_MODE_EDIT_CATEG:
if not self.active_text:
- forms.alert(self.get_locale_string("CategoryTitleRemoved"))
- return False
+ forms.alert("Title cannot be empty."); return False
try:
- # update category title if changed
if self.active_text != self._rkeynote.text:
- kdb.update_category_title(self._conn,
- self.active_key,
- self.active_text)
+ kdb.update_category_title(
+ self._conn, self.active_key, self.active_text)
kdb.end_edit(self._conn)
except System.TimeoutException as toutex:
- forms.alert(toutex.Message)
- return False
+ forms.alert(toutex.Message); return False
elif self._mode == kdb.EDIT_MODE_ADD_KEYNOTE:
if not self.active_key:
- forms.alert(self.get_locale_string("KeynoteKeyValidate"))
- return False
- elif not self.active_text:
- forms.alert(self.get_locale_string("KeynoteTextValidate"))
- return False
- elif not self.active_parent_key:
- forms.alert(self.get_locale_string("KeynoteParentValidate"))
- return False
+ forms.alert("Please provide a unique key."); return False
+ if not self.active_text:
+ forms.alert("Please provide keynote text."); return False
+ if not self.active_parent_key:
+ forms.alert("Please select a parent."); return False
try:
- self._res = kdb.add_keynote(self._conn,
- self.active_key,
- self.active_text,
- self.active_parent_key)
+ self._res = kdb.add_keynote(
+ self._conn, self.active_key,
+ self.active_text, self.active_parent_key)
kdb.end_edit(self._conn)
except System.TimeoutException as toutex:
- forms.alert(toutex.Message)
- return False
+ forms.alert(toutex.Message); return False
elif self._mode == kdb.EDIT_MODE_EDIT_KEYNOTE:
if not self.active_text:
- forms.alert(self.get_locale_string("KeynoteTextRemoved"))
- return False
+ forms.alert("Keynote text cannot be empty."); return False
try:
- # update keynote title if changed
if self.active_text != self._rkeynote.text:
- kdb.update_keynote_text(self._conn,
- self.active_key,
- self.active_text)
- # update keynote parent
+ kdb.update_keynote_text(
+ self._conn, self.active_key, self.active_text)
if self.active_parent_key != self._rkeynote.parent_key:
- kdb.move_keynote(self._conn,
- self.active_key,
- self.active_parent_key)
+ kdb.move_keynote(
+ self._conn, self.active_key, self.active_parent_key)
kdb.end_edit(self._conn)
except System.TimeoutException as toutex:
- forms.alert(toutex.Message)
- return False
+ forms.alert(toutex.Message); return False
return True
@@ -225,66 +373,49 @@ def show(self):
return self._res
def pick_key(self, sender, args):
- # remove previously reserved
if self._reserved_key:
try:
- kdb.release_key(self._conn,
- self._reserved_key,
+ kdb.release_key(self._conn, self._reserved_key,
category=self._cat)
except System.TimeoutException as toutex:
- forms.alert(toutex.Message)
- return
-
+ forms.alert(toutex.Message); return
try:
categories = kdb.get_categories(self._conn)
keynotes = kdb.get_keynotes(self._conn)
locks = kdb.get_locks(self._conn)
except System.TimeoutException as toutex:
- forms.alert(toutex.Message)
- return
-
- # collect existing keys
+ forms.alert(toutex.Message); return
reserved_keys = [x.key for x in categories]
reserved_keys.extend([x.key for x in keynotes])
reserved_keys.extend([x.LockTargetRecordKey for x in locks])
- # ask for a unique new key
new_key = forms.ask_for_unique_string(
- prompt=self.get_locale_string("EnterUniqueKey"),
+ prompt="Enter a unique key:",
title=self.Title,
- reserved_values=reserved_keys,
- owner=self)
+ reserved_values=reserved_keys, owner=self)
if new_key:
try:
kdb.reserve_key(self._conn, new_key, category=self._cat)
except System.TimeoutException as toutex:
- forms.alert(toutex.Message)
- return
+ forms.alert(toutex.Message); return
self._reserved_key = new_key
- # set the key value on the button
self.active_key = new_key
def pick_parent(self, sender, args):
categories = kdb.get_categories(self._conn)
keynotes = kdb.get_keynotes(self._conn)
- available_parents = [x.key for x in categories]
- available_parents.extend([x.key for x in keynotes])
- # remove self from that record if self is not none
- if self.active_key in available_parents:
- available_parents.remove(self.active_key)
- # prompt to select a record
+ available = [x.key for x in categories]
+ available.extend([x.key for x in keynotes])
+ if self.active_key in available:
+ available.remove(self.active_key)
new_parent = forms.SelectFromList.show(
- natsorted(available_parents),
- title='Select Parent',
- multiselect=False
- )
+ natsorted(available), title='Select Parent', multiselect=False)
if new_parent:
try:
- kdb.reserve_key(self._conn, self.active_key, category=self._cat)
+ kdb.reserve_key(self._conn, self.active_key,
+ category=self._cat)
except System.TimeoutException as toutex:
- forms.alert(toutex.Message)
- return
+ forms.alert(toutex.Message); return
self._reserved_key = self.active_key
- # apply the record key on the button
self.active_parent_key = new_parent
def to_upper(self, sender, args):
@@ -300,87 +431,107 @@ def to_sentence(self, sender, args):
self.active_text = self.active_text.capitalize()
def select_template(self, sender, args):
- # TODO: get templates from config
template = forms.SelectFromList.show(
- [self.get_locale_string("TemplateReserved"), self.get_locale_string("TemplateDontUse")],
- title=self.get_locale_string("SelectTemplate"),
- owner=self)
+ ["RESERVED", "DO NOT USE"],
+ title="Select Template", owner=self)
if template:
self.active_text = template
def translate(self, sender, args):
- forms.alert(self.get_locale_string("ComingSoon"))
+ forms.alert("Translation feature coming soon.")
def apply_changes(self, sender, args):
- logger.debug('Applying changes...')
self._commited = self.commit()
if self._commited:
self.Close()
def cancel_changes(self, sender, args):
- logger.debug('Cancelling changes...')
self.Close()
def window_closing(self, sender, args):
if not self._commited:
if self._reserved_key:
try:
- kdb.release_key(self._conn,
- self._reserved_key,
+ kdb.release_key(self._conn, self._reserved_key,
category=self._cat)
- except System.TimeoutException as toutex:
- forms.alert(toutex.Message)
except Exception:
pass
try:
kdb.end_edit(self._conn)
- except System.TimeoutException as toutex:
- forms.alert(toutex.Message)
except Exception:
pass
+# =============================================================================
+# MAIN KEYNOTE MANAGER WINDOW
+# =============================================================================
+
class KeynoteManagerWindow(forms.WPFWindow):
+ """Keynote manager with unified tree and hierarchy controls."""
+
def __init__(self, xaml_file_name, reset_config=False):
forms.WPFWindow.__init__(self, xaml_file_name)
- # setup keynote ref attrs
+ # Set Revit as the owner window — critical for modeless stability.
+ # Without this, WPF's message pump collides with Revit's on focus
+ # change, causing hard crashes.
+ try:
+ wih = WindowInteropHelper(self)
+ wih.Owner = SysProcess.GetCurrentProcess().MainWindowHandle
+ except Exception as ex:
+ logger.debug('WindowInteropHelper failed | %s' % ex)
+
+ # Hook WndProc to intercept WM_MOUSEACTIVATE — prevents the
+ # re-entrant activation crash when clicking between Revit and
+ # this modeless window.
+ self._hwnd_source = None
+ self._activation_pending = False
+ self.SourceInitialized += self._on_source_initialized
+
self._kfile = None
self._kfile_handler = None
self._kfile_ext = None
-
- # detemine the keynote file, and connect
self._conn = None
+
self._determine_kfile()
self._connect_kfile()
self._cache = []
- self._allcat = kdb.RKeynote(key='', text=self.get_locale_string("AllCategories"),
- parent_key='',
- locked=False, owner='',
- children=None)
-
self._needs_update = False
self._config = script.get_config()
self._used_keysdict = self.get_used_keynote_elements()
+
+ # drag state
+ self._drag_start_point = None
+ self._is_dragging = False
+
+ # modeless close state
+ self._close_pending = False
+
+ self._search_timer = DispatcherTimer()
+ self._search_timer.Interval = TimeSpan.FromMilliseconds(300) # Wait 300ms after last keystroke
+ self._search_timer.Tick += self._on_search_timer_tick
+
self.load_config(reset_config)
+ self._update_full_tree()
+ self._update_status_bar()
self.search_tb.Focus()
+ # =========================================================================
+ # PROPERTIES
+ # =========================================================================
+
@property
def window_geom(self):
return (self.Width, self.Height, self.Top, self.Left)
@window_geom.setter
def window_geom(self, geom_tuple):
- width, height, top, left = geom_tuple
- self.Width = self.Width if math.isnan(width) else width #pylint: disable=W0201
- self.Height = self.Height if math.isnan(height) else height #pylint: disable=W0201
- self.Top = self.Top if math.isnan(top) else top #pylint: disable=W0201
- self.Left = self.Left if math.isnan(left) else left #pylint: disable=W0201
-
- @property
- def target_id(self):
- return hash(self._kfile)
+ w, h, t, l = geom_tuple
+ self.Width = self.Width if math.isnan(w) else w
+ self.Height = self.Height if math.isnan(h) else h
+ self.Top = self.Top if math.isnan(t) else t
+ self.Left = self.Left if math.isnan(l) else l
@property
def search_term(self):
@@ -392,965 +543,1432 @@ def search_term(self, value):
@property
def postable_keynote_command(self):
- # order must match the order in GUI
return get_keynote_pcommands()[self.postcmd_idx]
@property
def postcmd_options(self):
- return [self.userknote_rb, self.materialknote_rb, self.elementknote_rb]
+ return [self.userknote_rb, self.materialknote_rb,
+ self.elementknote_rb]
@property
def postcmd_idx(self):
- # return self.keynotetype_cb.SelectedIndex
- for idx, postcmd_op in enumerate(self.postcmd_options):
- if postcmd_op.IsChecked:
+ for idx, rb in enumerate(self.postcmd_options):
+ if rb.IsChecked:
return idx
+ return 0
@postcmd_idx.setter
def postcmd_idx(self, index):
- postcmd_op = self.postcmd_options[index if index else 0]
- postcmd_op.IsChecked = True
+ self.postcmd_options[index if index else 0].IsChecked = True
@property
def selected_keynote(self):
return self.keynotes_tv.SelectedItem
@property
- def active_category(self):
- # grab active category in selector
- return self.categories_tv.SelectedItem
-
- @property
- def selected_category(self):
- # grab selected category
- cat = self.active_category
- if cat:
- # verify category is not all-categories item
- if cat != self._allcat:
- return cat
- # grab category from keynote list
- elif self.selected_keynote \
- and not self.selected_keynote.parent_key:
- return self.selected_keynote
-
- @selected_category.setter
- def selected_category(self, cat_key):
- self._update_ktree(active_catkey=cat_key)
+ def current_keynotes(self):
+ return self.keynotes_tv.ItemsSource
@property
def all_categories(self):
try:
return kdb.get_categories(self._conn)
except System.TimeoutException as toutex:
- forms.alert(toutex.Message,
- expanded="{}::all_categories()".format(
- self.__class__.__name__))
- return []
+ forms.alert(toutex.Message); return []
@property
def all_keynotes(self):
try:
return kdb.get_keynotes(self._conn)
except System.TimeoutException as toutex:
- forms.alert(toutex.Message,
- expanded="{}::all_keynotes()".format(
- self.__class__.__name__))
- return []
+ forms.alert(toutex.Message); return []
+
+ # =========================================================================
+ # STATUS BAR
+ # =========================================================================
+
+ def _update_status_bar(self):
+ if self._kfile:
+ fname = op.basename(self._kfile)
+ handler = ' ( ACC / FORMA )' if self._kfile_handler == 'adc' else ''
+ self.statusLeft.Text = u"{}{} \u2014 {}".format(
+ fname, handler, op.dirname(self._kfile))
+ else:
+ self.statusLeft.Text = "No keynote file loaded"
- @property
- def current_keynotes(self):
- return self.keynotes_tv.ItemsSource
+ try:
+ cats = self.all_categories if self._conn else []
+ knotes = self.all_keynotes if self._conn else []
+ used = len(self._used_keysdict)
+ self.statusRight.Text = \
+ u"{} groups \u00B7 {} keynotes \u00B7 {} in use".format(
+ len(cats), len(knotes), used)
+ except Exception:
+ self.statusRight.Text = ""
- @property
- def keynote_text_with(self):
- return 200
+ # =========================================================================
+ # REVIT THREAD DISPATCH (for modeless window)
+ # =========================================================================
+
+ def _revit_run(self, action, callback=None):
+ """Queue an action to execute on Revit's main thread.
+ Optional callback runs on the WPF thread after the action."""
+ _ext_handler.queue(action, callback, self)
+ _ext_event.Raise()
+
+ # =========================================================================
+ # MODELESS FOCUS MANAGEMENT (WndProc hook)
+ # =========================================================================
+
+ WM_MOUSEACTIVATE = 0x0021
+ MA_NOACTIVATE = 3
+
+ def _on_source_initialized(self, sender, args):
+ """Hook into the Win32 message loop once the HWND exists."""
+ try:
+ wih = WindowInteropHelper(self)
+ self._hwnd_source = HwndSource.FromHwnd(wih.Handle)
+ if self._hwnd_source:
+ self._hwnd_source.AddHook(self._wnd_proc)
+ except Exception as ex:
+ logger.debug('HwndSource hook failed | %s' % ex)
+
+ def _wnd_proc(self, hwnd, msg, wParam, lParam, handled):
+ """Win32 WndProc hook — intercept activation messages."""
+ if msg == self.WM_MOUSEACTIVATE:
+ handled.Value = True
+ if not self._activation_pending:
+ self._activation_pending = True
+ self.Dispatcher.BeginInvoke(
+ System.Action(self._safe_activate),
+ Windows.Threading.DispatcherPriority.Background)
+ return System.IntPtr(self.MA_NOACTIVATE)
+ return System.IntPtr.Zero
+
+ def _safe_activate(self):
+ """Deferred activation — runs when Revit's message loop is idle."""
+ self._activation_pending = False
+ try:
+ if self.IsLoaded and self.IsVisible:
+ self.Activate()
+ except Exception as ex:
+ logger.debug('Deferred activation failed | %s' % ex)
+
+ # =========================================================================
+ # TREE STATE PRESERVATION
+ # =========================================================================
+
+ def _get_scroll_viewer(self):
+ """Walk the visual tree to find the ScrollViewer inside TreeView."""
+ tv = self.keynotes_tv
+ if not tv or Windows.Media.VisualTreeHelper.GetChildrenCount(tv) == 0:
+ return None
+ try:
+ border = Windows.Media.VisualTreeHelper.GetChild(tv, 0)
+ if border and Windows.Media.VisualTreeHelper.GetChildrenCount(border) > 0:
+ sv = Windows.Media.VisualTreeHelper.GetChild(border, 0)
+ if isinstance(sv, Windows.Controls.ScrollViewer):
+ return sv
+ except Exception:
+ pass
+ return self._find_child_of_type(tv, Windows.Controls.ScrollViewer)
+
+ def _find_child_of_type(self, parent, child_type):
+ """Recursively find first child of a given type in the visual tree."""
+ try:
+ count = Windows.Media.VisualTreeHelper.GetChildrenCount(parent)
+ except Exception:
+ return None
+ for i in range(count):
+ child = Windows.Media.VisualTreeHelper.GetChild(parent, i)
+ if isinstance(child, child_type):
+ return child
+ result = self._find_child_of_type(child, child_type)
+ if result:
+ return result
+ return None
+
+ def _get_scroll_offset(self):
+ """Get the current vertical scroll offset of the TreeView."""
+ sv = self._get_scroll_viewer()
+ if sv:
+ return sv.VerticalOffset
+ return None
+
+ def _set_scroll_offset(self, offset):
+ """Restore the vertical scroll offset after a tree rebuild."""
+ def _do_scroll():
+ sv = self._get_scroll_viewer()
+ if sv:
+ sv.ScrollToVerticalOffset(offset)
+ self.Dispatcher.BeginInvoke(
+ System.Action(_do_scroll),
+ Windows.Threading.DispatcherPriority.Loaded)
+
+ def _select_keynote_by_key(self, key):
+ """Find and select the node with the given key in the new tree."""
+ path = self._find_node_path(self.keynotes_tv.ItemsSource, key)
+ if not path:
+ return
+
+ def _do_select():
+ container = None
+ parent_container = self.keynotes_tv
+ for node in path:
+ if container and hasattr(container, 'IsExpanded'):
+ container.IsExpanded = True
+ container.UpdateLayout()
+ idx = None
+ items = parent_container.ItemContainerGenerator
+ src = parent_container.Items if hasattr(parent_container, 'Items') \
+ else parent_container.ItemsSource
+ if src:
+ for i, item in enumerate(src):
+ if hasattr(item, 'key') and item.key == node.key:
+ idx = i
+ break
+ if idx is not None:
+ container = items.ContainerFromIndex(idx)
+ else:
+ container = items.ContainerFromItem(node)
+ if container is None:
+ if hasattr(parent_container, 'UpdateLayout'):
+ parent_container.UpdateLayout()
+ if idx is not None:
+ container = items.ContainerFromIndex(idx)
+ else:
+ container = items.ContainerFromItem(node)
+ if container is None:
+ return
+ parent_container = container
+
+ if container and hasattr(container, 'IsSelected'):
+ container.IsSelected = True
+ container.BringIntoView()
+
+ self.Dispatcher.BeginInvoke(
+ System.Action(_do_select),
+ Windows.Threading.DispatcherPriority.Loaded)
+
+ def _find_node_path(self, roots, target_key):
+ """Return the path [root, ..., target] from roots to the node
+ matching target_key, or None if not found."""
+ if not roots:
+ return None
+ for root in roots:
+ if root.key == target_key:
+ return [root]
+ if root.children:
+ sub = self._find_node_path(root.children, target_key)
+ if sub:
+ return [root] + sub
+ return None
+
+ # =========================================================================
+ # USED KEYNOTE TRACKING
+ # =========================================================================
def get_used_keynote_elements(self):
- used_keys = defaultdict(list)
+ used = defaultdict(list)
try:
- for knote in revit.query.get_used_keynotes(doc=revit.doc):
- key = knote.Parameter[DB.BuiltInParameter.KEY_VALUE].AsString()
- used_keys[key].append(knote.Id)
+ for kn in revit.query.get_used_keynotes(doc=revit.doc):
+ if kn is None:
+ continue
+ p = kn.Parameter[DB.BuiltInParameter.KEY_VALUE]
+ if p:
+ key = p.AsString()
+ if key:
+ used[key].append(kn.Id)
except Exception as ex:
- forms.alert(str(ex),
- expanded="{}::get_used_keynote_elements()".format(
- self.__class__.__name__))
- return used_keys
+ logger.debug('get_used_keynotes failed | %s' % ex)
+ return used
+
+ # =========================================================================
+ # CONFIG
+ # =========================================================================
def save_config(self):
- # save self.window_geom
- new_window_geom_dict = {}
- # cleanup removed keynote files
- for kfile, wgeom_value in self._config.get_option('last_window_geom',
- {}).items():
- if op.exists(kfile):
- new_window_geom_dict[kfile] = wgeom_value
- new_window_geom_dict[self._kfile] = self.window_geom
- self._config.set_option('last_window_geom', new_window_geom_dict)
-
- # save self.postable_keynote_command
- new_postcmd_dict = {}
- # cleanup removed keynote files
- for kfile, lpc_value in self._config.get_option('last_postcmd_idx',
- {}).items():
- if op.exists(kfile):
- new_postcmd_dict[kfile] = lpc_value
- new_postcmd_dict[self._kfile] = self.postcmd_idx
- self._config.set_option('last_postcmd_idx', new_postcmd_dict)
-
- # save self.active_category
- new_category_dict = {}
- # cleanup removed keynote files
- for kfile, lc_value in self._config.get_option('last_category',
- {}).items():
- if op.exists(kfile):
- new_category_dict[kfile] = lc_value
- new_category_dict[self._kfile] = ''
- if self.active_category:
- new_category_dict[self._kfile] = self.active_category.key
- self._config.set_option('last_category', new_category_dict)
-
- # save self.search_term
- new_category_dict = {}
+ wg = {}
+ for k, v in self._config.get_option('last_window_geom', {}).items():
+ if op.exists(k):
+ wg[k] = v
+ wg[self._kfile] = self.window_geom
+ self._config.set_option('last_window_geom', wg)
+
+ pc = {}
+ for k, v in self._config.get_option('last_postcmd_idx', {}).items():
+ if op.exists(k):
+ pc[k] = v
+ pc[self._kfile] = self.postcmd_idx
+ self._config.set_option('last_postcmd_idx', pc)
+
+ st = {}
if self.search_term:
- new_category_dict[self._kfile] = self.search_term
- self._config.set_option('last_search_term', new_category_dict)
+ st[self._kfile] = self.search_term
+ self._config.set_option('last_search_term', st)
script.save_config()
- def load_config(self, reset_config):
- # load last window geom
- if reset_config:
- last_window_geom_dict = {}
+ def load_config(self, reset):
+ wg = {} if reset else self._config.get_option(
+ 'last_window_geom', {})
+ if wg and self._kfile in wg:
+ w, h, t, l = wg[self._kfile]
else:
- last_window_geom_dict = \
- self._config.get_option('last_window_geom', {})
- if last_window_geom_dict and self._kfile in last_window_geom_dict:
- width, height, top, left = last_window_geom_dict[self._kfile]
- else:
- width, height, top, left = (None, None, None, None)
- # update window geom
- if all([width, height, top, left]) \
- and coreutils.is_box_visible_on_screens(
- left, top, width, height):
- self.window_geom = (width, height, top, left)
+ w, h, t, l = (None, None, None, None)
+ if all([w, h, t, l]) \
+ and coreutils.is_box_visible_on_screens(l, t, w, h):
+ self.window_geom = (w, h, t, l)
else:
self.WindowStartupLocation = \
framework.Windows.WindowStartupLocation.CenterScreen
- # load last postable commands id
- if reset_config:
- last_postcmd_dict = {}
- else:
- last_postcmd_dict = \
- self._config.get_option('last_postcmd_idx', {})
- if last_postcmd_dict and self._kfile in last_postcmd_dict:
- self.postcmd_idx = last_postcmd_dict[self._kfile]
- else:
- self.postcmd_idx = 0
+ pc = {} if reset else self._config.get_option(
+ 'last_postcmd_idx', {})
+ self.postcmd_idx = pc.get(self._kfile, 0)
- # load last category
- if reset_config:
- last_category_dict = {}
- else:
- last_category_dict = \
- self._config.get_option('last_category', {})
- if last_category_dict and self._kfile in last_category_dict:
- self._update_ktree(active_catkey=last_category_dict[self._kfile])
- else:
- self.selected_category = self._allcat
+ st = {} if reset else self._config.get_option(
+ 'last_search_term', {})
+ self.search_term = st.get(self._kfile, "")
- # load last search term
- if reset_config:
- last_searchterm_dict = {}
- else:
- last_searchterm_dict = \
- self._config.get_option('last_search_term', {})
- if last_searchterm_dict and self._kfile in last_searchterm_dict:
- self.search_term = last_searchterm_dict[self._kfile]
- else:
- self.search_term = ""
+ # =========================================================================
+ # KEYNOTE FILE CONNECTION
+ # =========================================================================
def _determine_kfile(self):
- # verify keynote file existence
self._kfile = revit.query.get_local_keynote_file(doc=revit.doc)
self._kfile_handler = None
self._kfile_ext = None
+
if not self._kfile:
self._kfile_ext = \
revit.query.get_external_keynote_file(doc=revit.doc)
self._kfile_handler = 'unknown'
- # mak sure ADC is available
+
if self._kfile_ext and self._kfile_handler == 'unknown':
if adc.is_available():
self._kfile_handler = 'adc'
- # check if keynote file is being synced by ADC
- # use the drive:// path for adc communications
- # adc module takes both remote or local paths,
- # but remote is faster lookup
- local_kfile = adc.get_local_path(self._kfile_ext)
- if local_kfile:
- # check is someone else has locked the file
- locked, owner = adc.is_locked(self._kfile_ext)
- if locked:
- forms.alert(self.get_locale_string("ADCLockedLocalFile").format(owner), exitscript=True)
- # force sync to get the latest contents
- adc.sync_file(self._kfile_ext)
- adc.lock_file(self._kfile_ext)
- # now that adc communication is done,
- # replace with local path
- self._kfile = local_kfile
- self.Title += ' (BIM360)' #pylint: disable=no-member
- else:
- forms.alert(self.get_locale_string("ADCLockedLocalFileNone").format(adc.ADC_NAME), exitscript=True)
+ try:
+ local_kfile = adc.get_local_path(self._kfile_ext)
+ if local_kfile:
+ try:
+ locked, owner = adc.is_locked(self._kfile_ext)
+ if locked:
+ forms.alert(
+ "File locked by {}."
+ .format(owner), exitscript=True)
+ except Exception as lockex:
+ logger.debug('ADC lock check | %s' % lockex)
+ try:
+ adc.sync_file(self._kfile_ext)
+ adc.lock_file(self._kfile_ext)
+ except Exception as syncex:
+ logger.debug('ADC sync/lock | %s' % syncex)
+ self._kfile = local_kfile
+ self.Title += ' ( ACC / FORMA )'
+ else:
+ forms.alert(
+ "Cannot resolve local path via {}."
+ .format(adc.ADC_NAME), exitscript=True)
+ except Exception as adcex:
+ logger.debug('ADC failed | %s' % adcex)
+ forms.alert(
+ "ADC communication failed.\n{}".format(adcex),
+ exitscript=True)
else:
forms.alert(
- self.get_locale_string("ADCNotAvailable")
- .format(long=adc.ADC_NAME, short=adc.ADC_SHORTNAME),
+ "{} is not available.".format(adc.ADC_NAME),
exitscript=True)
def _change_kfile(self):
kfile = forms.pick_file('txt')
if kfile:
- logger.debug('Setting keynote file: %s' % kfile)
try:
- with revit.Transaction(self.get_locale_string("SetKeynoteFileTransactionName")):
+ with revit.Transaction("Set Keynote File"):
revit.update.set_keynote_file(kfile, doc=revit.doc)
- except Exception as skex:
- forms.alert(str(skex),
- expanded="{}::_change_kfile() [transaction]".format(
- self.__class__.__name__))
+ except Exception as ex:
+ forms.alert(str(ex))
def _connect_kfile(self):
- # by this point we must have a local path to the keynote file
if not self._kfile or not op.exists(self._kfile):
self._kfile = None
- forms.alert(self.get_locale_string("KeynoteFileNotExists"))
+ forms.alert("Keynote file not found. Select a valid file.")
self._change_kfile()
self._determine_kfile()
-
- # if a keynote file is still not set, return
if not self._kfile:
- raise Exception(self.get_locale_string("KeynoteFileNotSetup"))
-
- # if a keynote file is still not set, return
+ raise Exception("No keynote file set for this project.")
if not os.access(self._kfile, os.W_OK):
- raise Exception(self.get_locale_string("KeynoteFileIsReadOnly"))
-
+ raise Exception("Keynote file is read-only:\n" + self._kfile)
try:
self._conn = kdb.connect(self._kfile)
except System.TimeoutException as toutex:
- forms.alert(toutex.Message,
- expanded="{}::__init__()".format(
- self.__class__.__name__),
- exitscript=True)
+ forms.alert(toutex.Message, exitscript=True)
except Exception as ex:
logger.debug('Connection failed | %s' % ex)
- res = forms.alert(self.get_locale_string("KeynoteFileConnectionFailed"),
- options=[self.get_locale_string("KeynoteFileConnectionConvert"),
- self.get_locale_string("KeynoteFileConnectionSelectOther"),
- self.get_locale_string("KeynoteFileConnectionHelp")])
- if res:
- if res == self.get_locale_string("KeynoteFileConnectionConvert"):
- try:
- self._convert_existing()
- forms.alert(self.get_locale_string("KeynoteFileConversionCompleted"))
- if not self._conn:
- forms.alert(self.get_locale_string("KeynoteFileLaunchAgain"), exitscript=True)
- except Exception as convex:
- logger.debug('Legacy conversion failed | %s' % convex)
- forms.alert(self.get_locale_string("KeynoteFileConversionFailed") % convex, exitscript=True)
- elif res == self.get_locale_string("KeynoteFileConnectionSelectOther"):
- self._change_kfile()
- self._determine_kfile()
- elif res == self.get_locale_string("KeynoteFileConnectionHelp"):
- script.open_url(HELP_URL) #pylint: disable=undefined-variable
- script.exit()
+ res = forms.alert(
+ "Cannot connect to keynote file.\n"
+ "It may need conversion to the new format.",
+ options=["Convert", "Select Other", "Help"])
+ if res == "Convert":
+ try:
+ self._convert_existing()
+ forms.alert("Converted! Please relaunch.")
+ if not self._conn:
+ forms.alert("Relaunch required.", exitscript=True)
+ except Exception as convex:
+ forms.alert("Conversion failed: %s" % convex,
+ exitscript=True)
+ elif res == "Select Other":
+ self._change_kfile()
+ self._determine_kfile()
+ elif res == "Help":
+ script.open_url(
+ "https://www.notion.so/pyrevitlabs/"
+ "Manage-Keynotes-6f083d6f66fe43d68dc5d5407c8e19da")
+ script.exit()
else:
- forms.alert(self.get_locale_string("KeynoteFileNotConversion"), exitscript=True)
-
- def _empty_file(self, filepath):
- open(filepath, 'w').close()
+ forms.alert("No valid keynote file.", exitscript=True)
def _convert_existing(self):
- # make a copy of the original keynote file
- temp_kfile = \
- script.get_data_file(op.basename(self._kfile), 'bak')
- if op.exists(temp_kfile):
- script.remove_data_file(temp_kfile)
+ temp = script.get_data_file(op.basename(self._kfile), 'bak')
+ if op.exists(temp):
+ script.remove_data_file(temp)
try:
- shutil.copy(self._kfile, temp_kfile)
+ shutil.copy(self._kfile, temp)
except Exception:
- raise Exception(self.get_locale_string("KeynoteFileBackupException"))
-
+ raise Exception("Backup failed.")
try:
- # don't delete files in keynotes folder
- # usually they're on network or synced drives and the IO is slow
- self._empty_file(self._kfile)
+ with open(self._kfile, 'w'):
+ pass
except Exception:
- raise Exception(self.get_locale_string("KeynoteFilePreparingException"))
-
- # import the keynotes from the backup into the emptied keynote file
+ raise Exception("File preparation failed.")
try:
self._conn = kdb.connect(self._kfile)
- kdb.import_legacy_keynotes(self._conn, temp_kfile, skip_dup=True)
- except System.TimeoutException as toutex:
- shutil.copy(temp_kfile, self._kfile)
- forms.alert(toutex.Message,
- expanded="{}::_convert_existing()".format(
- self.__class__.__name__),
- exitscript=True)
+ kdb.import_legacy_keynotes(self._conn, temp, skip_dup=True)
except Exception as ex:
- shutil.copy(temp_kfile, self._kfile)
+ shutil.copy(temp, self._kfile)
raise ex
finally:
- script.remove_data_file(temp_kfile)
+ script.remove_data_file(temp)
- def _update_ktree(self, active_catkey=None):
- categories = [self._allcat]
- categories.extend(self.all_categories)
+ # =========================================================================
+ # TREE BUILDING — UNIFIED (categories + keynotes in one tree)
+ # =========================================================================
- last_idx = 0
- if active_catkey:
- cat_keys = [x.key for x in categories]
- if active_catkey in cat_keys:
- last_idx = cat_keys.index(active_catkey)
- else:
- if self.categories_tv.ItemsSource:
- last_idx = self.categories_tv.SelectedIndex
+ def _build_full_tree(self):
+ """Build a single tree: categories at root, keynotes nested by
+ parent_key. Returns the root-level list of RKeynote objects
+ with children populated recursively."""
+ try:
+ categories = kdb.get_categories(self._conn)
+ all_knotes = kdb.get_keynotes(self._conn)
+ except System.TimeoutException as toutex:
+ forms.alert(toutex.Message)
+ return []
+ except Exception as ex:
+ forms.alert("Error loading keynotes:\n%s" % ex,
+ exitscript=True)
+ return []
- self.categories_tv.ItemsSource = None
- self.categories_tv.ItemsSource = categories
- self.categories_tv.SelectedIndex = last_idx
+ # Build parent -> children map from keynotes
+ cat_keys = set(c.key for c in categories)
+ children_map = defaultdict(list)
+ for kn in all_knotes:
+ if kn.parent_key:
+ children_map[kn.parent_key].append(kn)
+
+ # Recursive child population
+ def _populate(node):
+ node_children = natsorted(
+ children_map.get(node.key, []), key=lambda x: x.key)
+ # Replace the children list (clear first to avoid dupes)
+ while node.children:
+ node.children.pop()
+ for child in node_children:
+ _populate(child)
+ node.children.append(child)
+
+ # Root-level: categories
+ roots = natsorted(categories, key=lambda x: x.key)
+ for root in roots:
+ _populate(root)
+
+ # Also find keynotes whose parent_key is a category
+ # but weren't caught above (edge case: orphans)
+ all_parented = set()
+ for kids in children_map.values():
+ for k in kids:
+ all_parented.add(k.key)
+
+ return roots
+
+ def _update_full_tree(self, fast_filter=False):
+ """Refresh the single unified tree, applying search filter."""
+ # Save current state before rebuild
+ saved_key = None
+ saved_scroll = None
+ sel = self.selected_keynote
+ if sel:
+ saved_key = sel.key
+ saved_scroll = self._get_scroll_offset()
- def _update_ktree_knotes(self, fast=False):
keynote_filter = self.search_term if self.search_term else None
- # update the visible keys in active view if filter is ViewOnly
+ # Update view-only filter keys
if keynote_filter \
and kdb.RKeynoteFilters.ViewOnly.code in keynote_filter:
- visible_keys = \
- [x.TagText for x in
- revit.query.get_visible_keynotes(revit.active_view)]
+ visible_keys = [
+ x.TagText for x in
+ revit.query.get_visible_keynotes(revit.active_view)]
kdb.RKeynoteFilters.ViewOnly.set_keys(visible_keys)
- if fast and keynote_filter:
- # fast filtering using already loaded content
- active_tree = list(self._cache)
+ if fast_filter and keynote_filter:
+ tree = list(self._cache)
else:
- # get the keynote trees (not categories)
- try:
- active_tree = kdb.get_keynotes_tree(self._conn)
- except System.TimeoutException as toutex:
- forms.alert(
- toutex.Message,
- expanded="{}::_update_ktree_knotes() [timeout]".format(
- self.__class__.__name__))
- active_tree = []
- except Exception as ex:
- forms.alert(
- "Error retrieving keynotes.",
- expanded="{}\n{}::_update_ktree_knotes()".format(
- str(ex),
- self.__class__.__name__),
- exitscript=True
- )
-
- selected_cat = self.selected_category
- # if the is a category selected, list its children
- if selected_cat:
- if selected_cat.key:
- active_tree = \
- [x for x in active_tree
- if x.parent_key == self.selected_category.key]
- # otherwise list categories as roots witn children
- else:
- parents = defaultdict(list)
- for rkey in active_tree:
- parents[rkey.parent_key].append(rkey)
- categories = self.all_categories
- for crkey in categories:
- if crkey.key in parents:
- crkey.children.extend(parents[crkey.key])
- active_tree = categories
-
- # mark used keynotes
- for knote in active_tree:
- knote.update_used(self._used_keysdict)
-
- # filter keynotes
- self._cache = list(active_tree)
+ tree = self._build_full_tree()
+
+ # Mark used
+ for node in tree:
+ node.update_used(self._used_keysdict)
+
+ # Cache for fast re-filter
+ self._cache = list(tree)
+
+ # Apply search filter
if keynote_filter:
- clean_filter = keynote_filter.lower()
- filtered_keynotes = []
- for rkey in active_tree:
- if rkey.filter(clean_filter):
- filtered_keynotes.append(rkey)
+ clean = keynote_filter.lower()
+ tree = [n for n in tree if n.filter(clean)]
+
+ self.keynotes_tv.ItemsSource = tree
+
+ if tree:
+ self.emptyStateMsg.Visibility = Windows.Visibility.Collapsed
else:
- filtered_keynotes = active_tree
-
- # show keynotes
- self.keynotes_tv.ItemsSource = filtered_keynotes
-
- def _update_catedit_buttons(self):
- self.catEditButtons.IsEnabled = \
- self.selected_category and not self.selected_category.locked
-
- def _update_knoteedit_buttons(self):
- if self.selected_keynote \
- and not self.selected_keynote.locked:
- self.keynoteEditButtons.IsEnabled = \
- bool(self.selected_keynote.parent_key)
- self.keynoteSearch.IsEnabled = self.keynoteEditButtons.IsEnabled
- self.catEditButtons.IsEnabled = \
- not self.keynoteEditButtons.IsEnabled
+ self.emptyStateMsg.Visibility = Windows.Visibility.Visible
+
+ # Restore state after rebuild
+ if saved_key:
+ self._select_keynote_by_key(saved_key)
+ if saved_scroll is not None:
+ self._set_scroll_offset(saved_scroll)
+
+ # =========================================================================
+ # BUTTON STATE
+ # =========================================================================
+
+ def _update_buttons(self):
+ """Enable/disable toolbar buttons based on selection."""
+ sel = self.selected_keynote
+ if not sel or sel.locked:
+ for btn in [self.editKeynoteBtn, self.dupKeynoteBtn,
+ self.rekeyBtn, self.removeBtn,
+ self.findBtn, self.placeBtn,
+ self.indentBtn, self.outdentBtn,
+ self.moveUpBtn, self.moveDownBtn,
+ self.caseBtn]:
+ btn.IsEnabled = False
+ return
+
+ is_cat = sel.is_category # top-level group (no parent_key)
+ is_kn = bool(sel.parent_key)
+
+ self.editKeynoteBtn.IsEnabled = True
+ self.dupKeynoteBtn.IsEnabled = is_kn
+ self.rekeyBtn.IsEnabled = True
+ self.removeBtn.IsEnabled = True
+ self.findBtn.IsEnabled = is_kn
+ self.placeBtn.IsEnabled = is_kn
+ self.caseBtn.IsEnabled = True
+
+ # Hierarchy buttons
+ # Indent: can indent if it's a keynote and has a preceding sibling
+ can_indent = False
+ can_outdent = False
+ can_up = False
+ can_down = False
+
+ if is_kn:
+ siblings = _find_siblings(
+ self.all_keynotes, sel.parent_key)
+ idx = next(
+ (i for i, s in enumerate(siblings) if s.key == sel.key),
+ -1)
+ can_indent = idx > 0 # has a sibling above
+ # Can outdent if parent is a keynote (not a category)
+ cats = self.all_categories
+ cat_keys = set(c.key for c in cats)
+ parent_is_keynote = sel.parent_key not in cat_keys
+ can_outdent = parent_is_keynote
+ can_up = idx > 0
+ can_down = idx < len(siblings) - 1
+ elif is_cat:
+ cats = natsorted(self.all_categories, key=lambda x: x.key)
+ idx = next(
+ (i for i, c in enumerate(cats) if c.key == sel.key), -1)
+ can_up = idx > 0
+ can_down = idx < len(cats) - 1
+
+ self.indentBtn.IsEnabled = can_indent
+ self.outdentBtn.IsEnabled = can_outdent
+ self.moveUpBtn.IsEnabled = can_up
+ self.moveDownBtn.IsEnabled = can_down
+
+ # =========================================================================
+ # INDENT / OUTDENT — CORE HIERARCHY OPERATIONS
+ # =========================================================================
+
+ def indent_keynote(self, sender, args):
+ """Indent: make selected node a child of the sibling above it.
+ Effectively increases nesting depth by one level."""
+ sel = self.selected_keynote
+ if not sel or not sel.parent_key or sel.locked:
+ return
+
+ siblings = _find_siblings(self.all_keynotes, sel.parent_key)
+ idx = next(
+ (i for i, s in enumerate(siblings) if s.key == sel.key), -1)
+ if idx <= 0:
+ return
+
+ new_parent = siblings[idx - 1]
+ try:
+ kdb.move_keynote(self._conn, sel.key, new_parent.key)
+ self._needs_update = True
+ except System.TimeoutException as toutex:
+ forms.alert(toutex.Message); return
+ except Exception as ex:
+ forms.alert("Indent failed: %s" % ex); return
+
+ self._update_full_tree()
+ self._update_status_bar()
+
+ def outdent_keynote(self, sender, args):
+ """Outdent: promote selected node up one level.
+ Moves it to be a sibling of its current parent."""
+ sel = self.selected_keynote
+ if not sel or not sel.parent_key or sel.locked:
+ return
+
+ cats = self.all_categories
+ cat_keys = set(c.key for c in cats)
+
+ # Find current parent
+ current_parent_key = sel.parent_key
+ if current_parent_key in cat_keys:
+ # Parent is already a top-level category — can't outdent further
+ # (would need to become a category itself, which is a different op)
+ forms.alert(
+ "Already at the top keynote level.\n"
+ "To make this a top-level group, use the Re-Key as "
+ "category workflow.")
+ return
+
+ # Parent is a keynote — find grandparent
+ all_kn = self.all_keynotes
+ parent = next(
+ (k for k in all_kn if k.key == current_parent_key), None)
+ if not parent:
+ return
+
+ grandparent_key = parent.parent_key
+ if not grandparent_key:
+ return
+
+ try:
+ kdb.move_keynote(self._conn, sel.key, grandparent_key)
+ self._needs_update = True
+ except System.TimeoutException as toutex:
+ forms.alert(toutex.Message); return
+ except Exception as ex:
+ forms.alert("Outdent failed: %s" % ex); return
+
+ self._update_full_tree()
+ self._update_status_bar()
+
+ # =========================================================================
+ # MOVE UP / MOVE DOWN (swap keys with adjacent sibling)
+ # =========================================================================
+
+ def move_up(self, sender, args):
+ """Swap selected node's key with the sibling above it."""
+ self._swap_sibling(-1)
+
+ def move_down(self, sender, args):
+ """Swap selected node's key with the sibling below it."""
+ self._swap_sibling(1)
+
+ def _swap_sibling(self, direction):
+ """Swap keys between the selected node and its adjacent sibling.
+ direction: -1 for up, +1 for down."""
+ sel = self.selected_keynote
+ if not sel or sel.locked:
+ return
+
+ is_cat = sel.is_category
+ if is_cat:
+ siblings = natsorted(
+ self.all_categories, key=lambda x: x.key)
else:
- self.keynoteEditButtons.IsEnabled = False
- self.keynoteSearch.IsEnabled = False
+ siblings = _find_siblings(
+ self.all_keynotes, sel.parent_key)
+
+ idx = next(
+ (i for i, s in enumerate(siblings) if s.key == sel.key), -1)
+ target_idx = idx + direction
+ if target_idx < 0 or target_idx >= len(siblings):
+ return
+
+ other = siblings[target_idx]
+ if other.locked:
+ forms.alert("Adjacent item is locked."); return
+
+ # Swap keys
+ sel_key = sel.key
+ other_key = other.key
+ temp_key = "__swap_{}__".format(uuid.uuid4().hex[:8])
+
+ try:
+ if is_cat:
+ kdb.update_category_key(self._conn, sel_key, temp_key)
+ kdb.update_category_key(self._conn, other_key, sel_key)
+ kdb.update_category_key(self._conn, temp_key, other_key)
+ # Update children parent_keys
+ with kdb.BulkAction(self._conn):
+ for child in self.all_keynotes:
+ if child.parent_key == sel_key:
+ kdb.move_keynote(
+ self._conn, child.key, other_key)
+ elif child.parent_key == other_key:
+ kdb.move_keynote(
+ self._conn, child.key, sel_key)
+ else:
+ kdb.update_keynote_key(self._conn, sel_key, temp_key)
+ kdb.update_keynote_key(self._conn, other_key, sel_key)
+ kdb.update_keynote_key(self._conn, temp_key, other_key)
+ # Update children of swapped nodes
+ with kdb.BulkAction(self._conn):
+ for child in self.all_keynotes:
+ if child.parent_key == sel_key:
+ kdb.move_keynote(
+ self._conn, child.key, other_key)
+ elif child.parent_key == other_key:
+ kdb.move_keynote(
+ self._conn, child.key, sel_key)
+
+ # Update references in Revit model (async via ExternalEvent)
+ sk, ok = sel_key, other_key
+ self._revit_run(lambda: self._swap_keynote_refs(sk, ok))
+ self._needs_update = True
+ except System.TimeoutException as toutex:
+ forms.alert(toutex.Message); return
+ except Exception as ex:
+ forms.alert("Swap failed: %s" % ex); return
+
+ self._update_full_tree()
+ self._update_status_bar()
+
+ def _swap_keynote_refs(self, key_a, key_b):
+ """Swap Revit element references between two keynote keys."""
+ temp = "__ref_{}__".format(uuid.uuid4().hex[:8])
+ with revit.Transaction("Reorder Keynotes"):
+ for kid in self.get_used_keynote_elements().get(key_a, []):
+ kel = revit.doc.GetElement(kid)
+ if kel:
+ p = kel.Parameter[DB.BuiltInParameter.KEY_VALUE]
+ if p:
+ p.Set(temp)
+ for kid in self.get_used_keynote_elements().get(key_b, []):
+ kel = revit.doc.GetElement(kid)
+ if kel:
+ p = kel.Parameter[DB.BuiltInParameter.KEY_VALUE]
+ if p:
+ p.Set(key_a)
+ for kid in self.get_used_keynote_elements().get(key_a, []):
+ kel = revit.doc.GetElement(kid)
+ if kel:
+ p = kel.Parameter[DB.BuiltInParameter.KEY_VALUE]
+ if p and p.AsString() == temp:
+ p.Set(key_b)
+
+ # =========================================================================
+ # KEY PICKER
+ # =========================================================================
def _pick_new_key(self):
try:
- categories = kdb.get_categories(self._conn)
- keynotes = kdb.get_keynotes(self._conn)
+ cats = kdb.get_categories(self._conn)
+ kns = kdb.get_keynotes(self._conn)
locks = kdb.get_locks(self._conn)
except System.TimeoutException as toutex:
- forms.alert(toutex.Message)
- return
-
- # collect existing keys
- reserved_keys = [x.key for x in categories]
- reserved_keys.extend([x.key for x in keynotes])
- reserved_keys.extend([x.LockTargetRecordKey for x in locks])
- # ask for a unique new key
- new_key = forms.ask_for_unique_string(
- prompt=self.get_locale_string("EnterUniqueKey"),
- title=self.get_locale_string("ChooseUniqueKey"),
- reserved_values=reserved_keys,
- owner=self)
- return new_key
-
- def _pick_category(self):
- return forms.SelectFromList.show(
- self.all_categories,
- title=self.get_locale_string("SelectParentCategory"),
- name_attr='text',
- item_container_template=self.Resources["treeViewItem"],
- owner=self)
+ forms.alert(toutex.Message); return
+ reserved = [x.key for x in cats]
+ reserved.extend([x.key for x in kns])
+ reserved.extend([x.LockTargetRecordKey for x in locks])
+ return forms.ask_for_unique_string(
+ prompt="Enter a unique key:",
+ title="Choose Unique Key",
+ reserved_values=reserved, owner=self)
+
+ def _pick_parent(self):
+ """Pick any node (category or keynote) as a parent."""
+ cats = self.all_categories
+ kns = self.all_keynotes
+ items = natsorted(
+ ["{} — {}".format(x.key, x.text) for x in cats] +
+ ["{} — {}".format(x.key, x.text) for x in kns],
+ )
+ chosen = forms.SelectFromList.show(
+ items, title="Select Parent", multiselect=False, owner=self)
+ if chosen:
+ return chosen.split(" — ")[0].strip()
+ return None
+
+ # =========================================================================
+ # SEARCH
+ # =========================================================================
def search_txt_changed(self, sender, args):
- """Handle text change in search box."""
- logger.debug('New search term: %s', self.search_term)
if self.search_tb.Text == '':
- self.hide_element(self.clrsearch_b)
+ self.clrsearch_b.Visibility = Windows.Visibility.Collapsed
else:
- self.show_element(self.clrsearch_b)
-
- self._update_ktree_knotes(fast=True)
+ self.clrsearch_b.Visibility = Windows.Visibility.Visible
+
+ # Stop and restart the timer on every keystroke.
+ # The filter won't run until the typing pauses for 300ms.
+ if hasattr(self, '_search_timer'):
+ self._search_timer.Stop()
+ self._search_timer.Start()
+
+ def _on_search_timer_tick(self, sender, args):
+ """Fires when the user stops typing."""
+ self._search_timer.Stop()
+ self._update_full_tree(fast_filter=True)
def clear_search(self, sender, args):
- """Clear search box."""
- self.search_tb.Text = ' '
+ self.search_tb.Text = '' # Removed the space to ensure clean empty string
self.search_tb.Clear()
self.search_tb.Focus()
+ self._update_full_tree(fast_filter=True)
def custom_filter(self, sender, args):
sfilter = forms.SelectFromList.show(
kdb.RKeynoteFilters.get_available_filters(),
- title=self.get_locale_string("SelectFilter"),
- owner=self)
+ title="Select Filter", owner=self)
if sfilter:
self.search_term = sfilter.format_term(self.search_term)
- def selected_category_changed(self, sender, args):
- logger.debug('New category selected: %s', self.selected_category)
- self._update_catedit_buttons()
- self._update_ktree_knotes()
+ # =========================================================================
+ # SELECTION
+ # =========================================================================
def selected_keynote_changed(self, sender, args):
- logger.debug('New keynote selected: %s', self.selected_keynote)
- self._update_catedit_buttons()
- self._update_knoteedit_buttons()
+ self._update_buttons()
+
+ # =========================================================================
+ # KEYBOARD SHORTCUTS
+ # =========================================================================
+
+ def window_keydown(self, sender, args):
+ key = args.Key
+ mods = Windows.Input.Keyboard.Modifiers
+ ctrl = Windows.Input.ModifierKeys.Control
+ shift = Windows.Input.ModifierKeys.Shift
+
+ if key == Windows.Input.Key.F5:
+ self.refresh(sender, args); args.Handled = True
+ elif key == Windows.Input.Key.F2:
+ if self.selected_keynote:
+ self.edit_keynote(sender, args); args.Handled = True
+ elif key == Windows.Input.Key.Delete:
+ if self.selected_keynote:
+ self.remove_keynote(sender, args); args.Handled = True
+ elif key == Windows.Input.Key.N and mods == ctrl:
+ self.add_keynote(sender, args); args.Handled = True
+ elif key == Windows.Input.Key.D and mods == ctrl:
+ if self.selected_keynote:
+ self.duplicate_keynote(sender, args); args.Handled = True
+ elif key == Windows.Input.Key.I and mods == ctrl:
+ self.import_keynotes(sender, args); args.Handled = True
+ elif key == Windows.Input.Key.Tab and mods == shift:
+ self.outdent_keynote(sender, args); args.Handled = True
+ elif key == Windows.Input.Key.Tab and mods == Windows.Input.ModifierKeys.None:
+ self.indent_keynote(sender, args); args.Handled = True
+ elif key == Windows.Input.Key.Up and mods == ctrl:
+ self.move_up(sender, args); args.Handled = True
+ elif key == Windows.Input.Key.Down and mods == ctrl:
+ self.move_down(sender, args); args.Handled = True
+ elif key == Windows.Input.Key.Escape:
+ if self.search_term:
+ self.clear_search(sender, args)
+ else:
+ self.Close()
+ args.Handled = True
+
+ # =========================================================================
+ # DRAG AND DROP
+ # =========================================================================
+
+ def tree_preview_mouse_down(self, sender, args):
+ self._drag_start_point = args.GetPosition(sender)
+
+ def tree_preview_mouse_move(self, sender, args):
+ if self._drag_start_point is None:
+ return
+ if args.LeftButton != Windows.Input.MouseButtonState.Pressed:
+ self._drag_start_point = None; return
+
+ pt = args.GetPosition(sender)
+ diff = self._drag_start_point - pt
+ if abs(diff.X) > System.Windows.SystemParameters.MinimumHorizontalDragDistance \
+ or abs(diff.Y) > System.Windows.SystemParameters.MinimumVerticalDragDistance:
+ sel = self.selected_keynote
+ if sel and not sel.locked:
+ self._is_dragging = True
+ try:
+ data = Windows.DataObject("keynote", sel)
+ Windows.DragDrop.DoDragDrop(
+ self.keynotes_tv, data,
+ Windows.DragDropEffects.Move)
+ except Exception as ex:
+ logger.debug('Drag failed | %s' % ex)
+ finally:
+ self._is_dragging = False
+ self._drag_start_point = None
+
+ def tree_double_click(self, sender, args):
+ if not self._is_dragging and self.selected_keynote:
+ if self.selected_keynote.parent_key:
+ self.edit_keynote(sender, args)
+ else:
+ self.edit_category_inline(sender, args)
+
+ def tree_drag_over(self, sender, args):
+ args.Effects = Windows.DragDropEffects.None
+ if args.Data.GetDataPresent("keynote"):
+ args.Effects = Windows.DragDropEffects.Move
+
+ def tree_item_drag_over(self, sender, args):
+ args.Effects = Windows.DragDropEffects.None
+ if args.Data.GetDataPresent("keynote"):
+ args.Effects = Windows.DragDropEffects.Move
+ # Visual feedback
+ if hasattr(sender, 'Background'):
+ sender.Background = \
+ Windows.Media.SolidColorBrush(
+ Windows.Media.Color.FromArgb(40, 43, 87, 154))
+ args.Handled = True
+
+ def tree_item_drag_leave(self, sender, args):
+ if hasattr(sender, 'Background'):
+ sender.Background = None
+
+ def tree_drop(self, sender, args):
+ pass
+
+ def tree_item_drop(self, sender, args):
+ """Drop handler — reparent the dragged node under the target."""
+ if hasattr(sender, 'Background'):
+ sender.Background = None
+
+ if not args.Data.GetDataPresent("keynote"):
+ return
+ dragged = args.Data.GetData("keynote")
+ if not dragged:
+ return
+
+ target = getattr(sender, 'DataContext', None)
+ if target is None or target == dragged:
+ return
+
+ # Determine new parent key
+ new_parent_key = target.key
+
+ # Don't allow dropping onto self or own children
+ if new_parent_key == dragged.key:
+ return
+
+ # Check for circular reference
+ def _is_descendant(parent_key, child_key, all_kn):
+ """Check if child_key is a descendant of parent_key."""
+ visited = set()
+ stack = [child_key]
+ while stack:
+ current = stack.pop()
+ if current in visited:
+ continue
+ visited.add(current)
+ for kn in all_kn:
+ if kn.parent_key == current:
+ if kn.key == parent_key:
+ return True
+ stack.append(kn.key)
+ return False
+
+ if dragged.parent_key and _is_descendant(
+ new_parent_key, dragged.key, self.all_keynotes):
+ forms.alert("Cannot drop a parent onto its own descendant.")
+ return
+
+ # If dragged is a category, this is more complex — skip for now
+ if dragged.is_category:
+ forms.alert(
+ "Drag top-level groups is not supported.\n"
+ "Use Move Up / Move Down to reorder groups.")
+ return
+
+ if new_parent_key == dragged.parent_key:
+ return # no change
+
+ try:
+ kdb.move_keynote(self._conn, dragged.key, new_parent_key)
+ self._needs_update = True
+ except System.TimeoutException as toutex:
+ forms.alert(toutex.Message)
+ except Exception as ex:
+ forms.alert("Move failed: %s" % ex)
+
+ self._update_full_tree()
+ self._update_status_bar()
+ args.Handled = True
+
+ # =========================================================================
+ # REFRESH
+ # =========================================================================
def refresh(self, sender, args):
if self._conn:
- self._update_ktree()
- self._update_ktree_knotes()
- self.search_tb.Focus()
+ def _query_used():
+ self._used_keysdict = self.get_used_keynote_elements()
+ def _on_done():
+ self._update_full_tree()
+ self._update_status_bar()
+ self.search_tb.Focus()
+ self._revit_run(_query_used, callback=_on_done)
+ else:
+ self.search_tb.Focus()
+
+ # =========================================================================
+ # CATEGORY (GROUP) OPERATIONS
+ # =========================================================================
def add_category(self, sender, args):
try:
- new_cat = \
- EditRecordWindow(self, self._conn,
- kdb.EDIT_MODE_ADD_CATEG).show()
+ new_cat = EditRecordWindow(
+ self, self._conn, kdb.EDIT_MODE_ADD_CATEG).show()
if new_cat:
- self.selected_category = new_cat.key
- # make sure to reload on close
self._needs_update = True
- except System.TimeoutException as toutex:
- forms.alert(toutex.Message,
- expanded="{}::add_category() [timeout]".format(
- self.__class__.__name__))
except Exception as ex:
- forms.alert(str(ex),
- expanded="{}::add_category()".format(
- self.__class__.__name__))
-
- def edit_category(self, sender, args):
- selected_category = self.selected_category
- selected_keynote = self.selected_keynote
- # determine where the category is coming from
- # selected category in drop-down
- if selected_category:
- target_keynote = selected_category
- # or selected category in keynotes list
- elif selected_keynote and not selected_keynote.parent_key:
- target_keynote = selected_keynote
- if target_keynote:
- if target_keynote.locked:
- forms.alert(self.get_locale_string("KeynoteLocked")
- .format('\"%s\"' % target_keynote.owner
- if target_keynote.owner
- else self.get_locale_string("KeynoteLockedUnknownUser")))
- else:
- try:
- EditRecordWindow(self, self._conn,
- kdb.EDIT_MODE_EDIT_CATEG,
- rkeynote=target_keynote).show()
- # make sure to reload on close
- self._needs_update = True
- except System.TimeoutException as toutex:
- forms.alert(toutex.Message,
- expanded="{}::edit_category() [timeout]".format(
- self.__class__.__name__))
- except Exception as ex:
- forms.alert(str(ex),
- expanded="{}::edit_category()".format(
- self.__class__.__name__))
- finally:
- self._update_ktree()
- if selected_keynote:
- self._update_ktree_knotes()
-
- def rekey_category(self, sender, args):
- selected_category = self.selected_category
- selected_keynote = self.selected_keynote
-
- # determine where the category is coming from
- # selected category in drop-down
- if selected_category:
- target_keynote = selected_category
- # or selected category in keynotes list
- elif selected_keynote and not selected_keynote.parent_key:
- target_keynote = selected_keynote
-
- if target_keynote:
- # if category is locked
- if target_keynote.locked:
- forms.alert(self.get_locale_string("CategoryLocked")
- .format('\"%s\"' % target_keynote.owner
- if target_keynote.owner
- else self.get_locale_string("CategoryLockedUnknownUser")))
- # or any of its children are locked
- elif any(x.locked for x in target_keynote.children):
- forms.alert(self.get_locale_string("CategoryChildrenLocked"))
- else:
- try:
- from_key = selected_keynote.key
- to_key = self._pick_new_key()
- if to_key and to_key != from_key:
- # update category to new key
- kdb.update_category_key(self._conn, from_key, to_key)
- # update all children to new key
- with kdb.BulkAction(self._conn):
- for ckey in target_keynote.children:
- kdb.move_keynote(self._conn, ckey.key, to_key)
- # fix all keynote refs in the model
- self.rekey_keynote_refs(from_key, to_key)
- # make sure to reload on close
- self._needs_update = True
- except System.TimeoutException as toutex:
- forms.alert(
- toutex.Message,
- expanded="{}::rekey_category() [timeout]".format(
- self.__class__.__name__))
- except Exception as ex:
- forms.alert(str(ex),
- expanded="{}::rekey_category()".format(
- self.__class__.__name__))
- finally:
- self._update_ktree()
- if selected_keynote:
- self._update_ktree_knotes()
-
- def remove_category(self, sender, args):
- # TODO: ask user which category to move the subkeynotes or delete?
- selected_category = self.selected_category
- if selected_category:
- if selected_category.has_children():
- forms.alert(self.get_locale_string("CategoryHasChildren")
- % selected_category.key)
- elif selected_category.used:
- forms.alert(self.get_locale_string("CategoryUsed")
- % selected_category.key)
- else:
- if forms.alert(self.get_locale_string("PromptToDeleteCategory") % selected_category.key,
- yes=True, no=True):
- try:
- kdb.remove_category(self._conn, selected_category.key)
- # make sure to reload on close
- self._needs_update = True
- except System.TimeoutException as toutex:
- forms.alert(
- toutex.Message,
- expanded="{}::remove_category() [timeout]".format(
- self.__class__.__name__))
- except Exception as ex:
- forms.alert(str(ex),
- expanded="{}::remove_category()".format(
- self.__class__.__name__))
- finally:
- self._update_ktree(active_catkey=self._allcat)
+ forms.alert(str(ex))
+ finally:
+ self._update_full_tree()
+ self._update_status_bar()
- def add_keynote(self, sender, args):
- # try to get parent key from selected keynote or category
- parent_key = None
- if self.selected_keynote:
- parent_key = self.selected_keynote.parent_key
- elif self.selected_category:
- parent_key = self.selected_category.key
- # otherwise ask to select a parent category
- if not parent_key:
- cat = self._pick_category()
- if cat:
- parent_key = cat.key
- # if parent key is available proceed to create keynote
- if parent_key:
+ def edit_category_inline(self, sender, args):
+ """Edit a category (top-level group) via the edit dialog."""
+ sel = self.selected_keynote
+ if sel and sel.is_category and not sel.locked:
try:
- EditRecordWindow(self, self._conn,
- kdb.EDIT_MODE_ADD_KEYNOTE,
- pkey=parent_key).show()
- # make sure to reload on close
+ EditRecordWindow(
+ self, self._conn,
+ kdb.EDIT_MODE_EDIT_CATEG,
+ rkeynote=sel).show()
self._needs_update = True
- except System.TimeoutException as toutex:
- forms.alert(toutex.Message,
- expanded="{}::add_keynote() [timeout]".format(
- self.__class__.__name__))
except Exception as ex:
- forms.alert(str(ex),
- expanded="{}::add_keynote()".format(
- self.__class__.__name__))
+ forms.alert(str(ex))
finally:
- self._update_ktree_knotes()
+ self._update_full_tree()
+ self._update_status_bar()
+
+ # =========================================================================
+ # KEYNOTE CRUD
+ # =========================================================================
- def add_sub_keynote(self, sender, args):
- selected_keynote = self.selected_keynote
- if selected_keynote:
+ def add_keynote(self, sender, args):
+ parent_key = None
+ sel = self.selected_keynote
+ if sel:
+ parent_key = sel.key if sel.is_category else sel.parent_key
+ if not parent_key:
+ parent_key = self._pick_parent()
+ if parent_key:
try:
- EditRecordWindow(self, self._conn,
- kdb.EDIT_MODE_ADD_KEYNOTE,
- pkey=selected_keynote.key).show()
- # make sure to reload on close
+ EditRecordWindow(
+ self, self._conn,
+ kdb.EDIT_MODE_ADD_KEYNOTE,
+ pkey=parent_key).show()
self._needs_update = True
- except System.TimeoutException as toutex:
- forms.alert(toutex.Message,
- expanded="{}::add_sub_keynote() [timeout]".format(
- self.__class__.__name__))
except Exception as ex:
- forms.alert(str(ex),
- expanded="{}::add_sub_keynote()".format(
- self.__class__.__name__))
+ forms.alert(str(ex))
finally:
- self._update_ktree_knotes()
+ self._update_full_tree()
+ self._update_status_bar()
def duplicate_keynote(self, sender, args):
- if self.selected_keynote:
+ sel = self.selected_keynote
+ if sel and sel.parent_key:
try:
EditRecordWindow(
- self,
- self._conn,
+ self, self._conn,
kdb.EDIT_MODE_ADD_KEYNOTE,
- text=self.selected_keynote.text,
- pkey=self.selected_keynote.parent_key).show()
- # make sure to reload on close
+ text=sel.text, pkey=sel.parent_key).show()
self._needs_update = True
- except System.TimeoutException as toutex:
- forms.alert(toutex.Message,
- expanded="{}::duplicate_keynote() [timeout]".format(
- self.__class__.__name__))
except Exception as ex:
- forms.alert(str(ex),
- expanded="{}::duplicate_keynote()".format(
- self.__class__.__name__))
+ forms.alert(str(ex))
finally:
- self._update_ktree_knotes()
+ self._update_full_tree()
+ self._update_status_bar()
+
+ def edit_keynote(self, sender, args):
+ sel = self.selected_keynote
+ if not sel:
+ return
+ if sel.is_category:
+ self.edit_category_inline(sender, args)
+ return
+ try:
+ EditRecordWindow(
+ self, self._conn,
+ kdb.EDIT_MODE_EDIT_KEYNOTE,
+ rkeynote=sel).show()
+ self._needs_update = True
+ except Exception as ex:
+ forms.alert(str(ex))
+ finally:
+ self._update_full_tree()
def remove_keynote(self, sender, args):
- # TODO: ask user which category to move the subkeynotes or delete?
- selected_keynote = self.selected_keynote
- if selected_keynote:
- if selected_keynote.children:
- forms.alert(self.get_locale_string("KeynoteHasChildren")
- % selected_keynote.key)
- elif selected_keynote.used:
- forms.alert(self.get_locale_string("KeynoteUsed")
- % selected_keynote.key)
- else:
- if forms.alert(self.get_locale_string("PromptToDeleteKeynote") % selected_keynote.key,
- yes=True, no=True):
- try:
- kdb.remove_keynote(self._conn, selected_keynote.key)
- # make sure to reload on close
- self._needs_update = True
- except System.TimeoutException as toutex:
- forms.alert(
- toutex.Message,
- expanded="{}::remove_keynote() [timeout]".format(
- self.__class__.__name__))
- except Exception as ex:
- forms.alert(
- str(ex),
- expanded="{}::remove_keynote()".format(
- self.__class__.__name__))
- finally:
- self._update_ktree_knotes()
+ sel = self.selected_keynote
+ if not sel:
+ return
- def edit_keynote(self, sender, args):
- if self.selected_keynote:
- try:
- EditRecordWindow(
- self,
- self._conn,
- kdb.EDIT_MODE_EDIT_KEYNOTE,
- rkeynote=self.selected_keynote).show()
- # make sure to reload on close
- self._needs_update = True
- except System.TimeoutException as toutex:
- forms.alert(toutex.Message,
- expanded="{}::edit_keynote() [timeout]".format(
- self.__class__.__name__))
- except Exception as ex:
- forms.alert(str(ex),
- expanded="{}::edit_keynote()".format(
- self.__class__.__name__))
- finally:
- self._update_ktree_knotes()
+ if sel.is_category:
+ # Removing a category
+ if sel.has_children():
+ forms.alert(
+ "Group '%s' has children. Remove them first."
+ % sel.key)
+ return
+ if sel.used:
+ forms.alert("Group '%s' is in use." % sel.key)
+ return
+ if forms.alert("Delete group '%s'?" % sel.key,
+ yes=True, no=True):
+ try:
+ kdb.remove_category(self._conn, sel.key)
+ self._needs_update = True
+ except Exception as ex:
+ forms.alert(str(ex))
+ else:
+ # Removing a keynote
+ if sel.children:
+ forms.alert(
+ "Keynote '%s' has children. Remove them first."
+ % sel.key)
+ return
+ if sel.used:
+ forms.alert("Keynote '%s' is in use." % sel.key)
+ return
+ if forms.alert("Delete keynote '%s'?" % sel.key,
+ yes=True, no=True):
+ try:
+ kdb.remove_keynote(self._conn, sel.key)
+ self._needs_update = True
+ except Exception as ex:
+ forms.alert(str(ex))
+
+ self._update_full_tree()
+ self._update_status_bar()
def rekey_keynote(self, sender, args):
- selected_keynote = self.selected_keynote
- # if any of its children are locked
- if any(x.locked for x in selected_keynote.children):
- forms.alert(self.get_locale_string("ReKeyKeynoteChildrenLocked"))
- else:
- try:
- from_key = selected_keynote.key
- to_key = self._pick_new_key()
- if to_key and to_key != from_key:
- # update keynote to new key
- kdb.update_keynote_key(self._conn, from_key, to_key)
- # update all children to new key
+ sel = self.selected_keynote
+ if not sel:
+ return
+ if any(x.locked for x in sel.children):
+ forms.alert("Some children are locked — cannot re-key.")
+ return
+ try:
+ from_key = sel.key
+ to_key = self._pick_new_key()
+ if to_key and to_key != from_key:
+ if sel.is_category:
+ kdb.update_category_key(
+ self._conn, from_key, to_key)
with kdb.BulkAction(self._conn):
- for ckey in selected_keynote.children:
- kdb.move_keynote(self._conn, ckey.key, to_key)
- # fix all keynote refs in the model
- self.rekey_keynote_refs(from_key, to_key)
- # make sure to reload on close
+ for child in self.all_keynotes:
+ if child.parent_key == from_key:
+ kdb.move_keynote(
+ self._conn, child.key, to_key)
+ else:
+ kdb.update_keynote_key(
+ self._conn, from_key, to_key)
+ with kdb.BulkAction(self._conn):
+ for child in self.all_keynotes:
+ if child.parent_key == from_key:
+ kdb.move_keynote(
+ self._conn, child.key, to_key)
+ # Update Revit element refs (async via ExternalEvent)
+ fk, tk = from_key, to_key
+ self._revit_run(lambda: self._rekey_refs(fk, tk))
self._needs_update = True
- except System.TimeoutException as toutex:
- forms.alert(toutex.Message,
- expanded="{}::rekey_keynote() [timeout]".format(
- self.__class__.__name__))
- except Exception as ex:
- forms.alert(str(ex),
- expanded="{}::rekey_keynote()".format(
- self.__class__.__name__))
- finally:
- self._update_ktree_knotes()
+ except Exception as ex:
+ forms.alert(str(ex))
- def rekey_keynote_refs(self, from_key, to_key):
- with revit.Transaction(self.get_locale_string("ReKeyKeynoteTransaction").format(from_key)):
+ self._update_full_tree()
+ self._update_status_bar()
+
+ def _rekey_refs(self, from_key, to_key):
+ with revit.Transaction("Re-Key {}".format(from_key)):
for kid in self.get_used_keynote_elements().get(from_key, []):
kel = revit.doc.GetElement(kid)
if kel:
- key_param = kel.Parameter[DB.BuiltInParameter.KEY_VALUE]
- if key_param:
- key_param.Set(to_key)
-
- def recat_keynote(self, sender, args):
- selected_keynote = self.selected_keynote
- # if any of its children are locked
- if any(x.locked for x in selected_keynote.children):
- forms.alert(self.get_locale_string("ReCatKeynoteChildrenLocked"))
- else:
- try:
- from_cat = selected_keynote.parent_key
- to_cat = self._pick_category()
- if to_cat and to_cat.key != from_cat:
- kdb.move_keynote(self._conn, selected_keynote.key, to_cat.key)
- # make sure to reload on close
- self._needs_update = True
- except System.TimeoutException as toutex:
- forms.alert(toutex.Message,
- expanded="{}::recat_keynote() [timeout]".format(
- self.__class__.__name__))
- except Exception as ex:
- forms.alert(str(ex),
- expanded="{}::recat_keynote()".format(
- self.__class__.__name__))
- finally:
- self._update_ktree_knotes()
+ p = kel.Parameter[DB.BuiltInParameter.KEY_VALUE]
+ if p:
+ p.Set(to_key)
+
+ # =========================================================================
+ # TEXT CAPITALIZATION (quick apply without opening edit dialog)
+ # =========================================================================
+
+ def show_case_menu(self, sender, args):
+ """Open the capitalization context menu on the button."""
+ self.caseMenu.PlacementTarget = sender
+ self.caseMenu.IsOpen = True
+
+ def _apply_case(self, transform_fn):
+ """Apply a text transformation to the selected keynote/category."""
+ sel = self.selected_keynote
+ if not sel or sel.locked:
+ return
+ new_text = transform_fn(sel.text)
+ if new_text == sel.text:
+ return
+ try:
+ if sel.is_category:
+ kdb.update_category_title(self._conn, sel.key, new_text)
+ else:
+ kdb.update_keynote_text(self._conn, sel.key, new_text)
+ self._needs_update = True
+ except System.TimeoutException as toutex:
+ forms.alert(toutex.Message); return
+ except Exception as ex:
+ forms.alert("Case change failed: %s" % ex); return
+ self._update_full_tree()
+
+ def to_upper(self, sender, args):
+ self._apply_case(lambda t: t.upper())
+
+ def to_lower(self, sender, args):
+ self._apply_case(lambda t: t.lower())
+
+ def to_title(self, sender, args):
+ self._apply_case(lambda t: t.title())
+
+ def to_sentence(self, sender, args):
+ self._apply_case(lambda t: t[:1].upper() + t[1:].lower() if t else t)
+
+ # =========================================================================
+ # FIND / PLACE
+ # =========================================================================
def show_keynote(self, sender, args):
- if self.selected_keynote:
- self.Close()
- kids = self.get_used_keynote_elements() \
- .get(self.selected_keynote.key, [])
+ """Show keynote usage in pyRevit output — keeps the window open."""
+ sel = self.selected_keynote
+ if not sel:
+ return
+ key = sel.key
+ used_snapshot = dict(self._used_keysdict)
+ kids = used_snapshot.get(key, [])
+ if not kids:
+ self.statusLeft.Text = u"Keynote '{}' — not placed in model".format(key)
+ return
+ def _do():
for kid in kids:
source = viewname = ''
kel = revit.doc.GetElement(kid)
+ if kel is None:
+ continue
ehist = revit.query.get_history(kel)
- if kel:
- source = kel.Parameter[
- DB.BuiltInParameter.KEY_SOURCE_PARAM].AsString()
- vel = revit.doc.GetElement(kel.OwnerViewId)
- if vel:
- viewname = revit.query.get_name(vel)
- # prepare report
- report = \
- self.get_locale_string("KeynoteName").format(output.linkify(kid), source, viewname)
-
+ p = kel.Parameter[DB.BuiltInParameter.KEY_SOURCE_PARAM]
+ if p:
+ source = p.AsString()
+ vel = revit.doc.GetElement(kel.OwnerViewId)
+ if vel:
+ viewname = revit.query.get_name(vel)
+ report = "Keynote: {} | Source: {} | View: {}".format(
+ output.linkify(kid), source, viewname)
if ehist:
- report += \
- self.get_locale_string("LastEditKeynote").format(ehist.last_changed_by)
-
+ report += " | Last edit: %s" % ehist.last_changed_by
print(report)
+ def _update_status():
+ self.statusLeft.Text = \
+ u"Keynote '{}' — {} placements shown in output".format(
+ key, len(kids))
+ self._revit_run(_do, callback=_update_status)
def place_keynote(self, sender, args):
+ sel = self.selected_keynote
+ if not sel:
+ return
+ sel_key = sel.key
+ postcmd = self.postable_keynote_command
self.Close()
- keynotes_cat = \
- revit.query.get_category(DB.BuiltInCategory.OST_KeynoteTags)
- if keynotes_cat and self.selected_keynote:
- knote_key = self.selected_keynote.key
- def_kn_typeid = revit.doc.GetDefaultFamilyTypeId(keynotes_cat.Id)
- kn_type = revit.doc.GetElement(def_kn_typeid)
- if kn_type:
- try:
- # place keynotes and get placed keynote elements
- DocumentEventUtils.PostCommandAndUpdateNewElementProperties(
- HOST_APP.uiapp,
- revit.doc,
- self.postable_keynote_command,
- self.get_locale_string("UpdateKeynotesTransactionName"),
- DB.BuiltInParameter.KEY_VALUE,
- knote_key
- )
- except Exception as ex:
- forms.alert(str(ex),
- expanded="{}::place_keynote()".format(
- self.__class__.__name__))
-
- def enable_history(self, sender, args):
- forms.alert(self.get_locale_string("ComingSoon"))
-
- def show_category_history(self, sender, args):
- forms.alert(self.get_locale_string("ComingSoon"))
-
- def show_keynote_history(self, sender, args):
- forms.alert(self.get_locale_string("ComingSoon"))
+ def _do():
+ keynotes_cat = \
+ revit.query.get_category(DB.BuiltInCategory.OST_KeynoteTags)
+ if keynotes_cat:
+ def_id = revit.doc.GetDefaultFamilyTypeId(keynotes_cat.Id)
+ if revit.doc.GetElement(def_id):
+ DocumentEventUtils \
+ .PostCommandAndUpdateNewElementProperties(
+ HOST_APP.uiapp, revit.doc,
+ postcmd,
+ "Update Keynotes",
+ DB.BuiltInParameter.KEY_VALUE, sel_key)
+ self._revit_run(_do)
+
+ # =========================================================================
+ # FILE OPERATIONS
+ # =========================================================================
def change_keynote_file(self, sender, args):
- self._change_kfile()
- self._determine_kfile()
- self._connect_kfile()
- # make sure to reload on close
- self._needs_update = True
- self.Close()
+ kfile = forms.pick_file('txt')
+ if not kfile:
+ return
+ def _set_file():
+ with revit.Transaction("Set Keynote File"):
+ revit.update.set_keynote_file(kfile, doc=revit.doc)
+ def _reload():
+ self._determine_kfile()
+ self._connect_kfile()
+ self._needs_update = True
+ try:
+ self._used_keysdict = self.get_used_keynote_elements()
+ except Exception as ex:
+ logger.debug('Refresh used keys failed | %s' % ex)
+ self._update_full_tree()
+ self._update_status_bar()
+ self._revit_run(_set_file, callback=_reload)
def show_keynote_file(self, sender, args):
coreutils.show_entry_in_explorer(self._kfile)
def import_keynotes(self, sender, args):
- # verify existing keynotes when importing
- # maybe allow for merge conflict?
kfile = forms.pick_file('txt')
if kfile:
- logger.debug('Importing keynotes from: %s' % kfile)
- res = forms.alert(self.get_locale_string("SkipDuplicates"),
+ res = forms.alert("Skip duplicate entries?",
yes=True, no=True)
try:
- kdb.import_legacy_keynotes(self._conn, kfile, skip_dup=res)
- except System.TimeoutException as toutex:
- forms.alert(toutex.Message,
- expanded="{}::import_keynotes() [timeout]".format(
- self.__class__.__name__))
+ kdb.import_legacy_keynotes(
+ self._conn, kfile, skip_dup=res)
except Exception as ex:
- logger.debug('Importing legacy keynotes failed | %s' % ex)
- forms.alert(str(ex),
- expanded="{}::import_keynotes()".format(
- self.__class__.__name__))
+ forms.alert("Import failed: %s" % ex)
finally:
- self._update_ktree(active_catkey=self._allcat)
- self._update_ktree_knotes()
+ self._update_full_tree()
+ self._update_status_bar()
def export_keynotes(self, sender, args):
kfile = forms.save_file('txt')
if kfile:
- logger.debug('Exporting keynotes to: %s' % kfile)
try:
kdb.export_legacy_keynotes(self._conn, kfile)
- except System.TimeoutException as toutex:
- forms.alert(toutex.Message,
- expanded="{}::export_keynotes()".format(
- self.__class__.__name__))
+ except Exception as ex:
+ forms.alert(str(ex))
def export_visible_keynotes(self, sender, args):
kfile = forms.save_file('txt')
if kfile:
- logger.debug('Exporting visible keynotes to: %s' % kfile)
- include_list = set()
- for rkey in self.current_keynotes:
- include_list.update(rkey.collect_keys())
+ include = set()
+ for rk in (self.current_keynotes or []):
+ include.update(rk.collect_keys())
try:
- kdb.export_legacy_keynotes(self._conn,
- kfile,
- include_keys=include_list)
- except System.TimeoutException as toutex:
- forms.alert(toutex.Message,
- expanded="{}::export_visible_keynotes()".format(
- self.__class__.__name__))
+ kdb.export_legacy_keynotes(
+ self._conn, kfile, include_keys=include)
+ except Exception as ex:
+ forms.alert(str(ex))
+
+ # =========================================================================
+ # CLOSE
+ # =========================================================================
def update_model(self, sender, args):
+ """Queue keynote update transaction and keep window open."""
+ if self._needs_update:
+ def _do_update():
+ with revit.Transaction("Update Keynotes"):
+ revit.update.update_linked_keynotes(doc=revit.doc)
+
+ def _on_update_complete():
+ self._needs_update = False
+ forms.alert("Revit model updated successfully.", title="Success")
+
+ self._revit_run(_do_update, callback=_on_update_complete)
+ else:
+ forms.alert("The Revit model is already up to date.", title="Up to Date")
+
+ def _finalize_close(self):
+ """Called on WPF thread after Revit update completes."""
+ self._needs_update = False
+ self._close_pending = True
self.Close()
def window_closing(self, sender, args):
- # if keynote file is external, ask for unlock
- if self._kfile_handler == 'adc':
- # sync has already happened on last file write
- adc.unlock_file(self._kfile_ext)
+ global _active_window
+
+ # If we haven't synced yet and user closed via X button, ask
+ if self._needs_update and not self._close_pending:
+ res = forms.alert(
+ "Keynote file has been modified.\n"
+ "Sync changes to the Revit model before closing?",
+ yes=True, no=True)
+ if res:
+ args.Cancel = True
+ def _do_update():
+ with revit.Transaction("Update Keynotes"):
+ revit.update.update_linked_keynotes(doc=revit.doc)
+ self._close_pending = True
+ self._revit_run(_do_update, callback=self._finalize_close)
+ return
- if self._needs_update:
- with revit.Transaction(self.get_locale_string("UpdateKeynotesTransactionName")):
- revit.update.update_linked_keynotes(doc=revit.doc)
+ # Proceed with cleanup
+ # Remove WndProc hook to prevent leaks
+ if self._hwnd_source:
+ try:
+ self._hwnd_source.RemoveHook(self._wnd_proc)
+ except Exception:
+ pass
+ self._hwnd_source = None
+ if self._kfile_handler == 'adc':
+ try:
+ adc.unlock_file(self._kfile_ext)
+ except Exception:
+ pass
try:
self.save_config()
- except Exception as saveex:
- logger.debug('Saving configuration failed | %s' % saveex)
- forms.alert(str(saveex),
- expanded="{}::window_closing()".format(
- self.__class__.__name__))
-
+ except Exception as ex:
+ logger.debug('Save config failed | %s' % ex)
if self._conn:
- # manuall call dispose to release locks
try:
self._conn.Dispose()
except Exception:
pass
+ _active_window = None
+
+# =============================================================================
+# ENTRY POINT
+# =============================================================================
try:
- KeynoteManagerWindow(
- xaml_file_name='KeynoteManagerWindow.xaml',
- reset_config=__shiftclick__ #pylint: disable=undefined-variable
- ).show(modal=True)
+ # Singleton: if already open, bring to front
+ if _active_window and _active_window.IsLoaded:
+ _active_window.Activate()
+ _active_window.WindowState = framework.Windows.WindowState.Normal
+ else:
+ _active_window = KeynoteManagerWindow(
+ xaml_file_name='KeynoteManagerWindow.xaml',
+ reset_config=__shiftclick__ #pylint: disable=undefined-variable
+ )
+ _active_window.show(modal=False)
except Exception as kmex:
forms.alert(str(kmex), expanded="Creating keynote manager window")
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Drawing Set.panel/Print Sheets.pushbutton/script.py b/extensions/pyRevitTools.extension/pyRevit.tab/Drawing Set.panel/Print Sheets.pushbutton/script.py
index 5e242d8ab..f6a7a0a1f 100644
--- a/extensions/pyRevitTools.extension/pyRevit.tab/Drawing Set.panel/Print Sheets.pushbutton/script.py
+++ b/extensions/pyRevitTools.extension/pyRevit.tab/Drawing Set.panel/Print Sheets.pushbutton/script.py
@@ -29,7 +29,7 @@
import locale
from collections import namedtuple
-from pyrevit import HOST_APP
+from pyrevit import HOST_APP, EXEC_PARAMS
from pyrevit import framework
from pyrevit.framework import Windows, Drawing, ObjectModel, Forms, List
from pyrevit import coreutils
@@ -1690,7 +1690,7 @@ def cleanup_sheetnumbers(doc):
revit.selection.get_selection().clear()
# TODO: add copy filenames to sheet list
-if __shiftclick__: # pylint: disable=E0602
+if EXEC_PARAMS.config_mode:
open_docs = forms.select_open_docs(check_more_than_one=False)
if open_docs:
for open_doc in open_docs:
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Drawing Set.panel/Sheets.pulldown/Copy Sheets to Open Documents.pushbutton/script.py b/extensions/pyRevitTools.extension/pyRevit.tab/Drawing Set.panel/Sheets.pulldown/Copy Sheets to Open Documents.pushbutton/script.py
index f009f3a72..08ff3613a 100644
--- a/extensions/pyRevitTools.extension/pyRevit.tab/Drawing Set.panel/Sheets.pulldown/Copy Sheets to Open Documents.pushbutton/script.py
+++ b/extensions/pyRevitTools.extension/pyRevit.tab/Drawing Set.panel/Sheets.pulldown/Copy Sheets to Open Documents.pushbutton/script.py
@@ -1,3 +1,4 @@
+# -*- coding: utf-8 -*-
import sys
from pyrevit.framework import List
@@ -388,6 +389,105 @@ def apply_detail_number(original_vport, nvport):
print("\t\t\tSkipping detail number preservation (option not checked)")
+def get_source_vport_data(doc, vport, sheet_view):
+ """Capture source viewport placement and view title data.
+
+ Uses PROPERTY access (vport.LabelOffset, vport.LabelLineLength)
+ rather than method calls — IronPython 2.7 requires property syntax
+ for .NET properties exposed with get_/set_ accessors.
+
+ Also captures get_BoundingBox(sheet) which includes the view title
+ extent for a final position verification pass.
+ """
+ data = {
+ "box_center": vport.GetBoxCenter(),
+ "label_offset": None,
+ "label_line_length": None,
+ "bbox_min": None,
+ "bbox_max": None,
+ }
+ try:
+ data["label_offset"] = vport.LabelOffset
+ except AttributeError:
+ logger.debug("LabelOffset not available (requires Revit 2022+)")
+ except Exception as e:
+ logger.debug("Could not read LabelOffset: {}".format(e))
+ try:
+ data["label_line_length"] = vport.LabelLineLength
+ except AttributeError:
+ logger.debug("LabelLineLength not available (requires Revit 2022+)")
+ except Exception as e:
+ logger.debug("Could not read LabelLineLength: {}".format(e))
+ try:
+ bb = vport.get_BoundingBox(sheet_view)
+ if bb:
+ data["bbox_min"] = bb.Min
+ data["bbox_max"] = bb.Max
+ except Exception as e:
+ logger.debug("Could not read BoundingBox: {}".format(e))
+ return data
+
+
+def apply_vport_label_props(dest_doc, nvport_id, src_data):
+ """Set label offset and line length using PROPERTY syntax."""
+ label_offset = src_data.get("label_offset")
+ label_line_len = src_data.get("label_line_length")
+ if label_offset is None and label_line_len is None:
+ return
+ try:
+ with revit.Transaction(
+ "Set View Title Properties",
+ doc=dest_doc,
+ swallow_errors=True,
+ ):
+ vp = dest_doc.GetElement(nvport_id)
+ if label_offset is not None:
+ try:
+ vp.LabelOffset = label_offset
+ except AttributeError:
+ logger.debug("LabelOffset not available (requires Revit 2022+)")
+ if label_line_len is not None:
+ try:
+ vp.LabelLineLength = label_line_len
+ except AttributeError:
+ logger.debug("LabelLineLength not available (requires Revit 2022+)")
+ except Exception as e:
+ logger.warning("Label property set failed: {}".format(e))
+
+
+def correct_vport_by_bbox(dest_doc, nvport_id, src_data, dest_sheet):
+ """Verify and correct viewport position using BoundingBox(sheet).
+
+ get_BoundingBox(sheet) includes the view title extent. By comparing
+ source vs destination Min points, any residual drift is detected and
+ corrected with MoveElement (rigid translation).
+ """
+ src_bb_min = src_data.get("bbox_min")
+ if src_bb_min is None:
+ return
+ try:
+ vp = dest_doc.GetElement(nvport_id)
+ dst_bb = vp.get_BoundingBox(dest_sheet)
+ if dst_bb is None:
+ return
+ dx = src_bb_min.X - dst_bb.Min.X
+ dy = src_bb_min.Y - dst_bb.Min.Y
+ if abs(dx) <= 1e-9 and abs(dy) <= 1e-9:
+ return
+ with revit.Transaction(
+ "Align View Title Position",
+ doc=dest_doc,
+ swallow_errors=True,
+ ):
+ DB.ElementTransformUtils.MoveElement(
+ dest_doc,
+ nvport_id,
+ DB.XYZ(dx, dy, 0),
+ )
+ except Exception as e:
+ logger.warning("BBox position correction failed: {}".format(e))
+
+
def copy_sheet_viewports(activedoc, source_sheet, dest_doc, dest_sheet):
existing_views = [
dest_doc.GetElement(x).ViewId for x in dest_sheet.GetAllViewports()
@@ -412,15 +512,29 @@ def copy_sheet_viewports(activedoc, source_sheet, dest_doc, dest_sheet):
if new_view.Id not in existing_views:
print("\t\t\tPlacing copied view on sheet.")
+ src_data = get_source_vport_data(
+ activedoc, vport, source_sheet
+ )
+
with revit.Transaction("Place View on Sheet", doc=dest_doc):
nvport = DB.Viewport.Create(
- dest_doc, dest_sheet.Id, new_view.Id, vport.GetBoxCenter()
+ dest_doc,
+ dest_sheet.Id,
+ new_view.Id,
+ src_data["box_center"],
)
-
apply_detail_number(vport, nvport)
if nvport:
- apply_viewport_type(activedoc, vport_id, dest_doc, nvport.Id)
+ apply_viewport_type(
+ activedoc, vport_id, dest_doc, nvport.Id
+ )
+ apply_vport_label_props(
+ dest_doc, nvport.Id, src_data
+ )
+ correct_vport_by_bbox(
+ dest_doc, nvport.Id, src_data, dest_sheet
+ )
else:
print("\t\t\tView already exists on the sheet.")
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Drawing Set.panel/Sheets.pulldown/List TitleBlocks on Sheets.pushbutton/script.py b/extensions/pyRevitTools.extension/pyRevit.tab/Drawing Set.panel/Sheets.pulldown/List TitleBlocks on Sheets.pushbutton/script.py
index f3eeb7b34..2bb911e98 100644
--- a/extensions/pyRevitTools.extension/pyRevit.tab/Drawing Set.panel/Sheets.pulldown/List TitleBlocks on Sheets.pushbutton/script.py
+++ b/extensions/pyRevitTools.extension/pyRevit.tab/Drawing Set.panel/Sheets.pulldown/List TitleBlocks on Sheets.pushbutton/script.py
@@ -6,47 +6,7 @@
"""
# pylint: disable=import-error,invalid-name,broad-except,superfluous-parens
-from pyrevit import revit
-from pyrevit import forms
-from pyrevit import script
-
-output = script.get_output()
-logger = script.get_logger()
-
-
-def get_source_sheets():
- sheet_elements = forms.select_sheets(
- button_name="List TitleBlocks",
- use_selection=True,
- include_placeholder=False,
- )
- if not sheet_elements:
- script.exit()
- return sheet_elements
-
-
-def print_titleblocks(sheets):
- all_tblocks = []
- for sheet in sheets:
- tblocks = revit.query.get_sheet_tblocks(sheet)
- all_tblocks.extend([x.Id for x in tblocks])
- for tblock in tblocks:
- print(
- "SHEET: {0} - {1}\t\tTITLEBLOCK: {2} {3}".format(
- sheet.SheetNumber,
- sheet.Name,
- tblock.Name,
- output.linkify(tblock.Id),
- )
- )
- print(
- "{}".format(output.linkify(all_tblocks, title="Select All TitleBlocks"))
- )
-
-
-"""Select title blocks on selected sheets for batch editing."""
-# pylint: disable=import-error,invalid-name,broad-except,superfluous-parens
-from pyrevit import revit
+from pyrevit import revit, EXEC_PARAMS
from pyrevit import forms
from pyrevit import script
@@ -85,7 +45,7 @@ def print_titleblocks(sheets):
# orchestrate
-if __shiftclick__:
+if EXEC_PARAMS.config_mode:
selection = revit.get_selection()
sheets = get_source_sheets()
all_tblocks = []
@@ -94,4 +54,4 @@ def print_titleblocks(sheets):
all_tblocks.extend([x.Id for x in tblocks])
selection.set_to(all_tblocks)
else:
- print_titleblocks(get_source_sheets())
\ No newline at end of file
+ print_titleblocks(get_source_sheets())
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Drawing Set.panel/Sheets.pulldown/Pin All Viewports.pushbutton/script.py b/extensions/pyRevitTools.extension/pyRevit.tab/Drawing Set.panel/Sheets.pulldown/Pin All Viewports.pushbutton/script.py
index 91268c120..8041ade49 100644
--- a/extensions/pyRevitTools.extension/pyRevit.tab/Drawing Set.panel/Sheets.pulldown/Pin All Viewports.pushbutton/script.py
+++ b/extensions/pyRevitTools.extension/pyRevit.tab/Drawing Set.panel/Sheets.pulldown/Pin All Viewports.pushbutton/script.py
@@ -4,7 +4,7 @@
Pin all viewports on active sheet.
"""
-from pyrevit import revit, DB
+from pyrevit import revit, DB, EXEC_PARAMS
from pyrevit import script
from pyrevit import forms
@@ -29,7 +29,7 @@ def pin_viewports(sheet_list):
alreadypinnedcount))
-if __shiftclick__:
+if EXEC_PARAMS.config_mode:
if isinstance(revit.active_view, DB.ViewSheet):
sel_sheets = [revit.active_view]
else:
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Drawing Set.panel/Sheets.pulldown/Rename PDF Sheets.pushbutton/script.py b/extensions/pyRevitTools.extension/pyRevit.tab/Drawing Set.panel/Sheets.pulldown/Rename PDF Sheets.pushbutton/script.py
index fc92e1f9c..74d36000f 100644
--- a/extensions/pyRevitTools.extension/pyRevit.tab/Drawing Set.panel/Sheets.pulldown/Rename PDF Sheets.pushbutton/script.py
+++ b/extensions/pyRevitTools.extension/pyRevit.tab/Drawing Set.panel/Sheets.pulldown/Rename PDF Sheets.pushbutton/script.py
@@ -5,7 +5,7 @@
from pathlib import Path
-from pyrevit import forms
+from pyrevit import forms, EXEC_PARAMS
def renamepdf(old_name):
@@ -19,7 +19,7 @@ def renamepdf(old_name):
# if user shift-clicks, default to user desktop,
# otherwise ask for a folder containing the PDF files
-if __shiftclick__:
+if EXEC_PARAMS.config_mode:
basefolder = op.expandvars(r"%userprofile%\desktop")
else:
basefolder = forms.pick_folder()
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Drawing Set.panel/views.stack/Schedules.pulldown/Export Schedules To CSV.pushbutton/script.py b/extensions/pyRevitTools.extension/pyRevit.tab/Drawing Set.panel/views.stack/Schedules.pulldown/Export Schedules To CSV.pushbutton/script.py
index 16eb3b4e2..24ced696d 100644
--- a/extensions/pyRevitTools.extension/pyRevit.tab/Drawing Set.panel/views.stack/Schedules.pulldown/Export Schedules To CSV.pushbutton/script.py
+++ b/extensions/pyRevitTools.extension/pyRevit.tab/Drawing Set.panel/views.stack/Schedules.pulldown/Export Schedules To CSV.pushbutton/script.py
@@ -9,7 +9,7 @@
from pyrevit import forms
from pyrevit import coreutils
-from pyrevit import revit, DB
+from pyrevit import revit, DB, EXEC_PARAMS
from pyrevit import script
from pyrevit.userconfig import user_config
@@ -24,7 +24,7 @@
basefolder = ''
# if user shift-clicks, default to user desktop,
# otherwise ask for a folder containing the PDF files
-if __shiftclick__: #pylint: disable=E0602
+if EXEC_PARAMS.config_mode:
destopt, switches = forms.CommandSwitchWindow.show(
["My Desktop", "Where Revit Model Is", "My Downloads", "User Select"],
switches=["Open CSV File","Include Headers"],
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/3D.pulldown/Measure.pushbutton/cone.STL b/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/3D.pulldown/Measure.pushbutton/cone.STL
new file mode 100644
index 000000000..d86ba4351
Binary files /dev/null and b/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/3D.pulldown/Measure.pushbutton/cone.STL differ
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/3D.pulldown/Measure.pushbutton/measure3d.xaml b/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/3D.pulldown/Measure.pushbutton/measure3d.xaml
index f9f841efc..28298d267 100644
--- a/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/3D.pulldown/Measure.pushbutton/measure3d.xaml
+++ b/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/3D.pulldown/Measure.pushbutton/measure3d.xaml
@@ -29,7 +29,7 @@
-
+
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/3D.pulldown/Measure.pushbutton/script.py b/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/3D.pulldown/Measure.pushbutton/script.py
index ad892ae51..d0640694c 100644
--- a/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/3D.pulldown/Measure.pushbutton/script.py
+++ b/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/3D.pulldown/Measure.pushbutton/script.py
@@ -1,13 +1,13 @@
# -*- coding: utf-8 -*-
from collections import deque
-from math import pi, atan, sqrt
-from pyrevit import HOST_APP, revit, forms, script
+from math import pi, atan, sqrt, acos
+from pyrevit import revit, forms, script
from pyrevit import DB
from Autodesk.Revit.Exceptions import InvalidOperationException
logger = script.get_logger()
-doc = HOST_APP.doc
+doc = revit.doc
uidoc = revit.uidoc
# Length
length_format_options = doc.GetUnits().GetFormatOptions(DB.SpecTypeId.Length)
@@ -28,18 +28,17 @@
# Global variables
measure_window = None
-measure_handler_event = None
dc3d_server = None
MAX_HISTORY = 5
measurement_history = deque(maxlen=MAX_HISTORY)
# Visual aid configuration
-CUBE_SIZE = 0.3 # in feet (cube side length)
LINE_COLOR_X = DB.ColorWithTransparency(255, 0, 0, 0) # Red
LINE_COLOR_Y = DB.ColorWithTransparency(0, 255, 0, 0) # Green
LINE_COLOR_Z = DB.ColorWithTransparency(0, 0, 255, 0) # Blue
LINE_COLOR_DIAG = DB.ColorWithTransparency(200, 200, 0, 0) # Dark Yellow
-CUBE_COLOR = DB.ColorWithTransparency(255, 165, 0, 50) # Orange
+CONE_COLOR = DB.ColorWithTransparency(255, 165, 0, 50) # Orange
+CONE_SCALE = 0.05
def calculate_distances(point1, point2):
@@ -92,16 +91,31 @@ def format_point(point):
return "({:.2f}, {:.2f}, {:.2f})".format(x, y, z)
-def create_cube_mesh(center, size, color):
- """Create a cube mesh centered at the given point."""
- half_size = size / 2.0
+def create_cone_mesh(center, scale, color, view):
+ cone_path = script.get_bundle_file("cone.STL")
+ cam_forward = view.GetOrientation().ForwardDirection.Normalize()
+ cone_axis = cam_forward.Negate()
- bb = DB.BoundingBoxXYZ()
- bb.Min = DB.XYZ(-half_size, -half_size, -half_size)
- bb.Max = DB.XYZ(half_size, half_size, half_size)
- bb.Transform = DB.Transform.CreateTranslation(center)
+ z_axis = DB.XYZ.BasisZ
+ dot = max(-1.0, min(1.0, z_axis.DotProduct(cone_axis)))
- mesh = revit.dc3dserver.Mesh.from_boundingbox(bb, color, black_edges=True)
+ if dot >= 0.9999999:
+ rotation = DB.Transform.Identity
+ elif dot <= -0.9999999:
+ perp = z_axis.CrossProduct(DB.XYZ.BasisX)
+ if perp.GetLength() < 1e-6:
+ perp = z_axis.CrossProduct(DB.XYZ.BasisY)
+ rotation = DB.Transform.CreateRotationAtPoint(perp.Normalize(), pi, DB.XYZ.Zero)
+ else:
+ axis = z_axis.CrossProduct(cone_axis).Normalize()
+ angle = acos(dot)
+ rotation = DB.Transform.CreateRotationAtPoint(axis, angle, DB.XYZ.Zero)
+
+ translation = DB.Transform.CreateTranslation(center)
+ transform = translation.Multiply(rotation).ScaleBasis(scale)
+ mesh = revit.dc3dserver.Mesh.from_stl(
+ cone_path, color, transform=transform, black_edges=True
+ )
return mesh
@@ -119,7 +133,7 @@ def create_and_show_point_mesh(point1):
global dc3d_server
try:
new_meshes = []
- new_meshes.append(create_cube_mesh(point1, CUBE_SIZE, CUBE_COLOR))
+ new_meshes.append(create_cone_mesh(point1, CONE_SCALE, CONE_COLOR, doc.ActiveView))
if dc3d_server:
existing_meshes = dc3d_server.meshes if dc3d_server.meshes else []
dc3d_server.meshes = existing_meshes + new_meshes
@@ -132,9 +146,9 @@ def create_measurement_meshes(point1, point2):
"""Create all visual aid meshes for a measurement."""
meshes = []
- # Create cubes at measurement points
+ # Create cones at measurement points
# Mesh for point1 already immediately created and shown on selection
- meshes.append(create_cube_mesh(point2, CUBE_SIZE, CUBE_COLOR))
+ meshes.append(create_cone_mesh(point2, CONE_SCALE, CONE_COLOR, doc.ActiveView))
# Determine the work plane (use the lowest Z for X and Y lines)
lower_z = min(point1.Z, point2.Z)
@@ -168,17 +182,21 @@ def create_measurement_meshes(point1, point2):
def perform_measurement():
"""Perform the measurement workflow: pick points, create aids, update UI."""
# Add 3D view validation
- if not forms.check_viewtype(uidoc.ActiveView, DB.ViewType.ThreeD):
+ if not forms.check_viewtype(doc.ActiveView, DB.ViewType.ThreeD):
return
try:
- with forms.WarningBar(title=measure_window.get_locale_string("WarningBarPickFirstPoint")):
+ with forms.WarningBar(
+ title=measure_window.get_locale_string("WarningBarPickFirstPoint")
+ ):
point1 = revit.pick_elementpoint(world=True)
if not point1:
return
create_and_show_point_mesh(point1)
- with forms.WarningBar(title=measure_window.get_locale_string("WarningBarPickSecondPoint")):
+ with forms.WarningBar(
+ title=measure_window.get_locale_string("WarningBarPickSecondPoint")
+ ):
point2 = revit.pick_elementpoint(world=True)
if not point2:
return
@@ -204,7 +222,9 @@ def perform_measurement():
measure_window.slope_text.Text = measure_window.get_locale_string("SlopeFormat").format(format_slope(slope))
# Add to history
- history_entry = measure_window.get_locale_string("MeasurementHistoryEntry").format(
+ history_entry = (
+ measure_window.get_locale_string("MeasurementHistoryEntry")
+ .format(
len(measurement_history) + 1,
format_point(point1),
format_point(point2),
@@ -213,12 +233,15 @@ def perform_measurement():
format_distance(dz),
format_distance(diagonal),
format_slope(slope),
- ).lstrip()
+ )
+ .lstrip()
+ )
measurement_history.append(history_entry)
# Update history display
history_text = "\n".join(measurement_history)
measure_window.history_text.Text = history_text
+ measure_window.history_scroll.ScrollToEnd()
# Automatically start the next measurement
revit.events.execute_in_revit_context(perform_measurement)
@@ -230,9 +253,7 @@ def perform_measurement():
title=measure_window.get_locale_string("AlertMeasurementErrorTitle"),
)
except Exception as ex:
- logger.exception(
- "Error during measurement: {}".format(ex)
- )
+ logger.exception("Error during measurement: {}".format(ex))
forms.alert(
measure_window.get_locale_string("AlertUnexpectedError"),
title=measure_window.get_locale_string("AlertMeasurementErrorTitle"),
@@ -303,7 +324,7 @@ def window_closed(self, sender, args):
def main():
"""Main entry point for the tool."""
- global measure_window, measure_handler_event
+ global measure_window
global dc3d_server
dc3d_server = revit.dc3dserver.Server(
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/3D.pulldown/Section Box Navigator.pushbutton/script.py b/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/3D.pulldown/Section Box Navigator.pushbutton/script.py
index a29320c36..ddb0cac8a 100644
--- a/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/3D.pulldown/Section Box Navigator.pushbutton/script.py
+++ b/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/3D.pulldown/Section Box Navigator.pushbutton/script.py
@@ -36,33 +36,60 @@
# Initialize Variables
# --------------------
-uidoc = revit.uidoc
-doc = revit.doc
-active_view = revit.active_view
+uidoc = None
+doc = None
+active_view = None
logger = script.get_logger()
output = script.get_output()
output.close_others()
+my_config = script.get_config()
+
sb_form = None
-length_format_options = doc.GetUnits().GetFormatOptions(DB.SpecTypeId.Length)
-length_unit = length_format_options.GetUnitTypeId()
-length_unit_label = DB.LabelUtils.GetLabelForUnit(length_unit)
-length_unit_symbol = length_format_options.GetSymbolTypeId()
+length_unit = None
+length_unit_label = None
length_unit_symbol_label = None
-if not length_unit_symbol.Empty():
- length_unit_symbol_label = DB.LabelUtils.GetLabelForSymbol(length_unit_symbol)
-DEFAULT_NUDGE_VALUE_MM = 500.0
-default_nudge_value = DB.UnitUtils.Convert(
- DEFAULT_NUDGE_VALUE_MM, DB.UnitTypeId.Millimeters, length_unit
-)
+config_level_nudge_value = my_config.get_option("level_nudge_value", 1.64042)
+config_grid_nudge_value = my_config.get_option("grid_nudge_value", 1.64042)
+config_expand_nudge_value = my_config.get_option("expand_nudge_value", 1.64042)
+
TOLERANCE = 1e-5
DATAFILENAME = "SectionBox"
TEMP_DATAFILE = script.get_instance_data_file("SectionBoxTemp")
WINDOW_POSITION = "sbnavigator_window_pos"
+
+def initialize_globals():
+ global uidoc, doc, active_view
+ global length_unit, length_unit_label, length_unit_symbol_label
+ global level_nudge_value, grid_nudge_value, expand_nudge_value
+
+ uidoc = revit.uidoc
+ doc = revit.doc
+ active_view = revit.active_view
+
+ length_format_options = doc.GetUnits().GetFormatOptions(DB.SpecTypeId.Length)
+ length_unit = length_format_options.GetUnitTypeId()
+ length_unit_label = DB.LabelUtils.GetLabelForUnit(length_unit)
+ length_unit_symbol = length_format_options.GetSymbolTypeId()
+ length_unit_symbol_label = None
+ if not length_unit_symbol.Empty():
+ length_unit_symbol_label = DB.LabelUtils.GetLabelForSymbol(length_unit_symbol)
+
+ level_nudge_value = DB.UnitUtils.Convert(
+ config_level_nudge_value, DB.UnitTypeId.Feet, length_unit
+ )
+ grid_nudge_value = DB.UnitUtils.Convert(
+ config_grid_nudge_value, DB.UnitTypeId.Feet, length_unit
+ )
+ expand_nudge_value = DB.UnitUtils.Convert(
+ config_expand_nudge_value, DB.UnitTypeId.Feet, length_unit
+ )
+
+
# --------------------
# Helper Functions
# --------------------
@@ -135,7 +162,7 @@ def format_length_value(value):
# --------------------
-# View Changed Monitor
+# Event Monitor
# --------------------
@@ -143,6 +170,8 @@ def format_length_value(value):
@events.handle("view-activated")
def on_view_or_doc_changed(sender, args):
try:
+ if revit.doc != doc:
+ initialize_globals()
if not sb_form or not sb_form.chkAutoupdate.IsChecked:
return
sb_form.Dispatcher.Invoke(System.Action(sb_form.update_info))
@@ -162,7 +191,16 @@ class SectionBoxNavigatorForm(forms.WPFWindow):
def __init__(self, xaml_file_name):
forms.WPFWindow.__init__(self, xaml_file_name, handle_esc=False)
+ self.chkIncludeLinks.IsChecked = my_config.get_option("chkLinks_state", False)
+ self.chkPreview.IsChecked = my_config.get_option("chkPreview_state", True)
+ self.chkAutoupdate.IsChecked = my_config.get_option("chkAutoupdate_state", True)
+ self.rbLevel.IsChecked = my_config.get_option("rbLevel_state", True)
+ self.rbLevelNudge.IsChecked = not self.rbLevel.IsChecked
+ self.rbGrid.IsChecked = my_config.get_option("rbGrid_state", True)
+ self.rbGridNudge.IsChecked = not self.rbGrid.IsChecked
+
self.current_view = doc.ActiveView
+ self.current_length_unit = length_unit
self.all_levels = get_all_levels(doc, self.chkIncludeLinks.IsChecked)
self.all_grids = get_all_grids(doc, self.chkIncludeLinks.IsChecked)
self.preview_server = None
@@ -179,18 +217,7 @@ def __init__(self, xaml_file_name):
self.pending_action = None
- if not length_unit_symbol_label:
- self.project_unit_text.Visibility = forms.WPF_VISIBLE
- self.project_unit_text.Text = (
- self.get_locale_string("LengthLabelAdjust") + "\n" + length_unit_label
- )
- self.txtLevelNudgeAmount.Text = str(round(default_nudge_value, 3))
- self.txtLevelNudgeUnit.Text = length_unit_symbol_label or ""
- self.txtExpandAmount.Text = str(round(default_nudge_value, 3))
- self.txtExpandUnit.Text = length_unit_symbol_label or ""
- self.txtGridNudgeAmount.Text = str(round(default_nudge_value, 3))
- self.txtGridNudgeUnit.Text = length_unit_symbol_label or ""
-
+ self.update_fields_with_unit_dependencies()
self.update_info()
self.update_grid_status()
self.update_expand_actions_status()
@@ -200,9 +227,27 @@ def __init__(self, xaml_file_name):
script.restore_window_position(self)
self.Show()
+ def update_fields_with_unit_dependencies(self):
+ if not length_unit_symbol_label:
+ self.project_unit_text.Visibility = forms.WPF_VISIBLE
+ self.project_unit_text.Text = (
+ self.get_locale_string("LengthLabelAdjust") + "\n" + length_unit_label
+ )
+ else:
+ self.project_unit_text.Visibility = forms.WPF_COLLAPSED
+
+ self.txtLevelNudgeAmount.Text = str(round(level_nudge_value, 3))
+ self.txtLevelNudgeUnit.Text = length_unit_symbol_label or ""
+ self.txtExpandAmount.Text = str(round(expand_nudge_value, 3))
+ self.txtExpandUnit.Text = length_unit_symbol_label or ""
+ self.txtGridNudgeAmount.Text = str(round(grid_nudge_value, 3))
+ self.txtGridNudgeUnit.Text = length_unit_symbol_label or ""
+
def update_dropdown_visibility(self):
"""Show/hide dropdown arrows based on Level mode."""
- visibility = forms.WPF_VISIBLE if self.rbLevel.IsChecked else forms.WPF_COLLAPSED
+ visibility = (
+ forms.WPF_VISIBLE if self.rbLevel.IsChecked else forms.WPF_COLLAPSED
+ )
self.btnTopUpDropdown.Visibility = visibility
self.btnTopDownDropdown.Visibility = visibility
@@ -274,7 +319,7 @@ def populate_level_menu(self, menu, direction, target):
btn.Tag = {
"target": target,
"direction": direction,
- "elevation": level.Elevation
+ "elevation": level.Elevation,
}
# Wire up click and hover events
@@ -302,6 +347,10 @@ def update_info(self):
last_view = self.current_view.Id
self.current_view = doc.ActiveView
+ if self.current_length_unit != length_unit:
+ self.current_length_unit = length_unit
+ self.update_fields_with_unit_dependencies()
+
if is_2d_view(self.current_view):
self.btnAlignToView.Content = self.get_locale_string("AlignWith3DView")
if last_view != self.current_view.Id:
@@ -316,7 +365,9 @@ def update_info(self):
not isinstance(self.current_view, DB.View3D)
or not self.current_view.IsSectionBoxActive
):
- self.txtTopLevelAbove.Text = self.get_locale_string("NoSectionBoxActive")
+ self.txtTopLevelAbove.Text = self.get_locale_string(
+ "NoSectionBoxActive"
+ )
self.txtTopPosition.Text = ""
self.txtTopLevelBelow.Text = ""
self.txtBottomLevelAbove.Text = ""
@@ -364,37 +415,45 @@ def update_info(self):
# Update top info
if top_level_above:
- self.txtTopLevelAbove.Text = self.get_locale_string("AboveTopFormat").format(
- top_level_above.Name, top_level_above_elevation
- )
+ self.txtTopLevelAbove.Text = self.get_locale_string(
+ "AboveTopFormat"
+ ).format(top_level_above.Name, top_level_above_elevation)
else:
self.txtTopLevelAbove.Text = self.get_locale_string("NoLevelAboveTop")
if top_level_below:
- self.txtTopLevelBelow.Text = self.get_locale_string("BelowTopFormat").format(
- top_level_below.Name, top_level_below_elevation
- )
+ self.txtTopLevelBelow.Text = self.get_locale_string(
+ "BelowTopFormat"
+ ).format(top_level_below.Name, top_level_below_elevation)
else:
self.txtTopLevelBelow.Text = self.get_locale_string("NoLevelBelowTop")
top = format_length_value(transformed_max.Z)
- self.txtTopPosition.Text = self.get_locale_string("TopOfBoxFormat").format(top)
+ self.txtTopPosition.Text = self.get_locale_string("TopOfBoxFormat").format(
+ top
+ )
# Update bottom info
if bottom_level_above:
- self.txtBottomLevelAbove.Text = self.get_locale_string("AboveBottomFormat").format(
- bottom_level_above.Name, bottom_level_above_elevation
- )
+ self.txtBottomLevelAbove.Text = self.get_locale_string(
+ "AboveBottomFormat"
+ ).format(bottom_level_above.Name, bottom_level_above_elevation)
else:
- self.txtBottomLevelAbove.Text = self.get_locale_string("NoLevelAboveBottom")
- if bottom_level_below:
- self.txtBottomLevelBelow.Text = self.get_locale_string("BelowBottomFormat").format(
- bottom_level_below.Name, bottom_level_below_elevation
+ self.txtBottomLevelAbove.Text = self.get_locale_string(
+ "NoLevelAboveBottom"
)
+ if bottom_level_below:
+ self.txtBottomLevelBelow.Text = self.get_locale_string(
+ "BelowBottomFormat"
+ ).format(bottom_level_below.Name, bottom_level_below_elevation)
else:
- self.txtBottomLevelBelow.Text = self.get_locale_string("NoLevelBelowBottom")
+ self.txtBottomLevelBelow.Text = self.get_locale_string(
+ "NoLevelBelowBottom"
+ )
bottom = format_length_value(transformed_min.Z)
- self.txtBottomPosition.Text = self.get_locale_string("BottomOfBoxFormat").format(bottom)
+ self.txtBottomPosition.Text = self.get_locale_string(
+ "BottomOfBoxFormat"
+ ).format(bottom)
except Exception:
logger.exception("Error updating info.")
@@ -461,7 +520,9 @@ def update_grid_status(self):
def update_ui():
info = get_section_box_info(self.current_view, DATAFILENAME)
if not info:
- self.txtGridStatus.Text = self.get_locale_string("NoSectionBoxActive")
+ self.txtGridStatus.Text = self.get_locale_string(
+ "NoSectionBoxActive"
+ )
self.txtGridStatus.Foreground = Media.Brushes.Gray
return
@@ -480,16 +541,14 @@ def update_expand_actions_status(self):
def update_ui():
info = get_section_box_info(self.current_view, DATAFILENAME)
if not info:
- self.txtExpandActionsStatus.Text = self.get_locale_string("NoSectionBoxActive")
- self.txtExpandActionsStatus.Foreground = (
- Media.Brushes.Gray
+ self.txtExpandActionsStatus.Text = self.get_locale_string(
+ "NoSectionBoxActive"
)
+ self.txtExpandActionsStatus.Foreground = Media.Brushes.Gray
return
self.txtExpandActionsStatus.Text = "..."
- self.txtExpandActionsStatus.Foreground = (
- Media.Brushes.Black
- )
+ self.txtExpandActionsStatus.Foreground = Media.Brushes.Black
self.Dispatcher.Invoke(System.Action(update_ui))
except Exception as ex:
@@ -535,7 +594,9 @@ def do_level_move(self, params):
direction = params.get("direction") # 'up', 'down'
nudge_amount = params.get("nudge_amount", 0)
do_not_apply = params.get("do_not_apply", False)
- elevation = params.get("elevation", None) # picked from the level menu item preview
+ elevation = params.get(
+ "elevation", None
+ ) # picked from the level menu item preview
info = get_section_box_info(self.current_view, DATAFILENAME)
if not info:
@@ -549,7 +610,9 @@ def do_level_move(self, params):
next_bottom_level = None
next_level = None
- if self.rbLevel.IsChecked and not elevation:
+ if (
+ self.rbLevel.IsChecked and elevation is None
+ ): # for levels at elevation 0.0 'not elevation:' won't work
# Level mode - snap to next level
if target == "both":
if direction == "up":
@@ -570,7 +633,9 @@ def do_level_move(self, params):
if not next_top_level or not next_bottom_level:
if not do_not_apply:
self.show_status_message(
- 1, self.get_locale_string("CannotFindLevelsInDirection"), "error"
+ 1,
+ self.get_locale_string("CannotFindLevelsInDirection"),
+ "error",
)
return None
@@ -582,7 +647,9 @@ def do_level_move(self, params):
# Validate box dimensions
if next_top_level.Elevation <= next_bottom_level.Elevation:
if not do_not_apply:
- self.show_status_message(1, self.get_locale_string("WouldCreateInvalidBox"), "error")
+ self.show_status_message(
+ 1, self.get_locale_string("WouldCreateInvalidBox"), "error"
+ )
return None
elif target == "top":
@@ -598,7 +665,9 @@ def do_level_move(self, params):
if not next_level:
if not do_not_apply:
self.show_status_message(
- 1, self.get_locale_string("NoLevelFoundInDirection"), "error"
+ 1,
+ self.get_locale_string("NoLevelFoundInDirection"),
+ "error",
)
return None
@@ -607,7 +676,9 @@ def do_level_move(self, params):
# Validate won't go below bottom
if next_level.Elevation <= info["transformed_min"].Z:
if not do_not_apply:
- self.show_status_message(1, self.get_locale_string("WouldCreateInvalidBox"), "error")
+ self.show_status_message(
+ 1, self.get_locale_string("WouldCreateInvalidBox"), "error"
+ )
return None
elif target == "bottom":
@@ -623,7 +694,9 @@ def do_level_move(self, params):
if not next_level:
if not do_not_apply:
self.show_status_message(
- 1, self.get_locale_string("NoLevelFoundInDirection"), "error"
+ 1,
+ self.get_locale_string("NoLevelFoundInDirection"),
+ "error",
)
return None
@@ -632,10 +705,14 @@ def do_level_move(self, params):
# Validate won't go above top
if next_level.Elevation >= info["transformed_max"].Z:
if not do_not_apply:
- self.show_status_message(1, self.get_locale_string("WouldCreateInvalidBox"), "error")
+ self.show_status_message(
+ 1, self.get_locale_string("WouldCreateInvalidBox"), "error"
+ )
return None
- elif elevation:
+ elif (
+ elevation is not None
+ ): # for levels at elevation 0.0 'elif elevation:' won't work
if target == "top":
top_distance = elevation - info["transformed_max"].Z
# Validate won't go below bottom
@@ -659,7 +736,9 @@ def do_level_move(self, params):
current_height = info["transformed_max"].Z - info["transformed_min"].Z
top_distance = elevation - info["transformed_max"].Z
# Keep same height
- bottom_distance = (elevation - current_height) - info["transformed_min"].Z
+ bottom_distance = (elevation - current_height) - info[
+ "transformed_min"
+ ].Z
# Validate new bottom position won't be invalid
new_bottom = info["transformed_min"].Z + bottom_distance
new_top = info["transformed_max"].Z + top_distance
@@ -753,7 +832,9 @@ def do_level_move(self, params):
self.show_status_message(
1,
- self.get_locale_string("NudgedFormat").format(target, nudge_display, direction),
+ self.get_locale_string("NudgedFormat").format(
+ target, nudge_display, direction
+ ),
"success",
)
@@ -775,10 +856,18 @@ def do_expand_shrink(self, params):
):
# Success - show informative message
amount_display = format_length_value(amount)
- operation = self.get_locale_string("Expanded") if is_expand else self.get_locale_string("Shrunk")
+ operation = (
+ self.get_locale_string("Expanded")
+ if is_expand
+ else self.get_locale_string("Shrunk")
+ )
self.show_status_message(
3,
- operation + " " + self.get_locale_string("ExpandedShrunkByFormat").format(amount_display),
+ operation
+ + " "
+ + self.get_locale_string("ExpandedShrunkByFormat").format(
+ amount_display
+ ),
"success",
)
@@ -846,7 +935,9 @@ def do_grid_move(self, params):
if not do_not_apply:
self.show_status_message(
2,
- self.get_locale_string("NoGridFoundFormat").format(direction_name.upper()),
+ self.get_locale_string("NoGridFoundFormat").format(
+ direction_name.upper()
+ ),
"error",
)
return None
@@ -857,7 +948,9 @@ def do_grid_move(self, params):
if abs(move_distance) < TOLERANCE:
if not do_not_apply:
- self.show_status_message(2, self.get_locale_string("AlreadyAtGridLine"), "info")
+ self.show_status_message(
+ 2, self.get_locale_string("AlreadyAtGridLine"), "info"
+ )
return None
# Convert movement to local coordinates
@@ -1016,7 +1109,9 @@ def do_align_to_2d_view(self, params):
# Show preview and ask for confirmation
show_preview_mesh(new_box, self.preview_server)
result = forms.alert(
- self.get_locale_string("ApplySectionBoxFormat").format(view_data["view"].Name),
+ self.get_locale_string("ApplySectionBoxFormat").format(
+ view_data["view"].Name
+ ),
title=self.get_locale_string("ConfirmSectionBox"),
ok=True,
cancel=True,
@@ -1033,7 +1128,9 @@ def do_align_to_2d_view(self, params):
self.current_view.SetSectionBox(new_box)
self.show_status_message(
3,
- self.get_locale_string("SectionBoxAlignedFormat").format(view_data["view"].Name),
+ self.get_locale_string("SectionBoxAlignedFormat").format(
+ view_data["view"].Name
+ ),
"success",
)
@@ -1092,12 +1189,16 @@ def do_align_to_3d_view(self, params):
)
self.show_status_message(
3,
- self.get_locale_string("CropBoxAlignedFormat").format(view_data["view"].Name),
+ self.get_locale_string("CropBoxAlignedFormat").format(
+ view_data["view"].Name
+ ),
"success",
)
else:
- self.show_status_message(3, self.get_locale_string("UnsupportedViewType"), "warning")
+ self.show_status_message(
+ 3, self.get_locale_string("UnsupportedViewType"), "warning"
+ )
return
def do_toggle(self):
@@ -1114,10 +1215,16 @@ def do_toggle(self):
else:
return
if was_active != is_now_active:
- state = self.get_locale_string("Activated") if is_now_active else self.get_locale_string("Deactivated")
+ state = (
+ self.get_locale_string("Activated")
+ if is_now_active
+ else self.get_locale_string("Deactivated")
+ )
self.show_status_message(3, "Box " + state, "success")
else:
- self.show_status_message(3, self.get_locale_string("BoxToggleFailed"), "error")
+ self.show_status_message(
+ 3, self.get_locale_string("BoxToggleFailed"), "error"
+ )
def do_hide(self):
"""Hide or Unhide section or crop box."""
@@ -1130,10 +1237,16 @@ def do_hide(self):
self.current_view.CropBoxVisible = not was_hidden
else:
return
- state = self.get_locale_string("Hidden") if not was_hidden else self.get_locale_string("Unhidden")
+ state = (
+ self.get_locale_string("Hidden")
+ if not was_hidden
+ else self.get_locale_string("Unhidden")
+ )
self.show_status_message(3, "Box " + state, "success")
except Exception:
- self.show_status_message(3, self.get_locale_string("ErrorInBoxVisibility"), "error")
+ self.show_status_message(
+ 3, self.get_locale_string("ErrorInBoxVisibility"), "error"
+ )
def do_align_to_face(self):
"""Align to face"""
@@ -1141,12 +1254,16 @@ def do_align_to_face(self):
return
try:
align_to_face(doc, uidoc)
- self.show_status_message(3, self.get_locale_string("SectionBoxAlignedToFace"), "success")
+ self.show_status_message(
+ 3, self.get_locale_string("SectionBoxAlignedToFace"), "success"
+ )
except Exception as ex:
# User might have cancelled, don't show error for cancellation
if "cancelled" not in str(ex).lower() and "cancel" not in str(ex).lower():
self.show_status_message(
- 3, self.get_locale_string("FailedToAlignToFaceFormat").format(str(ex)), "error"
+ 3,
+ self.get_locale_string("FailedToAlignToFaceFormat").format(str(ex)),
+ "error",
)
def do_temp_switch(self):
@@ -1155,12 +1272,16 @@ def do_temp_switch(self):
return
try:
temp_switch(doc, TEMP_DATAFILE)
- self.show_status_message(3, self.get_locale_string("SuccessTempSwitch"), "success")
+ self.show_status_message(
+ 3, self.get_locale_string("SuccessTempSwitch"), "success"
+ )
except Exception as ex:
# User might have cancelled, don't show error for cancellation
if "cancelled" not in str(ex).lower() and "cancel" not in str(ex).lower():
self.show_status_message(
- 3, self.get_locale_string("FailedTempSwitch").format(str(ex)), "error"
+ 3,
+ self.get_locale_string("FailedTempSwitch").format(str(ex)),
+ "error",
)
def adjust_section_box(
@@ -1200,11 +1321,15 @@ def adjust_section_box(
and max_y_change == 0
):
self.show_status_message(
- 1, self.get_locale_string("InvalidSectionBoxDimensions"), "error"
+ 1,
+ self.get_locale_string("InvalidSectionBoxDimensions"),
+ "error",
)
else:
self.show_status_message(
- 3, self.get_locale_string("InvalidSectionBoxDimensions"), "error"
+ 3,
+ self.get_locale_string("InvalidSectionBoxDimensions"),
+ "error",
)
elif (
min_x_change != 0
@@ -1212,9 +1337,13 @@ def adjust_section_box(
or min_y_change != 0
or max_y_change != 0
):
- self.show_status_message(2, self.get_locale_string("InvalidSectionBoxDimensions"), "error")
+ self.show_status_message(
+ 2, self.get_locale_string("InvalidSectionBoxDimensions"), "error"
+ )
else:
- self.show_status_message(3, self.get_locale_string("InvalidSectionBoxDimensions"), "error")
+ self.show_status_message(
+ 3, self.get_locale_string("InvalidSectionBoxDimensions"), "error"
+ )
return False
with revit.Transaction("Adjust Section Box"):
@@ -1292,12 +1421,16 @@ def _handle_level_move(self, tag):
events.execute_in_revit_context(self.execute_action, self.pending_action)
except ValueError:
- self.show_status_message(1, self.get_locale_string("PleaseEnterValidNumber"), "warning")
+ self.show_status_message(
+ 1, self.get_locale_string("PleaseEnterValidNumber"), "warning"
+ )
return
except Exception as ex:
logger.error("Error in level nudge: {}".format(ex))
self.show_status_message(
- 1, self.get_locale_string("AnErrorOccurredFormat").format(str(ex)), "error"
+ 1,
+ self.get_locale_string("AnErrorOccurredFormat").format(str(ex)),
+ "error",
)
return
@@ -1317,21 +1450,27 @@ def _handle_grid_move(self, tag):
events.execute_in_revit_context(self.execute_action, self.pending_action)
except ValueError:
- self.show_status_message(2, self.get_locale_string("PleaseEnterValidNumber"), "warning")
+ self.show_status_message(
+ 2, self.get_locale_string("PleaseEnterValidNumber"), "warning"
+ )
return
except Exception as ex:
logger.error("Error in horizontal nudge: {}".format(ex))
self.show_status_message(
- 2, self.get_locale_string("AnErrorOccurredFormat").format(str(ex)), "error"
+ 2,
+ self.get_locale_string("AnErrorOccurredFormat").format(str(ex)),
+ "error",
)
return
- def _get_validated_nudge_amount(self, text_control, column=None, unit=length_unit):
+ def _get_validated_nudge_amount(self, text_control, column=None, unit=None):
"""Extract and validate nudge amount from text control.
Returns:
float: Converted distance in internal units, or None if invalid
"""
+ if unit is None:
+ unit = length_unit
try:
distance_text = text_control.Text.strip()
if column and not distance_text:
@@ -1343,14 +1482,18 @@ def _get_validated_nudge_amount(self, text_control, column=None, unit=length_uni
distance = float(distance_text)
if column and distance <= 0:
self.show_status_message(
- column, self.get_locale_string("AmountMustBeGreaterThanZero"), "warning"
+ column,
+ self.get_locale_string("AmountMustBeGreaterThanZero"),
+ "warning",
)
return None
return DB.UnitUtils.ConvertToInternalUnits(distance, unit)
except ValueError:
- self.show_status_message(column, self.get_locale_string("PleaseEnterValidNumber"), "warning")
+ self.show_status_message(
+ column, self.get_locale_string("PleaseEnterValidNumber"), "warning"
+ )
return None
def _normalize_tag(self, tag):
@@ -1359,7 +1502,7 @@ def _normalize_tag(self, tag):
return {
"target": tag.get("target"),
"direction": tag.get("direction"),
- "elevation": tag.get("elevation")
+ "elevation": tag.get("elevation"),
}
# XAML hardcoded Buttons
@@ -1369,11 +1512,7 @@ def _normalize_tag(self, tag):
target = parts[0]
direction = parts[1]
- return {
- "target": target,
- "direction": direction,
- "elevation": None
- }
+ return {"target": target, "direction": direction, "elevation": None}
# ----------
# Button Handlers
@@ -1419,12 +1558,18 @@ def btn_expansion_top_up_click(self, sender, e):
events.execute_in_revit_context(self.execute_action, self.pending_action)
except ValueError:
self.show_status_message(
- 3, self.get_locale_string("PleaseEnterValidNumberForExpansion"), "warning"
+ 3,
+ self.get_locale_string("PleaseEnterValidNumberForExpansion"),
+ "warning",
)
except Exception as ex:
logger.error("Error in expansion: {}".format(ex))
self.show_status_message(
- 3, self.get_locale_string("AnErrorOccurredWhileExpandingFormat").format(str(ex)), "error"
+ 3,
+ self.get_locale_string("AnErrorOccurredWhileExpandingFormat").format(
+ str(ex)
+ ),
+ "error",
)
def btn_expansion_top_down_click(self, sender, e):
@@ -1432,13 +1577,17 @@ def btn_expansion_top_down_click(self, sender, e):
try:
amount_text = self.txtExpandAmount.Text.strip()
if not amount_text:
- self.show_status_message(3, self.get_locale_string("PleaseEnterShrinkAmount"), "warning")
+ self.show_status_message(
+ 3, self.get_locale_string("PleaseEnterShrinkAmount"), "warning"
+ )
return
amount = float(amount_text)
if amount <= 0:
self.show_status_message(
- 3, self.get_locale_string("ShrinkAmountMustBeGreaterThanZero"), "warning"
+ 3,
+ self.get_locale_string("ShrinkAmountMustBeGreaterThanZero"),
+ "warning",
)
return
@@ -1456,7 +1605,11 @@ def btn_expansion_top_down_click(self, sender, e):
except Exception as ex:
logger.error("Error in shrink: {}".format(ex))
self.show_status_message(
- 3, self.get_locale_string("AnErrorOccurredWhileShrinkingFormat").format(str(ex)), "error"
+ 3,
+ self.get_locale_string("AnErrorOccurredWhileShrinkingFormat").format(
+ str(ex)
+ ),
+ "error",
)
def btn_align_box_to_view_click(self, sender, e):
@@ -1493,7 +1646,9 @@ def btn_align_box_to_view_click(self, sender, e):
info = get_section_box_info(selected_view, DATAFILENAME)
section_box = info.get("box")
if not section_box:
- self.show_status_message(3, self.get_locale_string("View3DHasNoSectionBox"), "error")
+ self.show_status_message(
+ 3, self.get_locale_string("View3DHasNoSectionBox"), "error"
+ )
return
view_data = {
@@ -1510,7 +1665,9 @@ def btn_align_box_to_view_click(self, sender, e):
return
if not view_data:
- self.show_status_message(3, self.get_locale_string("CouldNotExtractViewInformation"), "error")
+ self.show_status_message(
+ 3, self.get_locale_string("CouldNotExtractViewInformation"), "error"
+ )
return
events.execute_in_revit_context(self.execute_action, self.pending_action)
@@ -1731,10 +1888,26 @@ def form_closed(self, sender, args):
logger.warning("Error removing DC3D server: {}".format(ex))
# Refresh view
- try:
- uidoc.RefreshActiveView()
- except Exception as ex:
- logger.warning("Error refreshing view: {}".format(ex))
+ uidoc.RefreshActiveView()
+
+ # Save nudge values and radio buttons
+ level_nudge_value = self._get_validated_nudge_amount(
+ self.txtLevelNudgeAmount
+ )
+ grid_nudge_value = self._get_validated_nudge_amount(self.txtGridNudgeAmount)
+ expand_nudge_value = self._get_validated_nudge_amount(self.txtExpandAmount)
+ if level_nudge_value is not None:
+ my_config.set_option("level_nudge_value", level_nudge_value)
+ if grid_nudge_value is not None:
+ my_config.set_option("grid_nudge_value", grid_nudge_value)
+ if expand_nudge_value is not None:
+ my_config.set_option("expand_nudge_value", expand_nudge_value)
+ my_config.set_option("rbLevel_state", self.rbLevel.IsChecked)
+ my_config.set_option("rbGrid_state", self.rbGrid.IsChecked)
+ my_config.set_option("chkLinks_state", self.chkIncludeLinks.IsChecked)
+ my_config.set_option("chkPreview_state", self.chkPreview.IsChecked)
+ my_config.set_option("chkAutoupdate_state", self.chkAutoupdate.IsChecked)
+ script.save_config()
except Exception:
logger.exception("Error during cleanup.")
@@ -1746,6 +1919,7 @@ def form_closed(self, sender, args):
if __name__ == "__main__":
try:
+ initialize_globals()
# Check if section box is active
if not active_view.IsSectionBoxActive:
try:
@@ -1756,7 +1930,9 @@ def form_closed(self, sender, args):
# Create a temporary form instance to get locale strings for alerts
temp_form = SectionBoxNavigatorForm.__new__(SectionBoxNavigatorForm)
- forms.WPFWindow.__init__(temp_form, "SectionBoxNavigator.xaml", handle_esc=False)
+ forms.WPFWindow.__init__(
+ temp_form, "SectionBoxNavigator.xaml", handle_esc=False
+ )
# Ask user if they want to restore
if forms.alert(
@@ -1769,7 +1945,9 @@ def form_closed(self, sender, args):
except Exception:
# Create a temporary form instance to get locale strings
temp_form = SectionBoxNavigatorForm.__new__(SectionBoxNavigatorForm)
- forms.WPFWindow.__init__(temp_form, "SectionBoxNavigator.xaml", handle_esc=False)
+ forms.WPFWindow.__init__(
+ temp_form, "SectionBoxNavigator.xaml", handle_esc=False
+ )
forms.alert(
temp_form.get_locale_string("NoSectionBoxMessage"),
title=temp_form.get_locale_string("NoSectionBoxTitle"),
@@ -1783,9 +1961,13 @@ def form_closed(self, sender, args):
# Create a temporary form instance to get locale strings
try:
temp_form = SectionBoxNavigatorForm.__new__(SectionBoxNavigatorForm)
- forms.WPFWindow.__init__(temp_form, "SectionBoxNavigator.xaml", handle_esc=False)
+ forms.WPFWindow.__init__(
+ temp_form, "SectionBoxNavigator.xaml", handle_esc=False
+ )
error_title = temp_form.get_locale_string("ErrorTitle")
- error_msg = temp_form.get_locale_string("AnErrorOccurredFormat").format(str(ex))
+ error_msg = temp_form.get_locale_string("AnErrorOccurredFormat").format(
+ str(ex)
+ )
except Exception:
error_title = "Error"
error_msg = "An error occurred: {}".format(str(ex))
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/edit1.stack/Match.splitpushbutton/Match History Clipboard.smartbutton/bundle.yaml b/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/edit1.stack/Match.splitpushbutton/Match History Clipboard.smartbutton/bundle.yaml
new file mode 100644
index 000000000..86459a738
--- /dev/null
+++ b/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/edit1.stack/Match.splitpushbutton/Match History Clipboard.smartbutton/bundle.yaml
@@ -0,0 +1,52 @@
+title:
+ en_us: Match History Clipboard
+ fr_fr: Historique Presse-papiers
+ ru: История буфера совпадений
+ chinese_s: 匹配历史剪贴板
+ es_es: Portapapeles de historial
+ de_de: Zwischenablage-Verlauf
+ pt_br: Histórico de transferência
+tooltip:
+ en_us: >
+ Toggle the Match History Clipboard dockable pane.
+ Load parameter values from elements or view filters, then paste them
+ onto any number of elements — one by one, by box selection, or from
+ the current selection.
+ Up to 50 entries are kept in history so you can reuse values
+ across multiple operations without re-picking.
+ fr_fr: >
+ Afficher/masquer le panneau Historique Presse-papiers.
+ Chargez des valeurs de paramètres depuis des éléments ou des filtres de
+ vue, puis appliquez-les à autant d'éléments que nécessaire — un par un,
+ par sélection rectangulaire ou depuis la sélection active.
+ L'historique conserve jusqu'à 50 entrées réutilisables.
+ ru: >
+ Открыть/закрыть панель истории буфера совпадений.
+ Загружайте значения параметров из элементов или фильтров вида,
+ затем вставляйте их в любое количество элементов — поочерёдно,
+ прямоугольным выбором или из текущего выделения.
+ История хранит до 50 записей для повторного использования.
+ chinese_s: >
+ 切换匹配历史剪贴板停靠面板。
+ 从图元或视图过滤器中读取参数值,然后将其粘贴到任意数量的图元上——
+ 逐个拾取、矩形框选或使用当前选择集均可。
+ 历史记录最多保存 50 条,可跨操作重复使用。
+ es_es: >
+ Mostrar u ocultar el panel de historial de portapapeles.
+ Cargue valores de parámetros desde elementos o filtros de vista
+ y aplíquelos a cualquier número de elementos — uno a uno,
+ por selección rectangular o desde la selección activa.
+ El historial conserva hasta 50 entradas reutilizables.
+ de_de: >
+ Andockbares Fenster „Zwischenablage-Verlauf" ein-/ausblenden.
+ Parameterwerte aus Elementen oder Ansichtsfiltern laden und auf
+ beliebig viele Elemente übertragen — einzeln, per Rechteckauswahl
+ oder aus der aktuellen Auswahl.
+ Der Verlauf speichert bis zu 50 Einträge zur Wiederverwendung.
+ pt_br: >
+ Exibir ou ocultar o painel de histórico da área de transferência.
+ Carregue valores de parâmetros a partir de elementos ou filtros de
+ vista e aplique-os a quantos elementos quiser — um a um, por seleção
+ retangular ou a partir da seleção atual.
+ O histórico armazena até 50 entradas reutilizáveis.
+author: wurschdhaud
\ No newline at end of file
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/edit1.stack/Match.splitpushbutton/Match History Clipboard.smartbutton/off.dark.png b/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/edit1.stack/Match.splitpushbutton/Match History Clipboard.smartbutton/off.dark.png
new file mode 100644
index 000000000..3872dc5bd
Binary files /dev/null and b/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/edit1.stack/Match.splitpushbutton/Match History Clipboard.smartbutton/off.dark.png differ
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/edit1.stack/Match.splitpushbutton/Match History Clipboard.smartbutton/off.png b/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/edit1.stack/Match.splitpushbutton/Match History Clipboard.smartbutton/off.png
new file mode 100644
index 000000000..a9c45825b
Binary files /dev/null and b/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/edit1.stack/Match.splitpushbutton/Match History Clipboard.smartbutton/off.png differ
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/edit1.stack/Match.splitpushbutton/Match History Clipboard.smartbutton/on.dark.png b/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/edit1.stack/Match.splitpushbutton/Match History Clipboard.smartbutton/on.dark.png
new file mode 100644
index 000000000..fab6a6cdc
Binary files /dev/null and b/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/edit1.stack/Match.splitpushbutton/Match History Clipboard.smartbutton/on.dark.png differ
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/edit1.stack/Match.splitpushbutton/Match History Clipboard.smartbutton/on.png b/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/edit1.stack/Match.splitpushbutton/Match History Clipboard.smartbutton/on.png
new file mode 100644
index 000000000..b7e0d6873
Binary files /dev/null and b/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/edit1.stack/Match.splitpushbutton/Match History Clipboard.smartbutton/on.png differ
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/edit1.stack/Match.splitpushbutton/Match History Clipboard.smartbutton/script.py b/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/edit1.stack/Match.splitpushbutton/Match History Clipboard.smartbutton/script.py
new file mode 100644
index 000000000..69bf32181
--- /dev/null
+++ b/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/edit1.stack/Match.splitpushbutton/Match History Clipboard.smartbutton/script.py
@@ -0,0 +1,25 @@
+from pyrevit import forms, script
+from match import panel
+
+
+def __selfinit__(script_cmp, ui_button_cmp, __rvt__):
+ # TODO: This isn't working. No clue why.
+ is_shown = False
+ # try:
+ # if forms.is_registered_dockable_panel(panel.MatchHistoryClipboard):
+ # dockable_panel = forms.get_dockable_panel(panel.MatchHistoryClipboard)
+ # is_shown = dockable_panel.IsShown()
+ # except Exception:
+ # pass
+
+ script.toggle_icon(is_shown)
+ # init must return true if successful
+ return True
+
+
+if forms.is_registered_dockable_panel(panel.MatchHistoryClipboard):
+ dockable_panel = forms.get_dockable_panel(panel.MatchHistoryClipboard)
+ forms.toggle_dockable_panel(
+ panel.MatchHistoryClipboard, not dockable_panel.IsShown()
+ )
+ script.toggle_icon(dockable_panel.IsShown())
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/edit1.stack/Match.splitpushbutton/Match Properties.pushbutton/script.py b/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/edit1.stack/Match.splitpushbutton/Match Properties.pushbutton/script.py
index 799faf67c..0a95a8f40 100644
--- a/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/edit1.stack/Match.splitpushbutton/Match Properties.pushbutton/script.py
+++ b/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/edit1.stack/Match.splitpushbutton/Match Properties.pushbutton/script.py
@@ -4,169 +4,77 @@
Reapply the previous matched properties.
"""
-#pylint: disable=import-error,invalid-name,broad-except
import pickle
-from pyrevit import revit, DB
+from pyrevit import revit, DB, EXEC_PARAMS
from pyrevit import forms
from pyrevit import script
-from pyrevit.compat import get_elementid_value_func
+
+from match.match_utils import get_source_properties, match_prop
logger = script.get_logger()
output = script.get_output()
-class PropKeyValue(object):
- """Storage class for matched property info and value."""
- def __init__(self, name, datatype, value, istype):
- self.name = name
- self.datatype = datatype
- self.value = value
- self.istype = istype
-
- def __repr__(self):
- return str(self.__dict__)
-
-
-MEMFILE = script.get_document_data_file(
- file_id='MatchSelectedProperties',
- file_ext='pym',
- add_cmd_name=False
- )
-
-
-def match_prop(dest_inst, dest_type, src_props):
- """Match given properties on target instance or type"""
- for pkv in src_props:
- logger.debug("Applying %s", pkv.name)
-
- # determine target
- target = dest_type if pkv.istype else dest_inst
- # ensure target is valid if it is type
- if pkv.istype and not target:
- logger.warning("Element type is not accessible.")
- continue
- logger.debug("Target is %s", target)
-
- # find parameter
- dparam = target.LookupParameter(pkv.name)
- if dparam and pkv.datatype == dparam.StorageType:
- try:
- if dparam.StorageType == DB.StorageType.Integer:
- dparam.Set(pkv.value or 0)
- elif dparam.StorageType == DB.StorageType.Double:
- dparam.Set(pkv.value or 0.0)
- elif dparam.StorageType == DB.StorageType.ElementId:
- dparam.Set(DB.ElementId(pkv.value))
- else:
- dparam.Set(pkv.value or "")
- except Exception as setex:
- logger.warning(
- "Error applying value to: %s | %s",
- pkv.name, setex)
- continue
- else:
- logger.debug("Parameter \"%s\"not found on target.", pkv.name)
-
-
-def get_source_properties(src_element):
- """Return info on selected properties."""
- props = []
-
- src_type = revit.query.get_type(src_element)
-
- selected_params = forms.select_parameters(
- src_element,
- title="Select Parameters",
- multiple=True,
- include_instance=True,
- include_type=True
- ) or []
-
- logger.debug("Selected parameters: %s", [x.name for x in selected_params])
-
- for sparam in selected_params:
- logger.debug("Reading %s", sparam.name)
- target = src_type if sparam.istype else src_element
- tparam = target.LookupParameter(sparam.name)
- if tparam:
- if tparam.StorageType == DB.StorageType.Integer:
- value = tparam.AsInteger()
- elif tparam.StorageType == DB.StorageType.Double:
- value = tparam.AsDouble()
- elif tparam.StorageType == DB.StorageType.ElementId:
- get_elementid_value = get_elementid_value_func()
- value = get_elementid_value(tparam.AsElementId())
- else:
- value = tparam.AsString()
-
- props.append(
- PropKeyValue(
- name=sparam.name,
- datatype=tparam.StorageType,
- value=value,
- istype=sparam.istype
- ))
-
- return props
+MEMFILE = script.get_instance_data_file(file_id="MatchSelectedProperties")
def recall():
"""Load last matched properties from memory."""
data = []
try:
- with open(MEMFILE, 'rb') as mf:
+ with open(MEMFILE, "rb") as mf:
data = pickle.load(mf)
except Exception as ex:
- logger.debug(
- "Failed loading matched properties from memory | %s", str(ex)
- )
+ logger.debug("Failed loading matched properties from memory | %s", str(ex))
return data
def remember(src_props):
"""Save selected matched properties to memory."""
- with open(MEMFILE, 'wb') as mf:
+ with open(MEMFILE, "wb") as mf:
pickle.dump(src_props, mf)
# main
source_props = []
-if __shiftclick__: #pylint: disable=undefined-variable
+if EXEC_PARAMS.config_mode:
target_type, source_props = recall()
logger.debug("Recalled data: %s", source_props)
if not source_props:
# try use selected elements
selected_elements = revit.get_selection().elements
- if len(selected_elements) == 1 and forms.alert("Use selected %s?" % ("view"
- if isinstance(selected_elements[0], DB.View) else "element"),
- yes=True, no=True):
+ if len(selected_elements) == 1 and forms.alert(
+ "Use selected %s?"
+ % ("view" if isinstance(selected_elements[0], DB.View) else "element"),
+ yes=True,
+ no=True,
+ ):
source_element = selected_elements[0]
- target_type = "Views" if isinstance(source_element, DB.View)\
- else "Elements"
+ target_type = "Views" if isinstance(source_element, DB.View) else "Elements"
else:
source_element = None
# ask for type of elements to match
# some are not selectable in graphical views
- target_type = \
- forms.CommandSwitchWindow.show(
- ["Elements", "Views"],
- message="Pick type of targets:")
+ target_type = forms.CommandSwitchWindow.show(
+ ["Elements", "Views"], message="Pick type of targets:"
+ )
# determine source element
if target_type == "Elements":
with forms.WarningBar(title="Pick source object:"):
source_element = revit.pick_element()
elif target_type == "Views":
- source_element = \
- forms.select_views(title="Select Source View", multiple=False)
+ source_element = forms.select_views(
+ title="Select Source View", multiple=False
+ )
# grab properties from source element
if source_element:
if not source_props:
- source_props = get_source_properties(source_element)
+ source_props = get_source_properties(source_element, simple=True)
remember((target_type, source_props))
# apply values
@@ -181,26 +89,25 @@ def remember(src_props):
dest_type = revit.query.get_type(dest_element)
with revit.Transaction("Match Type Properties"):
# apply type params first
- match_prop(dest_element,
- dest_type,
- [x for x in source_props if x.istype])
+ match_prop(
+ dest_element, dest_type, [x for x in source_props if x.istype]
+ )
# then instance params
- match_prop(dest_element,
- dest_type,
- [x for x in source_props if not x.istype])
+ match_prop(
+ dest_element,
+ dest_type,
+ [x for x in source_props if not x.istype],
+ )
elif target_type == "Views":
- target_views = \
- forms.select_views(title="Select Target Views", multiple=True)
+ target_views = forms.select_views(title="Select Target Views", multiple=True)
if target_views:
with revit.Transaction("Match Type Properties"):
for tview in target_views:
tview_type = revit.query.get_type(tview)
# apply type params first
- match_prop(tview,
- tview_type,
- [x for x in source_props if x.istype])
+ match_prop(tview, tview_type, [x for x in source_props if x.istype])
# then instance params
- match_prop(tview,
- tview_type,
- [x for x in source_props if not x.istype])
+ match_prop(
+ tview, tview_type, [x for x in source_props if not x.istype]
+ )
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/edit1.stack/Match.splitpushbutton/Match Value Picker.pushbutton/bundle.yaml b/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/edit1.stack/Match.splitpushbutton/Match Value Picker.pushbutton/bundle.yaml
new file mode 100644
index 000000000..8d769f1aa
--- /dev/null
+++ b/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/edit1.stack/Match.splitpushbutton/Match Value Picker.pushbutton/bundle.yaml
@@ -0,0 +1,46 @@
+title:
+ en_us: Match Value Picker
+ fr_fr: Sélecteur de valeur (Correspondance)
+ ru: Подбор значения
+ chinese_s: 匹配数值拾取器
+ es_es: Selector de valores (Igualar)
+ de_de: Wert übernehmen (Picker)
+ pt_br: Seletor de valor (Correspondência)
+
+tooltip:
+ en_us: >-
+ Pick a value from an element and apply it to others.
+ Works like an eyedropper for parameter values.
+ Requires an active "equals" filter in the current view. Without it, nothing will happen.
+
+ fr_fr: >-
+ Sélectionnez une valeur sur un élément et appliquez-la aux autres.
+ Fonctionne comme une pipette pour les valeurs de paramètres.
+ Nécessite un filtre "égal à" actif dans la vue courante. Sans cela, rien ne se passe.
+
+ ru: >-
+ Выберите значение с элемента и примените его к другим.
+ Работает как пипетка для значений параметров.
+ Требуется активный фильтр «равно» в текущем виде. Без него ничего не произойдет.
+
+ chinese_s: >-
+ 从一个元素拾取数值并应用到其他元素。
+ 类似于用于参数数值的“取色器”。
+ 需要当前视图中存在启用的“等于”过滤器,否则不会执行任何操作。
+
+ es_es: >-
+ Selecciona un valor de un elemento y aplícalo a otros.
+ Funciona como un cuentagotas para valores de parámetros.
+ Requiere un filtro de tipo "igual a" activo en la vista actual. Sin él, no ocurrirá nada.
+
+ de_de: >-
+ Wert von einem Element aufnehmen und auf andere übertragen.
+ Funktioniert wie eine Pipette für Parameterwerte.
+ Erfordert einen aktiven „Ist gleich“-Filter in der aktuellen Ansicht. Ohne diesen passiert nichts.
+
+ pt_br: >-
+ Selecione um valor de um elemento e aplique a outros.
+ Funciona como um conta-gotas para valores de parâmetros.
+ Requer um filtro "igual a" ativo na vista atual. Sem isso, nada acontecerá.
+
+author: wurschdhaud
\ No newline at end of file
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/edit1.stack/Match.splitpushbutton/Match Value Picker.pushbutton/icon.dark.png b/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/edit1.stack/Match.splitpushbutton/Match Value Picker.pushbutton/icon.dark.png
new file mode 100644
index 000000000..18f4cd597
Binary files /dev/null and b/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/edit1.stack/Match.splitpushbutton/Match Value Picker.pushbutton/icon.dark.png differ
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/edit1.stack/Match.splitpushbutton/Match Value Picker.pushbutton/icon.png b/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/edit1.stack/Match.splitpushbutton/Match Value Picker.pushbutton/icon.png
new file mode 100644
index 000000000..a5766298c
Binary files /dev/null and b/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/edit1.stack/Match.splitpushbutton/Match Value Picker.pushbutton/icon.png differ
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/edit1.stack/Match.splitpushbutton/Match Value Picker.pushbutton/script.py b/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/edit1.stack/Match.splitpushbutton/Match Value Picker.pushbutton/script.py
new file mode 100644
index 000000000..b4ec1e7f4
--- /dev/null
+++ b/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/edit1.stack/Match.splitpushbutton/Match Value Picker.pushbutton/script.py
@@ -0,0 +1,45 @@
+# -*- coding: utf-8 -*-
+from pyrevit import revit, script
+
+from match.match_utils import paste_props, PropKeyValue, safe_get_parameter
+from match.filter_utils import get_most_common_filter_parameter
+
+logger = script.get_logger()
+
+
+def main():
+ param_id = get_most_common_filter_parameter(revit.doc, revit.active_view)
+ if not param_id:
+ return
+ sel = revit.get_selection()
+ elem = sel[0] if len(sel) == 1 else revit.pick_element()
+ if not elem:
+ return
+ props = []
+ try:
+ tparam = safe_get_parameter(elem, param_id)
+ if not tparam:
+ return
+ value = revit.query.get_param_value(tparam)
+ props = [
+ PropKeyValue(
+ name=tparam.Definition.Name,
+ datatype=tparam.StorageType,
+ value=value,
+ istype=False,
+ display_value=tparam.AsValueString() or str(value),
+ categories=[elem.Category],
+ )
+ ]
+ except Exception as ex:
+ logger.warning("load_from_filter_and_element: %s", ex)
+ return
+
+ if not props:
+ return
+
+ paste_props(props, "single")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/edit1.stack/Match.splitpushbutton/bundle.yaml b/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/edit1.stack/Match.splitpushbutton/bundle.yaml
index e19c73d08..7417f6a63 100644
--- a/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/edit1.stack/Match.splitpushbutton/bundle.yaml
+++ b/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/edit1.stack/Match.splitpushbutton/bundle.yaml
@@ -11,3 +11,5 @@ layout:
- Match Paint
- Match Properties
- Compare Properties
+ - Match History Clipboard
+ - Match Value Picker
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/edit2.stack/ReNumber.pushbutton/bundle.yaml b/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/edit2.stack/ReNumber.pushbutton/bundle.yaml
index 3128e7efb..d8ee374eb 100644
--- a/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/edit2.stack/ReNumber.pushbutton/bundle.yaml
+++ b/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/edit2.stack/ReNumber.pushbutton/bundle.yaml
@@ -7,11 +7,11 @@ title:
de_de: Umummerieren
pt_br: Renumerar
tooltip:
- en_us: ReNumber numbered elements in order of selection
- fr_fr: Renuméroter les éléments dans l'ordre de sélection
- ru: Перенумеровать пронумерованные элементы в порядке выделения.
- chinese_s: 按照选择顺序,为已编号的图元重新编号
- es_es: Renumerar elementos numerados en orden de selección
- de_de: Elemente werden mit der Reihenfolge der Auswahl neu nummeriert
- pt_br: Renumerar elementos numerados na ordem de seleção
+ en_us: ReNumber numbered elements in order of selection. Shift-Click to choose duplicate handling (Alert, Skip, or Sweep).
+ fr_fr: Renuméroter les éléments dans l'ordre de sélection. Maj+Clic pour gérer les doublons (Alerte, Ignorer ou Balayer).
+ ru: Перенумеровать пронумерованные элементы в порядке выделения. Shift+Клик для выбора обработки дублей (Предупреждение, Пропуск или Сквозная нумерация).
+ chinese_s: 按照选择顺序,为已编号的图元重新编号。按住 Shift 单击可选择重复项处理方式(提示、跳过或覆盖)。
+ es_es: Renumerar elementos numerados en orden de selección. Shift+Clic para gestionar duplicados (Alerta, Omitir o Barrer).
+ de_de: Elemente werden mit der Reihenfolge der Auswahl neu nummeriert. Shift+Klick zur Behandlung von Duplikaten (Warnung, Überspringen oder Durchnummerieren).
+ pt_br: Renumerar elementos numerados na ordem de seleção. Shift+Clique para lidar com duplicatas (Alerta, Ignorar ou Varrer).
author: '{{author}}'
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/edit2.stack/ReNumber.pushbutton/script.py b/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/edit2.stack/ReNumber.pushbutton/script.py
index fea654d45..7678bca49 100644
--- a/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/edit2.stack/ReNumber.pushbutton/script.py
+++ b/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/edit2.stack/ReNumber.pushbutton/script.py
@@ -1,3 +1,4 @@
+# -*- coding: utf-8 -*-
"""ReNumber numbered elements in order of selection."""
#pylint: disable=import-error,invalid-name,broad-except
from collections import OrderedDict
@@ -6,7 +7,7 @@
from pyrevit import revit, DB
from pyrevit import coreutils
from pyrevit import forms
-from pyrevit import script
+from pyrevit import script, EXEC_PARAMS
doc = revit.doc
uidoc = revit.uidoc
@@ -23,6 +24,9 @@
DB.ViewSection,
DB.ViewSheet,
)
+DUPE_MODE_SWEEP = "Sweep (renumber displaced)" # default behaviour
+DUPE_MODE_ALERT = "Alert (warn and skip)"
+DUPE_MODE_SKIP = "Skip (already numbered)"
class RNOpts(object):
@@ -146,7 +150,7 @@ def increment(number):
def get_number(target_element):
- """Get target elemnet number (might be from Number or other fields)"""
+ """Get target element number (might be from Number or other fields)"""
if hasattr(target_element, "Number"):
return target_element.Number
@@ -162,7 +166,7 @@ def get_number(target_element):
def set_number(target_element, new_number):
- """Set target elemnet number (might be at Number or other fields)"""
+ """Set target element number (might be at Number or other fields)"""
if hasattr(target_element, "Number"):
target_element.Number = new_number
return
@@ -227,34 +231,52 @@ def find_replacement_number(existing_number, elements_dict):
return replaced_number
-def renumber_element(target_views, target_element, new_number, elements_dict):
- """Renumber given element."""
- # check if elements with same number exists
+def renumber_element(target_views, target_element, new_number,
+ elements_dict, dupe_mode=DUPE_MODE_SWEEP):
+ """Renumber given element, respecting the chosen duplicate-handling mode."""
+
+ # ── duplicate check ───────────────────────────────────────────────────────
if new_number in elements_dict:
- element_with_same_number = \
- revit.doc.GetElement(elements_dict[new_number])
- # make sure its not the same as target_element
+ element_with_same_number = revit.doc.GetElement(elements_dict[new_number])
+
if element_with_same_number \
and element_with_same_number.Id != target_element.Id:
- # replace its number with something else that is not conflicting
- current_number = get_number(element_with_same_number)
- replaced_number = \
- find_replacement_number(current_number, elements_dict)
- set_number(element_with_same_number, replaced_number)
- # record the element with its new number for later renumber jobs
- elements_dict[replaced_number] = element_with_same_number.Id
-
- # check if target element is already listed
- # remove the existing number entry since we are renumbering
+
+ if dupe_mode == DUPE_MODE_ALERT:
+ forms.alert(
+ "Duplicate number \"{}\" found.\n"
+ "Element skipped.".format(new_number),
+ title="Duplicate Element Found"
+ )
+ return # abort – do not renumber this element
+
+ elif dupe_mode == DUPE_MODE_SKIP:
+ # silently skip if the target itself is already numbered
+ # (i.e. it has *any* number set – user probably tagged it already)
+ if get_number(target_element):
+ return
+ # if target has no number yet, fall through to sweep behaviour
+ # so it still gets a free slot
+ current_number = get_number(element_with_same_number)
+ replaced_number = find_replacement_number(current_number, elements_dict)
+ set_number(element_with_same_number, replaced_number)
+ elements_dict[replaced_number] = element_with_same_number.Id
+
+ else: # DUPE_MODE_SWEEP – original behaviour
+ current_number = get_number(element_with_same_number)
+ replaced_number = find_replacement_number(current_number, elements_dict)
+ set_number(element_with_same_number, replaced_number)
+ elements_dict[replaced_number] = element_with_same_number.Id
+
+ # ── remove stale entry for this element ───────────────────────────────────
existing_number = get_number(target_element)
if existing_number in elements_dict:
elements_dict.pop(existing_number)
- # renumber the given element
+ # ── apply the new number ──────────────────────────────────────────────────
logger.debug('applying %s', new_number)
set_number(target_element, new_number)
elements_dict[new_number] = target_element.Id
- # mark the element visually to renumbered
mark_element_as_renumbered(target_views, target_element)
@@ -272,39 +294,34 @@ def _unmark_collected(category_name, target_views, renumbered_element_ids):
unmark_renamed_elements(target_views, renumbered_element_ids)
-def pick_and_renumber(rnopts, starting_index, pb):
+def pick_and_renumber(rnopts, starting_index, pb, dupe_mode=DUPE_MODE_SWEEP):
"""Main renumbering routine for elements of given category."""
- # all actions under one transaction
if rnopts.bicat != BIC.OST_Viewports:
open_views = get_open_views()
else:
open_views = [revit.active_view]
with revit.TransactionGroup("Renumber {}".format(rnopts.name)):
- # make sure target elements are easily selectable
with EasilySelectableElements(open_views, rnopts.bicat):
index = starting_index
- # collect existing elements number:id data
existing_elements_data = get_elements_dict(open_views, rnopts.bicat)
- # dict to collect renumbered elements
renumbered_element_ids = {}
- # ask user to pick elements and renumber them
for picked_element in revit.get_picked_elements_by_category(
rnopts.bicat,
message="Select {} in order".format(rnopts.name.lower())):
- # need nested transactions to push revit to update view
- # on each renumber task
- pb.update_progress(int(index), int(starting_index))
+ try:
+ pb.update_progress(int(index), int(starting_index))
+ except (ValueError, TypeError):
+ pb.update_progress(0, 0)
with revit.Transaction("Renumber {}".format(rnopts.name)):
- # record the renumbered element
if picked_element.Id not in renumbered_element_ids:
renumbered_element_ids[picked_element.Id] = {}
for v in open_views:
- renumbered_element_ids[picked_element.Id][v.Id] = v.GetElementOverrides(picked_element.Id)
- # actual renumber task
+ renumbered_element_ids[picked_element.Id][v.Id] = \
+ v.GetElementOverrides(picked_element.Id)
renumber_element(open_views, picked_element,
- index, existing_elements_data)
+ index, existing_elements_data,
+ dupe_mode=dupe_mode)
index = increment(index)
- # unmark all renumbered elements
_unmark_collected(rnopts.name, open_views, renumbered_element_ids)
@@ -387,6 +404,11 @@ def door_by_room_renumber(rnopts):
# [X] renumber room
# [X] renumber doors by room
+class _NoOpPB(object):
+ """No-op progress bar stub for non-integer starting numbers."""
+ def update_progress(self, value, max_value):
+ pass
+
if isinstance(revit.active_view, ALLOWED_VIEW_CLASSES):
# prepare options
@@ -420,7 +442,19 @@ def door_by_room_renumber(rnopts):
width=400
)
+
if selected_option_name:
+ dupe_mode = DUPE_MODE_SWEEP # normal click → original behaviour
+ if EXEC_PARAMS.config_mode:
+ chosen = forms.CommandSwitchWindow.show(
+ [DUPE_MODE_ALERT, DUPE_MODE_SKIP, DUPE_MODE_SWEEP],
+ message="How should duplicate numbers be handled?",
+ title="Advanced: Duplicate Handling",
+ width=420
+ )
+ if not chosen:
+ script.exit()
+ dupe_mode = chosen
selected_option = options_dict[selected_option_name]
if selected_option.by_bicat:
# if renumber doors by room
@@ -430,14 +464,31 @@ def door_by_room_renumber(rnopts):
title="Pick Pairs of Door and Room. ESCAPE to end."
):
door_by_room_renumber(selected_option)
+ # NOTE: door_by_room_renumber calls renumber_element internally.
+ # To fully support dupe_mode there too, pass it through
+ # door_by_room_renumber(selected_option, dupe_mode=dupe_mode)
+ # and add the same dupe_mode param to that function.
else:
starting_number = ask_for_starting_number(selected_option.name)
if starting_number:
- with forms.ProgressBar(
- title="Pick {} One by One. ESCAPE to end. Current(Last set): {{value}}. Start: {{max_value}}".format(
- selected_option.name
- )
- ) as pb:
- pb.update_progress(int(starting_number), int(starting_number))
- pick_and_renumber(selected_option, starting_number, pb)
+ try:
+ _start_int = int(starting_number)
+ _is_int = True
+ except (ValueError, TypeError):
+ _is_int = False
+
+ if _is_int:
+ with forms.ProgressBar(
+ title="Pick {} One by One. ESCAPE to end. "
+ "Current(Last set): {{value}}. Start: {{max_value}}".format(
+ selected_option.name
+ )
+ ) as pb:
+ pb.update_progress(_start_int, _start_int)
+ pick_and_renumber(selected_option, starting_number, pb, dupe_mode=dupe_mode)
+ else:
+ with forms.WarningBar(
+ title="Pick {} One by One. ESCAPE to end.".format(selected_option.name)
+ ):
+ pick_and_renumber(selected_option, starting_number, _NoOpPB(), dupe_mode=dupe_mode)
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/edit3.stack/Groups.pulldown/Select Topmost Groups.pushbutton/script.py b/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/edit3.stack/Groups.pulldown/Select Topmost Groups.pushbutton/script.py
index d8d614e0f..6be675ef9 100644
--- a/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/edit3.stack/Groups.pulldown/Select Topmost Groups.pushbutton/script.py
+++ b/extensions/pyRevitTools.extension/pyRevit.tab/Modify.panel/edit3.stack/Groups.pulldown/Select Topmost Groups.pushbutton/script.py
@@ -5,8 +5,7 @@
Include not-grouped elements
"""
#pylint: disable=import-error,invalid-name,broad-except
-from pyrevit import revit, DB
-from pyrevit import forms
+from pyrevit import revit, DB, EXEC_PARAMS
from pyrevit import script
@@ -35,7 +34,7 @@ def get_higher_group(element, level=0):
if higher_group:
logger.debug("Found group: %s", higher_group.Id)
parent_group_ids.add(higher_group.Id)
- elif __shiftclick__: #pylint: disable=undefined-variable
+ elif EXEC_PARAMS.config_mode:
ungrouped_element_ids.append(selected_element.Id)
parent_group_ids.update(ungrouped_element_ids)
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Project.panel/Wipe.pulldown/Wipe Empty Tags.pushbutton/script.py b/extensions/pyRevitTools.extension/pyRevit.tab/Project.panel/Wipe.pulldown/Wipe Empty Tags.pushbutton/script.py
index 09c4f8a22..138e663f9 100644
--- a/extensions/pyRevitTools.extension/pyRevit.tab/Project.panel/Wipe.pulldown/Wipe Empty Tags.pushbutton/script.py
+++ b/extensions/pyRevitTools.extension/pyRevit.tab/Project.panel/Wipe.pulldown/Wipe Empty Tags.pushbutton/script.py
@@ -5,11 +5,11 @@
"""
#pylint: disable=C0103,E0401
from pyrevit import framework
-from pyrevit import revit, DB
+from pyrevit import revit, DB, EXEC_PARAMS
from pyrevit import forms
-if __shiftclick__: #pylint: disable=undefined-variable
+if EXEC_PARAMS.config_mode:
selected_views = \
forms.select_views(filterfunc=lambda x: isinstance(x, DB.ViewPlan),
use_selection=True)
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Project.panel/ptools.stack/Family.pulldown/Convert Imported 3D CAD.pushbutton/bundle.yaml b/extensions/pyRevitTools.extension/pyRevit.tab/Project.panel/ptools.stack/Family.pulldown/Convert Imported 3D CAD.pushbutton/bundle.yaml
index 8badea86c..e8392eee0 100644
--- a/extensions/pyRevitTools.extension/pyRevit.tab/Project.panel/ptools.stack/Family.pulldown/Convert Imported 3D CAD.pushbutton/bundle.yaml
+++ b/extensions/pyRevitTools.extension/pyRevit.tab/Project.panel/ptools.stack/Family.pulldown/Convert Imported 3D CAD.pushbutton/bundle.yaml
@@ -14,4 +14,4 @@ tooltip:
es_es: Convertir símbolo CAD importado a elementos de forma libre o DirectShape
de_de: Konvertiert importierte CAD Objekte in FreeForm oder DirectShape Elemente
pt_br: Converter Símbolo CAD Importado para Elementos FreeForm ou DirectShape
-is_beta: true
+is_beta:
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Project.panel/ptools.stack/Family.pulldown/Convert Imported 3D CAD.pushbutton/script.py b/extensions/pyRevitTools.extension/pyRevit.tab/Project.panel/ptools.stack/Family.pulldown/Convert Imported 3D CAD.pushbutton/script.py
index 44a0b0a67..c4c5f1e5f 100644
--- a/extensions/pyRevitTools.extension/pyRevit.tab/Project.panel/ptools.stack/Family.pulldown/Convert Imported 3D CAD.pushbutton/script.py
+++ b/extensions/pyRevitTools.extension/pyRevit.tab/Project.panel/ptools.stack/Family.pulldown/Convert Imported 3D CAD.pushbutton/script.py
@@ -14,8 +14,21 @@
forms.check_familydoc(exitscript=True)
selection = revit.get_selection()
+if selection:
+ cad_imports = [e for e in selection if isinstance(e, DB.ImportInstance)]
+ if not cad_imports:
+ forms.alert("No CAD import found in selection.", exitscript=True)
+ cad_import = cad_imports[0]
+ if len(cad_imports) > 1:
+ forms.alert("Multiple CAD imports selected. Using the first one.")
+else:
+ cad_import = revit.pick_element("Select an imported instance")
+ if not cad_import:
+ script.exit()
+ if not isinstance(cad_import, DB.ImportInstance):
+ forms.alert("Selected element is not a CAD import.", exitscript=True)
+
-cad_import = selection.first
cad_trans = cad_import.GetTransform()
cad_type = cad_import.Document.GetElement(cad_import.GetTypeId())
cad_name = revit.query.get_name(cad_type)
@@ -29,43 +42,48 @@
if isinstance(geo, DB.GeometryInstance):
geo_elements.extend([x for x in geo.GetSymbolGeometry()])
-solids = []
-for gel in geo_elements:
- logger.debug(gel)
- if isinstance(gel, DB.Solid):
- # if hasattr(gel, 'Volume') and gel.Volume > 0.0:
- solids.append(gel)
- elif isinstance(gel, DB.Mesh):
- builder = DB.TessellatedShapeBuilder()
- builder.OpenConnectedFaceSet(False)
+solid_count = 0
+mesh_count = 0
+with revit.Transaction("Convert CAD Import to FreeForm/DirectShape"):
+ for gel in geo_elements:
+ logger.debug(gel)
+ if isinstance(gel, DB.Solid):
+ # if hasattr(gel, 'Volume') and gel.Volume > 0.0:
+ DB.FreeFormElement.Create(revit.doc, gel)
+ solid_count += 1
+ elif isinstance(gel, DB.Mesh):
+ builder = DB.TessellatedShapeBuilder()
+ builder.OpenConnectedFaceSet(False)
- triangles = [gel.Triangle[x] for x in range(0, gel.NumTriangles)]
- for t in triangles:
- p1 = cad_trans.OfPoint(t.Vertex[0])
- p2 = cad_trans.OfPoint(t.Vertex[1])
- p3 = cad_trans.OfPoint(t.Vertex[2])
- tface = DB.TessellatedFace(List[DB.XYZ]([p1, p2, p3]),
- DB.ElementId.InvalidElementId)
- builder.AddFace(tface)
+ triangles = [gel.Triangle[x] for x in range(0, gel.NumTriangles)]
+ for t in triangles:
+ p1 = cad_trans.OfPoint(t.Vertex[0])
+ p2 = cad_trans.OfPoint(t.Vertex[1])
+ p3 = cad_trans.OfPoint(t.Vertex[2])
+ tface = DB.TessellatedFace(
+ List[DB.XYZ]([p1, p2, p3]), DB.ElementId.InvalidElementId
+ )
+ builder.AddFace(tface)
- builder.CloseConnectedFaceSet()
- builder.Target = DB.TessellatedShapeBuilderTarget.AnyGeometry
- builder.Fallback = DB.TessellatedShapeBuilderFallback.Mesh
- builder.Build()
+ builder.CloseConnectedFaceSet()
+ builder.Target = DB.TessellatedShapeBuilderTarget.AnyGeometry
+ builder.Fallback = DB.TessellatedShapeBuilderFallback.Mesh
+ builder.Build()
- with revit.Transaction("Convert CAD Import to DirectShape"):
- ds = DB.DirectShape.CreateElement(
- revit.doc,
- family_cat.Id
- # DB.ElementId(DB.BuiltInCategory.OST_DataDevices)
- )
- ds.ApplicationId = 'B39107C3-A1D7-47F4-A5A1-532DDF6EDB5D'
- ds.ApplicationDataId = ''
+ ds = DB.DirectShape.CreateElement(revit.doc, family_cat.Id)
+ ds.ApplicationId = "B39107C3-A1D7-47F4-A5A1-532DDF6EDB5D"
+ ds.ApplicationDataId = ""
ds.SetShape(builder.GetBuildResult().GetGeometricalObjects())
ds.Name = cad_name
+ mesh_count += 1
-
-# create freeform from solids
-with revit.Transaction("Convert CAD Import to FreeFrom/DirectShape"):
- for solid in solids:
- DB.FreeFormElement.Create(revit.doc, solid)
+if not solid_count and not mesh_count:
+ forms.alert(
+ "No solids or meshes found in the CAD import. Nothing was converted.",
+ exitscript=True,
+ )
+logger.info(
+ "Converted {} solids and {} meshes from {}".format(
+ solid_count, mesh_count, cad_name
+ )
+)
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Project.panel/ptools.stack/Family.pulldown/Family QuickCheck.pushbutton/FamilyQuickcheck_script.py b/extensions/pyRevitTools.extension/pyRevit.tab/Project.panel/ptools.stack/Family.pulldown/Family QuickCheck.pushbutton/FamilyQuickcheck_script.py
index 7667aaaf4..1aadc0f2f 100644
--- a/extensions/pyRevitTools.extension/pyRevit.tab/Project.panel/ptools.stack/Family.pulldown/Family QuickCheck.pushbutton/FamilyQuickcheck_script.py
+++ b/extensions/pyRevitTools.extension/pyRevit.tab/Project.panel/ptools.stack/Family.pulldown/Family QuickCheck.pushbutton/FamilyQuickcheck_script.py
@@ -7,7 +7,7 @@
PR731 https://github.com/pyrevitlabs/pyRevit/pull/731
"""
#pylint: disable=invalid-name,import-error,superfluous-parens,broad-except
-from pyrevit import revit
+from pyrevit import revit, EXEC_PARAMS
from pyrevit import forms
from pyrevit import script
@@ -25,7 +25,7 @@
all_families = revit.query.get_families(revit.doc, only_editable=True)
editable_families = []
-if __shiftclick__: #pylint: disable=E0602
+if EXEC_PARAMS.config_mode:
family_dict = {}
for family in all_families:
if family.FamilyCategory:
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Project.panel/ptools.stack/Links.pulldown/Create Workset For Linked Element.pushbutton/translations.json b/extensions/pyRevitTools.extension/pyRevit.tab/Project.panel/ptools.stack/Links.pulldown/Create Workset For Linked Element.pushbutton/translations.json
deleted file mode 100644
index 30d31cb74..000000000
--- a/extensions/pyRevitTools.extension/pyRevit.tab/Project.panel/ptools.stack/Links.pulldown/Create Workset For Linked Element.pushbutton/translations.json
+++ /dev/null
@@ -1,120 +0,0 @@
-{
- "script": {
- "en_us": {
- "Worksharing.Enable.Message": "The document doesn't have worksharing enabled.\nEnable it?",
- "Worksharing.Enable.Options": ["Yes", "No"],
- "Worksharing.Enable.Error": "The script cannot run in a document without worksharing.",
- "Worksharing.Enable.Error.Title": "The script has stopped",
- "Transaction.Name": "Create Workset(s) for linked model(s)",
- "Alert.NoLinksFound.Message": "No links found in the document.",
- "Alert.SelectOne.Message": "At least one linked element must be selected."
- },
- "fr_fr": {
- "Worksharing.Enable.Message": "Le partage de projet n'est pas activé pour ce document.\nL'activer ?",
- "Worksharing.Enable.Options": ["Oui", "Non"],
- "Worksharing.Enable.Error": "Le script ne peut pas s'exécuter dans un document sans partage de projet.",
- "Worksharing.Enable.Error.Title": "Le script s'est arrêté",
- "Transaction.Name": "Créer des sous-projets pour les modèles liés",
- "Alert.NoLinksFound.Message": "Aucun lien trouvé dans le document.",
- "Alert.SelectOne.Message": "Au moins un élément lié doit être sélectionné."
- },
- "ru": {
- "Worksharing.Enable.Message": "Документ без совместной работы.\nВключить её?",
- "Worksharing.Enable.Options": ["Да", "Нет"],
- "Worksharing.Enable.Error": "Скрипт не может работать в документе без совместной работы.",
- "Worksharing.Enable.Error.Title": "Скрипт остановлен",
- "Transaction.Name": "Создание рабочих наборов для связанных моделей",
- "Alert.NoLinksFound.Message": "В документе нет связанных файлов.",
- "Alert.SelectOne.Message": "Необходимо выбрать хотя бы один элемент связи."
- },
- "chinese_s": {
- "Worksharing.Enable.Message": "文档未启用工作共享。\n要启用它吗?",
- "Worksharing.Enable.Options": ["是", "否"],
- "Worksharing.Enable.Error": "该脚本无法在没有工作共享的文档中运行。",
- "Worksharing.Enable.Error.Title": "脚本已停止",
- "Transaction.Name": "为链接模型创建工作集",
- "Alert.NoLinksFound.Message": "在文档中找不到链接。",
- "Alert.SelectOne.Message": "必须至少选择一个链接元素。"
- },
- "es_es": {
- "Worksharing.Enable.Message": "El documento no tiene activada la compartición de proyecto.\n¿Activarla?",
- "Worksharing.Enable.Options": ["Sí", "No"],
- "Worksharing.Enable.Error": "El script no puede ejecutarse en un documento sin compartición de proyecto.",
- "Worksharing.Enable.Error.Title": "El script se ha detenido",
- "Transaction.Name": "Crear subproyectos para modelos vinculados",
- "Alert.NoLinksFound.Message": "No se encontraron vínculos en el documento.",
- "Alert.SelectOne.Message": "Se debe seleccionar al menos un elemento vinculado."
- },
- "de_de": {
- "Worksharing.Enable.Message": "Die Bearbeitungsbereiche sind für das Dokument nicht aktiviert.\nAktivieren?",
- "Worksharing.Enable.Options": ["Ja", "Nein"],
- "Worksharing.Enable.Error": "Das Skript kann nicht in einem Dokument ohne Bearbeitungsbereiche ausgeführt werden.",
- "Worksharing.Enable.Error.Title": "Das Skript wurde angehalten",
- "Transaction.Name": "Bearbeitungsbereiche für verknüpfte Modelle erstellen",
- "Alert.NoLinksFound.Message": "Keine Verknüpfungen im Dokument gefunden.",
- "Alert.SelectOne.Message": "Es muss mindestens ein verknüpftes Element ausgewählt werden."
- }
- },
- "config": {
- "en_us": {
- "Options.WindowTitle": "Select Options",
- "Options.Select.Button": "Save Selection",
- "Options.SetTypeWorkset.Text": "Set Workset for Type",
- "Options.SetAll.Text": "Collect all Links",
- "Options.CustomPrefixRvt.Text": "Custom Prefix for RVT",
- "Options.CustomPrefixDwg.Text": "Custom Prefix for DWG",
- "PrefixRvt.Prompt": "Pick a Prefix for RVTs",
- "PrefixDwg.Prompt": "Pick a Prefix for DWGs"
- },
- "fr_fr": {
- "Options.WindowTitle": "Sélectionner les options",
- "Options.Select.Button": "Enregistrer la sélection",
- "Options.SetTypeWorkset.Text": "Définir le sous-projet pour le type",
- "Options.SetAll.Text": "Collecter tous les liens",
- "Options.CustomPrefixRvt.Text": "Préfixe personnalisé pour RVT",
- "Options.CustomPrefixDwg.Text": "Préfixe personnalisé pour DWG",
- "PrefixRvt.Prompt": "Choisissez un préfixe pour les RVT",
- "PrefixDwg.Prompt": "Choisissez un préfixe pour les DWG"
- },
- "ru": {
- "Options.WindowTitle": "Выберите параметры",
- "Options.Select.Button": "Сохранить выбор",
- "Options.SetTypeWorkset.Text": "Назначить рабочий набор и для типоразмера связи",
- "Options.SetAll.Text": "Применить ко всем связям",
- "Options.CustomPrefixRvt.Text": "Пользовательский префикс для RVT",
- "Options.CustomPrefixDwg.Text": "Пользовательский префикс для DWG",
- "PrefixRvt.Prompt": "Выберите префикс для RVT",
- "PrefixDwg.Prompt": "Выберите префикс для DWG"
- },
- "chinese_s": {
- "Options.WindowTitle": "选择选项",
- "Options.Select.Button": "保存选择",
- "Options.SetTypeWorkset.Text": "为类型设置工作集",
- "Options.SetAll.Text": "收集所有链接",
- "Options.CustomPrefixRvt.Text": "RVT 的自定义前缀",
- "Options.CustomPrefixDwg.Text": "DWG 的自定义前缀",
- "PrefixRvt.Prompt": "为 RVT 选择一个前缀",
- "PrefixDwg.Prompt": "为 DWG 选择一个前缀"
- },
- "es_es": {
- "Options.WindowTitle": "Seleccionar opciones",
- "Options.Select.Button": "Guardar selección",
- "Options.SetTypeWorkset.Text": "Establecer subproyecto para tipo",
- "Options.SetAll.Text": "Recopilar todos los vínculos",
- "Options.CustomPrefixRvt.Text": "Prefijo personalizado para RVT",
- "Options.CustomPrefixDwg.Text": "Prefijo personalizado para DWG",
- "PrefixRvt.Prompt": "Elija un prefijo para los RVT",
- "PrefixDwg.Prompt": "Elija un prefijo para los DWG"
- },
- "de_de": {
- "Options.WindowTitle": "Optionen auswählen",
- "Options.Select.Button": "Auswahl speichern",
- "Options.SetTypeWorkset.Text": "Bearbeitungsbereich für Typ festlegen",
- "Options.SetAll.Text": "Alle Verknüpfungen sammeln",
- "Options.CustomPrefixRvt.Text": "Benutzerdefiniertes Präfix für RVT",
- "Options.CustomPrefixDwg.Text": "Benutzerdefiniertes Präfix für DWG",
- "PrefixRvt.Prompt": "Wählen Sie ein Präfix für RVTs",
- "PrefixDwg.Prompt": "Wählen Sie ein Präfix für DWGs"
- }
- }
-}
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Project.panel/ptools.stack/Links.pulldown/Create Workset For Linked Element.pushbutton/ws4links_config.py b/extensions/pyRevitTools.extension/pyRevit.tab/Project.panel/ptools.stack/Links.pulldown/Create Workset For Linked Element.pushbutton/ws4links_config.py
index 865349b4b..fde09849c 100644
--- a/extensions/pyRevitTools.extension/pyRevit.tab/Project.panel/ptools.stack/Links.pulldown/Create Workset For Linked Element.pushbutton/ws4links_config.py
+++ b/extensions/pyRevitTools.extension/pyRevit.tab/Project.panel/ptools.stack/Links.pulldown/Create Workset For Linked Element.pushbutton/ws4links_config.py
@@ -1,37 +1,8 @@
# -*- coding: UTF-8 -*-
-import io
-import json
-import os
from pyrevit import script, forms
from pyrevit.userconfig import user_config
from ws4links_script import main
-
-
-def get_translations(script_folder, script_type, locale):
- # type: (str, str, str) -> dict[str, str | list]
- """
- Get translation for a specific script type from a JSON file.
-
- Examples:
- ```python
- get_translations(script.get_script_path(), "script", "en_us")
- ```
-
- Args:
- script_folder (str): The folder containing the JSON file.
- script_type (str): The type of script for which translations are loaded.
- - "script"
- - "config"
- locale (str): The locale for which translations are loaded ("en_us" etc.).
-
- Returns:
- dict[str, str | list]: A dictionary containing the translation.
- """
- json_path = os.path.join(script_folder, 'translations.json')
- with io.open(json_path, 'r', encoding='utf-8') as f:
- translations = json.load(f)
- script_translations = translations.get(script_type, {})
- return script_translations.get(locale, script_translations.get("en_us", {}))
+from ws4links_translations import TRANSLATIONS_CONFIG
class MyOption(forms.TemplateListItem):
@@ -47,24 +18,23 @@ def name(self):
custom_prefix_for_rvt = my_config.get_option("custom_prefix_for_rvt", False)
custom_prefix_for_dwg = my_config.get_option("custom_prefix_for_dwg", False)
-translations_dict = get_translations(
- script.get_script_path(),
- "config",
- user_config.user_locale
-)
+translations = TRANSLATIONS_CONFIG.get(
+ user_config.user_locale,
+ TRANSLATIONS_CONFIG["en_us"]
+) # type: dict[str, str | list]
opts = [
- MyOption(translations_dict["Options.SetTypeWorkset.Text"], set_type_ws),
- MyOption(translations_dict["Options.SetAll.Text"], set_all),
- MyOption(translations_dict["Options.CustomPrefixRvt.Text"], custom_prefix_for_rvt),
- MyOption(translations_dict["Options.CustomPrefixDwg.Text"], custom_prefix_for_dwg),
+ MyOption(translations["Options.SetTypeWorkset.Text"], set_type_ws),
+ MyOption(translations["Options.SetAll.Text"], set_all),
+ MyOption(translations["Options.CustomPrefixRvt.Text"], custom_prefix_for_rvt),
+ MyOption(translations["Options.CustomPrefixDwg.Text"], custom_prefix_for_dwg),
]
results = forms.SelectFromList.show(
opts,
multiselect=True,
- title=translations_dict["Options.WindowTitle"],
- button_name=translations_dict["Options.Select.Button"],
+ title=translations["Options.WindowTitle"],
+ button_name=translations["Options.Select.Button"],
return_all=True,
width=330,
height=300,
@@ -75,34 +45,34 @@ def name(self):
my_config.set_option(
"set_type_ws",
- selected_items.get(translations_dict["Options.SetTypeWorkset.Text"], False)
+ selected_items.get(translations["Options.SetTypeWorkset.Text"], False)
)
my_config.set_option(
"set_all",
- selected_items.get(translations_dict["Options.SetAll.Text"], False)
+ selected_items.get(translations["Options.SetAll.Text"], False)
)
my_config.set_option(
"custom_prefix_for_rvt",
- selected_items.get(translations_dict["Options.CustomPrefixRvt.Text"], False)
+ selected_items.get(translations["Options.CustomPrefixRvt.Text"], False)
)
my_config.set_option(
"custom_prefix_for_dwg",
- selected_items.get(translations_dict["Options.CustomPrefixDwg.Text"], False)
+ selected_items.get(translations["Options.CustomPrefixDwg.Text"], False)
)
- if selected_items.get(translations_dict["Options.CustomPrefixRvt.Text"], False):
+ if selected_items.get(translations["Options.CustomPrefixRvt.Text"], False):
custom_prefix_value = my_config.get_option("custom_prefix_rvt_value", "ZL_RVT_")
custom_prefix_value = forms.ask_for_string(
default=custom_prefix_value,
- prompt=translations_dict["PrefixRvt.Prompt"]
+ prompt=translations["PrefixRvt.Prompt"]
)
my_config.set_option("custom_prefix_rvt_value", custom_prefix_value)
- if selected_items.get(translations_dict["Options.CustomPrefixDwg.Text"], False):
+ if selected_items.get(translations["Options.CustomPrefixDwg.Text"], False):
custom_prefix_value = my_config.get_option("custom_prefix_dwg_value", "ZL_DWG_")
custom_prefix_value = forms.ask_for_string(
default=custom_prefix_value,
- prompt=translations_dict["PrefixDwg.Prompt"]
+ prompt=translations["PrefixDwg.Prompt"]
)
my_config.set_option("custom_prefix_dwg_value", custom_prefix_value)
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Project.panel/ptools.stack/Links.pulldown/Create Workset For Linked Element.pushbutton/ws4links_script.py b/extensions/pyRevitTools.extension/pyRevit.tab/Project.panel/ptools.stack/Links.pulldown/Create Workset For Linked Element.pushbutton/ws4links_script.py
index 7d287314c..960e1a97e 100644
--- a/extensions/pyRevitTools.extension/pyRevit.tab/Project.panel/ptools.stack/Links.pulldown/Create Workset For Linked Element.pushbutton/ws4links_script.py
+++ b/extensions/pyRevitTools.extension/pyRevit.tab/Project.panel/ptools.stack/Links.pulldown/Create Workset For Linked Element.pushbutton/ws4links_script.py
@@ -1,48 +1,18 @@
# -*- coding: UTF-8 -*-
-import io
-import json
-import os
from pyrevit import revit, DB
from pyrevit import script
from pyrevit.forms import alert
from pyrevit.userconfig import user_config
from pyrevit import HOST_APP
-
-
-def get_translations(script_folder, script_type, locale):
- # type: (str, str, str) -> dict[str, str | list]
- """
- Get translation for a specific script type from a JSON file.
-
- Examples:
- ```python
- get_translations(script.get_script_path(), "script", "en_us")
- ```
-
- Args:
- script_folder (str): The folder containing the JSON file.
- script_type (str): The type of script for which translations are loaded.
- - "script"
- - "config"
- locale (str): The locale for which translations are loaded ("en_us" etc.).
-
- Returns:
- dict[str, str | list]: A dictionary containing the translation.
- """
- json_path = os.path.join(script_folder, 'translations.json')
- with io.open(json_path, 'r', encoding='utf-8') as f:
- translations = json.load(f)
- script_translations = translations.get(script_type, {})
- return script_translations.get(locale, script_translations.get("en_us", {}))
+from ws4links_translations import TRANSLATIONS_SCRIPT
doc = HOST_APP.doc
logger = script.get_logger()
-translations_dict = get_translations(
- script.get_script_path(),
- "script",
- user_config.user_locale
-)
+translations = TRANSLATIONS_SCRIPT.get(
+ user_config.user_locale,
+ TRANSLATIONS_SCRIPT["en_us"]
+) # type: dict[str, str | list]
def main():
@@ -68,27 +38,27 @@ def main():
)
if len(selection) > 0:
- enable_worksharing = alert(
- translations_dict["Worksharing.Enable.Message"],
- options=translations_dict["Worksharing.Enable.Options"],
- warn_icon=False
- ) # type: str
- if not enable_worksharing:
- script.exit()
- if (
- enable_worksharing == translations_dict["Worksharing.Enable.Options"][0]
- and not doc.IsWorkshared
- and doc.CanEnableWorksharing
- ):
- doc.EnableWorksharing("Shared Levels and Grids", "Workset1")
- else:
- alert(
- translations_dict["Worksharing.Enable.Error"],
- title=translations_dict["Worksharing.Enable.Error.Title"],
- exitscript=True
- )
+ if not doc.IsWorkshared:
+ enable_worksharing = alert(
+ translations["Worksharing.Enable.Message"],
+ options=translations["Worksharing.Enable.Options"],
+ warn_icon=False
+ ) # type: str
+ if not enable_worksharing:
+ script.exit()
+ if (
+ enable_worksharing == translations["Worksharing.Enable.Options"][0]
+ and doc.CanEnableWorksharing
+ ):
+ doc.EnableWorksharing("Shared Levels and Grids", "Workset1")
+ else:
+ alert(
+ translations["Worksharing.Enable.Error"],
+ title=translations["Worksharing.Enable.Error.Title"],
+ exitscript=True
+ )
- with revit.Transaction(translations_dict["Transaction.Name"]):
+ with revit.Transaction(translations["Transaction.Name"]):
for el in selection:
linked_model_name = ""
if isinstance(el, DB.RevitLinkInstance):
@@ -161,9 +131,9 @@ def main():
)
else:
if set_all:
- alert(translations_dict["Alert.NoLinksFound.Message"])
+ alert(translations["Alert.NoLinksFound.Message"])
else:
- alert(translations_dict["Alert.SelectOne.Message"])
+ alert(translations["Alert.SelectOne.Message"])
if __name__ == "__main__":
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Project.panel/ptools.stack/Links.pulldown/Create Workset For Linked Element.pushbutton/ws4links_translations.py b/extensions/pyRevitTools.extension/pyRevit.tab/Project.panel/ptools.stack/Links.pulldown/Create Workset For Linked Element.pushbutton/ws4links_translations.py
new file mode 100644
index 000000000..510858db4
--- /dev/null
+++ b/extensions/pyRevitTools.extension/pyRevit.tab/Project.panel/ptools.stack/Links.pulldown/Create Workset For Linked Element.pushbutton/ws4links_translations.py
@@ -0,0 +1,143 @@
+# -*- coding: UTF-8 -*-
+# Translations for scripts.
+
+# for script.py:
+TRANSLATIONS_SCRIPT = {
+ "en_us": {
+ "Worksharing.Enable.Message": "The document doesn't have worksharing enabled.\nEnable it?",
+ "Worksharing.Enable.Options": ["Yes", "No"],
+ "Worksharing.Enable.Error": "The script cannot run in a document without worksharing.",
+ "Worksharing.Enable.Error.Title": "The script has stopped",
+ "Transaction.Name": "Create Workset(s) for linked model(s)",
+ "Alert.NoLinksFound.Message": "No links found in the document.",
+ "Alert.SelectOne.Message": "At least one linked element must be selected."
+ },
+ "fr_fr": {
+ "Worksharing.Enable.Message": "Le partage de projet n'est pas activé pour ce document.\nL'activer ?",
+ "Worksharing.Enable.Options": ["Oui", "Non"],
+ "Worksharing.Enable.Error": "Le script ne peut pas s'exécuter dans un document sans partage de projet.",
+ "Worksharing.Enable.Error.Title": "Le script s'est arrêté",
+ "Transaction.Name": "Créer des sous-projets pour les modèles liés",
+ "Alert.NoLinksFound.Message": "Aucun lien trouvé dans le document.",
+ "Alert.SelectOne.Message": "Au moins un élément lié doit être sélectionné."
+ },
+ "ru": {
+ "Worksharing.Enable.Message": "Документ без совместной работы.\nВключить её?",
+ "Worksharing.Enable.Options": ["Да", "Нет"],
+ "Worksharing.Enable.Error": "Скрипт не может работать в документе без совместной работы.",
+ "Worksharing.Enable.Error.Title": "Скрипт остановлен",
+ "Transaction.Name": "Создание рабочих наборов для связанных моделей",
+ "Alert.NoLinksFound.Message": "В документе нет связанных файлов.",
+ "Alert.SelectOne.Message": "Необходимо выбрать хотя бы один элемент связи."
+ },
+ "chinese_s": {
+ "Worksharing.Enable.Message": "文档未启用工作共享。\n要启用它吗?",
+ "Worksharing.Enable.Options": ["是", "否"],
+ "Worksharing.Enable.Error": "该脚本无法在没有工作共享的文档中运行。",
+ "Worksharing.Enable.Error.Title": "脚本已停止",
+ "Transaction.Name": "为链接模型创建工作集",
+ "Alert.NoLinksFound.Message": "在文档中找不到链接。",
+ "Alert.SelectOne.Message": "必须至少选择一个链接元素。"
+ },
+ "es_es": {
+ "Worksharing.Enable.Message": "El documento no tiene activada la compartición de proyecto.\n¿Activarla?",
+ "Worksharing.Enable.Options": ["Sí", "No"],
+ "Worksharing.Enable.Error": "El script no puede ejecutarse en un documento sin compartición de proyecto.",
+ "Worksharing.Enable.Error.Title": "El script se ha detenido",
+ "Transaction.Name": "Crear subproyectos para modelos vinculados",
+ "Alert.NoLinksFound.Message": "No se encontraron vínculos en el documento.",
+ "Alert.SelectOne.Message": "Se debe seleccionar al menos un elemento vinculado."
+ },
+ "de_de": {
+ "Worksharing.Enable.Message": "Die Bearbeitungsbereiche sind für das Dokument nicht aktiviert.\nAktivieren?",
+ "Worksharing.Enable.Options": ["Ja", "Nein"],
+ "Worksharing.Enable.Error": "Das Skript kann nicht in einem Dokument ohne Bearbeitungsbereiche ausgeführt werden.",
+ "Worksharing.Enable.Error.Title": "Das Skript wurde angehalten",
+ "Transaction.Name": "Bearbeitungsbereiche für verknüpfte Modelle erstellen",
+ "Alert.NoLinksFound.Message": "Keine Verknüpfungen im Dokument gefunden.",
+ "Alert.SelectOne.Message": "Es muss mindestens ein verknüpftes Element ausgewählt werden."
+ },
+ "pt_br": {
+ "Worksharing.Enable.Message": "O documento não tem o compartilhamento de trabalho ativado.\nDeseja ativá-lo?",
+ "Worksharing.Enable.Options": ["Sim", "Não"],
+ "Worksharing.Enable.Error": "O script não pode ser executado em um documento sem compartilhamento de trabalho.",
+ "Worksharing.Enable.Error.Title": "O script foi interrompido",
+ "Transaction.Name": "Criar conjunto(s) de trabalho para modelo(s) vinculado(s)",
+ "Alert.NoLinksFound.Message": "Nenhum vínculo encontrado no documento.",
+ "Alert.SelectOne.Message": "Pelo menos um elemento vinculado deve ser selecionado."
+ },
+} # type: dict[str, dict[str, str | list]]
+
+# for config.py:
+TRANSLATIONS_CONFIG = {
+ "en_us": {
+ "Options.WindowTitle": "Select Options",
+ "Options.Select.Button": "Save Selection",
+ "Options.SetTypeWorkset.Text": "Set Workset for Type",
+ "Options.SetAll.Text": "Collect all Links",
+ "Options.CustomPrefixRvt.Text": "Custom Prefix for RVT",
+ "Options.CustomPrefixDwg.Text": "Custom Prefix for DWG",
+ "PrefixRvt.Prompt": "Pick a Prefix for RVTs",
+ "PrefixDwg.Prompt": "Pick a Prefix for DWGs"
+ },
+ "fr_fr": {
+ "Options.WindowTitle": "Sélectionner les options",
+ "Options.Select.Button": "Enregistrer la sélection",
+ "Options.SetTypeWorkset.Text": "Définir le sous-projet pour le type",
+ "Options.SetAll.Text": "Collecter tous les liens",
+ "Options.CustomPrefixRvt.Text": "Préfixe personnalisé pour RVT",
+ "Options.CustomPrefixDwg.Text": "Préfixe personnalisé pour DWG",
+ "PrefixRvt.Prompt": "Choisissez un préfixe pour les RVT",
+ "PrefixDwg.Prompt": "Choisissez un préfixe pour les DWG"
+ },
+ "ru": {
+ "Options.WindowTitle": "Выберите параметры",
+ "Options.Select.Button": "Сохранить выбор",
+ "Options.SetTypeWorkset.Text": "Назначить рабочий набор и для типоразмера связи",
+ "Options.SetAll.Text": "Применить ко всем связям",
+ "Options.CustomPrefixRvt.Text": "Пользовательский префикс для RVT",
+ "Options.CustomPrefixDwg.Text": "Пользовательский префикс для DWG",
+ "PrefixRvt.Prompt": "Выберите префикс для RVT",
+ "PrefixDwg.Prompt": "Выберите префикс для DWG"
+ },
+ "chinese_s": {
+ "Options.WindowTitle": "选择选项",
+ "Options.Select.Button": "保存选择",
+ "Options.SetTypeWorkset.Text": "为类型设置工作集",
+ "Options.SetAll.Text": "收集所有链接",
+ "Options.CustomPrefixRvt.Text": "RVT 的自定义前缀",
+ "Options.CustomPrefixDwg.Text": "DWG 的自定义前缀",
+ "PrefixRvt.Prompt": "为 RVT 选择一个前缀",
+ "PrefixDwg.Prompt": "为 DWG 选择一个前缀"
+ },
+ "es_es": {
+ "Options.WindowTitle": "Seleccionar opciones",
+ "Options.Select.Button": "Guardar selección",
+ "Options.SetTypeWorkset.Text": "Establecer subproyecto para tipo",
+ "Options.SetAll.Text": "Recopilar todos los vínculos",
+ "Options.CustomPrefixRvt.Text": "Prefijo personalizado para RVT",
+ "Options.CustomPrefixDwg.Text": "Prefijo personalizado para DWG",
+ "PrefixRvt.Prompt": "Elija un prefijo para los RVT",
+ "PrefixDwg.Prompt": "Elija un prefijo para los DWG"
+ },
+ "de_de": {
+ "Options.WindowTitle": "Optionen auswählen",
+ "Options.Select.Button": "Auswahl speichern",
+ "Options.SetTypeWorkset.Text": "Bearbeitungsbereich für Typ festlegen",
+ "Options.SetAll.Text": "Alle Verknüpfungen sammeln",
+ "Options.CustomPrefixRvt.Text": "Benutzerdefiniertes Präfix für RVT",
+ "Options.CustomPrefixDwg.Text": "Benutzerdefiniertes Präfix für DWG",
+ "PrefixRvt.Prompt": "Wählen Sie ein Präfix für RVTs",
+ "PrefixDwg.Prompt": "Wählen Sie ein Präfix für DWGs"
+ },
+ "pt_br": {
+ "Options.WindowTitle": "Selecionar Opções",
+ "Options.Select.Button": "Salvar Seleção",
+ "Options.SetTypeWorkset.Text": "Definir Conjunto de Trabalho para o Tipo",
+ "Options.SetAll.Text": "Coletar todos os Vínculos",
+ "Options.CustomPrefixRvt.Text": "Prefixo personalizado para RVT",
+ "Options.CustomPrefixDwg.Text": "Prefixo personalizado para DWG",
+ "PrefixRvt.Prompt": "Escolha um prefixo para RVTs",
+ "PrefixDwg.Prompt": "Escolha um prefixo para DWGs"
+ },
+} # type: dict[str, dict[str, str | list]]
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Project.panel/ptools2.stack/Get Central Path.pushbutton/script.py b/extensions/pyRevitTools.extension/pyRevit.tab/Project.panel/ptools2.stack/Get Central Path.pushbutton/script.py
index 554e1bb31..82f4f9020 100644
--- a/extensions/pyRevitTools.extension/pyRevit.tab/Project.panel/ptools2.stack/Get Central Path.pushbutton/script.py
+++ b/extensions/pyRevitTools.extension/pyRevit.tab/Project.panel/ptools2.stack/Get Central Path.pushbutton/script.py
@@ -6,14 +6,14 @@
#pylint: disable=E0401,invalid-name
import os.path as op
-from pyrevit import revit
+from pyrevit import revit, EXEC_PARAMS
from pyrevit import forms
from pyrevit import script
if forms.check_workshared(doc=revit.doc):
central_path = revit.query.get_central_path(doc=revit.doc)
- if __shiftclick__: #pylint: disable=E0602
+ if EXEC_PARAMS.config_mode:
script.show_folder_in_explorer(op.dirname(central_path))
else:
print(central_path)
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Selection.panel/Pick.splitpushbutton/Pick Elements by Parametervalue.pushbutton/bundle.yaml b/extensions/pyRevitTools.extension/pyRevit.tab/Selection.panel/Pick.splitpushbutton/Pick Elements by Parametervalue.pushbutton/bundle.yaml
new file mode 100644
index 000000000..d8780a884
--- /dev/null
+++ b/extensions/pyRevitTools.extension/pyRevit.tab/Selection.panel/Pick.splitpushbutton/Pick Elements by Parametervalue.pushbutton/bundle.yaml
@@ -0,0 +1,19 @@
+title:
+ en_us: Pick Elements by Parameter Value
+ fr_fr: Sélectionner des éléments par valeur de paramètre
+ ru: Выбрать элементы по значению параметра
+ chinese_s: 按参数值选择图元
+ es_es: Seleccionar elementos por valor de parámetro
+ de_de: Elemente nach Parameterwert auswählen
+ pt_br: Selecionar Elementos por Valor de Parâmetro
+
+tooltip:
+ en_us: Select elements that match the chosen parameter value(s). By default, any parameter match is accepted. Hold Shift while clicking to require all parameter values to match.
+ fr_fr: Sélectionne les éléments correspondant aux valeurs de paramètres choisies. Par défaut, une correspondance suffit. Maintenez Maj en cliquant pour exiger que toutes les valeurs correspondent.
+ ru: Выбирает элементы с совпадающими значениями параметров. По умолчанию достаточно любого совпадения. Удерживайте Shift при щелчке, чтобы требовать совпадение всех параметров.
+ chinese_s: 选择与所选参数值匹配的图元。默认情况下,任意一个参数匹配即可。按住 Shift 单击以要求所有参数值都匹配。
+ es_es: Selecciona elementos que coinciden con los valores de parámetro elegidos. Por defecto, cualquier coincidencia es suficiente. Mantén Shift al hacer clic para exigir que todos coincidan.
+ de_de: Wählt Elemente mit übereinstimmenden Parameterwerten aus. Standardmäßig reicht eine beliebige Übereinstimmung. Halten Sie Shift gedrückt, um zu verlangen, dass alle Parameterwerte übereinstimmen.
+ pt_br: Seleciona elementos que correspondem aos valores de parâmetro escolhidos. Por padrão, qualquer correspondência é aceita. Segure Shift ao clicar para exigir que todos os valores coincidam.
+
+author: wurschdhaud
\ No newline at end of file
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Selection.panel/Pick.splitpushbutton/Pick Elements by Parametervalue.pushbutton/script.py b/extensions/pyRevitTools.extension/pyRevit.tab/Selection.panel/Pick.splitpushbutton/Pick Elements by Parametervalue.pushbutton/script.py
new file mode 100644
index 000000000..28a0fe7cf
--- /dev/null
+++ b/extensions/pyRevitTools.extension/pyRevit.tab/Selection.panel/Pick.splitpushbutton/Pick Elements by Parametervalue.pushbutton/script.py
@@ -0,0 +1,83 @@
+"""Activates selection tool that picks only elements with the same parametervalue."""
+
+from pyrevit import forms, revit, EXEC_PARAMS
+from pyrevit import UI
+
+
+class ParamValueSelectionFilter(UI.Selection.ISelectionFilter):
+ def __init__(self, p_v_dict):
+ self.p_v_dict = p_v_dict
+
+ # standard API override function
+ def AllowElement(self, element):
+ matches = []
+
+ for p, v in self.p_v_dict.items():
+ try:
+ el_param = element.LookupParameter(p.Definition.Name)
+ if not el_param:
+ matches.append(False)
+ continue
+
+ el_val = revit.query.get_param_value(el_param)
+
+ matches.append(el_val == v)
+
+ except Exception:
+ matches.append(False)
+
+ # ANY match (default mode)
+ if not EXEC_PARAMS.config_mode and any(matches):
+ return True
+
+ # ALL match (config mode)
+ elif EXEC_PARAMS.config_mode and all(matches):
+ return True
+
+ else:
+ return False
+
+ # standard API override function
+ def AllowReference(self, refer, point):
+ return False
+
+
+def main():
+ try:
+ sel = revit.get_selection()
+ elem = (
+ sel[0]
+ if len(sel) == 1
+ else revit.pick_element(message="Pick an element for parameter selection")
+ )
+ if not elem:
+ return
+
+ param_defs = forms.select_parameters(elem, exclude_readonly=False)
+
+ p_v_dict = {}
+ for param_def in param_defs:
+ p = elem.LookupParameter(param_def.name)
+ v = revit.query.get_param_value(p)
+ p_v_dict[p] = v
+
+ pvsfilter = ParamValueSelectionFilter(p_v_dict)
+
+ selection_list = revit.pick_rectangle(
+ message="Draw a rectangle to pick elements with the same parametervalue",
+ pick_filter=pvsfilter,
+ )
+
+ filtered_list = []
+ for el in selection_list:
+ filtered_list.append(el.Id)
+
+ sel.set_to(filtered_list)
+ revit.uidoc.RefreshActiveView()
+
+ except Exception:
+ pass
+
+
+if __name__ == "__main__":
+ main()
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Selection.panel/Pick.splitpushbutton/bundle.yaml b/extensions/pyRevitTools.extension/pyRevit.tab/Selection.panel/Pick.splitpushbutton/bundle.yaml
index 14ae28954..adadfbe93 100644
--- a/extensions/pyRevitTools.extension/pyRevit.tab/Selection.panel/Pick.splitpushbutton/bundle.yaml
+++ b/extensions/pyRevitTools.extension/pyRevit.tab/Selection.panel/Pick.splitpushbutton/bundle.yaml
@@ -10,3 +10,4 @@ layout:
- Pick
- Pick Detail Elements
- Pick Model Elements
+ - Pick Elements by Parametervalue
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Selection.panel/memo.stack/Memory.pulldown/Next.pushbutton/script.py b/extensions/pyRevitTools.extension/pyRevit.tab/Selection.panel/memo.stack/Memory.pulldown/Next.pushbutton/script.py
index 215d16dda..f8f17f640 100644
--- a/extensions/pyRevitTools.extension/pyRevit.tab/Selection.panel/memo.stack/Memory.pulldown/Next.pushbutton/script.py
+++ b/extensions/pyRevitTools.extension/pyRevit.tab/Selection.panel/memo.stack/Memory.pulldown/Next.pushbutton/script.py
@@ -5,5 +5,6 @@
"""
import iter_selection
+from pyrevit import EXEC_PARAMS
-iter_selection.iterate('+', 10 if __shiftclick__ else 1)
+iter_selection.iterate('+', 10 if EXEC_PARAMS.config_mode else 1)
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Selection.panel/memo.stack/Memory.pulldown/Prev.pushbutton/script.py b/extensions/pyRevitTools.extension/pyRevit.tab/Selection.panel/memo.stack/Memory.pulldown/Prev.pushbutton/script.py
index a3d381e55..245df257c 100644
--- a/extensions/pyRevitTools.extension/pyRevit.tab/Selection.panel/memo.stack/Memory.pulldown/Prev.pushbutton/script.py
+++ b/extensions/pyRevitTools.extension/pyRevit.tab/Selection.panel/memo.stack/Memory.pulldown/Prev.pushbutton/script.py
@@ -5,6 +5,6 @@
"""
import iter_selection
+from pyrevit import EXEC_PARAMS
-
-iter_selection.iterate('-', 10 if __shiftclick__ else 1)
+iter_selection.iterate('-', 10 if EXEC_PARAMS.config_mode else 1)
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Selection.panel/memo.stack/lib/copypastestate/actions.py b/extensions/pyRevitTools.extension/pyRevit.tab/Selection.panel/memo.stack/lib/copypastestate/actions.py
index a1c97902f..e93a3f52e 100644
--- a/extensions/pyRevitTools.extension/pyRevit.tab/Selection.panel/memo.stack/lib/copypastestate/actions.py
+++ b/extensions/pyRevitTools.extension/pyRevit.tab/Selection.panel/memo.stack/lib/copypastestate/actions.py
@@ -4,7 +4,7 @@
import math
from pyrevit import PyRevitException
-from pyrevit import revit, DB
+from pyrevit import revit, DB, EXEC_PARAMS
from pyrevit.framework import List
from pyrevit.coreutils import logger
from pyrevit.coreutils import moduleutils
@@ -579,7 +579,7 @@ def paste(self):
viewports = revit.get_selection().include(DB.Viewport)
align_axis = None
- if __shiftclick__: # pylint: disable=undefined-variable
+ if EXEC_PARAMS.config_mode:
align_axis = forms.CommandSwitchWindow.show(
["X", "Y", "XY"], message="Align specific axis?"
)
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Selection.panel/select.stack/Select.pulldown/Invert Selection.pushbutton/script.py b/extensions/pyRevitTools.extension/pyRevit.tab/Selection.panel/select.stack/Select.pulldown/Invert Selection.pushbutton/script.py
index 6aa9e1164..f8d25eb55 100644
--- a/extensions/pyRevitTools.extension/pyRevit.tab/Selection.panel/select.stack/Select.pulldown/Invert Selection.pushbutton/script.py
+++ b/extensions/pyRevitTools.extension/pyRevit.tab/Selection.panel/select.stack/Select.pulldown/Invert Selection.pushbutton/script.py
@@ -4,7 +4,7 @@
Select group members instead of parent group elements.
"""
#pylint: disable=import-error,invalid-name,broad-except
-from pyrevit import revit, DB
+from pyrevit import revit, DB, EXEC_PARAMS
from pyrevit.compat import get_elementid_value_func
get_elementid_value = get_elementid_value_func()
@@ -29,7 +29,7 @@
# if shiftclick, select all the invert elements
# otherwise do not select elements inside a group
filtered_invert_ids = invert_ids.copy()
-if not __shiftclick__: #pylint: disable=undefined-variable
+if not EXEC_PARAMS.config_mode:
# collect ids of elements inside a group
grouped_element_ids = \
[get_elementid_value(x.Id) for x in viewelements
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Selection.panel/select.stack/Select.pulldown/Select All Objects Of Selected Type.pushbutton/script.py b/extensions/pyRevitTools.extension/pyRevit.tab/Selection.panel/select.stack/Select.pulldown/Select All Objects Of Selected Type.pushbutton/script.py
index c732279a2..ef8c6d643 100644
--- a/extensions/pyRevitTools.extension/pyRevit.tab/Selection.panel/select.stack/Select.pulldown/Select All Objects Of Selected Type.pushbutton/script.py
+++ b/extensions/pyRevitTools.extension/pyRevit.tab/Selection.panel/select.stack/Select.pulldown/Select All Objects Of Selected Type.pushbutton/script.py
@@ -6,7 +6,7 @@
Show Results
"""
#pylint: disable=import-error,invalid-name,unused-argument,broad-except,superfluous-parens
-from pyrevit import revit, DB
+from pyrevit import revit, DB, EXEC_PARAMS
from pyrevit import script
from pyrevit import forms
@@ -75,7 +75,7 @@
model_items.append(sim_element)
# print results if requested
-if __shiftclick__: #pylint: disable=undefined-variable
+if EXEC_PARAMS.config_mode:
if is_viewspecific:
for ovname, items in viewspecific_items.items():
print('OWNER VIEW: {0}'.format(ovname))
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Selection.panel/select.stack/Select.pulldown/Select All Vertical Reveals.pushbutton/script.py b/extensions/pyRevitTools.extension/pyRevit.tab/Selection.panel/select.stack/Select.pulldown/Select All Vertical Reveals.pushbutton/script.py
index 2435e73aa..06e42ab63 100644
--- a/extensions/pyRevitTools.extension/pyRevit.tab/Selection.panel/select.stack/Select.pulldown/Select All Vertical Reveals.pushbutton/script.py
+++ b/extensions/pyRevitTools.extension/pyRevit.tab/Selection.panel/select.stack/Select.pulldown/Select All Vertical Reveals.pushbutton/script.py
@@ -4,7 +4,7 @@
Select horizontal reveals.
"""
#pylint: disable=import-error,invalid-name
-from pyrevit import revit, DB
+from pyrevit import revit, DB, EXEC_PARAMS
reveal_ids = []
@@ -13,7 +13,7 @@
.OfCategory(DB.BuiltInCategory.OST_Reveals)\
.WhereElementIsNotElementType()\
.ToElements():
- if el.GetWallSweepInfo().IsVertical != __shiftclick__: #pylint: disable=undefined-variable
+ if el.GetWallSweepInfo().IsVertical != EXEC_PARAMS.config_mode:
reveal_ids.append(el.Id)
revit.get_selection().set_to(reveal_ids)
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Selection.panel/select.stack/Select.pulldown/Select Similar Elements In Active View.pushbutton/script.py b/extensions/pyRevitTools.extension/pyRevit.tab/Selection.panel/select.stack/Select.pulldown/Select Similar Elements In Active View.pushbutton/script.py
index 5a85147a1..0be49f2dd 100644
--- a/extensions/pyRevitTools.extension/pyRevit.tab/Selection.panel/select.stack/Select.pulldown/Select Similar Elements In Active View.pushbutton/script.py
+++ b/extensions/pyRevitTools.extension/pyRevit.tab/Selection.panel/select.stack/Select.pulldown/Select Similar Elements In Active View.pushbutton/script.py
@@ -3,12 +3,12 @@
Shift-Click: select in whole project"""
#pylint: disable=import-error,invalid-name,broad-except,superfluous-parens
-from pyrevit import revit, DB
+from pyrevit import revit, DB, EXEC_PARAMS
from pyrevit import forms
from pyrevit.framework import List
-if not __shiftclick__:
+if not EXEC_PARAMS.config_mode:
# ensure active view is a graphical view
forms.check_graphicalview(revit.active_view, exitscript=True)
@@ -28,7 +28,7 @@
mc_filter = DB.ElementMulticategoryFilter(List[DB.ElementId](sel_cat_ids))
# collect from whole model
-if __shiftclick__:
+if EXEC_PARAMS.config_mode:
cl = DB.FilteredElementCollector(revit.doc)\
.WhereElementIsNotElementType()
# collect from a view
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Toggles.panel/toggles1.stack/MinifyUI.smartbutton/minifyui.py b/extensions/pyRevitTools.extension/pyRevit.tab/Toggles.panel/toggles1.stack/MinifyUI.smartbutton/minifyui.py
index 38bde7722..cc3f86a95 100644
--- a/extensions/pyRevitTools.extension/pyRevit.tab/Toggles.panel/toggles1.stack/MinifyUI.smartbutton/minifyui.py
+++ b/extensions/pyRevitTools.extension/pyRevit.tab/Toggles.panel/toggles1.stack/MinifyUI.smartbutton/minifyui.py
@@ -1,8 +1,10 @@
-"""Monify UI backend."""
+# -*- coding: utf-8 -*-
+"""Minify UI backend."""
#pylint: disable=E0401,C0103
from pyrevit import forms
from pyrevit import script
from pyrevit.coreutils import ribbon
+from pyrevit.runtime import types
mlogger = script.get_logger()
@@ -46,12 +48,13 @@ def config_minifyui(config):
def update_ui(config):
- # Minify or unminify the ui here
hidden_tabs = get_minifyui_config(config)
- for tab in ribbon.get_current_ui():
- if tab.name in hidden_tabs:
- # not new state since the visible value is reverse
- tab.visible = not script.get_envvar(MINIFYUI_ENV_VAR)
+ is_active = script.get_envvar(MINIFYUI_ENV_VAR)
+
+ if is_active:
+ types.RibbonTabVisibilityUtils.StartHidingTabs(hidden_tabs)
+ else:
+ types.RibbonTabVisibilityUtils.StopHidingTabs()
def toggle_minifyui(config):
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Toggles.panel/toggles1.stack/MinifyUI.smartbutton/script.py b/extensions/pyRevitTools.extension/pyRevit.tab/Toggles.panel/toggles1.stack/MinifyUI.smartbutton/script.py
index 1cf33c4f1..2de7b9cae 100644
--- a/extensions/pyRevitTools.extension/pyRevit.tab/Toggles.panel/toggles1.stack/MinifyUI.smartbutton/script.py
+++ b/extensions/pyRevitTools.extension/pyRevit.tab/Toggles.panel/toggles1.stack/MinifyUI.smartbutton/script.py
@@ -1,3 +1,4 @@
+# -*- coding: utf-8 -*-
"""Reduce the list of open Revit tabs.
Shift+Click:
@@ -9,16 +10,25 @@
import pyrevit.extensions as exts
from pyrevit.coreutils.ribbon import ICON_MEDIUM
+import minifyui
+
config = script.get_config()
# FIXME: need to figure out a way to fix the icon sizing of toggle buttons
def __selfinit__(script_cmp, ui_button_cmp, __rvt__):
- off_icon = ui.resolve_icon_file(script_cmp.directory, exts.DEFAULT_OFF_ICON_FILE)
- ui_button_cmp.set_icon(off_icon, icon_size=ICON_MEDIUM)
+ is_active = script.get_envvar(minifyui.MINIFYUI_ENV_VAR)
+ if is_active:
+ on_icon = ui.resolve_icon_file(
+ script_cmp.directory, exts.DEFAULT_ON_ICON_FILE)
+ ui_button_cmp.set_icon(on_icon, icon_size=ICON_MEDIUM)
+ minifyui.update_ui(config)
+ else:
+ off_icon = ui.resolve_icon_file(
+ script_cmp.directory, exts.DEFAULT_OFF_ICON_FILE)
+ ui_button_cmp.set_icon(off_icon, icon_size=ICON_MEDIUM)
if __name__ == '__main__':
- import minifyui
minifyui.toggle_minifyui(config)
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Toggles.panel/toggles1.stack/Tab Coloring.smartbutton/script.py b/extensions/pyRevitTools.extension/pyRevit.tab/Toggles.panel/toggles1.stack/Tab Coloring.smartbutton/script.py
index 4cc74fc0b..e79cd8a47 100644
--- a/extensions/pyRevitTools.extension/pyRevit.tab/Toggles.panel/toggles1.stack/Tab Coloring.smartbutton/script.py
+++ b/extensions/pyRevitTools.extension/pyRevit.tab/Toggles.panel/toggles1.stack/Tab Coloring.smartbutton/script.py
@@ -1,9 +1,6 @@
"""Does its best at visually separating open documents."""
#pylint: disable=import-error,invalid-name,broad-except,superfluous-parens
-import re
-
-from pyrevit import HOST_APP
-from pyrevit.runtime.types import DocumentTabEventUtils
+from pyrevit import EXEC_PARAMS
from pyrevit.coreutils.ribbon import ICON_MEDIUM
from pyrevit import script
from pyrevit.userconfig import user_config
@@ -64,7 +61,7 @@ def reset_slots():
if __name__ == '__main__':
- if __shiftclick__: #pylint: disable=undefined-variable
+ if EXEC_PARAMS.config_mode:
selected_option = forms.CommandSwitchWindow.show(
["List Document Colors", "Reset Theme"],
message="Select option:"
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Toggles.panel/toggles3.stack/ViewRange.pushbutton/MainWindow.xaml b/extensions/pyRevitTools.extension/pyRevit.tab/Toggles.panel/toggles3.stack/ViewRange.pushbutton/MainWindow.xaml
index 47962b5f0..5d41f92a2 100644
--- a/extensions/pyRevitTools.extension/pyRevit.tab/Toggles.panel/toggles3.stack/ViewRange.pushbutton/MainWindow.xaml
+++ b/extensions/pyRevitTools.extension/pyRevit.tab/Toggles.panel/toggles3.stack/ViewRange.pushbutton/MainWindow.xaml
@@ -41,14 +41,16 @@
Text="{Binding topplane_new_value, UpdateSourceTrigger=PropertyChanged}"
Width="100" Height="25" Margin="5,0"
HorizontalContentAlignment="Right" VerticalContentAlignment="Center"
- IsEnabled="{Binding can_modify_view}"/>
+ IsEnabled="{Binding can_modify_view}"
+ Background="{Binding topplane_field_bg}"/>
+ IsEnabled="{Binding can_modify_view}"
+ Background="{Binding topplane_field_bg}"/>
@@ -58,7 +60,8 @@
Text="{Binding cutplane_new_value, UpdateSourceTrigger=PropertyChanged}"
Width="100" Height="25" Margin="5,0"
HorizontalContentAlignment="Right" VerticalContentAlignment="Center"
- IsEnabled="{Binding can_modify_view}"/>
+ IsEnabled="{Binding can_modify_view}"
+ Background="{Binding cutplane_field_bg}"/>
@@ -71,14 +74,16 @@
Text="{Binding bottomplane_new_value, UpdateSourceTrigger=PropertyChanged}"
Width="100" Height="25" Margin="5,0"
HorizontalContentAlignment="Right" VerticalContentAlignment="Center"
- IsEnabled="{Binding can_modify_view}"/>
+ IsEnabled="{Binding can_modify_view}"
+ Background="{Binding bottomplane_field_bg}"/>
+ IsEnabled="{Binding can_modify_view}"
+ Background="{Binding bottomplane_field_bg}"/>
@@ -88,14 +93,16 @@
Text="{Binding viewdepth_new_value, UpdateSourceTrigger=PropertyChanged}"
Width="100" Height="25" Margin="5,0"
HorizontalContentAlignment="Right" VerticalContentAlignment="Center"
- IsEnabled="{Binding can_modify_view}"/>
+ IsEnabled="{Binding can_modify_view}"
+ Background="{Binding viewdepth_field_bg}"/>
+ IsEnabled="{Binding can_modify_view}"
+ Background="{Binding viewdepth_field_bg}"/>
@@ -108,7 +115,17 @@
-
+
+
+
+
+
+
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Toggles.panel/toggles3.stack/ViewRange.pushbutton/bundle.yaml b/extensions/pyRevitTools.extension/pyRevit.tab/Toggles.panel/toggles3.stack/ViewRange.pushbutton/bundle.yaml
index f54880677..da57a72aa 100644
--- a/extensions/pyRevitTools.extension/pyRevit.tab/Toggles.panel/toggles3.stack/ViewRange.pushbutton/bundle.yaml
+++ b/extensions/pyRevitTools.extension/pyRevit.tab/Toggles.panel/toggles3.stack/ViewRange.pushbutton/bundle.yaml
@@ -25,6 +25,6 @@ tooltip:
author: Tamás Déri
min_revit_version: 2023
engine:
- clean: true
+ clean: false
full_frame: false
persistent: true
diff --git a/extensions/pyRevitTools.extension/pyRevit.tab/Toggles.panel/toggles3.stack/ViewRange.pushbutton/script.py b/extensions/pyRevitTools.extension/pyRevit.tab/Toggles.panel/toggles3.stack/ViewRange.pushbutton/script.py
index a242fb8c8..f6d55912f 100644
--- a/extensions/pyRevitTools.extension/pyRevit.tab/Toggles.panel/toggles3.stack/ViewRange.pushbutton/script.py
+++ b/extensions/pyRevitTools.extension/pyRevit.tab/Toggles.panel/toggles3.stack/ViewRange.pushbutton/script.py
@@ -1,28 +1,56 @@
# -*- coding: UTF-8 -*-
+# ── Duplicate-window guard ──────────────────────────────────────────
+# With engine: { persistent: true, clean: false }, the same IronPython
+# engine and scope persist across clicks. script.exit() is safe here —
+# it raises SystemExit which the runner catches without disposing the
+# engine. No new .NET objects created, no engine disposal side effects.
+from pyrevit import script
-from pyrevit import script, forms, revit, HOST_APP, DB, UI
+logger = script.get_logger()
+
+VIEWRANGE_WINDOW_KEY = "PYREVIT_VIEWRANGE_WINDOW"
+VIEWRANGE_EXECID_KEY = "PYREVIT_VIEWRANGE_EXECID"
+
+_existing = script.get_envvar(VIEWRANGE_WINDOW_KEY)
+if _existing:
+ try:
+ if _existing.IsVisible:
+ script.exit()
+ except SystemExit:
+ raise
+ except Exception:
+ script.set_envvar(VIEWRANGE_WINDOW_KEY, None)
+ script.set_envvar(VIEWRANGE_EXECID_KEY, None)
+
+# ── Full imports ────────────────────────────────────────────────────
+from pyrevit import forms, revit, HOST_APP, DB, UI, EXEC_PARAMS
from pyrevit.revit import events
from pyrevit.framework import Convert, List, Color, SolidColorBrush
from pyrevit.compat import get_elementid_value_func
from collections import OrderedDict
-
doc = HOST_APP.doc
uidoc = HOST_APP.uidoc
-logger = script.get_logger()
output = script.get_output()
-PLANES = OrderedDict([
- (DB.PlanViewPlane.TopClipPlane, ([0, 255, 0], "Top Clip Plane", "topplane")),
- (DB.PlanViewPlane.CutPlane, ([255, 0, 0], "Cut Plane", "cutplane")),
- (DB.PlanViewPlane.BottomClipPlane, ([0, 0, 255], "Bottom Clip Plane", "bottomplane")),
- (DB.PlanViewPlane.ViewDepthPlane, ([255, 127, 0], "View Depth Plane", "viewdepth")),
-])
+PLANES = OrderedDict(
+ [
+ (DB.PlanViewPlane.TopClipPlane, ([0, 255, 0], "Top Clip Plane", "topplane")),
+ (DB.PlanViewPlane.CutPlane, ([255, 0, 0], "Cut Plane", "cutplane")),
+ (
+ DB.PlanViewPlane.BottomClipPlane,
+ ([0, 0, 255], "Bottom Clip Plane", "bottomplane"),
+ ),
+ (
+ DB.PlanViewPlane.ViewDepthPlane,
+ ([255, 127, 0], "View Depth Plane", "viewdepth"),
+ ),
+ ]
+)
get_elementid_value = get_elementid_value_func()
INVALID_ID_VALUE = get_elementid_value(DB.ElementId.InvalidElementId)
-
class Context(object):
def __new__(cls, *args, **kwargs):
if not hasattr(cls, "instance"):
@@ -67,17 +95,38 @@ def source_view(self, value):
if not compare_views(self._source_view, value):
self._source_view = value
self._levels_populated = False # Reset when view changes
+
+ self._source_template = None
+ if (
+ self.source_view is not None
+ and self.source_view.ViewTemplateId != DB.ElementId.InvalidElementId
+ ):
+ template = self.source_view.Document.GetElement(
+ self.source_view.ViewTemplateId
+ )
+ non_controlled_params = template.GetNonControlledTemplateParameterIds()
+ if (
+ DB.ElementId(DB.BuiltInParameter.PLAN_VIEW_RANGE)
+ not in non_controlled_params
+ ):
+ self._source_template = template
+
self.context_changed()
def update_view_range(self, new_values, new_levels=None):
if not self.source_view or not isinstance(self.source_view, DB.ViewPlan):
- self.view_model.warning_message = "No valid plan view selected"
+ self.view_model.show_error("No valid plan view selected")
return False
- if self.source_view.IsTemplate:
- self.view_model.warning_message = (
- "Cannot modify view range - this is a view template"
+
+ if self._source_template is not None:
+ dialog_result = forms.alert(
+ "You are about to change a View Template! Are you sure you want to proceed?",
+ ok=False,
+ yes=True,
+ no=True,
)
- return False
+ if not dialog_result:
+ return False
events.execute_in_revit_context(
self._update_view_range_internal, new_values, new_levels
@@ -85,190 +134,142 @@ def update_view_range(self, new_values, new_levels=None):
return True
def _update_view_range_internal(self, new_values, new_levels=None):
- try:
- if not self._validate_view_range_order(new_values, new_levels):
- return False
-
- with revit.Transaction("Update View Range", doc=revit.doc):
- view_range = self.source_view.GetViewRange()
-
- # First, update levels if provided
- if new_levels:
- for plane, new_level_id in new_levels.items():
- current_level_id = view_range.GetLevelId(plane)
-
- # Handle Unlimited (InvalidElementId)
- if new_level_id == DB.ElementId.InvalidElementId:
- # For View Depth, Unlimited is common - set to InvalidElementId
- if current_level_id != DB.ElementId.InvalidElementId:
- try:
- # Note: Not all planes support InvalidElementId
- # View Depth typically does, others may not
- view_range.SetLevelId(
- plane, DB.ElementId.InvalidElementId
- )
- except Exception:
- pass
- # Handle regular level changes
- elif new_level_id and current_level_id != new_level_id:
- try:
- view_range.SetLevelId(plane, new_level_id)
- except Exception:
- pass
-
- # Then, update offsets (only for planes that have valid levels)
- for plane, offset_str in new_values.items():
- # Skip offset update if level is set to Unlimited
- level_id = view_range.GetLevelId(plane)
- if not level_id or level_id == DB.ElementId.InvalidElementId:
- continue
-
- if (
- offset_str
- and offset_str.strip()
- and offset_str != "-"
- and offset_str.upper() != "N/A"
- ):
- try:
- offset_display = float(offset_str)
- offset_internal = DB.UnitUtils.ConvertToInternalUnits(
- offset_display, self.length_unit
- )
- current_offset = view_range.GetOffset(plane)
-
- # Only update if different
- if abs(current_offset - offset_internal) > 0.0001:
- view_range.SetOffset(plane, offset_internal)
- except ValueError:
- return False
-
- # Apply the view range back to the view
- self.source_view.SetViewRange(view_range)
- self.context_changed()
- self.view_model.warning_message = "View range updated successfully"
- return True
+ self.view_model.clear_field_errors()
+ # ── Build the PlanViewRange in memory (no transaction needed) ──
+ try:
+ view_range = self.source_view.GetViewRange()
except Exception as e:
- self.view_model.warning_message = "Error updating view range: {}".format(
- str(e)
+ self.view_model.show_error(
+ "Error reading view range: {}".format(e)
)
return False
- def _validate_view_range_order(self, new_values, new_levels=None):
- try:
- elevations = {}
- offset_values = {}
-
- for plane, offset_str in new_values.items():
- # Skip validation for N/A values (Unlimited levels)
- if offset_str and offset_str.upper() == "N/A":
- continue
-
- if offset_str and offset_str.strip() and offset_str != "-":
- try:
- offset_value = float(offset_str)
- offset_values[plane] = offset_value
-
- # Use new level if provided, otherwise use current level
- level = None
- if new_levels and plane in new_levels:
- level_id = new_levels[plane]
- # Skip validation for Unlimited (InvalidElementId)
- if (
- not level_id
- or level_id == DB.ElementId.InvalidElementId
- ):
- continue
- level = self.source_view.Document.GetElement(level_id)
-
- if not level:
- level = self.level_data.get(plane)
-
- if level:
- level_elevation = DB.UnitUtils.ConvertFromInternalUnits(
- level.ProjectElevation, self.length_unit
+ # Update levels
+ if new_levels:
+ for plane, new_level_id in new_levels.items():
+ current_level_id = view_range.GetLevelId(plane)
+ if new_level_id == DB.ElementId.InvalidElementId:
+ if current_level_id != DB.ElementId.InvalidElementId:
+ try:
+ view_range.SetLevelId(
+ plane, DB.ElementId.InvalidElementId
)
- elevations[plane] = level_elevation + offset_value
- except ValueError:
- forms.alert(
- "Invalid Input Format!\n\nThe value '{}' for {} is not a valid number.\n\n"
- "Please enter a numeric value (e.g., 4.5, -2.0, 0)".format(
- offset_str, PLANES[plane][1]
- ),
- title="Invalid Number Format",
- warn_icon=True,
- )
- self.view_model.warning_message = (
- "Invalid number format: {}".format(offset_str)
- )
- return False
-
- # If any plane is set to Unlimited, skip full validation
- if len(elevations) < 4:
- return True
-
- # Check order: Top >= Cut >= Bottom >= ViewDepth
- checks = [
- (
- DB.PlanViewPlane.TopClipPlane,
- DB.PlanViewPlane.CutPlane,
- "Top Clip Plane",
- "Cut Plane",
- ),
- (
- DB.PlanViewPlane.CutPlane,
- DB.PlanViewPlane.BottomClipPlane,
- "Cut Plane",
- "Bottom Clip Plane",
- ),
- (
- DB.PlanViewPlane.BottomClipPlane,
- DB.PlanViewPlane.ViewDepthPlane,
- "Bottom Clip Plane",
- "View Depth Plane",
- ),
- ]
-
- for higher_plane, lower_plane, higher_name, lower_name in checks:
- higher_elev = elevations.get(higher_plane)
- lower_elev = elevations.get(lower_plane)
-
- if (
- higher_elev is not None
- and lower_elev is not None
- and higher_elev < lower_elev
- ):
- forms.alert(
- "View Range Order Error!\n\n{} (offset: {:.2f}') must be greater than or equal to {} "
- "(offset: {:.2f}').\n\nNote: These are offset values from the associated level.\n\n"
- "Correct order (top to bottom):\n1. Top Clip Plane (highest offset)\n2. Cut Plane\n"
- "3. Bottom Clip Plane\n4. View Depth Plane (lowest offset)".format(
- higher_name,
- offset_values.get(higher_plane, 0),
- lower_name,
- offset_values.get(lower_plane, 0),
- ),
- title="Invalid View Range",
- warn_icon=True,
+ except Exception:
+ pass
+ elif new_level_id and current_level_id != new_level_id:
+ try:
+ view_range.SetLevelId(plane, new_level_id)
+ except Exception:
+ pass
+
+ # Update offsets
+ for plane, offset_str in new_values.items():
+ level_id = view_range.GetLevelId(plane)
+ if not level_id or level_id == DB.ElementId.InvalidElementId:
+ continue
+ if (
+ offset_str
+ and offset_str.strip()
+ and offset_str != "-"
+ and offset_str.upper() != "N/A"
+ ):
+ try:
+ offset_display = float(offset_str)
+ offset_internal = DB.UnitUtils.ConvertToInternalUnits(
+ offset_display, self.length_unit
)
- self.view_model.warning_message = "{} must be >= {}".format(
- higher_name, lower_name
+ current_offset = view_range.GetOffset(plane)
+ if abs(current_offset - offset_internal) > 0.0001:
+ view_range.SetOffset(plane, offset_internal)
+ except ValueError:
+ _, _, prefix = PLANES[plane]
+ self.view_model.set_field_error(prefix)
+ self.view_model.show_error(
+ "Invalid number format in {} field".format(
+ PLANES[plane][1]
+ )
)
return False
- return True
+ # ── Validate assembled view range (internal units, single pass) ──
+ error_prefixes = self._find_elevation_violations(view_range)
+ if error_prefixes:
+ self.view_model.set_field_error(*error_prefixes)
+ self.view_model.show_error(
+ "Invalid view range: plane elevations must be ordered "
+ "Top \u2265 Cut \u2265 Bottom \u2265 View Depth"
+ )
+ return False
+ # ── Apply to the model inside a transaction ──
+ # try/except wraps the with block so the exception propagates to
+ # revit.Transaction.__exit__ for proper rollback (no ghost Undo).
+ apply_error = None
+ try:
+ with revit.Transaction("Update View Range", doc=revit.doc):
+ self.source_view.SetViewRange(view_range)
except Exception as e:
- self.view_model.warning_message = "Error validating view range: {}".format(
- str(e)
+ apply_error = e
+
+ if apply_error:
+ all_prefixes = [p for _, _, p in PLANES.values()]
+ self.view_model.set_field_error(*all_prefixes)
+ self.view_model.show_error(
+ "Invalid view range: the combination of levels and "
+ "offsets is not allowed by Revit"
)
return False
+ self.context_changed()
+ self.view_model.show_success("View range updated successfully")
+ return True
+
+ def _find_elevation_violations(self, view_range):
+ """Check the assembled view_range for ordering violations.
+
+ Returns a list of prefixes (e.g. ['topplane', 'cutplane']) for
+ planes that are out of order. Empty list means valid.
+ """
+ ordered_planes = [
+ DB.PlanViewPlane.TopClipPlane,
+ DB.PlanViewPlane.CutPlane,
+ DB.PlanViewPlane.BottomClipPlane,
+ DB.PlanViewPlane.ViewDepthPlane,
+ ]
+ elevations = {}
+ for plane in ordered_planes:
+ level_id = view_range.GetLevelId(plane)
+ if not level_id or level_id == DB.ElementId.InvalidElementId:
+ continue
+ level = self.source_view.Document.GetElement(level_id)
+ if not level:
+ continue
+ elevations[plane] = level.ProjectElevation + view_range.GetOffset(plane)
+
+ # Check each adjacent pair
+ error_planes = set()
+ checks = [
+ (DB.PlanViewPlane.TopClipPlane, DB.PlanViewPlane.CutPlane),
+ (DB.PlanViewPlane.CutPlane, DB.PlanViewPlane.BottomClipPlane),
+ (DB.PlanViewPlane.BottomClipPlane, DB.PlanViewPlane.ViewDepthPlane),
+ ]
+ for higher, lower in checks:
+ if higher in elevations and lower in elevations:
+ if elevations[higher] < elevations[lower] - 0.001:
+ error_planes.add(higher)
+ error_planes.add(lower)
+
+ # Map PlanViewPlane enums to prefixes
+ return [PLANES[p][2] for p in error_planes if p in PLANES]
+
def _populate_available_levels(self):
"""Populate the list of available levels in the project"""
try:
# Get all levels in the project
- level_collector = DB.FilteredElementCollector(self.source_view.Document).OfClass(DB.Level)
+ level_collector = DB.FilteredElementCollector(
+ self.source_view.Document
+ ).OfClass(DB.Level)
levels = list(level_collector)
# Sort levels by elevation
@@ -279,7 +280,11 @@ class LevelItem(object):
def __init__(self, name, element_id, elevation=None, is_special=False):
self.Name = name
self.Id = element_id
- self.IdValue = get_elementid_value(element_id) if element_id else INVALID_ID_VALUE
+ self.IdValue = (
+ get_elementid_value(element_id)
+ if element_id
+ else INVALID_ID_VALUE
+ )
self.Elevation = elevation
self.IsSpecial = is_special
@@ -302,7 +307,7 @@ def __init__(self, name, element_id, elevation=None, is_special=False):
self._levels_populated = True
except Exception as e:
- self.view_model.warning_message = "Error loading levels: {}".format(str(e))
+ self.view_model.show_error("Error loading levels: {}".format(str(e)))
def _set_current_level_selections(self, view_range):
"""Set the current level selections based on the view range"""
@@ -348,12 +353,18 @@ def _set_current_level_selections(self, view_range):
self.view_model.viewdepth_level_id = None
# Then set the actual values
- self.view_model.topplane_level_id = stored_selections.get("top", INVALID_ID_VALUE)
- self.view_model.bottomplane_level_id = stored_selections.get("bottom", INVALID_ID_VALUE)
- self.view_model.viewdepth_level_id = stored_selections.get("viewdepth", INVALID_ID_VALUE)
+ self.view_model.topplane_level_id = stored_selections.get(
+ "top", INVALID_ID_VALUE
+ )
+ self.view_model.bottomplane_level_id = stored_selections.get(
+ "bottom", INVALID_ID_VALUE
+ )
+ self.view_model.viewdepth_level_id = stored_selections.get(
+ "viewdepth", INVALID_ID_VALUE
+ )
except Exception as e:
- self.view_model.warning_message = (
+ self.view_model.show_error(
"Error setting level selections: {}".format(str(e))
)
@@ -373,10 +384,15 @@ def context_changed(self):
setattr(self.view_model, prefix + "_elevation", "-")
setattr(self.view_model, prefix + "_new_value", "")
- self.view_model.warning_message = ""
+ self.view_model.clear_warning()
+ self.view_model.clear_field_errors()
if not self.is_valid():
- server.meshes = None
+ try:
+ server.remove_server()
+ except Exception:
+ pass
+ server.meshes = []
events.execute_in_revit_context(refresh_active_view)
return
@@ -491,7 +507,17 @@ def context_changed(self):
view_dir_transform.OfPoint(pt) for pt in cut_plane_vertices
]
+ # Swap meshes with server removed to prevent Draw Thread
+ # from calling GetBoundingBox during the transition.
+ try:
+ server.remove_server()
+ except Exception:
+ pass
server.meshes = [revit.dc3dserver.Mesh(edges, triangles)]
+ try:
+ server.add_server()
+ except Exception:
+ pass
events.execute_in_revit_context(refresh_active_view)
except Exception as ex:
@@ -521,27 +547,52 @@ def is_valid(self):
)
self.view_model.can_modify_view = False
else:
- can_modify = (
- isinstance(self.source_view, DB.ViewPlan)
- and not self.source_view.IsTemplate
- )
+ can_modify = isinstance(self.source_view, DB.ViewPlan)
self.view_model.can_modify_view = can_modify
- if self.source_view.IsTemplate:
- self.view_model.message = "Showing View Range of [{}]\n(View Template - Cannot Modify)".format(
- self.source_view.Name
- )
- else:
- self.view_model.message = "Showing View Range of\n[{}]".format(
- self.source_view.Name
+ self.view_model.message = "Showing View Range of\n[{}]".format(
+ self.source_view.Name
+ )
+ if self._source_template is not None:
+ self.view_model.message += (
+ " - ⚠️ View Range driven by Template [{}]".format(
+ self._source_template.Name
+ )
)
return True
-
class MainViewModel(forms.Reactive):
+ # Brushes for field error highlighting
+ _DEFAULT_FIELD_BG = SolidColorBrush(Color.FromArgb(
+ Convert.ToByte(0), Convert.ToByte(255),
+ Convert.ToByte(255), Convert.ToByte(255))) # Transparent
+ _ERROR_FIELD_BG = SolidColorBrush(Color.FromArgb(
+ Convert.ToByte(255), Convert.ToByte(255),
+ Convert.ToByte(200), Convert.ToByte(200))) # Light red
+
+ # Warning banner brushes
+ _TRANSPARENT_BG = SolidColorBrush(Color.FromArgb(
+ Convert.ToByte(0), Convert.ToByte(0),
+ Convert.ToByte(0), Convert.ToByte(0)))
+ _ERROR_BANNER_BG = SolidColorBrush(Color.FromArgb(
+ Convert.ToByte(255), Convert.ToByte(254),
+ Convert.ToByte(235), Convert.ToByte(235))) # Soft red bg
+ _ERROR_BANNER_FG = SolidColorBrush(Color.FromRgb(
+ Convert.ToByte(180), Convert.ToByte(30),
+ Convert.ToByte(30))) # Dark red text
+ _SUCCESS_BANNER_BG = SolidColorBrush(Color.FromArgb(
+ Convert.ToByte(255), Convert.ToByte(235),
+ Convert.ToByte(250), Convert.ToByte(235))) # Soft green bg
+ _SUCCESS_BANNER_FG = SolidColorBrush(Color.FromRgb(
+ Convert.ToByte(30), Convert.ToByte(120),
+ Convert.ToByte(30))) # Dark green text
+
def __init__(self):
self._message = None
self._warning_message = ""
+ self._warning_icon = ""
+ self._warning_bg = self._TRANSPARENT_BG
+ self._warning_fg = self._ERROR_BANNER_FG
self._can_modify_view = False
# Initialize level-related properties - use INTEGER values for WPF binding
@@ -562,6 +613,49 @@ def __init__(self):
)
setattr(self, "_" + prefix + "_elevation", "-")
setattr(self, "_" + prefix + "_new_value", "")
+ # Per-field error background (bound to TextBox/ComboBox Background)
+ setattr(self, "_" + prefix + "_field_bg", self._DEFAULT_FIELD_BG)
+
+ def clear_field_errors(self):
+ """Reset all field backgrounds to default (no error)."""
+ for _, _, prefix in PLANES.values():
+ setattr(self, prefix + "_field_bg", self._DEFAULT_FIELD_BG)
+
+ def set_field_error(self, *prefixes):
+ """Set the specified field(s) to error highlight."""
+ for prefix in prefixes:
+ setattr(self, prefix + "_field_bg", self._ERROR_FIELD_BG)
+
+ def show_error(self, msg):
+ """Show an error banner with warning icon."""
+ self._warning_icon = "\u26A0" # ⚠
+ self._warning_bg = self._ERROR_BANNER_BG
+ self._warning_fg = self._ERROR_BANNER_FG
+ # Trigger all bindings
+ self.warning_icon = self._warning_icon
+ self.warning_bg = self._warning_bg
+ self.warning_fg = self._warning_fg
+ self.warning_message = msg
+
+ def show_success(self, msg):
+ """Show a success banner with check icon."""
+ self._warning_icon = "\u2714" # ✔
+ self._warning_bg = self._SUCCESS_BANNER_BG
+ self._warning_fg = self._SUCCESS_BANNER_FG
+ self.warning_icon = self._warning_icon
+ self.warning_bg = self._warning_bg
+ self.warning_fg = self._warning_fg
+ self.warning_message = msg
+
+ def clear_warning(self):
+ """Hide the warning banner."""
+ self._warning_icon = ""
+ self._warning_bg = self._TRANSPARENT_BG
+ self._warning_fg = self._ERROR_BANNER_FG
+ self.warning_icon = self._warning_icon
+ self.warning_bg = self._warning_bg
+ self.warning_fg = self._warning_fg
+ self.warning_message = ""
@forms.reactive
def message(self):
@@ -579,6 +673,30 @@ def warning_message(self):
def warning_message(self, value):
self._warning_message = value
+ @forms.reactive
+ def warning_icon(self):
+ return self._warning_icon
+
+ @warning_icon.setter
+ def warning_icon(self, value):
+ self._warning_icon = value
+
+ @forms.reactive
+ def warning_bg(self):
+ return self._warning_bg
+
+ @warning_bg.setter
+ def warning_bg(self, value):
+ self._warning_bg = value
+
+ @forms.reactive
+ def warning_fg(self):
+ return self._warning_fg
+
+ @warning_fg.setter
+ def warning_fg(self, value):
+ self._warning_fg = value
+
@forms.reactive
def can_modify_view(self):
return self._can_modify_view
@@ -694,6 +812,38 @@ def viewdepth_new_value(self):
def viewdepth_new_value(self, value):
self._viewdepth_new_value = value
+ # Per-field error background properties (bound to TextBox/ComboBox Background)
+ @forms.reactive
+ def topplane_field_bg(self):
+ return self._topplane_field_bg
+
+ @topplane_field_bg.setter
+ def topplane_field_bg(self, value):
+ self._topplane_field_bg = value
+
+ @forms.reactive
+ def cutplane_field_bg(self):
+ return self._cutplane_field_bg
+
+ @cutplane_field_bg.setter
+ def cutplane_field_bg(self, value):
+ self._cutplane_field_bg = value
+
+ @forms.reactive
+ def bottomplane_field_bg(self):
+ return self._bottomplane_field_bg
+
+ @bottomplane_field_bg.setter
+ def bottomplane_field_bg(self, value):
+ self._bottomplane_field_bg = value
+
+ @forms.reactive
+ def viewdepth_field_bg(self):
+ return self._viewdepth_field_bg
+
+ @viewdepth_field_bg.setter
+ def viewdepth_field_bg(self, value):
+ self._viewdepth_field_bg = value
class MainWindow(forms.WPFWindow):
def __init__(self):
@@ -705,13 +855,11 @@ def __init__(self):
def window_closed(self, sender, args):
script.save_window_position(self)
- server.remove_server()
- events.execute_in_revit_context(refresh_active_view)
- # Stop all registered events using the events API
- events.stop_events()
+ events.execute_in_revit_context(_on_close_cleanup)
def apply_changes_click(self, sender, e):
try:
+ self.DataContext.clear_field_errors()
new_values = {
plane: getattr(self.DataContext, prefix + "_new_value")
for plane, (_, _, prefix) in PLANES.items()
@@ -755,8 +903,8 @@ def apply_changes_click(self, sender, e):
context.update_view_range(new_values, new_levels)
except Exception as ex:
- self.DataContext.warning_message = "Error applying changes: {}".format(
- str(ex)
+ self.DataContext.show_error("Error applying changes: {}".format(
+ str(ex))
)
def reset_values_click(self, sender, e):
@@ -777,8 +925,8 @@ def reset_values_click(self, sender, e):
original_level_id
and original_level_id != DB.ElementId.InvalidElementId
):
- self.DataContext.topplane_level_id = (
- get_elementid_value(original_level_id)
+ self.DataContext.topplane_level_id = get_elementid_value(
+ original_level_id
)
else:
self.DataContext.topplane_level_id = INVALID_ID_VALUE
@@ -791,7 +939,10 @@ def reset_values_click(self, sender, e):
):
try:
# Use source_view.Document instead of active_view.Document
- if context.source_view and context.source_view.IsValidObject:
+ if (
+ context.source_view
+ and context.source_view.IsValidObject
+ ):
level = context.source_view.Document.GetElement(
original_level_id
)
@@ -810,8 +961,8 @@ def reset_values_click(self, sender, e):
original_level_id
and original_level_id != DB.ElementId.InvalidElementId
):
- self.DataContext.bottomplane_level_id = (
- get_elementid_value(original_level_id)
+ self.DataContext.bottomplane_level_id = get_elementid_value(
+ original_level_id
)
else:
self.DataContext.bottomplane_level_id = INVALID_ID_VALUE
@@ -821,8 +972,8 @@ def reset_values_click(self, sender, e):
original_level_id
and original_level_id != DB.ElementId.InvalidElementId
):
- self.DataContext.viewdepth_level_id = (
- get_elementid_value(original_level_id)
+ self.DataContext.viewdepth_level_id = get_elementid_value(
+ original_level_id
)
else:
self.DataContext.viewdepth_level_id = INVALID_ID_VALUE
@@ -832,71 +983,13 @@ def reset_values_click(self, sender, e):
view_range = context.source_view.GetViewRange()
context._set_current_level_selections(view_range)
- self.DataContext.warning_message = ""
+ self.DataContext.clear_warning()
except Exception as ex:
- self.DataContext.warning_message = "Error resetting values: {}".format(
- str(ex)
+ self.DataContext.show_error("Error resetting values: {}".format(
+ str(ex))
)
-
-# Event handlers are now registered via @events.handle decorators below
-# Old manual subscribe/unsubscribe functions removed in favor of events API
-
-
-def refresh_active_view():
- try:
- uidoc = revit.uidoc
- if not compare_views(uidoc.ActiveView, context.active_view):
- uidoc.ActiveView = context.active_view
- uidoc.RefreshActiveView()
- if context.source_view:
- uidoc.Selection.SetElementIds(List[DB.ElementId]([context.source_view.Id]))
- except Exception as ex:
- logger.exception(ex)
-
-
-@events.handle("view-activated")
-def view_activated(sender, args):
- try:
- context.active_view = args.CurrentActiveView
- except Exception as ex:
- logger.exception(ex)
-
-
-@events.handle("selection-changed")
-def selection_changed(sender, args):
- if not args.GetDocument().ActiveView.ViewType == DB.ViewType.ProjectBrowser:
- return
-
- try:
- doc = args.GetDocument()
- sel_ids = list(args.GetSelectedElements())
- if len(sel_ids) == 1:
- sel = doc.GetElement(sel_ids[0])
- if can_use_view_as_source(sel):
- context.source_view = sel
- return
- context.source_view = None
- except Exception as ex:
- logger.exception(ex)
-
-
-@events.handle("doc-changed")
-def doc_changed(sender, args):
- try:
- affected_ids = list(args.GetModifiedElementIds()) + list(
- args.GetDeletedElementIds()
- )
- if any(
- view.Id in affected_ids
- for view in [context.source_view, context.active_view]
- ):
- context.context_changed()
- except AttributeError:
- context.context_changed()
- except Exception as ex:
- logger.exception(ex)
-
+# ── Helper functions ────────────────────────────────────────────────
def compare_views(view1, view2):
if not view1 and not view2:
@@ -908,11 +1001,9 @@ def compare_views(view1, view2):
and view1.Id == view2.Id
)
-
def can_use_view_as_source(view):
return isinstance(view, (DB.ViewPlan, DB.ViewSection))
-
def corners_from_bb(bbox):
transform = bbox.Transform
corners = [
@@ -925,14 +1016,12 @@ def corners_from_bb(bbox):
]
return [transform.OfPoint(c) for c in corners]
-
def create_edges(vertices, color):
return [
revit.dc3dserver.Edge(vertices[i - 1], vertices[i], color)
for i in range(len(vertices))
]
-
def create_triangles(vertices, color):
return [
revit.dc3dserver.Triangle(
@@ -955,11 +1044,96 @@ def create_triangles(vertices, color):
),
]
-
def get_color_from_plane(plane):
rgb = PLANES[plane][0]
return DB.ColorWithTransparency(rgb[0], rgb[1], rgb[2], 180)
+def refresh_active_view():
+ try:
+ uidoc = revit.uidoc
+ if not compare_views(uidoc.ActiveView, context.active_view):
+ uidoc.ActiveView = context.active_view
+ uidoc.RefreshActiveView()
+ if context.source_view:
+ uidoc.Selection.SetElementIds(
+ List[DB.ElementId]([context.source_view.Id])
+ )
+ except Exception as ex:
+ logger.exception(ex)
+
+def _on_close_cleanup():
+ """Deferred cleanup in valid Revit API context.
+
+ Order matters:
+ 1. Unregister event handlers (using stored exec_id)
+ 2. Remove DC3D server (stops Draw Thread calls)
+ 3. Refresh view (clears stale rendering)
+ 4. Clear window envvar (allows reopening)
+ """
+ try:
+ stored_exec_id = script.get_envvar(VIEWRANGE_EXECID_KEY)
+ if stored_exec_id:
+ events.unregister_exec_handlers(stored_exec_id)
+ script.set_envvar(VIEWRANGE_EXECID_KEY, None)
+ except Exception as ex:
+ logger.error("Error stopping events: {}".format(ex))
+ try:
+ server.remove_server()
+ except Exception as ex:
+ logger.error("Error removing DC3D server: {}".format(ex))
+ try:
+ uidoc = revit.uidoc
+ uidoc.RefreshActiveView()
+ except Exception as ex:
+ logger.error("Error refreshing view: {}".format(ex))
+ # Clear window key AFTER all cleanup is complete, so the guard
+ # blocks re-entry until the server is fully removed.
+ script.set_envvar(VIEWRANGE_WINDOW_KEY, None)
+
+# ── Event handlers & initialization ─────────────────────────────────
+# This code is ONLY reached on the first click. Subsequent clicks
+# hit script.exit() at the top of the file before even importing
+# pyrevit.revit.events, so no .NET ExternalEvent objects are created
+# and no event handlers are re-registered.
+
+@events.handle("view-activated")
+def view_activated(sender, args):
+ try:
+ context.active_view = args.CurrentActiveView
+ except Exception as ex:
+ logger.exception(ex)
+
+@events.handle("selection-changed")
+def selection_changed(sender, args):
+ if not args.GetDocument().ActiveView.ViewType == DB.ViewType.ProjectBrowser:
+ return
+ try:
+ doc = args.GetDocument()
+ sel_ids = list(args.GetSelectedElements())
+ if len(sel_ids) == 1:
+ sel = doc.GetElement(sel_ids[0])
+ if can_use_view_as_source(sel):
+ context.source_view = sel
+ return
+ context.source_view = None
+ except Exception as ex:
+ logger.exception(ex)
+
+@events.handle("doc-changed")
+def doc_changed(sender, args):
+ try:
+ affected_ids = list(args.GetModifiedElementIds()) + list(
+ args.GetDeletedElementIds()
+ )
+ if any(
+ view.Id in affected_ids
+ for view in [context.source_view, context.active_view]
+ ):
+ context.context_changed()
+ except AttributeError:
+ context.context_changed()
+ except Exception as ex:
+ logger.exception(ex)
# Initialize
server = revit.dc3dserver.Server(register=False)
@@ -969,4 +1143,6 @@ def get_color_from_plane(plane):
main_window = MainWindow()
main_window.DataContext = vm
+script.set_envvar(VIEWRANGE_WINDOW_KEY, main_window)
+script.set_envvar(VIEWRANGE_EXECID_KEY, EXEC_PARAMS.exec_id)
main_window.show()
diff --git a/extensions/pyRevitTools.extension/startup.py b/extensions/pyRevitTools.extension/startup.py
new file mode 100644
index 000000000..ed49f60d3
--- /dev/null
+++ b/extensions/pyRevitTools.extension/startup.py
@@ -0,0 +1,9 @@
+from pyrevit import forms, script
+from match import panel
+
+logger = script.get_logger()
+
+if not forms.is_registered_dockable_panel(panel.MatchHistoryClipboard):
+ forms.register_dockable_panel(panel.MatchHistoryClipboard, default_visible=False)
+else:
+ logger.debug("Skipped registering dockable pane. Already exists.")
diff --git a/pyrevitlib/pyrevit/coreutils/__init__.py b/pyrevitlib/pyrevit/coreutils/__init__.py
index f94ef8763..63e6cd9cc 100644
--- a/pyrevitlib/pyrevit/coreutils/__init__.py
+++ b/pyrevitlib/pyrevit/coreutils/__init__.py
@@ -6,7 +6,7 @@
coreutils.cleanup_string('some string')
```
"""
-#pylint: disable=invalid-name
+# pylint: disable=invalid-name
import os
import os.path as op
import re
@@ -22,7 +22,7 @@
import socket
from collections import defaultdict
-#pylint: disable=E0401
+# pylint: disable=E0401
from pyrevit import HOST_APP, PyRevitException
from pyrevit.compat import PY3, PY2
from pyrevit.compat import safe_strtype
@@ -33,7 +33,7 @@
# import uuid
from System import Guid
-#pylint: disable=W0703,C0302
+# pylint: disable=W0703,C0302
DEFAULT_SEPARATOR = ';'
# extracted from
@@ -123,8 +123,10 @@ def extract_node_value(self, node):
elif isinstance(node_value, ast.List):
return node_value.elts
elif isinstance(node_value, ast.Dict):
- return {self.extract_node_value(k):self.extract_node_value(v)
- for k, v in zip(node_value.keys, node_value.values)}
+ return {
+ self.extract_node_value(k): self.extract_node_value(v)
+ for k, v in zip(node_value.keys, node_value.values)
+ }
def get_docstring(self):
"""Get global docstring."""
@@ -484,7 +486,7 @@ def calculate_dir_hash(dir_path, dir_filter, file_filter):
"1a885a0cae99f53d6088b9f7cee3bf4d"
"""
mtime_sum = 0
- for root, dirs, files in os.walk(dir_path): #pylint: disable=W0612
+ for root, dirs, files in os.walk(dir_path): #pylint: disable=W0612
if re.search(dir_filter, op.basename(root), flags=re.IGNORECASE):
mtime_sum += op.getmtime(root)
for filename in files:
@@ -777,7 +779,7 @@ def decrement_str(input_str, step=1, shrink=False):
Args:
input_str (str): identifier e.g. A310a
step (int): number of steps to change the identifier
- shrink (bool): removes leading zeroes or duplicate letters
+ shrink (bool): removes leading zeroes or duplicate letters
Returns:
(str): modified identifier
@@ -1099,6 +1101,80 @@ def random_rgba_color():
random_alpha())
+def _color_distance_sq(c1, c2):
+ """Return euclidean distance between two RGB colors."""
+ return (
+ (c1[0] - c2[0]) ** 2 +
+ (c1[1] - c2[1]) ** 2 +
+ (c1[2] - c2[2]) ** 2
+ )
+
+
+def distinct_rgb_colors(count, attempts=200):
+ """Generate visually distinct RGB colors.
+
+ Uses a greedy max-distance approach: each new color is chosen so its
+ minimum distance to existing colors is maximized.
+
+ Args:
+ count (int): number of colors to generate
+ attempts (int): random candidates evaluated per color
+
+ Returns:
+ list: list of (r, g, b) tuples
+ """
+ colors = []
+
+ for _ in range(count):
+ best_color = None
+ best_distance = -1
+
+ for _ in range(attempts):
+ candidate = (
+ random.randint(0, 255),
+ random.randint(0, 255),
+ random.randint(0, 255)
+ )
+
+ if not colors:
+ best_color = candidate
+ break
+
+ min_dist = min(_color_distance_sq(candidate, c) for c in colors)
+
+ if min_dist > best_distance:
+ best_distance = min_dist
+ best_color = candidate
+
+ colors.append(best_color)
+
+ return colors
+
+
+def distinct_hex_colors(count):
+ """Return visually distinct colors in hex format."""
+ colors = distinct_rgb_colors(count)
+ return ['#%02X%02X%02X' % c for c in colors]
+
+
+def distinct_rgb_strings(count):
+ """Return visually distinct colors in rgb(...) format."""
+ colors = distinct_rgb_colors(count)
+ return ['rgb(%d, %d, %d)' % c for c in colors]
+
+
+def distinct_rgba_strings(count, alpha=None):
+ """Return visually distinct colors in rgba(...) format. If alpha is None, it will get randomized."""
+ colors = distinct_rgb_colors(count)
+
+ result = []
+ for r, g, b in colors:
+ a = alpha if alpha is not None else random_alpha()
+ result.append('rgba(%d, %d, %d, %.2f)' % (r, g, b, a))
+
+ return result
+
+
def extract_range(formatted_str, max_range=500):
"""Extract range from formatted string.
@@ -1284,7 +1360,7 @@ def fuzzy_search_ratio(target_string, sfilter, regex=False):
# 91 to 92 reserved (2 scores)
- ## 80 to 90 for parts matches
+ # 80 to 90 for parts matches
tstring_parts = tstring.split()
sfilter_parts = sfilter.split()
if all(x in tstring_parts for x in sfilter_parts):
@@ -1308,10 +1384,7 @@ def fuzzy_search_ratio(target_string, sfilter, regex=False):
# doesn't contain
if len(e) > 1:
exclude_string = e[1:]
- if any(
- [exclude_string in
- part for part in lower_tstring_parts]
- ):
+ if any([exclude_string in part for part in lower_tstring_parts]):
return 0
if all(x in lower_tstring_parts for x in lower_sfilter_parts):
return 87
diff --git a/pyrevitlib/pyrevit/coreutils/ribbon.py b/pyrevitlib/pyrevit/coreutils/ribbon.py
index 2232bf8d9..df7a23838 100644
--- a/pyrevitlib/pyrevit/coreutils/ribbon.py
+++ b/pyrevitlib/pyrevit/coreutils/ribbon.py
@@ -356,12 +356,10 @@ def get_flagged_children(self, state=True):
return flagged_cmps
def keys(self):
- # FIXME: what does this do?
- list(self._sub_pyrvt_components.keys())
+ return list(self._sub_pyrvt_components.keys())
def values(self):
- # FIXME: what does this do?
- list(self._sub_pyrvt_components.values())
+ return list(self._sub_pyrvt_components.values())
@staticmethod
def is_native():
@@ -861,10 +859,10 @@ def process_deferred(self):
try:
if self.tooltip_image:
self.set_tooltip_image(self.tooltip_image)
- except Exception as ttvideo_err:
+ except Exception as ttimage_err:
raise PyRevitUIError(
- "Error setting deffered tooltip image {} | {} ".format(
- self.tooltip_video, ttvideo_err
+ "Error setting deferred tooltip image {} | {} ".format(
+ self.tooltip_image, ttimage_err
)
)
@@ -873,7 +871,7 @@ def process_deferred(self):
self.set_tooltip_video(self.tooltip_video)
except Exception as ttvideo_err:
raise PyRevitUIError(
- "Error setting deffered tooltip video {} | {} ".format(
+ "Error setting deferred tooltip video {} | {} ".format(
self.tooltip_video, ttvideo_err
)
)
@@ -1608,9 +1606,11 @@ def __init__(self, rvt_ribbon_panel, parent_tab):
self._add_component(_PyRevitRibbonGroupItem(revit_ribbon_item))
elif isinstance(revit_ribbon_item, UI.PushButton):
self._add_component(_PyRevitRibbonButton(revit_ribbon_item))
+ elif isinstance(revit_ribbon_item, UI.ComboBox):
+ self._add_component(_PyRevitRibbonComboBox(revit_ribbon_item))
else:
- raise PyRevitUIError(
- "Can not determin ribbon item type: {}".format(revit_ribbon_item)
+ mlogger.debug(
+ "Unknown ribbon item type, skipping: %s", revit_ribbon_item
)
def get_adwindows_object(self):
diff --git a/pyrevitlib/pyrevit/extensions/extpackages.py b/pyrevitlib/pyrevit/extensions/extpackages.py
index 6daca9d8b..5cb5d57fb 100644
--- a/pyrevitlib/pyrevit/extensions/extpackages.py
+++ b/pyrevitlib/pyrevit/extensions/extpackages.py
@@ -15,7 +15,6 @@
from pyrevit import extensions as exts
-#pylint: disable=W0703,C0302,C0103
mlogger = get_logger(__name__)
@@ -378,8 +377,8 @@ def _install_extpkg(extpkg, install_dir, install_dependencies=True):
dep_pkg = get_ext_package_by_name(dep_pkg_name)
if dep_pkg:
_install_extpkg(dep_pkg,
- install_dir,
- install_dependencies=True)
+ install_dir,
+ install_dependencies=True)
def _remove_extpkg(extpkg, remove_dependencies=True):
@@ -503,7 +502,7 @@ def get_dependency_graph():
def install(extpkg, install_dir, install_dependencies=True):
"""Install the extension in the given parent directory.
- This method uses .installed_dir property of extension object
+ This method uses .installed_dir property of extension object
as installation directory name for this extension.
This method also handles installation of extension dependencies.
@@ -544,4 +543,4 @@ def remove(extpkg, remove_dependencies=True):
except PyRevitPluginRemoveException as remove_err:
mlogger.error('Error removing extension: %s | %s',
extpkg.name, remove_err)
- raise
\ No newline at end of file
+ raise
diff --git a/pyrevitlib/pyrevit/forms/ParameterItemStyle.xaml b/pyrevitlib/pyrevit/forms/ParameterItemStyle.xaml
index 5da263524..106f84147 100644
--- a/pyrevitlib/pyrevit/forms/ParameterItemStyle.xaml
+++ b/pyrevitlib/pyrevit/forms/ParameterItemStyle.xaml
@@ -1,32 +1,54 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -37,5 +59,8 @@
+
+
+
-
+
\ No newline at end of file
diff --git a/pyrevitlib/pyrevit/forms/_ipy.py b/pyrevitlib/pyrevit/forms/_ipy.py
index 7df2012be..5324da4b4 100644
--- a/pyrevitlib/pyrevit/forms/_ipy.py
+++ b/pyrevitlib/pyrevit/forms/_ipy.py
@@ -1,4 +1,5 @@
-"""Reusable WPF forms for pyRevit.
+# encoding: utf-8
+"""Reusable WPF forms for pyRevit.
Examples:
```python
@@ -68,19 +69,68 @@
XAML_FILES_DIR = op.dirname(__file__)
-ParamDef = namedtuple("ParamDef", ["name", "istype", "definition", "isreadonly", "isunit", "storagetype"])
+ParamDef = namedtuple(
+ "ParamDef",
+ [
+ "name",
+ "istype",
+ "definition",
+ "isreadonly",
+ "isunit",
+ "storagetype",
+ "displayvalue",
+ "hasvalue",
+ "grouptype",
+ "isshared",
+ "paramid",
+ ],
+)
"""Parameter definition tuple.
-
Attributes:
name (str): parameter name
istype (bool): true if type parameter, otherwise false
definition (Autodesk.Revit.DB.Definition): parameter definition object
isreadonly (bool): true if the parameter value can't be edited
isunit (bool): true if its ForgeTypeId is measurable
- storagetype (Autodesk.Revit.DB.Storagetype): String, Integer, Double or ElementId
+ storagetype (Autodesk.Revit.DB.StorageType): String, Integer, Double or ElementId
+ displayvalue (str): display string of the current parameter value, or None
+ hasvalue (bool): true if the parameter has a value to display
+ grouptype (str): name of parameter group
+ isshared (bool): is the parameter shared
+ paramid (str): stringified parameter ElementId (integer id)
"""
+def _make_param_def(param, istype):
+ """Build a ParamDef from a Revit parameter object."""
+ if param.HasValue:
+ display_value_str = param.AsValueString() or param.AsString()
+ else:
+ display_value_str = None
+
+ return ParamDef(
+ name=param.Definition.Name,
+ istype=istype,
+ definition=param.Definition,
+ isreadonly=param.IsReadOnly,
+ isunit=(
+ DB.UnitUtils.IsMeasurableSpec(param.Definition.GetDataType())
+ if HOST_APP.is_newer_than(2022, True)
+ else False
+ ),
+ storagetype=param.StorageType,
+ displayvalue=display_value_str,
+ hasvalue=display_value_str is not None,
+ grouptype=(
+ DB.LabelUtils.GetLabelForGroup(param.Definition.GetGroupTypeId())
+ if HOST_APP.is_newer_than(2022, True)
+ else DB.LabelUtils.GetLabelFor(param.Definition.ParameterGroup)
+ ),
+ isshared=param.IsShared,
+ paramid=str(param.Id),
+ )
+
+
# https://gui-at.blogspot.com/2009/11/inotifypropertychanged-in-ironpython.html
class reactive(property):
"""Decorator for WPF bound properties."""
@@ -145,217 +195,77 @@ def __exit__(self, exception, exception_value, traceback):
self._window.show_dialog()
-class WPFWindow(framework.Windows.Window):
- r"""WPF Window base class for all pyRevit forms.
+class _WPFMixin(object):
+ """Shared behaviour for WPFWindow and WPFPanel.
- Args:
- xaml_source (str): xaml source filepath or xaml content
- literal_string (bool): xaml_source contains xaml content, not filepath
- handle_esc (bool): handle Escape button and close the window
- set_owner (bool): set the owner of window to host app window
-
- Examples:
- ```python
- from pyrevit import forms
- layout = '' \
- ''
- w = forms.WPFWindow(layout, literal_string=True)
- w.show()
- ```
+ Not intended for direct use — inherit via WPFWindow or WPFPanel.
"""
- def __init__(
- self, xaml_source, literal_string=False, handle_esc=True, set_owner=True
- ):
- """Initialize WPF window and resources."""
- # load xaml
- self.load_xaml(
- xaml_source,
- literal_string=literal_string,
- handle_esc=handle_esc,
- set_owner=set_owner,
- )
+ # ------------------------------------------------------------------ resources
- def load_xaml(
- self, xaml_source, literal_string=False, handle_esc=True, set_owner=True
- ):
- """Load the window XAML file.
-
- Args:
- xaml_source (str): The XAML content or file path to load.
- literal_string (bool, optional): True if `xaml_source` is content,
- False if it is a path. Defaults to False.
- handle_esc (bool, optional): Whether the ESC key should be handled.
- Defaults to True.
- set_owner (bool, optional): Whether to se the window owner.
- Defaults to True.
- """
- # create new id for this window
- self.window_id = coreutils.new_uuid()
-
- if not literal_string:
- wpf.LoadComponent(self, self._determine_xaml(xaml_source))
- if getattr(self, '_pending_resource_merge', None):
- self.merge_resource_dict(self._pending_resource_merge)
- self._pending_resource_merge = None
- else:
- wpf.LoadComponent(self, framework.StringReader(xaml_source))
-
- # set properties
- self.thread_id = framework.get_current_thread_id()
- if set_owner:
- self.setup_owner()
- self.setup_icon()
- WPFWindow.setup_resources(self)
- if handle_esc:
- self.setup_default_handlers()
-
- def _determine_xaml(self, xaml_source):
- self._pending_resource_merge = None
- xaml_file = xaml_source
- if not op.exists(xaml_file):
- xaml_file = os.path.join(EXEC_PARAMS.command_path, xaml_source)
-
- english_xaml_file = xaml_file.replace(".xaml", ".en_us.xaml")
- localized_xaml_file = xaml_file.replace(
- ".xaml", ".{}.xaml".format(user_config.user_locale)
- )
-
- english_xaml_resfile = xaml_file.replace(
- ".xaml", ".ResourceDictionary.en_us.xaml"
- )
- localized_xaml_resfile = xaml_file.replace(
- ".xaml", ".ResourceDictionary.{}.xaml".format(user_config.user_locale)
- )
-
- # if localized version of xaml file is provided, use that
- if os.path.isfile(localized_xaml_file):
- return localized_xaml_file
-
- if os.path.isfile(english_xaml_file):
- return english_xaml_file
-
- # otherwise look for .ResourceDictionary files and merge after load
- # (must merge after LoadComponent so XAML does not replace Resources)
- if os.path.isfile(localized_xaml_resfile):
- self._pending_resource_merge = localized_xaml_resfile
- elif os.path.isfile(english_xaml_resfile):
- self._pending_resource_merge = english_xaml_resfile
- else:
- self._pending_resource_merge = None
-
- return xaml_file
-
- def merge_resource_dict(self, xaml_source):
- """Merge a ResourceDictionary xaml file with this window.
-
- Args:
- xaml_source (str): xaml file with the resource dictionary
- """
- lang_dictionary = ResourceDictionary()
- lang_dictionary.Source = Uri(xaml_source, UriKind.Absolute)
- self.Resources.MergedDictionaries.Add(lang_dictionary)
-
- def get_locale_string(self, string_name):
- """Get localized string.
+ @staticmethod
+ def setup_resources(wpf_ctrl):
+ """Set pyRevit colour resources on any WPF control.
Args:
- string_name (str): string name
-
- Returns:
- (str): localized string
+ wpf_ctrl: any WPF FrameworkElement with a Resources dict.
"""
- return self.FindResource(string_name)
-
- def setup_owner(self):
- """Set the window owner."""
- wih = Interop.WindowInteropHelper(self)
- wih.Owner = AdWindows.ComponentManager.ApplicationWindow
-
- @staticmethod
- def setup_resources(wpf_ctrl):
- """Sets the WPF resources."""
- # 2c3e50
wpf_ctrl.Resources["pyRevitDarkColor"] = Media.Color.FromArgb(
0xFF, 0x2C, 0x3E, 0x50
)
-
- # 23303d
wpf_ctrl.Resources["pyRevitDarkerDarkColor"] = Media.Color.FromArgb(
0xFF, 0x23, 0x30, 0x3D
)
-
- # ffffff
wpf_ctrl.Resources["pyRevitButtonColor"] = Media.Color.FromArgb(
0xFF, 0xFF, 0xFF, 0xFF
)
-
- # f39c12
wpf_ctrl.Resources["pyRevitAccentColor"] = Media.Color.FromArgb(
0xFF, 0xF3, 0x9C, 0x12
)
-
wpf_ctrl.Resources["pyRevitDarkBrush"] = Media.SolidColorBrush(
wpf_ctrl.Resources["pyRevitDarkColor"]
)
wpf_ctrl.Resources["pyRevitAccentBrush"] = Media.SolidColorBrush(
wpf_ctrl.Resources["pyRevitAccentColor"]
)
-
wpf_ctrl.Resources["pyRevitDarkerDarkBrush"] = Media.SolidColorBrush(
wpf_ctrl.Resources["pyRevitDarkerDarkColor"]
)
-
wpf_ctrl.Resources["pyRevitButtonForgroundBrush"] = Media.SolidColorBrush(
wpf_ctrl.Resources["pyRevitButtonColor"]
)
-
wpf_ctrl.Resources["pyRevitRecognizesAccessKey"] = DEFAULT_RECOGNIZE_ACCESS_KEY
- def setup_default_handlers(self):
- """Set the default handlers."""
- self.PreviewKeyDown += self.handle_input_key # pylint: disable=E1101
-
- def handle_input_key(self, sender, args): # pylint: disable=W0613
- """Handle keyboard input and close the window on Escape."""
- if args.Key == Input.Key.Escape:
- self.Close()
+ def merge_resource_dict(self, xaml_source):
+ """Merge a ResourceDictionary xaml file into this control's resources.
- def set_icon(self, icon_path):
- """Set window icon to given icon path."""
- self.Icon = utils.bitmap_from_file(icon_path)
+ Args:
+ xaml_source (str): absolute path to a ResourceDictionary xaml file.
+ """
+ lang_dictionary = ResourceDictionary()
+ lang_dictionary.Source = Uri(xaml_source, UriKind.Absolute)
+ self.Resources.MergedDictionaries.Add(lang_dictionary)
- def setup_icon(self):
- """Setup default window icon."""
- self.set_icon(op.join(BIN_DIR, "pyrevit_settings.png"))
+ def get_locale_string(self, string_name):
+ """Return a localised string from the merged ResourceDictionary.
- def hide(self):
- """Hide window."""
- self.Hide()
+ Args:
+ string_name (str): resource key.
- def show(self, modal=False):
- """Show window."""
- if modal:
- return self.ShowDialog()
- # else open non-modal
- self.Show()
+ Returns:
+ str: localised string value.
+ """
+ return self.FindResource(string_name)
- def show_dialog(self):
- """Show modal window."""
- return self.ShowDialog()
+ # ------------------------------------------------------------------ images
@staticmethod
def set_image_source_file(wpf_element, image_file):
- """Set source file for image element.
+ """Set the source of a WPF Image element from a file path.
Args:
- wpf_element (System.Windows.Controls.Image): xaml image element
- image_file (str): image file path
+ wpf_element (System.Windows.Controls.Image): target image element.
+ image_file (str): absolute or command-relative image path.
"""
if not op.exists(image_file):
wpf_element.Source = utils.bitmap_from_file(
@@ -365,200 +275,356 @@ def set_image_source_file(wpf_element, image_file):
wpf_element.Source = utils.bitmap_from_file(image_file)
def set_image_source(self, wpf_element, image_file):
- """Set source file for image element.
-
- Args:
- wpf_element (System.Windows.Controls.Image): xaml image element
- image_file (str): image file path
- """
- WPFWindow.set_image_source_file(wpf_element, image_file)
-
- def dispatch(self, func, *args, **kwargs):
- """Runs the function in a new thread.
+ """Set the source of a WPF Image element from a file path.
Args:
- func (Callable): function to run
- *args (Any): positional arguments to pass to func
- **kwargs (Any): keyword arguments to pass to func
+ wpf_element (System.Windows.Controls.Image): target image element.
+ image_file (str): absolute or command-relative image path.
"""
- if framework.get_current_thread_id() == self.thread_id:
- t = threading.Thread(target=func, args=args, kwargs=kwargs)
- t.start()
- else:
- # ask ui thread to call the func with args and kwargs
- self.Dispatcher.Invoke(
- System.Action(lambda: func(*args, **kwargs)),
- Threading.DispatcherPriority.Background,
- )
+ _WPFMixin.set_image_source_file(wpf_element, image_file)
- def conceal(self):
- """Conceal window."""
- return WindowToggler(self)
-
- @property
- def pyrevit_version(self):
- """Active pyRevit formatted version e.g. '4.9-beta'."""
- return "pyRevit {}".format(versionmgr.get_pyrevit_version().get_formatted())
+ # ------------------------------------------------------------------ visibility
@staticmethod
def hide_element(*wpf_elements):
- """Collapse elements.
+ """Collapse one or more WPF elements (removes from layout).
Args:
- *wpf_elements (list[UIElement]): WPF framework elements to be collaped
+ *wpf_elements (list[UIElement]): elements to collapse.
"""
for wpfel in wpf_elements:
wpfel.Visibility = WPF_COLLAPSED
@staticmethod
def show_element(*wpf_elements):
- """Show collapsed elements.
+ """Make one or more collapsed WPF elements visible.
Args:
- *wpf_elements (list[UIElement]): WPF framework elements to be set to visible.
+ *wpf_elements (list[UIElement]): elements to show.
"""
for wpfel in wpf_elements:
wpfel.Visibility = WPF_VISIBLE
@staticmethod
def toggle_element(*wpf_elements):
- """Toggle visibility of elements.
+ """Toggle visibility of one or more WPF elements.
Args:
- *wpf_elements (list[UIElement]): WPF framework elements to be toggled.
+ *wpf_elements (list[UIElement]): elements to toggle.
"""
for wpfel in wpf_elements:
if wpfel.Visibility == WPF_VISIBLE:
- WPFWindow.hide_element(wpfel)
+ _WPFMixin.hide_element(wpfel)
elif wpfel.Visibility == WPF_COLLAPSED:
- WPFWindow.show_element(wpfel)
+ _WPFMixin.show_element(wpfel)
+
+ # ------------------------------------------------------------------ enabled
@staticmethod
def disable_element(*wpf_elements):
- """Enable elements.
+ """Disable one or more WPF elements.
Args:
- *wpf_elements (list[UIElement]): WPF framework elements to be enabled
+ *wpf_elements (list[UIElement]): elements to disable.
"""
for wpfel in wpf_elements:
wpfel.IsEnabled = False
@staticmethod
def enable_element(*wpf_elements):
- """Enable elements.
+ """Enable one or more WPF elements.
Args:
- *wpf_elements (list[UIElement]): WPF framework elements to be enabled
+ *wpf_elements (list[UIElement]): elements to enable.
"""
for wpfel in wpf_elements:
wpfel.IsEnabled = True
+ # ------------------------------------------------------------------ threading
+
+ def dispatch(self, func, *args, **kwargs):
+ """Run a function on the opposite thread.
+
+ When called from the UI thread, spawns a background thread.
+ When called from a background thread, marshals execution back
+ onto the UI thread via the Dispatcher. This makes the pattern
+ for background work + UI updates symmetrical:
+
+ # on UI thread — starts background work
+ self.dispatch(self._do_work)
+
+ def _do_work(self):
+ ...heavy work...
+ # on background thread — posts UI update
+ self.dispatch(self._update_label, "done")
+
+ Args:
+ func (Callable): function to run.
+ *args: positional arguments forwarded to func.
+ **kwargs: keyword arguments forwarded to func.
+ """
+ if framework.get_current_thread_id() == self.thread_id:
+ t = threading.Thread(target=func, args=args, kwargs=kwargs)
+ t.start()
+ else:
+ self.Dispatcher.Invoke(
+ System.Action(lambda: func(*args, **kwargs)),
+ Threading.DispatcherPriority.Background,
+ )
+
+ # ------------------------------------------------------------------ misc
+
+ @property
+ def pyrevit_version(self):
+ """Active pyRevit formatted version string, e.g. '4.9-beta'."""
+ return "pyRevit {}".format(versionmgr.get_pyrevit_version().get_formatted())
+
def handle_url_click(self, sender, args): # pylint: disable=unused-argument
- """Callback for handling click on package website url."""
+ """XAML event handler: open a Hyperlink URL in the default browser.
+
+ Wire up in XAML with: RequestNavigate="handle_url_click"
+ """
return webbrowser.open_new_tab(sender.NavigateUri.AbsoluteUri)
-class WPFPanel(framework.Windows.Controls.Page):
- r"""WPF panel base class for all pyRevit dockable panels.
+def _resolve_xaml_source(xaml_source):
+ """Resolve a XAML path with full locale-fallback logic.
+
+ Resolution order:
+ 1. ..xaml full localised replacement
+ 2. .en_us.xaml English replacement
+ 3. .xaml + ResourceDictionary merge localised strings
+ 4. .xaml + ResourceDictionary merge English strings
+ 5. .xaml bare, no merge
- panel_id (str) must be set on the type to dockable panel uuid
- panel_source (str): xaml source filepath
+ Args:
+ xaml_source (str): xaml filename or absolute path.
+
+ Returns:
+ tuple(str, str|None): (resolved xaml path, resource-dict path or None)
+ """
+ xaml_file = xaml_source
+ if not op.exists(xaml_file):
+ xaml_file = os.path.join(EXEC_PARAMS.command_path, xaml_source)
+
+ localized_xaml = xaml_file.replace(
+ ".xaml", ".{}.xaml".format(user_config.user_locale)
+ )
+ english_xaml = xaml_file.replace(".xaml", ".en_us.xaml")
+ localized_res = xaml_file.replace(
+ ".xaml", ".ResourceDictionary.{}.xaml".format(user_config.user_locale)
+ )
+ english_res = xaml_file.replace(".xaml", ".ResourceDictionary.en_us.xaml")
+
+ if os.path.isfile(localized_xaml):
+ return localized_xaml, None
+ if os.path.isfile(english_xaml):
+ return english_xaml, None
+ if os.path.isfile(localized_res):
+ return xaml_file, localized_res
+ if os.path.isfile(english_res):
+ return xaml_file, english_res
+ return xaml_file, None
+
+
+class WPFWindow(_WPFMixin, framework.Windows.Window):
+ r"""WPF Window base class for all pyRevit forms.
+
+ Args:
+ xaml_source (str): xaml source filepath or xaml content
+ literal_string (bool): xaml_source contains xaml content, not filepath
+ handle_esc (bool): handle Escape button and close the window
+ set_owner (bool): set the owner of window to host app window
Examples:
```python
from pyrevit import forms
- class MyPanel(forms.WPFPanel):
- panel_id = "181e05a4-28f6-4311-8a9f-d2aa528c8755"
- panel_source = "MyPanel.xaml"
-
- forms.register_dockable_panel(MyPanel)
- # then from the button that needs to open the panel
- forms.open_dockable_panel("181e05a4-28f6-4311-8a9f-d2aa528c8755")
+ layout = '' \
+ ''
+ w = forms.WPFWindow(layout, literal_string=True)
+ w.show()
```
"""
- panel_id = None
- panel_source = None
+ def __init__(
+ self, xaml_source, literal_string=False, handle_esc=True, set_owner=True
+ ):
+ """Initialize WPF window and resources."""
+ # load xaml
+ self.load_xaml(
+ xaml_source,
+ literal_string=literal_string,
+ handle_esc=handle_esc,
+ set_owner=set_owner,
+ )
- def __init__(self):
- """Initialize WPF panel and resources."""
- if not self.panel_id:
- raise PyRevitException('"panel_id" property is not set')
- if not self.panel_source:
- raise PyRevitException('"panel_source" property is not set')
+ def load_xaml(
+ self, xaml_source, literal_string=False, handle_esc=True, set_owner=True
+ ):
+ """Load the window XAML file.
- if not op.exists(self.panel_source):
- wpf.LoadComponent(
- self, os.path.join(EXEC_PARAMS.command_path, self.panel_source)
- )
+ Args:
+ xaml_source (str): The XAML content or file path to load.
+ literal_string (bool, optional): True if `xaml_source` is content,
+ False if it is a path. Defaults to False.
+ handle_esc (bool, optional): Whether the ESC key should be handled.
+ Defaults to True.
+ set_owner (bool, optional): Whether to set the window owner.
+ Defaults to True.
+ """
+ # create new id for this window
+ self.window_id = coreutils.new_uuid()
+
+ _WPFMixin.setup_resources(self)
+ if not literal_string:
+ xaml_path, pending_resource_merge = _resolve_xaml_source(xaml_source)
+ # merge before LoadComponent so resources are available during parse
+ if pending_resource_merge:
+ self.merge_resource_dict(pending_resource_merge)
+ wpf.LoadComponent(self, xaml_path)
else:
- wpf.LoadComponent(self, self.panel_source)
+ wpf.LoadComponent(self, framework.StringReader(xaml_source))
# set properties
self.thread_id = framework.get_current_thread_id()
- WPFWindow.setup_resources(self)
+ if set_owner:
+ self.setup_owner()
+ self.setup_icon()
+ if handle_esc:
+ self.setup_default_handlers()
- def set_image_source(self, wpf_element, image_file):
- """Set source file for image element.
+ def setup_owner(self):
+ """Set the window owner."""
+ wih = Interop.WindowInteropHelper(self)
+ wih.Owner = AdWindows.ComponentManager.ApplicationWindow
- Args:
- wpf_element (System.Windows.Controls.Image): xaml image element
- image_file (str): image file path
- """
- WPFWindow.set_image_source_file(wpf_element, image_file)
+ def setup_default_handlers(self):
+ """Set the default handlers."""
+ self.PreviewKeyDown += self.handle_input_key # pylint: disable=E1101
- @staticmethod
- def hide_element(*wpf_elements):
- """Collapse elements.
+ def handle_input_key(self, sender, args): # pylint: disable=W0613
+ """Handle keyboard input and close the window on Escape."""
+ if args.Key == Input.Key.Escape:
+ self.Close()
- Args:
- *wpf_elements (list[UIElement]): WPF framework elements to be collaped
- """
- WPFPanel.hide_element(*wpf_elements)
+ def set_icon(self, icon_path):
+ """Set window icon to given icon path."""
+ self.Icon = utils.bitmap_from_file(icon_path)
- @staticmethod
- def show_element(*wpf_elements):
- """Show collapsed elements.
+ def setup_icon(self):
+ """Setup default window icon."""
+ self.set_icon(op.join(BIN_DIR, "pyrevit_settings.png"))
- Args:
- *wpf_elements (list[UIElement]): WPF framework elements to be set to visible.
- """
- WPFPanel.show_element(*wpf_elements)
+ def hide(self):
+ """Hide window."""
+ self.Hide()
- @staticmethod
- def toggle_element(*wpf_elements):
- """Toggle visibility of elements.
+ def show(self, modal=False):
+ """Show window."""
+ if modal:
+ return self.ShowDialog()
+ # else open non-modal
+ self.Show()
- Args:
- *wpf_elements (list[UIElement]): WPF framework elements to be toggled.
- """
- WPFPanel.toggle_element(*wpf_elements)
+ def show_dialog(self):
+ """Show modal window."""
+ return self.ShowDialog()
- @staticmethod
- def disable_element(*wpf_elements):
- """Enable elements.
+ def conceal(self):
+ """Conceal window."""
+ return WindowToggler(self)
- Args:
- *wpf_elements (list[UIElement]): WPF framework elements to be enabled
- """
- WPFPanel.disable_element(*wpf_elements)
- @staticmethod
- def enable_element(*wpf_elements):
- """Enable elements.
+class WPFPanel(_WPFMixin, framework.Windows.Controls.Page):
+ r"""WPF panel base class for all pyRevit dockable panels.
+
+ Subclass this, set the three required class attributes, then register
+ and open the panel through the module-level helpers.
+
+ Required class attributes:
+ panel_id (str): stable UUID string identifying the panel in Revit.
+ panel_source (str): XAML filename (resolved relative to the command).
+ panel_title (str): title displayed in the panel chrome.
+
+ Optional class attributes:
+ initial_state (UI.DockablePaneState):
+ initial docking position; Revit picks a default when None.
+ editor_interaction (UI.EditorInteractionType):
+ how the panel behaves while an editor is active.
+ contextual_help (UI.ContextualHelp):
+ F1 help associated with the pane.
+
+ Examples:
+ ```python
+ from pyrevit import forms
+ import Autodesk.Revit.UI as UI
+
+ state = UI.DockablePaneState()
+ state.DockPosition = UI.DockPosition.Right
+
+ class MyPanel(forms.WPFPanel):
+ panel_id = "181e05a4-28f6-4311-8a9f-d2aa528c8755"
+ panel_source = "MyPanel.xaml"
+ panel_title = "My Panel"
+ initial_state = state
+
+ forms.register_dockable_panel(MyPanel)
+
+ # open from any command button
+ forms.open_dockable_panel(MyPanel)
+
+ # retrieve the live instance from anywhere
+ dockable_pane = forms.get_dockable_panel(MyPanel)
+ ```
+ """
+
+ panel_id = None
+ panel_source = None
+ panel_title = None
+ initial_state = None
+ editor_interaction = None
+ contextual_help = None
+
+ def __init__(self):
+ """Initialize WPF panel and resources."""
+ if not self.panel_id:
+ raise PyRevitException('"panel_id" class attribute is not set')
+ if not self.panel_source:
+ raise PyRevitException('"panel_source" class attribute is not set')
+ if not self.panel_title:
+ raise PyRevitException('"panel_title" class attribute is not set')
+
+ self.load_xaml(self.panel_source)
+
+ def load_xaml(self, xaml_source, literal_string=False):
+ """Load the panel XAML file.
+
+ Supports the full locale-fallback chain (see _resolve_xaml_source) and
+ can accept raw XAML content via literal_string, matching WPFWindow.
Args:
- *wpf_elements (list): WPF framework elements to be enabled
+ xaml_source (str): XAML content string or file path.
+ literal_string (bool): True when xaml_source is raw content.
"""
- WPFPanel.enable_element(*wpf_elements)
-
- def handle_url_click(self, sender, args): # pylint: disable=unused-argument
- """Callback for handling click on package website url."""
- return webbrowser.open_new_tab(sender.NavigateUri.AbsoluteUri)
+ _WPFMixin.setup_resources(self)
+ if not literal_string:
+ xaml_path, pending_resource_merge = _resolve_xaml_source(xaml_source)
+ # merge before LoadComponent so resources are available during parse
+ if pending_resource_merge:
+ self.merge_resource_dict(pending_resource_merge)
+ wpf.LoadComponent(self, xaml_path)
+ else:
+ wpf.LoadComponent(self, framework.StringReader(xaml_source))
+ self.thread_id = framework.get_current_thread_id()
class _WPFPanelProvider(UI.IDockablePaneProvider):
- """Internal Panel provider for panels."""
+ """Internal panel provider for dockable panels."""
def __init__(self, panel_type, default_visible=True):
self._panel_type = panel_type
@@ -566,12 +632,19 @@ def __init__(self, panel_type, default_visible=True):
self.panel = self._panel_type()
def SetupDockablePane(self, data):
- """Setup forms.WPFPanel set on this instance."""
- # TODO: need to implement panel data
- # https://apidocs.co/apps/revit/2021.1/98157ec2-ab26-6ab7-2933-d1b4160ba2b8.htm
+ """Configure DockablePaneProviderData from the panel type's class attributes."""
data.FrameworkElement = self.panel
data.VisibleByDefault = self._default_visible
+ if self._panel_type.initial_state is not None:
+ data.InitialState = self._panel_type.initial_state
+
+ if self._panel_type.editor_interaction is not None:
+ data.EditorInteraction = self._panel_type.editor_interaction
+
+ if self._panel_type.contextual_help is not None:
+ data.ContextualHelp = self._panel_type.contextual_help
+
def is_registered_dockable_panel(panel_type):
"""Check if dockable panel is already registered.
@@ -581,21 +654,33 @@ def is_registered_dockable_panel(panel_type):
"""
panel_uuid = coreutils.Guid.Parse(panel_type.panel_id)
dockable_panel_id = UI.DockablePaneId(panel_uuid)
- return UI.DockablePane.PaneExists(dockable_panel_id)
+ return UI.DockablePane.PaneIsRegistered(dockable_panel_id)
def register_dockable_panel(panel_type, default_visible=True):
- """Register dockable panel.
+ """Register a dockable panel with Revit and return its instance.
+
+ This function creates a provider for the given panel type, registers the
+ corresponding dockable pane with Revit, and returns the live WPF panel
+ instance managed by that provider.
Args:
panel_type (forms.WPFPanel): dockable panel type
default_visible (bool, optional):
- whether panel should be visible by default
+ Whether the panel should be visible by default when registered.
+
+ Returns:
+ forms.WPFPanel: The live panel instance created during registration.
"""
- if not issubclass(panel_type, WPFPanel):
+ if not isinstance(panel_type, type) or not issubclass(panel_type, WPFPanel):
raise PyRevitException("Dockable pane must be a subclass of forms.WPFPanel")
- panel_uuid = coreutils.Guid.Parse(panel_type.panel_id)
+ try:
+ panel_uuid = coreutils.Guid.Parse(panel_type.panel_id)
+ except System.FormatException:
+ raise PyRevitException(
+ 'Invalid dockable panel id "{}"'.format(panel_type.panel_id)
+ )
dockable_panel_id = UI.DockablePaneId(panel_uuid)
panel_provider = _WPFPanelProvider(panel_type, default_visible)
HOST_APP.uiapp.RegisterDockablePane(
@@ -605,11 +690,55 @@ def register_dockable_panel(panel_type, default_visible=True):
return panel_provider.panel
+def get_dockable_panel(panel_type_or_id):
+ """Retrieve the Revit dockable pane handle for a registered panel.
+
+ Args:
+ panel_type_or_id (forms.WPFPanel | str):
+ dockable panel type (subclass of forms.WPFPanel) or panel id string
+ corresponding to the Revit dockable pane id.
+
+ Returns:
+ UI.DockablePane: the live panel pane handle.
+
+ Raises:
+ PyRevitException: if the panel id is not registered with Revit.
+ """
+ dpanel_id = None
+ if isinstance(panel_type_or_id, str):
+ try:
+ panel_id = coreutils.Guid.Parse(panel_type_or_id)
+ except System.FormatException:
+ raise PyRevitException(
+ 'Invalid dockable panel id "{}"'.format(panel_type_or_id)
+ )
+ dpanel_id = UI.DockablePaneId(panel_id)
+ elif isinstance(panel_type_or_id, type) and issubclass(panel_type_or_id, WPFPanel):
+ try:
+ panel_id = coreutils.Guid.Parse(panel_type_or_id.panel_id)
+ except System.FormatException:
+ raise PyRevitException(
+ 'Invalid dockable panel id "{}"'.format(panel_type_or_id.panel_id)
+ )
+ dpanel_id = UI.DockablePaneId(panel_id)
+ else:
+ raise PyRevitException("Given type is not a forms.WPFPanel")
+
+ if dpanel_id:
+ if UI.DockablePane.PaneIsRegistered(dpanel_id):
+ dockable_panel = HOST_APP.uiapp.GetDockablePane(dpanel_id)
+ return dockable_panel
+ else:
+ raise PyRevitException(
+ 'Panel with id "%s" is not registered' % panel_type_or_id
+ )
+
+
def open_dockable_panel(panel_type_or_id):
"""Open previously registered dockable panel.
Args:
- panel_type_or_id (forms.WPFPanel, str): panel type or id
+ panel_type_or_id (forms.WPFPanel | str): panel type or id
"""
toggle_dockable_panel(panel_type_or_id, True)
@@ -618,7 +747,7 @@ def close_dockable_panel(panel_type_or_id):
"""Close previously registered dockable panel.
Args:
- panel_type_or_id (forms.WPFPanel, str): panel type or id
+ panel_type_or_id (forms.WPFPanel | str): panel type or id
"""
toggle_dockable_panel(panel_type_or_id, False)
@@ -630,27 +759,11 @@ def toggle_dockable_panel(panel_type_or_id, state):
panel_type_or_id (forms.WPFPanel | str): panel type or id
state (bool): True to show the panel, False to hide it.
"""
- dpanel_id = None
- if isinstance(panel_type_or_id, str):
- panel_id = coreutils.Guid.Parse(panel_type_or_id)
- dpanel_id = UI.DockablePaneId(panel_id)
- elif issubclass(panel_type_or_id, WPFPanel):
- panel_id = coreutils.Guid.Parse(panel_type_or_id.panel_id)
- dpanel_id = UI.DockablePaneId(panel_id)
+ dockable_panel = get_dockable_panel(panel_type_or_id)
+ if state:
+ dockable_panel.Show()
else:
- raise PyRevitException("Given type is not a forms.WPFPanel")
-
- if dpanel_id:
- if UI.DockablePane.PaneIsRegistered(dpanel_id):
- dockable_panel = HOST_APP.uiapp.GetDockablePane(dpanel_id)
- if state:
- dockable_panel.Show()
- else:
- dockable_panel.Hide()
- else:
- raise PyRevitException(
- 'Panel with id "%s" is not registered' % panel_type_or_id
- )
+ dockable_panel.Hide()
class TemplateUserInputWindow(WPFWindow):
@@ -679,7 +792,9 @@ def __init__(self, context, title, width, height, **kwargs):
localized_title = self.get_locale_string(self.default_title_key)
except System.Windows.ResourceReferenceKeyNotFoundException:
localized_title = None
- self.Title = localized_title if isinstance(localized_title, str) else "User Input"
+ self.Title = (
+ localized_title if isinstance(localized_title, str) else "User Input"
+ )
self.Width = width
self.Height = height
@@ -907,7 +1022,9 @@ def _setup(self, **kwargs):
else:
# Use localized default, falling back to generic text if resource is missing
try:
- self.select_b.Content = self.get_locale_string("SelectFromList.Select.Button")
+ self.select_b.Content = self.get_locale_string(
+ "SelectFromList.Select.Button"
+ )
except System.Windows.ResourceReferenceKeyNotFoundException:
self.select_b.Content = "Select"
@@ -1070,15 +1187,21 @@ def _get_all_ctx(self):
def _list_options(self, option_filter=None):
if option_filter:
try:
- self.checkall_b.Content = self.get_locale_string("SelectFromList.Check.Button")
+ self.checkall_b.Content = self.get_locale_string(
+ "SelectFromList.Check.Button"
+ )
except System.Windows.ResourceReferenceKeyNotFoundException:
self.checkall_b.Content = "Check"
try:
- self.uncheckall_b.Content = self.get_locale_string("SelectFromList.Uncheck.Button")
+ self.uncheckall_b.Content = self.get_locale_string(
+ "SelectFromList.Uncheck.Button"
+ )
except System.Windows.ResourceReferenceKeyNotFoundException:
self.uncheckall_b.Content = "Uncheck"
try:
- self.toggleall_b.Content = self.get_locale_string("SelectFromList.Toggle.Button")
+ self.toggleall_b.Content = self.get_locale_string(
+ "SelectFromList.Toggle.Button"
+ )
except System.Windows.ResourceReferenceKeyNotFoundException:
self.toggleall_b.Content = "Toggle"
# get a match score for every item and sort high to low
@@ -1103,15 +1226,21 @@ def _list_options(self, option_filter=None):
)
else:
try:
- self.checkall_b.Content = self.get_locale_string("SelectFromList.CheckAll.Button")
+ self.checkall_b.Content = self.get_locale_string(
+ "SelectFromList.CheckAll.Button"
+ )
except System.Windows.ResourceReferenceKeyNotFoundException:
self.checkall_b.Content = "Check All"
try:
- self.uncheckall_b.Content = self.get_locale_string("SelectFromList.UncheckAll.Button")
+ self.uncheckall_b.Content = self.get_locale_string(
+ "SelectFromList.UncheckAll.Button"
+ )
except System.Windows.ResourceReferenceKeyNotFoundException:
self.uncheckall_b.Content = "Uncheck All"
try:
- self.toggleall_b.Content = self.get_locale_string("SelectFromList.ToggleAll.Button")
+ self.toggleall_b.Content = self.get_locale_string(
+ "SelectFromList.ToggleAll.Button"
+ )
except System.Windows.ResourceReferenceKeyNotFoundException:
self.toggleall_b.Content = "Toggle All"
@@ -1487,9 +1616,7 @@ def _setup(self, **kwargs):
self.datePrompt.Text = value_prompt if value_prompt else "Pick date:"
if isinstance(value_default, datetime.datetime):
self.datePicker.SelectedDate = System.DateTime(
- value_default.year,
- value_default.month,
- value_default.day
+ value_default.year, value_default.month, value_default.day
)
elif self.value_type == "slider":
self.show_element(self.sliderPanel_sp)
@@ -1533,9 +1660,7 @@ def select(self, sender, args): # pylint: disable=W0613
if self.datePicker.SelectedDate:
selected = self.datePicker.SelectedDate
self.response = datetime.datetime(
- selected.Year,
- selected.Month,
- selected.Day
+ selected.Year, selected.Month, selected.Day
)
else:
self.response = None
@@ -2319,7 +2444,7 @@ def select_sheets(
sheetset_ops = sorted(
[SheetOption(x) for x in sheetset_sheets], key=lambda x: x.number
)
- if sheetset.Name == 'All Sheets':
+ if sheetset.Name == "All Sheets":
all_ops["[" + sheetset.Name + "]"] = sheetset_ops
else:
all_ops[sheetset.Name] = sheetset_ops
@@ -2815,18 +2940,7 @@ def select_parameters(
# collect instance parameters
param_defs.extend(
[
- ParamDef(
- name=x.Definition.Name,
- istype=False,
- definition=x.Definition,
- isreadonly=x.IsReadOnly,
- isunit=(
- DB.UnitUtils.IsMeasurableSpec(x.Definition.GetDataType())
- if HOST_APP.is_newer_than(2022, True)
- else False
- ),
- storagetype=x.StorageType,
- )
+ _make_param_def(x, istype=False)
for x in src_element.Parameters
if x.StorageType != non_storage_type
]
@@ -2838,18 +2952,7 @@ def select_parameters(
if src_type is not None:
param_defs.extend(
[
- ParamDef(
- name=x.Definition.Name,
- istype=True,
- definition=x.Definition,
- isreadonly=x.IsReadOnly,
- isunit=(
- DB.UnitUtils.IsMeasurableSpec(x.Definition.GetDataType())
- if HOST_APP.is_newer_than(2022, True)
- else False
- ),
- storagetype=x.StorageType,
- )
+ _make_param_def(x, istype=True)
for x in src_type.Parameters
if x.StorageType != non_storage_type
]
@@ -2870,7 +2973,7 @@ def select_parameters(
param_defs,
title=title,
button_name=button_name,
- width=450,
+ width=500,
multiselect=multiple,
item_template=itemplate,
)
@@ -3831,4 +3934,4 @@ def inform_wip():
forms.inform_wip()
```
"""
- alert("Work in progress.", exitscript=True)
\ No newline at end of file
+ alert("Work in progress.", exitscript=True)
diff --git a/pyrevitlib/pyrevit/forms/settings_window.py b/pyrevitlib/pyrevit/forms/settings_window.py
index 93cc71383..1b2647256 100644
--- a/pyrevitlib/pyrevit/forms/settings_window.py
+++ b/pyrevitlib/pyrevit/forms/settings_window.py
@@ -7,14 +7,19 @@
from pyrevit.forms import settings_window
settings = [
+ {"type": "section", "label": "Display"},
{"name": "scope", "type": "choice", "label": "Scope",
"options": ["Visibility", "Active State"], "default": "Visibility"},
+ {"name": "highlight_color", "type": "color", "label": "Highlight Color",
+ "default": "#ffff0000"},
+
+ {"type": "section", "label": "Processing"},
{"name": "set_workset", "type": "bool", "label": "Set Workset", "default": True},
- {"name": "tolerance", "type": "int", "label": "Tolerance (mm)",
+ {"name": "tolerance", "type": "slider", "label": "Tolerance (mm)",
"default": 10, "min": 0, "max": 1000},
{"name": "prefix", "type": "string", "label": "Prefix", "default": ""},
- {"name": "highlight_color", "type": "color", "label": "Highlight Color",
- "default": "#ffff0000"},
+
+ {"type": "section", "label": "Paths"},
{"name": "export_folder", "type": "folder", "label": "Export Folder", "default": ""},
{"name": "template_file", "type": "file", "label": "Template File",
"default": "", "file_ext": "rvt", "files_filter": "Revit Files (*.rvt)|*.rvt"},
@@ -27,6 +32,9 @@
from pyrevit import script, forms
from pyrevit.framework import Color, SolidColorBrush
+# Setting types that are purely visual and carry no config value.
+_DISPLAY_ONLY_TYPES = ("section", "separator")
+
class SettingsWindow(forms.WPFWindow):
"""Dynamic settings window that generates UI from schema."""
@@ -50,23 +58,20 @@ def __init__(
self.result = False
self.controls = {}
- # Generate XAML
xaml_string = self._generate_xaml()
-
- # Initialize WPF window with generated XAML
forms.WPFWindow.__init__(self, xaml_string, literal_string=True)
- # Set window title
self.Title = self.window_title
-
- # Populate controls with current values
self._populate_values()
- # Wire up button events
self.save_button.Click += self.save_clicked
self.cancel_button.Click += self.cancel_clicked
self.reset_button.Click += self.reset_clicked
+ # ------------------------------------------------------------------
+ # XAML helpers
+ # ------------------------------------------------------------------
+
def _escape_xml(self, text):
"""Escape special XML characters.
@@ -90,7 +95,6 @@ def _escape_xml(self, text):
def _generate_xaml(self):
"""Generate XAML string based on settings schema."""
- # Start with window definition
xaml_parts = [
'',
' ',
" ",
+ ' ",
'