├── tests └── __init__.py ├── .gitignore ├── identity.py ├── freeze.py ├── dims.py ├── changes.txt ├── LICENSE.txt ├── utils.py ├── pubsub.py ├── colors.py ├── html_viewer.py ├── action.py ├── preferences.py ├── data.py ├── README.md ├── hints.py ├── help.py ├── controls.py ├── wireframe.py ├── display.py └── main.py /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # files to ignore 2 | *.spec 3 | mynotes.txt 4 | *.pyo 5 | 6 | # folders to ignore 7 | **/__pycache__/** 8 | .vscode/** 9 | build/** 10 | dist/** 11 | other/** 12 | output/** 13 | settings/** 14 | -------------------------------------------------------------------------------- /identity.py: -------------------------------------------------------------------------------- 1 | """ identity.py 2 | 3 | https://semver.org/ 4 | """ 5 | PRODUCT = "Hypercube Viewer" 6 | MAJOR = 0 7 | MINOR = 2 8 | PATCH = 1 9 | 10 | VERSION = f"{MAJOR}.{MINOR}.{PATCH}" 11 | IDENTITY = f"{PRODUCT} {VERSION}" 12 | 13 | -------------------------------------------------------------------------------- /freeze.py: -------------------------------------------------------------------------------- 1 | # See https://pyinstaller.org/en/stable/usage.html#running-pyinstaller-from-python-code 2 | 3 | import identity 4 | 5 | import PyInstaller.__main__ 6 | 7 | PyInstaller.__main__.run([ 8 | '--onefile', 9 | '--windowed', 10 | f'--name=hypercube-{identity.VERSION}', 11 | '--hidden-import=pyi_splash', 12 | '--splash=build\\splash.jpg', 13 | 'main.py', 14 | ]) 15 | -------------------------------------------------------------------------------- /dims.py: -------------------------------------------------------------------------------- 1 | # module to hold information about dimensions 2 | 3 | MIN = 3 4 | MAX = 10 5 | 6 | X, Y, Z = range(3) # syntactic sugar for the first three dimensions 7 | 8 | # all the planes where rotation is visible 9 | planes = [(X, Y), (X, Z), (Y, Z)] 10 | 11 | # labels for all dimensions 12 | labels = ["X", "Y", "Z"] 13 | 14 | # fill in the above lists for all dimensions 15 | for dim in range(3, MAX): 16 | planes.append((X, dim)) 17 | planes.append((Y, dim)) 18 | labels.append(str(dim + 1)) 19 | -------------------------------------------------------------------------------- /changes.txt: -------------------------------------------------------------------------------- 1 | Hypercube 0.2.1 03/29/25 2 | Tweaks to avoid spurious lint problems 3 | 4 | Hypercube 0.2.0 06/22/22 5 | Add keyboard shortcuts for preference settings 6 | Add different color/width for dims 4+; Can conceal X-Y-Z edges 7 | Show face color to user 8 | Make random rotations smoother 9 | 10 | Hypercube 0.1.0 06/02/22 11 | Add opacity Control 12 | Add fontsize control 13 | Use sliders in preferences 14 | 15 | Hypercube 0.0.4 05/28/22 16 | Add preferences 17 | 18 | Hypercube 0.0.2 05/25/22 19 | Keyboard support 20 | 21 | Hypercube 0.0.1 05/20/22 22 | Initial release 23 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Phil Mayes 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. -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import os 3 | import sys 4 | import time 5 | import tkinter as tk 6 | 7 | 8 | def find_latest_file(path): 9 | latest = None 10 | tm = 0.0 11 | for fname in os.listdir(path): 12 | full = os.path.join(path, fname) 13 | stat_info = os.stat(full) 14 | if tm < stat_info.st_mtime: 15 | tm = stat_info.st_mtime 16 | latest = full 17 | return latest 18 | 19 | 20 | def get_location(rel_dir, fname): 21 | """Get the location of the data file.""" 22 | location = make_dir(rel_dir) 23 | return os.path.join(location, fname) 24 | 25 | 26 | def make_dir(rel_dir): 27 | """Get the location of the data file.""" 28 | base_dir = os.path.dirname(sys.argv[0]) 29 | location = os.path.join(base_dir, rel_dir) 30 | if not os.path.exists(location): 31 | os.mkdir(location) 32 | return location 33 | 34 | 35 | def make_filename(prefix: str, ext: str): 36 | """Create a filename that includes date and time.""" 37 | now = datetime.datetime.now() 38 | return f"{prefix}-{now:%y%m%d-%H%M%S}.{ext}" 39 | 40 | def time_function(func): 41 | """Decorator to time a function.""" 42 | def wrapper(*args, **kwargs): 43 | t1 = time.perf_counter() 44 | res = func(*args, **kwargs) 45 | t2 = time.perf_counter() 46 | print('%s took %0.3f ms' % (func.__name__, (t2-t1)*1000.0)) 47 | return res 48 | return wrapper 49 | 50 | -------------------------------------------------------------------------------- /pubsub.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | """ pubsub.py -- very cheap publish/subscribe 3 | 4 | Copyright (C) 2012 Phil Mayes 5 | See COPYING.txt for licensing information 6 | 7 | Contact: phil@philmayes.com 8 | 9 | Topics are string keys in a dictionary. The value for a topic is in turn 10 | a dictionary whose keys are the registered callback functions. Their values 11 | have no meaning. 12 | """ 13 | topics = {} 14 | 15 | 16 | def publish(topic, *args, **kwargs): 17 | if topic in topics: 18 | for consumer in topics[topic]: 19 | consumer(*args, **kwargs) 20 | 21 | 22 | def subscribe(topic, consumer): 23 | """Register callable for string .""" 24 | # add topic to the dictionary if it does not yet exist 25 | if not topic in topics: 26 | topics[topic] = {} 27 | # get the dictionary of consumers for this topic 28 | consumers = topics[topic] 29 | # register this consumer 30 | consumers[consumer] = 1 31 | 32 | 33 | def unsubscribe(topic, consumer): 34 | """Remove topic + consumer from the dictionary if it exists.""" 35 | if topic in topics: 36 | # remove consumer for this topic; OK if does not exist 37 | topics[topic].pop(consumer, 0) 38 | 39 | 40 | def test(): 41 | def callback1(data): 42 | print("callback1", data) 43 | 44 | def callback2(data, d2): 45 | print("callback2", data, d2) 46 | 47 | def callback3(*args, **kwargs): 48 | print("callback3", args, kwargs) 49 | 50 | # subscribe the above callbacks to various topics 51 | subscribe("alpha", callback1) 52 | subscribe("beta", callback2) 53 | subscribe("beta", callback3) 54 | subscribe("unpublished", callback3) 55 | 56 | # publish various topics 57 | publish("alpha", 111) 58 | publish("beta", [12, 23, 34], "string!") 59 | publish("gamma", "three") 60 | 61 | print(topics) 62 | unsubscribe("beta", callback3) 63 | print(topics) 64 | 65 | 66 | if __name__ == "__main__": 67 | test() 68 | -------------------------------------------------------------------------------- /colors.py: -------------------------------------------------------------------------------- 1 | # opencv colors are BGR 2 | 3 | from dims import MAX 4 | 5 | face_colors = {} 6 | 7 | 8 | def face(pair: list): 9 | """Given a face identified by its two dimensions, return its color. 10 | 11 | Colors are generated by blending the colors of the two sides and cached 12 | in a dictionary. 13 | """ 14 | dim1, dim2 = pair 15 | # Avoid [0, 1] and [1, 0] generating two cached entries 16 | if dim1 > dim2: 17 | dim1, dim2 = dim2, dim1 18 | # Convert the pair of dimensions into a hashable type 19 | key = dim1 * MAX + dim2 20 | if key not in face_colors: 21 | face_colors[key] = [sum(x) // 2 for x in zip(bgr[dim1], bgr[dim2])] 22 | return face_colors[key] 23 | 24 | 25 | def bgr_to_html(b, g, r): 26 | return f"#{r:02x}{g:02x}{b:02x}" 27 | 28 | 29 | def hex_to_rgb(s): 30 | """Convert a string of 6 hex values into an RGB 3-tuple.""" 31 | return int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16) 32 | 33 | 34 | def hex_to_bgr(s): 35 | """Convert a string of 6 hex values into a BGR 3-tuple.""" 36 | return int(s[4:6], 16), int(s[2:4], 16), int(s[0:2], 16) 37 | 38 | 39 | # These colors are in the opencv format of BGR 40 | node = (255, 255, 255) 41 | center = (255, 255, 255) 42 | vp = (244, 208, 140) # vanishing point: a shade of aqua 43 | text = (200, 200, 250) 44 | bg = (0, 0, 0) # must be zeros so we can fade to black in .draw() 45 | html_bg = bgr_to_html(*bg) 46 | dim4gray = (128,128,128) 47 | html_dim4gray = bgr_to_html(*dim4gray) 48 | names = ( 49 | ("ff0000", "red"), 50 | ("ffffff", "white"), 51 | ("00a8ec", "blue"), 52 | ("00ff00", "green"), 53 | ("00ffff", "aqua"), 54 | ("ffff00", "yellow"), 55 | ("ff00ff", "fuschia"), 56 | ("ff8000", "orange"), 57 | ("800080", "purple"), 58 | ("e62b86", "pink"), 59 | ("f1a629", "lt.orange"), 60 | ("fff99d", "lemon yellow"), 61 | ("8dcb41", "lt. green"), 62 | ("bfb2d3", "lilac"), 63 | ("826b89", "purple"), 64 | ("c0c0c0", "silver"), 65 | ) 66 | assert len(names) >= MAX # Yes, there can be more colors than necessary 67 | ascii = [name[0] for name in names] 68 | name = [name[1] for name in names] 69 | html = ["#" + s for s in ascii] 70 | bgr = [hex_to_bgr(c) for c in ascii] 71 | -------------------------------------------------------------------------------- /html_viewer.py: -------------------------------------------------------------------------------- 1 | from enum import Enum, auto 2 | import re 3 | 4 | import tkinter as tk 5 | from tkhtmlview import HTMLScrolledText 6 | 7 | re_strip = re.compile(r"<.*?>") 8 | 9 | class Name(Enum): 10 | NONE = 0 11 | ACTIONS = auto() 12 | HELP = auto() 13 | KEYS = auto() 14 | 15 | class HtmlViewer(): 16 | def __init__(self, viewer): 17 | self.viewer = viewer 18 | self.name = Name.NONE 19 | 20 | def clear(self): 21 | if self.name is not Name.NONE: 22 | self.name = Name.NONE 23 | self.viewer.clear_window() 24 | return True 25 | 26 | def clear_if_showing(self, name: Name): 27 | if name == self.name: 28 | self.clear() 29 | return True 30 | return False 31 | 32 | def copy(self): 33 | text = re_strip.sub("", self.html, 9999) 34 | w = self.window 35 | w.clipboard_clear() 36 | w.clipboard_append(text) 37 | w.update() # keep it on the clipboard after the window is closed 38 | 39 | 40 | def show(self, htm, name: Name): 41 | self.name = name 42 | frame = tk.Frame() 43 | window = HTMLScrolledText(frame, html=htm, padx=10) 44 | window.grid(row=0, column=0, padx=4, pady=4, sticky=tk.NW) 45 | vx, vy = self.viewer.data.get_viewer_size() 46 | # From measurement, the width and height settings for HTMLScrolledtext 47 | # (which is derived from tk.Text) are 16 and 8 pixels, so here we turn 48 | # the viewer size into character counts, allowing for the scroolbar 49 | # and button row. 50 | vx -= 64 51 | vy -= 80 52 | vx //= 8 53 | vy //= 16 54 | # Make it at least 30 high and nor more than 100 wide 55 | vx = min(100, vx) 56 | vy = max(30, vy) 57 | window.config(width=vx, height=vy) 58 | 59 | # add buttons at the bottom of the window 60 | frame2 = tk.Frame(frame) 61 | frame2.grid(row=1, sticky=tk.E, padx=2) 62 | ctl = tk.Button(frame2, width=10, text="Copy", command=self.copy) 63 | ctl.grid(row=0, column=0, sticky=tk.E, padx=0, pady=4) 64 | ctl = tk.Button(frame2, width=10, text="Close", command=self.clear) 65 | ctl.grid(row=0, column=1, sticky=tk.E, padx=10, pady=4) 66 | self.viewer.show_window(frame) 67 | 68 | # save values for possible copy operation 69 | self.html = htm 70 | self.window = window 71 | 72 | -------------------------------------------------------------------------------- /action.py: -------------------------------------------------------------------------------- 1 | from enum import Enum, auto 2 | from typing import Any 3 | 4 | class Cmd(Enum): 5 | RESET = auto() 6 | PLAYBACK = auto() 7 | MOVE = auto() 8 | ROTATE = auto() 9 | ZOOM = auto() 10 | DIMS = auto() 11 | VISIBLE = auto() 12 | 13 | class Action: 14 | """Class to hold an action request. 15 | 16 | The meanings of the parameters are: 17 | Action cmd p1 p2 p3 p4 18 | ---------------+-----------+-----------+-----------+-----------+------------- 19 | Reset RESET Reset flags 20 | Playback PLAYBACK 21 | Move MOVE u,d,l,r 22 | Rotate ROTATE 1st dim 2nd dim [3rd dim] direction 23 | Zoom ZOOM +/- 24 | Set dimensions DIMS n 25 | Visibility VISIBLE data name data value 26 | """ 27 | 28 | def __init__(self, cmd: Cmd, 29 | p1: Any=None, 30 | p2: Any=None, 31 | p3: Any=None, 32 | p4: Any=None): 33 | self.cmd: Cmd = cmd 34 | self.p1: Any = p1 35 | self.p2: Any = p2 36 | self.p3: Any = p3 37 | self.p4: Any = p4 38 | 39 | @property 40 | def visible(self): 41 | return self.cmd == Cmd.VISIBLE 42 | 43 | def __str__(self): 44 | s = str(self.cmd)[4:] 45 | if self.p1 is not None: 46 | s += f": {self.p1}" 47 | if self.p2 is not None: 48 | s += f", {self.p2}" 49 | if self.p3 is not None: 50 | s += f", {self.p3}" 51 | if self.p4 is not None: 52 | s += f", {self.p4}" 53 | return s 54 | 55 | class ActionQueue(list): 56 | """Class to hold a queue of Action items. 57 | 58 | The reason for this class is to merge successive SlideControl 59 | actions into a single change. 60 | """ 61 | 62 | merging = True # merge successive slider values into a single action 63 | # A list of slider datanames that is constructed by App.make_controls() 64 | sliders = [] 65 | 66 | def append(self, item): 67 | assert isinstance(item, Action) 68 | if ActionQueue.merging: 69 | if super().__len__(): 70 | prev = super().__getitem__(-1) 71 | if prev.cmd == Cmd.VISIBLE and\ 72 | item.cmd == Cmd.VISIBLE and\ 73 | prev.p1 == item.p1 and\ 74 | item.p1 in ActionQueue.sliders: 75 | prev.p2 = item.p2 76 | return 77 | super().append(item) 78 | 79 | def __str__(self): 80 | s = "Actions: " 81 | for n in range(super().__len__()): 82 | item = super().__getitem__(n) 83 | s += str(item) 84 | s += "; " 85 | return s 86 | -------------------------------------------------------------------------------- /preferences.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import tkinter as tk 3 | 4 | import controls 5 | import pubsub 6 | 7 | help4 = """\ 8 | Show dimensions 9 | 4 and up 10 | differently:""" 11 | 12 | class Preferences(tk.Toplevel): 13 | def __init__(self, data, win_x, win_y): 14 | super().__init__(None) 15 | self.transient(None) 16 | self.title("Preferences") 17 | self.grab_set() 18 | self.focus() 19 | self.geometry(f"+{win_x + 100}+{win_y + 100}") 20 | self.protocol("WM_DELETE_WINDOW", self.destroy) 21 | frame = tk.Frame(self) 22 | frame.grid(row=0, column=0, padx=4, pady=4) 23 | 24 | # Make a copy of the data so we don't trample on existing values. 25 | # The copy is returned via pubsub if the user accepts the changes. 26 | self.data = copy.copy(data) 27 | 28 | # make the controls 29 | big_font = ("calibri", 14, "bold") 30 | row = 0 31 | ctl = tk.Label(frame, text="VISIBILITY", font=big_font, fg="red3") 32 | ctl.grid(row=row, column=0, sticky=tk.W, pady=2) 33 | row += 1 34 | self.controls = { 35 | "node_radius": controls.SlideControl("Corner radius:", 1, 9, 1), 36 | "center_radius": controls.SlideControl("Center radius:", 1, 9, 1), 37 | "vp_radius": controls.SlideControl("Vanishing point radius:", 1, 9, 1), 38 | "edge_width": controls.SlideControl("Line width:", 1, 9, 1), 39 | "font_size": controls.SlideControl("Font size:", 0.2, 2.0, 0.2), 40 | 'show_coords': controls.CheckControl('Show coordinates'), 41 | "show_node_ids": controls.CheckControl("Show corner numbers"), 42 | "show_4_narrow": controls.CheckControl("Line width is 1"), 43 | "show_4_gray": controls.CheckControl("Line color is gray"), 44 | } 45 | for dataname, control in self.controls.items(): 46 | control.set_data(dataname, self.data) 47 | control.add_control(frame, row, 1) 48 | control.callback = self.on_data 49 | control.set(getattr(self.data, dataname)) 50 | row += 1 51 | 52 | ctl = tk.Label(frame, text=help4, justify=tk.LEFT) 53 | ctl.grid(row=row-2, column=0, rowspan=2, sticky=tk.W) 54 | 55 | frame2 = tk.Frame(frame) 56 | frame2.grid(row=row, columnspan=2, sticky=tk.E) 57 | self.ok = tk.Button(frame2, width=10, text="OK", command=self.on_ok) 58 | self.ok.grid(row=0, column=0, padx=4, pady=4) 59 | ctl = tk.Button(frame2, width=10, text="Cancel", command=self.destroy) 60 | ctl.grid(row=0, column=1, padx=0, pady=4) 61 | 62 | def on_data(self, dataname): 63 | control = self.controls.get(dataname, None) 64 | if control: 65 | control_value = control.get() 66 | value = self.data.coerce(control_value, dataname) 67 | setattr(self.data, dataname, value) 68 | 69 | def on_ok(self): 70 | """The user is happy with the changes. 71 | 72 | We must use pubsub before destroying the dialog because the data 73 | will also be destroyed. 74 | """ 75 | pubsub.publish("prefs", self.data) 76 | self.destroy() 77 | -------------------------------------------------------------------------------- /data.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import re 4 | 5 | 6 | class Data: 7 | """A class to hold persistent data. 8 | 9 | If new values are needed, just add them as class attributes. 10 | """ 11 | 12 | MISSING = "attribute is missing" 13 | MIN_SIZE = 100 14 | MAX_SIZE = 4096 15 | re_aspects = re.compile(r"\d\d?(:\d\d?)+$") 16 | re_view = re.compile(r"\s*(\d+)\s*[xX:]\s*(\d+)\s*$") 17 | 18 | def __init__(self): 19 | """Factory settings.""" 20 | # settings for how the wireframe is constructed 21 | self.dims = 4 22 | self.aspects = "1:1" 23 | self.viewer_size = "1000x1000" 24 | 25 | # settings for how the wireframe is displayed 26 | self.show_faces = False 27 | self.show_edges = True 28 | self.show_nodes = False 29 | self.show_steps = True 30 | self.show_center = False 31 | self.show_perspective = False 32 | self.show_vp = False 33 | self.depth = 2.0 34 | self.ghost = 0 35 | self.opacity = 1.0 36 | # these values are set in in preferences, not the main window. 37 | self.node_radius = 4 38 | self.center_radius = 1 39 | self.vp_radius = 2 40 | self.edge_width = 3 41 | self.font_size = 0.4 42 | self.show_coords = False 43 | self.show_node_ids = False 44 | self.show_4_narrow = False # True: Line width is 1 45 | self.show_4_gray = False # True: Line color is gray 46 | 47 | # settings for how the wireframe is rotated 48 | self.angle = 15 49 | self.auto_scale = 1.0 50 | 51 | # settings for recording 52 | self.frame_rate = 30 53 | 54 | # settings for playback 55 | self.replay_visible = True 56 | 57 | # window position 58 | self.win_x = 100 59 | self.win_y = 100 60 | 61 | # miscellaneous 62 | self.show_hints = True 63 | 64 | def get_viewer_size(self): 65 | """Test whether supplied string is valid for self.viewer_size.""" 66 | match = Data.re_view.match(self.viewer_size) 67 | assert match is not None 68 | return int(match.group(1)), int(match.group(2)) 69 | 70 | def validate_aspects(self, aspects): 71 | """Test whether supplied string is valid for self.aspects.""" 72 | return bool(Data.re_aspects.match(aspects)) 73 | 74 | def validate_viewer_size(self, viewer_size): 75 | """Test whether supplied string is valid for self.viewer_size.""" 76 | match = Data.re_view.match(viewer_size) 77 | if match: 78 | x = int(match.group(1)) 79 | y = int(match.group(2)) 80 | if ( 81 | Data.MIN_SIZE <= x <= Data.MAX_SIZE 82 | and Data.MIN_SIZE <= y <= Data.MAX_SIZE 83 | ): 84 | return x, y 85 | 86 | def xor(self, dataname): 87 | """Xor the value of a boolean attribute.""" 88 | old = getattr(self, dataname) 89 | assert isinstance(old, bool) 90 | setattr(self, dataname, not old) 91 | 92 | def load(self, fname): 93 | """Load and validate settings from a json file.""" 94 | try: 95 | with open(fname, "r") as read_file: 96 | data = json.load(read_file) 97 | for key, value in data.items(): 98 | # does this attribute already exist in this instance? 99 | existing = getattr(self, key, Data.MISSING) 100 | if existing is Data.MISSING: 101 | # if it doesn't, it's an attribute we no longer use 102 | # OR the json has been hacked, so ignore it 103 | print("Bad json: data not recognized:", key, value) 104 | continue 105 | # if it does exist, check that the type is correct; 106 | # if it doesn't, we've changed the type of the attribute 107 | # OR the json has been hacked, so ignore it 108 | if type(value) is not type(existing): 109 | print("Bad json: type is wrong:", key, value) 110 | continue 111 | # perform additional validation on certain values 112 | if key == "aspects" and not self.validate_aspects(value): 113 | print("Bad json: format is wrong:", key, value) 114 | continue 115 | if key == "viewer_size" and not self.validate_viewer_size(value): 116 | print("Bad json: format is wrong:", key, value) 117 | continue 118 | # everything looks good; use the json value 119 | setattr(self, key, value) 120 | except: 121 | pass 122 | 123 | def save(self, fname): 124 | """Save settings to a json file.""" 125 | data = self.__dict__ 126 | with open(fname, "w") as write_file: 127 | json.dump(data, write_file) 128 | 129 | def coerce(self, value, data_name): 130 | """Coerce the value from a widget into the data type.""" 131 | 132 | data_type = type(getattr(self, data_name)) 133 | if isinstance(value, str): 134 | if value.isdigit(): 135 | value = int(value) 136 | elif value == "True": 137 | value = True 138 | elif value == "False": 139 | value = False 140 | else: 141 | try: 142 | value = float(value) 143 | except: 144 | pass 145 | 146 | value = data_type(value) 147 | assert type(value) is data_type 148 | return value 149 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hypercube Viewer 2 | 3 | Hypercube Viewer is a program that draws a hypercube of 3 to 10 dimensions. 4 | 5 | ## What is a hypercube? 6 | 7 | Start with a point. It has no dimensions. Now move that point along a dimension. In doing so, you create a second point and also create a line (a one-dimensional object.) Now move that line along a dimension at right-angles (aka orthogonal) to the first dimension. You double the number of points and lines, and also create two new lines from the movement of the points. Voila! A square. Now repeat the process by moving the square along a new dimension orthogonal to the existing two dimensions, and you have created a cube. Note that it has 6 faces; one by moving the square in the new (third) dimension, and 4 from moving the 4 lines of the square along the new dimension. 8 | 9 | Now move that cube along an orthogonal fourth dimension. You now have 8 cubes; the original; one from the original being moved; and 6 from the 6 faces of the original cube being extended. This object is called a tesseract. 10 | 11 | We can continue this process without end. A 5-cube has 32 points, etc. Each value in this table comes from doubling the value above and adding the value above and in the previous column. 12 | 13 | | # Dims | Points | Edges | Faces | Cubes | 14 | | :----: | -----: | ----: | ----: | ----: | 15 | | 0 | 1 | | | | 16 | | 1 | 2 | 1 | | | 17 | | 2 | 4 | 4 | 1 | | 18 | | 3 | 8 | 12 | 6 | 1 | 19 | | 4 | 16 | 32 | 24 | 8 | 20 | | 5 | 32 | 80 | 80 | 50 | 21 | 22 | ## Drawing the hypercube 23 | 24 | The points, edges and/or faces are projected onto a plane surface (the x-y plane) in either a perspective or orthographic view. The origin is at the top left with the x-axis horizontal, the y-axis pointing down, and the Z-axis projecting into the screen. 25 | 26 | **NOTE:** Some of the drawing calculations take a long time. Factors that exacerbate this are: 27 | * A large number of dimensions 28 | * Drawing the faces 29 | * Showing intermediate steps 30 | * Showing ghosting 31 | * Resizing during rotation 32 | * Showing partially transparent faces 33 | 34 | These delays do NOT occur in the video recording. 35 | 36 | ## How is the hypercube rotated? 37 | 38 | In the real world, we think of rotation as about an axis. A square on your screen being rotated clockwise is though of as rotating around the z-axis that projects perpendicularly from the screen, but what is actually changing are the x- and y-coordinates of the 4 corners. Similarly, rotation around the x-axis is done by rotating the y-z plane. 39 | 40 | The concept of rotating about an axis works in 3 dimensions because, for any axis, there is only one plane that that is perpendicular to that axis. For higher dimension, each dimension is perpendicular to more than one plane, so naming the dimension would be ambiguous. For a higher dimension D, the only rotations visible on the screen (the x-y surface) are rotations in the x-D plane and the y-D plane. Hypercube-viewer allows the user to rotate in both directions around these higher planes. 41 | 42 | For random rotations, Hypercube Viewer rotates about two randomly-chosen planes at once. 43 | 44 | ## Available controls 45 | 46 | ### SETUP 47 | 48 | * **Number of dimensions:** Choose from 3 to 10. 49 | * **Aspect ratios:** It need not be a cube. Here, you can set the ratios of the edges for each dimension, for instance: 16:9:4 specifies a cuboid with width of 16, height of 9 and depth of 4. Additional dimensions take the value of the rightmost one specified. 50 | * **Viewing size:** The size in pixels of the plane surface on which the Hypercube is projected. 51 | 52 | ### VISIBILITY 53 | 54 | * **Show faces, edges, corners:** Control what is drawn. 55 | * **Show intermediate steps:** Draw the intermediate steps of moves and rotations. Note that this may slow the operation, especially when the hypercube has a large number of dimensions. 56 | * **Show center:** Show the centerpoint of the hypercube. 57 | * **Perspective view:** Choose whether the hypercube is projected as a perspective or orthographic view. 58 | * **Show vanishing point:** When perspective view is selected, show the vanishing point. 59 | * **Depth of perspective:** Controls the amount of perspective. The vanishing point is placed at this value times the screen width. 60 | * **Amount of ghosting:** As the hypercube is moved, the program can leave a ghost image that fades out. 0 indicates no ghosting, and with 10, no fading takes place. 61 | * **Rotation per click:** The rotation in degrees per click. 62 | * **Resizing during rotation:** The program can resize the object during rotation. This gives the amount by which the object is scaled. The rotation is slower because of this. Note that when intermediate steps are shown, a fraction of the scaling takes place for every step, making it much slower. When there are a large number of dimensions, the speed is even worse. This speed slowdown does not show in recorded videos. 63 | * **Opacity:** The opacity of the faces. Note that when the faces are translucent, drawing times are much slower. 64 | 65 | In Preferences, accessed through the menu, you can also change: 66 | 67 | * **Corner radius** 68 | * **Center radius** 69 | * **Vanishing point radius** 70 | * **Line width** 71 | * **Font Size:** The font size for coordinates and corner numbers. 72 | * **Show coordinates:** Show the coordinates of every point. The points may overlap for, say, a cube in orthogonal view. Avoid this by using perspective view and/or rotating the object so that every point is at a different place on screen. 73 | * **Show corner numbers:** Show the internal index number of all corners. 74 | * **Set line width to 1:** For dimensions of 4 or more, set the line width to 1. 75 | * **Set line color to gray:** For dimensions of 4 or more, set the line color to gray. 76 | 77 | ### MOVEMENT 78 | 79 | The object can be rotated around various planes, moved, zoomed and shrunk. 80 | 81 | * **Replay:** This button will replay all the movement and visibility actions from the beginning. 82 | * **Stop:** This button will stop replay and also long movement operations. 83 | * **Show Actions:** This button shows a list of all the actions performed so far. 84 | * **Begin Again:** This button will forget all movement that you have done and start again. 85 | * **Replay with original visibility settings:** This checkbox chooses whether replay includes all changes to visibility settings that were made. When it is unchecked, replay takes place using the current visibility settings. 86 | 87 | ### REQUIREMENTS 88 | 89 | It requires the following packages: 90 | 91 | * numpy 92 | * opencv-python 93 | * pillow 94 | * tkhtmlview 95 | 96 | ### RECORDING TO VIDEO 97 | 98 | Hypercube Viewer can record the movements to a video. Recording will capture 99 | replay events as well as the original actions. Each start and stop creates a separate 100 | video file. 101 | 102 | * **Frame rate of videos** Choose the frame rate which which videos are created. 103 | * **Record** Start recording to a video file whose name is time-stamped. 104 | * **Play** Play back the last recorded video file. 105 | * **View Folder** Open the folder where the video files are saved. 106 | 107 | ### EXAMPLE 108 | 109 | [Youtube demonstration](https://www.youtube.com/embed/KZZ3qxXrC58) 110 | -------------------------------------------------------------------------------- /hints.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import identity 4 | 5 | hint_about = f"""\ 6 | {identity.PRODUCT} 7 | 8 | Version {identity.VERSION}""" 9 | hint_angle = """\ 10 | Set the amount by which 11 | the movement controls 12 | rotate the object.""" 13 | hint_aspect = """\ 14 | A cube has all sides of equal length, 15 | but you can use different ratios, 16 | e.g. 4:3:2 would set: 17 | the x-dimension to 4 units 18 | the y-dimension to 3 units 19 | the z-dimension to 2 units 20 | Extra dimensions take the last-specified value, 21 | e.g. the 4th dimension would also be 2 units.""" 22 | hint_auto_scale = """\ 23 | Resize the object by this amount during 24 | each step of movement. 25 | 1.00 means no resizing. 26 | 27 | WARNING: 28 | The rotation is slower because of this. 29 | Note that when intermediate steps are 30 | shown, a fraction of the scaling takes 31 | place for every step, making it much slower. 32 | When there are a large number of dimensions, 33 | the speed is even worse. 34 | To see it at the correct speed, 35 | record the movements to video.""" 36 | hint_depth = """\ 37 | Change the amount of perspective that is shown. 38 | A larger number is less perspective. Put differently, 39 | the vanishing point is moved further away.""" 40 | hint_dims = "Choose how many dimensions for the hypercube" 41 | hint_folder = "Open the folder where videos are saved" 42 | hint_frame_rate = "Choose the frame rate of the video file." 43 | hint_ghost = """\ 44 | As the hypercube is moved, the program can leave 45 | a ghost image that fades out. The amount of ghost 46 | image left behind as the object is rotated is: 47 | 0 = no ghosting; 48 | 10 = no fading out.""" 49 | hint_list = "Show a list of all the actions so far" 50 | hint_move = "Move the object up, down, left or right." 51 | hint_opacity = """\ 52 | Change the opacity of the faces. 53 | 1.0 is completely opaque. 54 | 55 | WARNING: 56 | This is a very slow operation. 57 | To see it at the correct speed, 58 | record the movements to video.""" 59 | hint_play = "Play back the last recorded video file" 60 | hint_random = "Rotate the object randomly\naround 3 dimensions" 61 | hint_record = "Record all movement to a video file" 62 | hint_replay = "Play back all the movement\nthat you have done so far." 63 | hint_replay_visible = """\ 64 | Choose whether replay includes all changes 65 | to visibility settings that were made. 66 | When it is unchecked, replay takes place 67 | using the current visibility settings..""" 68 | hint_restart = "Forget all movement\nthat you have done\nand start again" 69 | hint_rotate = "Rotate the object around the given plane" 70 | hint_show_center = "Show the center point of the object" 71 | hint_show_edges = "Show the edges of the object" 72 | hint_show_faces = "Show the faces of the object" 73 | hint_show_hints = """\ 74 | Show a hint like this 75 | when moving the mouse 76 | over a control""" 77 | hint_show_nodes = "Show the corners of the object" 78 | hint_show_perspective = "Show the object in perspective view" 79 | hint_show_steps = """\ 80 | Draw the intermediate steps of moves and rotations. 81 | Note that this may slow the operation, especially 82 | when the hypercube has a large number of dimensions.""" 83 | hint_show_vp = "Show the vanishing point of the perspective view" 84 | hint_stop = "Stop the Replay" 85 | hint_viewsize = """\ 86 | The size in pixels of the hypercube display. 87 | The width and height need not be the same.""" 88 | hint_zoom_m = "Shrink the size of the object" 89 | hint_zoom_p = "Expand the size of the object" 90 | 91 | lookup: dict[str, str] = { 92 | "about": hint_about, 93 | "angle": hint_angle, 94 | "aspect": hint_aspect, 95 | "auto_scale": hint_auto_scale, 96 | "depth": hint_depth, 97 | "dims": hint_dims, 98 | "folder": hint_folder, 99 | "frame_rate": hint_frame_rate, 100 | "ghost": hint_ghost, 101 | "list": hint_list, 102 | "move": hint_move, 103 | "opacity": hint_opacity, 104 | "play": hint_play, 105 | "random": hint_random, 106 | "record": hint_record, 107 | "replay": hint_replay, 108 | "replay_visible": hint_replay_visible, 109 | "restart": hint_restart, 110 | "rotate": hint_rotate, 111 | "show_center": hint_show_center, 112 | "show_edges": hint_show_edges, 113 | "show_faces": hint_show_faces, 114 | "show_hints": hint_show_hints, 115 | "show_nodes": hint_show_nodes, 116 | "show_perspective": hint_show_perspective, 117 | "show_steps": hint_show_steps, 118 | "show_vp": hint_show_vp, 119 | "stop": hint_stop, 120 | "viewsize": hint_viewsize, 121 | "zoom_m": hint_zoom_m, 122 | "zoom_p": hint_zoom_p, 123 | } 124 | 125 | _ctl_to_hint_id: dict[Any, str] = {} 126 | 127 | def get_hint_for_ctl(ctl: Any) -> str: 128 | return _ctl_to_hint_id.get(ctl, '') 129 | 130 | def set_hint_for_ctl(ctl: Any, hint: str) -> None: 131 | _ctl_to_hint_id[ctl] = hint 132 | 133 | class Hints: 134 | def __init__(self, viewer): 135 | self.viewer = viewer 136 | self.active = True 137 | self.showing = None 138 | self.static = False 139 | 140 | def visible(self, active: bool): 141 | """Set whether hints are to show or not.""" 142 | self.active = active 143 | 144 | def show(self, hint_id: str): 145 | """Show a hint. 146 | 147 | State table: hint 148 | old state new state old==new exists action 149 | none none y - - 150 | none hint n y show 151 | none hint n n - 152 | hint none n - clear 153 | static none n - - 154 | hint hint y - - 155 | hint hint n y show 156 | hint hint n n clear 157 | Notes: 158 | * The hint should always exist. The "hint exists" 159 | column is just a precaution. 160 | * The table does not cover static hints, though the code does. 161 | See the docstring for show_static for details. 162 | """ 163 | if self.active: 164 | if not hint_id: 165 | if self.showing is not None and not self.static: 166 | self.viewer.clear_text() 167 | self.showing = None 168 | elif hint_id != self.showing: 169 | if hint_id in lookup: 170 | text = lookup[hint_id] 171 | self.viewer.show_text(text) 172 | self.showing = hint_id 173 | self.static = False 174 | else: 175 | self.viewer.clear_text() 176 | self.showing = None 177 | 178 | def show_static(self, hint_id: str): 179 | """Show a static hint. 180 | 181 | For example, about. This hint: 182 | * is shown regardless of the active state 183 | * is not canceled by show(None), aka ordinary mouse movement 184 | * is removed by a left-click on the canvas 185 | """ 186 | old_active = self.active 187 | # Temporarily force active so that the hint will be shown. 188 | self.active = True 189 | self.show(hint_id) 190 | # Set the static flag AFTER calling show() because that call clears it 191 | self.static = True 192 | self.active = old_active 193 | 194 | def stop_static(self): 195 | """Cancel a static hint.""" 196 | self.static = False 197 | self.viewer.clear_text() 198 | self.showing = None 199 | -------------------------------------------------------------------------------- /help.py: -------------------------------------------------------------------------------- 1 | import identity 2 | 3 | help = f""" 4 |

{identity.PRODUCT}

5 | 6 | {identity.PRODUCT} is a program that draws a hypercube of 3 to 10 dimensions. 7 | 8 |

What is a hypercube?

9 | 10 |

Start with a point. It has no dimensions. Now move that point along a dimension. 11 | In doing so, you create a second point and also create a line (a one-dimensional 12 | object.) Now move that line along a dimension at right-angles (aka orthogonal) 13 | to the first dimension. You double the number of points and lines, and also 14 | create two new lines from the movement of the points. Voila! A square. Now 15 | repeat the process by moving the square along a new dimension orthogonal to 16 | the existing two dimensions, and you have created a cube. Note that it has 17 | 6 faces; one by moving the square in the new (third) dimension, and 4 from 18 | moving the 4 lines of the square along the new dimension. 19 |

20 |

Now move that cube along an orthogonal fourth dimension. You now have 8 cubes; 21 | the original; one from the original being moved; and 6 from the 6 faces of the 22 | original cube being extended. This object is called a tesseract. 23 |

24 |

We can continue this process without end. A 5-cube has 32 points, etc. 25 | Each value in this table comes from doubling the value above and adding 26 | the value above and in the previous column.

27 | 28 | | # Dims | Points | Edges | Faces | Cubes | 29 | |--------|--------|-------|-------|-------| 30 | | 0 | 1 | | | | 31 | | 1 | 2 | 1 | | | 32 | | 2 | 4 | 4 | 1 | | 33 | | 3 | 8 | 12 | 6 | 1 | 34 | | 4 | 16 | 32 | 24 | 8 | 35 | | 5 | 32 | 80 | 80 | 50 | 36 | 37 | 38 |

Drawing the hypercube

39 | 40 |

The points, edges and/or faces are projected onto a plane surface 41 | (the x-y plane) in either a perspective or orthographic view. 42 | The origin is at the top left with the x-axis horizontal, the y-axis 43 | pointing down, and the Z-axis projecting into the screen. 44 |
45 | NOTE: Some of the drawing calculations take a long time. Factors that exacerbate this are: 46 |

54 | These delays do NOT occur in the video recording. 55 |

56 | 57 |

How is the hypercube rotated?

58 | 59 |

In the real world, we think of rotation as about an axis. A square on your 60 | screen being rotated clockwise is though of as rotating around the z-axis that 61 | projects perpendicularly from the screen, but what is actually changing are 62 | the x- and y-coordinates of the 4 corners. Similarly, rotation around the 63 | x-axis is done by rotating the y-z plane. 64 |

65 |

The concept of rotating about an axis works in 3 dimensions because, for 66 | any axis, there is only one plane that that is perpendicular to that axis. 67 | For higher dimension, each dimension is perpendicular to more than one plane, 68 | so naming the dimension would be ambiguous and XXXX. For a higher dimension D, 69 | the only rotations visible on the screen (the x-y surface) are rotations in 70 | the x-D plane and the y-D plane. Hypercube-viewer allows the user to rotate 71 | in both directions around these higher planes. 72 |

73 |

For random rotations, Hypercube Viewer rotates about two randomly-chosen planes at once. 74 |

75 | 76 |

Available controls

77 | 78 |
SETUP
79 | 91 | 92 |
VISIBILITY
93 | 128 | 129 |

In preferences, you can also control:

130 |