messthaler-wulff 2025.12.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ .git
2
+ .idea
3
+ .venv
4
+ dist
5
+ test
6
+ __pycache__/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 J. Meßthaler
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,16 @@
1
+ Metadata-Version: 2.4
2
+ Name: messthaler-wulff
3
+ Version: 2025.12.2
4
+ Author-email: Guenthner <guenthner.jonathan@gmail.com>
5
+ License-File: LICENSE.txt
6
+ Keywords: Wulff,Wulff Crystals
7
+ Classifier: License :: OSI Approved :: MIT License
8
+ Classifier: Operating System :: OS Independent
9
+ Classifier: Programming Language :: Python :: 3
10
+ Requires-Python: >=3.8
11
+ Requires-Dist: matplotlib
12
+ Requires-Dist: open3d
13
+ Requires-Dist: prettytable
14
+ Requires-Dist: psutil
15
+ Requires-Dist: scipy
16
+ Requires-Dist: sortedcontainers
File without changes
@@ -0,0 +1,24 @@
1
+ py=.venv/bin/python
2
+ user=guenthner
3
+ program-name=messthaler-wulff
4
+
5
+ set_user:
6
+ cp ~/.pypirc_$(user) ~/.pypirc
7
+
8
+ build: clean version
9
+ $(py) -m build
10
+
11
+ version:
12
+ vinc
13
+
14
+ clean:
15
+ touch dist/fuck
16
+ rm dist/*
17
+
18
+ upload: set_user build
19
+ $(py) -m twine upload --repository pypi dist/* $(flags)
20
+
21
+ reload: upload
22
+ pipx upgrade $(program-name)
23
+ pipx upgrade $(program-name)
24
+ $(program-name) --version h
@@ -0,0 +1,86 @@
1
+ import argparse
2
+ import logging
3
+ import math
4
+
5
+ import numpy as np
6
+
7
+ from .data import fcc_transform
8
+ from .terminal_formatting import parse_color
9
+ from .version import program_version
10
+
11
+ log = logging.getLogger("messthaler_wulff")
12
+ console = logging.StreamHandler()
13
+ log.addHandler(console)
14
+ log.setLevel(logging.DEBUG)
15
+ console.setFormatter(
16
+ logging.Formatter(parse_color("{asctime} [ℂ3.{levelname:>5}ℂ.] ℂ4.{name}ℂ.: {message}"),
17
+ style="{", datefmt="%W %a %I:%M"))
18
+
19
+ PROGRAM_NAME = "messthaler-wulff"
20
+ DEFAULT_DATE_FORMAT = "%y/%b/%NAME"
21
+
22
+
23
+ def command_entry_point():
24
+ try:
25
+ main()
26
+ except KeyboardInterrupt:
27
+ log.warning("Program was interrupted by user")
28
+
29
+
30
+ def parse_lattice(lattice):
31
+ match lattice.lower():
32
+ case "fcc":
33
+ return fcc_transform()
34
+
35
+ log.info(f"Unknown lattice name {lattice}, interpreting lattice as python code")
36
+
37
+ transform = np.array(eval(lattice, {"sqrt": math.sqrt}))
38
+
39
+ log.info(f"Using result as lattice transform:\n{transform}")
40
+
41
+ input("Press enter to continue...")
42
+ return transform
43
+
44
+
45
+ def main():
46
+ parser = argparse.ArgumentParser(prog=PROGRAM_NAME,
47
+ description="Wudduwudduwudduwudduwudduwudduwudduwuddu",
48
+ allow_abbrev=True, add_help=True, exit_on_error=True)
49
+
50
+ parser.add_argument('-v', '--verbose', action='store_true', help="Show more output")
51
+ parser.add_argument("--version", action="store_true", help="Show the current version of the program")
52
+ parser.add_argument("MODE",
53
+ help="What subprogram to execute; Can be 'view' or 'simulate' or 'interactive' or 'explore'")
54
+ parser.add_argument("--goal", help="The number of atoms to add initially", default="100")
55
+ parser.add_argument("--dimension", default="3")
56
+ parser.add_argument("--lattice", default="fcc")
57
+ parser.add_argument("--axis", action="store_true")
58
+ parser.add_argument("--orthogonal", action="store_true")
59
+ parser.add_argument("-w", "--windows", action="store_true")
60
+
61
+ args = parser.parse_args()
62
+
63
+ log.setLevel(logging.DEBUG if args.verbose else logging.INFO)
64
+ log.debug("Starting program...")
65
+
66
+ if args.version:
67
+ log.info(f"{PROGRAM_NAME} version {program_version}")
68
+ return
69
+
70
+ match args.MODE.lower():
71
+ case 'view':
72
+ from . import mode_view
73
+ mode_view.run_mode(use_orthogonal_projections=args.orthogonal, show_axes=args.axis)
74
+ case 'simulate':
75
+ from . import mode_simulate
76
+ mode_simulate.run_mode(goal=int(args.goal), lattice=parse_lattice(args.lattice))
77
+ case 'interactive':
78
+ from . import mode_interactive
79
+ mode_interactive.run_mode(goal=int(args.goal), dimension=int(args.dimension),
80
+ lattice=parse_lattice(args.lattice), windows_mode=args.windows)
81
+ case 'explore':
82
+ from . import mode_explore
83
+ mode_explore.run_mode(goal=int(args.goal), lattice=parse_lattice(args.lattice),
84
+ dimension=int(args.dimension))
85
+ case _:
86
+ log.error(f"Unknown mode {args.MODE}. Must be one of 'view' or 'simulate'")
@@ -0,0 +1,3 @@
1
+ import messthaler_wulff
2
+
3
+ messthaler_wulff.command_entry_point()
@@ -0,0 +1,456 @@
1
+ import json
2
+ import random
3
+ import shutil
4
+ import time
5
+ from collections import defaultdict
6
+
7
+ import numpy as np
8
+
9
+ from .progress import ProgressBar
10
+
11
+
12
+ class EnergyTracker:
13
+ class EnergyLevel:
14
+ def __init__(self):
15
+ self.values = []
16
+ self.indices = {}
17
+
18
+ def __len__(self):
19
+ return len(self.values)
20
+
21
+ def __getitem__(self, i):
22
+ return self.values[i]
23
+
24
+ def add(self, atom):
25
+ if atom in self.indices:
26
+ return
27
+
28
+ self.indices[atom] = len(self.values)
29
+ self.values.append(atom)
30
+
31
+ def remove(self, atom):
32
+ if len(self.values) == 0:
33
+ raise ValueError("I am empty")
34
+
35
+ index = self.indices[atom]
36
+ try:
37
+ if index == len(self.values) - 1:
38
+ self.values.pop()
39
+ else:
40
+ self.values[index] = self.values.pop()
41
+ self.indices[self.values[index]] = index
42
+ except IndexError:
43
+ raise IndexError(f"List index {index} out of range for {self.values}")
44
+
45
+ del self.indices[atom]
46
+
47
+ def __str__(self):
48
+ return str(len(self.values))
49
+
50
+ def __repr__(self):
51
+ return str(self)
52
+
53
+ def __iter__(self):
54
+ for i in range(len(self)):
55
+ yield self[i]
56
+
57
+ def json_obj(self):
58
+ return [str(v) for v in self.values] # f"{len(self.values)} values"
59
+
60
+ def __init__(self):
61
+ self.min_energy = None
62
+ self.energy_levels = defaultdict(EnergyTracker.EnergyLevel)
63
+ self.atom2energy = {}
64
+
65
+ def minimum(self, choice=lambda x: 0):
66
+ level = self.energy_levels[self.min_energy]
67
+ atom = level[choice(len(level))]
68
+ return atom, self.min_energy
69
+
70
+ def all_minimums(self):
71
+ level = self.energy_levels[self.min_energy]
72
+ return list(level)
73
+
74
+ def __len__(self):
75
+ return len(self.atom2energy)
76
+
77
+ def __contains__(self, atom):
78
+ return atom in self.atom2energy
79
+
80
+ def get(self, atom):
81
+ return self.atom2energy[atom]
82
+
83
+ def set(self, atom, energy):
84
+ if self.min_energy is None or energy < self.min_energy:
85
+ self.min_energy = energy
86
+
87
+ if atom in self.atom2energy:
88
+ old_energy = self.atom2energy[atom]
89
+ if old_energy != energy:
90
+ old_level = self.energy_levels[old_energy]
91
+ old_level.remove(atom)
92
+ level = self.energy_levels[energy]
93
+ level.add(atom)
94
+ self.adjust_min_energy()
95
+
96
+ self.atom2energy[atom] = energy
97
+ else:
98
+ level = self.energy_levels[energy]
99
+ level.add(atom)
100
+ self.atom2energy[atom] = energy
101
+
102
+ def unset(self, atom, energy):
103
+ level = self.energy_levels[energy]
104
+ level.remove(atom)
105
+ del self.atom2energy[atom]
106
+
107
+ self.adjust_min_energy()
108
+
109
+ def adjust_min_energy(self):
110
+ if len(self) == 0:
111
+ self.min_energy = None
112
+ else:
113
+ while len(self.energy_levels[self.min_energy]) <= 0:
114
+ self.min_energy += 1
115
+
116
+ def atoms(self):
117
+ return self.atom2energy.keys()
118
+
119
+ def __str__(self):
120
+ return json.dumps({
121
+ "min_energy": self.min_energy,
122
+ "atom2energy": {
123
+ str(k): v for (k, v) in self.atom2energy.items()},
124
+ "energy_levels": {
125
+ k: v.json_obj() for (k, v) in self.energy_levels.items()
126
+ }
127
+ }, indent=4)
128
+
129
+
130
+ class OmniSimulation:
131
+ BACKWARDS = 0
132
+ FORWARDS = 1
133
+
134
+ def __init__(self, neighborhood, energy_maximum=None, origin=(0, 0, 0, 0)):
135
+ if energy_maximum is None:
136
+ energy_maximum = neighborhood.energy_maximum
137
+
138
+ self.energy = 0
139
+ self.atoms = 0
140
+ self.neighborhood = neighborhood
141
+ self.energy_maximum = energy_maximum
142
+
143
+ self.boundaries = [EnergyTracker(), EnergyTracker()]
144
+ self.boundaries[self.FORWARDS].set(origin, self.calculate_energy(origin, mode=self.FORWARDS))
145
+
146
+ def calculate_energy(self, atom, mode):
147
+ energy = 0
148
+
149
+ for neighbor in self.neighborhood(atom):
150
+ energy += -1 if neighbor in self.boundaries[1 - mode] else 1
151
+
152
+ return energy
153
+
154
+ def set_atom(self, atom, energy, mode):
155
+ mode_boundary = self.boundaries[mode]
156
+ reverse_boundary = self.boundaries[1 - mode]
157
+
158
+ mode_energy = energy
159
+ self.energy += mode_energy
160
+ reverse_boundary.set(atom, -mode_energy)
161
+ if atom in mode_boundary:
162
+ mode_boundary.unset(atom, mode_energy)
163
+
164
+ for neighbor in self.neighborhood(atom):
165
+ if neighbor in reverse_boundary:
166
+ neighbor_energy = reverse_boundary.get(neighbor)
167
+ new_energy = neighbor_energy + 2
168
+
169
+ if self.energy_maximum() == new_energy:
170
+ reverse_boundary.unset(neighbor, neighbor_energy)
171
+ else:
172
+ reverse_boundary.set(neighbor, new_energy)
173
+ elif neighbor in mode_boundary:
174
+ neighbor_energy = mode_boundary.get(neighbor)
175
+ new_energy = neighbor_energy - 2
176
+
177
+ if self.energy_maximum() == new_energy:
178
+ mode_boundary.unset(neighbor, neighbor_energy)
179
+ else:
180
+ mode_boundary.set(neighbor, new_energy)
181
+ else:
182
+ mode_boundary.set(neighbor, self.calculate_energy(neighbor, mode))
183
+
184
+ def next_atom(self, choice, mode):
185
+ return self.boundaries[mode].minimum(choice)
186
+
187
+ def next_atoms(self, mode):
188
+ return self.boundaries[mode].all_minimums()
189
+
190
+ def adjust_atom_count(self, mode):
191
+ if mode == self.BACKWARDS and self.atoms <= 0:
192
+ raise ValueError("No atoms left, so can't remove one")
193
+
194
+ match mode:
195
+ case self.FORWARDS:
196
+ self.atoms += 1
197
+ case self.BACKWARDS:
198
+ self.atoms -= 1
199
+
200
+ def add_atom(self, choice=lambda l: 0):
201
+ self.adjust_atom_count(self.FORWARDS)
202
+
203
+ atom, energy = self.next_atom(choice, self.FORWARDS)
204
+ self.set_atom(atom,
205
+ energy,
206
+ self.FORWARDS)
207
+
208
+ def remove_atom(self, choice=lambda l: 0):
209
+ self.adjust_atom_count(self.BACKWARDS)
210
+
211
+ atom, energy = self.next_atom(choice, self.BACKWARDS)
212
+ self.set_atom(atom,
213
+ energy,
214
+ self.BACKWARDS)
215
+
216
+ def force_set_atom(self, atom, mode=FORWARDS):
217
+ self.adjust_atom_count(mode)
218
+
219
+ atom2energy = self.boundaries[mode].atom2energy
220
+ if atom in atom2energy:
221
+ energy = atom2energy[atom]
222
+ else:
223
+ energy = self.calculate_energy(atom, mode)
224
+ self.set_atom(atom, energy, mode)
225
+
226
+ def visualise_slice(self, atomiser=lambda x, y: (0, x, y), crosshair=False, view_energies=False, color=True):
227
+ width, height = shutil.get_terminal_size()
228
+ margin = 3
229
+ fg_red = "\x1b[38;2;200;0;0;1m" if color else ""
230
+ bg_green = "\x1b[48;2;0;70;0;1m" if color else ""
231
+ unset = "\x1b[m" if color else ""
232
+
233
+ def bg():
234
+ if crosshair and (x == 0 or y == 0):
235
+ print(end=bg_green + " " + unset)
236
+ else:
237
+ print(end=" ")
238
+
239
+ for y in range(-(height - margin) // 2, (height - margin) // 2):
240
+ for x in range(-(width - margin) // 2, (width - margin) // 2):
241
+ atom = atomiser(x, y)
242
+ mode = -1
243
+ energy = None
244
+
245
+ for m in range(2):
246
+ if atom in self.boundaries[m]:
247
+ mode = m
248
+ energy = self.boundaries[m].get(atom)
249
+
250
+ if view_energies:
251
+ if mode == -1:
252
+ bg()
253
+ elif energy < 0:
254
+ print(end=fg_red + str(-energy) + unset)
255
+ else:
256
+ print(end=str(energy))
257
+ else:
258
+ match mode:
259
+ case self.BACKWARDS:
260
+ print(end=fg_red + "X" + unset)
261
+ case self.FORWARDS:
262
+ print(end="O")
263
+ case _:
264
+ bg()
265
+ print()
266
+
267
+ print()
268
+
269
+ def interactive(self, dimension=2, color=True):
270
+ z = 0
271
+ view_energies = False
272
+ crosshair = False
273
+ atomiser = None
274
+ match dimension:
275
+ case 1:
276
+ atomiser = lambda x, y: (0, x)
277
+ case 2:
278
+ atomiser = lambda x, y: (0, x, y)
279
+ case 3:
280
+ atomiser = lambda x, y: (0, x, y, z)
281
+ case _:
282
+ raise ValueError(f"Unsupported dimension: {dimension}")
283
+
284
+ def set_cmd(method):
285
+ try:
286
+ if len(args) > 0:
287
+ goal = int(args[0])
288
+ progress = ProgressBar(goal, lambda: self.energy)
289
+ for i in range(goal):
290
+ progress(i)
291
+ method(lambda l: random.randrange(l))
292
+ else:
293
+ method(lambda l: random.randrange(l))
294
+ except (ValueError, TypeError):
295
+ pass
296
+
297
+ try:
298
+ while True:
299
+ wipe_screen()
300
+ self.visualise_slice(atomiser, crosshair=crosshair, view_energies=view_energies, color=color)
301
+ print(f"Total energy: {self.energy}; Z-Layer: {z}")
302
+
303
+ try:
304
+ cmd, *args = input("Input Command: (add, rm, up, down, exit, ?) ").split()
305
+ except ValueError:
306
+ continue
307
+
308
+ match (cmd.lower()):
309
+ case "add":
310
+ set_cmd(self.add_atom)
311
+ case "rm":
312
+ set_cmd(self.remove_atom)
313
+ case "up":
314
+ z += 1
315
+ case "down":
316
+ z -= 1
317
+ case "layer":
318
+ if len(args) > 0:
319
+ z = int(args[0])
320
+ case "energy":
321
+ view_energies = not view_energies
322
+ case "forwards":
323
+ print(self.boundaries[self.FORWARDS])
324
+ input("Press Enter to continue")
325
+ case "backwards":
326
+ print(self.boundaries[self.BACKWARDS])
327
+ input("Press Enter to continue")
328
+ case "crosshair":
329
+ crosshair = not crosshair
330
+ case "fill":
331
+ self.fill(lambda l: random.randrange(l))
332
+ case "exit":
333
+ break
334
+ case "?":
335
+ input("""
336
+ add - Adds the next atom in the sequence
337
+ rm - Removes the next atom in the reverse sequence
338
+ up - Goes up one z-layer (if possible)
339
+ down - Goes down one z-layer (if possible)
340
+ energy - Toggle the energy view mode
341
+ forwards - Display the forwards boundary energy tracker
342
+ backwards - Display the backwards boundary energy tracker
343
+ crosshair - Enable a crosshair
344
+ fill - Adds atoms to the crystal until the energy would increase
345
+ exit - Exit interactive mode
346
+ ? - Displays this help
347
+
348
+ Press Enter to continue execution
349
+ """)
350
+
351
+ except (ValueError, TypeError):
352
+ self.interactive(dimension=dimension, color=color)
353
+ except KeyboardInterrupt:
354
+ print()
355
+
356
+ def points(self):
357
+ return self.boundaries[self.BACKWARDS].atoms()
358
+
359
+ def fill(self, choice=lambda l: 0):
360
+ while True:
361
+ atom, energy = self.next_atom(choice, self.FORWARDS)
362
+ if energy > 0:
363
+ break
364
+
365
+ self.adjust_atom_count(self.FORWARDS)
366
+
367
+ self.set_atom(atom,
368
+ energy,
369
+ self.FORWARDS)
370
+
371
+
372
+ def wipe_screen():
373
+ print(f"\x1b[3J\x1b[H\x1b[J", end="")
374
+
375
+
376
+ def move(atom, offset):
377
+ return tuple((atom[0], *(atom[i + 1] + offset[i] for i in range(len(atom) - 1))))
378
+
379
+
380
+ class SimpleNeighborhood:
381
+ def __init__(self, transform: np.ndarray):
382
+ start = time.time()
383
+ shape = transform.shape
384
+ self.transform = transform
385
+ self.n = shape[0]
386
+ if shape[0] != shape[1]:
387
+ raise ValueError("I need a square matrix")
388
+
389
+ self.base_neighborhood = set()
390
+
391
+ self.find_neighbors()
392
+
393
+ print(f"Calculated in {time.time() - start:.2f} seconds neighbors for:\n{transform}")
394
+ print(f"Neighbors are: {self.base_neighborhood}")
395
+ print()
396
+
397
+ @staticmethod
398
+ def points_within_dist(n, dist):
399
+ if n == 0:
400
+ yield ()
401
+ return
402
+ if n == 1:
403
+ for i in list(range(-dist, dist + 1)):
404
+ yield (i,)
405
+ return
406
+
407
+ for i in range(-dist, dist + 1):
408
+ for p in SimpleNeighborhood.points_within_dist(n - 1, dist):
409
+ yield [*p, i]
410
+
411
+ @staticmethod
412
+ def points_with_dist(n, dist):
413
+ if dist == 0:
414
+ yield [0] * n
415
+ return
416
+ if dist == 1:
417
+ for p in SimpleNeighborhood.points_within_dist(n, dist):
418
+ if all(x == 0 for x in p): continue
419
+ yield p
420
+ return
421
+
422
+ for i in range(n):
423
+ for sign in [-1, 1]:
424
+ for p in SimpleNeighborhood.points_within_dist(n - 1, dist):
425
+ yield *p[:i], sign * dist, *p[i:]
426
+
427
+ def find_neighbors(self):
428
+ for dist in range(1, 10):
429
+ changed = 0
430
+
431
+ for p in SimpleNeighborhood.points_with_dist(self.n, dist):
432
+ point = np.dot(self.transform, np.array(p))
433
+ if np.linalg.norm(point) <= 1:
434
+ p = tuple(p)
435
+ if p not in self.base_neighborhood:
436
+ self.base_neighborhood.add(p)
437
+ changed += 1
438
+
439
+ if changed == 0:
440
+ return
441
+
442
+ raise RuntimeError("Could not find all neighbors")
443
+
444
+ def __call__(self, pos):
445
+ return [move(pos, x) for x in self.base_neighborhood]
446
+
447
+ def energy_maximum(self):
448
+ return len(self.base_neighborhood)
449
+
450
+
451
+ def main():
452
+ OmniSimulation(SimpleNeighborhood(np.identity(2)), None, (0, 0, 0)).interactive(2)
453
+
454
+
455
+ if __name__ == "__main__":
456
+ main()
@@ -0,0 +1,67 @@
1
+ from data import *
2
+
3
+
4
+ ################################################################################
5
+ # The definitions of the fcc, hcp, etc. methods
6
+
7
+ def fcc(grid_range=range(0, 4), lower_bound=0, upper_bound=1.9, upper_clip_plane=math.inf, add_lines=True):
8
+ """
9
+ Generates a subset of fcc
10
+ :grid_range: The range for the grid which will be range(-3,4) × range(-3,4) × range(-3, 4)
11
+ """
12
+ g = grid(grid_range, grid_range, grid_range)
13
+ g *= fcc_transform()
14
+ g = g.filter(lambda p: all(lower_bound <= t <= upper_bound for t in p))
15
+ g = g.filter(lambda p: sum(p[i] for i in range(3)) <= upper_clip_plane)
16
+ if add_lines:
17
+ g = auto_lines(g, 1)
18
+ return g
19
+
20
+
21
+ def fcc_wulff(opacity=1, corner_color='red', color='darkred'):
22
+ """
23
+ Generates the polygon that is the wulff crystal (with side length 1)
24
+ """
25
+ wulff = fcc_wulff_obj()
26
+ # wulff += pos(0,0,0) # center the crystal (somewhat)
27
+ wulff.foreach(Point, setter('color', corner_color))
28
+ wulff = convex_hull(wulff)
29
+ wulff.foreach(Triangle, setter('color', color))
30
+ wulff.foreach(Triangle, setter('opacity', opacity))
31
+ return wulff
32
+
33
+
34
+ def fcc_wulff2(opacity=1, corner_color='red', color='darkred'):
35
+ """
36
+ Generates the polygon that is the wulff crystal (with side length 2)
37
+ """
38
+ wulff = fcc_wulff2_obj()
39
+ wulff.foreach(Point, setter('color', corner_color))
40
+ wulff = convex_hull(wulff)
41
+ wulff.foreach(Triangle, setter('color', color))
42
+ wulff.foreach(Triangle, setter('alpha', opacity))
43
+ return wulff
44
+
45
+
46
+ def hcp(grid_range=range(0, 4), lower_bound=0, upper_bound=1.9, upper_clip_plane=math.inf, custom_filter=lambda p: True,
47
+ add_lines=True):
48
+ """
49
+ Generates a subset of hcp
50
+ """
51
+
52
+ g = grid(grid_range, grid_range, grid_range)
53
+ g *= hcp_transform()
54
+ g @= g + hcp_vector()
55
+ g = g.filter(lambda p: all(lower_bound <= t <= upper_bound for t in p))
56
+ g = g.filter(lambda p: sum(p[i] for i in range(3)) <= upper_clip_plane)
57
+ g = g.filter(custom_filter)
58
+ if add_lines:
59
+ g = auto_lines(g, 1)
60
+ return g
61
+
62
+
63
+ def print_point_set(s):
64
+ """
65
+ Given a set s consisting of Point instances, prints their positions
66
+ """
67
+ print(f"{len(s)} elements: ", *map(lambda p: p.pos, s))