├── .gitignore ├── LICENSE.txt ├── README.md ├── action.py ├── changes.txt ├── colors.py ├── controls.py ├── data.py ├── dims.py ├── display.py ├── freeze.py ├── help.py ├── hints.py ├── html_viewer.py ├── identity.py ├── main.py ├── preferences.py ├── pubsub.py ├── tests └── __init__.py ├── utils.py └── wireframe.py /.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 | -------------------------------------------------------------------------------- /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. -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /controls.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from functools import partial 4 | import tkinter as tk 5 | from tkinter import ttk 6 | 7 | import colors 8 | import dims 9 | import hints 10 | 11 | # Names for the states of buttons. Values are indices into lists. 12 | DISABLED = 0 13 | ENABLED = 1 14 | ACTIVE = 2 15 | 16 | 17 | class Button(tk.Button): 18 | """Class to implement a 3-state button.""" 19 | 20 | tk_states = (tk.DISABLED, tk.NORMAL, tk.NORMAL) 21 | 22 | def __init__(self, parent, **kwargs): 23 | # User can supply a list of texts for the 3 states. 24 | # If this is supplied, there is no need to supply the standard text 25 | self.texts = kwargs.pop("texts", []) 26 | if self.texts: 27 | kwargs["text"] = self.texts[1] 28 | else: 29 | self.texts = None 30 | 31 | # user can supply a custom color for ENABLED and/or ACTIVE states 32 | colors = None 33 | color1 = kwargs.pop("color1", None) 34 | color2 = kwargs.pop("color2", None) 35 | if color1 or color2: 36 | colors = ["SystemButtonFace"] * 3 37 | if color1: 38 | colors[ENABLED] = color1 39 | if color2: 40 | colors[ACTIVE] = color2 41 | self.colors = colors 42 | 43 | super().__init__(parent, **kwargs) 44 | self._state = ENABLED 45 | 46 | @property 47 | def state(self): 48 | return self._state 49 | 50 | @state.setter 51 | def state(self, state): 52 | assert isinstance(state, int) 53 | if state != self._state: 54 | self._state = state 55 | kwargs = {"state": Button.tk_states[state]} 56 | if self.colors: 57 | kwargs["bg"] = self.colors[state] 58 | if self.texts: 59 | kwargs["text"] = self.texts[state] 60 | self.configure(**kwargs) 61 | 62 | 63 | class Control: 64 | """Abstract base class for widgets.""" 65 | 66 | """Base class for customized widgets.""" 67 | 68 | def __init__(self, label): 69 | self.label = label 70 | self.callback = None 71 | self.dataname = '' 72 | # child classes will set .ctl to a tk control 73 | self.ctl = None 74 | self.datatype = type 75 | self.int_var = tk.IntVar() 76 | self.float_var = tk.DoubleVar() 77 | self.str_var = tk.StringVar() 78 | 79 | def action(self, x=None): 80 | if self.callback is not None: 81 | # condition is always satisfied; test is to keep lint happy 82 | self.callback(self.dataname) 83 | 84 | def get(self): 85 | if self.datatype is int or self.datatype is bool: 86 | return self.int_var.get() 87 | elif self.datatype is float: 88 | return self.float_var.get() 89 | elif self.datatype is str: 90 | return self.str_var.get() 91 | else: 92 | raise TypeError 93 | 94 | def set(self, value): 95 | # ctl is a ttk.Checkbutton, ttk.Combobox or tk.Scale, 96 | # so ignore lint complaint 97 | self.ctl.set(value) # type: ignore 98 | 99 | def set_data(self, dataname: str, data): 100 | """Construct a tkinter variable that is compatible with our data.""" 101 | self.dataname = dataname 102 | value = getattr(data, dataname) 103 | self.datatype = type(value) 104 | 105 | 106 | class CheckControl(Control): 107 | """Class to manage a ttk.CheckButton widget.""" 108 | 109 | def __init__(self, label, underline=-1): 110 | self.underline = underline 111 | super().__init__(label) 112 | 113 | def add_control(self, frame, row, col, **kwargs): 114 | self.ctl = ttk.Checkbutton( 115 | frame, text=self.label, variable=self.int_var, underline=self.underline, command=self.action 116 | ) 117 | self.ctl.grid(row=row, column=col, sticky=tk.W, **kwargs) 118 | hints.set_hint_for_ctl(self.ctl, self.dataname) 119 | 120 | def set(self, value): 121 | if isinstance(value, str): 122 | value = 1 if value == "True" else 0 123 | else: 124 | value = int(value) 125 | self.int_var.set(value) 126 | 127 | def xor(self): 128 | self.int_var.set(self.int_var.get() ^ 1) 129 | 130 | 131 | class ComboControl(Control): 132 | """Class to manage a ttk.Combobox widget.""" 133 | 134 | def __init__(self, label, values): 135 | self.values = values 136 | super().__init__(label) 137 | 138 | def add_control(self, frame, row, col, **kwargs): 139 | ctl = tk.Label(frame, text=self.label) 140 | ctl.grid(row=row, column=0, sticky=tk.SW) 141 | self.ctl = ttk.Combobox( 142 | frame, 143 | state="readonly", 144 | width=4, 145 | values=self.values, 146 | ) 147 | self.ctl.grid(row=row, column=col, sticky=tk.W, **kwargs) 148 | self.ctl.bind("<>", self.action) 149 | hints.set_hint_for_ctl(self.ctl, self.dataname) 150 | 151 | def get(self): 152 | return self.ctl.get() 153 | 154 | 155 | class SlideControl(Control): 156 | """Class to manage a tk.Scale widget.""" 157 | 158 | def __init__(self, label, from_, to, res): 159 | self.fr = from_ 160 | self.to = to 161 | self.res = res 162 | super().__init__(label) 163 | 164 | def add_control(self, frame, row, col, **kwargs): 165 | ctl = tk.Label(frame, text=self.label) 166 | ctl.grid(row=row, column=col - 1, sticky=tk.SW) 167 | self.ctl = tk.Scale( 168 | frame, 169 | from_=self.fr, 170 | to=self.to, 171 | resolution=self.res, 172 | orient=tk.HORIZONTAL, 173 | command=self.action, 174 | ) 175 | self.ctl.grid(row=row, column=col, sticky=tk.W, **kwargs) 176 | hints.set_hint_for_ctl(self.ctl, self.dataname) 177 | 178 | def get(self): 179 | return self.ctl.get() 180 | 181 | def step(self, units): 182 | """Step the number of units specified.""" 183 | self.ctl.set(self.ctl.get() + self.res * units) 184 | 185 | class PlaneControl: 186 | """A class to manage tkinter controls for a single plane.""" 187 | 188 | def __init__(self, frame, row, dim1, dim2, app): 189 | self.frame = frame 190 | self.row = row 191 | self.dim1 = dim1 192 | self.dim2 = dim2 193 | self.app = app 194 | self.active = False 195 | 196 | def add_controls(self): 197 | dim1str = dims.labels[self.dim1] 198 | dim2str = dims.labels[self.dim2] 199 | text = f"{dim1str}-{dim2str}" 200 | self.planes = tk.Label(self.frame, text=text) 201 | self.planes.grid(row=self.row, column=0, sticky=tk.EW, padx=2, pady=2) 202 | 203 | # create a subframe for the rotation controls 204 | self.rot_frame = tk.Frame(self.frame) 205 | self.rot_frame.grid(row=self.row, column=1) 206 | 207 | # insert rotation controls 208 | self.rotate1 = tk.Button( 209 | self.rot_frame, text=" < ", command=partial(self.app.on_rotate, "+", self) 210 | ) 211 | self.rotate1.grid(row=0, column=0, sticky=tk.W, padx=2, pady=2) 212 | hints.set_hint_for_ctl(self.rotate1, "rotate") 213 | self.rotate2 = tk.Button( 214 | self.rot_frame, text=" > ", command=partial(self.app.on_rotate, "-", self) 215 | ) 216 | self.rotate2.grid(row=0, column=1, sticky=tk.W, padx=2, pady=2) 217 | hints.set_hint_for_ctl(self.rotate2, "rotate") 218 | 219 | # insert information about colors of dimensions 220 | self.swatch1 = tk.Label(self.frame, text=f"{dim1str}: ████", bg=colors.html_bg) 221 | self.swatch1.grid(row=self.row, column=2, sticky=tk.NSEW) 222 | self.swatch2 = tk.Label(self.frame, text=f"{dim2str}: ████", bg=colors.html_bg) 223 | self.swatch2.grid(row=self.row, column=3, sticky=tk.NSEW) 224 | self.swatch3 = tk.Label(self.frame, text=f"████", bg=colors.html_bg) 225 | self.swatch3.grid(row=self.row, column=4, sticky=tk.NSEW) 226 | self.active = True 227 | # self.show_colors() 228 | 229 | def delete_controls(self): 230 | self.rot_frame.destroy() 231 | self.planes.destroy() 232 | self.rotate1.destroy() 233 | self.rotate2.destroy() 234 | self.swatch1.destroy() 235 | self.swatch2.destroy() 236 | self.swatch3.destroy() 237 | self.active = False 238 | 239 | def show_colors(self, data): 240 | if self.active: 241 | no_color = colors.html_bg 242 | if data.show_edges: 243 | if data.show_4_gray and (self.dim1 > 2 or self.dim2 > 2): 244 | color1 = color2 = colors.html_dim4gray 245 | else: 246 | color1 = colors.html[self.dim1] 247 | color2 = colors.html[self.dim2] 248 | else: 249 | color1 = color2 = no_color 250 | if data.show_faces: 251 | bgr = colors.face([self.dim1, self.dim2]) 252 | color3 = colors.bgr_to_html(*bgr) 253 | else: 254 | color3 = no_color 255 | self.swatch1.configure(fg=color1) 256 | self.swatch2.configure(fg=color2) 257 | self.swatch3.configure(fg=color3) -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /display.py: -------------------------------------------------------------------------------- 1 | #!/bin/env python 2 | """ 3 | There are four primitive actions: 4 | plot calculate new positions of nodes etc. 5 | draw project nodes onto an xy plane 6 | show show the xy plane on a window 7 | video_write write the xy plane to video 8 | 9 | display() executes draw, show, video_write 10 | 11 | """ 12 | 13 | import math 14 | import os 15 | import time 16 | 17 | import cv2 18 | import numpy as np 19 | from PIL import Image 20 | from PIL import ImageTk 21 | 22 | from action import Action, ActionQueue, Cmd 23 | import colors 24 | from data import Data 25 | from dims import MAX 26 | from dims import X, Y, Z # syntactic sugar for the first three dimensions 27 | import pubsub 28 | import utils 29 | import wireframe as wf 30 | 31 | 32 | class Viewer: 33 | """Display hypercube objects on a tkinter canvas.""" 34 | 35 | # map the slider values to the amount of ghosting 36 | ghost_to_factor = { 37 | 0: 0.0, 38 | 1: 0.6, 39 | 2: 0.7, 40 | 3: 0.75, 41 | 4: 0.8, 42 | 5: 0.85, 43 | 6: 0.9, 44 | 7: 0.95, 45 | 8: 0.98, 46 | 9: 0.99, 47 | 10: 1.0, 48 | } 49 | SCALE = 1.1 # fraction by which to zoom in/out 50 | TRANSLATE = 40 # amount in pixels to move up/down/left/right 51 | direction_to_values = { 52 | "l": (X, -TRANSLATE), 53 | "r": (X, TRANSLATE), 54 | "u": (Y, -TRANSLATE), 55 | "d": (Y, TRANSLATE), 56 | } 57 | # Construct the ratio of edge size to screen size such that the wireframe 58 | # will be nearly always fully displayed on the screen for all rotations. 59 | # These numbers were chosen pragmatically. 60 | r3 = 0.56 61 | r10 = 0.3 62 | ratio = math.pow(r10 / r3, 1 / 7) 63 | screen_fraction = [r3] * (MAX + 1) 64 | for dim in range(4, MAX + 1): 65 | screen_fraction[dim] = screen_fraction[dim - 1] * ratio 66 | 67 | def __init__(self, data: Data, canvas): 68 | self.data = data 69 | # make a directory to hold video output 70 | self.output_dir = utils.make_dir("output") 71 | # fraction of screen that the wireframe should occupy 72 | self.canvas = canvas 73 | self.actions = ActionQueue() 74 | self.recording = False 75 | self.video_reader = None 76 | self.video_writer = None 77 | self.id_rect = None 78 | self.id_text = None 79 | self.id_window = None 80 | 81 | def init(self, playback=False): 82 | """Initialize the viewer size and dimension count.""" 83 | self.width, self.height = self.data.get_viewer_size() 84 | self.img = np.zeros((self.height, self.width, 3), np.uint8) 85 | # set the vanishing point in the middle of the screen 86 | # and somewhere along the z-axis 87 | self.vp = [ 88 | int(round(self.width / 2)), 89 | int(round(self.height / 2)), 90 | int(round(self.width * self.data.depth)), 91 | ] 92 | # calculate the pixel sizes for all dimensions: 93 | # get the aspect ratios for all dimensions and the largest ratio 94 | ratios = [int(r) for r in self.data.aspects.split(":")] 95 | max_r = max(ratios) 96 | # calculate the size of the largest dimension in pixels 97 | screen_size = min(self.width, self.height) * Viewer.screen_fraction[self.data.dims] 98 | # scale all dimensions to that one 99 | sizes = [screen_size * r / max_r for r in ratios] 100 | self.set_rotation() 101 | 102 | # calculate top left position 103 | orgx = (self.width - sizes[X]) / 2 104 | orgy = (self.height - sizes[Y]) / 2 105 | 106 | # construct a wireframe object 107 | self.wireframe = wf.Wireframe(self.data.dims) 108 | self.wireframe.add_shape_sizes(orgx, orgy, sizes) 109 | self.make_normalize_translations() 110 | 111 | # We sort the edges and faces in z-order so they display correctly. 112 | # These flags are set when this is needed. 113 | self.sort_edges = True 114 | self.sort_faces = True 115 | 116 | self.stop = False 117 | 118 | # initialize recording settings 119 | # When initializing the viewer for playing back, we: 120 | # * skip clearing the list of actions; 121 | # * continue to let video recording run; 122 | if not playback: 123 | self.actions.clear() 124 | self.recording = False 125 | self.video_writer = None 126 | self.video_reader = None 127 | 128 | # remove any previous drawing 129 | cv2.rectangle(self.img, (0, 0), (self.width, self.height), colors.bg, -1) 130 | 131 | # prime the canvas 132 | image = Image.fromarray(self.img) 133 | self.image = ImageTk.PhotoImage(image) 134 | self.id_image = self.canvas.create_image(0, 0, anchor="nw", image=self.image) 135 | self.clear_text() 136 | self.clear_window() 137 | 138 | def clear_text(self): 139 | self.canvas.delete(self.id_rect) 140 | self.canvas.delete(self.id_text) 141 | self.id_rect = None 142 | self.id_text = None 143 | 144 | def clear_window(self): 145 | self.canvas.delete(self.id_window) 146 | self.id_window = None 147 | 148 | def display(self): 149 | # Draw the wireframe onto the xy plane 150 | self.draw() 151 | # Show the xy plane on the tkinter canvas 152 | self.show() 153 | # Write to video if needed 154 | self.video_write() 155 | 156 | # @utils.time_function 157 | def draw(self): 158 | """Draw the wireframe onto the xy plane.""" 159 | 160 | if self.data.ghost: 161 | # leave a shadow of the previous frame 162 | factor = Viewer.ghost_to_factor[self.data.ghost] 163 | np.multiply(self.img, factor, out=self.img, casting="unsafe") 164 | else: 165 | # clear the previous frame 166 | cv2.rectangle(self.img, (0, 0), (self.width, self.height), colors.bg, -1) 167 | 168 | wireframe = self.wireframe 169 | if self.data.show_vp: 170 | cv2.circle( 171 | self.img, (self.vp[X], self.vp[Y]), self.data.vp_radius, colors.vp, -1 172 | ) 173 | 174 | if self.data.show_center: 175 | cv2.circle( 176 | self.img, 177 | (self.get_xy(wireframe.center)), 178 | self.data.center_radius, 179 | colors.center, 180 | -1, 181 | ) 182 | 183 | if self.data.show_edges: 184 | w0 = self.data.edge_width 185 | w4 = self.data.show_4_narrow # True: Line width is 1 186 | c4 = self.data.show_4_gray # True: Line color is gray 187 | w4c4 = w4 or c4 188 | # If needed (because the wireframe has been rotated), the edges 189 | # are sorted in reverse z-order so that the edges at the front 190 | # overlay those at the back. 191 | if self.sort_edges: 192 | wireframe.sort_edges() 193 | self.sort_edges = False 194 | for n1, n2, color in wireframe.edges: 195 | node1 = wireframe.nodes[n1] 196 | node2 = wireframe.nodes[n2] 197 | width = w0 198 | if w4c4: 199 | # Don't show width or color for higher dimensions 200 | if n1 >= 8 or n2 >= 8: 201 | if w4: 202 | width = 1 203 | if c4: 204 | color = colors.dim4gray 205 | cv2.line(self.img, self.get_xy(node1), self.get_xy(node2), color, width) 206 | 207 | if self.data.show_faces: 208 | faces = wireframe.faces 209 | # see the sort explanation for edges 210 | if self.sort_faces: 211 | wireframe.sort_faces() 212 | self.sort_faces = False 213 | if self.data.opacity < 1.0: 214 | zmax = wireframe.get_face_z(faces[0]) 215 | zmin = wireframe.get_face_z(faces[-1]) 216 | zrange = zmax - zmin 217 | 218 | face_count = len(faces) 219 | start = 0 220 | if self.data.opacity == 1.0: 221 | start = face_count // 2 222 | for ndx in range(start, face_count): 223 | face = faces[ndx] 224 | # n0, n1, n2, n3, color = face 225 | # Get the x,y,z coordinates of each corner 226 | xyz0 = wireframe.nodes[face.node[0]][0:3] 227 | xyz1 = wireframe.nodes[face.node[1]][0:3] 228 | xyz2 = wireframe.nodes[face.node[2]][0:3] 229 | xyz3 = wireframe.nodes[face.node[3]][0:3] 230 | # Map those points onto the screen 231 | pts = [ 232 | self.get_xy(xyz0), 233 | self.get_xy(xyz1), 234 | self.get_xy(xyz2), 235 | self.get_xy(xyz3), 236 | ] 237 | shape = np.array(pts) 238 | if self.data.opacity < 1.0: 239 | # When the faces are translucent, draw every face 240 | alpha = self.data.opacity 241 | # scale the opacity from supplied value at front 242 | # to fully opaque at back 243 | z = wireframe.get_face_z(face) 244 | alpha += (z - zmin) / zrange * (1.0 - alpha) 245 | overlay = self.img.copy() 246 | cv2.fillConvexPoly(overlay, shape, face.color) 247 | self.img = cv2.addWeighted(overlay, alpha, self.img, 1-alpha, 0) 248 | else: 249 | # When the faces are opaque 250 | vec0 = xyz1 - xyz0 251 | vec1 = xyz3 - xyz0 252 | orth = np.cross(vec0, vec1) 253 | if 1:#orth[Z] > 0: 254 | # print(f"face={face.node} Z-vector = {orth[Z]:,.0f} = DRAW THIS FACE") 255 | cv2.fillConvexPoly(self.img, shape, face.color) 256 | else: 257 | print(f"face={face.node} Z-vector = {orth[Z]:,.0f} = SKIP THIS FACE") 258 | continue 259 | 260 | if self.data.show_nodes or self.data.show_node_ids or self.data.show_coords: 261 | radius = self.data.node_radius if self.data.show_nodes else 0 262 | # for node in wireframe.nodes: 263 | for index, node in enumerate(wireframe.nodes): 264 | xy = self.get_xy(node) 265 | if self.data.show_nodes: 266 | cv2.circle(self.img, xy, radius, colors.node, -1) 267 | text = "" 268 | if self.data.show_node_ids: 269 | text = str(index) 270 | if self.data.show_coords: 271 | join = ":" if text else "" 272 | values = [int(round(v)) for v in node[:-1]] 273 | text = f"{text}{join}{values}" 274 | if text: 275 | cv2.putText( 276 | self.img, text, (xy[X] + radius, xy[Y] + 3), 277 | cv2.FONT_HERSHEY_SIMPLEX, self.data.font_size, 278 | colors.text 279 | ) 280 | 281 | def get_xy(self, node): 282 | """Given a node, return orthogonal or perspective x,y 283 | 284 | orthogonal projection on screen 285 | | perspective projection on screen 286 | | | 287 | window------O------P--------V---------------------> x- or y-axis 288 | | |\ | | 289 | | | \ | | 290 | | | \ | | 291 | | | \ | | 292 | | | \ | | 293 | | | \| | 294 | | node: N ' | 295 | | \ | 296 | | \ | 297 | | \ | 298 | | \ | 299 | | \ | 300 | | \ | 301 | V \ | 302 | z-axis \| 303 | .vanishing point 304 | """ 305 | x = node[X] 306 | y = node[Y] 307 | if self.data.show_perspective: 308 | vp = self.vp 309 | f = node[Z] / vp[Z] 310 | x += (vp[X] - node[X]) * f 311 | y += (vp[Y] - node[Y]) * f 312 | return (int(round(x)), int(round(y))) 313 | 314 | def make_normalize_translations(self): 315 | """Make 2 matrices for moving to (0,0,0,...) and back.""" 316 | wireframe = self.wireframe 317 | normalize = [-x for x in wireframe.center] 318 | self.norm_matrix = wireframe.get_translation_matrix(normalize) 319 | self.denorm_matrix = wireframe.get_translation_matrix(wireframe.center) 320 | 321 | # def repeat_frame(self, count): 322 | # """Wait for frames.""" 323 | # for _ in range(count): 324 | # self.display() 325 | 326 | def rotate_all(self, dim1, dim2, theta, dim3=None): 327 | """Rotate all wireframes about their center, around one or two planes 328 | by a given angle.""" 329 | wireframe = self.wireframe 330 | assert dim1 < wireframe.dims and dim2 < wireframe.dims 331 | if dim3 is not None: 332 | assert dim3 < wireframe.dims 333 | count = self.rotation_count if self.data.show_steps else 1 334 | delta = theta / count 335 | if dim3 is None: 336 | # we're rotating about a single plane so move in regular steps 337 | angles = [delta] * count 338 | else: 339 | # We're rotating about two planes, so increase the steps linearly. 340 | # The list is be used in the reverse order for the second plane. 341 | # (Repeating the call with the sign of theta reversed does not 342 | # quite return to the original position??!) 343 | angles = [0.0] * count 344 | if count > 1: 345 | step = delta * 2 / (count - 1) 346 | value = 0.0 347 | for n in range(count): 348 | angles[n] = value 349 | value += step 350 | assert math.isclose(sum(angles), theta) 351 | else: 352 | # gotta avoid a divide by zero! 353 | angles[0] = delta / 2 354 | if theta < 0.0: 355 | angles.reverse() 356 | scale = (self.data.auto_scale - 1.0) / count + 1.0 357 | for n in range(count): 358 | if self.stop: 359 | break 360 | # calculate the rotation needed 361 | angle = angles[-n - 1] 362 | matrix = wireframe.get_rotate_matrix(dim1, dim2, angle) 363 | if dim3 is not None: 364 | angle = angles[n] 365 | matrix = wireframe.get_rotate_matrix(dim1, dim3, angle, matrix) 366 | # move, rotate, move back 367 | wireframe.transform(self.norm_matrix) 368 | wireframe.transform(matrix) 369 | wireframe.transform(self.denorm_matrix) 370 | # having rotated the wireframe, the lists of edges and faces may 371 | # no longer be in reverse z-order, so mark them for sorting 372 | self.sort_edges = True 373 | self.sort_faces = True 374 | self.display() 375 | if scale != 1.0: 376 | self.scale_all(scale) 377 | 378 | def scale_all(self, scale): 379 | """Scale all wireframes by a given scale, centered on the center of the wireframe.""" 380 | 381 | count = 10 if self.data.show_steps else 1 382 | scale = math.pow(scale, (1 / count)) 383 | wireframe = self.wireframe 384 | for _ in range(count): 385 | if self.stop: 386 | break 387 | matrix = wireframe.get_scale_matrix(scale) 388 | # move, scale, move back 389 | wireframe.transform(self.norm_matrix) 390 | wireframe.transform(matrix) 391 | wireframe.transform(self.denorm_matrix) 392 | self.display() 393 | 394 | def set_depth(self): 395 | """The perspective depth has changed.""" 396 | self.vp[2] = int(round(self.width * self.data.depth)) 397 | 398 | def set_rotation(self): 399 | """Set rotation values from data.angle which is in degrees.""" 400 | # convert to radians 401 | self.rotation = float(self.data.angle) * np.pi / 180 402 | # take 2 steps per degree 403 | self.rotation_count = self.data.angle * 2 404 | 405 | def show(self): 406 | """Display the xy plane on the tkinter canvas.""" 407 | rgb_image = cv2.cvtColor(self.img, cv2.COLOR_BGR2RGB) 408 | self.image = ImageTk.PhotoImage(Image.fromarray(rgb_image)) 409 | self.canvas.itemconfig(self.id_image, image=self.image) 410 | self.canvas.update() 411 | 412 | def show_text(self, text): 413 | if not self.id_rect: 414 | # construct background and text widgets 415 | self.id_rect = self.canvas.create_rectangle((0,0,0,0), fill="white") 416 | self.id_text = self.canvas.create_text(22, 16, anchor="nw", font="Arial 12", fill="black") 417 | # put the text into the canvas widget 418 | self.canvas.itemconfig(self.id_text, text=text) 419 | # get the bounding box for that text 420 | bbox = self.canvas.bbox(self.id_text) 421 | # expand it slightly so it looks less crowded 422 | adjust = (-12, -6, 12, 6) 423 | bbox2 = tuple(bbox[n] + adjust[n] for n in range(4)) 424 | # set the rect (the text background) to that size 425 | self.canvas.coords(self.id_rect, bbox2) 426 | 427 | def show_window(self, widget): 428 | if not self.id_window: 429 | self.id_window = self.canvas.create_window(10, 10, anchor="nw", window=widget) 430 | else: 431 | self.canvas.itemconfigure (self.id_window, window=widget) 432 | 433 | def take_action(self, action: Action, playback=False): 434 | """Perform and display the supplied action.""" 435 | acted = True 436 | showed = False 437 | self.stop = False 438 | cmd = action.cmd 439 | if cmd == Cmd.ROTATE: 440 | # The 3rd dimension is optional 441 | rotation = self.rotation if action.p4 == "+" else -self.rotation 442 | self.rotate_all(action.p1, action.p2, rotation, action.p3) 443 | showed = True 444 | elif cmd == Cmd.VISIBLE: 445 | # This is a visibility action like showing faces, etc. 446 | # It does not make any changes to the wireframe model, but we need 447 | # the wireframe to be drawn with the changed visibility setting. 448 | pass 449 | elif cmd == Cmd.ZOOM: 450 | if action.p1 == "+": 451 | self.scale_all(Viewer.SCALE) 452 | else: 453 | self.scale_all(1 / Viewer.SCALE) 454 | showed = True 455 | elif cmd == Cmd.MOVE: 456 | dim, amount = Viewer.direction_to_values[action.p1] 457 | self.translate_all(dim, amount) 458 | showed = True 459 | elif cmd == Cmd.DIMS: 460 | assert isinstance(action.p1, int) 461 | self.data.dims = action.p1 462 | self.init() 463 | elif cmd == Cmd.RESET: 464 | pubsub.publish("reset", action.p1) 465 | acted = False 466 | else: 467 | acted = False 468 | 469 | if acted: 470 | if not showed: 471 | self.display() 472 | # Save the action for possible playback 473 | # We /don't/ keep history when the history is being played back 474 | if not playback: 475 | self.actions.append(action) 476 | 477 | def translate_all(self, dim, amount): 478 | """Translate (move) the wireframe along a given axis by a certain amount. 479 | 480 | In practise, dim is always 0 or 1. 481 | """ 482 | count = 10 if self.data.show_steps else 1 483 | delta = amount / count 484 | wireframe = self.wireframe 485 | vector = [0] * wireframe.dims 486 | vector[dim] = delta 487 | matrix = wireframe.get_translation_matrix(vector) 488 | for n in range(count): 489 | if self.stop: 490 | break 491 | wireframe.transform(matrix) 492 | wireframe.center[dim] += delta 493 | self.make_normalize_translations() 494 | self.display() 495 | 496 | def video_play(self, video_file): 497 | try: 498 | self.video_reader = cv2.VideoCapture(video_file) 499 | print(self.video_reader, self.video_reader.isOpened()) 500 | if self.video_reader.isOpened(): 501 | while not self.stop: 502 | t1 = time.perf_counter() 503 | ret, frame = self.video_reader.read() 504 | if not ret: 505 | break 506 | self.img = frame 507 | self.show() 508 | self.wait_for_frame(t1) 509 | except: 510 | pass 511 | self.video_reader = None 512 | pubsub.publish("vplay", False) 513 | 514 | def video_record(self, state): 515 | """Start recording video. See note in .video_write about file creation.""" 516 | if state: 517 | assert not self.video_writer 518 | assert not self.recording 519 | self.recording = True 520 | else: 521 | self.video_writer = None 522 | self.recording = False 523 | 524 | def video_start(self): 525 | """Create a video file to write to.""" 526 | assert not self.video_writer 527 | types = (("mp4", "mp4v"), ("avi", "XVID")) 528 | ext, codec = types[0] 529 | assert not self.video_writer 530 | fname = utils.make_filename("video", ext) 531 | output = os.path.join(self.output_dir, fname) 532 | self.video_writer = cv2.VideoWriter( 533 | output, 534 | cv2.VideoWriter_fourcc(*codec), 535 | self.data.frame_rate, 536 | (self.width, self.height), 537 | ) 538 | 539 | def video_write(self): 540 | """Write the current xy plane to a video file. 541 | 542 | Takes about 80ms. 543 | """ 544 | if self.recording: 545 | # By deferring file creation until an actual write is issued, 546 | # we avoid creating an empty video file when the user starts 547 | # and then stops video recording. 548 | if not self.video_writer: 549 | self.video_start() 550 | self.video_writer.write(self.img) 551 | 552 | def wait_for_frame(self, t1): 553 | """Wait out the remaining duration (if any) of a video frame.""" 554 | t2 = time.perf_counter() 555 | # print('frame took %0.3f ms' % ((t2-t1)*1000.0)) 556 | frame_time = 1 / self.data.frame_rate 557 | pause = frame_time - t2 + t1 558 | if pause > 0.0: 559 | time.sleep(pause) 560 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 |