# -*- coding: utf-8 -*-
#
# New BSD license
#
# Copyright (c) DR0ID
# This file is part of spritesheetlib
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
# * Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# * Neither the name of the <organization> nor the
# names of its contributors may be used to endorse or promote products
# derived from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL DR0ID BE LIABLE FOR ANY
# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
"""
The spritesheet lib is a class to manipulate the spritesheet definitions. It can validate,
create, transform (spacing and margin) and save spritesheet definitions. It also can generate a pygame surface with
the outline of the sprites drawn on it (as template to draw sprites).
The spritesheet definition format is a json structure that allows to define the outline of a sprite as a polygon.
Versioning scheme based on: http://en.wikipedia.org/wiki/Versioning#Designating_development_stage
::
+-- api change, probably incompatible with older versions
| +-- enhancements but no api change
| |
major.minor[.build[.revision]]
| |
| +-|* x for x bugfixes
|
+-|* 0 for alpha (status)
|* 1 for beta (status)
|* 2 for release candidate
|* 3 for (public) release
.. versionchanged:: 0.0.0.0
initial version
"""
import logging
import os
import json
import sys
import itertools
__version__ = '1.0.3.0'
# for easy comparison as in sys.version_info but digits only
__version_info__ = tuple([int(d) for d in __version__.split('.')])
__author__ = "DR0ID"
__email__ = "dr0iddr0id {at} gmail [dot] com"
__copyright__ = "DR0ID @ 2014"
__credits__ = ["DR0ID", "Gummbum"] # list of contributors
__maintainer__ = "DR0ID"
__license__ = "New BSD license"
# list of public visible parts of this module
__all__ = ['AABB',
'DummyLogger',
'FileInfo',
'FileMode',
'get_area_of_polygon',
'get_needed_digits',
'get_orientation',
'get_signed_area_of_polygon',
'move_points',
'PolygonOrientation',
'sign',
'Sprite',
'SpriteListException',
'SpritesheetLib10',
'SpritesList',
'__version_info__',
'__version__',
]
__python_version_required__ = (2, 7)
if sys.version_info < __python_version_required__: # pragma: no cover
raise Exception("At least python '{0}' is required, found {1}.".format(__python_version_required__, sys.version_info))
# logging string format support extension
# :TODO: move to an extension module?
try:
# noinspection PyStatementEffect
str
_unicode = True
except NameError:
_unicode = False
_get_message = logging.LogRecord.getMessage
def _get_format_message(self):
try:
return self.getMessage(self)
except TypeError:
if not _unicode: # if no unicode support...
msg = str(self.msg)
else:
msg = self.msg
if not isinstance(msg, str):
try:
msg = str(self.msg)
except UnicodeError:
msg = self.msg # Defer encoding till later
if self.args:
args = self.args
msg = msg.format(*args)
return msg
logging.LogRecord.getMessage = _get_format_message
# noinspection PyMethodMayBeStatic
[docs]class DummyLogger(object):
"""
A dummy logger implementation. The methods are no-operations. Could be used as a basis to implement an interface
for another logging framework.
"""
[docs] def debug(self, msg, *args, **kwargs):
"""
The debug message to log.
:param msg: The message to log.
:param args: Arguments if msg is a format string.
:param kwargs: Keyword arguments.
"""
pass # pragma: no cover
[docs] def info(self, msg, *args, **kwargs):
"""
The info message to log.
:param msg: The message to log.
:param args: Arguments if msg is a format string.
:param kwargs: Keyword arguments.
"""
pass # pragma: no cover
[docs] def error(self, msg, *args, **kwargs):
"""
The error message to log.
:param msg: The message to log.
:param args: Arguments if msg is a format string.
:param kwargs: Keyword arguments.
"""
pass # pragma: no cover
[docs] def warn(self, msg, *args, **kwargs):
"""
The warn message to log.
:param msg: The message to log.
:param args: Arguments if msg is a format string.
:param kwargs: Keyword arguments.
"""
pass # pragma: no cover
[docs]class SpritesheetLib10(object):
"""
The spritesheet lib. For loading, saving, creating and manipulating a sprite definition v1.0.
sprite definition file format (xml)::
<version value="1.0"/>
<spritesheet filename="out.png.sdef" [width="" height=""] backgroundcolor="rrggbbaa">
<sprite name="" anchor="" points="" >
[<Transformations>
<alpha value=""/>
</Transformations>]
</sprite>
</spritesheet>
# minimal (json)::
{
"version": "1.0",
"filename": "out.png.sdef",
"sprites":
[
{
"name": "000",
"points": [(x1,y1), ],
},
{
"label": ""
"action": "",
"direction": "-1",
"gid": "", # unique number within sprite sheet
"row": "-1",
"col":"-1",
"points": [(x1,y1), ],
}
],
}
# full (json with <optional parts>)::
{
"version": "1.0",
"filename": "out.png.sdef",
"<backgroundcolor": [r, g, b, a],<255, 0, 255, 255>>
"sprites":
[
{
"gid": "000",
<"anchor": (x,y),<default: top left of AABB, otherwise relative coordinates to topleft of AABB>>
"points": [(x1,y1), ],
<"transformations":
[
{"type": "alpha", "value": 200},
{"type": "translation", "x": 200, "y": 200, "z": 200},
{"type": "rotation", "angle": 200, "axis-x": 0.0, "axis-y": 0.0, "axis-z": 1.0},
{"type": "scale", "x": 1.0, "y": 1.0, "z": 1.0},
{"type": "flip", "x": true, "y": false},
{"type": "shear", "?": 1.0},
{"type": "matrix", "?": 1.0},
]<None>>
<"properties": {'key': 'value', ...}>
},
{
"gid": "",
"anchor": (x,y),
"points": [(x1,y1), ],
"transformations":
[
{"name":alpha, "value":200},
]
<"properties": {'column': 2, 'row': 3, 'key': 'value', ...}>
}
]
}
:param logger: The logger instance to use.
"""
[docs] class ELEMENTS(object):
"""
The elements of the version 1 of the sprite definition.
"""
BACKGROUND = "backgroundcolor"
FILENAME = "filename"
VERSION = "version"
ANCHOR = "anchor"
GID = "gid"
SPRITES = "sprites"
POINTS = "points"
PROPERTIES = "properties"
[docs] class PROPERTIES(object):
"""
The properties that may be used by this library code.
update: just make an update of the properties, overwriting and extending
update_strict: additionally to update check if for every sprite there is an entry
extend: only add additional properties, do not overwrite
extend_strict: same as extend but checking if for every sprite there is an entry
"""
#: just make an update of the properties, overwriting and extending
MODE_UPDATE = "update"
#: additionally to update check if for every sprite there is an entry
MODE_UPDATE_STRICT = "update_strict"
#: only add additional properties, do not overwrite
MODE_EXTEND = "extend"
#: same as extend but checking if for every sprite there is an entry
MODE_EXTEND_STRICT = "extend_strict"
MODE = "mode"
ROW = "row"
COLUMN = "col"
FACING = "facing"
ACTION = "action"
def __init__(self, logger=DummyLogger()):
self.logger = logger
@staticmethod
[docs] def get_aabb_of_sprite_definition(sprite_definition):
"""
Calculates the AABB of the entire sprite definition (including all sprites).
:param sprite_definition: The sprite definition to calculate the AABB from.
:return: The AABB containing all sprites from the definition.
"""
aabb = AABB.from_points(
sprite_definition[SpritesheetLib10.ELEMENTS.SPRITES][0][SpritesheetLib10.ELEMENTS.POINTS])
for sprite in sprite_definition[SpritesheetLib10.ELEMENTS.SPRITES]:
aabb = aabb.union(AABB.from_points(sprite[SpritesheetLib10.ELEMENTS.POINTS]))
return aabb
@staticmethod
[docs] def create_empty_sprite_definition(file_name, background_color=None):
"""
Creates a empty sprite definition with some default values set.
:param file_name: The file name of the sprite definition.
:param background_color:
:return: A empty sprite definition in json format: a dictionary
"""
# if not background_color:
# background_color = [0, 0, 0, 255]
return {SpritesheetLib10.ELEMENTS.VERSION: "1.0",
SpritesheetLib10.ELEMENTS.FILENAME: file_name,
# ELEMENT_BACKGROUND: background_color,
SpritesheetLib10.ELEMENTS.SPRITES: []}
[docs] def update_sprite_properties(self, sprite_definition, properties):
"""
Updates the properties of the sprites. The new properties need an additional key-value pair called 'mode'.
The value of 'mode' can be one of the following: update, update_strict, extend, extend_strict
- 'update' does take what is in the properties dictionary and puts it into the existing one.
- 'update_strict' does the same as 'update' but checks that there is an entry for every gid.
- 'extend' does ignore the entries from the new dictionary if the key already exists in the sprites properties.
- 'extend_strict' does the same as extend but checks that there is an entry for every key
:param sprite_definition: The sprites to update.
:param properties: The properties to use for updating.
:return: None
"""
if properties:
# noinspection PyPep8Naming
PROPS = SpritesheetLib10.PROPERTIES
if PROPS.MODE not in properties:
raise KeyError("Properties: missing key: {0} ".format(PROPS.MODE))
mode = properties[PROPS.MODE]
if mode not in (
PROPS.MODE_UPDATE,
PROPS.MODE_UPDATE_STRICT,
PROPS.MODE_EXTEND,
PROPS.MODE_EXTEND_STRICT
):
raise ValueError("Properties: value '{0}' of '{1}' is unknown!".format(
mode, PROPS.MODE))
for sprite in sprite_definition.get(SpritesheetLib10.ELEMENTS.SPRITES, []):
sprite_gid = sprite[SpritesheetLib10.ELEMENTS.GID]
if mode in (PROPS.MODE_UPDATE_STRICT, PROPS.MODE_EXTEND_STRICT):
if sprite_gid not in properties:
gid_error_msg = "Properties: Completeness '{0}' requires that all gid are defined, " \
"missing gid: {1}"
raise KeyError(
gid_error_msg.format(PROPS.MODE_UPDATE_STRICT, sprite_gid))
new_props = properties.get(sprite_gid, {})
sprite_props = sprite[SpritesheetLib10.ELEMENTS.PROPERTIES]
if mode in (PROPS.MODE_UPDATE, PROPS.MODE_UPDATE_STRICT):
sprite_props.update(new_props)
elif mode in (PROPS.MODE_EXTEND, PROPS.MODE_EXTEND_STRICT):
for prop_key, prop_value in list(new_props.items()):
if prop_key not in sprite_props:
sprite_props[prop_key] = prop_value
[docs] def create_grid(self, num_tiles_x, num_tiles_y, tile_width, tile_height, file_info):
"""
Creates a sprite definition that contains a grid according to the arguments.
:param num_tiles_x: Number of tiles in x direction.
:param num_tiles_y: Number of tiles in y direction.
:param tile_width: The tile width in pixels.
:param tile_height: The tile height in pixels.
:param file_info: A FileInfo instance describing the filename to use in the sprite definition.
:return: New sprite definition with sprites arranged in a grid.
:raise ValueError: if number of digits is too small to hold all sprites.
"""
# TODO: should this method already respect the margin and spacing?
self.logger.debug("create_grid command: num_tiles: {0}/{1} size: {2}/{3} num_digits: {4} name: {5}")
sprite_definition = SpritesheetLib10.create_empty_sprite_definition(file_info.name)
gid = 0
for idx_y, y in enumerate(list(range(0, tile_height * num_tiles_y, tile_height))):
for idx_x, x in enumerate(list(range(0, tile_width * num_tiles_x, tile_width))):
points = []
for vx, vy in [(0, 0), (tile_width, 0), (tile_width, tile_height), (0, tile_height)]:
points.append([x + vx, y + vy])
properties = {SpritesheetLib10.PROPERTIES.COLUMN: idx_x, SpritesheetLib10.PROPERTIES.ROW: idx_y}
sprite_def = {
SpritesheetLib10.ELEMENTS.GID: gid,
SpritesheetLib10.ELEMENTS.POINTS: points,
SpritesheetLib10.ELEMENTS.PROPERTIES: properties
}
sprite_definition[SpritesheetLib10.ELEMENTS.SPRITES].append(sprite_def)
gid += 1
self.logger.debug('grid sprite definition created:\n\n{0}\n'.format(sprite_definition))
return sprite_definition
[docs] def adjust_spacing(self, source_sprite_def, spacing):
"""
Adjusts sprite definition to fulfill the spacing requirement. The margin is set to 0 (so no negative
coordinates exists for any sprites, this is important for the image).
:param source_sprite_def: The source sprite definition.
:param spacing: The spacing between the sprites in pixels. None: don't move the sprites, [0...) space between
sprites in pixels.
:return: New sprite definition respecting spacing.
:raise ValueError: If spacing or the margin is negative, ValueError is raised.
"""
self.logger.debug("converting sprite definition")
new_sdef = self.clone_sprite_definition(source_sprite_def)
if spacing is not None:
if spacing < 0:
err_msg = "Invalid spacing, should be greater than 0 instead of " + str(spacing)
self.logger.error(err_msg)
raise ValueError(err_msg)
else:
self.logger.debug("spacing: " + str(spacing))
boxes = []
for idx, sprite in enumerate(new_sdef[SpritesheetLib10.ELEMENTS.SPRITES]):
points = sprite[SpritesheetLib10.ELEMENTS.POINTS]
aabb = AABB.from_points(points)
self.logger.debug("generated aabb (index {2}) {0} from points {1} ".format(aabb, points, idx))
aabb.index = idx
boxes.append(aabb)
tiles = new_sdef[SpritesheetLib10.ELEMENTS.SPRITES]
self.logger.info("move sprites")
self._remove_collisions(tiles, boxes, spacing)
self.logger.info("move sprites done")
else:
self.logger.debug("no spacing: " + str(spacing))
new_sdef = self.adjust_margin(new_sdef, 0)
return new_sdef
def _remove_collisions(self, sprites, boxes, spacing):
# expand until no collisions are present
colliding, colliding_box = _has_collisions(boxes)
while colliding:
# double distance
for idx, box in enumerate(colliding):
dx = box.min_x
dy = box.min_y
sprite = sprites[box.index]
sprite[SpritesheetLib10.ELEMENTS.POINTS] = move_points(sprite[SpritesheetLib10.ELEMENTS.POINTS], dx, dy)
box.move_ip(dx, dy)
self.logger.debug("has more collisions?")
colliding, colliding_box = _has_collisions(boxes)
self.logger.debug("expand done")
# shrink x direction
boxes.sort(key=lambda b: (b.min_y, b.min_x))
for fix_idx, fixed_aabb in enumerate(boxes):
row = AABB(fixed_aabb.max_x, fixed_aabb.min_y + 1, sys.maxsize, fixed_aabb.max_y - 1)
colliding = row.collide_all(boxes[fix_idx + 1:])
if colliding:
box = colliding.pop(0)
dx = fixed_aabb.max_x + spacing - box.min_x
dy = 0
sprite = sprites[box.index]
sprite[SpritesheetLib10.ELEMENTS.POINTS] = move_points(sprite[SpritesheetLib10.ELEMENTS.POINTS], dx, dy)
box.move_ip(dx, dy)
for box in colliding:
sprite = sprites[box.index]
sprite[SpritesheetLib10.ELEMENTS.POINTS] = move_points(sprite[SpritesheetLib10.ELEMENTS.POINTS], dx,
dy)
box.move_ip(dx, dy)
self.logger.debug("adjust x done")
# shrink y direction
boxes.sort(key=lambda b: (b.min_x, b.min_y))
for fix_idx, fixed_aabb in enumerate(boxes):
row = AABB(fixed_aabb.min_x + 1, fixed_aabb.max_y, fixed_aabb.max_x - 1, sys.maxsize)
colliding = row.collide_all(boxes[fix_idx + 1:])
if colliding:
box = colliding.pop(0)
dx = 0
dy = fixed_aabb.max_y + spacing - box.min_y
sprite = sprites[box.index]
sprite[SpritesheetLib10.ELEMENTS.POINTS] = move_points(sprite[SpritesheetLib10.ELEMENTS.POINTS], dx, dy)
box.move_ip(dx, dy)
for box in colliding:
sprite = sprites[box.index]
sprite[SpritesheetLib10.ELEMENTS.POINTS] = move_points(sprite[SpritesheetLib10.ELEMENTS.POINTS], dx,
dy)
box.move_ip(dx, dy)
self.logger.debug("adjust y done")
[docs] def create_image(self, sprite_definition, pygame_module=None):
"""
Creates a image as pygame surface.
:param sprite_definition: The source sprite definition which the image is based on.
:param pygame_module: Default is None, therefore the pygame module is imported. Otherwise it should provide the
same functionality used in this method. Mainly used for testing with a mock object.
:return: pygame.Surface
"""
if pygame_module is None:
import pygame as pygame_module
aabb = SpritesheetLib10.get_aabb_of_sprite_definition(sprite_definition)
margin = min(aabb.min_x, aabb.min_y)
width = aabb.max_x + margin
height = aabb.max_y + margin
self.logger.debug("creating image of size {0}, {1}", width, height)
surf = pygame_module.Surface((width, height))
surf.fill((0, 0, 0))
for sprite in sprite_definition[SpritesheetLib10.ELEMENTS.SPRITES]:
self.logger.debug("drawing sprite {0}", sprite[SpritesheetLib10.ELEMENTS.GID])
points = sprite[SpritesheetLib10.ELEMENTS.POINTS]
# pygame.draw.polygon(surf, (255, 255, 255), points, 0)
pygame_module.draw.lines(surf, (255, 255, 255), True, points, 1)
return surf
[docs] def is_sprite_def_valid(self, sprite_definition):
# TODO: mandatory vs optional attributes
# mandatory: version, sprites, gid (unique), points
# optional: filename(not empty? or should be just same as filename?),
# backgroundcolor ([255, 0, 0, 255, 255]), anchor, transformations
# TODO: save/reload sdef, it should be the same afterwards
# serialized_sdef = self._serialize_to_json_string(sprite_definition) # json.dumps(sprite_def)
# reloaded_sdef = self._de_serialize_from_json_string(serialized_sdef) # json.loads(j)
# if sprite_def != reloaded_sdef:
# return False
# TODO: change to exceptions for failing validation
# TODO: check orientation, ccw vs cw -> use the signed area -> should a sprite have a orientation attribute?
# TODO: better doc string?
"""
Checks if the sprite definition version 1.0 is valid.
:param sprite_definition: The sprite definition to check.
:return: True if it validates, otherwise False.
"""
self.logger.debug("validating sprite definition")
# mandatory part
if sprite_definition is None:
self.logger.error("Invalid sprite definition: it was None")
return False
if SpritesheetLib10.ELEMENTS.VERSION not in sprite_definition:
self.logger.error("Invalid sprite definition: should have a 'version' attribute.")
return False
if sprite_definition[SpritesheetLib10.ELEMENTS.VERSION] != "1.0":
self.logger.error(
"Invalid sprite definition: should be version 1.0, found instead: " + sprite_definition[
SpritesheetLib10.ELEMENTS.VERSION])
return False
if SpritesheetLib10.ELEMENTS.FILENAME not in sprite_definition:
self.logger.error("Invalid sprite definition: should have a 'filename' attribute. ")
return False
if len(sprite_definition[SpritesheetLib10.ELEMENTS.FILENAME]) == 0:
self.logger.error("Invalid sprite definition: should have a filename.")
return False
if SpritesheetLib10.ELEMENTS.SPRITES not in sprite_definition:
self.logger.error("Invalid sprite definition: should have at least one sprite.")
return False
if len(sprite_definition[SpritesheetLib10.ELEMENTS.SPRITES]) == 0:
self.logger.error("Invalid sprite definition: should have at least one sprite.")
return False
orientation = sign(get_signed_area_of_polygon(
sprite_definition[SpritesheetLib10.ELEMENTS.SPRITES][0][SpritesheetLib10.ELEMENTS.POINTS]))
gids = []
for sprite in sprite_definition[SpritesheetLib10.ELEMENTS.SPRITES]:
if SpritesheetLib10.ELEMENTS.GID not in sprite:
self.logger.error("Invalid sprite definition: should have a gid for each sprite")
return False
gid = sprite[SpritesheetLib10.ELEMENTS.GID]
if gid in gids:
self.logger.error(
"Invalid sprite definition: sprite gid should be unique, duplicate found: " + str(gid))
return False
gids.append(gid)
if SpritesheetLib10.ELEMENTS.POINTS not in sprite:
self.logger.error("Invalid sprite definition: sprite should have points")
return False
if len(sprite[SpritesheetLib10.ELEMENTS.POINTS]) < 3:
self.logger.error(
"Invalid sprite definition: at least 3 coordinates are expected in points of sprite: " + gid)
return False
# check if the (three) points are not colinear (e.g. they form a polygon and don't lie on a line)
signed_area = get_signed_area_of_polygon(sprite[SpritesheetLib10.ELEMENTS.POINTS])
if signed_area == 0:
self.logger.error(
"Invalid sprite definition: sprite '" + str(gid) + "' has no area for polygon: " + str(
sprite[SpritesheetLib10.ELEMENTS.POINTS]))
return False
if sign(signed_area) != orientation:
self.logger.error("Invalid sprite definition: inconsistent orientation in sprite: " + gid)
return False
for idx, point in enumerate(sprite[SpritesheetLib10.ELEMENTS.POINTS]):
px, py = point
# test for negative coordinates
if px < 0 or py < 0:
self.logger.error(
"Invalid sprite definition: sprite should not have negative coordinates: " + str(
gid) + " " + str((px, py)))
return False
# test for points with same coordinates
for qx, qy in sprite[SpritesheetLib10.ELEMENTS.POINTS][idx + 1:]:
if px == qx and py == qy:
self.logger.warn(
"Two overlapping points found in sprite '" + gid + "': " + str((px, py)) + "==" + str(
(qx, qy)))
# optional parts
# TODO validate optional parts
return True
[docs] def clone_sprite_definition(self, source_sprite_def):
# TODO: use the serialization methods
# serialized_sdef = self._serialize_to_json_string(sprite_definition) # json.dumps(sprite_def)
# reloaded_sdef = self._de_serialize_from_json_string(serialized_sdef) # json.loads(j)
"""
Makes a deep copy (using json serialization).
:param source_sprite_def: The source sprite definition.
:return: Copy of source sprite definition.
"""
self.logger.debug("cloning definition")
json_string = json.dumps(source_sprite_def)
new_sdef = json.loads(json_string)
return new_sdef
[docs] def adjust_margin(self, sdef, margin):
"""
Creates a new sprite definition with added margin.
:param sdef: Sprite definition to manipulate.
:param margin: The margin to add in pixels.
:return: New sprite definition with adjusted margin.
:raise ValueError: ValueError is raised if the margin is negative.
"""
new_sdef = self.clone_sprite_definition(sdef)
if margin < 0:
err_msg = "Invalid margin, should be greater than 0 instead of " + str(margin)
self.logger.error(err_msg)
raise ValueError(err_msg)
else:
self.logger.debug("using margin " + str(margin))
aabb = SpritesheetLib10.get_aabb_of_sprite_definition(new_sdef)
dx = margin - aabb.min_x
dy = margin - aabb.min_y
for sprite in new_sdef[SpritesheetLib10.ELEMENTS.SPRITES]:
sprite[SpritesheetLib10.ELEMENTS.POINTS] = move_points(sprite[SpritesheetLib10.ELEMENTS.POINTS], dx, dy)
return new_sdef
[docs] def save_sprite_definition_to_disk(self, sprite_definition, file_info):
"""
Saves the sprite definition to disk to the file from file_info.
:param sprite_definition: The sprite definition to be saved.
:param file_info: The file info to save the sprite definition to.
"""
if file_info.exists:
self.logger.info("deleting already existing file '{0}' to replace it!".format(file_info.full_name))
file_info.delete()
self.logger.debug("saving sdef to disk as {0}", file_info)
with file_info.open(FileMode.WriteBinary) as stream:
json.dump(sprite_definition, stream, indent=4, separators=(',', ': '), sort_keys=True)
[docs] def save_image_to_disk(self, sprite_sheet_mask, file_info, pygame_module=None):
"""
Saves the image to disk to the file from the file_info.
:param pygame_module: The module to use as pygame. If None, pygame will be imported and used.
:param sprite_sheet_mask: The pygame.surface to save.
:param file_info: The file info to save the image to.
"""
self.logger.debug("saving image to disk as {0}", file_info)
if pygame_module is None: # pragma: no cover
import pygame as pygame_module # pragma: no cover
if file_info.exists:
self.logger.info("deleting already existing file '{0}' to replace it!".format(file_info.full_name))
file_info.delete()
pygame_module.image.save(sprite_sheet_mask, file_info.name)
[docs] def load_sdef_from_file(self, file_info, json_module=None):
"""
Loads a sprite definition from the file system.
:param json_module: The json module, mainly for mocking purposes.
:param file_info: The file info object containing the file name to load.
:raise ValueError: ValueError is raised if the file name element has not the same name as the file it contains.
"""
class _MyError(Exception):
def __init__(self, message):
Exception.__init__(self, message)
if json_module is None: # pragma: no cover
import json as json_module
# TODO: unittest with read binary should fail
json_file = file_info.open(FileMode.ReadText)
try:
sdef = json_module.load(json_file)
filename_from_sdef = sdef[SpritesheetLib10.ELEMENTS.FILENAME]
if filename_from_sdef != file_info.name: # case sensitive compare!!
raise _MyError("Sprite definitions element filename '{0}' "
"differs from loaded filename '{1}' ".format(filename_from_sdef, file_info.name))
# TODO: add optional default values!!
# TODO: unittests!
# for sprite in sdef[SpritesheetLib10.ELEMENTS.SPRITES]:
# if SpritesheetLib10.ELEMENTS.ROW not in sprite:
# sprite[SpritesheetLib10.ELEMENTS.ROW] = -1
# if SpritesheetLib10.ELEMENTS.COLUMN not in sprite:
# sprite[SpritesheetLib10.ELEMENTS.COLUMN] = -1
return sdef
except ValueError as ve:
# nicer formatting and better info about which file failed
raise ValueError("{0} from file '{1}' "
"(probably not a sprite definition file)!".format(ve.args, file_info.full_name))
except _MyError as me:
raise ValueError(me.args)
[docs] def get_image_name_from_sdef(self, sprite_definition):
"""
Returns the image name according to the sprite definition.
:param sprite_definition: The sprite definition file.
:return: The image name.
"""
return self.get_image_name_from_sdef_name(sprite_definition[SpritesheetLib10.ELEMENTS.FILENAME])
# noinspection PyMethodMayBeStatic
[docs] def get_image_name_from_sdef_name(self, sdef_file_name):
"""
Returns the image name according to the sprite definition name. It actually just chops of the last extension.
:param sdef_file_name: The sprite definition name.
:return: The image name.
"""
image_name, ext = os.path.splitext(sdef_file_name)
if image_name.rfind(os.extsep) == -1:
raise ValueError("sprite definition name is not of the form "
"[name]{1}[image extension]{1}[sprite definition extension]:"
" {0}".format(sdef_file_name, os.extsep))
return image_name
[docs] def load_spritesheet(self, sprite_definition_file_info, pygame_module=None):
"""
Loads the spritesheet and returns a dictionary containing the sprites keyed by their name.
:param sprite_definition_file_info: The sprite definition file info to load.
:param pygame_module: Default is None, therefore the pygame module is imported. Otherwise it should provide the
same functionality used in this method. Mainly used for testing with a mock object.
:return: SpritesList instance containing Sprite instances.
"""
sdef = self.load_sdef_from_file(sprite_definition_file_info)
# TODO: validate sdef??
image_file_name = os.path.join(sprite_definition_file_info.directory_name, self.get_image_name_from_sdef(sdef))
image_file_info = FileInfo(image_file_name)
return self.load_spritesheet_from_sdef(sdef, image_file_info, pygame_module)
[docs] def load_spritesheet_from_sdef(self, sprite_definition, image_file_info, pygame_module=None):
"""
Loads the sprites using the given sprite definition and the image file path.
:param sprite_definition:
:param image_file_info: The name of the image as file info to load.
:param pygame_module: Default is None, therefore the pygame module is imported. Otherwise it should provide the
same functionality used in this method. Mainly used for testing with a mock object.
:return: SpritesList instance containing Sprite instances.
"""
if pygame_module is None: # pragma: no cover
import pygame as pygame_module
# TODO: check if file exists, if not raise nice error message
sprite_sheet_surf = pygame_module.image.load(image_file_info.full_name)
return self.load_spritesheet_from_sdef_and_surface(sprite_definition, sprite_sheet_surf, pygame_module)
# noinspection PyMethodMayBeStatic
[docs] def load_spritesheet_from_sdef_and_surface(self, sprite_definition, sprite_sheet_surf, pygame_module=None):
"""
Loads the sprites from the sprite sheet using the sprite definition.
:param sprite_sheet_surf: The surface to use as the spritesheet.
:param sprite_definition: The sprite definition (json dict).
:param pygame_module: Default is None, therefore the pygame module is imported. Otherwise it should provide the
same functionality used in this method. Mainly used for testing with a mock object.
:return: SpritesList instance containing Sprite instances.
"""
if pygame_module is None: # pragma: no cover
import pygame as pygame_module
# TODO: add unittest to check if sdef fits on sprite_sheet_surface
# sprite_sheet_surf = pygame_module.image.load(sprite_sheet_surf.full_name)
sprites = SpritesList()
for sprite_def in sprite_definition[SpritesheetLib10.ELEMENTS.SPRITES]:
points = sprite_def[SpritesheetLib10.ELEMENTS.POINTS]
aabb = AABB.from_points(points)
rect = pygame_module.Rect(aabb.to_rect_tuple())
# noinspection PyArgumentList
mask = pygame_module.Surface(rect.size, pygame_module.SRCALPHA, 32) # alpha? flags?
mask.fill((0, 0, 0, 0))
local_points = move_points(points, -aabb.min_x, -aabb.min_y) # need to move the points to local coordinates
pygame_module.draw.polygon(mask, (255, 255, 255, 255), local_points, 0)
# noinspection PyArgumentList
image = pygame_module.Surface(rect.size, pygame_module.SRCALPHA, 32) # alpha? flags?
image.blit(sprite_sheet_surf, (0, 0), rect)
image.blit(mask, (0, 0), None, pygame_module.BLEND_RGBA_MULT)
name = sprite_def[SpritesheetLib10.ELEMENTS.GID]
anchor = [0, 0]
if SpritesheetLib10.ELEMENTS.ANCHOR in sprite_def:
anchor = sprite_def[SpritesheetLib10.ELEMENTS.ANCHOR] # TODO: use correct anchor point
properties = {}
if SpritesheetLib10.ELEMENTS.PROPERTIES in sprite_def:
properties = dict(sprite_def[SpritesheetLib10.ELEMENTS.PROPERTIES])
sprites.append(Sprite(image, anchor, name, properties))
return sprites
_missing_property = None
[docs]class SpriteListException(Exception):
"""
The SpriteListException class. Makes this exceptions catchable.
"""
pass
def _sort_func(spr, prop_name):
try:
return spr.properties[prop_name]
except KeyError:
raise SpriteListException("KeyError: Sprite (gid: {0}) has no '{1}' property!".format(spr.gid, prop_name))
[docs]class SpritesList(list):
"""
The SpriteList class. It is basically a list with some convenience methods to sort and group the sprites.
:param iterable: Initial items if not None.
"""
# noinspection PyTypeChecker
def __init__(self, iterable=None):
if iterable is None:
list.__init__(self)
else:
list.__init__(self, iterable)
[docs] def get_columns(self):
"""
Returns a list of columns, e.g. [[sprites of 1st column], [2.column], ...] if 'col' and 'row' properties
are defined, otherwise that sprite is left out (may result in an empty list). The inner lists are instance
of SpritesList. This will fail if any sprite that has a 'col' property is missing the 'row' property.
"""
# the inner_sort_key lambda will fail if not 'row' property exists, this is intentional!
groups = self.get_grouped_by_property(SpritesheetLib10.PROPERTIES.COLUMN,
group_sort_key=lambda spr: _sort_func(spr,
SpritesheetLib10.PROPERTIES.ROW))
if _missing_property in groups:
del groups[_missing_property]
columns = list(groups.values())
columns.sort(key=lambda cols: cols[0].properties[SpritesheetLib10.PROPERTIES.COLUMN])
return columns
[docs] def get_rows(self):
"""
Returns a list of rows, e.g. [[sprites of 1st row], [2. row], ...] if 'col' and 'row' properties are defined,
otherwise that sprite is left out (may result in an empty list). The inner lists are instance of SpritesList.
This will fail if any sprite that has a 'row' property is missing the 'col' property.
"""
# the inner_sort_key lambda will fail if not 'col' property exists, this is intentional!
groups = self.get_grouped_by_property(SpritesheetLib10.PROPERTIES.ROW,
group_sort_key=lambda spr: _sort_func(spr,
SpritesheetLib10.PROPERTIES.COLUMN))
if _missing_property in groups:
del groups[_missing_property]
rows = list(groups.values())
rows.sort(key=lambda cols: cols[0].properties[SpritesheetLib10.PROPERTIES.ROW])
return rows
[docs] def get_grouped_by_property(self, property_name, group_sort_key=None):
"""
Returns a dict containing the groups indexed by key, e.g. {key: [spr1, spr2, ...], ...}
:param property_name: The name of the property that should be grouped by.
:param group_sort_key: The key function used to sort the groups.
:return: Dictionary.
"""
grouped_sprites = {}
# TODO: maybe the property needs a type conversion? Or should this be done before, in a separate step?
key_func = lambda spr: (spr.properties.get(property_name, _missing_property) is None, spr.properties.get(property_name, _missing_property))
sprites = sorted(self, key=key_func)
grouped = itertools.groupby(sprites, key=key_func)
for key, group in grouped:
sprites_group = SpritesList(group)
if key == (True, _missing_property):
grouped_sprites[_missing_property] = sprites_group
else:
# TODO: default sort should be by GID
if group_sort_key is not None:
sprites_group.sort(key=group_sort_key)
grouped_sprites[key[1]] = sprites_group
return grouped_sprites
[docs] def get_grouped_by_facing_and_action(self):
"""
Returns a dict containing the sprites sorted as follows::
{
facing1:{action1:[sprites], action2:[sprites], ...},
facing2:{action1:[sprites], action2:[sprites], ...},
facing3:{action1:[sprites], action3:[sprites], ...},
...
}
:return: New dict containing the sprites with key by 'facing' then by 'action'.
"""
grouped_by_facing = self.get_grouped_by_property(SpritesheetLib10.PROPERTIES.FACING,
group_sort_key=lambda spr:
_sort_func(spr, SpritesheetLib10.PROPERTIES.ACTION))
result = {}
for facing, sprites in list(grouped_by_facing.items()):
# TODO: here gid order is assumed! make it changeable? custom group_sort_key for sorting sprites?
result[facing] = SpritesList(sprites).get_grouped_by_property(SpritesheetLib10.PROPERTIES.ACTION,
group_sort_key=lambda spr: spr.gid)
return result
[docs] def get_grouped_by_action_and_facing(self):
"""
Returns a dict containing the sprites sorted as follows::
{
action1:{facing1:[sprites], facing2:[sprites], ...},
action2:{facing1:[sprites], facing2:[sprites], ...},
action3:{facing1:[sprites], facing2:[sprites], ...},
...
}
This allows to easily find out what actions are available in the spritesheet.
:return: New dict containing the sprites with key by 'action' then by 'facing'.
"""
grouped_by_action = self.get_grouped_by_property(SpritesheetLib10.PROPERTIES.ACTION,
group_sort_key=lambda spr:
_sort_func(spr, SpritesheetLib10.PROPERTIES.FACING))
result = {}
for action, sprites in list(grouped_by_action.items()):
# TODO: here gid order is assumed! make it changeable? custom group_sort_key for sorting sprites?
result[action] = SpritesList(sprites).get_grouped_by_property(SpritesheetLib10.PROPERTIES.FACING,
group_sort_key=lambda spr: spr.gid)
return result
[docs] def get_gid_slice(self, start_gid, end_gid=sys.maxsize):
"""
Returns a SpritesList containing all sprites with start <= gid <= end. May be empty or the sequence of gid
could have gaps (if some gid is missing).
:param start_gid: The first gid that should be considered.
:param end_gid: The last gid that should be considered. If not set, all sprites to the end will be returned.
"""
return SpritesList([spr for spr in (sorted(self, key=lambda spr: spr.gid)) if start_gid <= spr.gid <= end_gid])
[docs]class Sprite(object):
"""
The sprite class.
:param image: The pygame surface containing the image data.
:param anchor: The anchor point.
:param gid: The global id of this image (from the sprite definition).
:param properties: The properties of this Sprite as defined in the sprite definition as dictionary {'name': value}.
"""
def __init__(self, image, anchor, gid, properties):
self.image = image
self.anchor = anchor
self.gid = gid
self.properties = properties
[docs]class AABB(object):
"""
Axis aligned bounding box.
:param min_x: the min x coordinate
:param min_y: the min y coordinate
:param max_x: the max x coordinate
:param max_y: the max y coordinate
"""
def __init__(self, min_x, min_y, max_x, max_y):
# TODO: convert asserts to if with value switch??
assert min_x <= max_x
assert min_y <= max_y
self.min_x = min_x
self.min_y = min_y
self.max_x = max_x
self.max_y = max_y
@property
def center_x(self):
"""
Returns the calculated center x value.
:return: calculated center in x.
"""
return self.min_x + (self.max_x - self.min_x) // 2
@property
def center_y(self):
"""
Returns the calculated center y value.
:return: calculated center in y.
"""
return self.min_y + (self.max_y - self.min_y) // 2
[docs] def to_rect_tuple(self):
"""
Converts the AABB to a tuple representing a rect in the format (x, y, w, h).
:return: tuple with following coordinates: (x, y, width, height)
"""
return self.min_x, self.min_y, self.max_x - self.min_x, self.max_y - self.min_y
# TODO: rename?
[docs] def copy_from(self, other):
"""
Copies the values from the other instance.
:param other: Other AABB instance.
"""
self.min_x = other.min_x
self.min_y = other.min_y
self.max_x = other.max_x
self.max_y = other.max_y
[docs] def copy(self):
"""
Creates a new AABB instance with the same values.
:return: New AABB with same values.
"""
return AABB(self.min_x, self.min_y, self.max_x, self.max_y)
[docs] def collide(self, other):
"""
Check if this AABB collides with other AABB.
:param other: other AABB to collide with.
:return: True if they have a common area, otherwise False.
"""
if self.min_x > other.max_x or self.max_x < other.min_x or self.min_y > other.max_y or self.max_y < other.min_y:
return False
return True
[docs] def collide_all(self, others):
"""
Checks if any of the other AABBs collide.
:param others: list or iterable of other AABB.
:return: List of colliding AABB. May be empty.
"""
colliding = []
colliding_append = colliding.append
for other in others:
if self.min_x > other.max_x or self.max_x < other.min_x or \
self.min_y > other.max_y or self.max_y < other.min_y:
continue
colliding_append(other)
return colliding
[docs] def move(self, dx, dy):
"""
Creates a new instance of an AABB that is moved by dx, dy
:param dx: the distance to move in x direction.
:param dy: the distance to move in y direction.
:return: a new AABB instance that is moved by dx, dy.
"""
return AABB(self.min_x + dx, self.min_y + dy, self.max_x + dx, self.max_y + dy)
[docs] def move_ip(self, dx, dy):
"""
Move this AABB instance about dx, dy.
:param dx: the distance to move in x direction.
:param dy: the distance to move in y direction.
"""
self.min_x += dx
self.min_y += dy
self.max_x += dx
self.max_y += dy
@staticmethod
[docs] def from_points(points):
"""
Creates a AABB from a bunch of points in the form [(px, py),...].
:param points: a list of points in px, py coordinates, e.g. [(px, py), ....].
:return: The AABB containing all points.
"""
aabb = AABB(points[0][0], points[0][1], points[0][0], points[0][1])
for px, py in points:
if px < aabb.min_x:
aabb.min_x = px
if py < aabb.min_y:
aabb.min_y = py
if px > aabb.max_x:
aabb.max_x = px
if py > aabb.max_y:
aabb.max_y = py
return aabb
def __str__(self):
return self.__repr__()
def __repr__(self):
return "<AABB({0}, {1}, {2}, {3})>".format(self.min_x, self.min_y, self.max_x, self.max_y)
def __eq__(self, other):
if isinstance(other, AABB):
return self.min_x == other.min_x and self.min_y == other.min_y and \
self.max_x == other.max_x and self.max_y == other.max_y
return NotImplemented
def __ne__(self, other):
result = self.__eq__(other)
if result is NotImplemented:
return result
return not result
[docs] def union_all(self, others):
"""
Creates a new instance which contains all other AABB instances.
:param others: iterable of other AABB instances.
:return: an AABB containing all other AABB.
"""
aabb = self.copy()
for other in others:
if other.min_x < aabb.min_x:
aabb.min_x = other.min_x
if other.min_y < aabb.min_y:
aabb.min_y = other.min_y
if other.max_x > aabb.max_x:
aabb.max_x = other.max_x
if other.max_y > aabb.max_y:
aabb.max_y = other.max_y
return aabb
[docs] def union(self, other):
"""
Creates a new instances containing the other AABB.
:param other: another AABB instance.
:return: an AABB containing the other AABB.
"""
aabb = self.copy()
if other.min_x < aabb.min_x:
aabb.min_x = other.min_x
if other.min_y < aabb.min_y:
aabb.min_y = other.min_y
if other.max_x > aabb.max_x:
aabb.max_x = other.max_x
if other.max_y > aabb.max_y:
aabb.max_y = other.max_y
return aabb
[docs]class FileMode(object):
"""
This is a 'enum' enumerating the file modes.
WriteUpdate corresponds to 'w+' and truncates the file!
"""
# TODO: document values
#: same as ReadText, read text mode
Read = 'r'
Write = "w"
Append = 'a'
ReadBinary = 'rb'
WriteBinary = 'wb'
AppendBinary = 'ab'
ReadUpdate = 'r+'
#: Truncates the file!
WriteUpdate = 'w+'
AppendUpdate = 'a+'
ReadUpdateBinary = 'r+b'
#: Truncates the file to 0 bytes!
WriteUpdateBinary = 'w+b'
AppendUpdateBinary = 'a+b'
ReadUniversalNewLines = 'rU'
ReadText = 'rt'
WriteText = 'wt'
[docs]class FileInfo(object):
"""
This is an object for easier file manipulation.
:param file_path: the path to the file, it must be a file.
:raise ValueError: ValueError is raised if the file path ends with either os.sep, os.altsep, os.curdir or the file
path does contain a os.pathsep or the file path equals os.devnull.
"""
def __init__(self, file_path, os_module=os):
if file_path.endswith(os.sep) or \
(os.altsep is not None and file_path.endswith(os.altsep)) or \
file_path.endswith(os.curdir) or \
file_path.find(os.pathsep) > -1 or \
file_path == os.devnull or \
os_module.path.isdir(file_path):
raise ValueError("Given path is not a file: " + file_path)
if os.altsep is not None: # hack to enforce forward slash on windows
file_path = file_path.replace(os.sep, os.altsep)
self._file_path = file_path
self._os_module = os_module
@property
def exists(self):
"""
The exists property indicates if the file exists on the file system.
:return: True, if the file exists on the file system, otherwise False.
"""
return self._os_module.path.exists(self.full_name) # and not self.is_casing_different
@property
def is_casing_different(self):
"""
Returns true if the files on the filesystem differs in the casing. Only relevant on windows.
Otherwise it returns False, as if the file does not exist.
:return: True if the casing is different than the one on the filesystem, otherwise False.
"""
if self._os_module.path.exists(self.directory_name) and self._os_module.path.exists(self.full_name):
files = self._os_module.listdir(self.directory_name)
try:
files.index(self.name)
# TODO: check if path has same casing too
except ValueError:
return True
return False
@property
def name(self):
"""
Name of the file.
:return: The name of the file.
"""
return self._os_module.path.split(self.full_name)[1]
@property
def full_name(self):
"""
The full name of the file, containing the path.
:return: The path and filename of the file.
"""
return self._file_path
@property
def directory_name(self):
"""
The directory name where the file is.
:return: The directory of the file.
"""
return self._os_module.path.dirname(self.full_name)
[docs] def open(self, mode, buffering=-1):
"""
Opens the file for manipulation.
:param mode: the file open mode. See FileMode.
:param buffering: the buffering.
:return: a file descriptor.
"""
if self.is_casing_different:
raise IOError("File with different casing " + self.full_name + " detected. Delete or rename it!")
return open(self.full_name, mode, buffering)
[docs] def delete(self):
"""
Deletes the file.
"""
self._os_module.remove(self._file_path)
def __eq__(self, other):
if isinstance(other, FileInfo):
return self.full_name == other.full_name
return NotImplemented
def __ne__(self, other):
result = self.__eq__(other)
if result is NotImplemented:
return result
return not result
def __str__(self):
return self.__repr__()
def __repr__(self):
return "<FileInfo('{0}')>".format(self.full_name)
[docs]def get_needed_digits(integer_number):
"""
Calculates the number of digits that are needed to represent a number as string.
:param integer_number: The number to get the digits from.
:return: :raise ValueError: if its not of type int of it is negative, ValueError is raised.
"""
if not isinstance(integer_number, int):
raise TypeError("should be of type int but was " + str(type(integer_number)))
if integer_number < 0:
raise ValueError("should be equal or greater than zero, but was ", str(integer_number))
# count = 0
# while value > 0:
# count += 1
# value /= 10
# return count if count > 0 else 1
# this seems pretty elegant, but is it fast?
return len(str(integer_number))
# TODO: move this into SpritesheetLib10
[docs]def move_points(points, dx, dy):
"""
Moves the points about dx, dy.
:param points: iterable of points, e.g. [(p1x, p1y), (p2x, p2y),....]
:param dx: Distance to move in x direction, positive or negative.
:param dy: Distance to move in y direction, positive or negative.
:return: returns a new list of moved points as list of lists, e.g. [[p1x, p1y], [p2x, p2y],....]
"""
moved_points = []
for px, py in points:
moved_points.append([px + dx, py + dy])
return moved_points
# TODO: move this into SpritesheetLib10
def _has_collisions(boxes):
"""
Checks if any AABB collide. Stops when first collision is detected.
:param boxes: iterable of AABB instances.
:return: if collisions: tuple of list of colliding AABB and the AABB they collide with otherwise (None, None)
"""
for idx, box in enumerate(boxes):
colliding = box.collide_all(boxes[idx + 1:])
if colliding:
return colliding, box
return None, None
# TODO: move this functions into a math lib?
[docs]def sign(v):
# TODO: is this a step function
"""
Sign function.
:param v: Value to return the sign.
:return: -1 for negative values, 0 for 0, 1 for positive values.
"""
if v < 0:
return -1
elif v > 0:
return 1
return 0
[docs]class PolygonOrientation(object):
"""
This is an 'enum' to define Clockwise and CounterClockwise.
Clockwise=1
CounterClockwise=-1
"""
Clockwise = 1
CounterClockwise = -1
[docs]def get_orientation(points):
"""
Finds the orientation of a list of points.
:param points: list of points, e.g. [(p1x, p1y), (p2x, p2y),...]
:return: Returns 1 for Clockwise orientation, otherwise -1 for Counterclockwise.
"""
return sign(get_signed_area_of_polygon(points))
[docs]def get_signed_area_of_polygon(points, epsilon=sys.float_info.epsilon):
"""
Calculates the signed area of a polygon.
:param points: List of points, e.g. [(p1x, p1y), (p2x, p2y), ...]
:param epsilon: Precision, any absolute area value smaller than epsilon will be treated as 0.
:return: The signed area of the polygon. Positive for a clockwise order of points, otherwise negative.
"""
points = list(points) # handle iterators
assert len(points) >= 3
# Shoelace formula, this implementation isn't probably the fastest nor the most accurate
# area = 0.5 * sum([p[0] * points[idx - 1][1] - p[1] * points[idx - 1][0] for idx, p in enumerate(points)])
area = 0.5 * sum([(p[0] - points[idx - 1][0]) * (p[1] + points[idx - 1][1]) for idx, p in enumerate(points)])
if abs(area) <= epsilon:
return 0.0
return area
[docs]def get_area_of_polygon(points, epsilon=sys.float_info.epsilon):
"""
Calculates the (unsigned) area of a polygon.
:param points: List of points, e.g. [(p1x, p1y), (p2x, p2y), ....]
:param epsilon: Precision, any absolute area value smaller than epsilon will be treated as 0.
:return: The area of the polygon. Always positive or 0.
"""
return abs(get_signed_area_of_polygon(points, epsilon))