├── .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 |
--------------------------------------------------------------------------------