Skip to content

Commit 5d81cc2

Browse files
Access and modify attributes as arrays (#42)
* initial woorking * cleanup * ruff cleanup * bump bpy version for cov upload * fix typo * more tests * improve test coverage * improve coverage * improve coverage * remove lower versions for bpy testing * more test coverage * repr for AttributeArrays * Create test_array_print_methods.py * cleanup operations * cleanup docs * cleanup bad print methods and move away from bob * more docs * remove mock from tests
1 parent 39104a5 commit 5d81cc2

17 files changed

Lines changed: 1201 additions & 74 deletions

.github/workflows/test-upstream.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ on:
55
branches: ["main"]
66

77
jobs:
8-
build:
8+
test-in-blender:
99
runs-on: ${{ matrix.os }}
1010
env:
1111
BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
@@ -30,4 +30,4 @@ jobs:
3030
run: |
3131
blender -b -P tests/python.py -- -m pip install -e ".[test]"
3232
blender -b -P tests/python.py -- -m pip install git+https://github.com/${{ env.REPO_NAME}}.git@${{ env.BRANCH_NAME }}
33-
blender -b -P tests/run.py -- -vv
33+
blender -b -P tests/run.py -- -vv

.github/workflows/tests.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ jobs:
1313
max-parallel: 4
1414
fail-fast: false
1515
matrix:
16-
version: ["4.2", "4.3", "4.4"]
16+
version: ["4.4"]
1717
os: [macos-14]
1818
steps:
1919
- uses: actions/checkout@v4
@@ -34,12 +34,12 @@ jobs:
3434
3535
- name: Upload coverage reports to Codecov
3636
uses: codecov/codecov-action@v5
37-
if: matrix.os == 'macos-14' && matrix.version == '4.2'
37+
if: matrix.os == 'macos-14' && matrix.version == '4.4'
3838
with:
3939
token: ${{ secrets.CODECOV_TOKEN }}
4040
name: coverage.xml
4141

4242
- name: Upload coverage reports to Codecov
43-
if: matrix.os == 'maxos-14' && matrix.version == '4.2'
43+
if: matrix.os == 'macos-14' && matrix.version == '4.4'
4444
uses: codecov/codecov-action@v3
4545

databpy/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from .addon import register, unregister
1414
from .utils import centre, lerp
1515
from .collection import create_collection
16+
from .array import AttributeArray
1617
from .attribute import (
1718
named_attribute,
1819
store_named_attribute,

databpy/array.py

Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
import numpy as np
2+
from .attribute import Attribute, AttributeTypes, store_named_attribute
3+
import bpy
4+
5+
6+
class ColumnAccessor:
7+
"""
8+
Helper class to handle column operations on AttributeArray.
9+
10+
This provides a clean way to handle operations like pos[:, 2] += 1.0
11+
without the complexity of numpy array views.
12+
"""
13+
14+
def __init__(self, parent, column_idx):
15+
self.parent = parent
16+
self.column_idx = column_idx
17+
self.parent_array = np.asarray(parent).view(np.ndarray)
18+
19+
def _apply_operation(self, operation, value):
20+
"""Common method for all in-place operations."""
21+
operation(self.parent_array[:, self.column_idx], value)
22+
self.parent._sync_to_blender()
23+
return self
24+
25+
def __iadd__(self, value):
26+
return self._apply_operation(lambda arr, val: arr.__iadd__(val), value)
27+
28+
def __isub__(self, value):
29+
return self._apply_operation(lambda arr, val: arr.__isub__(val), value)
30+
31+
def __imul__(self, value):
32+
return self._apply_operation(lambda arr, val: arr.__imul__(val), value)
33+
34+
def __itruediv__(self, value):
35+
return self._apply_operation(lambda arr, val: arr.__itruediv__(val), value)
36+
37+
def __array__(self, dtype=None):
38+
"""Convert to array, handling optional dtype argument."""
39+
column_data = self.parent_array[:, self.column_idx]
40+
return column_data.astype(dtype) if dtype is not None else column_data
41+
42+
def __eq__(self, other):
43+
"""Handle equality comparison."""
44+
column_data = self.parent_array[:, self.column_idx]
45+
46+
if hasattr(other, "__array__"):
47+
return np.array_equal(column_data, np.asarray(other))
48+
return column_data == other
49+
50+
def __array_wrap__(self, out_arr, context=None):
51+
"""Handle the output of NumPy ufuncs and other functions."""
52+
self.parent_array[:, self.column_idx] = out_arr
53+
self.parent._sync_to_blender()
54+
return self
55+
56+
@property
57+
def column_data(self):
58+
"""Get the column data."""
59+
return self.parent_array[:, self.column_idx]
60+
61+
def __getattr__(self, name):
62+
"""Delegate attribute access to the column data."""
63+
column_data = self.parent_array[:, self.column_idx]
64+
if hasattr(column_data, name):
65+
return getattr(column_data, name)
66+
raise AttributeError(
67+
f"'{self.__class__.__name__}' object has no attribute '{name}'"
68+
)
69+
70+
71+
class AttributeArray(np.ndarray):
72+
"""
73+
A numpy array subclass that automatically syncs changes back to the Blender object.
74+
75+
Values are retrieved from the Blender object as a numpy array, the operation is applied
76+
and the result is store back on the Blender object.
77+
This allows for operations like `pos[:, 2] += 1.0` to work seamlessly.
78+
79+
Examples:
80+
--------
81+
```{python}
82+
import databpy as db
83+
import numpy as np
84+
85+
obj = db.create_object(np.random.rand(10, 3), name="test_bob")
86+
db.AttributeArray(obj, "position")
87+
```
88+
89+
```{python}
90+
import databpy as db
91+
import numpy as np
92+
93+
bob = db.create_bob(np.random.rand(10, 3), name="test_bob")
94+
print('Initial position:')
95+
print(bob.position) # Access the position attribute as an AttributeArray
96+
bob.position[:, 2] += 1.0
97+
print('Updated position:')
98+
print(bob.position)
99+
100+
print('As Array:')
101+
print(np.asarray(bob.position)) # Convert to a regular numpy array
102+
```
103+
"""
104+
105+
def __new__(cls, obj: bpy.types.Object, name: str) -> "AttributeArray":
106+
"""Create a new AttributeArray that wraps a Blender attribute."""
107+
attr = Attribute(obj.data.attributes[name])
108+
arr = np.asarray(attr.as_array()).view(cls)
109+
arr._blender_object = obj
110+
arr._attribute = attr
111+
arr._attr_name = name
112+
return arr
113+
114+
def __array_finalize__(self, obj):
115+
"""Initialize attributes when array is created through operations."""
116+
if obj is None:
117+
return
118+
119+
self._blender_object = getattr(obj, "_blender_object", None)
120+
self._attribute = getattr(obj, "_attribute", None)
121+
self._attr_name = getattr(obj, "_attr_name", None)
122+
123+
def __eq__(self, other):
124+
"""Handle equality comparison for array objects."""
125+
self_arr = np.asarray(self).view(np.ndarray)
126+
127+
if isinstance(other, AttributeArray):
128+
other_arr = np.asarray(other).view(np.ndarray)
129+
return np.array_equal(self_arr, other_arr)
130+
131+
if isinstance(other, ColumnAccessor):
132+
return np.array_equal(self_arr, other.parent_array)
133+
134+
if hasattr(other, "__array__"):
135+
other_arr = np.asarray(other)
136+
137+
# Handle shape differences for column comparisons
138+
if (
139+
self_arr.shape != other_arr.shape
140+
and other_arr.ndim == 1
141+
and self_arr.ndim == 2
142+
and other_arr.shape[0] == self_arr.shape[0]
143+
):
144+
return any(
145+
np.array_equal(self_arr[:, i], other_arr)
146+
for i in range(self_arr.shape[1])
147+
)
148+
149+
return np.array_equal(self_arr, other_arr)
150+
151+
return self_arr == other
152+
153+
def __getitem__(self, key):
154+
"""Get item with special handling for column operations."""
155+
# Handle column operations: pos[:, 2]
156+
if (
157+
isinstance(key, tuple)
158+
and len(key) == 2
159+
and isinstance(key[0], slice)
160+
and key[0] == slice(None)
161+
and isinstance(key[1], int)
162+
):
163+
return ColumnAccessor(self, key[1])
164+
165+
return super().__getitem__(key)
166+
167+
def __setitem__(self, key, value):
168+
"""Set item and sync changes back to Blender."""
169+
# Handle column operations: pos[:, 2] = value
170+
if (
171+
isinstance(key, tuple)
172+
and len(key) == 2
173+
and isinstance(key[0], slice)
174+
and key[0] == slice(None)
175+
and isinstance(key[1], int)
176+
):
177+
arr_view = np.asarray(self).view(np.ndarray)
178+
col_idx = key[1]
179+
180+
if isinstance(value, ColumnAccessor):
181+
arr_view[:, col_idx] = value.column_data
182+
else:
183+
arr_view[:, col_idx] = value
184+
185+
self._sync_to_blender()
186+
return
187+
188+
super().__setitem__(key, value)
189+
self._sync_to_blender()
190+
191+
def _get_expected_components(self):
192+
"""Get the expected number of components for the attribute type."""
193+
if self._attribute.atype == AttributeTypes.FLOAT_COLOR:
194+
return 4
195+
elif self._attribute.atype == AttributeTypes.FLOAT_VECTOR:
196+
return 3
197+
return None
198+
199+
def _ensure_correct_shape(self, data):
200+
"""Ensure data has the correct shape for Blender."""
201+
expected_components = self._get_expected_components()
202+
if expected_components is None:
203+
return data
204+
205+
# Reshape 1D to 2D if needed
206+
if data.ndim == 1 and len(data) % expected_components == 0:
207+
return data.reshape(-1, expected_components)
208+
209+
# Handle incorrect column count
210+
if (
211+
data.ndim == 2
212+
and data.shape[1] != expected_components
213+
and data.shape[1] == 1
214+
):
215+
# Try to get the full array
216+
full_array = np.asarray(self).view(np.ndarray).copy()
217+
if full_array.shape[1] == expected_components:
218+
return full_array
219+
220+
return data
221+
222+
def _sync_to_blender(self):
223+
"""Sync the current array data back to the Blender object."""
224+
if self._blender_object is None:
225+
return
226+
227+
data_to_sync = np.asarray(self).view(np.ndarray)
228+
data_to_sync = self._ensure_correct_shape(data_to_sync)
229+
230+
# Ensure float32 dtype
231+
if data_to_sync.dtype != np.float32:
232+
data_to_sync = data_to_sync.astype(np.float32)
233+
234+
store_named_attribute(
235+
self._blender_object,
236+
data_to_sync,
237+
name=self._attr_name,
238+
atype=self._attribute.atype,
239+
domain=self._attribute.domain.name,
240+
)
241+
242+
def _inplace_operation_with_sync(self, operation, other):
243+
"""Common method for in-place operations."""
244+
result = operation(other)
245+
self._sync_to_blender()
246+
return result
247+
248+
def __iadd__(self, other):
249+
"""In-place addition with Blender syncing."""
250+
return self._inplace_operation_with_sync(super().__iadd__, other)
251+
252+
def __isub__(self, other):
253+
"""In-place subtraction with Blender syncing."""
254+
return self._inplace_operation_with_sync(super().__isub__, other)
255+
256+
def __imul__(self, other):
257+
"""In-place multiplication with Blender syncing."""
258+
return self._inplace_operation_with_sync(super().__imul__, other)
259+
260+
def __itruediv__(self, other):
261+
"""In-place division with Blender syncing."""
262+
return self._inplace_operation_with_sync(super().__itruediv__, other)
263+
264+
def __str__(self):
265+
"""String representation showing attribute info and array data."""
266+
# Get basic info
267+
attr_name = getattr(self, "_attr_name", "Unknown")
268+
domain = getattr(self._attribute, "domain", None)
269+
domain_name = domain.name if domain else "Unknown"
270+
271+
# Get object info
272+
obj_name = "Unknown"
273+
obj_type = "Unknown"
274+
if self._blender_object:
275+
obj_name = getattr(self._blender_object, "name", "Unknown")
276+
obj_type = getattr(self._blender_object.data, "name", "Unknown")
277+
278+
# Get array info
279+
array_str = np.array_str(np.asarray(self).view(np.ndarray))
280+
281+
return (
282+
f"AttributeArray '{attr_name}' from {obj_type}('{obj_name}')"
283+
f"(domain: {domain_name}, shape: {self.shape}, dtype: {self.dtype})\n"
284+
f"{array_str}"
285+
)
286+
287+
def __repr__(self):
288+
"""Detailed representation for debugging."""
289+
# Get basic info
290+
attr_name = getattr(self, "_attr_name", "Unknown")
291+
domain = getattr(self._attribute, "domain", None)
292+
domain_name = domain.name if domain else "Unknown"
293+
atype = getattr(self._attribute, "atype", "Unknown")
294+
295+
# Get object info
296+
obj_name = "Unknown"
297+
obj_type = "Unknown"
298+
if self._blender_object:
299+
obj_name = getattr(self._blender_object, "name", "Unknown")
300+
obj_type = getattr(self._blender_object.data, "name", "Unknown")
301+
302+
# Get array representation
303+
array_repr = np.array_repr(np.asarray(self).view(np.ndarray))
304+
305+
return (
306+
f"AttributeArray(name='{attr_name}', object='{obj_name}', mesh='{obj_type}', "
307+
f"domain={domain_name}, type={atype.value}, shape={self.shape}, dtype={self.dtype})\n"
308+
f"{array_repr}"
309+
)

0 commit comments

Comments
 (0)