├── .editorconfig ├── .gitignore ├── Images.ipynb ├── LICENSE.txt ├── Mine.ipynb ├── N6.ipynb ├── README.md ├── Truchet.ipynb ├── backgrounds.py ├── carlson.py ├── drawing.py ├── helpers.py ├── image_helpers.py ├── n6.py ├── requirements.txt ├── smith.py └── tiler.py /.editorconfig: -------------------------------------------------------------------------------- 1 | # This file is for unifying the coding style for different editors and IDEs. 2 | # More information at http://EditorConfig.org 3 | 4 | root = true 5 | 6 | [*] 7 | charset = utf-8 8 | end_of_line = lf 9 | indent_size = 4 10 | indent_style = space 11 | insert_final_newline = true 12 | max_line_length = 80 13 | trim_trailing_whitespace = true 14 | 15 | [*.{yml,yaml}] 16 | indent_size = 2 17 | 18 | [*.rst] 19 | max_line_length = 79 20 | 21 | [Makefile] 22 | indent_style = tab 23 | indent_size = 8 24 | 25 | [*.diff] 26 | trim_trailing_whitespace = false 27 | 28 | [.git/*] 29 | trim_trailing_whitespace = false 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .ipynb_checkpoints/ 2 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | A Jupyter notebook implementation of [Christopher Carlson][carlson]'s 2 | multi-scale Truchet tiles. 3 | 4 | I wrote a [blog post](https://nedbatchelder.com/blog/202208/truchet_images.html) 5 | about it. 6 | 7 | The code is Apache licensed. Images you make with it are your own. It would be 8 | cool if you gave me a shout-out in some way. 9 | 10 | [carlson]: https://christophercarlson.com/portfolio/multi-scale-truchet-patterns 11 | -------------------------------------------------------------------------------- /backgrounds.py: -------------------------------------------------------------------------------- 1 | from helpers import make_bgfg 2 | from n6 import n6_circles 3 | from tiler import multiscale_truchet 4 | 5 | DIR = "~/wallpaper/tru6/1680" 6 | NIMG = 30 7 | for i in range(NIMG): 8 | multiscale_truchet( 9 | tiles=n6_circles, width=1680, height=1050, tilew=200, nlayers=3, 10 | chance=.4, 11 | seed=i, 12 | **make_bgfg(i/NIMG, (.55, .45), .45), 13 | format="png", output=f"{DIR}/bg_{i:02d}.png", 14 | ) 15 | 16 | DIR = "~/wallpaper/tru6/1920" 17 | NIMG = 15 18 | for i in range(NIMG): 19 | multiscale_truchet( 20 | tiles=n6_circles, width=1920, height=1080, tilew=200, nlayers=3, 21 | chance=.4, 22 | seed=i, 23 | **make_bgfg(i/NIMG, (.55, .45), .45), 24 | format="png", output=f"{DIR}/bg_{i:02d}.png", 25 | ) 26 | 27 | DIR = "~/wallpaper/tru6/2872" 28 | for i in range(NIMG): 29 | multiscale_truchet( 30 | tiles=n6_circles, width=2872, height=5108, tilew=300, nlayers=3, 31 | chance=.4, bg="#335495", fg="#243b6a", seed=i, 32 | format="png", output=f"{DIR}/bg_{i:02d}.png", 33 | ) 34 | 35 | DIR = "~/wallpaper/tru6/1536" 36 | for i in range(NIMG): 37 | multiscale_truchet( 38 | tiles=n6_circles, width=1536, height=960, tilew=200, nlayers=3, 39 | chance=.4, bg="#335495", fg="#243b6a", seed=i*3, 40 | format="png", output=f"{DIR}/bg_{i:02d}.png", 41 | ) 42 | 43 | DIR = "~/wallpaper/tru6/1360" 44 | NIMG = 120 45 | for i in range(NIMG): 46 | multiscale_truchet( 47 | tiles=n6_circles, width=1360, height=768, tilew=150, nlayers=3, 48 | chance=.4, 49 | seed=i*10, 50 | **make_bgfg(i/NIMG, (.55, .45), .45), 51 | format="png", output=f"{DIR}/bg_{i:03d}.png", 52 | ) 53 | 54 | # Dell 34" curved monitor 55 | DIR = "~/wallpaper/tru6/2560" 56 | NIMG = 120 57 | for i in range(NIMG): 58 | multiscale_truchet( 59 | tiles=n6_circles, width=2560, height=1080, tilew=150, nlayers=3, 60 | chance=.4, 61 | seed=i*10, 62 | **make_bgfg(i/NIMG, (.55, .45), .45), 63 | format="png", output=f"{DIR}/bg_{i:03d}.png", 64 | ) 65 | -------------------------------------------------------------------------------- /carlson.py: -------------------------------------------------------------------------------- 1 | from drawing import CE, CS, CW, CN, DEG90 2 | from tiler import TileBase, rotations 3 | 4 | 5 | class CarlsonTile(TileBase): 6 | """https://christophercarlson.com/portfolio/multi-scale-truchet-patterns/""" 7 | 8 | class G(TileBase.G): 9 | def __init__(self, wh, bgfg=None): 10 | super().__init__(wh, bgfg) 11 | # +--+--+--+--+-----+ 12 | # 0 wh1 wh3 wh 13 | # wh6 wh2 14 | self.wh1 = wh / 3 15 | self.wh2 = wh / 2 16 | self.wh3 = wh * 2 / 3 17 | self.wh6 = wh / 6 18 | 19 | def init_tile(self, ctx, g, base_color=None): 20 | ctx.arc(0, 0, g.wh1, CS, CE) 21 | ctx.arc(g.wh, 0, g.wh1, CW, CS) 22 | ctx.arc(g.wh, g.wh, g.wh1, CN, CW) 23 | ctx.arc(0, g.wh, g.wh1, CE, CN) 24 | ctx.close_path() 25 | ctx.set_source_rgba(*g.bgfg[0]) 26 | ctx.fill() 27 | ctx.set_source_rgba(*g.bgfg[1]) 28 | ctx.translate(g.wh2, g.wh2) 29 | ctx.rotate(DEG90 * self.rot) 30 | ctx.translate(-g.wh2, -g.wh2) 31 | 32 | def draw(self, ctx, wh: int): 33 | ... 34 | 35 | 36 | class CarlsonSlash(CarlsonTile): 37 | def draw(self, ctx, g): 38 | ctx.arc(g.wh2, 0, g.wh6, CW, CE) 39 | ctx.arc_negative(g.wh, 0, g.wh1, CW, CS) 40 | ctx.arc(g.wh, g.wh2, g.wh6, CN, CS) 41 | ctx.arc(g.wh, 0, g.wh3, CS, CW) 42 | ctx.fill() 43 | ctx.arc(0, g.wh2, g.wh6, CS, CN) 44 | ctx.arc(0, g.wh, g.wh3, CN, CE) 45 | ctx.arc(g.wh2, g.wh, g.wh6, CE, CW) 46 | ctx.arc_negative(0, g.wh, g.wh1, CE, CN) 47 | ctx.fill() 48 | 49 | 50 | class CarlsonMinus(CarlsonTile): 51 | def draw(self, ctx, g): 52 | ctx.circle(g.wh2, 0, g.wh6) 53 | ctx.fill() 54 | ctx.arc(g.wh, g.wh2, g.wh6, CN, CS) 55 | ctx.arc(0, g.wh2, g.wh6, CS, CN) 56 | ctx.close_path() 57 | ctx.fill() 58 | ctx.circle(g.wh2, g.wh, g.wh6) 59 | ctx.fill() 60 | 61 | 62 | class CarlsonHalfMinus(CarlsonTile): 63 | def draw(self, ctx, g): 64 | ctx.circle(g.wh2, 0, g.wh6) 65 | ctx.fill() 66 | ctx.arc(g.wh, g.wh2, g.wh6, CN, CS) 67 | ctx.arc(g.wh2, g.wh2, g.wh6, CS, CN) 68 | ctx.close_path() 69 | ctx.fill() 70 | ctx.circle(g.wh2, g.wh, g.wh6) 71 | ctx.fill() 72 | ctx.circle(0, g.wh2, g.wh6) 73 | ctx.fill() 74 | 75 | 76 | class CarlsonFour(CarlsonTile): 77 | def draw(self, ctx, g): 78 | for x, y in [(g.wh2, 0), (g.wh, g.wh2), (g.wh2, g.wh), (0, g.wh2)]: 79 | ctx.circle(x, y, g.wh6) 80 | ctx.fill() 81 | 82 | 83 | class CarlsonX(CarlsonTile): 84 | def draw(self, ctx, g): 85 | ctx.arc(g.wh2, 0, g.wh6, CW, CE) 86 | ctx.arc_negative(g.wh, 0, g.wh1, CW, CS) 87 | ctx.arc(g.wh, g.wh2, g.wh6, CN, CS) 88 | ctx.arc_negative(g.wh, g.wh, g.wh1, CN, CW) 89 | ctx.arc(g.wh2, g.wh, g.wh6, CE, CW) 90 | ctx.arc_negative(0, g.wh, g.wh1, CE, CN) 91 | ctx.arc(0, g.wh2, g.wh6, CS, CN) 92 | ctx.arc_negative(0, 0, g.wh1, CS, CE) 93 | ctx.fill() 94 | 95 | 96 | class CarlsonPlus(CarlsonTile): 97 | def draw(self, ctx, g): 98 | ctx.arc(g.wh, g.wh2, g.wh6, CN, CS) 99 | ctx.arc(0, g.wh2, g.wh6, CS, CN) 100 | ctx.close_path() 101 | ctx.fill() 102 | ctx.arc(g.wh2, 0, g.wh6, CW, CE) 103 | ctx.arc(g.wh2, g.wh, g.wh6, CE, CW) 104 | ctx.close_path() 105 | ctx.fill() 106 | 107 | 108 | class CarlsonFrown(CarlsonTile): 109 | def draw(self, ctx, g): 110 | ctx.arc(g.wh2, 0, g.wh6, CW, CE) 111 | ctx.arc_negative(g.wh, 0, g.wh1, CW, CS) 112 | ctx.arc(g.wh, g.wh2, g.wh6, CN, CS) 113 | ctx.arc(g.wh, 0, g.wh3, CS, CW) 114 | ctx.fill() 115 | ctx.circle(g.wh2, g.wh, g.wh6) 116 | ctx.fill() 117 | ctx.circle(0, g.wh2, g.wh6) 118 | ctx.fill() 119 | 120 | 121 | class CarlsonT(CarlsonTile): 122 | def draw(self, ctx, g): 123 | ctx.arc(g.wh2, 0, g.wh6, CW, CE) 124 | ctx.arc_negative(g.wh, 0, g.wh1, CW, CS) 125 | ctx.arc(g.wh, g.wh2, g.wh6, CN, CS) 126 | ctx.arc(0, g.wh2, g.wh6, CS, CN) 127 | ctx.arc_negative(0, 0, g.wh1, CS, CE) 128 | ctx.fill() 129 | ctx.circle(g.wh2, g.wh, g.wh6) 130 | ctx.fill() 131 | 132 | 133 | carlson_demo = ( 134 | CarlsonSlash(), 135 | CarlsonMinus(), 136 | #CarlsonHalfMinus(), 137 | CarlsonFour(), 138 | CarlsonX(), 139 | CarlsonPlus(), 140 | CarlsonFrown(), 141 | CarlsonT(), 142 | ) 143 | 144 | 145 | carlson_tiles = ( 146 | *rotations(CarlsonSlash, 2), 147 | *rotations(CarlsonMinus, 2), 148 | CarlsonFour(), 149 | CarlsonX(), 150 | CarlsonPlus(), 151 | *rotations(CarlsonFrown, 4), 152 | *rotations(CarlsonT, 4), 153 | ) 154 | 155 | carlson_extra = ( 156 | *carlson_tiles, 157 | *rotations(CarlsonHalfMinus, 4), 158 | ) 159 | -------------------------------------------------------------------------------- /drawing.py: -------------------------------------------------------------------------------- 1 | """Helpers for drawing in Jupyter notebooks with PyCairo.""" 2 | 3 | import contextlib 4 | import io 5 | import math 6 | import os.path 7 | 8 | import cairo 9 | import IPython.display 10 | 11 | 12 | # Compass points for making circle arcs 13 | DEG90 = math.pi / 2 14 | DEG180 = math.pi 15 | CE, CS, CW, CN = [i * DEG90 for i in range(4)] 16 | 17 | 18 | class _CairoContext: 19 | """Base class for Cairo contexts that can display in Jupyter, or write to a file.""" 20 | 21 | def __init__(self, width: int, height: int, output: str | None = None): 22 | self.width = width 23 | self.height = height 24 | if isinstance(output, str): 25 | self.output = os.path.expandvars(os.path.expanduser(output)) 26 | else: 27 | self.output = output 28 | self.surface = None 29 | self.ctx = None 30 | 31 | def _repr_pretty_(self, p, cycle_unused): 32 | """Plain text repr for the context.""" 33 | # This is implemented just to limit needless changes in notebook files. 34 | # This gets written to the .ipynb file, and the default includes the 35 | # memory address, which changes each time. This string does not. 36 | p.text(f"<{self.__class__.__module__}.{self.__class__.__name__}>") 37 | 38 | def _repr_html_(self): 39 | """ 40 | HTML display in Jupyter. 41 | 42 | If output went to a file, display a message saying so. If output 43 | didn't go to a file, do nothing and the derived class will implement a 44 | method to display the output in Jupyter. 45 | """ 46 | if self.output is not None: 47 | return f"Wrote to {self.output}" 48 | 49 | def __enter__(self): 50 | return self 51 | 52 | def __getattr__(self, name): 53 | """Proxy to the cairo context, so that we have all the same methods.""" 54 | return getattr(self.ctx, name) 55 | 56 | # Drawing helpers 57 | 58 | def circle(self, x, y, r): 59 | """Add a complete circle to the path.""" 60 | self.ctx.arc(x, y, r, 0, 2 * math.pi) 61 | 62 | @contextlib.contextmanager 63 | def save_restore(self): 64 | self.ctx.save() 65 | try: 66 | yield 67 | finally: 68 | self.ctx.restore() 69 | 70 | @contextlib.contextmanager 71 | def flip_lr(self, wh): 72 | with self.save_restore(): 73 | self.ctx.translate(wh, 0) 74 | self.ctx.scale(-1, 1) 75 | yield 76 | 77 | @contextlib.contextmanager 78 | def flip_tb(self, wh): 79 | with self.save_restore(): 80 | self.ctx.translate(0, wh) 81 | self.ctx.scale(1, -1) 82 | yield 83 | 84 | @contextlib.contextmanager 85 | def rotated(self, wh, nturns): 86 | with self.save_restore(): 87 | self.ctx.translate(wh / 2, wh / 2) 88 | self.ctx.rotate(math.pi * nturns / 2) 89 | self.ctx.translate(-wh / 2, -wh / 2) 90 | yield 91 | 92 | 93 | class _CairoSvg(_CairoContext): 94 | """For creating an SVG drawing in Jupyter.""" 95 | 96 | def __init__(self, width: int, height: int, output: str | None = None): 97 | super().__init__(width, height, output) 98 | self.svgio = io.BytesIO() 99 | self.surface = cairo.SVGSurface(self.svgio, self.width, self.height) 100 | self.surface.set_document_unit(cairo.SVGUnit.PX) 101 | self.ctx = cairo.Context(self.surface) 102 | 103 | def __exit__(self, typ, val, tb): 104 | self.surface.finish() 105 | if self.output is not None: 106 | with open(self.output, "wb") as svgout: 107 | svgout.write(self.svgio.getvalue()) 108 | 109 | def _repr_svg_(self): 110 | if self.output is None: 111 | return self.svgio.getvalue().decode() 112 | 113 | 114 | class _CairoPng(_CairoContext): 115 | """For creating a PNG drawing in Jupyter.""" 116 | 117 | def __init__(self, width: int, height: int, output: str | None = None): 118 | super().__init__(width, height, output) 119 | self.pngio = None 120 | self.surface = cairo.ImageSurface(cairo.Format.RGB24, self.width, self.height) 121 | self.ctx = cairo.Context(self.surface) 122 | 123 | def __exit__(self, typ, val, tb): 124 | if self.output is not None: 125 | self.surface.write_to_png(self.output) 126 | else: 127 | self.pngio = io.BytesIO() 128 | self.surface.write_to_png(self.pngio) 129 | self.surface.finish() 130 | 131 | def _repr_png_(self): 132 | if self.output is None: 133 | return self.pngio.getvalue() 134 | 135 | 136 | def cairo_context( 137 | width: int, height: int, format: str = "svg", output: str | None = None 138 | ): 139 | """ 140 | Create a PyCairo context for use in Jupyter. 141 | 142 | Arguments: 143 | width (int), height (int): the size of the drawing in pixels. 144 | format (str): either "svg" or "png". 145 | output (optional str): if provided, the output will be written to this 146 | file. If None, the output will be displayed in the Jupyter notebook. 147 | 148 | Returns: 149 | A PyCairo context proxy. 150 | """ 151 | 152 | if format == "svg": 153 | cls = _CairoSvg 154 | elif format == "png": 155 | cls = _CairoPng 156 | else: 157 | raise ValueError(f"Unknown format: {format!r}") 158 | return cls(width, height, output) 159 | 160 | 161 | def svg_row(*svgs): 162 | sbs = '
{}
' 163 | return IPython.display.HTML(sbs.format("".join(s._repr_svg_() for s in svgs))) 164 | -------------------------------------------------------------------------------- /helpers.py: -------------------------------------------------------------------------------- 1 | """Helpers for drawing in Jupyter notebooks with PyCairo.""" 2 | 3 | import colorsys 4 | import itertools 5 | 6 | 7 | def range2d(nx, ny): 8 | return itertools.product(range(nx), range(ny)) 9 | 10 | 11 | def array_slices_2d(arr, x0, y0, nx, dx=None, ny=None, dy=None): 12 | """ 13 | Produce slices of the array in a 2d grid. 14 | 15 | The slices start at x0,y0. There are nx across, ny (defaulting to nx) down. 16 | The slices are dx across, dy (defaulting to dx) down. 17 | 18 | """ 19 | ny = ny or nx 20 | dy = dy or dx 21 | for ix, iy in range2d(nx, ny): 22 | x = x0 + ix * dx 23 | y = y0 + iy * dy 24 | yield arr[y:y+dy, x:x+dx] 25 | 26 | 27 | def color(val): 28 | """Create an RGBA color tuple from a variety of inputs.""" 29 | if isinstance(val, (int, float)): 30 | return (val, val, val, 1) 31 | elif isinstance(val, (list, tuple)): 32 | if len(val) == 3: 33 | return (*val, 1) 34 | else: 35 | return tuple(val) 36 | elif isinstance(val, str): 37 | if val[0] == "#": 38 | val = tuple(int(val[i : i + 2], 16) / 255 for i in [1, 3, 5]) 39 | return (*val, 1) 40 | 41 | 42 | def make_bgfg(hs, ls, ss): 43 | hslsss = [[v,v] if isinstance(v, (int, float)) else v for v in [hs, ls, ss]] 44 | return dict(zip(["bg", "fg"], [colorsys.hls_to_rgb(*hls) for hls in zip(*hslsss)])) 45 | 46 | 47 | def ffffx(start, f): 48 | allfx = set() 49 | to_call = [start] 50 | while to_call: 51 | next_to_call = [] 52 | for v in to_call: 53 | for vv in f(v): 54 | if vv not in allfx: 55 | allfx.add(vv) 56 | next_to_call.append(vv) 57 | to_call = next_to_call 58 | return allfx 59 | 60 | def all_subclasses(cls): 61 | """Return a set of all subclasses of `cls`, including subclasses of subclasses.""" 62 | return ffffx(cls, lambda c: c.__subclasses__()) 63 | 64 | def closest(x, values): 65 | return min(values, key=lambda v: abs(v - x)) 66 | -------------------------------------------------------------------------------- /image_helpers.py: -------------------------------------------------------------------------------- 1 | from drawing import cairo_context 2 | from helpers import range2d 3 | 4 | import numpy as np 5 | 6 | def downsample_by_averaging(img, window_shape): 7 | return np.mean( 8 | img.reshape(( 9 | *img.shape[:-2], 10 | img.shape[-2] // window_shape[-2], window_shape[-2], 11 | img.shape[-1] // window_shape[-1], window_shape[-1], 12 | )), 13 | axis=(-1, -3), 14 | ) 15 | 16 | def show_array_as_blocks(image, width, tilew): 17 | imgh, imgw = image.shape 18 | tile_across = width // tilew 19 | window_w = imgw // tile_across 20 | simg = downsample_by_averaging(image, (window_w, window_w)) 21 | imgh, imgw = simg.shape 22 | with cairo_context(width, width) as ctx: 23 | for x, y in range2d(imgw, imgh): 24 | color = simg[y, x] / 256 25 | dx = x * tilew 26 | dy = y * tilew 27 | ctx.set_source_rgb(color, color, color) 28 | ctx.rectangle(dx, dy, tilew, tilew) 29 | ctx.fill() 30 | return ctx 31 | -------------------------------------------------------------------------------- /n6.py: -------------------------------------------------------------------------------- 1 | 2 | from drawing import CE, CS, CW, CN, DEG90, DEG180 3 | from tiler import TileBase, collect, stroke 4 | 5 | 6 | n6_tiles = [] 7 | n6_connected = [] 8 | n6_circles = [] 9 | n6_weird = [] 10 | n6_filled = [] 11 | n6_lattice = [] 12 | 13 | class Tile(TileBase): 14 | """Multi-scale truchet tiles of my own devising.""" 15 | 16 | class G(TileBase.G): 17 | def __init__(self, wh, bgfg=None): 18 | super().__init__(wh, bgfg) 19 | # +-----+-----+-----+-----+-----+-----+ 20 | # 0 w16 w26 w46 w56 wh 21 | # w1c w3c w9c 22 | # w12 23 | self.w1c = wh / 12 24 | self.w3c = wh * 3 / 12 25 | self.w9c = wh * 9 / 12 26 | self.w16 = wh / 6 27 | self.w26 = wh * 2 / 6 28 | self.w46 = wh * 4 / 6 29 | self.w56 = wh * 5 / 6 30 | self.w12 = wh / 2 31 | self.w1cc = wh / 24 32 | 33 | def init_tile(self, ctx, g, base_color=None): 34 | ctx.arc(0, 0, g.w16, CS, CE) 35 | ctx.arc(g.w12, 0, g.w16, CW, CE) 36 | ctx.arc(g.wh, 0, g.w16, CW, CS) 37 | ctx.arc(g.wh, g.w12, g.w16, CN, CS) 38 | ctx.arc(g.wh, g.wh, g.w16, CN, CW) 39 | ctx.arc(g.w12, g.wh, g.w16, CE, CW) 40 | ctx.arc(0, g.wh, g.w16, CE, CN) 41 | ctx.arc(0, g.w12, g.w16, CS, CN) 42 | ctx.set_source_rgba(*(base_color or g.bgfg[0])) 43 | ctx.fill() 44 | ctx.set_source_rgba(*g.bgfg[1]) 45 | ctx.translate(g.w12, g.w12) 46 | ctx.rotate(DEG90 * self.rot) 47 | ctx.translate(-g.w12, -g.w12) 48 | if self.flipped: 49 | ctx.translate(g.wh, 0) 50 | ctx.scale(-1, 1) 51 | 52 | def dot(self, ctx, g, x, y): 53 | ctx.circle(x, y, g.w1c) 54 | ctx.fill() 55 | 56 | @stroke 57 | def four_corners(self, ctx, g, which=(0,1,2,3)): 58 | with ctx.save_restore(): 59 | for i in which: 60 | with ctx.rotated(g.wh, i): 61 | ctx.arc(g.w3c, 0, g.w1c, CW, CE) 62 | ctx.arc(0, 0, g.w26, CE, CS) 63 | ctx.arc(0, g.w3c, g.w1c, CS, CN) 64 | ctx.arc_negative(0, 0, g.w16, CS, CE) 65 | ctx.fill() 66 | @stroke 67 | def all_dots(self, ctx, g): 68 | for a in [g.w3c, g.w9c]: 69 | for b in [0, g.wh]: 70 | self.dot(ctx, g, a, b) 71 | self.dot(ctx, g, b, a) 72 | 73 | @stroke 74 | def bar(self, ctx, g): 75 | ctx.arc(0, g.w3c, g.w1c, CS, CN) 76 | ctx.arc(g.wh, g.w3c, g.w1c, CN, CS) 77 | ctx.fill() 78 | 79 | @stroke 80 | def bar_gapped(self, ctx, g): 81 | self.bar(ctx, g) 82 | ctx.set_source_rgba(*g.bgfg[0]) 83 | ctx.rectangle(0, g.w16 - g.w1cc, g.wh, g.w1cc) 84 | ctx.fill() 85 | ctx.rectangle(0, g.w26, g.wh, g.w1cc) 86 | ctx.fill() 87 | 88 | @stroke 89 | def half_bar_gapped(self, ctx, g): 90 | with ctx.save_restore(): 91 | ctx.arc(0, g.w3c, g.w1c, CS, CN) 92 | ctx.line_to(g.w12, g.w16) 93 | ctx.line_to(g.w12, g.w26) 94 | ctx.close_path() 95 | ctx.fill() 96 | ctx.set_source_rgba(*g.bgfg[0]) 97 | ctx.rectangle(0, g.w16 - g.w1cc, g.w12, g.w1cc) 98 | ctx.fill() 99 | ctx.rectangle(0, g.w26, g.w12, g.w1cc) 100 | ctx.fill() 101 | 102 | @stroke 103 | def slash(self, ctx, g): 104 | with ctx.save_restore(): 105 | ctx.arc(g.w9c, 0, g.w1c, CW, CE) 106 | ctx.arc(0, 0, g.w56, CE, CS) 107 | ctx.arc(0, g.w9c, g.w1c, CS, CN) 108 | ctx.arc_negative(0, 0, g.w46, CS, CE) 109 | ctx.fill() 110 | 111 | @stroke 112 | def slash_gapped(self, ctx, g): 113 | self.slash(ctx, g) 114 | with ctx.save_restore(): 115 | ctx.set_source_rgba(*g.bgfg[0]) 116 | ctx.arc(0, 0, g.w46, CE, CS) 117 | ctx.arc_negative(0, 0, g.w46 - g.w1cc, CS, CE) 118 | ctx.fill() 119 | ctx.arc_negative(0, 0, g.w56, CS, CE) 120 | ctx.arc(0, 0, g.w56 + g.w1cc, CE, CS) 121 | ctx.fill() 122 | 123 | @stroke 124 | def top_edge(self, ctx, g): 125 | with ctx.save_restore(): 126 | ctx.arc(g.w3c, 0, g.w1c, CW, CE) 127 | ctx.arc_negative(g.w12, 0, g.w16, CW, CE) 128 | ctx.arc(g.w9c, 0, g.w1c, CW, CE) 129 | ctx.arc(g.w12, 0, g.w26, CE, CW) 130 | ctx.fill() 131 | 132 | @stroke 133 | def ell(self, ctx, g): 134 | with ctx.save_restore(): 135 | ctx.arc(g.w9c, 0, g.w1c, CW, CE) 136 | ctx.arc(g.w12, 0, g.w26, CE, CS) 137 | ctx.arc(0, g.w3c, g.w1c, CS, CN) 138 | ctx.arc_negative(g.w12, 0, g.w16, CS, CE) 139 | ctx.fill() 140 | 141 | @stroke 142 | def high_frown(self, ctx, g): 143 | ctx.arc(g.w3c, g.wh, g.w1c, CE, CW) 144 | ctx.arc(g.w12, g.w9c, g.w26, CW, CE) 145 | ctx.arc(g.w9c, g.wh, g.w1c, CE, CW) 146 | ctx.arc_negative(g.w12, g.w9c, g.w16, CE, CW) 147 | ctx.fill() 148 | 149 | @stroke 150 | def cowboy_hat(self, ctx, g): 151 | ctx.arc(g.w3c, 0, g.w1c, CW, CE) 152 | ctx.arc_negative(g.w12, 0, g.w16, CW, CE) 153 | ctx.arc(g.w9c, 0, g.w1c, CW, CE) 154 | ctx.arc(0, 0, g.w56, CE, CS) 155 | ctx.arc(0, g.w9c, g.w1c, CS, CN) 156 | ctx.arc_negative(0, g.w12, g.w16, CS, CN) 157 | ctx.arc(0, g.w3c, g.w1c, CS, CN) 158 | ctx.arc_negative(0, 0, g.w16, CS, CE) 159 | ctx.fill() 160 | 161 | @stroke 162 | def mid_loop(self, ctx, g): 163 | ctx.arc(0, g.w9c, g.w1c, CS, CN) 164 | ctx.arc_negative(0, g.w12, g.w16, CS, CE) 165 | ctx.arc(g.w12, g.w12, g.w26, CW, CE) 166 | ctx.arc_negative(g.wh, g.w12, g.w16, CW, CS) 167 | ctx.arc(g.wh, g.w9c, g.w1c, CN, CS) 168 | ctx.arc(g.wh, g.w12, g.w26, CS, CW) 169 | ctx.arc_negative(g.w12, g.w12, g.w16, CE, CW) 170 | ctx.arc(0, g.w12, g.w26, CE, CS) 171 | ctx.fill() 172 | 173 | def draw(self, ctx, g): 174 | ... 175 | 176 | 177 | @collect(n6_tiles) 178 | @collect(n6_circles, repeat=3) 179 | class Slash21(Tile): 180 | def draw(self, ctx, g): 181 | self.slash(ctx, g) 182 | self.dot(ctx, g, g.wh, g.w3c) 183 | self.dot(ctx, g, g.w3c, g.wh) 184 | self.four_corners(ctx, g, which=(0, 2)) 185 | 186 | @collect(n6_tiles) 187 | @collect(n6_circles, repeat=3) 188 | class Slash11(Tile): 189 | def draw(self, ctx, g): 190 | self.slash(ctx, g) 191 | self.dot(ctx, g, g.wh, g.w3c) 192 | self.dot(ctx, g, g.w3c, g.wh) 193 | self.dot(ctx, g, 0, g.w3c) 194 | self.dot(ctx, g, g.w3c, 0) 195 | self.four_corners(ctx, g, which=(2,)) 196 | 197 | @collect(n6_tiles) 198 | @collect(n6_circles, repeat=3) 199 | class Slash2(Tile): 200 | def draw(self, ctx, g): 201 | self.slash(ctx, g) 202 | self.dot(ctx, g, g.wh, g.w3c) 203 | self.dot(ctx, g, g.w3c, g.wh) 204 | self.dot(ctx, g, g.wh, g.w9c) 205 | self.dot(ctx, g, g.w9c, g.wh) 206 | self.four_corners(ctx, g, which=(0,)) 207 | 208 | @collect(n6_weird) 209 | @collect(n6_lattice) 210 | class SlashCross(Tile): 211 | def draw(self, ctx, g): 212 | self.slash(ctx, g) 213 | with ctx.flip_lr(g.wh): 214 | self.slash_gapped(ctx, g) 215 | self.dot(ctx, g, g.wh, g.w3c) 216 | self.dot(ctx, g, g.w3c, g.wh) 217 | self.dot(ctx, g, 0, g.w3c) 218 | self.dot(ctx, g, g.w9c, g.wh) 219 | 220 | @collect(n6_tiles) 221 | @collect(n6_circles, repeat=2) 222 | class Cowboy(Tile): 223 | def draw(self, ctx, g): 224 | self.cowboy_hat(ctx, g) 225 | self.four_corners(ctx, g, which=(2,)) 226 | self.dot(ctx, g, g.wh, g.w3c) 227 | self.dot(ctx, g, g.w3c, g.wh) 228 | 229 | @collect(n6_tiles) 230 | class CowboyMinus(Tile): 231 | def draw(self, ctx, g): 232 | self.cowboy_hat(ctx, g) 233 | self.dot(ctx, g, g.wh, g.w3c) 234 | self.dot(ctx, g, g.w3c, g.wh) 235 | self.dot(ctx, g, g.wh, g.w9c) 236 | self.dot(ctx, g, g.w9c, g.wh) 237 | 238 | @collect(n6_tiles) 239 | class Empty(Tile): 240 | rotations = 1 241 | def draw(self, ctx, g): 242 | self.all_dots(ctx, g) 243 | 244 | @collect(n6_tiles) 245 | class Empty1(Tile): 246 | def draw(self, ctx, g): 247 | self.all_dots(ctx, g) 248 | self.dot(ctx, g, g.w12, g.w3c) 249 | 250 | @collect(n6_tiles) 251 | class Dotted(Tile): 252 | rotations = 1 253 | def draw(self, ctx, g): 254 | self.all_dots(ctx, g) 255 | self.dot(ctx, g, g.w12, g.w3c) 256 | self.dot(ctx, g, g.w3c, g.w12) 257 | self.dot(ctx, g, g.w9c, g.w12) 258 | self.dot(ctx, g, g.w12, g.w9c) 259 | 260 | @collect(n6_tiles) 261 | @collect(n6_filled) 262 | @collect(n6_connected) 263 | class Filled(Tile): 264 | rotations = 1 265 | def draw(self, ctx, g): 266 | ctx.arc(g.w3c, 0, g.w1c, CW, CE) 267 | ctx.arc_negative(g.w12, 0, g.w16, CW, CE) 268 | ctx.arc(g.w9c, 0, g.w1c, CW, CE) 269 | ctx.arc_negative(g.wh, 0, g.w16, CW, CS) 270 | ctx.arc(g.wh, g.w3c, g.w1c, CN, CS) 271 | ctx.arc_negative(g.wh, g.w12, g.w16, CN, CS) 272 | ctx.arc(g.wh, g.w9c, g.w1c, CN, CS) 273 | ctx.arc_negative(g.wh, g.wh, g.w16, CN, CW) 274 | ctx.arc(g.w9c, g.wh, g.w1c, CE, CW) 275 | ctx.arc_negative(g.w12, g.wh, g.w16, CE, CW) 276 | ctx.arc(g.w3c, g.wh, g.w1c, CE, CW) 277 | ctx.arc_negative(0, g.wh, g.w16, CE, CN) 278 | ctx.arc(0, g.w9c, g.w1c, CS, CN) 279 | ctx.arc_negative(0, g.w12, g.w16, CS, CN) 280 | ctx.arc(0, g.w3c, g.w1c, CS, CN) 281 | ctx.arc_negative(0, 0, g.w16, CS, CE) 282 | ctx.fill() 283 | 284 | 285 | @collect(n6_tiles) 286 | @collect(n6_circles) 287 | @collect(n6_filled) 288 | @collect(n6_connected) 289 | class FilledHollow(Filled): 290 | rotations = 1 291 | def draw(self, ctx, g): 292 | super().draw(ctx, g) 293 | ctx.set_source_rgba(*g.bgfg[0]) 294 | ctx.circle(g.w12, g.w12, g.w16) 295 | ctx.fill() 296 | 297 | 298 | @collect(n6_tiles) 299 | @collect(n6_circles) 300 | @collect(n6_filled) 301 | class Filled12(Tile): 302 | rotations = 2 303 | def draw(self, ctx, g): 304 | ctx.arc(g.wh, g.w3c, g.w1c, CN, CS) 305 | ctx.arc_negative(g.wh, g.w12, g.w16, CN, CS) 306 | ctx.arc(g.wh, g.w9c, g.w1c, CN, CS) 307 | ctx.arc(0, g.w9c, g.w1c, CS, CN) 308 | ctx.arc_negative(0, g.w12, g.w16, CS, CN) 309 | ctx.arc(0, g.w3c, g.w1c, CS, CN) 310 | ctx.fill() 311 | for x in [g.w3c, g.w9c]: 312 | for y in [0, g.wh]: 313 | self.dot(ctx, g, x, y) 314 | 315 | 316 | @collect(n6_tiles) 317 | @collect(n6_circles) 318 | @collect(n6_filled) 319 | class Filled12Hollow(Filled12): 320 | rotations = 2 321 | def draw(self, ctx, g): 322 | super().draw(ctx, g) 323 | ctx.set_source_rgba(*g.bgfg[0]) 324 | ctx.circle(g.w12, g.w12, g.w16) 325 | ctx.fill() 326 | 327 | 328 | @collect(n6_tiles) 329 | @collect(n6_circles) 330 | @collect(n6_filled) 331 | class Filled13(Tile): 332 | def draw(self, ctx, g): 333 | ctx.arc(g.w3c, 0, g.w1c, CW, CE) 334 | ctx.arc_negative(g.w12, 0, g.w16, CW, CE) 335 | ctx.arc(g.w9c, 0, g.w1c, CW, CE) 336 | ctx.arc_negative(g.wh, 0, g.w16, CW, CS) 337 | ctx.arc(g.wh, g.w3c, g.w1c, CN, CS) 338 | ctx.arc(0, g.w3c, g.w1c, CS, CN) 339 | ctx.arc_negative(0, 0, g.w16, CS, CE) 340 | ctx.fill() 341 | self.dot(ctx, g, g.w3c, g.wh) 342 | self.dot(ctx, g, g.w9c, g.wh) 343 | self.dot(ctx, g, 0, g.w9c) 344 | self.dot(ctx, g, g.wh, g.w9c) 345 | 346 | 347 | @collect(n6_tiles) 348 | @collect(n6_circles) 349 | @collect(n6_filled) 350 | class Filled13Bar(Tile): 351 | def draw(self, ctx, g): 352 | ctx.arc(g.w3c, 0, g.w1c, CW, CE) 353 | ctx.arc_negative(g.w12, 0, g.w16, CW, CE) 354 | ctx.arc(g.w9c, 0, g.w1c, CW, CE) 355 | ctx.arc_negative(g.wh, 0, g.w16, CW, CS) 356 | ctx.arc(g.wh, g.w3c, g.w1c, CN, CS) 357 | ctx.arc(0, g.w3c, g.w1c, CS, CN) 358 | ctx.arc_negative(0, 0, g.w16, CS, CE) 359 | ctx.fill() 360 | self.dot(ctx, g, g.w3c, g.wh) 361 | self.dot(ctx, g, g.w9c, g.wh) 362 | with ctx.rotated(g.wh, 2): 363 | self.bar(ctx, g) 364 | 365 | 366 | @collect(n6_tiles) 367 | @collect(n6_circles) 368 | @collect(n6_filled) 369 | class Filled34(Tile): 370 | def draw(self, ctx, g): 371 | ctx.arc(g.w3c, 0, g.w1c, CW, CE) 372 | ctx.arc_negative(g.w12, 0, g.w16, CW, CE) 373 | ctx.arc(g.w9c, 0, g.w1c, CW, CE) 374 | ctx.arc_negative(g.wh, 0, g.w16, CW, CS) 375 | ctx.arc(g.wh, g.w3c, g.w1c, CN, CS) 376 | ctx.arc_negative(g.wh, g.w12, g.w16, CN, CS) 377 | ctx.arc(g.wh, g.w9c, g.w1c, CN, CS) 378 | ctx.arc(0, g.w9c, g.w1c, CS, CN) 379 | ctx.arc_negative(0, g.w12, g.w16, CS, CN) 380 | ctx.arc(0, g.w3c, g.w1c, CS, CN) 381 | ctx.arc_negative(0, 0, g.w16, CS, CE) 382 | ctx.fill() 383 | self.dot(ctx, g, g.w3c, g.wh) 384 | self.dot(ctx, g, g.w9c, g.wh) 385 | 386 | 387 | @collect(n6_tiles) 388 | @collect(n6_circles) 389 | @collect(n6_filled) 390 | class Filled34Hollow(Filled34): 391 | def draw(self, ctx, g): 392 | super().draw(ctx, g) 393 | ctx.set_source_rgba(*g.bgfg[0]) 394 | ctx.circle(g.w12, g.w12, g.w16) 395 | ctx.fill() 396 | 397 | 398 | @collect(n6_tiles) 399 | @collect(n6_connected) 400 | class EdgeHash(Tile): 401 | rotations = 1 402 | def draw(self, ctx, g): 403 | for i in range(4): 404 | with ctx.rotated(g.wh, i): 405 | self.top_edge(ctx, g) 406 | 407 | 408 | @collect(n6_tiles) 409 | @collect(n6_connected) 410 | class MidLoop(Tile): 411 | rotations = 4 412 | def draw(self, ctx, g): 413 | with ctx.rotated(g.wh, 2): 414 | self.top_edge(ctx, g) 415 | self.four_corners(ctx, g, which=(0, 1)) 416 | self.mid_loop(ctx, g) 417 | 418 | 419 | @collect(n6_tiles) 420 | class MidLoopHalfSparse(Tile): 421 | rotations = 4 422 | flip = True 423 | def draw(self, ctx, g): 424 | with ctx.rotated(g.wh, 2): 425 | self.top_edge(ctx, g) 426 | self.four_corners(ctx, g, which=(0,)) 427 | self.dot(ctx, g, g.w9c, 0) 428 | self.dot(ctx, g, g.wh, g.w3c) 429 | self.mid_loop(ctx, g) 430 | 431 | @collect(n6_tiles) 432 | class MidLoopTopSparse(Tile): 433 | rotations = 4 434 | def draw(self, ctx, g): 435 | with ctx.rotated(g.wh, 2): 436 | self.top_edge(ctx, g) 437 | self.dot(ctx, g, g.w3c, 0) 438 | self.dot(ctx, g, 0, g.w3c) 439 | self.dot(ctx, g, g.w9c, 0) 440 | self.dot(ctx, g, g.wh, g.w3c) 441 | self.mid_loop(ctx, g) 442 | 443 | @collect(n6_tiles) 444 | class MidLoopAllSparse(Tile): 445 | rotations = 4 446 | def draw(self, ctx, g): 447 | self.dot(ctx, g, g.w3c, g.wh) 448 | self.dot(ctx, g, g.w9c, g.wh) 449 | self.dot(ctx, g, g.w3c, 0) 450 | self.dot(ctx, g, 0, g.w3c) 451 | self.dot(ctx, g, g.w9c, 0) 452 | self.dot(ctx, g, g.wh, g.w3c) 453 | self.mid_loop(ctx, g) 454 | 455 | @collect(n6_tiles) 456 | @collect(n6_connected) 457 | class EdgeHashBar(Tile): 458 | rotations = 4 459 | def draw(self, ctx, g): 460 | for i in range(4): 461 | with ctx.rotated(g.wh, i): 462 | self.top_edge(ctx, g) 463 | self.bar(ctx, g) 464 | 465 | 466 | @collect(n6_tiles) 467 | @collect(n6_circles) 468 | class Edge34(Tile): 469 | def draw(self, ctx, g): 470 | with ctx.save_restore(): 471 | for _ in range(3): 472 | self.top_edge(ctx, g) 473 | ctx.translate(g.wh, 0) 474 | ctx.rotate(DEG90) 475 | self.dot(ctx, g, 0, g.w3c) 476 | self.dot(ctx, g, 0, g.w9c) 477 | 478 | @collect(n6_tiles) 479 | @collect(n6_circles) 480 | class Edge34Bar(Tile): 481 | def draw(self, ctx, g): 482 | with ctx.save_restore(): 483 | for _ in range(3): 484 | self.top_edge(ctx, g) 485 | ctx.translate(g.wh, 0) 486 | ctx.rotate(DEG90) 487 | self.dot(ctx, g, 0, g.w3c) 488 | self.dot(ctx, g, 0, g.w9c) 489 | with ctx.rotated(g.wh, 1): 490 | self.bar(ctx, g) 491 | 492 | @collect(n6_tiles) 493 | @collect(n6_circles) 494 | class HourGlass(Tile): 495 | rotations = 2 496 | def draw(self, ctx, g): 497 | with ctx.save_restore(): 498 | for _ in range(2): 499 | self.top_edge(ctx, g) 500 | ctx.translate(g.wh, g.wh) 501 | ctx.rotate(DEG180) 502 | for x in [0, g.wh]: 503 | for y in [g.w3c, g.w9c]: 504 | self.dot(ctx, g, x, y) 505 | 506 | @collect(n6_tiles) 507 | @collect(n6_circles) 508 | class ThatWay(Tile): 509 | flip = True 510 | def draw(self, ctx, g): 511 | self.top_edge(ctx, g) 512 | self.dot(ctx, g, 0, g.w3c) 513 | self.dot(ctx, g, g.wh, g.w3c) 514 | self.dot(ctx, g, 0, g.w9c) 515 | self.dot(ctx, g, g.w9c, g.wh) 516 | with ctx.save_restore(): 517 | ctx.translate(g.wh, g.wh) 518 | ctx.rotate(DEG180) 519 | self.ell(ctx, g) 520 | 521 | @collect(n6_tiles) 522 | @collect(n6_circles) 523 | class ThoseWays(Tile): 524 | rotations = 2 525 | flip = True 526 | def draw(self, ctx, g): 527 | self.dot(ctx, g, g.w3c, 0) 528 | self.dot(ctx, g, g.wh, g.w3c) 529 | self.dot(ctx, g, 0, g.w9c) 530 | self.dot(ctx, g, g.w9c, g.wh) 531 | self.ell(ctx, g) 532 | with ctx.rotated(g.wh, 2): 533 | self.ell(ctx, g) 534 | 535 | @collect(n6_tiles) 536 | @collect(n6_circles) 537 | class ThoseWaysX(Tile): 538 | flip = True 539 | def draw(self, ctx, g): 540 | self.top_edge(ctx, g) 541 | self.dot(ctx, g, g.w3c, 0) 542 | self.dot(ctx, g, g.wh, g.w3c) 543 | self.dot(ctx, g, 0, g.w9c) 544 | self.dot(ctx, g, g.w9c, g.wh) 545 | self.ell(ctx, g) 546 | with ctx.rotated(g.wh, 2): 547 | self.ell(ctx, g) 548 | 549 | @collect(n6_connected) 550 | @collect(n6_weird) 551 | class Kanji(Tile): 552 | def draw(self, ctx, g): 553 | self.ell(ctx, g) 554 | with ctx.flip_tb(g.wh): 555 | self.ell(ctx, g) 556 | with ctx.rotated(g.wh, 3): 557 | self.bar(ctx, g) 558 | with ctx.rotated(g.wh, 1): 559 | self.top_edge(ctx, g) 560 | 561 | @collect(n6_connected) 562 | @collect(n6_weird) 563 | @collect(n6_lattice) 564 | class KanjiGapped(Kanji): 565 | def draw(self, ctx, g): 566 | super().draw(ctx, g) 567 | self.half_bar_gapped(ctx, g) 568 | with ctx.rotated(g.wh, 3): 569 | self.half_bar_gapped(ctx, g) 570 | 571 | @collect(n6_tiles) 572 | @collect(n6_connected) 573 | @collect(n6_circles) 574 | class CornerHash(Tile): 575 | rotations = 1 576 | def draw(self, ctx, g): 577 | self.four_corners(ctx, g) 578 | 579 | @collect(n6_weird) 580 | class Octagon(Tile): 581 | rotations = 1 582 | def draw(self, ctx, g): 583 | self.four_corners(ctx, g) 584 | for i in range(4): 585 | with ctx.rotated(g.wh, i): 586 | self.top_edge(ctx, g) 587 | 588 | 589 | @collect(n6_tiles) 590 | @collect(n6_circles) 591 | class Corner34(Tile): 592 | def draw(self, ctx, g): 593 | self.four_corners(ctx, g, which=(0,1,2)) 594 | self.dot(ctx, g, 0, g.w9c) 595 | self.dot(ctx, g, g.w3c, g.wh) 596 | 597 | @collect(n6_tiles) 598 | @collect(n6_connected) 599 | @collect(n6_circles) 600 | class SwimSuit(Tile): 601 | def draw(self, ctx, g): 602 | self.four_corners(ctx, g) 603 | self.top_edge(ctx, g) 604 | 605 | @collect(n6_tiles) 606 | @collect(n6_connected) 607 | @collect(n6_circles) 608 | class SwimSuit2(Tile): 609 | def draw(self, ctx, g): 610 | self.four_corners(ctx, g) 611 | self.high_frown(ctx, g) 612 | 613 | @collect(n6_tiles) 614 | @collect(n6_connected) 615 | @collect(n6_circles) 616 | class SwimSuit2Plus(Tile): 617 | def draw(self, ctx, g): 618 | self.four_corners(ctx, g) 619 | self.high_frown(ctx, g) 620 | self.dot(ctx, g, g.w12, g.w9c) 621 | 622 | @collect(n6_tiles) 623 | @collect(n6_circles) 624 | class SadFace(Tile): 625 | def draw(self, ctx, g): 626 | with ctx.save_restore(): 627 | for _ in range(2): 628 | ctx.arc(g.w3c, 0, g.w1c, CW, CE) 629 | ctx.arc(0, 0, g.w26, CE, CS) 630 | ctx.arc(0, g.w3c, g.w1c, CS, CN) 631 | ctx.arc_negative(0, 0, g.w16, CS, CE) 632 | ctx.fill() 633 | ctx.translate(g.wh, 0) 634 | ctx.rotate(DEG90) 635 | self.dot(ctx, g, 0, g.w9c) 636 | self.dot(ctx, g, g.wh, g.w9c) 637 | ctx.arc(g.w3c, g.wh, g.w1c, CE, CW) 638 | ctx.arc(g.w12, g.wh, g.w26, CW, CE) 639 | ctx.arc(g.w9c, g.wh, g.w1c, CE, CW) 640 | ctx.arc_negative(g.w12, g.wh, g.w16, CE, CW) 641 | ctx.fill() 642 | 643 | @collect(n6_tiles) 644 | @collect(n6_circles) 645 | class SadFaceHigh(Tile): 646 | def draw(self, ctx, g): 647 | with ctx.save_restore(): 648 | for _ in range(2): 649 | ctx.arc(g.w3c, 0, g.w1c, CW, CE) 650 | ctx.arc(0, 0, g.w26, CE, CS) 651 | ctx.arc(0, g.w3c, g.w1c, CS, CN) 652 | ctx.arc_negative(0, 0, g.w16, CS, CE) 653 | ctx.fill() 654 | ctx.translate(g.wh, 0) 655 | ctx.rotate(DEG90) 656 | self.dot(ctx, g, 0, g.w9c) 657 | self.dot(ctx, g, g.wh, g.w9c) 658 | self.high_frown(ctx, g) 659 | 660 | @collect(n6_tiles) 661 | @collect(n6_circles) 662 | class Frog(Tile): 663 | def draw(self, ctx, g): 664 | with ctx.save_restore(): 665 | for _ in range(2): 666 | ctx.arc(g.w3c, 0, g.w1c, CW, CE) 667 | ctx.arc(0, 0, g.w26, CE, CS) 668 | ctx.arc(0, g.w3c, g.w1c, CS, CN) 669 | ctx.arc_negative(0, 0, g.w16, CS, CE) 670 | ctx.fill() 671 | ctx.translate(g.wh, 0) 672 | ctx.rotate(DEG90) 673 | with ctx.rotated(g.wh, 2): 674 | self.bar(ctx, g) 675 | self.dot(ctx, g, g.w3c, g.wh) 676 | self.dot(ctx, g, g.w9c, g.wh) 677 | 678 | 679 | @collect(n6_tiles) 680 | class CrossCross(Tile): 681 | def draw(self, ctx, g): 682 | ctx.arc(g.w3c, 0, g.w1c, CW, CE) 683 | ctx.arc(0, 0, g.w26, CE, CS) 684 | ctx.arc(0, g.w3c, g.w1c, CS, CN) 685 | ctx.arc_negative(0, 0, g.w16, CS, CE) 686 | ctx.fill() 687 | 688 | with ctx.rotated(g.wh, 1): 689 | self.bar(ctx, g) 690 | with ctx.rotated(g.wh, 2): 691 | self.bar_gapped(ctx, g) 692 | 693 | self.dot(ctx, g, g.w3c, g.wh) 694 | self.dot(ctx, g, g.wh, g.w3c) 695 | 696 | @collect(n6_lattice) 697 | class CrossCrossSlash(CrossCross): 698 | def draw(self, ctx, g): 699 | super().draw(ctx, g) 700 | with ctx.rotated(g.wh, 2): 701 | self.slash_gapped(ctx, g) 702 | with ctx.rotated(g.wh, 1): 703 | self.half_bar_gapped(ctx, g) 704 | 705 | @collect(n6_lattice) 706 | class Hash(Tile): 707 | def draw(self, ctx, g): 708 | for _ in range(4): 709 | self.bar(ctx, g) 710 | ctx.translate(g.wh, 0) 711 | ctx.rotate(DEG90) 712 | for _ in range(4): 713 | self.half_bar_gapped(ctx, g) 714 | ctx.translate(g.wh, 0) 715 | ctx.rotate(DEG90) 716 | 717 | 718 | @collect(n6_tiles) 719 | @collect(n6_connected) 720 | @collect(n6_circles, repeat=3) 721 | class CornerSlash(Tile): 722 | def draw(self, ctx, g): 723 | self.four_corners(ctx, g) 724 | self.slash(ctx, g) 725 | 726 | @collect(n6_tiles) 727 | class CornerSlashMinus(Tile): 728 | def draw(self, ctx, g): 729 | self.four_corners(ctx, g, which=(0,2,3)) 730 | self.slash(ctx, g) 731 | self.dot(ctx, g, g.wh, g.w3c) 732 | 733 | @collect(n6_connected) 734 | @collect(n6_weird) 735 | class CornerSlashCross(Tile): 736 | def draw(self, ctx, g): 737 | self.slash(ctx, g) 738 | with ctx.flip_lr(g.wh): 739 | self.slash_gapped(ctx, g) 740 | self.four_corners(ctx, g) 741 | 742 | @collect(n6_weird) 743 | @collect(n6_lattice) 744 | class CornerSlashCrossUnder(Tile): 745 | def draw(self, ctx, g): 746 | self.four_corners(ctx, g) 747 | self.slash(ctx, g) 748 | with ctx.flip_lr(g.wh): 749 | self.slash_gapped(ctx, g) 750 | 751 | @collect(n6_weird) 752 | class Sprout(Tile): 753 | def draw(self, ctx, g): 754 | self.slash(ctx, g) 755 | self.ell(ctx, g) 756 | self.four_corners(ctx, g, which=(1,2,3)) 757 | self.dot(ctx, g, g.w3c, 0) 758 | 759 | @collect(n6_tiles) 760 | @collect(n6_circles) 761 | class DoubleEll(Tile): 762 | def draw(self, ctx, g): 763 | self.ell(ctx, g) 764 | with ctx.flip_lr(g.wh): 765 | with ctx.rotated(g.wh, 1): 766 | self.ell(ctx, g) 767 | self.four_corners(ctx, g, which=(2,)) 768 | self.dot(ctx, g, g.wh, g.w3c) 769 | self.dot(ctx, g, g.w3c, g.wh) 770 | 771 | @collect(n6_circles) 772 | class TopEdge(Tile): 773 | def draw(self, ctx, g): 774 | self.bar(ctx, g) 775 | self.top_edge(ctx, g) 776 | self.all_dots(ctx, g) 777 | 778 | 779 | @collect(n6_lattice) 780 | class SlashTriangle(Tile): 781 | def draw(self, ctx, g): 782 | self.bar(ctx, g) 783 | self.slash_gapped(ctx, g) 784 | with ctx.flip_lr(g.wh): 785 | self.slash_gapped(ctx, g) 786 | self.half_bar_gapped(ctx, g) 787 | self.dot(ctx, g, g.w3c, g.wh) 788 | self.dot(ctx, g, g.w9c, g.wh) 789 | 790 | 791 | n6_strokes = [] 792 | for meth_name in dir(Tile): 793 | meth = getattr(Tile, meth_name, None) 794 | if getattr(meth, "is_stroke", False): 795 | class _OneStroke(Tile): 796 | def draw_tile(self, ctx, wh, meth_name=meth_name): 797 | super().draw_tile(ctx, wh, base_color=(1, .65, .65), meth_name=meth_name) 798 | cls = type(meth_name, (_OneStroke,), {}) 799 | n6_strokes.append(cls()) 800 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # On mac will also need: 2 | # brew install cmake pkgconf cairo 3 | ipywidgets 4 | jupyterlab 5 | numpy<2.0.0 6 | pillow 7 | pycairo 8 | shapely 9 | tqdm 10 | -------------------------------------------------------------------------------- /smith.py: -------------------------------------------------------------------------------- 1 | import math 2 | import random 3 | 4 | from drawing import cairo_context 5 | from helpers import color, range2d 6 | 7 | PI = math.pi 8 | PI2 = math.pi / 2 9 | 10 | eps = 0.5 11 | 12 | 13 | class SmithTile: 14 | def init_tile(self, ctx, wh, bgfg=None): 15 | if bgfg is None: 16 | bgfg = [color(1), color(0)] 17 | eps = 0 18 | ctx.rectangle(0 - eps, 0 - eps, wh + eps, wh + eps) 19 | ctx.set_source_rgba(*bgfg[0]) 20 | ctx.fill() 21 | ctx.set_source_rgba(*bgfg[1]) 22 | 23 | def draw(self, ctx, wh): 24 | ... 25 | 26 | 27 | class SmithLeftTile(SmithTile): 28 | def draw(self, ctx, wh, bgfg=None): 29 | self.init_tile(ctx, wh, bgfg) 30 | wh2 = wh // 2 31 | ctx.move_to(0 - eps, wh2) 32 | ctx.arc(0 - eps, wh + eps, wh2 + eps, -PI2, 0) 33 | ctx.line_to(0 - eps, wh + eps) 34 | ctx.close_path() 35 | ctx.fill() 36 | 37 | ctx.move_to(wh + eps, wh2) 38 | ctx.arc(wh + eps, 0 - eps, wh2 + eps, PI2, PI) 39 | ctx.line_to(wh + eps, 0 - eps) 40 | ctx.close_path() 41 | ctx.fill() 42 | 43 | 44 | class SmithRightTile(SmithLeftTile): 45 | def draw(self, ctx, wh, bgfg): 46 | wh2 = wh / 2 47 | ctx.save() 48 | ctx.translate(wh2, wh2) 49 | ctx.rotate(PI2) 50 | ctx.translate(-wh2, -wh2) 51 | super().draw(ctx, wh, bgfg) 52 | ctx.restore() 53 | 54 | 55 | def smith(width=400, height=200, tilew=40, grid=False, gap=0, seed=None): 56 | rand = random.Random(seed) 57 | with cairo_context(width, height) as ctx: 58 | tiles = [SmithLeftTile(), SmithRightTile()] 59 | bgfgs = [ 60 | [color(1), color(0)], 61 | [color(0), color(1)], 62 | ] 63 | for ox, oy in range2d(width // tilew, height // tilew): 64 | ctx.save() 65 | ctx.translate(ox * (tilew + gap), oy * (tilew + gap)) 66 | coin = rand.choice([0, 1]) 67 | tiles[coin].draw(ctx, tilew, bgfgs[(ox + oy + coin) % 2]) 68 | if grid: 69 | ctx.set_line_width(0.1) 70 | ctx.rectangle(0, 0, tilew, tilew) 71 | ctx.set_source_rgb(0, 0, 0) 72 | ctx.stroke() 73 | ctx.restore() 74 | return ctx 75 | -------------------------------------------------------------------------------- /tiler.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import math 3 | import random 4 | 5 | import numpy as np 6 | from PIL import Image 7 | 8 | from drawing import cairo_context 9 | from helpers import array_slices_2d, color, range2d, closest 10 | 11 | def rotations(cls, num_rots=4): 12 | return map(cls, range(num_rots)) 13 | 14 | def collect(tile_list, repeat=1, rotations=None, flip=None): 15 | def _dec(cls): 16 | rots = rotations 17 | if rots is None: 18 | rots = cls.rotations 19 | will_flip = flip 20 | if will_flip is None: 21 | will_flip = cls.flip 22 | flips = [False, True] if will_flip else [False] 23 | for _ in range(repeat): 24 | for rot in range(rots): 25 | for flipped in flips: 26 | tile_list.append(cls(rot=rot, flipped=flipped)) 27 | return cls 28 | return _dec 29 | 30 | 31 | def stroke(method): 32 | method.is_stroke = True 33 | return method 34 | 35 | 36 | class TileBase: 37 | class G: 38 | def __init__(self, wh, bgfg=None): 39 | self.wh = wh 40 | self.bgfg = bgfg 41 | if self.bgfg is None: 42 | self.bgfg = [color(1), color(0)] 43 | 44 | rotations = 4 45 | flip = False 46 | 47 | def __init__(self, rot=0, flipped=False): 48 | self.rot = rot 49 | self.flipped = flipped 50 | 51 | def init_tile(self, ctx, g, base_color=None): 52 | ... 53 | 54 | def draw_tile(self, ctx, wh, bgfg=None, base_color=None, meth_name="draw"): 55 | g = self.G(wh, bgfg) 56 | self.init_tile(ctx, g, base_color=base_color) 57 | getattr(self, meth_name)(ctx, g) 58 | 59 | 60 | 61 | def tile_value(tile): 62 | """The gray value of a tile from 0 (black) to 1 (white).""" 63 | pic = multiscale_truchet(tiles=[tile], width=10, height=10, tilew=10, nlayers=1, format="png") 64 | a = np.array(Image.open(pic.pngio).convert("L")) 65 | value = np.sum(a) / a.size / 255 66 | return value 67 | 68 | 69 | def tile_value4(tile): 70 | """The four-quadrant gray values (0->1) of a tile.""" 71 | pw = 10 72 | pw2 = pw // 2 73 | pic = multiscale_truchet(tiles=[tile], width=pw, height=pw, tilew=pw, nlayers=1, format="png") 74 | a = np.array(Image.open(pic.pngio).convert("L")) 75 | values = [] 76 | for a4 in array_slices_2d(a, 0, 0, nx=2, dx=pw2): 77 | values.append(np.sum(a4) / a4.size / 255) 78 | return np.array(values) 79 | 80 | 81 | def value_chart(tiles, inverted=False): 82 | marg = 50 83 | width = 800 84 | mid = 30 85 | 86 | def tick(x, h): 87 | v = (width - 2 * marg) * x + marg 88 | ctx.move_to(v, mid - h / 2) 89 | ctx.line_to(v, mid + h / 2) 90 | ctx.stroke() 91 | 92 | with cairo_context(width, mid * 2) as ctx: 93 | ctx.set_line_width(0.5) 94 | ctx.move_to(marg, mid) 95 | ctx.line_to(width - marg, mid) 96 | ctx.stroke() 97 | tick(0, 20) 98 | tick(1, 20) 99 | for t in tiles: 100 | value = tile_value(t) 101 | tick(value, 20) 102 | if inverted: 103 | tick(1 - value, 20) 104 | ctx.set_source_rgb(1, 0, 0) 105 | ctx.set_line_width(2) 106 | for i in range(11): 107 | tick(i / 10, 10) 108 | return ctx 109 | 110 | 111 | def show_tiles( 112 | tiles, 113 | size=100, 114 | frac=.6, 115 | width=950, 116 | with_value=False, 117 | with_name=False, 118 | only_one=False, 119 | sort=True, 120 | ): 121 | if only_one: 122 | # Keep only one of each class 123 | classes = {tile.__class__ for tile in tiles} 124 | tiles = [cls() for cls in classes] 125 | if with_value: 126 | values = {t: f"{tile_value(t):.3f}" for t in tiles} 127 | if sort: 128 | tiles = sorted(tiles, key=lambda t: t.__class__.__name__) 129 | if with_value: 130 | tiles = sorted(tiles, key=values.get) 131 | wh = size * frac 132 | gap = size / 10 133 | per_row = (width + gap) // (size + gap) 134 | nrows = len(tiles) // per_row + (1 if len(tiles) % per_row else 0) 135 | ncols = per_row if nrows > 1 else len(tiles) 136 | totalW = (size + gap) * ncols - gap 137 | totalH = (size + gap) * nrows - gap 138 | with cairo_context(totalW, totalH) as ctx: 139 | ctx.select_font_face("Sans") 140 | ctx.set_font_size(10) 141 | for i, tile in enumerate(tiles): 142 | r, c = divmod(i, per_row) 143 | ctx.save() 144 | ctx.translate((size + gap) * c, (size + gap) * r) 145 | ctx.rectangle(0, 0, size, size) 146 | ctx.set_source_rgb(0.85, 0.85, 0.85) 147 | ctx.fill() 148 | 149 | ctx.save() 150 | ctx.translate((size - wh) / 2, (size - wh) / 2) 151 | 152 | tile.draw_tile(ctx, wh) 153 | 154 | ctx.rectangle(0, 0, wh, wh) 155 | ctx.set_source_rgba(0.5, 0.5, 0.5, 0.75) 156 | ctx.set_line_width(1) 157 | ctx.set_dash([5, 5], 7.5) 158 | ctx.stroke() 159 | ctx.restore() 160 | 161 | if with_value: 162 | ctx.move_to(2, 10) 163 | ctx.set_source_rgba(0, 0, 0, 1) 164 | ctx.show_text(values[tile]) 165 | 166 | if with_name: 167 | ctx.move_to(2, size - 2) 168 | ctx.set_source_rgba(0, 0, 0, 1) 169 | ctx.show_text(tile.__class__.__name__) 170 | 171 | ctx.restore() 172 | 173 | return ctx 174 | 175 | 176 | def show_overlap(tile): 177 | W = 200 178 | bgfg = [color(1), color(0)] 179 | with cairo_context(W, W) as ctx: 180 | ctx.rectangle(0, 0, W, W) 181 | ctx.set_source_rgb(.75, .75, .75) 182 | ctx.fill() 183 | ctx.save() 184 | ctx.translate(W/4, W/4) 185 | tile.draw(ctx, W/2, bgfg) 186 | ctx.restore() 187 | offset = 0 188 | bgfg = [color((0, 0, .7)), color((1, .5, .5))] 189 | for x, y in range2d(2, 2): 190 | ctx.save() 191 | ctx.translate(W/4 + x * W/4 + offset, W/4 + y * W/4 + offset) 192 | tile.draw(ctx, W/4, bgfg) 193 | ctx.restore() 194 | return ctx 195 | 196 | 197 | def multiscale_truchet( 198 | tiles=None, 199 | tile_chooser=None, 200 | width=400, 201 | height=200, 202 | tilew=40, 203 | nlayers=2, 204 | chance=0.5, 205 | should_split=None, 206 | bg=1, 207 | fg=0, 208 | seed=None, 209 | format="svg", 210 | output=None, 211 | grid=False, 212 | ): 213 | all_boxes = [] 214 | 215 | rand = random.Random(seed) 216 | 217 | if isinstance(tiles, (list, tuple)): 218 | assert tile_chooser is None 219 | tile_chooser = lambda ux, uy, uw, ilevel: rand.choice(tiles) 220 | 221 | if isinstance(chance, float): 222 | _chance = chance 223 | chance = lambda *a, **k: _chance 224 | 225 | if should_split is None: 226 | should_split = lambda x, y, size, ilayer: rand.random() <= chance(x, y, size, ilayer) 227 | 228 | def one_tile(x, y, size, ilayer): 229 | tile = tile_chooser(x / width, y / width, size / width, ilayer) 230 | with ctx.save_restore(): 231 | ctx.translate(x, y) 232 | tile.draw_tile(ctx, size, bgfg) 233 | boxes.append((x, y, size)) 234 | if grid: 235 | all_boxes.append((x, y, size)) 236 | 237 | with cairo_context(width, height, format=format, output=output) as ctx: 238 | boxes = [] 239 | bgfg = [color(bg), color(fg)] 240 | wextra = 1 if (width % tilew) else 0 241 | hextra = 1 if (height % tilew) else 0 242 | for ox, oy in range2d(int(width / tilew) + wextra, int(height / tilew) + hextra): 243 | one_tile(ox * tilew, oy * tilew, tilew, 0) 244 | 245 | for ilayer in range(nlayers - 1): 246 | last_boxes = boxes 247 | bgfg = bgfg[::-1] 248 | boxes = [] 249 | for bx, by, bsize in last_boxes: 250 | if should_split((bx + bsize/2)/ width, (by + bsize/2) / height, bsize / width, ilayer): 251 | nbsize = bsize / 2 252 | for dx, dy in range2d(2, 2): 253 | nbx, nby = bx + dx * nbsize, by + dy * nbsize 254 | one_tile(nbx, nby, nbsize, ilayer+1) 255 | 256 | if grid: 257 | ctx.set_line_width(.5) 258 | ctx.set_source_rgb(1, 0, 0) 259 | for x, y, size in all_boxes: 260 | ctx.rectangle(x, y, size, size) 261 | ctx.stroke() 262 | 263 | return ctx 264 | 265 | 266 | def nearest(levels, data): 267 | """Find the values in a closest to the values in b""" 268 | data_shape = data.shape 269 | linear = data.reshape((math.prod(data_shape),)) 270 | adjusted = levels[np.argmin(np.abs(levels[:, np.newaxis] - linear[np.newaxis, :]), axis=0)] 271 | return adjusted.reshape(data_shape) 272 | 273 | 274 | def image_truchet( 275 | tiles, 276 | image, 277 | width=400, 278 | height=400, 279 | tilew=40, 280 | nlayers=1, 281 | format="svg", 282 | output=None, 283 | grid=False, 284 | seed=None, 285 | scale=0, 286 | jitter=0, 287 | split_thresh=50, 288 | split_test=2, 289 | ): 290 | rand = random.Random(seed) 291 | 292 | if isinstance(image, str): 293 | image = np.array(Image.open(image).convert("L")) 294 | 295 | tile_valuess = [] 296 | levelss = [] 297 | all_levels = [] 298 | for half in [0, 1]: 299 | tile_values = collections.defaultdict(list) 300 | for tile in tiles: 301 | value = int(tile_value(tile) * 255) 302 | if half == 1: 303 | value = 256 - value 304 | tile_values[value].append(tile) 305 | levels = np.array(sorted(tile_values.keys())) 306 | tile_valuess.append(tile_values) 307 | levelss.append(levels) 308 | all_levels.extend(levels) 309 | 310 | lmin, lmax = min(all_levels), max(all_levels) 311 | imin, imax = np.min(image), np.max(image) 312 | scale = float(scale) 313 | lmin *= scale 314 | lmax = 1 - scale * (1 - lmax) 315 | imin *= scale 316 | imax = 1 - scale * (1 - imax) 317 | image = image - imin # make a copy of the image 318 | image /= (imax - imin) 319 | image *= (lmax - lmin) 320 | image += lmin 321 | 322 | def tile_chooser(ux, uy, us, ilayer): 323 | ix = int(ux * image.shape[0]) 324 | iy = int(uy * image.shape[1]) 325 | isize = int(us * image.shape[0]) 326 | color = np.mean(image[iy:iy+isize, ix:ix+isize]) 327 | if jitter: 328 | color += rand.random() * jitter * 2 - jitter 329 | close_color = closest(color, levelss[ilayer % 2]) 330 | tiles = tile_valuess[ilayer % 2][close_color] 331 | return rand.choice(tiles) 332 | 333 | def should_split(ux, uy, us, _): 334 | nsplit = 2 ** split_test 335 | ix = int(ux * image.shape[0]) 336 | iy = int(uy * image.shape[1]) 337 | isize = int(us * image.shape[0] / nsplit) 338 | colors = [] 339 | for aslice in array_slices_2d(image, ix, iy, nx=nsplit, dx=isize): 340 | colors.append(np.mean(aslice)) 341 | lo = min(colors) 342 | hi = max(colors) 343 | return (hi - lo) > split_thresh 344 | 345 | return multiscale_truchet( 346 | tile_chooser=tile_chooser, 347 | should_split=should_split, 348 | width=width, 349 | height=height, 350 | tilew=tilew, 351 | nlayers=nlayers, 352 | bg=1, 353 | fg=0, 354 | format=format, 355 | output=output, 356 | grid=grid, 357 | ) 358 | 359 | 360 | def image_truchet4( 361 | tiles, 362 | image, 363 | width=400, 364 | height=400, 365 | tilew=40, 366 | nlayers=1, 367 | format="svg", 368 | output=None, 369 | grid=False, 370 | split_thresh=50, 371 | split_test=2, 372 | ): 373 | if isinstance(image, str): 374 | image = np.array(Image.open(image).convert("L")) 375 | 376 | tile_valuess = [] 377 | for half in [0, 1]: 378 | tile_values = [] 379 | for tile in tiles: 380 | value4 = tile_value4(tile) 381 | if half == 1: 382 | value4 = 1 - value4 383 | tile_values.append((value4, tile)) 384 | tile_valuess.append(tile_values) 385 | 386 | def tile_chooser(ux, uy, us, ilayer): 387 | ix = int(ux * image.shape[0]) 388 | iy = int(uy * image.shape[1]) 389 | isize = int(us * image.shape[0]) 390 | color4 = [] 391 | is2 = isize // 2 392 | for aslice in array_slices_2d(image, ix, iy, nx=2, dx=is2): 393 | color4.append(np.mean(aslice)) 394 | color4 = np.array(color4) / 255 395 | min_close = 999999999 396 | best_tile = None 397 | for value4, tile in tile_valuess[ilayer % 2]: 398 | close = np.sum(np.absolute(color4 - value4)) 399 | if close < min_close: 400 | min_close = close 401 | best_tile = tile 402 | return best_tile 403 | 404 | def should_split(ux, uy, us, _): 405 | nsplit = 2 ** split_test 406 | ix = int(ux * image.shape[0]) 407 | iy = int(uy * image.shape[1]) 408 | isize = int(us * image.shape[0] / nsplit) 409 | colors = [] 410 | for aslice in array_slices_2d(image, ix, iy, nx=nsplit, dx=isize): 411 | colors.append(np.mean(aslice)) 412 | lo = min(colors) 413 | hi = max(colors) 414 | return (hi - lo) > split_thresh 415 | 416 | return multiscale_truchet( 417 | tile_chooser=tile_chooser, 418 | should_split=should_split, 419 | width=width, 420 | height=height, 421 | tilew=tilew, 422 | nlayers=nlayers, 423 | bg=1, 424 | fg=0, 425 | format=format, 426 | output=output, 427 | grid=grid, 428 | ) 429 | --------------------------------------------------------------------------------