diff --git a/bemani/format/afp/blend/blend.py b/bemani/format/afp/blend/blend.py index 0d37676..6294d90 100644 --- a/bemani/format/afp/blend/blend.py +++ b/bemani/format/afp/blend/blend.py @@ -1,4 +1,3 @@ -import math import multiprocessing import signal from PIL import Image # type: ignore @@ -432,8 +431,8 @@ def pixel_renderer( # Essentially what we're doing here is calculating the scale, clamping it at 1.0 as the # minimum and then setting the AA sample swing accordingly. This has the effect of anti-aliasing # scaled up images a bit softer than would otherwise be achieved. - xscale = 1.0 / math.sqrt(inverse.a * inverse.a + inverse.b * inverse.b) - yscale = 1.0 / math.sqrt(inverse.c * inverse.c + inverse.d * inverse.d) + xscale = 1.0 / inverse.xscale + yscale = 1.0 / inverse.yscale # These are used for picking the various sample points for SSAA method below. xswing = 0.5 * max(1.0, xscale) @@ -446,7 +445,7 @@ def pixel_renderer( bilinear = False if xscale >= 1.0 and yscale >= 1.0: aaloc = inverse.multiply_point(Point(imgx + 0.5, imgy + 0.5)) - aax, aay = aaloc.as_tuple() + aax, aay, _ = aaloc.as_tuple() if not (aax <= 0 or aay <= 0 or aax >= (texwidth - 1) or aay >= (texheight - 1)): bilinear = True @@ -454,7 +453,7 @@ def pixel_renderer( if bilinear: # Calculate the pixel we're after, and what percentage into the pixel we are. texloc = inverse.multiply_point(Point(imgx + 0.5, imgy + 0.5)) - aax, aay = texloc.as_tuple() + aax, aay, _ = texloc.as_tuple() aaxrem = texloc.x - aax aayrem = texloc.y - aay @@ -498,7 +497,7 @@ def pixel_renderer( for addy in ypoints: for addx in xpoints: texloc = inverse.multiply_point(Point(imgx + addx, imgy + addy)) - aax, aay = texloc.as_tuple() + aax, aay, _ = texloc.as_tuple() # If we're out of bounds, don't update. Factor this in, however, so we can get partial # transparency to the pixel that is already there. @@ -541,7 +540,7 @@ def pixel_renderer( else: # Calculate what texture pixel data goes here. texloc = inverse.multiply_point(Point(imgx + 0.5, imgy + 0.5)) - texx, texy = texloc.as_tuple() + texx, texy, _ = texloc.as_tuple() # If we're out of bounds, don't update. if texx < 0 or texy < 0 or texx >= texwidth or texy >= texheight: diff --git a/bemani/format/afp/blend/blendcpp.pyi b/bemani/format/afp/blend/blendcpp.pyi index a03c6f9..d2316bb 100644 --- a/bemani/format/afp/blend/blendcpp.pyi +++ b/bemani/format/afp/blend/blendcpp.pyi @@ -1,7 +1,8 @@ from PIL import Image # type: ignore -from typing import Optional, Tuple +from typing import Optional + +from ..types import Color, Matrix -from ..types import Color, Matrix, Point def affine_composite( img: Image.Image, @@ -11,7 +12,7 @@ def affine_composite( mask: Optional[Image.Image], blendfunc: int, texture: Image.Image, - single_threaded: bool = False, - enable_aa: bool = True, + single_threaded: bool = ..., + enable_aa: bool = ..., ) -> Image.Image: ... diff --git a/bemani/format/afp/blend/blendcpp.pyx b/bemani/format/afp/blend/blendcpp.pyx index 85650f8..53a371a 100644 --- a/bemani/format/afp/blend/blendcpp.pyx +++ b/bemani/format/afp/blend/blendcpp.pyx @@ -11,16 +11,18 @@ cdef extern struct floatcolor_t: float a; cdef extern struct matrix_t: - float a; - float b; - float c; - float d; - float tx; - float ty; - -cdef extern struct point_t: - float x; - float y; + float a11; + float a12; + float a13; + float a21; + float a22; + float a23; + float a31; + float a32; + float a33; + float a41; + float a42; + float a43; cdef extern int affine_composite_fast( unsigned char *imgdata, @@ -106,7 +108,12 @@ def affine_composite( # Convert classes to C structs. cdef floatcolor_t c_addcolor = floatcolor_t(r=add_color.r, g=add_color.g, b=add_color.b, a=add_color.a) cdef floatcolor_t c_multcolor = floatcolor_t(r=mult_color.r, g=mult_color.g, b=mult_color.b, a=mult_color.a) - cdef matrix_t c_inverse = matrix_t(a=inverse.a, b=inverse.b, c=inverse.c, d=inverse.d, tx=inverse.tx, ty=inverse.ty) + cdef matrix_t c_inverse = matrix_t( + a11=inverse.a11, a12=inverse.a12, a13=inverse.a13, + a21=inverse.a21, a22=inverse.a22, a23=inverse.a23, + a31=inverse.a31, a32=inverse.a32, a33=inverse.a33, + a41=inverse.a41, a42=inverse.a42, a43=inverse.a43, + ) cdef unsigned int threads = 1 if single_threaded else multiprocessing.cpu_count() # Call the C++ function. diff --git a/bemani/format/afp/blend/blendcppimpl.cxx b/bemani/format/afp/blend/blendcppimpl.cxx index b448248..394e07e 100644 --- a/bemani/format/afp/blend/blendcppimpl.cxx +++ b/bemani/format/afp/blend/blendcppimpl.cxx @@ -24,29 +24,46 @@ extern "C" typedef struct point { float x; float y; + float z; struct point add(struct point other) { return (struct point){ x + other.x, y + other.y, + z + other.z, }; }; } point_t; typedef struct matrix { - float a; - float b; - float c; - float d; - float tx; - float ty; + float a11; + float a12; + float a13; + float a21; + float a22; + float a23; + float a31; + float a32; + float a33; + float a41; + float a42; + float a43; point_t multiply_point(point_t point) { return (point_t){ - (a * point.x) + (c * point.y) + tx, - (b * point.x) + (d * point.y) + ty, + (a11 * point.x) + (a21 * point.y) + (a31 * point.z) + a41, + (a12 * point.x) + (a22 * point.y) + (a32 * point.z) + a42, + (a13 * point.x) + (a23 * point.y) + (a33 * point.z) + a43, }; } + + float xscale() { + return sqrt((a11 * a11) + (a12 * a12) + (a13 * a13)); + } + + float yscale() { + return sqrt((a21 * a21) + (a22 * a22) + (a23 * a23)); + } } matrix_t; typedef struct work { @@ -253,8 +270,8 @@ extern "C" // costs us almost nothing. Essentially what we're doing here is calculating the scale, clamping it at 1.0 as the // minimum and then setting the AA sample swing accordingly. This has the effect of anti-aliasing scaled up images // a bit softer than would otherwise be achieved. - float xscale = 1.0 / sqrt(work->inverse.a * work->inverse.a + work->inverse.b * work->inverse.b); - float yscale = 1.0 / sqrt(work->inverse.c * work->inverse.c + work->inverse.d * work->inverse.d); + float xscale = 1.0 / work->inverse.xscale(); + float yscale = 1.0 / work->inverse.yscale(); // These are used for picking the various sample points for SSAA method below. float xswing = 0.5 * fmax(1.0, xscale); diff --git a/bemani/format/afp/render.py b/bemani/format/afp/render.py index 116bfa2..0978b2e 100644 --- a/bemani/format/afp/render.py +++ b/bemani/format/afp/render.py @@ -96,10 +96,10 @@ class Mask: class PlacedObject: # An object that occupies the screen at some depth. - def __init__(self, object_id: int, depth: int, rotation_offset: Point, transform: Matrix, mult_color: Color, add_color: Color, blend: int, mask: Optional[Mask]) -> None: + def __init__(self, object_id: int, depth: int, rotation_origin: Point, transform: Matrix, mult_color: Color, add_color: Color, blend: int, mask: Optional[Mask]) -> None: self.__object_id = object_id self.__depth = depth - self.rotation_offset = rotation_offset + self.rotation_origin = rotation_origin self.transform = transform self.mult_color = mult_color self.add_color = add_color @@ -129,7 +129,7 @@ class PlacedShape(PlacedObject): self, object_id: int, depth: int, - rotation_offset: Point, + rotation_origin: Point, transform: Matrix, mult_color: Color, add_color: Color, @@ -137,7 +137,7 @@ class PlacedShape(PlacedObject): mask: Optional[Mask], source: RegisteredShape, ) -> None: - super().__init__(object_id, depth, rotation_offset, transform, mult_color, add_color, blend, mask) + super().__init__(object_id, depth, rotation_origin, transform, mult_color, add_color, blend, mask) self.__source = source @property @@ -155,7 +155,7 @@ class PlacedClip(PlacedObject): self, object_id: int, depth: int, - rotation_offset: Point, + rotation_origin: Point, transform: Matrix, mult_color: Color, add_color: Color, @@ -163,7 +163,7 @@ class PlacedClip(PlacedObject): mask: Optional[Mask], source: RegisteredClip, ) -> None: - super().__init__(object_id, depth, rotation_offset, transform, mult_color, add_color, blend, mask) + super().__init__(object_id, depth, rotation_origin, transform, mult_color, add_color, blend, mask) self.placed_objects: List[PlacedObject] = [] self.frame: int = 0 self.unplayed_tags: List[int] = [i for i in range(len(source.tags))] @@ -254,7 +254,7 @@ class PlacedImage(PlacedObject): self, object_id: int, depth: int, - rotation_offset: Point, + rotation_origin: Point, transform: Matrix, mult_color: Color, add_color: Color, @@ -262,7 +262,7 @@ class PlacedImage(PlacedObject): mask: Optional[Mask], source: RegisteredImage, ) -> None: - super().__init__(object_id, depth, rotation_offset, transform, mult_color, add_color, blend, mask) + super().__init__(object_id, depth, rotation_origin, transform, mult_color, add_color, blend, mask) self.__source = source @property @@ -279,7 +279,7 @@ class PlacedDummy(PlacedObject): self, object_id: int, depth: int, - rotation_offset: Point, + rotation_origin: Point, transform: Matrix, mult_color: Color, add_color: Color, @@ -287,7 +287,7 @@ class PlacedDummy(PlacedObject): mask: Optional[Mask], source: RegisteredDummy, ) -> None: - super().__init__(object_id, depth, rotation_offset, transform, mult_color, add_color, blend, mask) + super().__init__(object_id, depth, rotation_origin, transform, mult_color, add_color, blend, mask) self.__source = source @property @@ -721,7 +721,7 @@ class AFPRenderer(VerboseOutput): new_mult_color = tag.mult_color or obj.mult_color new_add_color = tag.add_color or obj.add_color new_transform = tag.transform or obj.transform - new_rotation_offset = tag.rotation_offset or obj.rotation_offset + new_rotation_origin = tag.rotation_origin or obj.rotation_origin new_blend = tag.blend or obj.blend if tag.source_tag_id is not None and tag.source_tag_id != obj.source.tag_id: @@ -733,7 +733,7 @@ class AFPRenderer(VerboseOutput): operating_clip.placed_objects[i] = PlacedShape( obj.object_id, obj.depth, - new_rotation_offset, + new_rotation_origin, new_transform, new_mult_color, new_add_color, @@ -748,7 +748,7 @@ class AFPRenderer(VerboseOutput): operating_clip.placed_objects[i] = PlacedImage( obj.object_id, obj.depth, - new_rotation_offset, + new_rotation_origin, new_transform, new_mult_color, new_add_color, @@ -763,7 +763,7 @@ class AFPRenderer(VerboseOutput): new_clip = PlacedClip( tag.object_id, tag.depth, - new_rotation_offset, + new_rotation_origin, new_transform, new_mult_color, new_add_color, @@ -779,7 +779,7 @@ class AFPRenderer(VerboseOutput): operating_clip.placed_objects[i] = PlacedDummy( obj.object_id, obj.depth, - new_rotation_offset, + new_rotation_origin, new_transform, new_mult_color, new_add_color, @@ -798,7 +798,7 @@ class AFPRenderer(VerboseOutput): obj.mult_color = new_mult_color obj.add_color = new_add_color obj.transform = new_transform - obj.rotation_offset = new_rotation_offset + obj.rotation_origin = new_rotation_origin obj.blend = new_blend return None, True @@ -818,7 +818,7 @@ class AFPRenderer(VerboseOutput): PlacedShape( tag.object_id, tag.depth, - tag.rotation_offset or Point.identity(), + tag.rotation_origin or Point.identity(), tag.transform or Matrix.identity(), tag.mult_color or Color(1.0, 1.0, 1.0, 1.0), tag.add_color or Color(0.0, 0.0, 0.0, 0.0), @@ -835,7 +835,7 @@ class AFPRenderer(VerboseOutput): PlacedImage( tag.object_id, tag.depth, - tag.rotation_offset or Point.identity(), + tag.rotation_origin or Point.identity(), tag.transform or Matrix.identity(), tag.mult_color or Color(1.0, 1.0, 1.0, 1.0), tag.add_color or Color(0.0, 0.0, 0.0, 0.0), @@ -851,7 +851,7 @@ class AFPRenderer(VerboseOutput): placed_clip = PlacedClip( tag.object_id, tag.depth, - tag.rotation_offset or Point.identity(), + tag.rotation_origin or Point.identity(), tag.transform or Matrix.identity(), tag.mult_color or Color(1.0, 1.0, 1.0, 1.0), tag.add_color or Color(0.0, 0.0, 0.0, 0.0), @@ -875,7 +875,7 @@ class AFPRenderer(VerboseOutput): PlacedDummy( tag.object_id, tag.depth, - tag.rotation_offset or Point.identity(), + tag.rotation_origin or Point.identity(), tag.transform or Matrix.identity(), tag.mult_color or Color(1.0, 1.0, 1.0, 1.0), tag.add_color or Color(0.0, 0.0, 0.0, 0.0), @@ -1012,7 +1012,7 @@ class AFPRenderer(VerboseOutput): self.vprint(f"{prefix} Rendering placed object ID {renderable.object_id} from sprite {renderable.source.tag_id} onto Depth {renderable.depth}") # Compute the affine transformation matrix for this object. - transform = renderable.transform.multiply(parent_transform).translate(Point.identity().subtract(renderable.rotation_offset)) + transform = renderable.transform.multiply(parent_transform).translate(Point.identity().subtract(renderable.rotation_origin)) # Calculate blending and blend color if it is present. mult_color = (renderable.mult_color or Color(1.0, 1.0, 1.0, 1.0)).multiply(parent_mult_color) @@ -1331,7 +1331,7 @@ class AFPRenderer(VerboseOutput): frameno: int = 0 # Calculate actual size based on given movie transform. - actual_size = movie_transform.multiply_point(Point(swf.location.width, swf.location.height)).as_tuple() + resized_width, resized_height, _ = movie_transform.multiply_point(Point(swf.location.width, swf.location.height)).as_tuple() # TODO: If the location top/left is nonzero, we need move the root transform # so that the correct viewport is rendered. @@ -1360,7 +1360,7 @@ class AFPRenderer(VerboseOutput): # Stretch the image to make sure it fits the entire frame. imgwidth = float(background_image.width) imgheight = float(background_image.height) - background_matrix = Matrix( + background_matrix = Matrix.affine( a=swf.location.width / imgwidth, b=0, c=0, @@ -1388,10 +1388,10 @@ class AFPRenderer(VerboseOutput): -1, # The coordinates of the rectangle of the shape in screen space. [ - Point(0.0, 0.0), - Point(imgwidth, 0.0), + Point(0, 0), + Point(imgwidth, 0), Point(imgwidth, imgheight), - Point(0.0, imgheight), + Point(0, imgheight), ], # The coordinates of the original texture in UV space (we don't use this). [ @@ -1419,7 +1419,7 @@ class AFPRenderer(VerboseOutput): ) # Create the root mask for where to draw the root clip. - movie_mask = Image.new("RGBA", actual_size, color=(255, 0, 0, 255)) + movie_mask = Image.new("RGBA", (resized_width, resized_height), color=(255, 0, 0, 255)) # These could possibly be overwritten from an external source of we wanted. actual_mult_color = Color(1.0, 1.0, 1.0, 1.0) @@ -1452,7 +1452,7 @@ class AFPRenderer(VerboseOutput): if changed or last_rendered_frame is None: # Now, render out the placed objects. color = swf.color or Color(0.0, 0.0, 0.0, 0.0) - curimage = Image.new("RGBA", actual_size, color=color.as_tuple()) + curimage = Image.new("RGBA", (resized_width, resized_height), color=color.as_tuple()) curimage = self.__render_object(curimage, root_clip, movie_transform, movie_mask, actual_mult_color, actual_add_color, actual_blend, only_depths=only_depths) else: # Nothing changed, make a copy of the previous render. diff --git a/bemani/format/afp/swf.py b/bemani/format/afp/swf.py index 0427cf8..58d0d00 100644 --- a/bemani/format/afp/swf.py +++ b/bemani/format/afp/swf.py @@ -230,14 +230,21 @@ class AP2DefineButtonTag(Tag): class AP2PlaceCameraTag(Tag): - def __init__(self) -> None: - # TODO: I need to figure out what camera placements actually DO, and take the - # values that I parsed out store them here... + def __init__(self, camera_id: int, center: Optional[Point], focal_length: float) -> None: super().__init__(None) + # This is not actually Tag ID, just a way to refer to the camera. Confusing, I know. + # Probably this happened when they hacked 3D into the format. + self.camera_id = camera_id + self.center = center + self.focal_length = focal_length + def as_dict(self, *args: Any, **kwargs: Any) -> Dict[str, Any]: return { **super().as_dict(*args, **kwargs), + "camera_id": self.camera_id, + 'center': self.center.as_dict(*args, **kwargs) if self.center is not None else None, + "focal_length": self.focal_length, } @@ -283,7 +290,7 @@ class AP2PlaceObjectTag(Tag): blend: Optional[int], update: bool, transform: Optional[Matrix], - rotation_offset: Optional[Point], + rotation_origin: Optional[Point], mult_color: Optional[Color], add_color: Optional[Color], triggers: Dict[int, List[ByteCode]], @@ -315,7 +322,7 @@ class AP2PlaceObjectTag(Tag): # Whether there is a transform matrix to apply before placing/updating this object or not. self.transform = transform - self.rotation_offset = rotation_offset + self.rotation_origin = rotation_origin # If there is a color to blend with the sprite/shape when drawing. self.mult_color = mult_color @@ -338,7 +345,7 @@ class AP2PlaceObjectTag(Tag): 'blend': self.blend, 'update': self.update, 'transform': self.transform.as_dict(*args, **kwargs) if self.transform is not None else None, - 'rotation_offset': self.rotation_offset.as_dict(*args, **kwargs) if self.rotation_offset is not None else None, + 'rotation_origin': self.rotation_origin.as_dict(*args, **kwargs) if self.rotation_origin is not None else None, 'mult_color': self.mult_color.as_dict(*args, **kwargs) if self.mult_color is not None else None, 'add_color': self.add_color.as_dict(*args, **kwargs) if self.add_color is not None else None, 'triggers': {i: [b.as_dict(*args, **kwargs) for b in t] for (i, t) in self.triggers.items()} @@ -1134,6 +1141,7 @@ class SWF(TrackedCoverage, VerboseOutput): # Handle transformation matrix. transform = Matrix.identity() + transform_set = False if flags & 0x100: # Has scale component. @@ -1144,6 +1152,8 @@ class SWF(TrackedCoverage, VerboseOutput): transform.a = float(a_int) / 1024.0 transform.d = float(d_int) / 1024.0 + transform_set = True + self.vprint(f"{prefix} Transform Matrix A: {transform.a}, D: {transform.d}") if flags & 0x200: @@ -1155,6 +1165,8 @@ class SWF(TrackedCoverage, VerboseOutput): transform.b = float(b_int) / 1024.0 transform.c = float(c_int) / 1024.0 + transform_set = True + self.vprint(f"{prefix} Transform Matrix B: {transform.b}, C: {transform.c}") if flags & 0x400: @@ -1166,6 +1178,8 @@ class SWF(TrackedCoverage, VerboseOutput): transform.tx = float(tx_int) / 20.0 transform.ty = float(ty_int) / 20.0 + transform_set = True + self.vprint(f"{prefix} Transform Matrix TX: {transform.tx}, TY: {transform.ty}") # Handle object colors @@ -1325,7 +1339,9 @@ class SWF(TrackedCoverage, VerboseOutput): # I don't know however as I've not encountered data with this bit. self.vprint(f"{prefix} Unknown Filter data Count: {count}, Size: {filter_size}") - rotation_offset = None + rotation_origin = Point(0.0, 0.0, 0.0) + rotation_origin_set = False + if flags & 0x1000000: # I am certain that this is the rotation origin, as treating it as such works for # basically all files. @@ -1334,26 +1350,34 @@ class SWF(TrackedCoverage, VerboseOutput): self.add_coverage(dataoffset + running_pointer, 8) running_pointer += 8 - rotation_offset = Point(float(x) / 20.0, float(y) / 20.0) - self.vprint(f"{prefix} Rotation Origin: {rotation_offset}") + rotation_origin.x = float(x) / 20.0 + rotation_origin.y = float(y) / 20.0 + rotation_origin_set = True + + self.vprint(f"{prefix} Rotation XY Origin: {rotation_origin.x}, {rotation_origin.y}") if flags & 0x200000000: - # TODO: This might be z rotation origin? I've only seen it on files that have a place - # camera, and its setting a local value that is close to the rotation origin - # x and y constants. + # This is Z rotation origin. unhandled_flags &= ~0x200000000 z_int = struct.unpack(" None: + # A simple 3D point. For ease of construction, the Z can be left out + # at which point it is assumed to be zero. + def __init__(self, x: float, y: float, z: float = 0.0) -> None: self.x = x self.y = y + self.z = z @staticmethod def identity() -> "Point": - return Point(0.0, 0.0) + return Point(0.0, 0.0, 0.0) def as_dict(self, *args: Any, **kwargs: Any) -> Dict[str, Any]: return { 'x': self.x, 'y': self.y, + 'z': self.z, } - def as_tuple(self) -> Tuple[int, int]: - return (int(round(self.x, 5)), int(round(self.y, 5))) + def as_tuple(self) -> Tuple[int, int, int]: + return (int(round(self.x, 5)), int(round(self.y, 5)), int(round(self.z, 5))) def add(self, other: "Point") -> "Point": x = self.x + other.x y = self.y + other.y - return Point(x, y) + z = self.z + other.z + return Point(x, y, z) def subtract(self, other: "Point") -> "Point": x = self.x - other.x y = self.y - other.y - return Point(x, y) + z = self.z - other.z + return Point(x, y, z) def __repr__(self) -> str: - return f"x: {round(self.x, 5)}, y: {round(self.y, 5)}" + return f"x: {round(self.x, 5)}, y: {round(self.y, 5)}, z: {round(self.z, 5)}" class Rectangle: @@ -113,6 +120,12 @@ class Rectangle: class Matrix: + # A transformation matrix that can be used to calculate both affine and perspective + # transforms. This is a 4x4 matrix where the final column is assumed to be 0, 0, 0, 1. + # Note that for ease of construction and use with 2D-only parts of the rendering engine + # this is capable of being used as a standard 2D affine transformation matrix, as documented + # in the next paragraph. + # The classic SWF matrix. Technically it is missing the third column, but that # column never changes and thus can be omitted. Includes operations for multiplying # 2D points as well as other matrixes and inverting itself. This is how SWF (and @@ -124,72 +137,280 @@ class Matrix: # | c d 0 | # | tx ty 1 | - def __init__(self, a: float, b: float, c: float, d: float, tx: float, ty: float) -> None: - self.a = a - self.b = b - self.c = c - self.d = d - self.tx = tx - self.ty = ty + def __init__( + self, *, + a11: float, a12: float, a13: float, + a21: float, a22: float, a23: float, + a31: float, a32: float, a33: float, + a41: float, a42: float, a43: float, + ) -> None: + self.a11 = a11 + self.a12 = a12 + self.a13 = a13 + self.a21 = a21 + self.a22 = a22 + self.a23 = a23 + self.a31 = a31 + self.a32 = a32 + self.a33 = a33 + self.a41 = a41 + self.a42 = a42 + self.a43 = a43 @staticmethod def identity() -> "Matrix": - return Matrix(a=1.0, b=0.0, c=0.0, d=1.0, tx=0.0, ty=0.0) + return Matrix( + a11=1.0, a12=0.0, a13=0.0, + a21=0.0, a22=1.0, a23=0.0, + a31=0.0, a32=0.0, a33=1.0, + a41=0.0, a42=0.0, a43=0.0, + ) + + @staticmethod + def affine(*, a: float, b: float, c: float, d: float, tx: float, ty: float) -> "Matrix": + return Matrix( + a11=a, a12=b, a13=0.0, + a21=c, a22=d, a23=0.0, + a31=0.0, a32=0.0, a33=1.0, + a41=tx, a42=ty, a43=0.0, + ) + + def __is_affine(self) -> bool: + return ( + round(abs(self.a13), 5) == 0.0 and + round(abs(self.a23), 5) == 0.0 and + round(abs(self.a31), 5) == 0.0 and + round(abs(self.a32), 5) == 0.0 and + round(self.a33, 5) == 1.0 and + round(abs(self.a43), 5) == 0.0 + ) def as_dict(self, *args: Any, **kwargs: Any) -> Dict[str, Any]: - return { - 'a': self.a, - 'b': self.b, - 'c': self.c, - 'd': self.d, - 'tx': self.tx, - 'ty': self.ty, - } + if self.__is_affine: + return { + 'a': self.a, + 'b': self.b, + 'c': self.c, + 'd': self.d, + 'tx': self.tx, + 'ty': self.ty, + } + else: + return { + 'a11': self.a11, + 'a12': self.a12, + 'a13': self.a13, + 'a21': self.a21, + 'a22': self.a22, + 'a23': self.a23, + 'a31': self.a31, + 'a32': self.a32, + 'a33': self.a33, + 'a41': self.a41, + 'a42': self.a42, + 'a43': self.a43, + } + + @property + def xscale(self) -> float: + return math.sqrt((self.a11 * self.a11) + (self.a12 * self.a12) + (self.a13 * self.a13)) + + @property + def yscale(self) -> float: + return math.sqrt((self.a21 * self.a21) + (self.a22 * self.a22) + (self.a23 * self.a23)) + + @property + def a(self) -> float: + return self.a11 + + @a.setter + def a(self, val: float) -> None: + self.a11 = val + + @property + def b(self) -> float: + return self.a12 + + @b.setter + def b(self, val: float) -> None: + self.a12 = val + + @property + def c(self) -> float: + return self.a21 + + @c.setter + def c(self, val: float) -> None: + self.a21 = val + + @property + def d(self) -> float: + return self.a22 + + @d.setter + def d(self, val: float) -> None: + self.a22 = val + + @property + def tx(self) -> float: + return self.a41 + + @tx.setter + def tx(self, val: float) -> None: + self.a41 = val + + @property + def ty(self) -> float: + return self.a42 + + @ty.setter + def ty(self, val: float) -> None: + self.a42 = val + + @property + def tz(self) -> float: + return self.a43 + + @tz.setter + def tz(self, val: float) -> None: + self.a43 = val def multiply_point(self, point: Point) -> Point: return Point( - x=(self.a * point.x) + (self.c * point.y) + self.tx, - y=(self.b * point.x) + (self.d * point.y) + self.ty, + x=(self.a11 * point.x) + (self.a21 * point.y) + (self.a31 * point.z) + self.a41, + y=(self.a12 * point.x) + (self.a22 * point.y) + (self.a32 * point.z) + self.a42, + z=(self.a13 * point.x) + (self.a23 * point.y) + (self.a33 * point.z) + self.a43, ) def translate(self, point: Point) -> "Matrix": new_point = self.multiply_point(point) return Matrix( - a=self.a, - b=self.b, - c=self.c, - d=self.d, - tx=new_point.x, - ty=new_point.y, + a11=self.a11, + a12=self.a12, + a13=self.a13, + a21=self.a21, + a22=self.a22, + a23=self.a23, + a31=self.a31, + a32=self.a32, + a33=self.a33, + a41=new_point.x, + a42=new_point.y, + a43=new_point.z, ) def multiply(self, other: "Matrix") -> "Matrix": return Matrix( - a=self.a * other.a + self.b * other.c, - b=self.a * other.b + self.b * other.d, - c=self.c * other.a + self.d * other.c, - d=self.c * other.b + self.d * other.d, - tx=self.tx * other.a + self.ty * other.c + other.tx, - ty=self.tx * other.b + self.ty * other.d + other.ty, + a11=self.a11 * other.a11 + self.a12 * other.a21 + self.a13 * other.a31, + a12=self.a11 * other.a12 + self.a12 * other.a22 + self.a13 * other.a32, + a13=self.a11 * other.a13 + self.a12 * other.a23 + self.a13 * other.a33, + + a21=self.a21 * other.a11 + self.a22 * other.a21 + self.a23 * other.a31, + a22=self.a21 * other.a12 + self.a22 * other.a22 + self.a23 * other.a32, + a23=self.a21 * other.a13 + self.a22 * other.a23 + self.a23 * other.a33, + + a31=self.a31 * other.a11 + self.a32 * other.a21 + self.a33 * other.a31, + a32=self.a31 * other.a12 + self.a32 * other.a22 + self.a33 * other.a32, + a33=self.a31 * other.a13 + self.a32 * other.a23 + self.a33 * other.a33, + + a41=self.a41 * other.a11 + self.a42 * other.a21 + self.a43 * other.a31 + other.a41, + a42=self.a41 * other.a12 + self.a42 * other.a22 + self.a43 * other.a32 + other.a42, + a43=self.a41 * other.a13 + self.a42 * other.a23 + self.a43 * other.a33 + other.a43, ) def inverse(self) -> "Matrix": - denom = (self.a * self.d - self.b * self.c) + # Use gauss-jordan eliminiation to invert the matrix. + size = 4 + m = [ + [self.a11, self.a12, self.a13, 0.0], + [self.a21, self.a22, self.a23, 0.0], + [self.a31, self.a32, self.a33, 0.0], + [self.a41, self.a42, self.a43, 1.0], + ] + inverse: List[List[float]] = [[1 if row == col else 0 for col in range(size)] for row in range(size)] - try: - return Matrix( - a=self.d / denom, - b=-self.b / denom, - c=-self.c / denom, - d=self.a / denom, - tx=(self.c * self.ty - self.d * self.tx) / denom, - ty=-(self.a * self.ty - self.b * self.tx) / denom, - ) - except ZeroDivisionError: - pass + # First, get upper triangle of the matrix. + for col in range(size): + if col < size - 1: + numbers = [m[row][col] for row in range(col, size)] + if all(n == 0 for n in numbers): + print("HOO!") + raise ZeroDivisionError(f"Matrix({self}) cannot be inverted!") - # This happens if one of the scaling factors is zero. - raise ZeroDivisionError(f"Matrix({self}) cannot be inverted!") + # Reorder the matrix until all nonzero numbers are at the top. + for row in range(col, size - 1): + # First, make sure all non-zero values are at the top. + nrow = row - col + if numbers[nrow] == 0: + # Put this at the end. + numbers = [ + *numbers[:nrow], + *numbers[(nrow + 1):], + numbers[nrow], + ] + m = [ + *m[:row], + *m[(row + 1):], + m[row], + ] + inverse = [ + *inverse[:row], + *inverse[(row + 1):], + inverse[row], + ] + row += 1 + + # Now, figure out what multiplier we need to make every + # other entry zero. + major = m[col][col] + + for row in range(size): + if row == col: + continue + if m[row][col] != 0: + factor = -(m[row][col] / major) + + m = [ + *m[:row], + [m[row][i] + m[col][i] * factor for i in range(size)], + *m[(row + 1):], + ] + inverse = [ + *inverse[:row], + [inverse[row][i] + inverse[col][i] * factor for i in range(size)], + *inverse[(row + 1):], + ] + + # Finally, divide the current column to make it a unit. + factor = 1 / m[col][col] + m = [ + *m[:col], + [e * factor for e in m[col]], + *m[(col + 1):], + ] + inverse = [ + *inverse[:col], + [e * factor for e in inverse[col]], + *inverse[(col + 1):], + ] + + # Technically the rest of the matrix that we don't care about could have values other + # than 0.0, 0.0, 0.0, 1.0 but in practice that's because of floating point errors + # accumulating so we simply trust the math and discard those values. + return Matrix( + a11=inverse[0][0], a12=inverse[0][1], a13=inverse[0][2], + a21=inverse[1][0], a22=inverse[1][1], a23=inverse[1][2], + a31=inverse[2][0], a32=inverse[2][1], a33=inverse[2][2], + a41=inverse[3][0], a42=inverse[3][1], a43=inverse[3][2], + ) def __repr__(self) -> str: - return f"a: {round(self.a, 5)}, b: {round(self.b, 5)}, c: {round(self.c, 5)}, d: {round(self.d, 5)}, tx: {round(self.tx, 5)}, ty: {round(self.ty, 5)}" + if self.__is_affine: + return f"a: {round(self.a, 5)}, b: {round(self.b, 5)}, c: {round(self.c, 5)}, d: {round(self.d, 5)}, tx: {round(self.tx, 5)}, ty: {round(self.ty, 5)}" + else: + return "; ".join([ + f"a11: {round(self.a11, 5)}, a12: {round(self.a12, 5)}, a13: {round(self.a13, 5)}", + f"a21: {round(self.a21, 5)}, a22: {round(self.a22, 5)}, a23: {round(self.a23, 5)}", + f"a31: {round(self.a31, 5)}, a32: {round(self.a32, 5)}, a33: {round(self.a33, 5)}", + f"a41: {round(self.a41, 5)}, a42: {round(self.a42, 5)}, a43: {round(self.a43, 5)}", + ]) diff --git a/bemani/utils/afputils.py b/bemani/utils/afputils.py index 1a7531d..e666006 100644 --- a/bemani/utils/afputils.py +++ b/bemani/utils/afputils.py @@ -610,7 +610,7 @@ def render_path( requested_height *= scale_height # Calculate the overall view matrix based on the requested width/height. - transform = Matrix( + transform = Matrix.affine( a=requested_width / swf_location.width, b=0.0, c=0.0,