361 lines
9.5 KiB
Python
361 lines
9.5 KiB
Python
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
|
|
import bpy
|
|
import math
|
|
import collections
|
|
import typing
|
|
|
|
from abc import ABC
|
|
from itertools import tee, chain, islice, repeat, permutations
|
|
from mathutils import Vector, Matrix, Color
|
|
from rna_prop_ui import rna_idprop_value_to_python
|
|
|
|
|
|
T = typing.TypeVar('T')
|
|
IdType = typing.TypeVar('IdType', bound=bpy.types.ID)
|
|
|
|
AnyVector = Vector | typing.Sequence[float]
|
|
|
|
##############################################
|
|
# Math
|
|
##############################################
|
|
|
|
axis_vectors = {
|
|
'x': (1, 0, 0),
|
|
'y': (0, 1, 0),
|
|
'z': (0, 0, 1),
|
|
'-x': (-1, 0, 0),
|
|
'-y': (0, -1, 0),
|
|
'-z': (0, 0, -1),
|
|
}
|
|
|
|
|
|
# Matrices that reshuffle axis order and/or invert them
|
|
shuffle_matrix = {
|
|
sx+x+sy+y+sz+z: Matrix((
|
|
axis_vectors[sx+x], axis_vectors[sy+y], axis_vectors[sz+z]
|
|
)).transposed().freeze()
|
|
for x, y, z in permutations(['x', 'y', 'z'])
|
|
for sx in ('', '-')
|
|
for sy in ('', '-')
|
|
for sz in ('', '-')
|
|
}
|
|
|
|
|
|
def angle_on_plane(plane: Vector, vec1: Vector, vec2: Vector):
|
|
""" Return the angle between two vectors projected onto a plane.
|
|
"""
|
|
plane.normalize()
|
|
vec1 = vec1 - (plane * (vec1.dot(plane)))
|
|
vec2 = vec2 - (plane * (vec2.dot(plane)))
|
|
vec1.normalize()
|
|
vec2.normalize()
|
|
|
|
# Determine the angle
|
|
angle = math.acos(max(-1.0, min(1.0, vec1.dot(vec2))))
|
|
|
|
if angle < 0.00001: # close enough to zero that sign doesn't matter
|
|
return angle
|
|
|
|
# Determine the sign of the angle
|
|
vec3 = vec2.cross(vec1)
|
|
vec3.normalize()
|
|
sign = vec3.dot(plane)
|
|
if sign >= 0:
|
|
sign = 1
|
|
else:
|
|
sign = -1
|
|
|
|
return angle * sign
|
|
|
|
|
|
# Convert between a matrix and axis+roll representations.
|
|
# Re-export the C implementation internally used by bones.
|
|
matrix_from_axis_roll = bpy.types.Bone.MatrixFromAxisRoll
|
|
axis_roll_from_matrix = bpy.types.Bone.AxisRollFromMatrix
|
|
|
|
|
|
def matrix_from_axis_pair(y_axis: AnyVector, other_axis: AnyVector, axis_name: str):
|
|
assert axis_name in 'xz'
|
|
|
|
y_axis = Vector(y_axis).normalized()
|
|
|
|
if axis_name == 'x':
|
|
z_axis = Vector(other_axis).cross(y_axis).normalized()
|
|
x_axis = y_axis.cross(z_axis)
|
|
else:
|
|
x_axis = y_axis.cross(other_axis).normalized()
|
|
z_axis = x_axis.cross(y_axis)
|
|
|
|
return Matrix((x_axis, y_axis, z_axis)).transposed()
|
|
|
|
|
|
##############################################
|
|
# Color correction functions
|
|
##############################################
|
|
|
|
# noinspection SpellCheckingInspection
|
|
def linsrgb_to_srgb(linsrgb: float):
|
|
"""Convert physically linear RGB values into sRGB ones. The transform is
|
|
uniform in the components, so *linsrgb* can be of any shape.
|
|
|
|
*linsrgb* values should range between 0 and 1, inclusively.
|
|
|
|
"""
|
|
# From Wikipedia, but easy analogue to the above.
|
|
gamma = 1.055 * linsrgb**(1./2.4) - 0.055
|
|
scale = linsrgb * 12.92
|
|
# return np.where (linsrgb > 0.0031308, gamma, scale)
|
|
if linsrgb > 0.0031308:
|
|
return gamma
|
|
return scale
|
|
|
|
|
|
def gamma_correct(color: Color):
|
|
corrected_color = Color()
|
|
for i, component in enumerate(color): # noqa
|
|
corrected_color[i] = linsrgb_to_srgb(color[i]) # noqa
|
|
return corrected_color
|
|
|
|
|
|
##############################################
|
|
# Iterators
|
|
##############################################
|
|
|
|
# noinspection SpellCheckingInspection
|
|
def padnone(iterable, pad=None):
|
|
return chain(iterable, repeat(pad))
|
|
|
|
|
|
# noinspection SpellCheckingInspection
|
|
def pairwise_nozip(iterable):
|
|
"""s -> (s0,s1), (s1,s2), (s2,s3), ..."""
|
|
a, b = tee(iterable)
|
|
next(b, None)
|
|
return a, b
|
|
|
|
|
|
def pairwise(iterable):
|
|
"""s -> (s0,s1), (s1,s2), (s2,s3), ..."""
|
|
a, b = tee(iterable)
|
|
next(b, None)
|
|
return zip(a, b)
|
|
|
|
|
|
def map_list(func, *inputs):
|
|
"""[func(a0,b0...), func(a1,b1...), ...]"""
|
|
return list(map(func, *inputs))
|
|
|
|
|
|
def skip(n, iterable):
|
|
"""Returns an iterator skipping first n elements of an iterable."""
|
|
iterator = iter(iterable)
|
|
if n == 1:
|
|
next(iterator, None)
|
|
else:
|
|
next(islice(iterator, n, n), None)
|
|
return iterator
|
|
|
|
|
|
def map_apply(func, *inputs):
|
|
"""Apply the function to inputs like map for side effects, discarding results."""
|
|
collections.deque(map(func, *inputs), maxlen=0)
|
|
|
|
|
|
##############################################
|
|
# Lazy references
|
|
##############################################
|
|
|
|
Lazy: typing.TypeAlias = T | typing.Callable[[], T]
|
|
OptionalLazy: typing.TypeAlias = typing.Optional[T | typing.Callable[[], T]]
|
|
|
|
|
|
def force_lazy(value: OptionalLazy[T]) -> T:
|
|
"""If the argument is callable, invokes it without arguments.
|
|
Otherwise, returns the argument as is."""
|
|
if callable(value):
|
|
return value()
|
|
else:
|
|
return value
|
|
|
|
|
|
class LazyRef(typing.Generic[T]):
|
|
"""Hashable lazy reference. When called, evaluates (foo, 'a', 'b'...) as foo('a','b')
|
|
if foo is callable. Otherwise, the remaining arguments are used as attribute names or
|
|
keys, like foo.a.b or foo.a[b] etc."""
|
|
|
|
def __init__(self, first, *args):
|
|
self.first = first
|
|
self.args = tuple(args)
|
|
self.first_hashable = first.__hash__ is not None
|
|
|
|
def __repr__(self):
|
|
return 'LazyRef{}'.format((self.first, *self.args))
|
|
|
|
def __eq__(self, other):
|
|
return (
|
|
isinstance(other, LazyRef) and
|
|
(self.first == other.first if self.first_hashable else self.first is other.first) and
|
|
self.args == other.args
|
|
)
|
|
|
|
def __hash__(self):
|
|
return (hash(self.first) if self.first_hashable
|
|
else hash(id(self.first))) ^ hash(self.args)
|
|
|
|
def __call__(self) -> T:
|
|
first = self.first
|
|
if callable(first):
|
|
return first(*self.args)
|
|
|
|
for item in self.args:
|
|
if isinstance(first, (dict, list)):
|
|
first = first[item]
|
|
else:
|
|
first = getattr(first, item)
|
|
|
|
return first
|
|
|
|
|
|
##############################################
|
|
# Misc
|
|
##############################################
|
|
|
|
def copy_attributes(a, b):
|
|
keys = dir(a)
|
|
for key in keys:
|
|
if not (key.startswith("_") or
|
|
key.startswith("error_") or
|
|
key in ("group", "is_valid", "is_valid", "bl_rna")):
|
|
try:
|
|
setattr(b, key, getattr(a, key))
|
|
except AttributeError:
|
|
pass
|
|
|
|
|
|
def property_to_python(value) -> typing.Any:
|
|
value = rna_idprop_value_to_python(value)
|
|
|
|
if isinstance(value, dict):
|
|
return {k: property_to_python(v) for k, v in value.items()}
|
|
elif isinstance(value, list):
|
|
return map_list(property_to_python, value)
|
|
else:
|
|
return value
|
|
|
|
|
|
def clone_parameters(target):
|
|
return property_to_python(dict(target))
|
|
|
|
|
|
def assign_parameters(target, val_dict=None, **params):
|
|
if val_dict is not None:
|
|
for key in list(target.keys()):
|
|
del target[key]
|
|
|
|
data = {**val_dict, **params}
|
|
else:
|
|
data = params
|
|
|
|
for key, value in data.items():
|
|
try:
|
|
target[key] = value
|
|
except Exception as e:
|
|
raise Exception(f"Couldn't set {key} to {value}: {e}")
|
|
|
|
|
|
def select_object(context: bpy.types.Context, obj: bpy.types.Object, deselect_all=False):
|
|
view_layer = context.view_layer
|
|
|
|
if deselect_all:
|
|
for layer_obj in view_layer.objects:
|
|
layer_obj.select_set(False) # deselect all objects
|
|
|
|
obj.select_set(True)
|
|
view_layer.objects.active = obj
|
|
|
|
|
|
##############################################
|
|
# Text
|
|
##############################################
|
|
|
|
def wrap_list_to_lines(prefix: str, delimiters: tuple[str, str] | str,
|
|
items: typing.Iterable[str], *,
|
|
limit=90, indent=4) -> list[str]:
|
|
"""
|
|
Generate a string representation of a list of items, wrapping lines if necessary.
|
|
|
|
Args:
|
|
prefix: Text of the first line before the list.
|
|
delimiters: Start and end of list delimiters.
|
|
items: List items, already converted to strings.
|
|
limit: Maximum line length.
|
|
indent: Wrapped line indent relative to prefix.
|
|
"""
|
|
start, end = delimiters
|
|
items = list(items)
|
|
simple_line = prefix + start + ', '.join(items) + end
|
|
|
|
if not items or len(simple_line) <= limit:
|
|
return [simple_line]
|
|
|
|
prefix_indent = prefix[0: len(prefix) - len(prefix.lstrip())]
|
|
inner_indent = prefix_indent + ' ' * indent
|
|
|
|
result = []
|
|
line = prefix + start
|
|
|
|
for item in items:
|
|
item_repr = item + ','
|
|
|
|
if not result or len(line) + len(item_repr) + 1 > limit:
|
|
result.append(line)
|
|
line = inner_indent + item_repr
|
|
else:
|
|
line += ' ' + item_repr
|
|
|
|
result.append(line[:-1] + end)
|
|
return result
|
|
|
|
|
|
##############################################
|
|
# Typing
|
|
##############################################
|
|
|
|
class TypedObject(bpy.types.Object, typing.Generic[IdType]):
|
|
data: IdType
|
|
|
|
|
|
ArmatureObject = TypedObject[bpy.types.Armature]
|
|
MeshObject = TypedObject[bpy.types.Mesh]
|
|
|
|
|
|
def verify_armature_obj(obj: bpy.types.Object) -> ArmatureObject:
|
|
assert obj and obj.type == 'ARMATURE'
|
|
return obj # noqa
|
|
|
|
|
|
def verify_mesh_obj(obj: bpy.types.Object) -> MeshObject:
|
|
assert obj and obj.type == 'MESH'
|
|
return obj # noqa
|
|
|
|
|
|
class IdPropSequence(typing.Mapping[str, T], ABC):
|
|
def __getitem__(self, item: str | int) -> T:
|
|
pass
|
|
|
|
def __setitem__(self, key: str | int, value: T):
|
|
pass
|
|
|
|
def add(self) -> T:
|
|
pass
|
|
|
|
def clear(self):
|
|
pass
|
|
|
|
def move(self, from_idx: int, to_idx: int):
|
|
pass
|
|
|
|
def remove(self, item: int):
|
|
pass
|