lifx-async 5.0.0__py3-none-any.whl → 5.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lifx/__init__.py +4 -0
- lifx/animation/__init__.py +87 -0
- lifx/animation/animator.py +323 -0
- lifx/animation/framebuffer.py +395 -0
- lifx/animation/orientation.py +159 -0
- lifx/animation/packets.py +497 -0
- lifx/api.py +0 -8
- lifx/const.py +18 -0
- lifx/devices/ceiling.py +7 -8
- lifx/devices/matrix.py +2 -19
- lifx/effects/colorloop.py +2 -2
- lifx/network/connection.py +8 -7
- lifx/network/discovery.py +3 -3
- lifx/network/mdns/transport.py +2 -2
- lifx/network/transport.py +8 -3
- lifx/network/utils.py +15 -0
- lifx/protocol/serializer.py +0 -85
- {lifx_async-5.0.0.dist-info → lifx_async-5.1.0.dist-info}/METADATA +1 -1
- {lifx_async-5.0.0.dist-info → lifx_async-5.1.0.dist-info}/RECORD +21 -15
- {lifx_async-5.0.0.dist-info → lifx_async-5.1.0.dist-info}/WHEEL +0 -0
- {lifx_async-5.0.0.dist-info → lifx_async-5.1.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
"""Orientation mapping for LIFX matrix device animations.
|
|
2
|
+
|
|
3
|
+
This module provides the FrameBuffer class, which handles pixel coordinate
|
|
4
|
+
remapping for matrix devices based on tile orientation. For animations,
|
|
5
|
+
every frame is assumed to change all pixels, so no diff tracking is performed.
|
|
6
|
+
|
|
7
|
+
Multi-Tile Canvas Support:
|
|
8
|
+
For devices with multiple tiles (e.g., original LIFX Tile with 5 tiles),
|
|
9
|
+
the FrameBuffer creates a unified canvas based on tile positions (user_x,
|
|
10
|
+
user_y). The user provides colors for the entire canvas, and the FrameBuffer
|
|
11
|
+
extracts the appropriate region for each tile.
|
|
12
|
+
|
|
13
|
+
Example with 5 tiles arranged horizontally:
|
|
14
|
+
- Canvas dimensions: 40x8 (5 tiles * 8 pixels wide)
|
|
15
|
+
- User provides 320 HSBK tuples (40*8)
|
|
16
|
+
- FrameBuffer extracts 64 pixels for each tile based on position
|
|
17
|
+
|
|
18
|
+
Design Philosophy:
|
|
19
|
+
Colors are "protocol-ready" HSBK tuples - uint16 values matching the LIFX
|
|
20
|
+
protocol (0-65535 for H/S/B, 1500-9000 for K).
|
|
21
|
+
|
|
22
|
+
Example:
|
|
23
|
+
```python
|
|
24
|
+
from lifx.animation.framebuffer import FrameBuffer
|
|
25
|
+
|
|
26
|
+
# Create framebuffer for a matrix device
|
|
27
|
+
fb = await FrameBuffer.for_matrix(matrix_device)
|
|
28
|
+
|
|
29
|
+
# For multi-tile devices, check canvas dimensions
|
|
30
|
+
print(f"Canvas: {fb.canvas_width}x{fb.canvas_height}") # e.g., 40x8
|
|
31
|
+
|
|
32
|
+
# Provide colors for the entire canvas
|
|
33
|
+
canvas_colors = [(65535, 65535, 65535, 3500)] * (fb.canvas_width * fb.canvas_height)
|
|
34
|
+
device_order_data = fb.apply(canvas_colors)
|
|
35
|
+
```
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
from dataclasses import dataclass
|
|
41
|
+
from typing import TYPE_CHECKING
|
|
42
|
+
|
|
43
|
+
if TYPE_CHECKING:
|
|
44
|
+
from lifx.devices.matrix import MatrixLight, TileInfo
|
|
45
|
+
from lifx.devices.multizone import MultiZoneLight
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class TileRegion:
|
|
50
|
+
"""Region of a tile within the canvas.
|
|
51
|
+
|
|
52
|
+
Attributes:
|
|
53
|
+
x: X offset in canvas coordinates
|
|
54
|
+
y: Y offset in canvas coordinates
|
|
55
|
+
width: Tile width in pixels
|
|
56
|
+
height: Tile height in pixels
|
|
57
|
+
orientation_lut: Lookup table for orientation remapping (optional)
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
x: int
|
|
61
|
+
y: int
|
|
62
|
+
width: int
|
|
63
|
+
height: int
|
|
64
|
+
orientation_lut: tuple[int, ...] | None = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class FrameBuffer:
|
|
68
|
+
"""Orientation mapping for matrix device animations.
|
|
69
|
+
|
|
70
|
+
For matrix devices with tile orientation (like the original LIFX Tile),
|
|
71
|
+
this class remaps pixel coordinates from user-space (logical layout) to
|
|
72
|
+
device-space (physical tile order accounting for rotation).
|
|
73
|
+
|
|
74
|
+
For multi-tile devices, the FrameBuffer creates a unified canvas where
|
|
75
|
+
each tile's position (user_x, user_y) determines which region of the
|
|
76
|
+
canvas it displays. This allows animations to span across all tiles
|
|
77
|
+
instead of being mirrored.
|
|
78
|
+
|
|
79
|
+
For multizone devices and matrix devices without orientation, this is
|
|
80
|
+
essentially a passthrough.
|
|
81
|
+
|
|
82
|
+
Attributes:
|
|
83
|
+
pixel_count: Total number of device pixels
|
|
84
|
+
canvas_width: Width of the logical canvas in pixels
|
|
85
|
+
canvas_height: Height of the logical canvas in pixels
|
|
86
|
+
tile_regions: List of tile regions with positions and orientations
|
|
87
|
+
|
|
88
|
+
Example:
|
|
89
|
+
```python
|
|
90
|
+
# Create for a device
|
|
91
|
+
fb = await FrameBuffer.for_matrix(matrix_device)
|
|
92
|
+
|
|
93
|
+
# Check canvas dimensions
|
|
94
|
+
print(f"Canvas: {fb.canvas_width}x{fb.canvas_height}")
|
|
95
|
+
|
|
96
|
+
# Provide canvas-sized input
|
|
97
|
+
canvas = [(0, 0, 65535, 3500)] * (fb.canvas_width * fb.canvas_height)
|
|
98
|
+
device_data = fb.apply(canvas)
|
|
99
|
+
```
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
def __init__(
|
|
103
|
+
self,
|
|
104
|
+
pixel_count: int,
|
|
105
|
+
canvas_width: int = 0,
|
|
106
|
+
canvas_height: int = 0,
|
|
107
|
+
tile_regions: list[TileRegion] | None = None,
|
|
108
|
+
) -> None:
|
|
109
|
+
"""Initialize framebuffer.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
pixel_count: Total number of device pixels
|
|
113
|
+
canvas_width: Width of the logical canvas (0 = same as pixel_count)
|
|
114
|
+
canvas_height: Height of the logical canvas (0 = 1 for linear)
|
|
115
|
+
tile_regions: List of tile regions with positions and orientations.
|
|
116
|
+
If provided, input is interpreted as a 2D canvas.
|
|
117
|
+
"""
|
|
118
|
+
if pixel_count < 0:
|
|
119
|
+
raise ValueError(f"pixel_count must be non-negative, got {pixel_count}")
|
|
120
|
+
|
|
121
|
+
self._pixel_count = pixel_count
|
|
122
|
+
self._tile_regions = tile_regions
|
|
123
|
+
|
|
124
|
+
# Canvas dimensions
|
|
125
|
+
if tile_regions:
|
|
126
|
+
# Calculate from tile regions
|
|
127
|
+
self._canvas_width = canvas_width
|
|
128
|
+
self._canvas_height = canvas_height
|
|
129
|
+
else:
|
|
130
|
+
# Linear (multizone) or single tile
|
|
131
|
+
self._canvas_width = canvas_width if canvas_width > 0 else pixel_count
|
|
132
|
+
self._canvas_height = canvas_height if canvas_height > 0 else 1
|
|
133
|
+
|
|
134
|
+
@classmethod
|
|
135
|
+
async def for_matrix(
|
|
136
|
+
cls,
|
|
137
|
+
device: MatrixLight,
|
|
138
|
+
) -> FrameBuffer:
|
|
139
|
+
"""Create a FrameBuffer configured for a MatrixLight device.
|
|
140
|
+
|
|
141
|
+
Automatically determines pixel count from device chain and creates
|
|
142
|
+
appropriate mapping for tile orientations and positions.
|
|
143
|
+
|
|
144
|
+
For multi-tile devices (has_chain capability), creates a unified canvas
|
|
145
|
+
based on tile positions (user_x, user_y). Each tile's position determines
|
|
146
|
+
which region of the canvas it displays, allowing animations to span
|
|
147
|
+
across all tiles.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
device: MatrixLight device (must be connected)
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Configured FrameBuffer instance
|
|
154
|
+
|
|
155
|
+
Example:
|
|
156
|
+
```python
|
|
157
|
+
async with await MatrixLight.from_ip("192.168.1.100") as matrix:
|
|
158
|
+
fb = await FrameBuffer.for_matrix(matrix)
|
|
159
|
+
print(f"Canvas: {fb.canvas_width}x{fb.canvas_height}")
|
|
160
|
+
```
|
|
161
|
+
"""
|
|
162
|
+
# Ensure device chain is loaded
|
|
163
|
+
if device.device_chain is None:
|
|
164
|
+
await device.get_device_chain()
|
|
165
|
+
|
|
166
|
+
tiles = device.device_chain
|
|
167
|
+
if not tiles:
|
|
168
|
+
raise ValueError("Device has no tiles")
|
|
169
|
+
|
|
170
|
+
# Calculate total device pixels
|
|
171
|
+
pixel_count = sum(t.width * t.height for t in tiles)
|
|
172
|
+
|
|
173
|
+
# Ensure capabilities are loaded
|
|
174
|
+
if device.capabilities is None:
|
|
175
|
+
await device._ensure_capabilities()
|
|
176
|
+
|
|
177
|
+
# Only build canvas mapping for devices with chain capability.
|
|
178
|
+
# The original LIFX Tile is the only matrix device with accelerometer-based
|
|
179
|
+
# orientation detection and multi-tile positioning. Other matrix devices
|
|
180
|
+
# (Ceiling, Luna, Candle, Path, etc.) have fixed positions.
|
|
181
|
+
if device.capabilities and device.capabilities.has_chain:
|
|
182
|
+
return cls._for_multi_tile(tiles, pixel_count)
|
|
183
|
+
else:
|
|
184
|
+
# Single tile device - simple passthrough
|
|
185
|
+
first_tile = tiles[0]
|
|
186
|
+
return cls(
|
|
187
|
+
pixel_count=pixel_count,
|
|
188
|
+
canvas_width=first_tile.width,
|
|
189
|
+
canvas_height=first_tile.height,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
@classmethod
|
|
193
|
+
def _for_multi_tile(
|
|
194
|
+
cls,
|
|
195
|
+
tiles: list[TileInfo],
|
|
196
|
+
pixel_count: int,
|
|
197
|
+
) -> FrameBuffer:
|
|
198
|
+
"""Create FrameBuffer for multi-tile device with canvas positioning.
|
|
199
|
+
|
|
200
|
+
Uses user_x/user_y to determine where each tile sits in the canvas.
|
|
201
|
+
Coordinates are in tile-width units (1.0 = one tile width) and
|
|
202
|
+
represent the center of each tile.
|
|
203
|
+
"""
|
|
204
|
+
from lifx.animation.orientation import Orientation, build_orientation_lut
|
|
205
|
+
|
|
206
|
+
if not tiles: # pragma: no cover
|
|
207
|
+
raise ValueError("No tiles provided")
|
|
208
|
+
|
|
209
|
+
first_tile = tiles[0]
|
|
210
|
+
tile_width = first_tile.width
|
|
211
|
+
tile_height = first_tile.height
|
|
212
|
+
|
|
213
|
+
# Convert tile center positions to pixel coordinates
|
|
214
|
+
# user_x/user_y are in "tile width" units, representing tile centers
|
|
215
|
+
tile_centers = [
|
|
216
|
+
(int(round(t.user_x * tile_width)), int(round(t.user_y * tile_height)))
|
|
217
|
+
for t in tiles
|
|
218
|
+
]
|
|
219
|
+
|
|
220
|
+
# Calculate bounding box of all tile centers
|
|
221
|
+
min_cx = min(c[0] for c in tile_centers)
|
|
222
|
+
max_cx = max(c[0] for c in tile_centers)
|
|
223
|
+
min_cy = min(c[1] for c in tile_centers)
|
|
224
|
+
max_cy = max(c[1] for c in tile_centers)
|
|
225
|
+
|
|
226
|
+
# Canvas extends from leftmost tile left edge to rightmost tile right edge
|
|
227
|
+
# Since centers are at tile_width/2 from edges:
|
|
228
|
+
# - Left edge of leftmost tile: min_cx - tile_width/2
|
|
229
|
+
# - Right edge of rightmost tile: max_cx + tile_width/2
|
|
230
|
+
# Total width = (max_cx - min_cx) + tile_width
|
|
231
|
+
canvas_width = (max_cx - min_cx) + tile_width
|
|
232
|
+
canvas_height = (max_cy - min_cy) + tile_height
|
|
233
|
+
|
|
234
|
+
# Origin offset (to convert tile centers to top-left positions)
|
|
235
|
+
origin_x = min_cx - tile_width // 2
|
|
236
|
+
origin_y = min_cy - tile_height // 2
|
|
237
|
+
|
|
238
|
+
# Build tile regions with canvas-relative positions
|
|
239
|
+
tile_regions: list[TileRegion] = []
|
|
240
|
+
for tile, (cx, cy) in zip(tiles, tile_centers, strict=True):
|
|
241
|
+
# Convert center to top-left, relative to canvas origin
|
|
242
|
+
x = cx - tile_width // 2 - origin_x
|
|
243
|
+
y = cy - tile_height // 2 - origin_y
|
|
244
|
+
|
|
245
|
+
# Build orientation LUT for this tile
|
|
246
|
+
orientation = Orientation.from_string(tile.nearest_orientation)
|
|
247
|
+
lut = build_orientation_lut(tile_width, tile_height, orientation)
|
|
248
|
+
|
|
249
|
+
tile_regions.append(
|
|
250
|
+
TileRegion(
|
|
251
|
+
x=x,
|
|
252
|
+
y=y,
|
|
253
|
+
width=tile_width,
|
|
254
|
+
height=tile_height,
|
|
255
|
+
orientation_lut=lut,
|
|
256
|
+
)
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
return cls(
|
|
260
|
+
pixel_count=pixel_count,
|
|
261
|
+
canvas_width=canvas_width,
|
|
262
|
+
canvas_height=canvas_height,
|
|
263
|
+
tile_regions=tile_regions,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
@classmethod
|
|
267
|
+
async def for_multizone(
|
|
268
|
+
cls,
|
|
269
|
+
device: MultiZoneLight,
|
|
270
|
+
) -> FrameBuffer:
|
|
271
|
+
"""Create a FrameBuffer configured for a MultiZoneLight device.
|
|
272
|
+
|
|
273
|
+
Automatically determines pixel count from zone count.
|
|
274
|
+
Multizone devices don't need permutation (zones are linear).
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
device: MultiZoneLight device (must be connected)
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
Configured FrameBuffer instance
|
|
281
|
+
|
|
282
|
+
Example:
|
|
283
|
+
```python
|
|
284
|
+
async with await MultiZoneLight.from_ip("192.168.1.100") as strip:
|
|
285
|
+
fb = await FrameBuffer.for_multizone(strip)
|
|
286
|
+
```
|
|
287
|
+
"""
|
|
288
|
+
# Get zone count (fetches from device if not cached)
|
|
289
|
+
zone_count = await device.get_zone_count()
|
|
290
|
+
|
|
291
|
+
return cls(pixel_count=zone_count)
|
|
292
|
+
|
|
293
|
+
@property
|
|
294
|
+
def pixel_count(self) -> int:
|
|
295
|
+
"""Get total number of device pixels."""
|
|
296
|
+
return self._pixel_count
|
|
297
|
+
|
|
298
|
+
@property
|
|
299
|
+
def canvas_width(self) -> int:
|
|
300
|
+
"""Get width of the logical canvas in pixels."""
|
|
301
|
+
return self._canvas_width
|
|
302
|
+
|
|
303
|
+
@property
|
|
304
|
+
def canvas_height(self) -> int:
|
|
305
|
+
"""Get height of the logical canvas in pixels."""
|
|
306
|
+
return self._canvas_height
|
|
307
|
+
|
|
308
|
+
@property
|
|
309
|
+
def canvas_size(self) -> int:
|
|
310
|
+
"""Get total number of canvas pixels (width * height)."""
|
|
311
|
+
return self._canvas_width * self._canvas_height
|
|
312
|
+
|
|
313
|
+
@property
|
|
314
|
+
def tile_regions(self) -> list[TileRegion] | None:
|
|
315
|
+
"""Get tile regions if configured."""
|
|
316
|
+
return self._tile_regions
|
|
317
|
+
|
|
318
|
+
def apply(
|
|
319
|
+
self, hsbk: list[tuple[int, int, int, int]]
|
|
320
|
+
) -> list[tuple[int, int, int, int]]:
|
|
321
|
+
"""Apply orientation mapping to frame data.
|
|
322
|
+
|
|
323
|
+
For multi-tile devices, the input is interpreted as a row-major 2D
|
|
324
|
+
canvas of size (canvas_width x canvas_height). Each tile extracts
|
|
325
|
+
its region from the canvas based on its position.
|
|
326
|
+
|
|
327
|
+
For single-tile or multizone devices, this is a passthrough.
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
hsbk: List of protocol-ready HSBK tuples.
|
|
331
|
+
- For multi-tile: length must match canvas_size
|
|
332
|
+
- For single-tile/multizone: length must match pixel_count
|
|
333
|
+
Each tuple is (hue, sat, brightness, kelvin) where
|
|
334
|
+
H/S/B are 0-65535 and K is 1500-9000.
|
|
335
|
+
|
|
336
|
+
Returns:
|
|
337
|
+
Remapped HSBK data in device order
|
|
338
|
+
|
|
339
|
+
Raises:
|
|
340
|
+
ValueError: If hsbk length doesn't match expected size
|
|
341
|
+
"""
|
|
342
|
+
# Multi-tile canvas mode
|
|
343
|
+
if self._tile_regions:
|
|
344
|
+
expected_size = self._canvas_width * self._canvas_height
|
|
345
|
+
if len(hsbk) != expected_size:
|
|
346
|
+
raise ValueError(
|
|
347
|
+
f"HSBK length ({len(hsbk)}) must match "
|
|
348
|
+
f"canvas_size ({expected_size})"
|
|
349
|
+
)
|
|
350
|
+
return self._apply_canvas(hsbk)
|
|
351
|
+
|
|
352
|
+
# Single-tile or multizone mode (passthrough)
|
|
353
|
+
if len(hsbk) != self._pixel_count:
|
|
354
|
+
raise ValueError(
|
|
355
|
+
f"HSBK length ({len(hsbk)}) must match "
|
|
356
|
+
f"pixel_count ({self._pixel_count})"
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
return list(hsbk)
|
|
360
|
+
|
|
361
|
+
def _apply_canvas(
|
|
362
|
+
self, hsbk: list[tuple[int, int, int, int]]
|
|
363
|
+
) -> list[tuple[int, int, int, int]]:
|
|
364
|
+
"""Extract tile regions from canvas and apply orientation.
|
|
365
|
+
|
|
366
|
+
Args:
|
|
367
|
+
hsbk: Row-major canvas data (canvas_width x canvas_height)
|
|
368
|
+
|
|
369
|
+
Returns:
|
|
370
|
+
Device-ordered pixels (concatenated tiles)
|
|
371
|
+
"""
|
|
372
|
+
result: list[tuple[int, int, int, int]] = []
|
|
373
|
+
canvas_width = self._canvas_width
|
|
374
|
+
|
|
375
|
+
for region in self._tile_regions: # type: ignore[union-attr]
|
|
376
|
+
# Extract pixels for this tile from the canvas
|
|
377
|
+
tile_pixels: list[tuple[int, int, int, int]] = []
|
|
378
|
+
|
|
379
|
+
for row in range(region.height):
|
|
380
|
+
canvas_y = region.y + row
|
|
381
|
+
for col in range(region.width):
|
|
382
|
+
canvas_x = region.x + col
|
|
383
|
+
canvas_idx = canvas_y * canvas_width + canvas_x
|
|
384
|
+
tile_pixels.append(hsbk[canvas_idx])
|
|
385
|
+
|
|
386
|
+
# Apply orientation remapping for this tile
|
|
387
|
+
if region.orientation_lut:
|
|
388
|
+
tile_pixels = [
|
|
389
|
+
tile_pixels[region.orientation_lut[i]]
|
|
390
|
+
for i in range(len(tile_pixels))
|
|
391
|
+
]
|
|
392
|
+
|
|
393
|
+
result.extend(tile_pixels)
|
|
394
|
+
|
|
395
|
+
return result
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Tile orientation remapping for LIFX matrix devices.
|
|
2
|
+
|
|
3
|
+
This module provides utilities for remapping pixel coordinates based on tile
|
|
4
|
+
orientation, enabling correct display regardless of how tiles are physically
|
|
5
|
+
mounted.
|
|
6
|
+
|
|
7
|
+
LIFX tiles report their orientation via accelerometer data. This module
|
|
8
|
+
converts that orientation into lookup tables for efficient pixel remapping
|
|
9
|
+
during animation.
|
|
10
|
+
|
|
11
|
+
The key insight is that orientation affects how row-major framebuffer indices
|
|
12
|
+
map to physical tile positions. By pre-computing lookup tables (LUTs), we
|
|
13
|
+
can apply orientation correction with a single array lookup per pixel.
|
|
14
|
+
|
|
15
|
+
Example:
|
|
16
|
+
```python
|
|
17
|
+
from lifx.animation.orientation import Orientation, build_orientation_lut
|
|
18
|
+
|
|
19
|
+
# Build LUT for a single 8x8 tile rotated 90 degrees
|
|
20
|
+
lut = build_orientation_lut(8, 8, Orientation.ROTATED_90)
|
|
21
|
+
|
|
22
|
+
# Apply LUT to remap pixels
|
|
23
|
+
output = [framebuffer[lut[i]] for i in range(len(framebuffer))]
|
|
24
|
+
```
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from enum import IntEnum
|
|
30
|
+
from functools import lru_cache
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Orientation(IntEnum):
|
|
34
|
+
"""Tile orientation based on accelerometer data.
|
|
35
|
+
|
|
36
|
+
These values match the orientation detection in TileInfo.nearest_orientation
|
|
37
|
+
but use integer enum for efficient comparison and caching.
|
|
38
|
+
|
|
39
|
+
Physical mounting positions:
|
|
40
|
+
- RIGHT_SIDE_UP: Normal position, no rotation needed
|
|
41
|
+
- ROTATED_90: Rotated 90 degrees clockwise (RotatedRight)
|
|
42
|
+
- ROTATED_180: Upside down (UpsideDown)
|
|
43
|
+
- ROTATED_270: Rotated 90 degrees counter-clockwise (RotatedLeft)
|
|
44
|
+
- FACE_UP: Tile facing ceiling
|
|
45
|
+
- FACE_DOWN: Tile facing floor
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
RIGHT_SIDE_UP = 0 # "Upright"
|
|
49
|
+
ROTATED_90 = 1 # "RotatedRight"
|
|
50
|
+
ROTATED_180 = 2 # "UpsideDown"
|
|
51
|
+
ROTATED_270 = 3 # "RotatedLeft"
|
|
52
|
+
FACE_UP = 4 # "FaceUp"
|
|
53
|
+
FACE_DOWN = 5 # "FaceDown"
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def from_string(cls, orientation_str: str) -> Orientation:
|
|
57
|
+
"""Convert TileInfo.nearest_orientation string to Orientation enum.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
orientation_str: String from TileInfo.nearest_orientation
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Corresponding Orientation enum value
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
ValueError: If orientation string is not recognized
|
|
67
|
+
"""
|
|
68
|
+
mapping = {
|
|
69
|
+
"Upright": cls.RIGHT_SIDE_UP,
|
|
70
|
+
"RotatedRight": cls.ROTATED_90,
|
|
71
|
+
"UpsideDown": cls.ROTATED_180,
|
|
72
|
+
"RotatedLeft": cls.ROTATED_270,
|
|
73
|
+
"FaceUp": cls.FACE_UP,
|
|
74
|
+
"FaceDown": cls.FACE_DOWN,
|
|
75
|
+
}
|
|
76
|
+
if orientation_str not in mapping:
|
|
77
|
+
raise ValueError(f"Unknown orientation: {orientation_str}")
|
|
78
|
+
return mapping[orientation_str]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@lru_cache(maxsize=64)
|
|
82
|
+
def build_orientation_lut(
|
|
83
|
+
width: int,
|
|
84
|
+
height: int,
|
|
85
|
+
orientation: Orientation,
|
|
86
|
+
) -> tuple[int, ...]:
|
|
87
|
+
"""Build a lookup table for remapping pixels based on tile orientation.
|
|
88
|
+
|
|
89
|
+
The LUT maps physical tile positions to row-major framebuffer indices.
|
|
90
|
+
For a pixel at physical position i, lut[i] gives the framebuffer index.
|
|
91
|
+
|
|
92
|
+
This is LRU-cached because tiles typically have standard dimensions (8x8)
|
|
93
|
+
and there are only 6 orientations, so the cache will be highly effective.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
width: Tile width in pixels
|
|
97
|
+
height: Tile height in pixels
|
|
98
|
+
orientation: Tile orientation
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
Tuple of indices mapping physical position to framebuffer position.
|
|
102
|
+
Tuple is used instead of list for hashability in caches.
|
|
103
|
+
|
|
104
|
+
Example:
|
|
105
|
+
>>> lut = build_orientation_lut(8, 8, Orientation.RIGHT_SIDE_UP)
|
|
106
|
+
>>> len(lut)
|
|
107
|
+
64
|
|
108
|
+
>>> lut[0] # First pixel maps to index 0
|
|
109
|
+
0
|
|
110
|
+
>>> lut = build_orientation_lut(8, 8, Orientation.ROTATED_180)
|
|
111
|
+
>>> lut[0] # First physical position maps to last framebuffer index
|
|
112
|
+
63
|
|
113
|
+
"""
|
|
114
|
+
size = width * height
|
|
115
|
+
lut: list[int] = [0] * size
|
|
116
|
+
|
|
117
|
+
for y in range(height):
|
|
118
|
+
for x in range(width):
|
|
119
|
+
# Physical position in row-major order
|
|
120
|
+
physical_idx = y * width + x
|
|
121
|
+
|
|
122
|
+
# Calculate source position based on orientation
|
|
123
|
+
if orientation == Orientation.RIGHT_SIDE_UP:
|
|
124
|
+
# No transformation
|
|
125
|
+
src_x, src_y = x, y
|
|
126
|
+
elif orientation == Orientation.ROTATED_90:
|
|
127
|
+
# 90 degrees clockwise: (x, y) -> (height - 1 - y, x)
|
|
128
|
+
# Note: Only valid for square tiles. Non-square tiles would require
|
|
129
|
+
# a source buffer with swapped dimensions (e.g., 5x7 for a 7x5 tile).
|
|
130
|
+
# For non-square tiles, fall back to identity transformation.
|
|
131
|
+
if width == height:
|
|
132
|
+
src_x = height - 1 - y
|
|
133
|
+
src_y = x
|
|
134
|
+
else:
|
|
135
|
+
src_x, src_y = x, y
|
|
136
|
+
elif orientation == Orientation.ROTATED_180:
|
|
137
|
+
# 180 degrees: (x, y) -> (width - 1 - x, height - 1 - y)
|
|
138
|
+
# Works for both square and non-square tiles
|
|
139
|
+
src_x = width - 1 - x
|
|
140
|
+
src_y = height - 1 - y
|
|
141
|
+
elif orientation == Orientation.ROTATED_270:
|
|
142
|
+
# 270 degrees (90 counter-clockwise): (x, y) -> (y, width - 1 - x)
|
|
143
|
+
# Note: Only valid for square tiles. For non-square tiles,
|
|
144
|
+
# fall back to identity transformation.
|
|
145
|
+
if width == height:
|
|
146
|
+
src_x = y
|
|
147
|
+
src_y = width - 1 - x
|
|
148
|
+
else:
|
|
149
|
+
src_x, src_y = x, y
|
|
150
|
+
else:
|
|
151
|
+
# FACE_UP and FACE_DOWN: treat as right-side-up (no x/y rotation)
|
|
152
|
+
# The z-axis orientation doesn't affect 2D pixel mapping
|
|
153
|
+
src_x, src_y = x, y
|
|
154
|
+
|
|
155
|
+
# Source index in row-major order
|
|
156
|
+
src_idx = src_y * width + src_x
|
|
157
|
+
lut[physical_idx] = src_idx
|
|
158
|
+
|
|
159
|
+
return tuple(lut)
|