Compare commits

...

107 commits
v0.0.2 ... main

Author SHA1 Message Date
a5602a6095 bump version number to v0.0.12 2025-06-05 13:20:39 +02:00
cd99362bb1 [feature] spline animation 2025-06-05 10:59:14 +02:00
f02f8fc2f0 depsgraph update set text
this updates on animation
2025-06-05 10:57:53 +02:00
e95266afc9 [fix] transfer text properties 2025-06-05 10:56:33 +02:00
58e0df3427 first step spline animation 2025-06-04 21:28:08 +02:00
7de8fcc5d1 refactor ensure glyphs + alternates 2025-06-04 14:47:09 +02:00
14d1b7a160 [fix] glyph receives text_id 2025-06-01 16:32:22 +02:00
59edb2e786 bump version to v0.0.11 2025-05-31 20:01:41 +02:00
bb0a5a4a2c add testing scripts 2025-05-31 19:57:11 +02:00
6160b99c93 [fix] recursive selections 2025-05-31 19:53:37 +02:00
d61607c75d [fix] deletion fixes+
and some smaller cosmetic changes
2025-05-31 17:47:47 +02:00
8470425d20 potential fix
i cannot imagine how the while loop would go on forever, but hard limits
are nice
2025-05-31 17:16:59 +02:00
01fcb60e31 fix version string 2025-05-31 17:11:07 +02:00
7a43cfaf2f [feature] unload glyphs and refresh fonts 2025-05-31 16:31:50 +02:00
2dcd4e7a2c [feature] unload glyphs 2025-05-31 16:13:16 +02:00
9423659153 update requirements.txt 2025-05-31 16:12:40 +02:00
19f4bf586f bump version to v0.0.10 2025-05-29 19:34:15 +02:00
04229fbc31 [fix] fix bezier when individual handles sit on points
bonus: prevent eternal while loop
2025-05-29 19:32:56 +02:00
963d89daf9 useful comment 2025-05-29 19:29:56 +02:00
3ef2ae934d [optimization] skip bezier
skip bezier if all handles sit on their points
2025-05-29 19:29:34 +02:00
8965ab11eb [feature] print line number 2025-05-29 19:28:35 +02:00
513497d492 better notices 2025-05-29 15:39:26 +02:00
335ab1face stabilizing, user experience
use class for glyph availability
use isinstance instead of type
better user experience when export directory does not exist
2025-05-29 15:27:24 +02:00
777644e509 bump version to v0.0.9 2025-05-26 06:57:13 +02:00
e14251523b cleanup prints 2025-05-26 06:55:29 +02:00
8f3d58aad0 transfer glyph transforms on duplication 2025-05-25 22:00:54 +02:00
2ace31a246 use get_text_properties instead of id as index 2025-05-25 20:41:00 +02:00
88cfaf3be7 detect textobject and allow primitive duplication 2025-05-25 20:36:46 +02:00
10e57dd46a depsgraph detect texts
implementing in depsgraph allows for duplication
2025-05-25 15:35:52 +02:00
c27cf41368 introduce detect_text() and friends 2025-05-25 14:16:15 +02:00
7a034efd1c all floats 2025-05-25 14:15:42 +02:00
3ea2f0e304 ignore more venvs 2025-05-25 14:15:21 +02:00
840fdf1ca4 bump version to v0.0.8 2025-05-24 15:25:12 +02:00
7b4e65cbb7 check for None 2025-05-24 15:22:01 +02:00
4113343e79 move imports to top 2025-05-24 15:18:20 +02:00
e21ecaef0a fix blender import path 4.3+ 2025-05-24 14:54:24 +02:00
9c77139dcd Merge branch 'main' into dev 2025-05-24 14:38:22 +02:00
13b5a4dd88 bump version to v0.0.7 2025-05-23 11:37:54 +02:00
49699db309 regenerate if needed in update_callback 2025-05-23 11:33:38 +02:00
7ebe913e49 fix rendering crashes
1) introduce can_regenerate so we only regenerate when necessary
2) no notifications of missing glyphs when rendering
3) use frame_change_pre instead of post
2025-05-20 19:24:43 +02:00
2422d0cf09 clean startup 2025-05-20 19:22:00 +02:00
d6dfbfa5a1 cleanup 2025-05-20 19:21:32 +02:00
88f5579d40 loop in and out 2025-05-18 18:47:00 +02:00
ca8b4302a3 fix accessing None 2025-05-18 18:46:35 +02:00
6c3ad47cb6 remove print 2025-05-18 18:46:20 +02:00
093d0813af fix selection being updated 2025-05-18 18:46:07 +02:00
56afa0b453 bump version 2025-05-18 17:37:04 +02:00
8094e56e5a cleanup 2025-05-18 17:36:54 +02:00
f911f2f23a remove local vimrc 2025-05-18 17:36:16 +02:00
7c9a725338 better comments 2025-05-18 17:35:58 +02:00
d56ca84236 glyph properties, orientation fixes 2025-05-18 17:23:38 +02:00
6943a9189c fix shadowing variable 2025-05-18 17:22:48 +02:00
d88c0c50cd cleanup 2025-05-18 17:22:31 +02:00
ed5db93613 cleanup 2025-05-18 17:21:33 +02:00
6708fd0491 conventions 2025-05-18 17:21:23 +02:00
34eeb4af94 align object origins
convenience function to align origins
2025-05-18 13:43:50 +02:00
be5f060c98 add naming helper
convenience function for naming glyphs
2025-05-18 13:42:22 +02:00
da382f5fab add glyph_to_name 2025-05-18 13:40:05 +02:00
71dda9f316 bump version to v0.0.5 in readme 2025-05-14 17:30:03 +02:00
1d4fece0ea fix naming when creating font 2025-05-14 17:22:04 +02:00
05371c3675 fix timer 2025-05-14 17:21:45 +02:00
73d7a56897 fix rotation issue 2025-05-14 17:21:36 +02:00
19c86420f8 fix closed splines 2025-05-14 17:21:26 +02:00
c95e010f81 fix ignore_orientation 2025-05-13 18:02:36 +02:00
8609db1597 hide Nulls 2025-05-13 18:02:02 +02:00
79c0a563f1 add fallback 2025-05-13 17:29:28 +02:00
16bdfc8cc6 cosmetics 2025-05-13 17:29:12 +02:00
47bc10df3f fix first wrong placement 2025-05-13 17:28:45 +02:00
c063c7af1d cleanup
formatting etc
2025-05-13 16:59:32 +02:00
9276ad4fac [fix] scene variable to global
this prevents failure of access in case the scene is refreshed by
blender
2025-05-13 16:59:17 +02:00
f41808adc3 [fix] remove infix 2025-05-13 16:27:56 +02:00
a3c7172573 cleanup 2025-05-13 16:25:19 +02:00
abdee651e0 enable ale
why not
2025-05-13 16:24:54 +02:00
ff85c93551 formatting 2025-05-13 16:08:53 +02:00
a681245093 more explanation 2025-05-13 16:08:40 +02:00
33dac5eae1 formating
auto format made this, i like most of the changes so let's keep it
2025-05-13 15:50:22 +02:00
5c79c7e06e reshuffle and simplify 2025-05-13 15:49:45 +02:00
21480ee371 remove import_infix
not necessary and potentially confusing
2025-05-13 15:45:40 +02:00
fc9b1b65b0 autodetect names earlier
this makes it obvious to the user if autodetect works
2025-05-13 15:38:37 +02:00
9252985405 version bump 2025-05-13 15:11:07 +02:00
5bd78a3fc1 Merge branch 'main' into dev 2025-01-19 16:05:03 +01:00
b40d49c723 bump version v0.0.4 2025-01-19 14:24:23 +01:00
490723496c simplify 2025-01-19 14:21:21 +01:00
2f94702ea9 add removeNonAlphabetic 2025-01-19 14:20:37 +01:00
f046546e61 less print 2025-01-19 14:20:14 +01:00
167dea8164 allow replacements (upper/lower) 2025-01-19 14:19:59 +01:00
36c8f25e29 reset rotation mode 2025-01-19 12:03:42 +01:00
d13afa7d7d friendliness
prevent repetitive messages
2025-01-18 18:22:32 +01:00
1fbac99bd8 add space recognition 2025-01-18 18:21:33 +01:00
e69cdc951d cosmetics 2025-01-18 18:19:52 +01:00
cddbc79151 add requirements.txt
useful for development
2025-01-18 17:18:23 +01:00
1e54a10341 Merge branch 'main' into dev 2024-12-07 15:16:14 +01:00
648d4a6dee bump version v0.0.3 2024-12-07 15:13:28 +01:00
20fb69465b manual positioning
closes #1
2024-12-07 15:13:28 +01:00
b9bd72f979 remove outdated comments 2024-12-07 15:13:28 +01:00
9b20b703dc cleanup 2024-12-07 15:13:28 +01:00
bae49a2346 remove right panel 2024-12-07 15:13:28 +01:00
23624ea1eb bump version v0.0.3 2024-12-07 15:07:11 +01:00
42c4a33801 manual positioning
closes #1
2024-12-07 14:57:33 +01:00
35d864b9b8 remove outdated comments 2024-12-07 14:28:37 +01:00
a2c4ba60f2 cleanup 2024-11-21 15:04:08 +01:00
7e2eeeeec1 remove right panel 2024-11-21 14:59:26 +01:00
77f30d51d1 Merge pull request 'update doc' (#6) from dev into main
Reviewed-on: #6
2024-11-21 14:51:06 +01:00
c3055ac2c9 update doc 2024-11-21 14:44:51 +01:00
5c79392b40 more robust selection getter
this could actually be a function in butils
2024-11-21 14:35:21 +01:00
2ba83ea3fe updater
- introduce updater.host
- allow dev branch
- minor improvements
2024-11-21 14:32:29 +01:00
cd6457352b fix updater zipball 2024-11-16 15:13:34 +01:00
15 changed files with 4026 additions and 1771 deletions

1
.gitignore vendored
View file

@ -1,6 +1,7 @@
# python
__pycache__
venv
venv*
# vim
*.swo

47
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,47 @@
```
_ ____ ____ _____ ____
/ \ | __ ) / ___|___ /| _ \
/ _ \ | _ \| | |_ \| | | |
/ ___ \| |_) | |___ ___) | |_| |
/_/ \_\____/ \____|____/|____/
```
Convenience tool to work with 3D typography in Blender and Cinema4D.
# get bpy python working by:
```bash
$HOME/git/tools/blender_git/build_linux_v4.1/bin/4.1/python/bin/python3.11 -m venv venv
source venv/bin/activate
pip install bpy
```
to install mathutils, this was necessary for me:
```
sudo xbps-install -Sy python3.11-devel
CFLAGS=$(python3.11-config --cflags) LDFLAGS=$(python3.11-config --ldflags) pip install mathutils
```
# install addon:
```bash
cd <root directory>
ln -s $(pwd) $HOME/git/tools/blender_git/build_linux_v4.1/bin/4.1/scripts/addons/abc3d
```
# get blender addon path:
```python
bpy.utils.script_paths()
```
then check it for the `addons` directory
# addons dir:
```
~/git/tools/blender_git/build_linux_v4.1/bin/4.1/scripts/addons/
```
# addon data:
```
~/.config/blender/4.1/datafiles
```
# reload addon in blender:
F3 -> "reload scripts"

View file

@ -5,45 +5,8 @@
/ ___ \| |_) | |___ ___) | |_| |
/_/ \_\____/ \____|____/|____/
```
v0.0.12
Convenience tool to work with 3D typography in Blender and Cinema4D.
Convenience addon to work with 3D typography in Blender and Cinema4D.
The readme is at the moment for development only. Install as you would normally install an addon.
# get bpy python working by:
```bash
$HOME/git/tools/blender_git/build_linux_v4.1/bin/4.1/python/bin/python3.11 -m venv venv
source venv/bin/activate
pip install bpy
```
to install mathutils, this was necessary for me:
```
sudo xbps-install -Sy python3.11-devel
CFLAGS=$(python3.11-config --cflags) LDFLAGS=$(python3.11-config --ldflags) pip install mathutils
```
# install addon:
```bash
cd <root directory>
ln -s $(pwd) $HOME/git/tools/blender_git/build_linux_v4.1/bin/4.1/scripts/addons/abc3d
```
# get blender addon path:
```python
bpy.utils.script_paths()
```
then check it for the `addons` directory
# addons dir:
```
~/git/tools/blender_git/build_linux_v4.1/bin/4.1/scripts/addons/
```
# addon data:
```
~/.config/blender/4.1/datafiles
```
# reload addon in blender:
F3 -> "reload scripts"
Instructions for development in [CONTRIBUTING,md](./CONTRIBUTING.md).

File diff suppressed because it is too large Load diff

View file

@ -1,20 +0,0 @@
""""""""""""""""""""""""""""""""" JEDI
let g:jedi#auto_initialization = 1
let g:jedi#use_tabs_not_buffers = 1
let g:jedi#environment_path = "venv"
""""""""""""""""""""""""""""""""" ALE
"let g:ale_python_pylint_executable = '/home/jrkb/git/pointer/neomatter/font3d/abc3d/venv/bin/pylint'
"let g:ale_python_executable='/home/jrkb/git/pointer/neomatter/font3d/abc3d/venv/bin/python'
"let g:ale_python_pylint_use_global=1
"let g:ale_use_global_executables=1
"let g:ale_python_auto_pipenv=1
"let g:ale_python_auto_virtualenv=1
"let g:ale_virtualenv_dir_names = ['venv']
"let g:ale_linters = { 'javascript': ['eslint', 'tsserver'], 'python': ['jedils', 'pylint', 'flake8'], 'cpp': ['cc', 'clangcheck', 'clangd', 'clangtidy', 'clazy', 'cppcheck', 'cpplint', 'cquery', 'cspell', 'flawfinder'], 'php': ['php_cs_fixer'] }
"let g:ale_fixers = { '*': ['remove_trailing_lines', 'trim_whitespace'], 'python': ['autopep8'], 'cpp': ['uncrustify'], 'javascript': js_fixers, 'css': ['prettier'], 'json': ['prettier'], 'php': ['php_cs_fixer'] }
let g:ale_pattern_options = {'\.py$': {'ale_enabled': 0}}

View file

@ -54,8 +54,8 @@ class SingletonUpdater:
needed throughout the addon. It implements all the interfaces for running
updates.
"""
def __init__(self):
def __init__(self):
self._engine = ForgejoEngine()
self._user = None
self._repo = None
@ -68,7 +68,7 @@ class SingletonUpdater:
self._latest_release = None
self._use_releases = False
self._include_branches = False
self._include_branch_list = ['master']
self._include_branch_list = ["master"]
self._include_branch_auto_check = False
self._manual_only = False
self._version_min_update = None
@ -110,7 +110,8 @@ class SingletonUpdater:
self._addon = __package__.lower()
self._addon_package = __package__ # Must not change.
self._updater_path = os.path.join(
os.path.dirname(__file__), self._addon + "_updater")
os.path.dirname(__file__), self._addon + "_updater"
)
self._addon_root = os.path.dirname(__file__)
self._json = dict()
self._error = None
@ -202,11 +203,13 @@ class SingletonUpdater:
@property
def check_interval(self):
return (self._check_interval_enabled,
self._check_interval_months,
self._check_interval_days,
self._check_interval_hours,
self._check_interval_minutes)
return (
self._check_interval_enabled,
self._check_interval_months,
self._check_interval_days,
self._check_interval_hours,
self._check_interval_minutes,
)
@property
def current_version(self):
@ -221,12 +224,10 @@ class SingletonUpdater:
try:
tuple(tuple_values)
except:
raise ValueError(
"current_version must be a tuple of integers")
raise ValueError("current_version must be a tuple of integers")
for i in tuple_values:
if type(i) is not int:
raise ValueError(
"current_version must be a tuple of integers")
raise ValueError("current_version must be a tuple of integers")
self._current_version = tuple(tuple_values)
@property
@ -285,15 +286,15 @@ class SingletonUpdater:
def include_branch_list(self, value):
try:
if value is None:
self._include_branch_list = ['master']
self._include_branch_list = ["master"]
elif not isinstance(value, list) or len(value) == 0:
raise ValueError(
"include_branch_list should be a list of valid branches")
"include_branch_list should be a list of valid branches"
)
else:
self._include_branch_list = value
except:
raise ValueError(
"include_branch_list should be a list of valid branches")
raise ValueError("include_branch_list should be a list of valid branches")
@property
def include_branches(self):
@ -362,8 +363,7 @@ class SingletonUpdater:
if value is None:
self._remove_pre_update_patterns = list()
elif not isinstance(value, list):
raise ValueError(
"remove_pre_update_patterns needs to be in a list format")
raise ValueError("remove_pre_update_patterns needs to be in a list format")
else:
self._remove_pre_update_patterns = value
@ -548,8 +548,7 @@ class SingletonUpdater:
tag_names.append(tag["name"])
return tag_names
def set_check_interval(self, enabled=False,
months=0, days=14, hours=0, minutes=0):
def set_check_interval(self, enabled=False, months=0, days=14, hours=0, minutes=0):
"""Set the time interval between automated checks, and if enabled.
Has enabled = False as default to not check against frequency,
@ -582,7 +581,8 @@ class SingletonUpdater:
def __str__(self):
return "Updater, with user: {a}, repository: {b}, url: {c}".format(
a=self._user, b=self._repo, c=self.form_repo_url())
a=self._user, b=self._repo, c=self.form_repo_url()
)
# -------------------------------------------------------------------------
# API-related functions
@ -621,10 +621,7 @@ class SingletonUpdater:
temp_branches.reverse()
for branch in temp_branches:
request = self.form_branch_url(branch)
include = {
"name": branch.title(),
"zipball_url": request
}
include = {"name": branch.title(), "zipball_url": request}
self._tags = [include] + self._tags # append to front
if self._tags is None:
@ -643,13 +640,18 @@ class SingletonUpdater:
if not self._error:
self._tag_latest = self._tags[0]
branch = self._include_branch_list[0]
self.print_verbose("{} branch found, no releases: {}".format(
branch, self._tags[0]))
self.print_verbose(
"{} branch found, no releases: {}".format(branch, self._tags[0])
)
elif ((len(self._tags) - len(self._include_branch_list) == 0
and self._include_branches)
or (len(self._tags) == 0 and not self._include_branches)
and self._prefiltered_tag_count > 0):
elif (
(
len(self._tags) - len(self._include_branch_list) == 0
and self._include_branches
)
or (len(self._tags) == 0 and not self._include_branches)
and self._prefiltered_tag_count > 0
):
self._tag_latest = None
self._error = "No releases available"
self._error_msg = "No versions found within compatible version range"
@ -659,13 +661,15 @@ class SingletonUpdater:
if not self._include_branches:
self._tag_latest = self._tags[0]
self.print_verbose(
"Most recent tag found:" + str(self._tags[0]['name']))
"Most recent tag found:" + str(self._tags[0]["name"])
)
else:
# Don't return branch if in list.
n = len(self._include_branch_list)
self._tag_latest = self._tags[n] # guaranteed at least len()=n+1
self.print_verbose(
"Most recent tag found:" + str(self._tags[n]['name']))
"Most recent tag found:" + str(self._tags[n]["name"])
)
def get_raw(self, url):
"""All API calls to base url."""
@ -680,13 +684,12 @@ class SingletonUpdater:
# Setup private request headers if appropriate.
if self._engine.token is not None:
if self._engine.name == "gitlab":
request.add_header('PRIVATE-TOKEN', self._engine.token)
request.add_header("PRIVATE-TOKEN", self._engine.token)
else:
self.print_verbose("Tokens not setup for engine yet")
# Always set user agent.
request.add_header(
'User-Agent', "Python/" + str(platform.python_version()))
request.add_header("User-Agent", "Python/" + str(platform.python_version()))
# Run the request.
try:
@ -747,8 +750,7 @@ class SingletonUpdater:
error = None
# Make/clear the staging folder, to ensure the folder is always clean.
self.print_verbose(
"Preparing staging folder for download:\n" + str(local))
self.print_verbose("Preparing staging folder for download:\n" + str(local))
if os.path.isdir(local):
try:
shutil.rmtree(local)
@ -782,17 +784,16 @@ class SingletonUpdater:
# Setup private token if appropriate.
if self._engine.token is not None:
if self._engine.name == "gitlab":
request.add_header('PRIVATE-TOKEN', self._engine.token)
request.add_header("PRIVATE-TOKEN", self._engine.token)
else:
self.print_verbose(
"Tokens not setup for selected engine yet")
self.print_verbose("Tokens not setup for selected engine yet")
# Always set user agent
request.add_header(
'User-Agent', "Python/" + str(platform.python_version()))
request.add_header("User-Agent", "Python/" + str(platform.python_version()))
self.url_retrieve(urllib.request.urlopen(request, context=context),
self._source_zip)
self.url_retrieve(
urllib.request.urlopen(request, context=context), self._source_zip
)
# Add additional checks on file size being non-zero.
self.print_verbose("Successfully downloaded update zip")
return True
@ -809,7 +810,8 @@ class SingletonUpdater:
self.print_verbose("Backing up current addon folder")
local = os.path.join(self._updater_path, "backup")
tempdest = os.path.join(
self._addon_root, os.pardir, self._addon + "_updater_backup_temp")
self._addon_root, os.pardir, self._addon + "_updater_backup_temp"
)
self.print_verbose("Backup destination path: " + str(local))
@ -818,7 +820,8 @@ class SingletonUpdater:
shutil.rmtree(local)
except:
self.print_verbose(
"Failed to removed previous backup folder, continuing")
"Failed to removed previous backup folder, continuing"
)
self.print_trace()
# Remove the temp folder.
@ -827,16 +830,17 @@ class SingletonUpdater:
try:
shutil.rmtree(tempdest)
except:
self.print_verbose(
"Failed to remove existing temp folder, continuing")
self.print_verbose("Failed to remove existing temp folder, continuing")
self.print_trace()
# Make a full addon copy, temporarily placed outside the addon folder.
if self._backup_ignore_patterns is not None:
try:
shutil.copytree(self._addon_root, tempdest,
ignore=shutil.ignore_patterns(
*self._backup_ignore_patterns))
shutil.copytree(
self._addon_root,
tempdest,
ignore=shutil.ignore_patterns(*self._backup_ignore_patterns),
)
except:
print("Failed to create backup, still attempting update.")
self.print_trace()
@ -853,7 +857,8 @@ class SingletonUpdater:
# Save the date for future reference.
now = datetime.now()
self._json["backup_date"] = "{m}-{d}-{yr}".format(
m=now.strftime("%B"), d=now.day, yr=now.year)
m=now.strftime("%B"), d=now.day, yr=now.year
)
self.save_updater_json()
def restore_backup(self):
@ -861,7 +866,8 @@ class SingletonUpdater:
self.print_verbose("Restoring backup, backing up current addon folder")
backuploc = os.path.join(self._updater_path, "backup")
tempdest = os.path.join(
self._addon_root, os.pardir, self._addon + "_updater_backup_temp")
self._addon_root, os.pardir, self._addon + "_updater_backup_temp"
)
tempdest = os.path.abspath(tempdest)
# Move instead contents back in place, instead of copy.
@ -910,10 +916,8 @@ class SingletonUpdater:
self._error_msg = "Failed to create extract directory"
return -1
self.print_verbose(
"Begin extracting source from zip:" + str(self._source_zip))
self.print_verbose("Begin extracting source from zip:" + str(self._source_zip))
with zipfile.ZipFile(self._source_zip, "r") as zfile:
if not zfile:
self._error = "Install failed"
self._error_msg = "Resulting file is not a zip, cannot extract"
@ -923,19 +927,20 @@ class SingletonUpdater:
# Now extract directly from the first subfolder (not root)
# this avoids adding the first subfolder to the path length,
# which can be too long if the download has the SHA in the name.
zsep = '/' # Not using os.sep, always the / value even on windows.
zsep = "/" # Not using os.sep, always the / value even on windows.
for name in zfile.namelist():
if zsep not in name:
continue
top_folder = name[:name.index(zsep) + 1]
top_folder = name[: name.index(zsep) + 1]
if name == top_folder + zsep:
continue # skip top level folder
sub_path = name[name.index(zsep) + 1:]
sub_path = name[name.index(zsep) + 1 :]
if name.endswith(zsep):
try:
os.mkdir(os.path.join(outdir, sub_path))
self.print_verbose(
"Extract - mkdir: " + os.path.join(outdir, sub_path))
"Extract - mkdir: " + os.path.join(outdir, sub_path)
)
except OSError as exc:
if exc.errno != errno.EEXIST:
self._error = "Install failed"
@ -947,7 +952,8 @@ class SingletonUpdater:
data = zfile.read(name)
outfile.write(data)
self.print_verbose(
"Extract - create: " + os.path.join(outdir, sub_path))
"Extract - create: " + os.path.join(outdir, sub_path)
)
self.print_verbose("Extracted source")
@ -959,8 +965,8 @@ class SingletonUpdater:
return -1
if self._subfolder_path:
self._subfolder_path.replace('/', os.path.sep)
self._subfolder_path.replace('\\', os.path.sep)
self._subfolder_path.replace("/", os.path.sep)
self._subfolder_path.replace("\\", os.path.sep)
# Either directly in root of zip/one subfolder, or use specified path.
if not os.path.isfile(os.path.join(unpath, "__init__.py")):
@ -1018,25 +1024,31 @@ class SingletonUpdater:
# Make sure that base is not a high level shared folder, but
# is dedicated just to the addon itself.
self.print_verbose(
"clean=True, clearing addon folder to fresh install state")
"clean=True, clearing addon folder to fresh install state"
)
# Remove root files and folders (except update folder).
files = [f for f in os.listdir(base)
if os.path.isfile(os.path.join(base, f))]
folders = [f for f in os.listdir(base)
if os.path.isdir(os.path.join(base, f))]
files = [
f for f in os.listdir(base) if os.path.isfile(os.path.join(base, f))
]
folders = [
f for f in os.listdir(base) if os.path.isdir(os.path.join(base, f))
]
for f in files:
os.remove(os.path.join(base, f))
self.print_verbose(
"Clean removing file {}".format(os.path.join(base, f)))
"Clean removing file {}".format(os.path.join(base, f))
)
for f in folders:
if os.path.join(base, f) is self._updater_path:
continue
shutil.rmtree(os.path.join(base, f))
self.print_verbose(
"Clean removing folder and contents {}".format(
os.path.join(base, f)))
os.path.join(base, f)
)
)
except Exception as err:
error = "failed to create clean existing addon folder"
@ -1047,8 +1059,9 @@ class SingletonUpdater:
# but avoid removing/altering backup and updater file.
for path, dirs, files in os.walk(base):
# Prune ie skip updater folder.
dirs[:] = [d for d in dirs
if os.path.join(path, d) not in [self._updater_path]]
dirs[:] = [
d for d in dirs if os.path.join(path, d) not in [self._updater_path]
]
for file in files:
for pattern in self.remove_pre_update_patterns:
if fnmatch.filter([file], pattern):
@ -1066,8 +1079,9 @@ class SingletonUpdater:
# actual file copying/replacements.
for path, dirs, files in os.walk(merger):
# Verify structure works to prune updater sub folder overwriting.
dirs[:] = [d for d in dirs
if os.path.join(path, d) not in [self._updater_path]]
dirs[:] = [
d for d in dirs if os.path.join(path, d) not in [self._updater_path]
]
rel_path = os.path.relpath(path, merger)
dest_path = os.path.join(base, rel_path)
if not os.path.exists(dest_path):
@ -1090,23 +1104,27 @@ class SingletonUpdater:
os.remove(dest_file)
os.rename(srcFile, dest_file)
self.print_verbose(
"Overwrote file " + os.path.basename(dest_file))
"Overwrote file " + os.path.basename(dest_file)
)
else:
self.print_verbose(
"Pattern not matched to {}, not overwritten".format(
os.path.basename(dest_file)))
os.path.basename(dest_file)
)
)
else:
# File did not previously exist, simply move it over.
os.rename(srcFile, dest_file)
self.print_verbose(
"New file " + os.path.basename(dest_file))
self.print_verbose("New file " + os.path.basename(dest_file))
# now remove the temp staging folder and downloaded zip
try:
shutil.rmtree(staging_path)
except:
error = ("Error: Failed to remove existing staging directory, "
"consider manually removing ") + staging_path
error = (
"Error: Failed to remove existing staging directory, "
"consider manually removing "
) + staging_path
self.print_verbose(error)
self.print_trace()
@ -1168,12 +1186,12 @@ class SingletonUpdater:
return ()
segments = list()
tmp = ''
tmp = ""
for char in str(text):
if not char.isdigit():
if len(tmp) > 0:
segments.append(int(tmp))
tmp = ''
tmp = ""
else:
tmp += char
if len(tmp) > 0:
@ -1184,7 +1202,7 @@ class SingletonUpdater:
if not self._include_branches:
return ()
else:
return (text)
return text
return tuple(segments)
def check_for_update_async(self, callback=None):
@ -1193,7 +1211,8 @@ class SingletonUpdater:
self._json is not None
and "update_ready" in self._json
and self._json["version_text"] != dict()
and self._json["update_ready"])
and self._json["update_ready"]
)
if is_ready:
self._update_ready = True
@ -1210,15 +1229,13 @@ class SingletonUpdater:
self.print_verbose("Skipping async check, already started")
# already running the bg thread
elif self._update_ready is None:
print("{} updater: Running background check for update".format(
self.addon))
print("{} updater: Running background check for update".format(self.addon))
self.start_async_check_update(False, callback)
def check_for_update_now(self, callback=None):
self._error = None
self._error_msg = None
self.print_verbose(
"Check update pressed, first getting current status")
self.print_verbose("Check update pressed, first getting current status")
if self._async_checking:
self.print_verbose("Skipping async check, already started")
return # already running the bg thread
@ -1243,9 +1260,7 @@ class SingletonUpdater:
# avoid running again in, just return past result if found
# but if force now check, then still do it
if self._update_ready is not None and not now:
return (self._update_ready,
self._update_version,
self._update_link)
return (self._update_ready, self._update_version, self._update_link)
if self._current_version is None:
raise ValueError("current_version not yet defined")
@ -1259,22 +1274,18 @@ class SingletonUpdater:
self.set_updater_json() # self._json
if not now and not self.past_interval_timestamp():
self.print_verbose(
"Aborting check for updated, check interval not reached")
self.print_verbose("Aborting check for updated, check interval not reached")
return (False, None, None)
# check if using tags or releases
# note that if called the first time, this will pull tags from online
if self._fake_install:
self.print_verbose(
"fake_install = True, setting fake version as ready")
self.print_verbose("fake_install = True, setting fake version as ready")
self._update_ready = True
self._update_version = "(999,999,999)"
self._update_link = "http://127.0.0.1"
return (self._update_ready,
self._update_version,
self._update_link)
return (self._update_ready, self._update_version, self._update_link)
# Primary internet call, sets self._tags and self._tag_latest.
self.get_tags()
@ -1327,7 +1338,6 @@ class SingletonUpdater:
else:
# Situation where branches not included.
if new_version > self._current_version:
self._update_ready = True
self._update_version = new_version
self._update_link = link
@ -1386,8 +1396,7 @@ class SingletonUpdater:
if self._fake_install:
# Change to True, to trigger the reload/"update installed" handler.
self.print_verbose("fake_install=True")
self.print_verbose(
"Just reloading and running any handler triggers")
self.print_verbose("Just reloading and running any handler triggers")
self._json["just_updated"] = True
self.save_updater_json()
if self._backup_current is True:
@ -1401,15 +1410,16 @@ class SingletonUpdater:
self.print_verbose("Update stopped, new version not ready")
if callback:
callback(
self._addon_package,
"Update stopped, new version not ready")
self._addon_package, "Update stopped, new version not ready"
)
return "Update stopped, new version not ready"
elif self._update_link is None:
# this shouldn't happen if update is ready
self.print_verbose("Update stopped, update link unavailable")
if callback:
callback(self._addon_package,
"Update stopped, update link unavailable")
callback(
self._addon_package, "Update stopped, update link unavailable"
)
return "Update stopped, update link unavailable"
if revert_tag is None:
@ -1461,12 +1471,12 @@ class SingletonUpdater:
return True
now = datetime.now()
last_check = datetime.strptime(
self._json["last_check"], "%Y-%m-%d %H:%M:%S.%f")
last_check = datetime.strptime(self._json["last_check"], "%Y-%m-%d %H:%M:%S.%f")
offset = timedelta(
days=self._check_interval_days + 30 * self._check_interval_months,
hours=self._check_interval_hours,
minutes=self._check_interval_minutes)
minutes=self._check_interval_minutes,
)
delta = (now - offset) - last_check
if delta.total_seconds() > 0:
@ -1482,8 +1492,8 @@ class SingletonUpdater:
Will also rename old file paths to addon-specific path if found.
"""
json_path = os.path.join(
self._updater_path,
"{}_updater_status.json".format(self._addon_package))
self._updater_path, "{}_updater_status.json".format(self._addon_package)
)
old_json_path = os.path.join(self._updater_path, "updater_status.json")
# Rename old file if it exists.
@ -1517,7 +1527,7 @@ class SingletonUpdater:
"ignore": False,
"just_restored": False,
"just_updated": False,
"version_text": dict()
"version_text": dict(),
}
self.save_updater_json()
@ -1537,11 +1547,13 @@ class SingletonUpdater:
jpath = self.get_json_path()
if not os.path.isdir(os.path.dirname(jpath)):
print("State error: Directory does not exist, cannot save json: ",
os.path.basename(jpath))
print(
"State error: Directory does not exist, cannot save json: ",
os.path.basename(jpath),
)
return
try:
with open(jpath, 'w') as outf:
with open(jpath, "w") as outf:
data_out = json.dumps(self._json, indent=4)
outf.write(data_out)
except:
@ -1575,8 +1587,13 @@ class SingletonUpdater:
if self._async_checking:
return
self.print_verbose("Starting background checking thread")
check_thread = threading.Thread(target=self.async_check_update,
args=(now, callback,))
check_thread = threading.Thread(
target=self.async_check_update,
args=(
now,
callback,
),
)
check_thread.daemon = True
self._check_thread = check_thread
check_thread.start()
@ -1630,17 +1647,19 @@ class SingletonUpdater:
# Updater Engines
# -----------------------------------------------------------------------------
class BitbucketEngine:
"""Integration to Bitbucket API for git-formatted repositories"""
def __init__(self):
self.api_url = 'https://api.bitbucket.org'
self.api_url = "https://api.bitbucket.org"
self.token = None
self.name = "bitbucket"
def form_repo_url(self, updater):
return "{}/2.0/repositories/{}/{}".format(
self.api_url, updater.user, updater.repo)
self.api_url, updater.user, updater.repo
)
def form_tags_url(self, updater):
return self.form_repo_url(updater) + "/refs/tags?sort=-name"
@ -1650,31 +1669,28 @@ class BitbucketEngine:
def get_zip_url(self, name, updater):
return "https://bitbucket.org/{user}/{repo}/get/{name}.zip".format(
user=updater.user,
repo=updater.repo,
name=name)
user=updater.user, repo=updater.repo, name=name
)
def parse_tags(self, response, updater):
if response is None:
return list()
return [
{
"name": tag["name"],
"zipball_url": self.get_zip_url(tag["name"], updater)
} for tag in response["values"]]
{"name": tag["name"], "zipball_url": self.get_zip_url(tag["name"], updater)}
for tag in response["values"]
]
class GithubEngine:
"""Integration to Github API"""
def __init__(self):
self.api_url = 'https://api.github.com'
self.api_url = "https://api.github.com"
self.token = None
self.name = "github"
def form_repo_url(self, updater):
return "{}/repos/{}/{}".format(
self.api_url, updater.user, updater.repo)
return "{}/repos/{}/{}".format(self.api_url, updater.user, updater.repo)
def form_tags_url(self, updater):
if updater.use_releases:
@ -1698,7 +1714,7 @@ class GitlabEngine:
"""Integration to GitLab API"""
def __init__(self):
self.api_url = 'https://gitlab.com'
self.api_url = "https://gitlab.com"
self.token = None
self.name = "gitlab"
@ -1710,19 +1726,19 @@ class GitlabEngine:
def form_branch_list_url(self, updater):
# does not validate branch name.
return "{}/repository/branches".format(
self.form_repo_url(updater))
return "{}/repository/branches".format(self.form_repo_url(updater))
def form_branch_url(self, branch, updater):
# Could clash with tag names and if it does, it will download TAG zip
# instead of branch zip to get direct path, would need.
return "{}/repository/archive.zip?sha={}".format(
self.form_repo_url(updater), branch)
self.form_repo_url(updater), branch
)
def get_zip_url(self, sha, updater):
return "{base}/repository/archive.zip?sha={sha}".format(
base=self.form_repo_url(updater),
sha=sha)
base=self.form_repo_url(updater), sha=sha
)
# def get_commit_zip(self, id, updater):
# return self.form_repo_url(updater)+"/repository/archive.zip?sha:"+id
@ -1733,18 +1749,25 @@ class GitlabEngine:
return [
{
"name": tag["name"],
"zipball_url": self.get_zip_url(tag["commit"]["id"], updater)
} for tag in response]
"zipball_url": self.get_zip_url(tag["commit"]["id"], updater),
}
for tag in response
]
class ForgejoEngine:
"""Integration to Forgejo/Gitea API"""
def __init__(self):
self.api_url = 'https://git.pointer.click'
# the api_url may be overwritten by form_repo_url
# if updater.host is set
self.api_url = "https://codeberg.org"
self.token = None
self.name = "forgejo"
def form_repo_url(self, updater):
if updater.host:
self.api_url = "https://" + updater.host
return "{}/api/v1/repos/{}/{}".format(self.api_url, updater.user, updater.repo)
def form_tags_url(self, updater):
@ -1752,19 +1775,17 @@ class ForgejoEngine:
def form_branch_list_url(self, updater):
# does not validate branch name.
return "{}/branches".format(
self.form_repo_url(updater))
return "{}/branches".format(self.form_repo_url(updater))
def form_branch_url(self, branch, updater):
# Could clash with tag names and if it does, it will download TAG zip
# instead of branch zip to get direct path, would need.
return "{}/archive/{}.zip".format(
self.form_repo_url(updater), branch)
return "{}/archive/{}.zip".format(self.form_repo_url(updater), branch)
def get_zip_url(self, sha, updater):
return "{base}/archive/{sha}.zip".format(
base=self.form_repo_url(updater),
sha=sha)
base=self.form_repo_url(updater), sha=sha
)
# def get_commit_zip(self, id, updater):
# return self.form_repo_url(updater)+"/repository/archive.zip?sha:"+id
@ -1775,8 +1796,10 @@ class ForgejoEngine:
return [
{
"name": tag["name"],
"zipball_url": self.get_zip_url(tag["commit"]["id"], updater)
} for tag in response]
"zipball_url": self.get_zip_url(tag["commit"]["sha"], updater),
}
for tag in response
]
# -----------------------------------------------------------------------------

View file

@ -72,7 +72,7 @@ except Exception as e:
# not match and have errors. Must be all lowercase and no spaces! Should also
# be unique among any other addons that could exist (using this updater code),
# to avoid clashes in operator registration.
updater.addon = "addon_updater_demo"
updater.addon = "abc3d"
# -----------------------------------------------------------------------------
@ -83,15 +83,17 @@ def make_annotations(cls):
if not hasattr(bpy.app, "version") or bpy.app.version < (2, 80):
return cls
if bpy.app.version < (2, 93, 0):
bl_props = {k: v for k, v in cls.__dict__.items()
if isinstance(v, tuple)}
bl_props = {k: v for k, v in cls.__dict__.items() if isinstance(v, tuple)}
else:
bl_props = {k: v for k, v in cls.__dict__.items()
if isinstance(v, bpy.props._PropertyDeferred)}
bl_props = {
k: v
for k, v in cls.__dict__.items()
if isinstance(v, bpy.props._PropertyDeferred)
}
if bl_props:
if '__annotations__' not in cls.__dict__:
setattr(cls, '__annotations__', {})
annotations = cls.__dict__['__annotations__']
if "__annotations__" not in cls.__dict__:
setattr(cls, "__annotations__", {})
annotations = cls.__dict__["__annotations__"]
for k, v in bl_props.items():
annotations[k] = v
delattr(cls, k)
@ -129,20 +131,23 @@ def get_user_preferences(context=None):
# Simple popup to prompt use to check for update & offer install if available.
class AddonUpdaterInstallPopup(bpy.types.Operator):
"""Check and install update if available"""
bl_label = "Update {x} addon".format(x=updater.addon)
bl_idname = updater.addon + ".updater_install_popup"
bl_description = "Popup to check and display current updates available"
bl_options = {'REGISTER', 'INTERNAL'}
bl_options = {"REGISTER", "INTERNAL"}
# if true, run clean install - ie remove all files before adding new
# equivalent to deleting the addon and reinstalling, except the
# updater folder/backup folder remains
clean_install = bpy.props.BoolProperty(
name="Clean install",
description=("If enabled, completely clear the addon's folder before "
"installing new update, creating a fresh install"),
description=(
"If enabled, completely clear the addon's folder before "
"installing new update, creating a fresh install"
),
default=False,
options={'HIDDEN'}
options={"HIDDEN"},
)
ignore_enum = bpy.props.EnumProperty(
@ -151,9 +156,9 @@ class AddonUpdaterInstallPopup(bpy.types.Operator):
items=[
("install", "Update Now", "Install update now"),
("ignore", "Ignore", "Ignore this update to prevent future popups"),
("defer", "Defer", "Defer choice till next blender session")
("defer", "Defer", "Defer choice till next blender session"),
],
options={'HIDDEN'}
options={"HIDDEN"},
)
def check(self, context):
@ -170,10 +175,11 @@ class AddonUpdaterInstallPopup(bpy.types.Operator):
elif updater.update_ready:
col = layout.column()
col.scale_y = 0.7
col.label(text="Update {} ready!".format(updater.update_version),
icon="LOOP_FORWARDS")
col.label(text="Choose 'Update Now' & press OK to install, ",
icon="BLANK1")
col.label(
text="Update {} ready!".format(updater.update_version),
icon="LOOP_FORWARDS",
)
col.label(text="Choose 'Update Now' & press OK to install, ", icon="BLANK1")
col.label(text="or click outside window to defer", icon="BLANK1")
row = col.row()
row.prop(self, "ignore_enum", expand=True)
@ -194,22 +200,21 @@ class AddonUpdaterInstallPopup(bpy.types.Operator):
def execute(self, context):
# In case of error importing updater.
if updater.invalid_updater:
return {'CANCELLED'}
return {"CANCELLED"}
if updater.manual_only:
bpy.ops.wm.url_open(url=updater.website)
elif updater.update_ready:
# Action based on enum selection.
if self.ignore_enum == 'defer':
return {'FINISHED'}
elif self.ignore_enum == 'ignore':
if self.ignore_enum == "defer":
return {"FINISHED"}
elif self.ignore_enum == "ignore":
updater.ignore_update()
return {'FINISHED'}
return {"FINISHED"}
res = updater.run_update(force=False,
callback=post_update_callback,
clean=self.clean_install)
res = updater.run_update(
force=False, callback=post_update_callback, clean=self.clean_install
)
# Should return 0, if not something happened.
if updater.verbose:
@ -222,84 +227,86 @@ class AddonUpdaterInstallPopup(bpy.types.Operator):
# Re-launch this dialog.
atr = AddonUpdaterInstallPopup.bl_idname.split(".")
getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT')
getattr(getattr(bpy.ops, atr[0]), atr[1])("INVOKE_DEFAULT")
else:
updater.print_verbose("Doing nothing, not ready for update")
return {'FINISHED'}
return {"FINISHED"}
# User preference check-now operator
class AddonUpdaterCheckNow(bpy.types.Operator):
bl_label = "Check now for " + updater.addon + " update"
bl_idname = updater.addon + ".updater_check_now"
bl_description = "Check now for an update to the {} addon".format(
updater.addon)
bl_options = {'REGISTER', 'INTERNAL'}
bl_description = "Check now for an update to the {} addon".format(updater.addon)
bl_options = {"REGISTER", "INTERNAL"}
def execute(self, context):
if updater.invalid_updater:
return {'CANCELLED'}
return {"CANCELLED"}
if updater.async_checking and updater.error is None:
# Check already happened.
# Used here to just avoid constant applying settings below.
# Ignoring if error, to prevent being stuck on the error screen.
return {'CANCELLED'}
return {"CANCELLED"}
# apply the UI settings
settings = get_user_preferences(context)
if not settings:
updater.print_verbose(
"Could not get {} preferences, update check skipped".format(
__package__))
return {'CANCELLED'}
"Could not get {} preferences, update check skipped".format(__package__)
)
return {"CANCELLED"}
updater.set_check_interval(
enabled=settings.auto_check_update,
months=settings.updater_interval_months,
days=settings.updater_interval_days,
hours=settings.updater_interval_hours,
minutes=settings.updater_interval_minutes)
minutes=settings.updater_interval_minutes,
)
# Input is an optional callback function. This function should take a
# bool input. If true: update ready, if false: no update ready.
updater.check_for_update_now(ui_refresh)
return {'FINISHED'}
return {"FINISHED"}
class AddonUpdaterUpdateNow(bpy.types.Operator):
bl_label = "Update " + updater.addon + " addon now"
bl_idname = updater.addon + ".updater_update_now"
bl_description = "Update to the latest version of the {x} addon".format(
x=updater.addon)
bl_options = {'REGISTER', 'INTERNAL'}
x=updater.addon
)
bl_options = {"REGISTER", "INTERNAL"}
# If true, run clean install - ie remove all files before adding new
# equivalent to deleting the addon and reinstalling, except the updater
# folder/backup folder remains.
clean_install = bpy.props.BoolProperty(
name="Clean install",
description=("If enabled, completely clear the addon's folder before "
"installing new update, creating a fresh install"),
description=(
"If enabled, completely clear the addon's folder before "
"installing new update, creating a fresh install"
),
default=False,
options={'HIDDEN'}
options={"HIDDEN"},
)
def execute(self, context):
# in case of error importing updater
if updater.invalid_updater:
return {'CANCELLED'}
return {"CANCELLED"}
if updater.manual_only:
bpy.ops.wm.url_open(url=updater.website)
if updater.update_ready:
# if it fails, offer to open the website instead
try:
res = updater.run_update(force=False,
callback=post_update_callback,
clean=self.clean_install)
res = updater.run_update(
force=False, callback=post_update_callback, clean=self.clean_install
)
# Should return 0, if not something happened.
if updater.verbose:
@ -312,30 +319,30 @@ class AddonUpdaterUpdateNow(bpy.types.Operator):
updater._error_msg = str(expt)
updater.print_trace()
atr = AddonUpdaterInstallManually.bl_idname.split(".")
getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT')
getattr(getattr(bpy.ops, atr[0]), atr[1])("INVOKE_DEFAULT")
elif updater.update_ready is None:
(update_ready, version, link) = updater.check_for_update(now=True)
# Re-launch this dialog.
atr = AddonUpdaterInstallPopup.bl_idname.split(".")
getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT')
getattr(getattr(bpy.ops, atr[0]), atr[1])("INVOKE_DEFAULT")
elif not updater.update_ready:
self.report({'INFO'}, "Nothing to update")
return {'CANCELLED'}
self.report({"INFO"}, "Nothing to update")
return {"CANCELLED"}
else:
self.report(
{'ERROR'}, "Encountered a problem while trying to update")
return {'CANCELLED'}
self.report({"ERROR"}, "Encountered a problem while trying to update")
return {"CANCELLED"}
return {'FINISHED'}
return {"FINISHED"}
class AddonUpdaterUpdateTarget(bpy.types.Operator):
bl_label = updater.addon + " version target"
bl_idname = updater.addon + ".updater_update_target"
bl_description = "Install a targeted version of the {x} addon".format(
x=updater.addon)
bl_options = {'REGISTER', 'INTERNAL'}
x=updater.addon
)
bl_options = {"REGISTER", "INTERNAL"}
def target_version(self, context):
# In case of error importing updater.
@ -352,7 +359,7 @@ class AddonUpdaterUpdateTarget(bpy.types.Operator):
target = bpy.props.EnumProperty(
name="Target version to install",
description="Select the version to install",
items=target_version
items=target_version,
)
# If true, run clean install - ie remove all files before adding new
@ -360,10 +367,12 @@ class AddonUpdaterUpdateTarget(bpy.types.Operator):
# updater folder/backup folder remains.
clean_install = bpy.props.BoolProperty(
name="Clean install",
description=("If enabled, completely clear the addon's folder before "
"installing new update, creating a fresh install"),
description=(
"If enabled, completely clear the addon's folder before "
"installing new update, creating a fresh install"
),
default=False,
options={'HIDDEN'}
options={"HIDDEN"},
)
@classmethod
@ -389,36 +398,35 @@ class AddonUpdaterUpdateTarget(bpy.types.Operator):
def execute(self, context):
# In case of error importing updater.
if updater.invalid_updater:
return {'CANCELLED'}
return {"CANCELLED"}
res = updater.run_update(
force=False,
revert_tag=self.target,
callback=post_update_callback,
clean=self.clean_install)
clean=self.clean_install,
)
# Should return 0, if not something happened.
if res == 0:
updater.print_verbose("Updater returned successful")
else:
updater.print_verbose(
"Updater returned {}, , error occurred".format(res))
return {'CANCELLED'}
updater.print_verbose("Updater returned {}, , error occurred".format(res))
return {"CANCELLED"}
return {'FINISHED'}
return {"FINISHED"}
class AddonUpdaterInstallManually(bpy.types.Operator):
"""As a fallback, direct the user to download the addon manually"""
bl_label = "Install update manually"
bl_idname = updater.addon + ".updater_install_manually"
bl_description = "Proceed to manually install update"
bl_options = {'REGISTER', 'INTERNAL'}
bl_options = {"REGISTER", "INTERNAL"}
error = bpy.props.StringProperty(
name="Error Occurred",
default="",
options={'HIDDEN'}
name="Error Occurred", default="", options={"HIDDEN"}
)
def invoke(self, context, event):
@ -435,10 +443,8 @@ class AddonUpdaterInstallManually(bpy.types.Operator):
if self.error != "":
col = layout.column()
col.scale_y = 0.7
col.label(text="There was an issue trying to auto-install",
icon="ERROR")
col.label(text="Press the download button below and install",
icon="BLANK1")
col.label(text="There was an issue trying to auto-install", icon="ERROR")
col.label(text="Press the download button below and install", icon="BLANK1")
col.label(text="the zip file like a normal addon.", icon="BLANK1")
else:
col = layout.column()
@ -454,12 +460,10 @@ class AddonUpdaterInstallManually(bpy.types.Operator):
if updater.update_link is not None:
row.operator(
"wm.url_open",
text="Direct download").url = updater.update_link
"wm.url_open", text="Direct download"
).url = updater.update_link
else:
row.operator(
"wm.url_open",
text="(failed to retrieve direct download)")
row.operator("wm.url_open", text="(failed to retrieve direct download)")
row.enabled = False
if updater.website is not None:
@ -471,20 +475,19 @@ class AddonUpdaterInstallManually(bpy.types.Operator):
row.label(text="See source website to download the update")
def execute(self, context):
return {'FINISHED'}
return {"FINISHED"}
class AddonUpdaterUpdatedSuccessful(bpy.types.Operator):
"""Addon in place, popup telling user it completed or what went wrong"""
bl_label = "Installation Report"
bl_idname = updater.addon + ".updater_update_successful"
bl_description = "Update installation response"
bl_options = {'REGISTER', 'INTERNAL', 'UNDO'}
bl_options = {"REGISTER", "INTERNAL", "UNDO"}
error = bpy.props.StringProperty(
name="Error Occurred",
default="",
options={'HIDDEN'}
name="Error Occurred", default="", options={"HIDDEN"}
)
def invoke(self, context, event):
@ -510,9 +513,8 @@ class AddonUpdaterUpdatedSuccessful(bpy.types.Operator):
rw = col.row()
rw.scale_y = 2
rw.operator(
"wm.url_open",
text="Click for manual download.",
icon="BLANK1").url = updater.website
"wm.url_open", text="Click for manual download.", icon="BLANK1"
).url = updater.website
elif not updater.auto_reload_post_update:
# Tell user to restart blender after an update/restore!
if "just_restored" in saved and saved["just_restored"]:
@ -521,20 +523,17 @@ class AddonUpdaterUpdatedSuccessful(bpy.types.Operator):
alert_row = col.row()
alert_row.alert = True
alert_row.operator(
"wm.quit_blender",
text="Restart blender to reload",
icon="BLANK1")
"wm.quit_blender", text="Restart blender to reload", icon="BLANK1"
)
updater.json_reset_restore()
else:
col = layout.column()
col.label(
text="Addon successfully installed", icon="FILE_TICK")
col.label(text="Addon successfully installed", icon="FILE_TICK")
alert_row = col.row()
alert_row.alert = True
alert_row.operator(
"wm.quit_blender",
text="Restart blender to reload",
icon="BLANK1")
"wm.quit_blender", text="Restart blender to reload", icon="BLANK1"
)
else:
# reload addon, but still recommend they restart blender
@ -543,28 +542,28 @@ class AddonUpdaterUpdatedSuccessful(bpy.types.Operator):
col.scale_y = 0.7
col.label(text="Addon restored", icon="RECOVER_LAST")
col.label(
text="Consider restarting blender to fully reload.",
icon="BLANK1")
text="Consider restarting blender to fully reload.", icon="BLANK1"
)
updater.json_reset_restore()
else:
col = layout.column()
col.scale_y = 0.7
col.label(text="Addon successfully installed", icon="FILE_TICK")
col.label(
text="Addon successfully installed", icon="FILE_TICK")
col.label(
text="Consider restarting blender to fully reload.",
icon="BLANK1")
text="Consider restarting blender to fully reload.", icon="BLANK1"
)
def execute(self, context):
return {'FINISHED'}
return {"FINISHED"}
class AddonUpdaterRestoreBackup(bpy.types.Operator):
"""Restore addon from backup"""
bl_label = "Restore backup"
bl_idname = updater.addon + ".updater_restore_backup"
bl_description = "Restore addon from backup"
bl_options = {'REGISTER', 'INTERNAL'}
bl_options = {"REGISTER", "INTERNAL"}
@classmethod
def poll(cls, context):
@ -576,17 +575,18 @@ class AddonUpdaterRestoreBackup(bpy.types.Operator):
def execute(self, context):
# in case of error importing updater
if updater.invalid_updater:
return {'CANCELLED'}
return {"CANCELLED"}
updater.restore_backup()
return {'FINISHED'}
return {"FINISHED"}
class AddonUpdaterIgnore(bpy.types.Operator):
"""Ignore update to prevent future popups"""
bl_label = "Ignore update"
bl_idname = updater.addon + ".updater_ignore"
bl_description = "Ignore update to prevent future popups"
bl_options = {'REGISTER', 'INTERNAL'}
bl_options = {"REGISTER", "INTERNAL"}
@classmethod
def poll(cls, context):
@ -600,25 +600,26 @@ class AddonUpdaterIgnore(bpy.types.Operator):
def execute(self, context):
# in case of error importing updater
if updater.invalid_updater:
return {'CANCELLED'}
return {"CANCELLED"}
updater.ignore_update()
self.report({"INFO"}, "Open addon preferences for updater options")
return {'FINISHED'}
return {"FINISHED"}
class AddonUpdaterEndBackground(bpy.types.Operator):
"""Stop checking for update in the background"""
bl_label = "End background check"
bl_idname = updater.addon + ".end_background_check"
bl_description = "Stop checking for update in the background"
bl_options = {'REGISTER', 'INTERNAL'}
bl_options = {"REGISTER", "INTERNAL"}
def execute(self, context):
# in case of error importing updater
if updater.invalid_updater:
return {'CANCELLED'}
return {"CANCELLED"}
updater.stop_async_check_update()
return {'FINISHED'}
return {"FINISHED"}
# -----------------------------------------------------------------------------
@ -645,16 +646,16 @@ def updater_run_success_popup_handler(scene):
try:
if "scene_update_post" in dir(bpy.app.handlers):
bpy.app.handlers.scene_update_post.remove(
updater_run_success_popup_handler)
bpy.app.handlers.scene_update_post.remove(updater_run_success_popup_handler)
else:
bpy.app.handlers.depsgraph_update_post.remove(
updater_run_success_popup_handler)
updater_run_success_popup_handler
)
except:
pass
atr = AddonUpdaterUpdatedSuccessful.bl_idname.split(".")
getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT')
getattr(getattr(bpy.ops, atr[0]), atr[1])("INVOKE_DEFAULT")
@persistent
@ -669,11 +670,11 @@ def updater_run_install_popup_handler(scene):
try:
if "scene_update_post" in dir(bpy.app.handlers):
bpy.app.handlers.scene_update_post.remove(
updater_run_install_popup_handler)
bpy.app.handlers.scene_update_post.remove(updater_run_install_popup_handler)
else:
bpy.app.handlers.depsgraph_update_post.remove(
updater_run_install_popup_handler)
updater_run_install_popup_handler
)
except:
pass
@ -687,12 +688,12 @@ def updater_run_install_popup_handler(scene):
# User probably manually installed to get the up to date addon
# in here. Clear out the update flag using this function.
updater.print_verbose(
"{} updater: appears user updated, clearing flag".format(
updater.addon))
"{} updater: appears user updated, clearing flag".format(updater.addon)
)
updater.json_reset_restore()
return
atr = AddonUpdaterInstallPopup.bl_idname.split(".")
getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT')
getattr(getattr(bpy.ops, atr[0]), atr[1])("INVOKE_DEFAULT")
def background_update_callback(update_ready):
@ -720,11 +721,9 @@ def background_update_callback(update_ready):
return
if "scene_update_post" in dir(bpy.app.handlers): # 2.7x
bpy.app.handlers.scene_update_post.append(
updater_run_install_popup_handler)
bpy.app.handlers.scene_update_post.append(updater_run_install_popup_handler)
else: # 2.8+
bpy.app.handlers.depsgraph_update_post.append(
updater_run_install_popup_handler)
bpy.app.handlers.depsgraph_update_post.append(updater_run_install_popup_handler)
ran_auto_check_install_popup = True
updater.print_verbose("Attempted popup prompt")
@ -748,17 +747,18 @@ def post_update_callback(module_name, res=None):
# This is the same code as in conditional at the end of the register
# function, ie if "auto_reload_post_update" == True, skip code.
updater.print_verbose(
"{} updater: Running post update callback".format(updater.addon))
"{} updater: Running post update callback".format(updater.addon)
)
atr = AddonUpdaterUpdatedSuccessful.bl_idname.split(".")
getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT')
getattr(getattr(bpy.ops, atr[0]), atr[1])("INVOKE_DEFAULT")
global ran_update_success_popup
ran_update_success_popup = True
else:
# Some kind of error occurred and it was unable to install, offer
# manual download instead.
atr = AddonUpdaterUpdatedSuccessful.bl_idname.split(".")
getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT', error=res)
getattr(getattr(bpy.ops, atr[0]), atr[1])("INVOKE_DEFAULT", error=res)
return
@ -791,11 +791,13 @@ def check_for_update_background():
settings = get_user_preferences(bpy.context)
if not settings:
return
updater.set_check_interval(enabled=settings.auto_check_update,
months=settings.updater_interval_months,
days=settings.updater_interval_days,
hours=settings.updater_interval_hours,
minutes=settings.updater_interval_minutes)
updater.set_check_interval(
enabled=settings.auto_check_update,
months=settings.updater_interval_months,
days=settings.updater_interval_days,
hours=settings.updater_interval_hours,
minutes=settings.updater_interval_minutes,
)
# Input is an optional callback function. This function should take a bool
# input, if true: update ready, if false: no update ready.
@ -813,22 +815,25 @@ def check_for_update_nonthreaded(self, context):
settings = get_user_preferences(bpy.context)
if not settings:
if updater.verbose:
print("Could not get {} preferences, update check skipped".format(
__package__))
print(
"Could not get {} preferences, update check skipped".format(__package__)
)
return
updater.set_check_interval(enabled=settings.auto_check_update,
months=settings.updater_interval_months,
days=settings.updater_interval_days,
hours=settings.updater_interval_hours,
minutes=settings.updater_interval_minutes)
updater.set_check_interval(
enabled=settings.auto_check_update,
months=settings.updater_interval_months,
days=settings.updater_interval_days,
hours=settings.updater_interval_hours,
minutes=settings.updater_interval_minutes,
)
(update_ready, version, link) = updater.check_for_update(now=False)
if update_ready:
atr = AddonUpdaterInstallPopup.bl_idname.split(".")
getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT')
getattr(getattr(bpy.ops, atr[0]), atr[1])("INVOKE_DEFAULT")
else:
updater.print_verbose("No update ready")
self.report({'INFO'}, "No update ready")
self.report({"INFO"}, "No update ready")
def show_reload_popup():
@ -866,11 +871,9 @@ def show_reload_popup():
return
if "scene_update_post" in dir(bpy.app.handlers): # 2.7x
bpy.app.handlers.scene_update_post.append(
updater_run_success_popup_handler)
bpy.app.handlers.scene_update_post.append(updater_run_success_popup_handler)
else: # 2.8+
bpy.app.handlers.depsgraph_update_post.append(
updater_run_success_popup_handler)
bpy.app.handlers.depsgraph_update_post.append(updater_run_success_popup_handler)
ran_update_success_popup = True
@ -896,10 +899,7 @@ def update_notice_box_ui(self, context):
col = box.column()
alert_row = col.row()
alert_row.alert = True
alert_row.operator(
"wm.quit_blender",
text="Restart blender",
icon="ERROR")
alert_row.operator("wm.quit_blender", text="Restart blender", icon="ERROR")
col.label(text="to complete update")
return
@ -924,13 +924,13 @@ def update_notice_box_ui(self, context):
colR = split.column(align=True)
colR.scale_y = 1.5
if not updater.manual_only:
colR.operator(AddonUpdaterUpdateNow.bl_idname,
text="Update", icon="LOOP_FORWARDS")
colR.operator(
AddonUpdaterUpdateNow.bl_idname, text="Update", icon="LOOP_FORWARDS"
)
col.operator("wm.url_open", text="Open website").url = updater.website
# ops = col.operator("wm.url_open",text="Direct download")
# ops.url=updater.update_link
col.operator(AddonUpdaterInstallManually.bl_idname,
text="Install manually")
col.operator(AddonUpdaterInstallManually.bl_idname, text="Install manually")
else:
# ops = col.operator("wm.url_open", text="Direct download")
# ops.url=updater.update_link
@ -959,7 +959,7 @@ def update_settings_ui(self, context, element=None):
return
settings = get_user_preferences(context)
if not settings:
box.label(text="Error getting updater preferences", icon='ERROR')
box.label(text="Error getting updater preferences", icon="ERROR")
return
# auto-update settings
@ -971,9 +971,11 @@ def update_settings_ui(self, context, element=None):
saved_state = updater.json
if "just_updated" in saved_state and saved_state["just_updated"]:
row.alert = True
row.operator("wm.quit_blender",
text="Restart blender to complete update",
icon="ERROR")
row.operator(
"wm.quit_blender",
text="Restart blender to complete update",
icon="ERROR",
)
return
split = layout_split(row, factor=0.4)
@ -1007,16 +1009,13 @@ def update_settings_ui(self, context, element=None):
split.scale_y = 2
if "ssl" in updater.error_msg.lower():
split.enabled = True
split.operator(AddonUpdaterInstallManually.bl_idname,
text=updater.error)
split.operator(AddonUpdaterInstallManually.bl_idname, text=updater.error)
else:
split.enabled = False
split.operator(AddonUpdaterCheckNow.bl_idname,
text=updater.error)
split.operator(AddonUpdaterCheckNow.bl_idname, text=updater.error)
split = sub_col.split(align=True)
split.scale_y = 2
split.operator(AddonUpdaterCheckNow.bl_idname,
text="", icon="FILE_REFRESH")
split.operator(AddonUpdaterCheckNow.bl_idname, text="", icon="FILE_REFRESH")
elif updater.update_ready is None and not updater.async_checking:
col.scale_y = 2
@ -1032,61 +1031,62 @@ def update_settings_ui(self, context, element=None):
split.scale_y = 2
split.operator(AddonUpdaterEndBackground.bl_idname, text="", icon="X")
elif updater.include_branches and \
len(updater.tags) == len(updater.include_branch_list) and not \
updater.manual_only:
elif (
updater.include_branches
and len(updater.tags) == len(updater.include_branch_list)
and not updater.manual_only
):
# No releases found, but still show the appropriate branch.
sub_col = col.row(align=True)
sub_col.scale_y = 1
split = sub_col.split(align=True)
split.scale_y = 2
update_now_txt = "Update directly to {}".format(
updater.include_branch_list[0])
update_now_txt = "Update directly to {}".format(updater.include_branch_list[0])
split.operator(AddonUpdaterUpdateNow.bl_idname, text=update_now_txt)
split = sub_col.split(align=True)
split.scale_y = 2
split.operator(AddonUpdaterCheckNow.bl_idname,
text="", icon="FILE_REFRESH")
split.operator(AddonUpdaterCheckNow.bl_idname, text="", icon="FILE_REFRESH")
elif updater.update_ready and not updater.manual_only:
sub_col = col.row(align=True)
sub_col.scale_y = 1
split = sub_col.split(align=True)
split.scale_y = 2
split.operator(AddonUpdaterUpdateNow.bl_idname,
text="Update now to " + str(updater.update_version))
split.operator(
AddonUpdaterUpdateNow.bl_idname,
text="Update now to " + str(updater.update_version),
)
split = sub_col.split(align=True)
split.scale_y = 2
split.operator(AddonUpdaterCheckNow.bl_idname,
text="", icon="FILE_REFRESH")
split.operator(AddonUpdaterCheckNow.bl_idname, text="", icon="FILE_REFRESH")
elif updater.update_ready and updater.manual_only:
col.scale_y = 2
dl_now_txt = "Download " + str(updater.update_version)
col.operator("wm.url_open",
text=dl_now_txt).url = updater.website
col.operator("wm.url_open", text=dl_now_txt).url = updater.website
else: # i.e. that updater.update_ready == False.
sub_col = col.row(align=True)
sub_col.scale_y = 1
split = sub_col.split(align=True)
split.enabled = False
split.scale_y = 2
split.operator(AddonUpdaterCheckNow.bl_idname,
text="Addon is up to date")
split.operator(AddonUpdaterCheckNow.bl_idname, text="Addon is up to date")
split = sub_col.split(align=True)
split.scale_y = 2
split.operator(AddonUpdaterCheckNow.bl_idname,
text="", icon="FILE_REFRESH")
split.operator(AddonUpdaterCheckNow.bl_idname, text="", icon="FILE_REFRESH")
if not updater.manual_only:
col = row.column(align=True)
if updater.include_branches and len(updater.include_branch_list) > 0:
branch = updater.include_branch_list[0]
col.operator(AddonUpdaterUpdateTarget.bl_idname,
text="Install {} / old version".format(branch))
col.operator(
AddonUpdaterUpdateTarget.bl_idname,
text="Install {} / old version".format(branch),
)
else:
col.operator(AddonUpdaterUpdateTarget.bl_idname,
text="(Re)install addon version")
col.operator(
AddonUpdaterUpdateTarget.bl_idname, text="(Re)install addon version"
)
last_date = "none found"
backup_path = os.path.join(updater.stage_path, "backup")
if "backup_date" in updater.json and os.path.isdir(backup_path):
@ -1103,7 +1103,7 @@ def update_settings_ui(self, context, element=None):
if updater.error is not None and updater.error_msg is not None:
row.label(text=updater.error_msg)
elif last_check:
last_check = last_check[0: last_check.index(".")]
last_check = last_check[0 : last_check.index(".")]
row.label(text="Last update check: " + last_check)
else:
row.label(text="Last update check: Never")
@ -1127,7 +1127,7 @@ def update_settings_ui_condensed(self, context, element=None):
return
settings = get_user_preferences(context)
if not settings:
row.label(text="Error getting updater preferences", icon='ERROR')
row.label(text="Error getting updater preferences", icon="ERROR")
return
# Special case to tell user to restart blender, if set that way.
@ -1138,7 +1138,8 @@ def update_settings_ui_condensed(self, context, element=None):
row.operator(
"wm.quit_blender",
text="Restart blender to complete update",
icon="ERROR")
icon="ERROR",
)
return
col = row.column()
@ -1149,16 +1150,13 @@ def update_settings_ui_condensed(self, context, element=None):
split.scale_y = 2
if "ssl" in updater.error_msg.lower():
split.enabled = True
split.operator(AddonUpdaterInstallManually.bl_idname,
text=updater.error)
split.operator(AddonUpdaterInstallManually.bl_idname, text=updater.error)
else:
split.enabled = False
split.operator(AddonUpdaterCheckNow.bl_idname,
text=updater.error)
split.operator(AddonUpdaterCheckNow.bl_idname, text=updater.error)
split = sub_col.split(align=True)
split.scale_y = 2
split.operator(AddonUpdaterCheckNow.bl_idname,
text="", icon="FILE_REFRESH")
split.operator(AddonUpdaterCheckNow.bl_idname, text="", icon="FILE_REFRESH")
elif updater.update_ready is None and not updater.async_checking:
col.scale_y = 2
@ -1174,9 +1172,11 @@ def update_settings_ui_condensed(self, context, element=None):
split.scale_y = 2
split.operator(AddonUpdaterEndBackground.bl_idname, text="", icon="X")
elif updater.include_branches and \
len(updater.tags) == len(updater.include_branch_list) and not \
updater.manual_only:
elif (
updater.include_branches
and len(updater.tags) == len(updater.include_branch_list)
and not updater.manual_only
):
# No releases found, but still show the appropriate branch.
sub_col = col.row(align=True)
sub_col.scale_y = 1
@ -1186,20 +1186,20 @@ def update_settings_ui_condensed(self, context, element=None):
split.operator(AddonUpdaterUpdateNow.bl_idname, text=now_txt)
split = sub_col.split(align=True)
split.scale_y = 2
split.operator(AddonUpdaterCheckNow.bl_idname,
text="", icon="FILE_REFRESH")
split.operator(AddonUpdaterCheckNow.bl_idname, text="", icon="FILE_REFRESH")
elif updater.update_ready and not updater.manual_only:
sub_col = col.row(align=True)
sub_col.scale_y = 1
split = sub_col.split(align=True)
split.scale_y = 2
split.operator(AddonUpdaterUpdateNow.bl_idname,
text="Update now to " + str(updater.update_version))
split.operator(
AddonUpdaterUpdateNow.bl_idname,
text="Update now to " + str(updater.update_version),
)
split = sub_col.split(align=True)
split.scale_y = 2
split.operator(AddonUpdaterCheckNow.bl_idname,
text="", icon="FILE_REFRESH")
split.operator(AddonUpdaterCheckNow.bl_idname, text="", icon="FILE_REFRESH")
elif updater.update_ready and updater.manual_only:
col.scale_y = 2
@ -1211,12 +1211,10 @@ def update_settings_ui_condensed(self, context, element=None):
split = sub_col.split(align=True)
split.enabled = False
split.scale_y = 2
split.operator(AddonUpdaterCheckNow.bl_idname,
text="Addon is up to date")
split.operator(AddonUpdaterCheckNow.bl_idname, text="Addon is up to date")
split = sub_col.split(align=True)
split.scale_y = 2
split.operator(AddonUpdaterCheckNow.bl_idname,
text="", icon="FILE_REFRESH")
split.operator(AddonUpdaterCheckNow.bl_idname, text="", icon="FILE_REFRESH")
row = element.row()
row.prop(settings, "auto_check_update")
@ -1227,7 +1225,7 @@ def update_settings_ui_condensed(self, context, element=None):
if updater.error is not None and updater.error_msg is not None:
row.label(text=updater.error_msg)
elif last_check != "" and last_check is not None:
last_check = last_check[0: last_check.index(".")]
last_check = last_check[0 : last_check.index(".")]
row.label(text="Last check: " + last_check)
else:
row.label(text="Last check: Never")
@ -1328,7 +1326,7 @@ classes = (
AddonUpdaterUpdatedSuccessful,
AddonUpdaterRestoreBackup,
AddonUpdaterIgnore,
AddonUpdaterEndBackground
AddonUpdaterEndBackground,
)
@ -1346,6 +1344,10 @@ def register(bl_info):
# updater.engine = "GitLab"
# updater.engine = "Bitbucket"
# set your host.
# only Forgejo. (codeberg.org is default if not specified).
updater.host = "git.pointer.click"
# If using private repository, indicate the token here.
# Must be set after assigning the engine.
# **WARNING** Depending on the engine, this token can act like a password!!
@ -1392,7 +1394,13 @@ def register(bl_info):
updater.backup_current = True # True by default
# Sample ignore patterns for when creating backup of current during update.
updater.backup_ignore_patterns = ["__pycache__"]
updater.backup_ignore_patterns = [
".git",
"__pycache__",
"*.bat",
".gitignore",
"*.exe",
]
# Alternate example patterns:
# updater.backup_ignore_patterns = [".git", "__pycache__", "*.bat", ".gitignore", "*.exe"]
@ -1461,7 +1469,7 @@ def register(bl_info):
# Note: updater.include_branch_list defaults to ['master'] branch if set to
# none. Example targeting another multiple branches allowed to pull from:
# updater.include_branch_list = ['master', 'dev']
updater.include_branch_list = ['main'] # None is the equivalent = ['master']
updater.include_branch_list = ["main", "dev"] # None is the equivalent = ['master']
# Only allow manual install, thus prompting the user to open
# the addon's web page to download, specifically: updater.website

View file

@ -1,64 +1,85 @@
import bpy
from bpy.props import (StringProperty,
BoolProperty,
EnumProperty,
IntProperty,
FloatProperty,
CollectionProperty)
from bpy.props import (
StringProperty,
BoolProperty,
EnumProperty,
IntProperty,
CollectionProperty,
)
from bpy.types import Operator
from bpy_extras.io_utils import ImportHelper, ExportHelper
from io_scene_gltf2 import ConvertGLTF2_Base
from bpy_extras.io_utils import ImportHelper
from io_scene_gltf2 import ConvertGLTF2_Base
import importlib
# then import dependencies for our addon
if "Font" in locals():
importlib.reload(Font)
else:
from .common import Font
pass
if "utils" in locals():
importlib.reload(utils)
else:
from .common import utils
try:
from io_scene_gltf2.io.imp.gltf2_io_gltf import glTFImporter, ImportError
from io_scene_gltf2.blender.imp.gltf2_blender_gltf import BlenderGlTF
from io_scene_gltf2.blender.imp.gltf2_blender_vnode import VNode, compute_vnodes
from io_scene_gltf2.blender.com.gltf2_blender_extras import set_extras
from io_scene_gltf2.blender.imp.gltf2_blender_node import BlenderNode
except (ModuleNotFoundError, ImportError):
from io_scene_gltf2.io.imp.gltf2_io_gltf import glTFImporter, ImportError
from io_scene_gltf2.blender.imp.blender_gltf import BlenderGlTF
from io_scene_gltf2.blender.imp.vnode import VNode, compute_vnodes
from io_scene_gltf2.blender.com.extras import set_extras
from io_scene_gltf2.blender.imp.node import BlenderNode
# taken from blender_git/blender/scripts/addons/io_scene_gltf2/__init__.py
# taken from blender_git/blender/scripts/addons/io_scene_gltf2/__init__.py
def get_font_faces_in_file(filepath):
from io_scene_gltf2.io.imp.gltf2_io_gltf import glTFImporter, ImportError
try:
import_settings = { 'import_user_extensions': [] }
import_settings = {"import_user_extensions": []}
gltf_importer = glTFImporter(filepath, import_settings)
gltf_importer.read()
gltf_importer.checks()
out = []
for node in gltf_importer.data.nodes:
if type(node.extras) != type(None) \
and "glyph" in node.extras \
and not ("type" in node.extras and node.extras["type"] == "metrics") \
and not (f"{utils.prefix()}_type" in node.extras and node.extras[f"{utils.prefix()}_type"] == "metrics"):
if (
type(node.extras) != type(None)
and "glyph" in node.extras
and not ("type" in node.extras and node.extras["type"] == "metrics")
and not (
f"{utils.prefix()}_type" in node.extras
and node.extras[f"{utils.prefix()}_type"] == "metrics"
)
):
out.append(node.extras)
return out
except ImportError as e:
except ImportError:
return None
# taken from blender_git/blender/scripts/addons/io_scene_gltf2/__init__.py
# taken from blender_git/blender/scripts/addons/io_scene_gltf2/__init__.py
class GetFontFacesInFile(Operator, ImportHelper):
"""Load a glTF 2.0 font and check which faces are in there"""
bl_idname = f"abc3d.check_font_gltf"
bl_label = 'Check glTF 2.0 Font'
bl_options = {'REGISTER', 'UNDO'}
bl_idname = "abc3d.check_font_gltf"
bl_label = "Check glTF 2.0 Font"
bl_options = {"REGISTER", "UNDO"}
files: CollectionProperty(
name="File Path",
type=bpy.types.OperatorFileListElement,
)
# bpy.ops.abc3d.check_font_gltf(filepath="/home/jrkb/.config/blender/4.1/datafiles/abc3d/fonts/JRKB_LOL.glb")
# bpy.ops.abc3d.check_font_gltf(filepath="/home/jrkb/.config/blender/4.1/datafiles/abc3d/fonts/JRKB_LOL.glb")
found_fonts = []
def execute(self, context):
@ -66,100 +87,109 @@ class GetFontFacesInFile(Operator, ImportHelper):
def check_gltf2(self, context):
import os
import sys
if self.files:
# Multiple file check
ret = {'CANCELLED'}
ret = {"CANCELLED"}
dirname = os.path.dirname(self.filepath)
for file in self.files:
path = os.path.join(dirname, file.name)
if self.unit_check(path) == {'FINISHED'}:
ret = {'FINISHED'}
if self.unit_check(path) == {"FINISHED"}:
ret = {"FINISHED"}
return ret
else:
# Single file check
return self.unit_check(self.filepath)
def unit_check(self, filename):
self.found_fonts.append(["LOL","WHATEVER"])
return {'FINISHED'}
self.found_fonts.append(["LOL", "WHATEVER"])
return {"FINISHED"}
class ImportGLTF2(Operator, ConvertGLTF2_Base, ImportHelper):
"""Load a glTF 2.0 font"""
bl_idname = f"abc3d.import_font_gltf"
bl_label = 'Import glTF 2.0 Font'
bl_options = {'REGISTER', 'UNDO'}
filter_glob: StringProperty(default="*.glb;*.gltf", options={'HIDDEN'})
bl_idname = "abc3d.import_font_gltf"
bl_label = "Import glTF 2.0 Font"
bl_options = {"REGISTER", "UNDO"}
filter_glob: StringProperty(default="*.glb;*.gltf", options={"HIDDEN"})
files: CollectionProperty(
name="File Path",
type=bpy.types.OperatorFileListElement,
)
loglevel: IntProperty(
name='Log Level',
description="Log Level")
loglevel: IntProperty(name="Log Level", description="Log Level")
import_pack_images: BoolProperty(
name='Pack Images',
description='Pack all images into .blend file',
default=True
name="Pack Images", description="Pack all images into .blend file", default=True
)
merge_vertices: BoolProperty(
name='Merge Vertices',
name="Merge Vertices",
description=(
'The glTF format requires discontinuous normals, UVs, and '
'other vertex attributes to be stored as separate vertices, '
'as required for rendering on typical graphics hardware. '
'This option attempts to combine co-located vertices where possible. '
'Currently cannot combine verts with different normals'
"The glTF format requires discontinuous normals, UVs, and "
"other vertex attributes to be stored as separate vertices, "
"as required for rendering on typical graphics hardware. "
"This option attempts to combine co-located vertices where possible. "
"Currently cannot combine verts with different normals"
),
default=False,
)
import_shading: EnumProperty(
name="Shading",
items=(("NORMALS", "Use Normal Data", ""),
("FLAT", "Flat Shading", ""),
("SMOOTH", "Smooth Shading", "")),
items=(
("NORMALS", "Use Normal Data", ""),
("FLAT", "Flat Shading", ""),
("SMOOTH", "Smooth Shading", ""),
),
description="How normals are computed during import",
default="NORMALS")
default="NORMALS",
)
bone_heuristic: EnumProperty(
name="Bone Dir",
items=(
("BLENDER", "Blender (best for import/export round trip)",
(
"BLENDER",
"Blender (best for import/export round trip)",
"Good for re-importing glTFs exported from Blender, "
"and re-exporting glTFs to glTFs after Blender editing. "
"Bone tips are placed on their local +Y axis (in glTF space)"),
("TEMPERANCE", "Temperance (average)",
"Bone tips are placed on their local +Y axis (in glTF space)",
),
(
"TEMPERANCE",
"Temperance (average)",
"Decent all-around strategy. "
"A bone with one child has its tip placed on the local axis "
"closest to its child"),
("FORTUNE", "Fortune (may look better, less accurate)",
"closest to its child",
),
(
"FORTUNE",
"Fortune (may look better, less accurate)",
"Might look better than Temperance, but also might have errors. "
"A bone with one child has its tip placed at its child's root. "
"Non-uniform scalings may get messed up though, so beware"),
"Non-uniform scalings may get messed up though, so beware",
),
),
description="Heuristic for placing bones. Tries to make bones pretty",
default="BLENDER",
)
guess_original_bind_pose: BoolProperty(
name='Guess Original Bind Pose',
name="Guess Original Bind Pose",
description=(
'Try to guess the original bind pose for skinned meshes from '
'the inverse bind matrices. '
'When off, use default/rest pose as bind pose'
"Try to guess the original bind pose for skinned meshes from "
"the inverse bind matrices. "
"When off, use default/rest pose as bind pose"
),
default=True,
)
import_webp_texture: BoolProperty(
name='Import WebP textures',
name="Import WebP textures",
description=(
"If a texture exists in WebP format, "
"loads the WebP texture instead of the fallback PNG/JPEG one"
@ -168,7 +198,7 @@ class ImportGLTF2(Operator, ConvertGLTF2_Base, ImportHelper):
)
glyphs: StringProperty(
name='Import only these glyphs',
name="Import only these glyphs",
description=(
"Loading glyphs is expensive, if the meshes are huge"
"So we can filter all glyphs out that we do not want"
@ -197,25 +227,32 @@ class ImportGLTF2(Operator, ConvertGLTF2_Base, ImportHelper):
layout.use_property_split = True
layout.use_property_decorate = False # No animation.
layout.prop(self, 'import_pack_images')
layout.prop(self, 'merge_vertices')
layout.prop(self, 'import_shading')
layout.prop(self, 'guess_original_bind_pose')
layout.prop(self, 'bone_heuristic')
layout.prop(self, 'export_import_convert_lighting_mode')
layout.prop(self, 'import_webp_texture')
layout.prop(self, "import_pack_images")
layout.prop(self, "merge_vertices")
layout.prop(self, "import_shading")
layout.prop(self, "guess_original_bind_pose")
layout.prop(self, "bone_heuristic")
layout.prop(self, "export_import_convert_lighting_mode")
layout.prop(self, "import_webp_texture")
def invoke(self, context, event):
import sys
preferences = bpy.context.preferences
for addon_name in preferences.addons.keys():
try:
if hasattr(sys.modules[addon_name], 'glTF2ImportUserExtension') or hasattr(sys.modules[addon_name], 'glTF2ImportUserExtensions'):
importer_extension_panel_unregister_functors.append(sys.modules[addon_name].register_panel())
if hasattr(
sys.modules[addon_name], "glTF2ImportUserExtension"
) or hasattr(sys.modules[addon_name], "glTF2ImportUserExtensions"):
importer_extension_panel_unregister_functors.append(
sys.modules[addon_name].register_panel()
)
except Exception:
pass
self.has_active_importer_extensions = len(importer_extension_panel_unregister_functors) > 0
self.has_active_importer_extensions = (
len(importer_extension_panel_unregister_functors) > 0
)
return ImportHelper.invoke(self, context, event)
def execute(self, context):
@ -230,25 +267,26 @@ class ImportGLTF2(Operator, ConvertGLTF2_Base, ImportHelper):
user_extensions = []
import sys
preferences = bpy.context.preferences
for addon_name in preferences.addons.keys():
try:
module = sys.modules[addon_name]
except Exception:
continue
if hasattr(module, 'glTF2ImportUserExtension'):
if hasattr(module, "glTF2ImportUserExtension"):
extension_ctor = module.glTF2ImportUserExtension
user_extensions.append(extension_ctor())
import_settings['import_user_extensions'] = user_extensions
import_settings["import_user_extensions"] = user_extensions
if self.files:
# Multiple file import
ret = {'CANCELLED'}
ret = {"CANCELLED"}
dirname = os.path.dirname(self.filepath)
for file in self.files:
path = os.path.join(dirname, file.name)
if self.unit_import(path, import_settings) == {'FINISHED'}:
ret = {'FINISHED'}
if self.unit_import(path, import_settings) == {"FINISHED"}:
ret = {"FINISHED"}
return ret
else:
# Single file import
@ -256,11 +294,6 @@ class ImportGLTF2(Operator, ConvertGLTF2_Base, ImportHelper):
def unit_import(self, filename, import_settings):
import time
from io_scene_gltf2.io.imp.gltf2_io_gltf import glTFImporter, ImportError
from io_scene_gltf2.blender.imp.gltf2_blender_gltf import BlenderGlTF
from io_scene_gltf2.blender.imp.gltf2_blender_vnode import VNode, compute_vnodes
from io_scene_gltf2.blender.com.gltf2_blender_extras import set_extras
from io_scene_gltf2.blender.imp.gltf2_blender_node import BlenderNode
try:
gltf = glTFImporter(filename, import_settings)
@ -308,18 +341,31 @@ class ImportGLTF2(Operator, ConvertGLTF2_Base, ImportHelper):
# indeed representing a glyph we want
for node in gltf.data.nodes:
# :-O woah
if type(node.extras) != type(None) \
and "glyph" in node.extras \
and (node.extras["glyph"] in self.glyphs \
or len(self.glyphs) == 0) \
and (self.font_name == "" or \
( "font_name" in node.extras \
and (node.extras["font_name"] in self.font_name \
or len(self.glyphs) == 0))) \
and (self.face_name == "" or \
( "face_name" in node.extras \
and (node.extras["face_name"] in self.face_name \
or len(self.glyphs) == 0))):
if (
type(node.extras) != type(None)
and "glyph" in node.extras
and (node.extras["glyph"] in self.glyphs or len(self.glyphs) == 0)
and (
self.font_name == ""
or (
"font_name" in node.extras
and (
node.extras["font_name"] in self.font_name
or len(self.glyphs) == 0
)
)
)
and (
self.face_name == ""
or (
"face_name" in node.extras
and (
node.extras["face_name"] in self.face_name
or len(self.glyphs) == 0
)
)
)
):
# if there is a match, add the node incl children ..
add_node(node)
# .. and their parents recursively
@ -355,7 +401,7 @@ class ImportGLTF2(Operator, ConvertGLTF2_Base, ImportHelper):
# and some have different indices
for node in nodes:
if type(node.children) != type(None):
children = [] # brand new children
children = [] # brand new children
for i, c in enumerate(node.children):
# check if children are lost
if c in node_indices:
@ -399,23 +445,26 @@ class ImportGLTF2(Operator, ConvertGLTF2_Base, ImportHelper):
vnode = gltf.vnodes[vi]
if vnode.type == VNode.Object:
if vnode.parent is not None:
if not hasattr(gltf.vnodes[vnode.parent],
"blender_object"):
create_blender_object(gltf,
vnode.parent,
nodes)
if not hasattr(vnode,
"blender_object"):
if not hasattr(gltf.vnodes[vnode.parent], "blender_object"):
create_blender_object(gltf, vnode.parent, nodes)
if not hasattr(vnode, "blender_object"):
obj = BlenderNode.create_object(gltf, vi)
obj["font_import"] = True
n_vars = vars(nodes[vi])
if "extras" in n_vars:
set_extras(obj, n_vars["extras"])
if "glyph" in n_vars["extras"] and \
not ("type" in n_vars["extras"] and \
n_vars["extras"]["type"] == "metrics") and \
not (f"{utils.prefix()}_type" in n_vars["extras"] and \
n_vars["extras"][f"{utils.prefix()}_type"] == "metrics"):
if (
"glyph" in n_vars["extras"]
and not (
"type" in n_vars["extras"]
and n_vars["extras"]["type"] == "metrics"
)
and not (
f"{utils.prefix()}_type" in n_vars["extras"]
and n_vars["extras"][f"{utils.prefix()}_type"]
== "metrics"
)
):
obj["type"] = "glyph"
for vi, vnode in gltf.vnodes.items():
@ -432,14 +481,15 @@ class ImportGLTF2(Operator, ConvertGLTF2_Base, ImportHelper):
if hasattr(gltf.log.logger, "removeHandler"):
gltf.log.logger.removeHandler(gltf.log_handler)
return {'FINISHED'}
return {"FINISHED"}
except ImportError as e:
self.report({'ERROR'}, e.args[0])
return {'CANCELLED'}
self.report({"ERROR"}, e.args[0])
return {"CANCELLED"}
def set_debug_log(self):
import logging
if bpy.app.debug_value == 0:
self.loglevel = logging.CRITICAL
elif bpy.app.debug_value == 1:

2336
butils.py

File diff suppressed because it is too large Load diff

View file

@ -1,68 +1,68 @@
from typing import TypedDict
from typing import Dict
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, NamedTuple
# convenience dictionary for translating names to glyph ids
# note: overwritten/extended by the content of "glypNamesToUnicode.txt"
# when addon is registered in __init__.py
name_to_glyph_d = {
"zero": "0",
"one": "1",
"two": "2",
"three": "3",
"four": "4",
"five": "5",
"six": "6",
"seven": "7",
"eight": "8",
"nine": "9",
"ampersand": "&",
"backslash": "\\",
"colon": ":",
"comma": ",",
"equal": "=",
"exclam": "!",
"hyphen": "-",
"minus": "",
"parenleft": "(",
"parenright": "(",
"period": ".",
"plus": "+",
"question": "?",
"quotedblleft": "",
"quotedblright": "",
"semicolon": ";",
"slash": "/",
"space": " ",
}
"zero": "0",
"one": "1",
"two": "2",
"three": "3",
"four": "4",
"five": "5",
"six": "6",
"seven": "7",
"eight": "8",
"nine": "9",
"ampersand": "&",
"backslash": "\\",
"colon": ":",
"comma": ",",
"equal": "=",
"exclam": "!",
"hyphen": "-",
"minus": "",
"parenleft": "(",
"parenright": "(",
"period": ".",
"plus": "+",
"question": "?",
"quotedblleft": "",
"quotedblright": "",
"semicolon": ";",
"slash": "/",
"space": " ",
}
space_d = {}
known_misspellings = {
# simple misspelling
"excent" : "accent",
"overdot" : "dotaccent",
"diaresis": "dieresis",
"diaeresis": "dieresis",
# character does not exist.. maybe something else
"Odoubleacute": "Ohungarumlaut",
"Udoubleacute": "Uhungarumlaut",
"Wcaron": "Wcircumflex",
"Neng": "Nlongrightleg",
"Lgrave": "Lacute",
# currency stuff
"doller": "dollar",
"euro": "Euro",
"yuan": "yen", # https://en.wikipedia.org/wiki/Yen_and_yuan_sign
"pound": "sterling",
# whoopsie
"__": "_",
}
# simple misspelling
"excent": "accent",
"overdot": "dotaccent",
"diaresis": "dieresis",
"diaeresis": "dieresis",
# different conventions
"doubleacute": "hungarumlaut",
# character does not exist.. maybe something else
"Wcaron": "Wcircumflex",
"Neng": "Nlongrightleg",
"Lgrave": "Lacute",
# currency stuff
"doller": "dollar",
"euro": "Euro",
"yuan": "yen", # https://en.wikipedia.org/wiki/Yen_and_yuan_sign
"pound": "sterling",
# whoopsie
"__": "_",
}
def fix_glyph_name_misspellings(name):
for misspelling in known_misspellings:
if misspelling in name:
return name.replace(misspelling,
known_misspellings[misspelling])
return name.replace(misspelling, known_misspellings[misspelling])
return name
@ -74,18 +74,56 @@ def name_to_glyph(name):
else:
return None
def generate_name_to_glyph_d():
def glyph_to_name(glyph_id):
for k in name_to_glyph_d:
if glyph_id == name_to_glyph_d[k]:
return k
return glyph_id
def is_space(character):
for name in space_d:
if character == space_d[name][0]:
return space_d[name][1]
return False
def generate_from_file_d(filepath):
d = {}
with open(f"{Path(__file__).parent}/glyphNamesToUnicode.txt") as f:
with open(filepath) as f:
for line in f:
if line[0] == '#':
if line[0] == "#":
continue
(name, hexstr) = line.split(' ')
val = chr(int(hexstr, base=16))
d[name] = val
split = line.split(" ")
if len(split) == 2:
(name, hexstr) = line.split(" ")
val = chr(int(hexstr, base=16))
d[name] = val
if len(split) == 3:
# we might have a parameter, like for the spaces
(name, hexstr, parameter) = line.split(" ")
parameter_value = float(parameter)
val = chr(int(hexstr, base=16))
d[name] = [val, parameter_value]
return d
def generate_name_to_glyph_d():
return generate_from_file_d(f"{Path(__file__).parent}/glyphNamesToUnicode.txt")
def generate_space_d():
return generate_from_file_d(f"{Path(__file__).parent}/spacesUnicode.txt")
def init():
global name_to_glyph_d
global space_d
name_to_glyph_d = generate_name_to_glyph_d()
space_d = generate_space_d()
class FontFace:
"""FontFace is a class holding glyphs
@ -98,8 +136,8 @@ class FontFace:
:param filenames: from which file is this face
:type filenames: List[str]
"""
def __init__(self,
glyphs = {}):
def __init__(self, glyphs={}):
self.glyphs = glyphs
# lists have to be initialized in __init__
# to be attributes per instance.
@ -110,34 +148,65 @@ class FontFace:
self.filepaths = []
self.unit_factor = 1.0
class Font:
"""Font holds the faces and various metadata for a font
:param faces: dictionary of faces, defaults to ``Dict[str, FontFace]``
:type faces: Dict[str, FontFace]
"""
def __init__(self, faces = Dict[str, FontFace]):
def __init__(self, faces=Dict[str, FontFace]):
self.faces = faces
# TODO: better class structure?
# TODO: get fonts and faces directly
def register_font(font_name, face_name, glyphs_in_fontfile, filepath):
if not fonts.keys().__contains__(font_name):
fonts[font_name] = Font({})
if fonts[font_name].faces.get(face_name) == None:
if fonts[font_name].faces.get(face_name) is None:
fonts[font_name].faces[face_name] = FontFace({})
fonts[font_name].faces[face_name].glyphs_in_fontfile = glyphs_in_fontfile
else:
fonts[font_name].faces[face_name].glyphs_in_fontfile = \
list(set(fonts[font_name].faces[face_name].glyphs_in_fontfile + glyphs_in_fontfile))
fonts[font_name].faces[face_name].glyphs_in_fontfile = list(
set(
fonts[font_name].faces[face_name].glyphs_in_fontfile
+ glyphs_in_fontfile
)
)
if filepath not in fonts[font_name].faces[face_name].filepaths:
fonts[font_name].faces[face_name].filepaths.append(filepath)
def get_font(font_name):
if not fonts.keys().__contains__(font_name):
print(f"ABC3D::get_font: font name({font_name}) not found")
print(fonts.keys())
return None
return fonts[font_name]
def get_font_face(font_name, face_name):
font = get_font(font_name)
if font is None:
return None
if not font.faces.keys().__contains__(face_name):
print(
f"ABC3D::get_font_face (font: {font_name}): face name({face_name}) not found"
)
print(font.faces.keys())
return None
return font.faces[face_name]
def get_font_face_filepaths(font_name, face_name):
face = get_font_face(font_name, face_name)
if not face:
return None
return face.filepaths
def add_glyph(font_name, face_name, glyph_id, glyph_object):
""" add_glyph adds a glyph to a FontFace
"""add_glyph adds a glyph to a FontFace
it creates the :class:`Font` and :class:`FontFace` if it does not exist yet
:param font_name: The Font you want to add the glyph to
@ -152,18 +221,18 @@ def add_glyph(font_name, face_name, glyph_id, glyph_object):
if not fonts.keys().__contains__(font_name):
fonts[font_name] = Font({})
if fonts[font_name].faces.get(face_name) == None:
if fonts[font_name].faces.get(face_name) is None:
fonts[font_name].faces[face_name] = FontFace({})
if fonts[font_name].faces[face_name].glyphs.get(glyph_id) == None:
if fonts[font_name].faces[face_name].glyphs.get(glyph_id) is None:
fonts[font_name].faces[face_name].glyphs[glyph_id] = []
fonts[font_name].faces[face_name].glyphs.get(glyph_id).append(glyph_object)
if glyph_id not in fonts[font_name].faces[face_name].loaded_glyphs:
fonts[font_name].faces[face_name].loaded_glyphs.append(glyph_id)
def get_glyph(font_name, face_name, glyph_id, alternate=0):
""" add_glyph adds a glyph to a FontFace
it creates the :class:`Font` and :class:`FontFace` if it does not exist yet
def get_glyphs(font_name, face_name, glyph_id):
"""get_glyphs returns an array of glyphs of a FontFace
:param font_name: The :class:`Font` you want to get the glyph from
:type font_name: str
@ -172,78 +241,150 @@ def get_glyph(font_name, face_name, glyph_id, alternate=0):
:param glyph_id: The ``glyph_id`` from the glyph you want
:type glyph_id: str
...
:return: returns a list of the glyph objects, or an empty list if none exists
:rtype: `List`
"""
face = get_font_face(font_name, face_name)
if face is None:
print(f"ABC3D::get_glyph: font({font_name}) face({face_name}) not found")
try:
print(fonts[font_name].faces.keys())
except:
print(fonts.keys())
return []
glyphs_for_id = face.glyphs.get(glyph_id)
if glyphs_for_id is None:
print(
f"ABC3D::get_glyph: font({font_name}) face({face_name}) glyph({glyph_id}) not found"
)
if glyph_id not in fonts[font_name].faces[face_name].missing_glyphs:
fonts[font_name].faces[face_name].missing_glyphs.append(glyph_id)
return []
return glyphs_for_id
def get_glyph(
font_name: str,
face_name: str,
glyph_id: str,
alternate: int = 0,
alternate_tolerant: bool = True,
):
"""add_glyph adds a glyph to a FontFace
it creates the :class:`Font` and :class:`FontFace` if it does not exist yet
:param font_name: The :class:`Font` you want to get the glyph from
:type font_name: str
:param face_name: The :class:`FontFace` you want to get the glyph from
:type face_name: str
:param glyph_id: The ``glyph_id`` from the glyph you want
:type glyph_id: str
:param alternate: The ``alternate`` from the glyph you want
:type alternate: int
:param alternate_tolerant: Fetch an existing alternate if requested is out of bounds
:type glyph_id: bool
...
:return: returns the glyph object, or ``None`` if it does not exist
:rtype: `Object`
"""
if not fonts.keys().__contains__(font_name):
print(f"ABC3D::get_glyph: font name({font_name}) not found")
print(fonts.keys())
glyphs = get_glyphs(font_name, face_name, glyph_id)
if len(glyphs) == 0:
print(
f"ABC3D::get_glyph: font({font_name}) face({face_name}) glyph({glyph_id})[{alternate}] not found"
)
return None
face = fonts[font_name].faces.get(face_name)
if face == None:
if len(glyphs) <= alternate:
if alternate_tolerant:
alternate = 0
else:
print(
f"ABC3D::get_glyph: font({font_name}) face({face_name}) glyph({glyph_id})[{alternate}] not found"
)
return None
return glyphs[alternate]
def unloaded_glyph(font_name, face_name, glyph_id):
face = get_font_face(font_name, face_name)
if face is None:
print(f"ABC3D::get_glyph: font({font_name}) face({face_name}) not found")
print(fonts[font_name].faces.keys())
return None
return
while True:
try:
fonts[font_name].faces[face_name].loaded_glyphs.remove(glyph_id)
del fonts[font_name].faces[face_name].glyphs[glyph_id]
except ValueError:
break
class GlyphsAvailability(NamedTuple):
loaded: str
missing: str
unloaded: str
filepaths: list[str]
glyphs_for_id = face.glyphs.get(glyph_id)
if glyphs_for_id == None or len(glyphs_for_id) <= alternate:
print(f"ABC3D::get_glyph: font({font_name}) face({face_name}) glyph({glyph_id})[{alternate}] not found")
if glyph_id not in fonts[font_name].faces[face_name].missing_glyphs:
fonts[font_name].faces[face_name].missing_glyphs.append(glyph_id)
return None
return fonts[font_name].faces[face_name].glyphs.get(glyph_id)[alternate]
def test_glyphs_availability(font_name, face_name, text):
# maybe there is NOTHING yet
if not fonts.keys().__contains__(font_name) or \
fonts[font_name].faces.get(face_name) == None:
return "", "", text # <loaded>, <missing>, <maybe>
if (
not fonts.keys().__contains__(font_name)
or fonts[font_name].faces.get(face_name) is None
):
return GlyphsAvailability("", "", "", [])
loaded = []
missing = []
maybe = []
unloaded = []
for c in text:
if c in fonts[font_name].faces[face_name].loaded_glyphs:
loaded.append(c)
elif c in fonts[font_name].faces[face_name].glyphs_in_fontfile:
maybe.append(c)
unloaded.append(c)
else:
if c not in fonts[font_name].faces[face_name].missing_glyphs:
fonts[font_name].faces[face_name].missing_glyphs.append(c)
missing.append(c)
return ''.join(loaded), ''.join(missing), ''.join(maybe), fonts[font_name].faces[face_name].filepaths
return GlyphsAvailability(
"".join(loaded),
"".join(missing),
"".join(unloaded),
fonts[font_name].faces[face_name].filepaths,
)
def get_loaded_fonts():
return fonts.keys()
def get_loaded_fonts_and_faces():
out = []
for f in fonts.keys():
for ff in fonts[f].faces.keys():
out.append([f,ff])
out.append([f, ff])
return out
MISSING_FONT = 0
MISSING_FACE = 1
def test_availability(font_name, face_name, text):
if not fonts.keys().__contains__(font_name):
return MISSING_FONT
if fonts[font_name].faces.get(face_name) == None:
if fonts[font_name].faces.get(face_name) is None:
return MISSING_FACE
loaded, missing, maybe, filepaths = test_glyphs_availability(font_name,
face_name,
text)
return {
"loaded": loaded,
"missing": missing,
"maybe": maybe,
"filepaths": filepaths,
}
availability: GlyphsAvailability = test_glyphs_availability(
font_name, face_name, text
)
return availability
# holds all fonts
fonts = {}

23
common/spacesUnicode.txt Normal file
View file

@ -0,0 +1,23 @@
# The space value derives from The Elements of Typographic Style
# same for en-/em values. Rest are rough guesses.
space 0020 0.25
nbspace 00A0 0.25
# ethi:wordspace 1361 # NOTE: has shape
enquad 2000 0.5
emquad 2001 1.0
enspace 2002 0.5
emspace 2003 1.0
threeperemspace 2004 3.0
fourperemspace 2005 4.0
sixperemspace 2006 6.0
figurespace 2007 1.0
punctuationspace 2008 1.0
thinspace 2009 0.1
hairspace 200A 0.05
zerowidthspace 200B 0.0
narrownobreakspace 202F 0.1
mediummathematicalspace 205F 1.0
cntr:space 2420 0.25
ideographicspace 3000 1.0
# ideographichalffillspace 303F # NOTE: has shape
zerowidthnobreakspace FEFF 0.0

View file

@ -1,25 +1,31 @@
# NOTE: also change version in ../__init__.py
def get_version_major():
return 0
def get_version_minor():
return 0
def get_version_patch():
return 2
return 12
def get_version_string():
return f"{get_version_major()}.{get_version_minor()}.{get_version_patch}"
return f"{get_version_major()}.{get_version_minor()}.{get_version_patch()}"
def prefix():
return "ABC3D"
import time
import datetime
from mathutils import (
Vector,
)
import time
def get_timestamp():
return datetime.datetime \
.fromtimestamp(time.time()) \
.strftime('%Y.%m.%d-%H:%M:%S')
return datetime.datetime.fromtimestamp(time.time()).strftime("%Y.%m.%d-%H:%M:%S")
def mapRange(in_value, in_min, in_max, out_min, out_max, clamp=False):
output = out_min + ((out_max - out_min) / (in_max - in_min)) * (in_value - in_min)
@ -32,65 +38,108 @@ def mapRange(in_value, in_min, in_max, out_min, out_max, clamp=False):
return output
import warnings
import functools
import warnings
def deprecated(func):
"""This is a decorator which can be used to mark functions
as deprecated. It will result in a warning being emitted
when the function is used."""
@functools.wraps(func)
def new_func(*args, **kwargs):
warnings.simplefilter('always', DeprecationWarning) # turn off filter
warnings.warn("Call to deprecated function {}.".format(func.__name__),
category=DeprecationWarning,
stacklevel=2)
warnings.simplefilter('default', DeprecationWarning) # reset filter
warnings.simplefilter("always", DeprecationWarning) # turn off filter
warnings.warn(
"Call to deprecated function {}.".format(func.__name__),
category=DeprecationWarning,
stacklevel=2,
)
warnings.simplefilter("default", DeprecationWarning) # reset filter
return func(*args, **kwargs)
return new_func
import subprocess
import sys
def open_file_browser(directory):
if sys.platform=='win32':
if sys.platform == "win32":
os.startfile(directory)
elif sys.platform=='darwin':
subprocess.Popen(['open', directory])
elif sys.platform == "darwin":
subprocess.Popen(["open", directory])
else:
try:
subprocess.Popen(['xdg-open', directory])
subprocess.Popen(["xdg-open", directory])
except OSError:
pass
# er, think of something else to try
# xdg-open *should* be supported by recent Gnome, KDE, Xfce
def LINE():
return sys._getframe(1).f_lineno
def printerr(*args, **kwargs):
print(*args, file=sys.stderr, **kwargs)
def removeNonAlphabetic(s):
return "".join([i for i in s if i.isalpha()])
import os
import pathlib
def can_create_path(path_str: str):
path = pathlib.Path(path_str).absolute().resolve()
tries = 0
maximum_tries = 1000
while True:
if path.exists():
if os.access(path, os.W_OK):
return True
else:
return False
elif path == path.parent:
# should never be reached, because root exists
# but if it doesn't.. well then we can't
return False
path = path.parent
tries += 1
if tries > maximum_tries:
# always, always break out of while loops eventually
# IF you don't want to be here forever
break
# # Evaluate a bezier curve for the parameter 0<=t<=1 along its length
# def evaluateBezierPoint(p1, h1, h2, p2, t):
# return ((1 - t)**3) * p1 + (3 * t * (1 - t)**2) * h1 + (3 * (t**2) * (1 - t)) * h2 + (t**3) * p2
# return ((1 - t)**3) * p1 + (3 * t * (1 - t)**2) * h1 + (3 * (t**2) * (1 - t)) * h2 + (t**3) * p2
# # Evaluate the unit tangent on a bezier curve for t
# def evaluateBezierTangent(p1, h1, h2, p2, t):
# return (
# (-3 * (1 - t)**2) * p1 + (-6 * t * (1 - t) + 3 * (1 - t)**2) * h1 +
# (-3 * (t**2) + 6 * t * (1 - t)) * h2 + (3 * t**2) * p2
# ).normalized()
# return (
# (-3 * (1 - t)**2) * p1 + (-6 * t * (1 - t) + 3 * (1 - t)**2) * h1 +
# (-3 * (t**2) + 6 * t * (1 - t)) * h2 + (3 * t**2) * p2
# ).normalized()
# def calculateBezierLength(p1, h1, h2, p2, resolution=20):
# step = 1/resolution
# previous_p = p1
# length = 0
# for i in range(0, resolution):
# t = (i + 1) * step
# p = evaluateBezierPoint(p1, h1, h2, p2, t)
# length += p.distance(previous_p)
# previous_p = p
# return length
# step = 1/resolution
# previous_p = p1
# length = 0
# for i in range(0, resolution):
# t = (i + 1) * step
# p = evaluateBezierPoint(p1, h1, h2, p2, t)
# length += p.distance(previous_p)
# previous_p = p
# return length

39
requirements.txt Normal file
View file

@ -0,0 +1,39 @@
asttokens==3.0.0
attrs==25.3.0
bpy==4.4.0
cattrs==24.1.3
certifi==2025.4.26
charset-normalizer==3.4.2
Cython==3.1.1
decorator==5.2.1
docstring-to-markdown==0.17
executing==2.2.0
idna==3.10
importlib_metadata==8.7.0
ipython==9.2.0
ipython_pygments_lexers==1.1.1
jedi==0.19.2
jedi-language-server==0.45.1
lsprotocol==2023.0.1
mathutils==3.3.0
matplotlib-inline==0.1.7
numpy==1.26.4
parso==0.8.4
pexpect==4.9.0
pluggy==1.6.0
prompt_toolkit==3.0.51
ptyprocess==0.7.0
pure_eval==0.2.3
pygls==1.3.1
Pygments==2.19.1
python-jsonrpc-server==0.4.0
python-lsp-jsonrpc==1.1.2
requests==2.32.3
stack-data==0.6.3
traitlets==5.14.3
typing_extensions==4.13.2
ujson==5.10.0
urllib3==2.4.0
wcwidth==0.2.13
zipp==3.22.0
zstandard==0.23.0

View file

@ -0,0 +1,25 @@
import bpy
from mathutils import *
from math import *
import abc3d.butils
v = 0
goal = 5.0
step = 0.1
speed = 1.0
C = bpy.context
obj = C.scene.objects['Cube']
curve = C.scene.objects['BézierCurve']
m = curve.matrix
def fun(distance):
obj.location = m @ abc3d.butils.calc_point_on_bezier_curve(curve,
distance,
output_tangent=True)
print(f"executed {distance}")
while v < goal:
bpy.app.timers.register(lambda: fun(v), first_interval=(v * speed))
v += step

View file

@ -0,0 +1,115 @@
import bpy
import abc3d
from abc3d import butils
from abc3d.common import Font
def get_text_properties_by_mom(mom):
scene = bpy.context.scene
abc3d_data = scene.abc3d_data
for text_properties in abc3d_data.available_texts:
if mom == text_properties.text_object:
return text_properties
return None
def isolate_objects(objects):
for area in bpy.context.window.screen.areas:
if area.type == "VIEW_3D":
with bpy.context.temp_override(
selected_objects=list(objects),
area=area,
refgion=[region for region in area.regions if region.type == "WINDOW"][
0
],
screen=bpy.context.window.screen,
):
# bpy.ops.view3d.view_selected()
bpy.ops.view3d.localview(frame_selected=True)
break
def main():
# create a curve
bpy.ops.curve.primitive_bezier_curve_add(
radius=1,
enter_editmode=False,
align="WORLD",
location=(0, 0, 0),
scale=(1, 1, 1),
)
# new curve is active object
mom = bpy.context.active_object
# make sure
print(f"MOM: {mom.name}")
fonts = Font.get_loaded_fonts_and_faces()
if len(fonts) == 0:
print("no fonts! what?")
return
font_name = fonts[0][0]
face_name = fonts[0][1]
font = f"{font_name} {face_name}"
isolate_objects([mom])
bpy.ops.abc3d.placetext(
font_name=font_name,
face_name=face_name,
font=font,
text="SOMETHING SOMETHING BROKEN ARMS",
letter_spacing=0,
font_size=1,
offset=0,
translation=(0, 0, 0),
orientation=(1.5708, 0, 0),
)
def change_text(font_name="", face_name="", text=""):
print(f"change_text to '{text}'")
text_properties = get_text_properties_by_mom(mom)
if font_name != "":
text_properties["font_name"] = font_name
if face_name != "":
text_properties["face_name"] = face_name
if text != "":
text_properties.text = text
else:
text_properties.text = text_properties.text
return None
def unload(glyph_id):
print(f"unload glyph '{glyph_id}'")
butils.unload_unused_glyph(font_name, face_name, glyph_id)
return None
def unload_all():
print(f"unload glyph all unused glyphs")
butils.unload_unused_glyphs()
return None
bpy.app.timers.register(lambda: change_text(text="SOMETHING"), first_interval=0)
bpy.app.timers.register(lambda: change_text(text="LOLSS"), first_interval=2)
bpy.app.timers.register(lambda: change_text(text="LOLAA"), first_interval=3)
bpy.app.timers.register(lambda: change_text(text="WHAT"), first_interval=4)
bpy.app.timers.register(lambda: change_text(text="LOL"), first_interval=5)
bpy.app.timers.register(lambda: unload("A"), first_interval=10)
bpy.app.timers.register(lambda: unload_all(), first_interval=12)
bpy.app.timers.register(lambda: change_text(text="LOLM"), first_interval=16)
bpy.app.timers.register(lambda: change_text(text="ZHE END"), first_interval=20)
bpy.app.timers.register(
lambda: change_text(font_name="NM_Origin", face_name="Tender"),
first_interval=30,
)
bpy.app.timers.register(lambda: unload_all(), first_interval=42)
main()