joshbduncan / word-search-generator

Make awesome Word Search puzzles!
MIT License
75 stars 24 forks source link

Implement shape masks #20

Closed duck57 closed 1 year ago

duck57 commented 2 years ago

I made a very basic proof of concept that's disconnected from the rest of the generator code this afternoon. I've put plenty of TODO items in the commit message. Some of the code I wrote seems quite duplicituous. Let's use this issue for higher-level design discussions and comments on the commit to discuss the implementation in its current code.

I was inspired to write this as well as #21 after stumbling across a different Python word search generator

I could implement this one, but if I don't get the time to finish it before Halloween, I may not be able to return until March [unlikely that it will be untouched for that entire time, but I expect to have much less availablilty to work on side projects over the winter].

duck57 commented 2 years ago

Before I do any more work on this, what do you think of my Puzzle object and approach to the modification functions? My gut feeling suggests that there is some far easier way to wrap a list[list[chr]] into an object and handle all those modification functions. However, I'm drawing a blank as to what those changes may be.

joshbduncan commented 2 years ago

Hey, I was out of town last weekend so I haven't had the time to review this. I definitely like the idea so I'll try to take a look at the implementation this weekend and get back to you. Thanks!

duck57 commented 2 years ago

Take your time: I’ll be traveling this weekend, so I won’t have time to poke at this again until next week.  Once we decide on the data structure and filter function strategy, it should mostly be a straightforward plug-and-chug for me to update the existing generate.py code to use the new functionality and implement several more built-in filters to be available.

When you get to look at this, let me know if you think of any CLI ergonomics.  As of now, I do not plan to implement CLI access to this functionality, but I have nothing against adding it if you have a suggestion ready by the time I start trying to implement this for real.  If you instead implement the CLI for this after I’ve submitted my initial PR, that’s fine by me.  My initial guess says that this feature set is more relevant when using the generator as a library rather than as an app.

— Chris On Oct 13, 2022, 10:33 -0400, Josh Duncan @.***>, wrote:

Hey, I was out of town last weekend so I haven't had the time to review this. I definitely like the idea so I'll try to take a look at the implementation this weekend and get back to you. Thanks! — Reply to this email directly, view it on GitHub, or unsubscribe. You are receiving this because you authored the thread.Message ID: @.***>

joshbduncan commented 2 years ago

Yes, I agree about the CLI. Maybe it's something we can implement later but I think it's best to just get everything working in the API.

duck57 commented 2 years ago

This is a bit of a thought dump for when I go to do the implementation. Feel free to ignore this if you don't find it useful when giving my demo code an in-depth review.

Data Structure of Puzzle

For 90% of what I want to do, list[list[str]] is all I need. However, there are some instances when I think treating the Puzzle as dict[Position, str] (a.k.a. dict[tuple[int, int], str]) would be more convenient.

Properties or just additional tasks for the setter?

I highly doubt this would become a performance-sensitive issue. However, I'm unfamiliar with how Python caches its @properties—would they need to be recomputed every time they're accessed or just each time they've been accessed after the Puzzle has changed?

Structure of filter functions

I'm going to change up the signature of these functions. Namely, instead of some ad-hoc boolean about whether to blackout or clear the selection, separate out the selections and effects into separate functions. Remaining to be decided: does the selection function take an effect function as a param or do I make the filter list accept tuples, making it list[tuple[selection, effect]]?

Typing this out, probably best to use the effect function as a param in the pattern function.

Below are the four effect functions I've thought of.

def mark_oob(_: chr) -> chr:
    return config.OOB_CHR

def mark_clear(_: chr) -> chr:
    return ""

def toggle_cell(c: chr) -> chr:
    return "" if c == config.OOB_CHR else config.OOB_CHR

def random(c: chr, effect: Optional[Callable[[chr], chr]] = None, strength: float = 0.5) -> chr:
    if not effect:
        effect = random.choice([mark_oob, mark_clear, toggle_cell])
    return effect(c) if random.random() < strength else c
  1. Do I make these (or the selection functions) functions or methods of a Puzzle instance?
  2. I'm going to have to get familiar with functools.partial if I don't want to use nested functions, won't I?

List of patterns to implement:


There were probably some other items, but I started typing this over my lunch break on Friday and then returned to it after work this evening, so there's plenty I've forgotten.

joshbduncan commented 2 years ago

@duck57, if we are going to put in the work to build this out I want to clean up a few things to make the module a bit more robust before we start.

So far, I've already done the work to create a word class that tracks the text, position, coordinates (for potential use), xy position for display. So far, it's def better and just keeping a set of words and a separate key. I just need to finish implementing this into the function.

I'm also cleaning up the utility and generate function to use the actual puzzle object so that we don't have to pass around so many variables. I just pass the puzzle object and extract the variable I need to do the work from there.

Next, I'm going to clean up the actual puzzle generator function. I bolted on checks as I implemented them so I need to go back in and clean things up.

I hope to have this all done in the next few days.

duck57 commented 2 years ago

I see you've made some of the changes already. I've done similar refactors (RE: moving things to a class because of excessively redundant function signatures) on my own projects before.

Is the plan to finish the cleanup and then merge #17? If so, I'll wait until after that has been merged to explore more so there will be a stable foundation to build from.

joshbduncan commented 2 years ago

Exactly! I've got the work done for creating the word class object and will commit it tomorrow. I think that's the last thing I'm going to squeeze into that PR. I wanted to have a more solid foundation and easier extensibility before we work to add these advanced features. It would be even harder to make the switch later.

duck57 commented 2 years ago

For the sake of consistent nomenclature, should this feature be called "masks" or "filters"?

joshbduncan commented 2 years ago

@duck57, I like masks. I feel like more people would understand that over filer.

joshbduncan commented 2 years ago
  1. Do I make these (or the selection functions) functions or methods of a Puzzle instance?

I would make them methods of the Puzzle object since they only interact with a "puzzle".

joshbduncan commented 2 years ago

2. I'm going to have to get familiar with functools.partial if I don't want to use nested functions, won't I?

I've never actually used functools.partial in a working program ( just doesn't come to mind)... But yes, it could certainly help to reduce nesting and clean up a function.

joshbduncan commented 2 years ago

So, on your proof of concept, I see that masks are applied by expanding the puzzle. What is your plan for making that work with puzzle size? Say I supply a puzzle size of 10h x 20w like below with some random masks.

>>> p = Puzzle(
    10,
    20,
    masks=[
        expand(0, 4, True),
        expand(0, -7, True),
        expand(-3, 0, False),
        expand(3, 0, False),
        expand(0, 2, False),
        expand(0, -2, False),
    ],
)
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
. . # # # # # # # . . . . . . . . . . . . . . . . . . . . # # # # . .
. . # # # # # # # . . . . . . . . . . . . . . . . . . . . # # # # . .
. . # # # # # # # . . . . . . . . . . . . . . . . . . . . # # # # . .
. . # # # # # # # . . . . . . . . . . . . . . . . . . . . # # # # . .
. . # # # # # # # . . . . . . . . . . . . . . . . . . . . # # # # . .
. . # # # # # # # . . . . . . . . . . . . . . . . . . . . # # # # . .
. . # # # # # # # . . . . . . . . . . . . . . . . . . . . # # # # . .
. . # # # # # # # . . . . . . . . . . . . . . . . . . . . # # # # . .
. . # # # # # # # . . . . . . . . . . . . . . . . . . . . # # # # . .
. . # # # # # # # . . . . . . . . . . . . . . . . . . . . # # # # . .
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .

If I now call p.width or p.height the values don't match what I originally set.

>>> p.height
16
>>> p.width
35
>>> print(p)

I understand what is happening but do you think the user will? Should we "remove" the masked areas from the actual puzzle size they specify like below?

 # # # # # # # . . . . . . . . . # # # #
 # # # # # # # . . . . . . . . . # # # #
 # # # # # # # . . . . . . . . . # # # #
 # # # # # # # . . . . . . . . . # # # #
 # # # # # # # . . . . . . . . . # # # #
 # # # # # # # . . . . . . . . . # # # #
 # # # # # # # . . . . . . . . . # # # #
 # # # # # # # . . . . . . . . . # # # #
 # # # # # # # . . . . . . . . . # # # #
 # # # # # # # . . . . . . . . . # # # #

Your original "I" shaped example wouldn't work in this case though as you are masking out 8 cols and the puzzle size is only 6 cols. This could throw the exception you have set up. But if you reduced the masks size to 2 cols and could control the height you would end up with the following...

>>> print(
    Puzzle(
        6,
        masks=[
            remove_rectangle(row=2, col=0, width=2, height=2, True),
            remove_rectangle(row=2, col=4, width=2, height=2, True),
        ],
    )
)
. . . . . .
. . . . . .
# # . . # #
# # . . # #
. . . . . .
. . . . . .

Just some thoughts as I try and wrap my head around the implementation...

joshbduncan commented 2 years ago

And I don't think it would be too hard to implement a bitmap mask using PIL...

joshbduncan commented 2 years ago

@duck57, FYI, I had to patch a small booboo so I just published v2.0.1. My check for all of the placed words properties when checking to see if the word had a 'position'. Well the way we have position setup it always return true making all words show up in those properties and the key no matter if they were placed on the board or not. Was easy to notice when I tried to fit 100 words in a 5x5 puzzle. 🤦‍♂️

duck57 commented 2 years ago

I just pushed a commit to my fork's mask-test branch. It's very much so a WIP. As I said in the commit message, truly a minimal viable proof-of-concept for the changes. However, I probably won't have time to do in-depth work on it again until mid-February.

This commit (or series of commits, rather):

  1. Merges in #17 to use it as the starting base [I started this on Tuesday, so it's before you've made your comments]
  2. Re-implements a few masks to work on a list[list[str]] instead of a Puzzle object—the Puzzle object seemed like it would end up being a replacement for the WordSearch object if it kept growing.
  3. Leaves puzzle.py mostly alone. The re-implementations are in masks.py
  4. At least adds some support for polling which cells contain which character (though I'd still need to write tests to see if it works/is used appropriately)
  5. The new mask implementation has been hooked up with WordSearch objects. They do correctly build the word searches around the fenced-off coordinates.
  6. Allow for the creation of WordSearches with width≠height

Some to-do items [this list is as much for me as for you]:

To address some of your comments,

If I have unexpected free time this weekend or early next week, I may take a stab at addressing some of the to-do items. If not, it can be a project for me in the spring. Feel free to use my chicken scratch as the base for your implementation if you want if you don't want to wait until later February.

joshbduncan commented 2 years ago

@duck57, so after talking it over with a few friends, I wanted to take a go at creating a different approach for masking. I'm a graphic designer by trade so masks are something I use a lot and this approach just works better with my brain. It needs some cleanup and has zero type hints at the moment but it should be pretty easy to understand. I have included a pretty extensive readme below.

I plan to add more shapes, probably recalculate the heart, and check on some rounding issues, but most of the base is there. The bitmap mask could easily be expanded to work with PIL (which is already a requirement of the PDF generator) and allow user images to work as masks.

This could pretty easily be implemented into the actual package.

I'm on my lunch break and rambling at this point, so check it out if you have time, and let me know what you think.

https://gist.github.com/joshbduncan/949caf7a6d0ae6d9d2ada564a0562f4f

Puzzle Masks

Masks allow you to "mask" areas of a WordSearch puzzle, making those areas inactive for placing characters.

Mask() Base Class

All puzzle masks are based on the base Mask class. There are two subclasses, Bitmap and Polygon, that inherits from Mask.

def __init__(self, method=1, static=True):
    """A puzzle mask object.

    Args:
        method (int, optional): Masking method. Defaults to 1.
            1. Standard (Intersection)
            2. Additive
            3. Subtractive
        static (bool, optional): Mask should not be recalculated
        and reapplied after a size change. Defaults to True.
    """
    self.puzzle_size = None
    self.grid = None
    self.method = method
    self.static = static

The base mask class only has a few key properties, Mask.method and Mask.static.

Mask().method

Mask.method determines how the mask is applied to the puzzle.

To best understand Mask.method, let me show what a sample mask looks like.

>>> mask = Diamond()
>>> mask.generate(11)
>>> mask.show()
# # # # # * # # # # #
# # # # * * * # # # #
# # # * * * * * # # #
# # * * * * * * * # #
# * * * * * * * * * #
* * * * * * * * * * *
# * * * * * * * * * #
# # * * * * * * * # #
# # # * * * * * # # #
# # # # * * * # # # #
# # # # # * # # # # #

As you can see in the output above, a mask (no matter the type) is made up of ACTIVE (*) and INACTIVE (#) spaces. ACTIVE (*) spaces will "act" on a puzzle depending on the Mask.method. If it helps, in the physical world, the above mask would be a square with a diamond shape cut out of the middle, masking all of the areas marked INACTIVE (#), and revealing all of the areas marked ACTIVE (*).

Method Types

  1. Standard: All INACTIVE (#) spaces from the mask will deactivate corresponding spaces on the current puzzle, intersecting with any previously applied masks.
>>> p = Puzzle(15)
>>> p.apply_mask(Ellipse(15, 7))
>>> p.show()
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# # # # * * * * * * * # # # #
# * * * * * * * * * * * * * #
* * * * * * * * * * * * * * *
* * * * * * * * * * * * * * *
* * * * * * * * * * * * * * *
# * * * * * * * * * * * * * #
# # # # * * * * * * * # # # #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
>>> p.apply_mask(Ellipse(7, 15, method=2))
>>> p.show()
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# # # # * * * * * * * # # # #
# # # # * * * * * * * # # # #
# # # # * * * * * * * # # # #
# # # # * * * * * * * # # # #
# # # # * * * * * * * # # # #
# # # # * * * * * * * # # # #
# # # # * * * * * * * # # # #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #

Using the default standard method (method=1) on the second vertical oval mask, you can see that it interacts with the previous mask so only intersecting/overlapping areas are active on the puzzle.

  1. Additive: All ACTIVE (*) spaces from the mask will activate corresponding spaces on the current puzzle, no matter the current puzzle state.
>>> p = Puzzle(15)
>>> p.apply_mask(Ellipse(15, 7))
>>> p.show()
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# # # # * * * * * * * # # # #
# * * * * * * * * * * * * * #
* * * * * * * * * * * * * * *
* * * * * * * * * * * * * * *
* * * * * * * * * * * * * * *
# * * * * * * * * * * * * * #
# # # # * * * * * * * # # # #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
>>> p.apply_mask(Ellipse(7, 15, method=2))
>>> p.show()
# # # # # # * * * # # # # # #
# # # # # * * * * * # # # # #
# # # # # * * * * * # # # # #
# # # # # * * * * * # # # # #
# # # # * * * * * * * # # # #
# * * * * * * * * * * * * * #
* * * * * * * * * * * * * * *
* * * * * * * * * * * * * * *
* * * * * * * * * * * * * * *
# * * * * * * * * * * * * * #
# # # # * * * * * * * # # # #
# # # # # * * * * * # # # # #
# # # # # * * * * * # # # # #
# # # # # * * * * * # # # # #
# # # # # # * * * # # # # # #

Using the additive method (method=2) on the second vertical oval mask, you can see that it doesn't interact with the previous mask at all and simply activates all of it's area on the current puzzle.

  1. Subtractive: All ACTIVE (*) spaces from the mask will deactivate corresponding spaces on the current puzzle, no matter the current puzzle state.
>>> p = Puzzle(15)
>>> p.apply_mask(Ellipse(15, 7))
>>> p.show()
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# # # # * * * * * * * # # # #
# * * * * * * * * * * * * * #
* * * * * * * * * * * * * * *
* * * * * * * * * * * * * * *
* * * * * * * * * * * * * * *
# * * * * * * * * * * * * * #
# # # # * * * * * * * # # # #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
>>> p.apply_mask(Ellipse(7, 15, method=3))
>>> p.show()
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# * * * # # # # # # # * * * #
* * * * # # # # # # # * * * *
* * * * # # # # # # # * * * *
* * * * # # # # # # # * * * *
# * * * # # # # # # # * * * #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #
# # # # # # # # # # # # # # #

Using the subtractive method (method=3) on the second vertical oval mask, you can see that it doesn't interact with the previous mask at all and simply deactivates all of its area on the current puzzle.

Mask().static

A Puzzle object retains all applied masks so that they can be reapplied if the puzzle size changes. Only masks marked as non static Mask.static = False will be reapplied. All masks are marked as True by default.

The reason for this property, is there are many Preset Masks that are calculated based on the puzzle size. These masks will easily scale if you change the puzzle size. But a problem arises when you create a custom Bitmap or Polygon mask that can't be easily recalculated to fit on a different puzzle size. In this case (Mask.static = True) the mask will remain in Puzzle.masks but will not be re-applied when the puzzle size changes.

If you would like to remove all static masks from Puzzle.masks after a resize, you can use Puzzle.remove_static_masks(). If you want to remove all masks from a puzzle (static or not), use Puzzle.remove_masks()

Masks can be applied to a Puzzle object using Puzzle.apply_mask() (for singular operations) or Puzzle.apply_mask([List]) (for multiple operations).

Mask() Methods

Mask Shape Centering

Please note, anytime a mask shape with a calculated center (Triangle, Diamond, Ellipse, Star, Heart) is applied to a puzzle with an even Puzzle.size the mask will be offset one grid unit toward the top-left origin point (0, 0) since there is no true center.

Puzzle size is even and an Ellipse size is odd...

>>> p = Puzzle(9)
>>> p.apply_mask(Ellipse(8, 4))
>>> p.show()
# # # # # # # # #
# # # # # # # # #
# * * * * * * # #
* * * * * * * * #
* * * * * * * * #
# * * * * * * # #
# # # # # # # # #
# # # # # # # # #
# # # # # # # # #

Puzzle size is odd and an Ellipse size is even...

>>> p = Puzzle(10)
>>> p.apply_mask(Ellipse(9, 5))
>>> p.show()
# # # # # # # # # #
# # # # # # # # # #
# # * * * * * # # #
* * * * * * * * * #
* * * * * * * * * #
* * * * * * * * * #
# # * * * * * # # #
# # # # # # # # # #
# # # # # # # # # #
# # # # # # # # # #

Preset Masks

Current preset masks:

Bitmap Masks

Bitmap masks work similarly to bitmap images. Every point (grid square) specified in the Bitmap.points property will be included in the mask.

Masks that inherit from Bitmap:

Ellipse

Draw an ellipse at the specified width and height on the puzzle. This is the mask type that was used above to explain Mask Method Types.

>>> p = Puzzle(20)
>>> p.apply_mask(Ellipse(18,10))
>>> p.show()
# # # # # # # # # # # # # # # # # # # #
# # # # # # # # # # # # # # # # # # # #
# # # # # # # # # # # # # # # # # # # #
# # # # # # # # # # # # # # # # # # # #
# # # # # # # # # # # # # # # # # # # #
# # # # # # * * * * * * * * # # # # # #
# # # # * * * * * * * * * * * * # # # #
# # * * * * * * * * * * * * * * * * # #
# * * * * * * * * * * * * * * * * * * #
# * * * * * * * * * * * * * * * * * * #
# * * * * * * * * * * * * * * * * * * #
# * * * * * * * * * * * * * * * * * * #
# # * * * * * * * * * * * * * * * * # #
# # # # * * * * * * * * * * * * # # # #
# # # # # # * * * * * * * * # # # # # #
# # # # # # # # # # # # # # # # # # # #
# # # # # # # # # # # # # # # # # # # #
# # # # # # # # # # # # # # # # # # # #
# # # # # # # # # # # # # # # # # # # #
# # # # # # # # # # # # # # # # # # # #

Circle

Draw a circle that fills the entire puzzle. And, since a circle is just an ellipse with width == height, the Circle class inherits from the Ellipse class but doesn't accept any parameters.

>>> p = Puzzle(10)
>>> p.apply_mask(Circle())
>>> p.show()
# # # * * * * # # #
# * * * * * * * * #
# * * * * * * * * #
* * * * * * * * * *
* * * * * * * * * *
* * * * * * * * * *
* * * * * * * * * *
# * * * * * * * * #
# * * * * * * * * #
# # # * * * * # # #

🍩 Donuts anyone?

>>> p = Puzzle(21)
>>> e1 = Ellipse(21, 21)
>>> e2 = Ellipse(9, 9, method=3)
>>> p.apply_masks([e1, e2])
>>> p.show()
# # # # # # # * * * * * * * # # # # # # #
# # # # # * * * * * * * * * * * # # # # #
# # # # * * * * * * * * * * * * * # # # #
# # # * * * * * * * * * * * * * * * # # #
# # * * * * * * * * * * * * * * * * * # #
# * * * * * * * * * * * * * * * * * * * #
# * * * * * * * # # # # # * * * * * * * #
* * * * * * * # # # # # # # * * * * * * *
* * * * * * # # # # # # # # # * * * * * *
* * * * * * # # # # # # # # # * * * * * *
* * * * * * # # # # # # # # # * * * * * *
* * * * * * # # # # # # # # # * * * * * *
* * * * * * # # # # # # # # # * * * * * *
* * * * * * * # # # # # # # * * * * * * *
# * * * * * * * # # # # # * * * * * * * #
# * * * * * * * * * * * * * * * * * * * #
# # * * * * * * * * * * * * * * * * * # #
# # # * * * * * * * * * * * * * * * # # #
# # # # * * * * * * * * * * * * * # # # #
# # # # # * * * * * * * * * * * # # # # #
# # # # # # # * * * * * * * # # # # # # #

Polygon Masks

Polygon masks accept a list of at least 3 points. During mask generation those points will be connected using the Bresenham's line algorithm, then the shape will be filled using the Polygon.fill_shape() method.

>>> p = Puzzle(11)
>>> polygon = Polygon([(1,1), (7,4), (2,9)])
>>> p.apply_mask(polygon)
# # # # # # # # # # #
# * * # # # # # # # #
# * * * * # # # # # #
# * * * * * * # # # #
# * * * * * * * # # #
# # * * * * * # # # #
# # * * * * # # # # #
# # * * * # # # # # #
# # * * # # # # # # #
# # * # # # # # # # #
# # # # # # # # # # #

⚠️ I have noticed that on some polygons Bresenham's line algorithm calculation can be slightly off when drawing back toward the origin point (after the halfway point). This may be due to rounding but I am not sure at this point. I have created a fix (not yet implemented) that draws the first half of the path from the origin, then returns to the origin and draws the second half of the path in reverse order. I just need to iron out a few edge cases before implementing it.

Masks that inherit from Polygon

Rectangle

Draw a rectangle mask from 4 points. The points should be specified as a list of (x, y) tuples. The default origin point of (0, 0) is at the top-left of the puzzle.

>>> p = Puzzle(11)
>>> p.apply_mask(Rectangle(5,7))
>>> p.show()
* * * * * * * # # # #
* * * * * * * # # # #
* * * * * * * # # # #
* * * * * * * # # # #
* * * * * * * # # # #
# # # # # # # # # # #
# # # # # # # # # # #
# # # # # # # # # # #
# # # # # # # # # # #
# # # # # # # # # # #
# # # # # # # # # # #

You can also specify a specific (x, y) origin position=(2,3) from where the rectangle will be drawn.

>>> p = Puzzle(11)
>>> p.apply_mask(Rectangle(5,7, position=(3,4)))
>>> p.show()
# # # # # # # # # # #
# # # # # # # # # # #
# # # # # # # # # # #
# # # # * * * * * * *
# # # # * * * * * * *
# # # # * * * * * * *
# # # # * * * * * * *
# # # # * * * * * * *
# # # # # # # # # # #
# # # # # # # # # # #
# # # # # # # # # # #

Triangle

Draw a triangle that fills the entire puzzle.

⚠️ If you prefer an Equilateral Triangle, don't worry that is built-in too using the ConvexPolygon mask so no need to worry with the calculation.

>>> p = Puzzle(11)
>>> p.apply_mask(Triangle())
>>> p.show()
# # # # # * # # # # #
# # # # # * * # # # #
# # # # * * * # # # #
# # # # * * * * # # #
# # # * * * * * # # #
# # # * * * * * * # #
# # * * * * * * * # #
# # * * * * * * * * #
# * * * * * * * * * #
# * * * * * * * * * *
* * * * * * * * * * *

Diamond

Draw a diamond that fills the entire puzzle.

>>> p = Puzzle(10)
>>> p.apply_mask(Diamond())
>>> p.show()
# # # # * # # # # #
# # # * * * # # # #
# # * * * * * * # #
# * * * * * * * * #
* * * * * * * * * *
# * * * * * * * * #
# # * * * * * * # #
# # * * * * * # # #
# # # * * * # # # #
# # # # * # # # # #
>>> p = Puzzle(11)
>>> p.apply_mask(Diamond())
>>> p.show()
# # # # # * # # # # #
# # # # * * * # # # #
# # # * * * * * # # #
# # * * * * * * * # #
# * * * * * * * * * #
* * * * * * * * * * *
# * * * * * * * * * #
# # * * * * * * * # #
# # # * * * * * # # #
# # # # * * * # # # #
# # # # # * # # # # #

If you want to generate an Equilateral Diamond (equal sides, with opposing sides parallel to each other) no matter the puzzle_size, there is a pre-built EquilateralDiamond mask based on calculations from the ConvexPolygon mask it inherits from.

Star

The Star or Pentagram is regular is a regular 5-pointed star polygon. The points for the star are calculated using the calculate_regular_convex_polygon_points() function just like the Pentagon below. The points are then rearranged and connected just like any Polygon mask.

>>> p = Puzzle(13)
>>> p.apply_mask(Heart())
>>> p.show()
# # # # # # * # # # # # #
# # # # # # * # # # # # #
# # # # # * * * # # # # #
# # # # # * * * # # # # #
* * * * * * * * * * * * *
# * * * * * * * * * * * #
# # # * * * * * * * # # #
# # # * * * * * * * # # #
# # # * * * * * * * # # #
# # # * * * # * * * # # #
# # * * * # # # * * * # #
# # * # # # # # # # * # #
# # # # # # # # # # # # #

The star will fill as much of the puzzle as possible and can be rotated.

>>> p = Puzzle(13)
>>> p.apply_mask(Heart(rotation=30))
>>> p.show()
# # # # # # # # # # # # #
# # # * # # # # # # # # #
# # # * * # # # # * * # #
# # # * * * # * * * # # #
# # # # * * * * * * # # #
# # # * * * * * * # # # #
# * * * * * * * * * # # #
* * * * * * * * * * * # #
# # # # * * * * * * * * #
# # # # * * * # # # # # #
# # # # # * * # # # # # #
# # # # # * # # # # # # #
# # # # # * # # # # # # #

Heart

>>> p = Puzzle(13)
>>> p.apply_mask(Heart())
>>> p.show()
# # * * # # # # # * * # #
# * * * * # # # * * * * #
# * * * * * # * * * * * #
* * * * * * * * * * * * *
* * * * * * * * * * * * *
* * * * * * * * * * * * *
* * * * * * * * * * * * *
# * * * * * * * * * * * #
# # * * * * * * * * * # #
# # # * * * * * * * # # #
# # # # * * * * * # # # #
# # # # # * * * # # # # #
# # # # # # * # # # # # #

ConvexPolygon

Draw a regular Convex Polygon mask with 3 or more sides. All points are calculated from the puzzle center and cover as much of the available puzzle area as possible.

All ConvexPolygon masks accept two parameters, sides and rotation.

Masks that inherit from ConvexPolygon:

* Are calculated as Regular Polygons

EquilateralTriangle

A Triangle in which all 3 sides have the same length and all three internal angles are also congruent to each other at 60°.

# # # # # # * # # # # # #
# # # # # * * * # # # # #
# # # # # * * * # # # # #
# # # # * * * * * # # # #
# # # # * * * * * # # # #
# # # * * * * * * * # # #
# # # * * * * * * * # # #
# # * * * * * * * * * # #
# # * * * * * * * * * # #
# * * * * * * * * * * * #
# # # # # # # # # # # # #
# # # # # # # # # # # # #
# # # # # # # # # # # # #

♺ Want it rotated?

>>> p = Puzzle(13)
>>> p.apply_mask(EquilateralTriangle(45))
>>> p.show()
# # # # # # # # # # # # #
# # # # # # # # # # # # #
# # * * * # # # # # # # #
# # * * * * * * * * # # #
# # * * * * * * * * * * *
# # # * * * * * * * * * #
# # # * * * * * * * * # #
# # # * * * * * * * # # #
# # # * * * * * * # # # #
# # # * * * * * # # # # #
# # # # * * * # # # # # #
# # # # * * # # # # # # #
# # # # * # # # # # # # #

EquilateralDiamond

A Diamond (rotated square) with 4 equal sides.

# # # # # # * # # # # # #
# # # # # * * * # # # # #
# # # # * * * * * # # # #
# # # * * * * * * * # # #
# # * * * * * * * * * # #
# * * * * * * * * * * * #
* * * * * * * * * * * * *
# * * * * * * * * * * * #
# # * * * * * * * * * # #
# # # * * * * * * * # # #
# # # # * * * * * # # # #
# # # # # * * * # # # # #
# # # # # # * # # # # # #

Pentagon

A simple Pentagon with 5 equal sides.

# # # # # # * # # # # # #
# # # # * * * * * # # # #
# # # * * * * * * * # # #
# * * * * * * * * * * * #
* * * * * * * * * * * * *
* * * * * * * * * * * * *
# * * * * * * * * * * * #
# * * * * * * * * * * * #
# * * * * * * * * * * * #
# * * * * * * * * * * * #
# # * * * * * * * * * # #
# # * * * * * * * * * # #
# # # # # # # # # # # # #

Hexagon

A [simple Hexagon]

# # # # # # * # # # # # #
# # # # * * * * * # # # #
# # * * * * * * * * * # #
# * * * * * * * * * * * #
# * * * * * * * * * * * #
# * * * * * * * * * * * #
# * * * * * * * * * * * #
# * * * * * * * * * * * #
# * * * * * * * * * * * #
# * * * * * * * * * * * #
# # * * * * * * * * * # #
# # # # * * * * * # # # #
# # # # # # * # # # # # #

Octagon

# # # # # * * # # # # # #
# # # * * * * * * # # # #
# # * * * * * * * * * # #
# # * * * * * * * * * * #
# * * * * * * * * * * * #
# * * * * * * * * * * * *
* * * * * * * * * * * * *
* * * * * * * * * * * * #
# * * * * * * * * * * * #
# * * * * * * * * * * # #
# # * * * * * * * * * # #
# # # # * * * * * * # # #
# # # # # # * * # # # # #

⚠️ If you want the Octagon to look like a stop sign you can set rotation=30 at creation.

❗️ Please note, there seems to be an off-by-1 miscalculation with the Octagon. It could be a rounding issue but I'm not sure at the moment. Hopefully, I can sort it out later.

joshbduncan commented 2 years ago

Some Fun Puzzle Masks

I had a few free minutes tonight so I made some fun mask shapes. It's really easy to make these. I could easily create a library of them.

Donut 🍩

>>> p = Puzzle(21)
>>> p.apply_mask(Ellipse(21, 21))
>>> p.apply_mask(Ellipse(9, 9, method=3))

              S W X O M K B
          T C G B J G K B R G Y
        Q C O Q T M O H B W R T L
      M C B G X K L G D Z K X O M F
    H O C T W Q F H Q J R U G Z F L H
  T R W D R V R B B R B F E V E J B O U
  T A J U W B Z           L J M F B A I
C E G U H U L               F O I T K P R
X U O A F A                   F F K G Y Y
L M O R I U                   K T W Z J H
V F Y N J V                   B N A D B V
W Y Y G H E                   C A L Z T S
R R R P Y N                   H H S G Z Z
L V C C F D B               W T X C Z F C
  F R Q O I P X           T G B Y I H Z
  Y I F C A H Q Z E Z R F V Z H D I C M
    M C J V E Z H M F X N L G T R J W
      J O L T T G T V R J A T F A G
        K M H Y X D O C D L A J C
          E C O P D X M J F J R
              O I R J O J A

Smiley Face

>>> p = Puzzle(21)
>>> p.apply_mask(Rectangle(2,6, position=(6,4), method=3))
>>> p.apply_mask(Rectangle(2,6, position=(13,4), method=3))
>>> p.apply_mask(Rectangle(9,2, position=(6,14), method=3))
>>> p.apply_mask(Rectangle(2,2, position=(5,13), method=3))
>>> p.apply_mask(Rectangle(2,2, position=(14,13), method=3))

              P T X L S G P
          J N Y H M I I N A Z T
        Y F W D U S N I D V S J J
      C P X T K Z Z R L L H S H D E
    Y B M X     B S V J I     A S Q P
  N Z R Z M     A L P R O     I U S P H
  L U L N L     K H E Y H     E O D Q D
S Q D I V O     B P E E C     O H D Y C J
B R B L I Y     K K P K J     T C I E L N
Y S Z T Q S     Y Q P M G     C M Q R N U
D J X A R V U F O R J H K D E N R E F N T
R Q C K N F F O L I S W A X K L U S U G D
A T B B J C O W P K U Q Q E U H Z Y T Y K
H O W J R     C D F S P O O     W K S H S
  O Q M C                       B L O L
  G A M A B                   F S N G K
    Q B A D X E I Q M D H B P A L E V
      J L B E Q I Q T Y H V V Q O Y
        X U G E T H A S P P G C T
          Q G N L V Y S S S Q J
              V R R R B E D

Tree

>>> p = Puzzle(15)
>>> p.apply_mask(EquilateralTriangle())
>>> p.apply_mask(Rectangle(3,3, position=(6,12), method=2))

              V
            D E Y
            M G E
          U G K W R
          F N B W D
        Z J Q Y K P R
        P E P A W X S
      Z L K Q N P N T W
      U D B M Z H R N I
    I H Z V T X I H F I W
    A E D X N B E U S V U
  S Y K D R A T C V M L G K
            F I Q
            A A S
            U Z M

Six Pointed Star

>>> p = Puzzle(21)
>>> p.apply_mask(EquilateralTriangle())
>>> p.apply_mask(EquilateralTriangle(rotation=180))

                    D
                  S H L
                  E O Y
                E L L L H
                A Z X C F
  L Z W O J Y P F Y L Q C D Y R F O C E
    O S F T K K R B P C X O O D O Y O
    A B T K K L T X B J K J E Q T O Q
      I G F P S O E O J X N C P W T
      B Q P W G T S G L H N O O U K
        D E E E W B H O Z J U D R
      B X E M O K U F X A V Z T X D
      H I D R C R B R S M R I F P R
    G O H W N G Y P A J M D F F K K B
    L B F T V R D T V V Q D G B U N C
  R T M N X B H I I M I Q G X Z C Y D A
                H S U L N
                G Y E Y S
                  R Q P
                  G S E
                    N

Cross

>>> p = Puzzle(15)
>>> p.apply_mask(Rectangle(3,15, position=(6,0)))
>>> p.apply_mask(Rectangle(15,3, position=(0,6), method=2))
>>> p.invert_masking()

M B K U J J       E E P O Z I
B K G V S K       Q F S J H Z
Y G X S F P       T E C V L Y
K T S R P B       K C D N D Z
F U K Q N W       P R K H C D
Q W L F W O       R T A H J E

X K S C G E       O U N J K K
R P F O U Y       J L J L K H
L A P O A R       K G U P C V
T J C Y K W       U M W A J Y
I P M V T T       I U Y G S K
Y J J R T V       B T M L O D

Inverted Cross In Circle

p = Puzzle(21)
p.apply_mask(Circle())
p.apply_mask(Rectangle(31,3, position=(0,9), method=3))
p.apply_mask(Rectangle(3,31, position=(9,0), method=3))

              H N       S B
          G K I F       U C F V
        K J F Z S       R S H X U
      O X C N D U       L R Z O H K
    I P P Q I L I       Z H N A B I A
  C M O P B P T E       W A I P Y P I E
  A P B S V D S J       X N U E G O A M
P I O J D K T Z A       D T K U A B I T I
S K S T G O Z M W       I L E D T N J C T

L L N B I I Z W J       M N Z H P K A O D
H I E Z E Y W A Q       L N M Y L Y M O X
  T G R W M C K D       M E T I D P Q N
  J M K K X U C Z       X E G A X B B O
    N U W C X M G       U C L G F S Y
      F M K C L S       I M M V D L
        A R G I B       R A J I Q
          L C X P       S M P I
              F U       Q K

"Hole-y" Cross 😉

>>> p = Puzzle(21)
>>> p.apply_mask(Rectangle(7,7, method=3))
>>> p.apply_mask(Rectangle(7,7, position=(14,0), method=3))
>>> p.apply_mask(Rectangle(7,7, position=(0,14), method=3))
>>> p.apply_mask(Rectangle(7,7, position=(14,14), method=3))
>>> p.apply_mask(Ellipse(5,5, method=3))

              K D G N K C N
              S S H U S S K
              S C H L N I N
              M G A J U G E
              C U Y U M H B
              M R V I Z I R
              I O Q Y H P J
K D G P X H X O K Q U R F K W K J U I A X
M F Y N J Y U T M       M C Q T F E T Q T
C C H O E J U S           I G L J U E Y E
E R I E T G S K           K J C D Y H C Y
Z F D J N F I Y           M X I C T Q R L
B Y H N D H P A E       O X C C N O G L M
W A H A O H F Z D L Y Z U W E Q T G C Y G
              X K A R X L P
              H T T F I A F
              I P M K V G A
              G D Z S O Y G
              T J X F P W O
              Y S V E R X T
              R F E Q U S B

Checkboard

>>> p = Puzzle(30)
>>> p.invert_masking()
>>> for x in range(0, 30, 5):
>>>     for y in range(0, 30, 5):
>>>         if (x//5) % 2 == 0 and (y//2) % 5 == 0:
>>>             p.apply_mask(Rectangle(5,5, position=(x,y), method=2))
>>>         if (x//5) % 2 != 0 and (y//2) % 5 != 0:
>>>             p.apply_mask(Rectangle(5,5, position=(x,y), method=2))

O B W Z O           B R W U B           C J M N G
N J K D Y           U X U I J           K N W J I
E U R S H           H R A A F           J F L A F
Q T F U A           O B E O H           N Q L S O
I O S X N           I A H N U           K M U Z M
          N D J W K           W W W F S           G Q Q M Y
          D Q E F K           O I L Z F           J U T V S
          E B B M Y           O F Y B C           V T Y Z K
          B T I Y M           O N A L C           M T T A M
          F W P I U           R V E E R           P J I F E
A P W M Q           K M U J V           I C S A B
K G I S H           V P H O S           V J I X A
K K B M F           X X Y P J           D S O H P
R D I R B           V Z S E M           X W U A F
E D G T O           Q Z T L J           E G T Y W
          T X E H E           H M F T N           Q X A C T
          A T H C Q           J D B F V           E A U M X
          D S H S A           A M U N E           V J Q C C
          E V H W T           U O M T I           A Q X T D
          I L U Y M           S P P A C           R W X B Q
V T C F W           Z D Y N K           F P I K S
B C D I U           D M I P Y           P C G H T
O P F E U           Q K R J Q           M Q Y H Z
J Z D H Q           L L G W G           R N L V I
Z Q J S Z           M I G Z P           K X W D C
          Y L U H I           M R D J O           K G Y H U
          M L A U T           E D P X Y           N W Q V U
          V P O N X           T T H C S           W J B A R
          C U I O E           G P I H S           F J O F A
          W G Q E N           D C L A S           L L V M V
duck57 commented 2 years ago

Those look amazing! Haven't had too much time to do some proper code review.

joshbduncan commented 1 year ago

Implemented in v3