├── .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 |
47 | - A large number of dimensions
48 | - Drawing the faces
49 | - Showing intermediate steps
50 | - Showing ghosting
51 | - Resizing during rotation
52 | - Showing partially transparent faces
53 |
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 |
80 | - Number of dimensions: Choose from 3 to 10.
81 |
82 | - Aspect ratios: It need not be a cube. Here, you can set the ratios
83 | of the edges for each dimension, for instance: 16:9:4 specifies a cuboid with
84 | width of 16, height of 9 and depth of 4. Additional dimensions take the value
85 | of the rightmost one specified.
86 |
87 | - Viewing size: The size in pixels of the plane surface on which the
88 | Hypercube is projected.
89 |
90 |
91 |
92 | VISIBILITY
93 |
94 | - Show faces, edges, corners: Control what is drawn.
95 |
96 | - Show intermediate steps: Draw the intermediate steps of moves and
97 | rotations. Note that this may slow the operation, especially when the hypercube has
98 | a large number of dimensions.
99 |
100 | - Show center: Show the centerpoint of the hypercube.
101 |
102 | - Perspective view: Choose whether the hypercube is projected as a
103 | perspective or orthographic view.
104 |
105 | - Show vanishing point: When perspective view is selected, show the
106 | vanishing point.
107 |
108 | - Depth of perspective: Controls the amount of perspective. The
109 | vanishing point is placed at this value times the screen width.
110 |
111 | - Amount of ghosting: As the hypercube moves, the program can
112 | leave a ghost image that fades out. 0 indicates no ghosting, and with 10,
113 | no fading takes place.
114 |
115 | - Rotation per click: The rotation in degrees per click.
116 |
117 | - Resizing during rotation: The program can resize the object during rotation.
118 | This gives the amount by which the object is scaled. The rotation is slower because of
119 | this. Note that when intermediate steps are shown, a fraction of the scaling takes place
120 | for every step, making it much slower. When there are a large number of dimensions,
121 | the speed is even worse. This speed slowdown does not show in recorded videos.
122 |
123 | - Opacity: The opacity of the faces. Note that when the faces are
124 | translucent, drawing times are much slower.
125 |
126 |
127 |
128 |
129 | In preferences, you can also control:
130 |
131 | - Corner radius
132 | - Center radius
133 | - Vanishing point radius
134 | - Line width
135 | - Font size: The font size for coordinates and corner numbers
136 | - Show coordinates: Show the coordinates of every point. The points
137 | may overlap for, say, a cube in orthogonal view. Avoid this by using perspective
138 | view and/or rotating the object so that every point is at a different place on screen.
139 |
140 | - Show corner numbers: The internal index number of all corners.
141 | - Set line width to 1: For dimensions of 4 or more, set the line width to 1.
142 | - Set line color to gray: For dimensions of 4 or more, set the line color to gray.
143 |
144 | MOVEMENT
145 |
146 | - The object can be rotated around various planes, moved, zoomed and shrunk.
147 |
148 | - Replay: This button will replay all the movement and visibility actions
149 | from the beginning.
150 |
151 | - Stop: This button will stop replay and also long movement operations.
152 |
153 | - Begin Again: This button will forget all movement that you have done
154 | and start again.
155 |
156 | - Replay with original visibility settings: This checkbox chooses whether
157 | replay includes all changes to visibility settings that were made. When it is
158 | unchecked, replay takes place using the current visibility settings.
159 |
160 |
161 |
162 | RECORDING TO VIDEO
163 | {identity.PRODUCT} can record the movements to a video. Recording will capture
164 | replay events as well as the original actions. Each start and stop creates a separate
165 | video file.
166 |
167 |
168 | - Frame rate of videos: Choose the frame rate which which videos are created.
169 |
170 | - Record: Start recording to a video file whose name is time-stamped.
171 |
172 | - Play: Play back the last recorded video file.
173 |
174 | - View Folder: Open the folder where the video files are saved.
175 |
176 |
177 | """
178 |
179 | keys = """
180 | VISIBILITY
181 | f = Show faces
182 | e = Show edges
183 | c = Show corners
184 | i = Show intermediate steps
185 | t = Show center
186 | p = Perspective view
187 | v = Show vanishing point
188 | ----
189 | d = Depth of perspective
190 | g = Amount of ghosting
191 | r = Rotation per click in degrees
192 | z = Resizing during rotation
193 | ----
194 | o = Show coordinates
195 | n = Show corner numbers
196 | For dimensions of 4 or more:
197 | w = Set edge width to 1
198 | o = Set edge color to gray
199 | ----
200 | h = Show hints
201 |
202 | MOVEMENT
203 | 1-9 = Rotation in that plane (1-3 = X,Y,Z)
204 | 0 = Random rotation
205 | Ctrl+1 etc. Rotates in the opposite direction
206 | +,- = Zoom in, out
207 | Arrows = Movement
208 |
209 | REPLAYING
210 | s = Replay with original visibility settings
211 | Space = Replay
212 | Esc = Stop
213 | a = Show Actions
214 |
215 | """
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/main.py:
--------------------------------------------------------------------------------
1 | #! python3
2 | # -*- coding: utf-8 -*-
3 | """
4 | All activity takes place via Action objects. They are pushed onto a queue
5 | (actionQ) which is emptied by run(), which pauses after each event to allow
6 | tkinter to update the user interface and keep the program responsive.
7 | (In addition, long-running drawing actions in display.py that would otherwise
8 | make user requests wait can be terminated by setting a flag.)
9 |
10 | Activities are of two sorts: visibility changes and movement changes.
11 | Movement actions control the orientation of the hypercube; visibility actions
12 | control how it is displayed, e.g. whether faces, edges or corners are shown;
13 | the perspective; the amount of rotation; etc.
14 |
15 | A history of actions is kept, and it is possible to replay these. The program
16 | updates the user interface to correspond with the original actions. This
17 | requires the program to know what control corresponds to which action, and to
18 | update it; this needs a 3-way relationship between the control, the action and
19 | the state. This is done by having Control classes encapsulate widgets (this
20 | also hides the differences in the way widgets operate), holding them as values
21 | in a dictionary whose keys are the action types, and placing callback info in
22 | the class instances.
23 |
24 | When the actions are originally carried out, triggered by the user clicking
25 | controls, the run loop updates the state and changes the wireframe and display.
26 | When the actions are replayed, the run loop leaves the state untouched and
27 | updates the control, wireframe and display.
28 | """
29 | import argparse
30 | import copy
31 | import enum
32 | from functools import partial
33 | import os
34 | import random
35 | import tkinter as tk
36 | from tkinter import ttk, messagebox
37 |
38 | from action import Action, ActionQueue, Cmd
39 | import controls
40 | from controls import DISABLED, ENABLED, ACTIVE
41 | from data import Data
42 | import dims
43 | from dims import X, Y, Z # syntactic sugar for the first three dimensions
44 | import display
45 | from preferences import Preferences
46 | from hints import Hints, get_hint_for_ctl, set_hint_for_ctl
47 | from html_viewer import HtmlViewer, Name
48 | import help
49 | import pubsub
50 | import utils
51 |
52 | STR_UP = "↑"
53 | STR_DN = "↓"
54 | STR_LEFT = "←"
55 | STR_RIGHT = "→"
56 |
57 | # The application can be in any of these 5 states. These states maintain
58 | # buttons in the appropriate state. (Buttons have 3 states: disabled, enabled,
59 | # and active. This allows for text and color changes.)
60 | # CLEAN is the state when no actions have been issued, so the Play and Stop
61 | # buttons should be disabled.
62 | # IDLE is when nothing is happening.
63 | # RUNNING is when user-issued actions are being executed.
64 | # DO NOT CONFUSE REPLAYING and PLAYING. REPLAYING is playing back all the
65 | # commands like move and rotate that have been issued, while PLAYING is the
66 | # playback of a previously-recorded video.
67 | CLEAN, IDLE, RUNNING, REPLAYING, PLAYING = range(5)
68 |
69 | # An additional state that exists alongside the above five is whether we are
70 | # recording to video or not. This state is held in Viewer.recording.
71 | # This table gives the button that is pressed to enter the state
72 | # and the action that is triggered.
73 | # state button action
74 | # -----------|----------------|-----------------
75 | # CLEAN .restart_button .restart
76 | # IDLE .stop_button .on_stop
77 | # RUNNING movement buttons .queue_action(Action)
78 | # PLAYING .play_button .on_play_video
79 | # REPLAYING .replay_button .queue_action(PB)
80 | # RECORDING .record_button .set_record_state
81 |
82 | # These lists give the states of the above 5 buttons for each of the 5
83 | # application states for each of the 2 record states.
84 | button_states_normal = (
85 | # replay_button stop_button record_button play_button state
86 | (DISABLED, DISABLED, ENABLED, ENABLED), # CLEAN
87 | (ENABLED, DISABLED, ENABLED, ENABLED), # IDLE
88 | (ENABLED, ACTIVE, ENABLED, ENABLED), # RUNNING
89 | (ACTIVE, ACTIVE, ENABLED, DISABLED), # REPLAYING
90 | (DISABLED, DISABLED, DISABLED, ACTIVE), # PLAYING
91 | )
92 | button_states_recording = (
93 | # replay_button stop_button record_button play_button state
94 | (DISABLED, DISABLED, ACTIVE, ENABLED), # CLEAN
95 | (ENABLED, DISABLED, ACTIVE, DISABLED), # IDLE
96 | (ENABLED, ACTIVE, ACTIVE, ENABLED), # RUNNING
97 | (ACTIVE, ACTIVE, ACTIVE, DISABLED), # REPLAYING
98 | (DISABLED, DISABLED, DISABLED, DISABLED), # PLAYING
99 | )
100 |
101 |
102 | class Reset(enum.IntFlag):
103 | FACTORY = 1 # restore factory settings
104 | DATA = 2 # restore settings at beginning
105 | DIM = 4 # change the number of dimensions
106 | ASPECT = 8 # change the aspect ratios
107 | VIEW = 16 # change the viewer size
108 |
109 |
110 | class App(tk.Frame):
111 |
112 | def __init__(self, root, args):
113 | tk.Frame.__init__(self, root)
114 | # set up hooks for program close
115 | self.root = root
116 | self.args = args
117 | root.protocol("WM_DELETE_WINDOW", self.on_close)
118 | root.bind('', self.on_key)
119 |
120 | # create an instance for loading and saving data and get the filename
121 | # of the json file that holds data (.load_settings() and
122 | # .save_settings() will perform the actual transfers)
123 | # This is the canonical version of the persistent data. It is passed
124 | # into display.Viewer so that App and Viewer share the data.
125 | self.data = Data()
126 | self.data_file = utils.get_location("settings", "values.json")
127 | self.data.load(self.data_file)
128 | # set top-left position of window
129 | root.geometry(f'+{self.data.win_x}+{self.data.win_y}')
130 | self.actionQ = ActionQueue() # queue of Action instances
131 | self.playback_index = -1 # if >= 0, we are replaying actionQ
132 | self.state = CLEAN
133 | self.construct_keymap()
134 |
135 | self.dim_controls = []
136 | self.grid(sticky=tk.NSEW)
137 | self.rowconfigure(0, weight=1)
138 | self.columnconfigure(0, weight=1)
139 | self.winfo_toplevel().title('Hypercube')
140 | self.big_font = ('calibri', 14, 'bold')
141 |
142 | # create a frame for display and add a canvas to it
143 | self.right_frame = tk.Frame(self)
144 | self.right_frame.grid(row=0, column=1, sticky=tk.NE)
145 | self.canvas = tk.Canvas(self.right_frame, highlightthickness=0)
146 | self.canvas.grid(row=0, column=0, sticky=tk.NSEW)
147 | self.canvas.bind("", self.on_canvas)
148 |
149 | # construct the viewer and the wireframe
150 | self.viewer = display.Viewer(self.data, self.canvas)
151 | self.hints = Hints(self.viewer)
152 | self.html_viewer = HtmlViewer(self.viewer)
153 |
154 | # create a frame for controls and add them
155 | self.left_frame = tk.Frame(self)
156 | self.left_frame.grid(row=0, column=0, sticky=tk.NW)
157 | self.construct_controls()
158 | self.add_controls(self.left_frame, 0, 0)
159 | self.buttons = (self.replay_button, self.stop_button, self.record_button, self.play_button)
160 |
161 | self.add_menu()
162 | self.load_settings()
163 | self.reset(Reset.DIM | Reset.ASPECT | Reset.VIEW)
164 |
165 | pubsub.subscribe('reset', self.reset)
166 | pubsub.subscribe('prefs', self.set_prefs)
167 | pubsub.subscribe('vplay', self.on_play_end)
168 | self.run()
169 |
170 | def add_controls(self, parent_frame, row, col):
171 | """Add user controls to the window."""
172 | # create a subframe and place it as requested
173 | frame = tk.Frame(parent_frame)
174 | frame.grid(row=row, column=col, padx=2)
175 | row = 0
176 | color = "red3"
177 |
178 | # add setup controls
179 | ctl = tk.Label(frame, text='SETUP', font=self.big_font, fg=color)
180 | ctl.grid(row=row, column=0, sticky=tk.W, pady=2)
181 | row += 1
182 | self.add_setup_controls(frame, row, 0)
183 | row += 1
184 | w = ttk.Separator(frame, orient=tk.HORIZONTAL)
185 | w.grid(row=row, column=0, sticky=tk.EW, pady=(8,0))
186 | row += 1
187 |
188 | # add visibility controls
189 | ctl = tk.Label(frame, text='VISIBILITY', font=self.big_font, fg=color)
190 | ctl.grid(row=row, column=0, sticky=tk.W, pady=2)
191 | row += 1
192 | self.add_visibility_controls(frame, row, 0)
193 | row += 1
194 | w = ttk.Separator(frame, orient=tk.HORIZONTAL)
195 | w.grid(row=row, column=0, sticky=tk.EW, pady=(6,0))
196 | row += 1
197 |
198 | # add movement controls
199 | ctl = tk.Label(frame, text='MOVEMENT', font=self.big_font, fg=color)
200 | ctl.grid(row=row, column=0, sticky=tk.W, padx=2, pady=2)
201 | row += 1
202 | self.add_rotation_controls(frame, row, 0)
203 | row += 1
204 | self.add_movement_controls(frame, row, 0)
205 | row += 1
206 | w = ttk.Separator(frame, orient=tk.HORIZONTAL)
207 | w.grid(row=row, column=0, sticky=tk.EW, pady=(10,0))
208 | row += 1
209 |
210 | # add recording controls
211 | ctl = tk.Label(frame, text='RECORDING TO VIDEO', font=self.big_font, fg=color)
212 | ctl.grid(row=row, column=0, sticky=tk.W, padx=2, pady=2)
213 | row += 1
214 | self.add_recording_controls(frame, row, 0)
215 | row += 1
216 | w = ttk.Separator(frame, orient=tk.HORIZONTAL)
217 | w.grid(row=row, column=0, sticky=tk.EW, pady=(10,0))
218 | row += 1
219 |
220 | # add test controls
221 | if self.args.test:
222 | ctl = tk.Label(frame, text='TEST', font=self.big_font, fg=color)
223 | ctl.grid(row=row, column=0, sticky=tk.W, padx=2, pady=2)
224 | row += 1
225 | self.add_test_controls(frame, row, 0)
226 | row += 1
227 |
228 | def add_test_controls(self, parent_frame, row, col):
229 | """Add test controls to the window."""
230 | frame = tk.Frame(parent_frame)
231 | frame.grid(row=row, column=col, sticky=tk.W, padx=2)
232 | row = 0
233 | ctl = tk.Button(frame, text="Test 1", command=self.on_test1)
234 | ctl.grid(row=row, column=0, sticky=tk.E, padx=4)
235 | ctl = tk.Button(frame, text="Test 2", command=self.on_test2)
236 | ctl.grid(row=row, column=1, sticky=tk.E, padx=4)
237 | ctl = tk.Button(frame, text="Test 3", command=self.on_test3)
238 | ctl.grid(row=row, column=2, sticky=tk.E, padx=4)
239 | row += 1
240 | ctl = tk.Button(frame, text="Test 4", command=self.on_test4)
241 | ctl.grid(row=row, column=0, sticky=tk.E, padx=4)
242 | ctl = tk.Button(frame, text="Test 5", command=self.on_test5)
243 | ctl.grid(row=row, column=1, sticky=tk.E, padx=4)
244 | ctl = tk.Button(frame, text="Test 6", command=self.on_test6)
245 | ctl.grid(row=row, column=2, sticky=tk.E, padx=4)
246 | row += 1
247 |
248 | def on_test1(self):
249 | print(self.viewer.actions)
250 |
251 | ttt = ""
252 | def on_test2(self):
253 | App.ttt += "A rose by any other name\nwould still be\na rose.\n"
254 | self.viewer.show_text(App.ttt)
255 |
256 | def on_test3(self):
257 | control = self.controls['ghost']
258 | value = self.data.ghost + 1
259 | print('set ghost to', value)
260 | control.set(value)
261 |
262 | def on_test4(self):
263 | control = self.controls['angle']
264 | value = self.data.angle + 1
265 | print('set angle to', value)
266 | control.set(value)
267 |
268 | def on_test5(self):
269 | control = self.controls['auto_scale']
270 | value = self.data.auto_scale + 0.02
271 | print('set auto_scale to', value)
272 | control.set(value)
273 |
274 | def on_test6(self):
275 | control = self.controls['show_faces']
276 | value = self.data.show_faces ^ True
277 | print('set show_faces to', value)
278 | control.set(value)
279 |
280 | def add_aspect_control(self, parent_frame, row, col):
281 | """Add view size control to the window."""
282 | frame = tk.Frame(parent_frame)
283 | frame.grid(row=row, column=col, sticky=tk.W, padx=2)
284 | self.aspect = tk.Entry(frame, width=12)
285 | self.aspect.grid(row=0, column=0, sticky=tk.W)
286 | self.aspect.bind('', self.on_aspect)
287 | self.aspect.bind('', self.on_aspect)
288 | set_hint_for_ctl(self.aspect, "aspect")
289 |
290 | def add_menu(self):
291 | menubar = tk.Menu(self.root, background='#ff8000', foreground='black', activebackground='white', activeforeground='black')
292 | file = tk.Menu(menubar, tearoff=0)
293 | # file.add_command(label="New")
294 | # file.add_command(label="Open")
295 | # file.add_command(label="Save")
296 | # file.add_command(label="Save as")
297 | # file.add_separator()
298 | file.add_command(label="Exit", command=self.on_close)
299 | menubar.add_cascade(label="File", menu=file)
300 |
301 | edit = tk.Menu(menubar, tearoff=0)
302 | edit.add_command(label="Factory reset", command=self.on_factory_reset)
303 | edit.add_command(label="Preferences...", command=self.on_prefs)
304 | menubar.add_cascade(label="Edit", menu=edit)
305 |
306 | help = tk.Menu(menubar, tearoff=0)
307 | help.add_command(label="Help", command=self.on_help)
308 | help.add_command(label="Keyboard shortcuts", command=self.on_help_keys)
309 | help.add_command(label="About", command=partial(self.hints.show_static, "about"))
310 | menubar.add_cascade(label="Help", menu=help)
311 | self.root.config(menu=menubar)
312 |
313 | def add_movement_controls(self, parent_frame, row, col):
314 | """Add up/down/left/right controls to the window."""
315 | frame = tk.Frame(parent_frame)
316 | frame.grid(row=row, column=col, sticky=tk.W, padx=4)
317 | row = 0
318 | Zm = Action(Cmd.ZOOM, '-')
319 | Zp = Action(Cmd.ZOOM, '+')
320 | Ml = Action(Cmd.MOVE, 'l')
321 | Mr = Action(Cmd.MOVE, 'r')
322 | Mu = Action(Cmd.MOVE, 'u')
323 | Md = Action(Cmd.MOVE, 'd')
324 | PB = Action(Cmd.PLAYBACK)
325 | ctl = tk.Button(frame, text='-', font=self.big_font, command=partial(self.queue_action, Zm))
326 | ctl.grid(row=row, column=0, sticky=tk.E, padx=2, pady=2)
327 | set_hint_for_ctl(ctl, "zoom_m")
328 | ctl = tk.Button(frame, text=STR_UP, font=self.big_font, command=partial(self.queue_action, Mu))
329 | ctl.grid(row=row, column=1, sticky=tk.W, padx=2, pady=2)
330 | set_hint_for_ctl(ctl, "move")
331 | ctl = tk.Button(frame, text='+', font=self.big_font, command=partial(self.queue_action, Zp))
332 | ctl.grid(row=row, column=2, sticky=tk.W, padx=2, pady=2)
333 | set_hint_for_ctl(ctl, "zoom_p")
334 | # add a "Replay" control
335 | spacer1 = tk.Label(frame, text="")
336 | spacer1.grid(row=row, column=3, padx=20)
337 | self.replay_button = controls.Button(frame,
338 | texts = ["Replay", "Replay", "Replaying"],
339 | font=self.big_font,
340 | width=12,
341 | command=partial(self.queue_action, PB))
342 | set_hint_for_ctl(self.replay_button, "replay")
343 | self.replay_button.grid(row=row, column=4, columnspan=2, sticky=tk.NSEW, padx=2, pady=2)
344 | row += 1
345 | ctl = tk.Button(frame, text=STR_LEFT, font=self.big_font, command=partial(self.queue_action, Ml))
346 | ctl.grid(row=row, column=0, sticky=tk.W, padx=2, pady=2)
347 | set_hint_for_ctl(ctl, "move")
348 | ctl = tk.Button(frame, text=STR_DN, font=self.big_font, command=partial(self.queue_action, Md))
349 | ctl.grid(row=row, column=1, sticky=tk.W, padx=2, pady=2)
350 | set_hint_for_ctl(ctl, "move")
351 | ctl = tk.Button(frame, text=STR_RIGHT, font=self.big_font, command=partial(self.queue_action, Mr))
352 | ctl.grid(row=row, column=2, sticky=tk.W, padx=2, pady=2)
353 | set_hint_for_ctl(ctl, "move")
354 | # add a "Stop" control
355 | self.stop_button = controls.Button(frame, text="Stop", color2="red", font=self.big_font, width=12, command=self.on_stop)
356 | self.stop_button.grid(row=row, column=4, sticky=tk.NSEW, padx=2, pady=2)
357 | set_hint_for_ctl(self.stop_button, "stop")
358 | row += 1
359 | # add a "Restart" control
360 | self.restart_button = controls.Button(frame, text="Begin Again", command=self.on_restart)
361 | self.restart_button.grid(row=row, column=0, columnspan=3, sticky=tk.NSEW, padx=2, pady=2)
362 | set_hint_for_ctl(self.restart_button, "restart")
363 | # add a "Restart" control
364 | ctl = controls.Button(frame, text="Show Actions", command=self.on_list)
365 | ctl.grid(row=row, column=4, sticky=tk.NSEW, padx=2, pady=2)
366 | set_hint_for_ctl(ctl, "list")
367 | row += 1
368 |
369 | def add_recording_controls(self, parent_frame, row, col):
370 | """Add recording controls to the window."""
371 | frame = tk.Frame(parent_frame)
372 | frame.grid(row=row, column=col, sticky=tk.W, padx=4)
373 | row = 0
374 |
375 | # add choice of frame rate
376 | control = self.controls['frame_rate']
377 | control.add_control(frame, row, 1, **{"columnspan": 3})
378 | row += 1
379 |
380 | w = 10
381 | self.record_button = controls.Button(frame, texts=["Record", "Record", "Stop"], color2="red", width=w, command=self.set_record_state)
382 | self.record_button.grid(row=row, column=0, sticky=tk.E, pady=2)
383 | set_hint_for_ctl(self.record_button, "record")
384 | self.play_button = controls.Button(frame, texts=["Play", "Play", "Stop"], color2="red", width=w, command=self.on_play_video)
385 | self.play_button.grid(row=row, column=1, pady=2)
386 | set_hint_for_ctl(self.play_button, "play")
387 | ctl = tk.Button(frame, text='View Folder', width=w, command=self.on_view_files)
388 | ctl.grid(row=row, column=2, pady=2)
389 | set_hint_for_ctl(ctl, "folder")
390 | row += 1
391 |
392 | def add_rotation_controls(self, parent_frame, row, col):
393 | """Add rotation controls to the window."""
394 | frame = tk.Frame(parent_frame)
395 | frame.grid(row=row, column=col)
396 | row = 0
397 | # add column headings
398 | labels = (
399 | 'Plane of\nRotation',
400 | 'Direction of\nRotation',
401 | 'Color of 1st\nDimension',
402 | 'Color of 2nd\nDimension',
403 | 'Color of\nface',
404 | )
405 | for col, label in enumerate(labels):
406 | ctl = tk.Label(frame, text=label)
407 | ctl.grid(row=row, column=col, sticky=tk.W, padx=2, pady=2)
408 | row += 1
409 |
410 | for plane in dims.planes:
411 | control = controls.PlaneControl(frame, row, plane[0], plane[1], self)
412 | self.dim_controls.append(control)
413 | control.show_colors(self.data)
414 | row += 1
415 |
416 | # add a random rotation
417 | ctl = tk.Label(frame, text='Random')
418 | ctl.grid(row=row, column=0, sticky=tk.EW, padx=2, pady=2)
419 | # insert controls for random rotation in a subframe
420 | rot_frame = tk.Frame(frame)
421 | rot_frame.grid(row=row, column=1)
422 | btn = tk.Button(rot_frame, text=' < ', command=partial(self.on_random, '-'))
423 | btn.grid(row=0, column=0, sticky=tk.W, padx=2, pady=2)
424 | set_hint_for_ctl(btn, "random")
425 | btn = tk.Button(rot_frame, text=' > ', command=partial(self.on_random, '+'))
426 | btn.grid(row=0, column=1, sticky=tk.W, padx=2, pady=2)
427 | set_hint_for_ctl(btn, "random")
428 |
429 | # add a checkbox for whether replay tracks the visible settings
430 | ctl = self.controls['replay_visible']
431 | ctl.add_control(frame, row, 2, **{"padx":(12,0), "columnspan":2})
432 | row += 1
433 |
434 | def add_setup_controls(self, parent_frame, row, col):
435 | """Add setup controls to the window."""
436 | frame = tk.Frame(parent_frame)
437 | frame.grid(row=row, column=col, sticky=tk.W, padx=2)
438 | row = 0
439 | # add choice of number of dimensions
440 | ctl = tk.Label(frame, text='Number of dimensions:')
441 | ctl.grid(row=row, column=0, sticky=tk.W, pady=2)
442 | self.dim_choice = ttk.Combobox(frame,
443 | state='readonly',
444 | width=3,
445 | values=[str(n) for n in range(dims.MIN, dims.MAX+1)],
446 | )
447 | self.dim_choice.grid(row=row, column=1, sticky=tk.W, pady=0)
448 | self.dim_choice.bind('<>', self.on_dim)
449 | set_hint_for_ctl(self.dim_choice, "dims")
450 | row += 1
451 |
452 | # add control of aspect ratios
453 | ctl = tk.Label(frame, text='Aspect ratios:')
454 | ctl.grid(row=row, column=0, sticky=tk.SW)
455 | self.add_aspect_control(frame, row, 1)
456 | row += 1
457 |
458 | # add control of viewer size
459 | ctl = tk.Label(frame, text='Viewing size:')
460 | ctl.grid(row=row, column=0, sticky=tk.SW)
461 | self.add_viewer_size_control(frame, row, 1)
462 | row += 1
463 |
464 | def add_viewer_size_control(self, parent_frame, row, col):
465 | """Add view size control to the window."""
466 | frame = tk.Frame(parent_frame)
467 | frame.grid(row=row, column=col, sticky=tk.W, padx=2)
468 | self.viewer_size = tk.Entry(frame, width=12)
469 | self.viewer_size.grid(row=0, column=0, sticky=tk.W)
470 | self.viewer_size.bind('', self.on_viewer_size)
471 | self.viewer_size.bind('', self.on_viewer_size)
472 | set_hint_for_ctl(self.viewer_size, "viewsize")
473 |
474 | def add_visibility_controls(self, parent_frame, row, col):
475 | """Add controls for what to display to the window."""
476 | frame = tk.Frame(parent_frame)
477 | frame.grid(row=row, column=col, sticky=tk.W, padx=2)
478 | row = 0
479 | ctl = tk.Label(frame, text='These controls\naffect how the\nmovement actions\nare displayed.')
480 | ctl.grid(row=row, column=0, rowspan=8, sticky=tk.W, pady=2)
481 |
482 | # add controls to this frame
483 | for dataname in (
484 | 'show_faces',
485 | 'show_edges',
486 | 'show_nodes',
487 | 'show_steps',
488 | 'show_center',
489 | 'show_perspective',
490 | 'show_vp',
491 | 'depth',
492 | 'ghost',
493 | 'angle',
494 | 'auto_scale',
495 | 'opacity',
496 | ):
497 | control = self.controls[dataname]
498 | control.add_control(frame, row, 1)
499 | row += 1
500 | # show_hints is treated separately as it lives in a different column
501 | control = self.controls['show_hints']
502 | control.add_control(frame, row, 0)
503 | row += 1
504 |
505 | def construct_controls(self):
506 | """Construct controls in a dictionary."""
507 | self.controls = {
508 | 'show_faces': controls.CheckControl('Show faces', underline=5),
509 | 'show_edges': controls.CheckControl('Show edges', underline=5),
510 | 'show_nodes': controls.CheckControl('Show corners', underline=5),
511 | 'show_steps': controls.CheckControl('Show intermediate steps', underline=5),
512 | 'show_center': controls.CheckControl('Show center', underline=8),
513 | 'show_perspective': controls.CheckControl('Perspective view', underline=0),
514 | 'show_vp': controls.CheckControl('Show vanishing point', underline=5),
515 | 'depth': controls.SlideControl('Depth of perspective:', 2.0, 10.0, 0.5),
516 | 'ghost': controls.SlideControl('Amount of ghosting:', 0, 10, 1),
517 | 'angle': controls.SlideControl('Rotation per click in degrees:', 1, 20, 1),
518 | 'auto_scale': controls.SlideControl('Resizing during rotation:', 0.90, 1.10, 0.02),
519 | 'opacity': controls.SlideControl('Opacity:', 0.1, 1.0, 0.1),
520 | 'replay_visible': controls.CheckControl('Replay with original\nvisibility settings'),
521 | 'frame_rate': controls.ComboControl('Frame rate of video:', ['24', '25', '30', '60', '120']),
522 | 'show_hints': controls.CheckControl('Show hints'),
523 | # The following datanames are changed in preferences and have no
524 | # widget in this window. They exist here for the benefit of
525 | # keyboard shortcuts, see key_visible().
526 | 'show_coords': None,
527 | "show_node_ids": None,
528 | "show_4_narrow": None,
529 | "show_4_gray": None,
530 | }
531 |
532 | # set up a sleazy but convenient way of associating the control
533 | # and the callback
534 | callback = self.visibility_action
535 | for dataname, control in self.controls.items():
536 | if control:
537 | control.set_data(dataname, self.data)
538 | control.callback = callback
539 | # show_hints uses a different callback
540 | if dataname == "show_hints":
541 | control.callback = self.on_hints
542 | # If this control is a slider, tell the action queue so it is able
543 | # to merge successive values together
544 | if isinstance(control, controls.SlideControl) or isinstance(control, controls.ComboControl):
545 | ActionQueue.sliders.append(dataname)
546 |
547 | def construct_keymap(self):
548 | """Construct the mapping from tkinter key events to actions."""
549 | self.key_map = {
550 | "f": (self.key_visible, "show_faces"),
551 | "e": (self.key_visible, "show_edges"),
552 | "c": (self.key_visible, "show_nodes"),
553 | "i": (self.key_visible, "show_steps"),
554 | "t": (self.key_visible, "show_center"),
555 | "p": (self.key_visible, "show_perspective"),
556 | "v": (self.key_visible, "show_vp"),
557 |
558 | "d": (self.key_slider, "depth"),
559 | "g": (self.key_slider, "ghost"),
560 | "r": (self.key_slider, "angle"),
561 | "z": (self.key_slider, "auto_scale"),
562 | "0": (self.key_rotate, None),
563 |
564 | "minus": (self.key_action, Action(Cmd.ZOOM, '-')),
565 | "plus": (self.key_action, Action(Cmd.ZOOM, '+')),
566 | "left": (self.key_action, Action(Cmd.MOVE, 'l')),
567 | "right": (self.key_action, Action(Cmd.MOVE, 'r')),
568 | "up": (self.key_action, Action(Cmd.MOVE, 'u')),
569 | "down": (self.key_action, Action(Cmd.MOVE, 'd')),
570 | "h": (self.key_visible, "show_hints"),
571 |
572 | "s": (self.key_visible, "replay_visible"),
573 | "space": (self.key_action, Action(Cmd.PLAYBACK)),
574 | "escape": (self.key_passthrough, self.on_escape),
575 | "a": (self.key_passthrough, self.on_list),
576 |
577 | "f1": (self.key_passthrough, self.on_help),
578 |
579 | # keys for values that are set in preferences
580 | "o": (self.key_visible, "show_coords"),
581 | "n": (self.key_visible, "show_node_ids"),
582 | "w": (self.key_visible, "show_4_narrow"),
583 | "q": (self.key_visible, "show_4_gray"),
584 | }
585 |
586 | def copy_data(self, from_data: Data, skip=False):
587 | """Update the data settings from another instance of Data().
588 |
589 | This function is used for several purposes: for a full factory reset,
590 | and to reset values as they were when starting. For the latter, there
591 | are certain settings that the user will expect to remain the same, and
592 | so skip is supplied to leave those untouched.
593 |
594 | This function is used for several purposes:
595 | * For a full factory reset
596 | * To reset values as they were when starting. There are certain
597 | settings that the user will expect to remain the same, and so
598 | skip is supplied to leave those untouched.
599 | * To update preferences. For this, we need to know what has changed
600 | so we can push events on the actionQ.
601 | """
602 | changed = []
603 | for attr in from_data.__dict__:
604 | old_value = getattr(self.data, attr)
605 | new_value = getattr(from_data, attr)
606 | if new_value != old_value:
607 | if skip and attr in ("replay_visible", "frame_rate"):
608 | # Keep the current replay_visible value because if it started
609 | # out one way but the user changed it, we don't want to use
610 | # the original setting. Same applies to frame_rate.
611 | continue
612 | setattr(self.data, attr, new_value)
613 | if attr in self.controls:
614 | control = self.controls[attr]
615 | if control:
616 | control.set(new_value)
617 | changed.append(attr)
618 | return changed
619 |
620 | def get_previous_action(self):
621 | if self.actionQ:
622 | return self.actionQ[-1]
623 | if self.viewer.actions:
624 | return self.viewer.actions[-1]
625 |
626 | def hint_manager(self):
627 | try:
628 | # get the widget under the cursor
629 | x, y = self.winfo_pointerxy()
630 | widget = self.winfo_containing(x, y)
631 | if widget:
632 | # get possible hint id for this control...
633 | hint_id = get_hint_for_ctl(widget)
634 | else:
635 | hint_id = ''
636 | self.hints.show(hint_id)
637 | except:
638 | # specifically, we are catching a popdown exception in
639 | # winfo_containing, but why not catch everything?
640 | pass
641 |
642 | #
643 | # key_xxxx() functions are callbacks from on_key()
644 | #
645 |
646 | def key_action(self, keysym, state, action):
647 | if not state & 0x20004: # ignore Ctl and Alt modifiers
648 | self.queue_action(action)
649 |
650 | def key_passthrough(self, keysym, state, function):
651 | function()
652 |
653 | def key_rotate(self, keysym, state, value):
654 | keysym = int(keysym)
655 | direction = '+' if state & 4 else '-'
656 | if keysym == 0:
657 | self.on_random(direction)
658 | else:
659 | dim = keysym - 1
660 | if dim < self.data.dims:
661 | plane = self.dim_controls[dim]
662 | self.on_rotate(direction, plane)
663 |
664 | def key_slider(self, keysym, state, dataname):
665 | control = self.controls[dataname]
666 | step = -1 if keysym.islower() else 1
667 | control.step(step)
668 | self.visibility_action(dataname)
669 |
670 | def key_visible(self, keysym, state, dataname):
671 | if not state & 0x20004: # ignore Ctl and Alt modifiers
672 | control = self.controls[dataname]
673 | if control:
674 | # Change the value and visible appearance of the control.
675 | # In visibility_action, the value will be extracted and put
676 | # into an Action object. The value is not set into the data
677 | # instance until run() processes the queue.
678 | control.xor()
679 | else:
680 | # If there is no control, it is because the keystroke is a
681 | # shortcut for a value managed by preferences, so flip it
682 | # here and visibility_action will extract it from self.data.
683 | self.data.xor(dataname)
684 | self.visibility_action(dataname)
685 |
686 | def load_settings(self):
687 | """Load initial settings."""
688 | self.aspect.delete(0,999)
689 | self.aspect.insert(0, self.data.aspects)
690 | self.viewer_size.delete(0,999)
691 | self.viewer_size.insert(0, self.data.viewer_size)
692 | for dataname, control in self.controls.items():
693 | value = getattr(self.data, dataname)
694 | if control:
695 | control.set(value)
696 |
697 | def on_aspect(self, _):
698 | """The aspect ratios have been changed.
699 |
700 | If they're valid, save them and rebuild the viewer,
701 | else highlight the edit control in yellow
702 | """
703 | aspects = self.aspect.get()
704 | if self.data.validate_aspects(aspects):
705 | self.aspect.configure(bg='white')
706 | if aspects != self.data.aspects:
707 | self.data.aspects = aspects
708 | self.stop()
709 | self.queue_action(Action(Cmd.RESET, Reset.ASPECT))
710 | else:
711 | self.aspect.configure(bg='yellow')
712 |
713 | def on_canvas(self, x):
714 | """Left-click on canvas."""
715 | self.hints.stop_static()
716 |
717 | def on_close(self):
718 | # close the app
719 | data = self.data
720 | data.win_x = self.root.winfo_x()
721 | data.win_y = self.root.winfo_y()
722 | data.dims = int(self.dim_choice.get())
723 | data.save(self.data_file)
724 | self.root.destroy()
725 |
726 | def on_dim(self, param):
727 | """User has selected the number of dimensions via the combo box."""
728 | dims = int(param.widget.get())
729 | if dims != self.data.dims:
730 | self.data.dims = dims
731 | self.stop()
732 | self.queue_action(Action(Cmd.RESET, Reset.DIM))
733 |
734 | def on_escape(self):
735 | """User has hit ESC key."""
736 | self.html_viewer.clear()
737 | self.stop()
738 |
739 | def on_factory_reset(self):
740 | self.stop()
741 | self.queue_action(Action(Cmd.RESET, Reset.FACTORY | Reset.DIM | Reset.VIEW))
742 |
743 | def on_help(self):
744 | if not self.html_viewer.clear_if_showing(Name.HELP):
745 | self.html_viewer.show(help.help, Name.HELP)
746 |
747 | def on_help_keys(self):
748 | if not self.html_viewer.clear_if_showing(Name.KEYS):
749 | self.html_viewer.show(help.keys, Name.KEYS)
750 |
751 | def on_hints(self, dataname):
752 | """User has toggled whether hints are to be shown."""
753 | control = self.controls[dataname]
754 | value = bool(control.get())
755 | if not value:
756 | # Special case: when turning off hints, the hint for this control
757 | # wouldn't get hidden by hint_manager because Hints.active==False,
758 | # so force it to be hidden here.
759 | self.hints.show('')
760 | self.data.show_hints = value
761 | self.hints.visible(value)
762 |
763 | def on_key(self, event):
764 | # print(event, 'state=', hex(event.state))
765 | focus = self.focus_get()
766 | # Ignore keystroke when editing a field
767 | if focus == self.aspect or focus == self.viewer_size:
768 | return
769 | # Convert to lower to simplify the keymap for, say, A nd a
770 | lower = event.keysym.lower()
771 | # Simplify the keymap further by forcing all digits to zero
772 | if lower.isdigit():
773 | lower = '0'
774 | # Look for an action and a possible parameter
775 | callback, value = self.key_map.get(lower, (None, None))
776 | # if there is a handler for this keystroke, execute it
777 | if callback:
778 | callback(event.keysym, event.state, value)
779 |
780 | def on_list(self):
781 | if not self.html_viewer.clear_if_showing(Name.ACTIONS):
782 | htm = ""
783 | if self.viewer.actions:
784 | for action in self.viewer.actions:
785 | htm += str(action)
786 | htm += "
"
787 | else:
788 | htm = "There are no actions to list."
789 | self.html_viewer.show(htm, Name.ACTIONS)
790 |
791 | def on_play_end(self, state):
792 | assert state is False
793 | self.set_button_state(IDLE)
794 |
795 | def on_play_video(self):
796 | """Show the last video recorded."""
797 | play_file = None
798 | playing = self.viewer.video_reader is not None
799 | self.viewer.stop = playing
800 | if not playing:
801 | play_file = utils.find_latest_file(self.viewer.output_dir)
802 | if play_file:
803 | self.set_button_state(PLAYING)
804 | self.viewer.video_play(play_file)
805 | else:
806 | self.set_button_state(IDLE)
807 |
808 | def on_prefs(self):
809 | win_x = self.root.winfo_x()
810 | win_y = self.root.winfo_y()
811 | Preferences(self.data, win_x, win_y)
812 |
813 | def on_random(self, direction):
814 | """Rotate the wireframe randomly in 3 dimensions."""
815 |
816 | # Make a list of every dimension. A random dimension is chosen from
817 | # this list and then removed to ensure that a later pick has a
818 | # different value.
819 | dims = list(range(self.data.dims))
820 |
821 | # The first dimension is always X or Y so we can see it on screen
822 | dim1 = random.randint(X, Y)
823 | dims.remove(dim1)
824 |
825 | # If the previous action was also a random rotation, reuse its second
826 | # dimension to reduce the jerkiness unless that is dim1
827 | # otherwise just pick a random one
828 | prev = self.get_previous_action()
829 | if prev and prev.cmd == Cmd.ROTATE and prev.p3 is not None:
830 | dim2 = prev.p2
831 | if dim2 == dim1:
832 | dim2 = random.choice(dims)
833 | else:
834 | dim2 = random.choice(dims)
835 | dims.remove(dim2)
836 |
837 | # pick the 3rd dimension from the remaining ones
838 | dim3 = random.choice(dims)
839 |
840 | # Create and queue the action
841 | action = Action(Cmd.ROTATE, dim1, dim2, dim3, direction)
842 | self.queue_action(action)
843 |
844 | def on_restart(self):
845 | self.stop()
846 | self.queue_action(Action(Cmd.RESET, Reset.DATA))
847 |
848 | def on_rotate(self, direction, dim_control):
849 | """Rotate the wireframe."""
850 | action = Action(Cmd.ROTATE, dim_control.dim1, dim_control.dim2, None, direction)
851 | self.queue_action(action)
852 |
853 | def on_stop(self):
854 | """User has asked for the current and pending actions to be stopped."""
855 | self.stop()
856 |
857 | def on_view_files(self):
858 | """Show the folder where video output is saved."""
859 | os.startfile(self.viewer.output_dir)
860 |
861 | def on_viewer_size(self, _):
862 | """The viewer_size ratios have been changed.
863 |
864 | If they're valid,
865 | if they've changed, save them and rebuild the viewer,
866 | else highlight the edit control in yellow
867 | """
868 | viewer_size = self.viewer_size.get()
869 | if self.data.validate_viewer_size(viewer_size):
870 | self.viewer_size.configure(bg='white')
871 | if viewer_size != self.data.viewer_size:
872 | self.data.viewer_size = viewer_size
873 | self.stop()
874 | self.queue_action(Action(Cmd.RESET, Reset.VIEW))
875 | else:
876 | self.viewer_size.configure(bg='yellow')
877 |
878 | def queue_action(self, action: Action):
879 | """Add this action to the queue awaiting execution."""
880 | # Sometimes (why the inconsistency?) setting the value for a widget
881 | # generates a callback. This doubles up the action during playback,
882 | # so ignore all queue requests during playback.
883 | if self.playback_index < 0:
884 | self.actionQ.append(action)
885 | self.restart_button.state = ENABLED
886 |
887 | def reset(self, flags: Reset):
888 | """Reset the program in various ways.
889 |
890 | It is called:
891 | at initial start
892 | at restart
893 | when the dimensions, aspect or view size has changed
894 | The flags parameter indicates what actions to take.
895 |
896 | NOTE: this must not be called directly except at initial start
897 | because there may be an action in progress that is relying on old
898 | values and will crash. This is weird: flushing actionQ and forcing
899 | stop does not solve the problem. The fix is that reset calls must
900 | be made via the actionQ. In this way, the prior action will have
901 | completed.
902 | """
903 | if flags & Reset.FACTORY:
904 | self.copy_data(Data())
905 | self.load_settings()
906 |
907 | if flags & Reset.DATA:
908 | self.copy_data(self.data_copy, skip=True)
909 | self.load_settings()
910 |
911 | if flags & Reset.DIM:
912 | dim_count = self.data.dims
913 | self.dim_choice.set(str(dim_count))
914 | # create or destroy the dimension controls as appropriate
915 | for dim, control in enumerate(self.dim_controls):
916 | if control.dim2 < dim_count:
917 | if not control.active:
918 | self.dim_controls[dim].add_controls()
919 | control.show_colors(self.data)
920 | else:
921 | if control.active:
922 | self.dim_controls[dim].delete_controls()
923 |
924 | if flags & Reset.VIEW:
925 | x, y = self.data.get_viewer_size()
926 | self.canvas.config(width=x, height=y)
927 |
928 | if flags & Reset.ASPECT:
929 | aspects = self.aspect.get()
930 | self.data.aspects = aspects
931 |
932 | # make a copy of the data for when we replay with visibility
933 | self.data_copy = copy.copy(self.data)
934 | self.viewer.init()
935 | self.viewer.display()
936 |
937 | self.hints.visible(self.data.show_hints)
938 | self.set_record_state(False)
939 | self.set_button_state(CLEAN, force=True)
940 | self.restart_button.state = DISABLED
941 |
942 | # For reasons I do not understand, the controls "ghost" and "angle"
943 | # trigger callbacks when their value is set. Flush the spurious actions.
944 | self.actionQ.clear()
945 |
946 | def run(self):
947 | """Run the actions on the action queue.
948 |
949 | https://stackoverflow.com/questions/18499082/tkinter-only-calls-after-idle-once
950 | """
951 | if self.playback_index >= 0:
952 | # remove any HTML window
953 | self.html_viewer.clear()
954 | # we're playing back the actions previously taken
955 | if self.playback_index < len(self.viewer.actions):
956 | # there are more actions to take
957 | action = self.viewer.actions[self.playback_index]
958 | self.playback_index += 1
959 | act = True
960 | if action.visible:
961 | # only replay visibility actions when asked
962 | if act := self.data.replay_visible:
963 | # change the value in .data
964 | # and the visible state of the control
965 | self.set_data_value(action)
966 | self.set_visible_state(action)
967 | if act:
968 | self.viewer.take_action(action, playback=True)
969 | else:
970 | # we've played back all the actions, so cancel playback
971 | self.playback_index = -1
972 | self.set_button_state(IDLE)
973 |
974 | elif self.actionQ:
975 | # the user has initiated an action like rotate, zoom, etc.
976 | # and it has been placed on a queue. Take it off the queue.
977 | action = self.actionQ[0]
978 | del self.actionQ[0]
979 | # remove any HTML window
980 | self.html_viewer.clear()
981 | if action.cmd == Cmd.PLAYBACK:
982 | # the action is to play back all the actions up until now,
983 | self.playback_index = 0
984 | self.set_button_state(REPLAYING)
985 | if self.data.replay_visible:
986 | # restore most data settings in place at the beginning
987 | self.copy_data(self.data_copy, skip=True)
988 | self.viewer.init(playback=True)
989 | self.viewer.display()
990 | else:
991 | # It's a regular action. Enable the replay button
992 | # (it would have been disabled if the queue were formerly empty)
993 | self.set_button_state(RUNNING)
994 | need_action = True
995 | real_ghost = 0
996 | if action.visible:
997 | # change the value in .data
998 | self.set_data_value(action)
999 | # certain actions need special treatment
1000 | vis = action.p1
1001 | if vis == 'show_faces':
1002 | # If ghosting is on when we hide faces, they don't
1003 | # seem to be hidden because their ghost is still
1004 | # visible, so turn ghosting off and then back on.
1005 | if not self.data.show_faces:
1006 | real_ghost = self.data.ghost
1007 | self.data.ghost = 0
1008 | self.show_colors()
1009 | elif vis == 'depth':
1010 | self.viewer.set_depth()
1011 | elif vis == 'angle':
1012 | self.viewer.set_rotation()
1013 | elif vis == 'auto_scale':
1014 | need_action = False
1015 | elif vis == 'replay_visible':
1016 | need_action = False
1017 | elif vis in ('show_4_gray', 'show_edges'):
1018 | self.show_colors()
1019 |
1020 | if need_action:
1021 | # execute the action
1022 | self.viewer.take_action(action)
1023 | if real_ghost:
1024 | # We turned off ghosting. Turn it back on.
1025 | self.data.ghost = real_ghost
1026 |
1027 | if not self.actionQ:
1028 | # if there are no more actions queued, it makes no sense
1029 | # to offer a "Stop" action, so disable the button
1030 | state = IDLE if self.viewer.actions else CLEAN
1031 | self.set_button_state(state)
1032 | else:
1033 | self.hint_manager()
1034 |
1035 | # wait 10ms, which allows tk UI actions, then check again
1036 | self.root.after(10, self.run)
1037 |
1038 | def set_button_state(self, new_state: int, force: bool=False):
1039 | """Set the new app state and adjust button states to match."""
1040 | combined = self.state * 10 + new_state # useful for debug breakpoints
1041 | if not force and new_state == self.state:
1042 | # print(f'state change {self.state} unchanged')
1043 | return
1044 | button_states = button_states_recording if self.viewer.recording\
1045 | else button_states_normal
1046 | values = button_states[new_state]
1047 | for index, btn_state in enumerate(values):
1048 | self.buttons[index].state = btn_state
1049 | # print(f'state change {self.state} -> {new_state}; buttons={values}; forced={force}')
1050 | self.state = new_state
1051 |
1052 | def set_data_value(self, action: Action):
1053 | assert action.visible
1054 | data_name: str = action.p1
1055 | value = action.p2
1056 |
1057 | # change the data value
1058 | old_data = getattr(self.data, data_name)
1059 | assert type(value) is type(old_data)
1060 | setattr(self.data, data_name, value)
1061 |
1062 | def set_prefs(self, new_data):
1063 | for dataname in self.copy_data(new_data, skip=True):
1064 | self.visibility_action(dataname)
1065 |
1066 | def set_record_state(self, active=None):
1067 | """Set or Xor the record button.
1068 |
1069 | active == None: xor the recording state
1070 | active == boolean: recording [not] wanted
1071 | """
1072 | if active is None:
1073 | active = not self.viewer.recording
1074 | self.viewer.video_record(active)
1075 | self.set_button_state(self.state, force=True)
1076 |
1077 | def set_replay_button(self, state):
1078 | """Set the Replay button as disabled, ready or replaying."""
1079 | self.replay_button.state = state
1080 |
1081 | def set_visible_state(self, action: Action):
1082 | """Set the visible state of the control associated with the action."""
1083 | assert action.visible
1084 | dataname = action.p1
1085 | value = action.p2
1086 |
1087 | # Get the control associated with the data_name (if any; might be
1088 | # adjusted in prefs) and change its state to match the data value.
1089 | control = self.controls.get(dataname, None)
1090 | if control:
1091 | control.set(value)
1092 |
1093 | def stop(self):
1094 | """Stop the current and pending actions."""
1095 | # ask the viewer to stop ASAP
1096 | self.viewer.stop = True
1097 | # discard all actions in the pending queue
1098 | self.actionQ.clear()
1099 | # stop any playback
1100 | self.playback_index = -1
1101 | # adjust button states
1102 | self.set_button_state(IDLE)
1103 |
1104 | def visibility_action(self, dataname):
1105 | """Execute a visibility action."""
1106 | # get the control associated with the data_name and the present value
1107 | control = self.controls.get(dataname, None)
1108 | if control:
1109 | control_value = control.get()
1110 | value = self.data.coerce(control_value, dataname)
1111 | else:
1112 | # Some visibility settings are adjusted in preferences, so they
1113 | # don't have a control in this window; the new value has already
1114 | # been put into self.data by copy_data().
1115 | # Alternatively, the shortcut key for a setting in preferences has
1116 | # been typed and the new value has been set in key_visible().
1117 | value = getattr(self.data, dataname)
1118 | action = Action(Cmd.VISIBLE, dataname, value)
1119 | self.queue_action(action)
1120 |
1121 | def show_colors(self):
1122 | for control in self.dim_controls:
1123 | control.show_colors(self.data)
1124 |
1125 | if __name__ == '__main__':
1126 | parser = argparse.ArgumentParser(description='Hypercube')
1127 | parser.add_argument("-t", "--test", action="store_true", help="run in test mode")
1128 | args = parser.parse_args()
1129 | root = tk.Tk()
1130 | app = App(root, args)
1131 | try:
1132 | # if compiled with pyinstaller, close any flash screen
1133 | import pyi_splash # type: ignore
1134 | pyi_splash.close()
1135 | except:
1136 | pass
1137 | root.mainloop()
1138 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/philmayes/hypercube-viewer/976312df699e624549f080bc6439d75cfdb54f97/tests/__init__.py
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/wireframe.py:
--------------------------------------------------------------------------------
1 | #!/bin/env python
2 |
3 | """
4 | The original code is from
5 | http://www.petercollingridge.co.uk/pygame-3d-graphics-tutorial/using-matrices
6 | See rbaleksandar answer at
7 | https://math.stackexchange.com/questions/363652/understanding-rotation-matrices
8 | Each column of a rotation matrix represents one of the axes of the space it is
9 | applied in. In two dimensions, the standard rotation matrix has the form:
10 | | cos R -sin R |
11 | | sin R cos R |
12 | In higher dimensions, we rotate around a plane, and apply this rotation to the
13 | two axes of the plane.
14 | """
15 |
16 | import copy
17 | import numpy as np
18 |
19 | import colors
20 |
21 |
22 | def add_face_color(face, nodes):
23 | """Add a color to a face instance.
24 |
25 | A face is 4 nodes (points) in a plane. We color all faces in the same
26 | plane, e.g. the opposite faces of a cube, in the same color. To find what
27 | the two dimensions making up that plane are, we compare the node values
28 | of every dimension, looking for the two dimensions where the values vary.
29 | That pair is mapped to a unique color by colors.face()
30 | """
31 | node0 = nodes[face.node[0]]
32 | node1 = nodes[face.node[1]]
33 | node2 = nodes[face.node[2]]
34 | node3 = nodes[face.node[3]]
35 | other = [node1, node2, node3]
36 | # make a list of every dimension whose values are not all the same
37 | changes = []
38 | for dim, value in enumerate(node0):
39 | mismatch = [node[dim] for node in other if node[dim] != value]
40 | if mismatch:
41 | changes.append(dim)
42 | if len(changes) == 2:
43 | # no need to look any further; there are only two dimensions
44 | # that differ
45 | break
46 | face.color = colors.face(changes)
47 |
48 |
49 | class Face:
50 | def __init__(self):
51 | self.node = [0] * 4
52 | self.out = True
53 | self.color = ""
54 |
55 | class Wireframe:
56 | def __init__(self, dims):
57 | self.dims = dims
58 | # create a NumPy array with 0 rows, 1 col per dim, 1 for scale
59 | self.nodes = np.zeros((0, dims + 1))
60 | # List of edges; each edge is [node_index1, node_index2, color]
61 | self.edges = []
62 | # List of Face() instances
63 | self.faces = []
64 |
65 | def add_nodes(self, node_array):
66 | ones_column = np.ones((len(node_array), 1))
67 | ones_added = np.hstack((node_array, ones_column))
68 | self.nodes = np.vstack((self.nodes, ones_added))
69 |
70 | def add_edges(self, edgeList):
71 | self.edges += edgeList
72 |
73 | def add_shape_sizes(self, orgx=50, orgy=50, sizes=[200]):
74 | """Create a shape and set up its nodes and edges."""
75 |
76 | orgs = [0] * self.dims
77 | orgs[0] = orgx
78 | orgs[1] = orgy
79 | # make sure there is a size for each dimension
80 | while len(sizes) < self.dims:
81 | sizes.append(sizes[-1])
82 | center = [0] * self.dims
83 | # calculate the center along each dimension
84 | for d in range(self.dims):
85 | center[d] = int(round(orgs[d] + sizes[d] / 2))
86 |
87 | nodes = []
88 | edges = []
89 | faces = []
90 | # start with a point
91 | nodes.append([])
92 | # extend everything along the axes
93 | for dim in range(self.dims):
94 | edge_color = colors.bgr[dim]
95 | begin = orgs[dim]
96 | end = begin + sizes[dim]
97 | node_count = len(nodes)
98 | # When we extend this shape into the next dimension:
99 | # * node count will be double
100 | # * edge count will be double + number of nodes in previous dimension
101 | # * face count will be double + number of edges in previous dimension
102 | # * (and so on for cubes, tesseracts, etc., but we ignore those)
103 | # and their locations will be different,
104 | # so copy the existing faces, edges and nodes:
105 |
106 | # Make a copy of every existing face
107 | new_faces = copy.deepcopy(faces)
108 | # Each new face will be defined by new nodes (that have not been
109 | # created yet, but that doesn't matter) whose indices follow on
110 | # from the existing ones.
111 | for new_face in new_faces:
112 | new_face.node[0] += node_count
113 | new_face.node[1] += node_count
114 | new_face.node[2] += node_count
115 | new_face.node[3] += node_count
116 | new_face.out ^= True
117 |
118 | # Make a copy of every existing edge
119 | new_edges = copy.deepcopy(edges)
120 | # Adjust their node indices (again, these nodes don't exist yet)
121 | # and create a face for every edge that has been moved.
122 | for new_edge in new_edges:
123 | # A face is 4 nodes; the first two are the ends of the edge before moving it;
124 | # face = [new_edge[0], new_edge[1], 0, 0]
125 | face = Face()
126 | face.node[0] = new_edge[0]
127 | face.node[1] = new_edge[1]
128 | # adjust the location of the new edge
129 | new_edge[0] += node_count
130 | new_edge[1] += node_count
131 | # the second 2 nodes of the face are the ends of the edge after moving it
132 | face.node[2] = new_edge[1]
133 | face.node[3] = new_edge[0]
134 | faces.append(face)
135 |
136 | # Make a copy of every existing node
137 | new_nodes = [None] * node_count
138 | nodes.extend(new_nodes)
139 | for ndx in range(node_count):
140 | old_node = nodes[ndx]
141 | # create a new node
142 | new_node = old_node.copy()
143 | # add the location in this dimension to the existing node
144 | old_node.append(begin)
145 | # add the location in this dimension to the new node
146 | new_node.append(end)
147 | # put the new node on the list
148 | nodes[ndx + node_count] = new_node
149 | # extending this node into the next dimension creates another
150 | # edge, identified by the two node indices
151 | edges.append([ndx, ndx + node_count, edge_color])
152 |
153 | # add these extended objects to the existing ones
154 | faces.extend(new_faces)
155 | edges.extend(new_edges)
156 |
157 | # color the faces
158 | for face in faces:
159 | add_face_color(face, nodes)
160 |
161 | self.add_nodes(nodes)
162 | self.add_edges(edges)
163 | self.faces = faces
164 | self.center = center
165 |
166 | def get_edge_z(self, edge):
167 | """Get 2x the z-value of the midpoint of the edge."""
168 | return self.nodes[edge[0]][2] + self.nodes[edge[1]][2]
169 |
170 | def get_face_z(self, face):
171 | """Get 4x the z-value of the midpoint of the face."""
172 | return (
173 | self.nodes[face.node[0]][2] +
174 | self.nodes[face.node[1]][2] +
175 | self.nodes[face.node[2]][2] +
176 | self.nodes[face.node[3]][2]
177 | )
178 |
179 | def get_rotate_matrix(self, dim1, dim2, radians, a=None):
180 | """Return matrix for rotating about the x-axis by 'radians' radians"""
181 |
182 | if a is None:
183 | a = np.eye(self.dims + 1)
184 | c = np.cos(radians)
185 | s = np.sin(radians)
186 | a[dim1][dim1] = c
187 | a[dim1][dim2] = -s
188 | a[dim2][dim1] = s
189 | a[dim2][dim2] = c
190 | return a
191 |
192 | def get_scale_matrix(self, scale):
193 | """Return matrix for scaling equally along all axes.
194 |
195 | return np.array([[s, 0, 0, 0],
196 | [0, s, 0, 0],
197 | [0, 0, s, 0],
198 | [0, 0, 0, 1]])
199 | """
200 | dims = self.dims
201 | a = np.eye(dims + 1)
202 | for n in range(dims):
203 | a[n][n] = scale
204 | return a
205 |
206 | def get_translation_matrix(self, vector):
207 | """Return matrix for translation along vector (dx, dy, dz).
208 |
209 | return np.array([[1, 0, 0, 0],
210 | [0, 1, 0, 0],
211 | [0, 0, 1, 0],
212 | [v, v, v, 1]])
213 | """
214 |
215 | dims = self.dims
216 | a = np.eye(dims + 1)
217 | a[dims][:dims] = vector
218 | return a
219 |
220 | def output_nodes(self):
221 | print("\n --- Nodes --- ")
222 | for i, node in enumerate(self.nodes):
223 | print(f"{i:3}: ", end="")
224 | for j in node[:-1]:
225 | print(f"{j:>-7}, ", end="")
226 | print()
227 |
228 | def output_edges(self):
229 | print("\n --- Edges --- ")
230 | for i, (node1, node2, color) in enumerate(self.edges):
231 | print("%2d: %2d -> %2d " % (i, node1, node2), color)
232 |
233 | def findCentre(self):
234 | """Unused for now."""
235 | num_nodes = len(self.nodes)
236 | meanX = sum([node.x for node in self.nodes]) / num_nodes
237 | meanY = sum([node.y for node in self.nodes]) / num_nodes
238 | meanZ = sum([node.z for node in self.nodes]) / num_nodes
239 |
240 | return (meanX, meanY, meanZ)
241 |
242 | def sort_edges(self):
243 | """Sort the edges so that the furthest away is first in the list,
244 | and hence gets drawn first and is then overlaid by nearer ones.
245 | "Furthest away" means furthest on the z-axis, and we use the
246 | midpoint of the edge for this (except there's no need to divide
247 | by 2 for each edge)
248 | """
249 |
250 | self.edges.sort(key=self.get_edge_z, reverse=True)
251 |
252 | def sort_faces(self):
253 | """See explanation for sort_edges()."""
254 |
255 | self.faces.sort(key=self.get_face_z, reverse=True)
256 |
257 | def transform(self, matrix):
258 | """Apply a transformation defined by a given matrix."""
259 |
260 | self.nodes = np.dot(self.nodes, matrix)
261 |
--------------------------------------------------------------------------------