├── .github
└── workflows
│ └── lint.yaml
├── src
└── phyterminal
│ ├── __init__.py
│ ├── keys.py
│ ├── shape.py
│ └── main.py
├── requirments.txt
├── dev-requirements.txt
├── .gitignore
├── README.md
├── .pre-commit-config.yaml
├── LICENSE
└── tox.ini
/.github/workflows/lint.yaml:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/phyterminal/__init__.py:
--------------------------------------------------------------------------------
1 | from .main import Renderer
--------------------------------------------------------------------------------
/requirments.txt:
--------------------------------------------------------------------------------
1 | pymunk==6.0.0
2 | cffi==1.14.5
3 | numpy==1.21.0
4 | windows-curses==2.2.0; sys.platform == 'win32'
5 |
--------------------------------------------------------------------------------
/dev-requirements.txt:
--------------------------------------------------------------------------------
1 | #code formatters and checkers
2 | flake8~=3.7
3 | isort~=5.9
4 | pre-commit~=2.13.0
5 | flake8-annotations~=2.0
6 | flake8-bandit~=2.1
7 | flake8-docstrings~=1.5
8 | flake8-isort~=4.0
9 |
10 | # basic requirements to run the project
11 | pymunk==6.0.0
12 | cffi==1.14.5
13 | numpy==1.21.0
14 | windows-curses==2.2.0; sys.platform == 'win32'
15 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 |
2 | # Files generated by the interpreter
3 | __pycache__/
4 | *.py[cod]
5 |
6 | # Environment specific
7 | .venv
8 | venv
9 | .env
10 | env
11 |
12 | # Unittest reports
13 | .coverage*
14 |
15 | # Logs
16 | *.log
17 |
18 | # PyEnv version selector
19 | .python-version
20 |
21 | # Built objects
22 | *.so
23 | dist/
24 | build/
25 |
26 | # IDEs
27 | # PyCharm
28 | .idea/
29 | # VSCode
30 | .vscode/
31 | # MacOS
32 | .DS_Stor
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Phyterminal
2 | Phyterminal is a 2D-physics renderer for terminal, which uses [Pymunk](https://github.com/viblo/pymunk) as its physics engine. Currently work in progress, but could be used in Windows, MacOS and Linux.
3 |
4 | ## Developer Logs
5 | ### Update 1: Boxes and Segments
6 | [](http://www.youtube.com/watch?v=igII7gpNs4I)
7 |
8 | ### Update 2: Circles
9 | [](http://www.youtube.com/watch?v=aXxD_0Oplyw)
10 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | repos:
2 | - repo: https://github.com/pre-commit/pre-commit-hooks
3 | rev: v2.5.0
4 | hooks:
5 | - id: check-toml
6 | - id: check-yaml
7 | - id: end-of-file-fixer
8 | - id: trailing-whitespace
9 | args: [--markdown-linebreak-ext=md]
10 |
11 | - repo: https://github.com/pre-commit/pygrep-hooks
12 | rev: v1.5.1
13 | hooks:
14 | - id: python-check-blanket-noqa
15 |
16 | - repo: https://github.com/pre-commit/mirrors-isort
17 | rev: v5.9.2
18 | hooks:
19 | - id: isort
20 |
21 | - repo: https://github.com/pycqa/flake8
22 | rev: 3.9.2
23 | hooks:
24 | - id: flake8
25 | additional_dependencies:
26 | - flake8-annotations~=2.0
27 | - flake8-bandit~=2.1
28 | - flake8-docstrings~=1.5
29 | - flake8-isort~=4.0
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 StoneSteel27
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.
22 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | # Flake8 and ISort configuration
2 |
3 | [flake8]
4 | # Increase the line length. This breaks PEP8 but it is way easier to work with.
5 | # The original reason for this limit was a standard vim terminal is only 79 characters,
6 | # but this doesn't really apply anymore.
7 | max-line-length=119
8 | # Don't lint the venv or the CPython cache.
9 | exclude=.venv,__pycache__
10 | # Ignore some of the most obnoxious linting errors.
11 | ignore=
12 | B311,W503,E226,S311,T000
13 | # Missing Docstrings
14 | D100,D104,D105,D106,D107,
15 | # Docstring Whitespace
16 | D203,D212,D214,D215,
17 | # Docstring Quotes
18 | D301,D302,
19 | # Docstring Content
20 | D400,D401,D402,D404,D405,D406,D407,D408,D409,D410,D411,D412,D413,D414,D416,D417,
21 | # Comments
22 | E266,
23 | # Type Annotations
24 | ANN002,ANN003,ANN101,ANN102,ANN204,ANN206
25 |
26 | [isort]
27 | # Select the 5th style (Hanging grid grouped) to handle longer import.
28 | # This choice is mostly arbitrary and can be changed at your will.
29 | #
30 | # Example of this style:
31 | # from third_party import (
32 | # lib1, lib2, lib3, lib4,
33 | # lib5, ...
34 | # )
35 | multi_line_output=5
--------------------------------------------------------------------------------
/src/phyterminal/keys.py:
--------------------------------------------------------------------------------
1 | '''
2 | A Python class implementing KBHIT, the standard keyboard-interrupt poller.
3 | Works transparently on Windows and Posix (Linux, Mac OS X). Doesn't work
4 | with IDLE.
5 |
6 | This program is free software: you can redistribute it and/or modify
7 | it under the terms of the GNU Lesser General Public License as
8 | published by the Free Software Foundation, either version 3 of the
9 | License, or (at your option) any later version.
10 |
11 | This program is distributed in the hope that it will be useful,
12 | but WITHOUT ANY WARRANTY; without even the implied warranty of
13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 | GNU General Public License for more details.
15 |
16 | '''
17 |
18 | import os
19 |
20 | # Windows
21 | if os.name == 'nt':
22 | import msvcrt
23 |
24 | # Posix (Linux, OS X)
25 | else:
26 | import sys
27 | import termios
28 | import atexit
29 | from select import select
30 |
31 |
32 | class KBHit:
33 |
34 | def __init__(self):
35 | '''Creates a KBHit object that you can call to do various keyboard things.
36 | '''
37 |
38 | if os.name == 'nt':
39 | pass
40 |
41 | else:
42 |
43 | # Save the terminal settings
44 | self.fd = sys.stdin.fileno()
45 | self.new_term = termios.tcgetattr(self.fd)
46 | self.old_term = termios.tcgetattr(self.fd)
47 |
48 | # New terminal setting unbuffered
49 | self.new_term[3] = (self.new_term[3] & ~termios.ICANON & ~termios.ECHO)
50 | termios.tcsetattr(self.fd, termios.TCSAFLUSH, self.new_term)
51 |
52 | # Support normal-terminal reset at exit
53 | atexit.register(self.set_normal_term)
54 |
55 |
56 | def set_normal_term(self):
57 | ''' Resets to normal terminal. On Windows this is a no-op.
58 | '''
59 |
60 | if os.name == 'nt':
61 | pass
62 |
63 | else:
64 | termios.tcsetattr(self.fd, termios.TCSAFLUSH, self.old_term)
65 |
66 |
67 | def getch(self):
68 | ''' Returns a keyboard character after kbhit() has been called.
69 | Should not be called in the same program as getarrow().
70 | '''
71 |
72 | s = ''
73 |
74 | if os.name == 'nt':
75 | return msvcrt.getch().decode('utf-8')
76 |
77 | else:
78 | return sys.stdin.read(1)
79 |
80 |
81 | def getarrow(self):
82 | ''' Returns an arrow-key code after kbhit() has been called. Codes are
83 | 0 : up
84 | 1 : right
85 | 2 : down
86 | 3 : left
87 | Should not be called in the same program as getch().
88 | '''
89 |
90 | if os.name == 'nt':
91 | msvcrt.getch() # skip 0xE0
92 | c = msvcrt.getch()
93 | vals = [72, 77, 80, 75]
94 |
95 | else:
96 | c = sys.stdin.read(3)[2]
97 | vals = [65, 67, 66, 68]
98 |
99 | return vals.index(ord(c.decode('utf-8')))
100 |
101 |
102 | def kbhit(self):
103 | ''' Returns True if keyboard character was hit, False otherwise.
104 | '''
105 | if os.name == 'nt':
106 | return msvcrt.kbhit()
107 |
108 | else:
109 | dr,dw,de = select([sys.stdin], [], [], 0)
110 | return dr != []
111 |
112 |
113 | # Test
114 | if __name__ == "__main__":
115 |
116 | kb = KBHit()
117 |
118 | print('Hit any key, or ESC to exit')
119 |
120 | while True:
121 |
122 | if kb.kbhit():
123 | c = kb.getch()
124 | if ord(c) == 27: # ESC
125 | break
126 | print(c)
127 |
128 | kb.set_normal_term()
129 |
--------------------------------------------------------------------------------
/src/phyterminal/shape.py:
--------------------------------------------------------------------------------
1 | import math
2 | from typing import Callable
3 |
4 |
5 | class Shape:
6 | """The main Shape drawing class"""
7 |
8 | def __init__(self, pixel_drawing_func: Callable):
9 | self.pixel_drawing_func = pixel_drawing_func
10 |
11 | def polygon(self, coords: list[int, int]) -> None:
12 | """
13 | Draws a closed polygon using the given pixel_drawing_func
14 |
15 | :param coords: list containing coordinates of the corners of polygon
16 | """
17 | prev = ""
18 | for i, n in enumerate(coords):
19 | if i == 0:
20 | prev = n
21 | continue
22 | self.line((prev[0], prev[1]), (n[0], n[1]))
23 | prev = n
24 | else:
25 | self.line((coords[0][0], coords[0][1]), (prev[0], prev[1]))
26 |
27 | def line(self, point1: list[int, int], point2: list[int, int]) -> None:
28 | """Draws a line using the given pixel_drawing_func"""
29 | x1, y1 = point1
30 | x2, y2 = point2
31 | x, y = x1, y1
32 | length = abs((x2 - x1)) if abs((x2 - x1)) > abs((y2 - y1)) else abs((y2 - y1))
33 | if length == 0:
34 | self.pixel_drawing_func(x1, y1)
35 | dx = (x2 - x1) / float(length)
36 | dy = (y2 - y1) / float(length)
37 |
38 | self.pixel_drawing_func(int(x + 0.5), int(y + 0.5))
39 | for i in range(length):
40 | x += dx
41 | y += dy
42 | self.pixel_drawing_func(int(x + 0.5), int(y + 0.5))
43 |
44 | def ellipse(self, rx: int, ry: int, xc: int, yc: int) -> None:
45 | """
46 | Draws a ellipse using the given pixel_drawing_func
47 |
48 | :param rx: width of the ellipse in x axis
49 | :param ry: width of the ellipse in y axis
50 | :param xc: x coordinate of the center of ellipse
51 | :param yc: y coordinate of the center of ellipse
52 | """
53 | x = 0
54 | y = ry
55 |
56 | # Initial decision parameter of region 1
57 | d1 = (ry * ry) - (rx * rx * ry) + (0.25 * rx * rx)
58 | dx = 2 * ry * ry * x
59 | dy = 2 * rx * rx * y
60 |
61 | # For region 1
62 | while dx < dy:
63 |
64 | # Print points based on 4-way symmetry
65 | self.pixel_drawing_func(x + xc, y + yc)
66 | self.pixel_drawing_func(-x + xc, y + yc)
67 | self.pixel_drawing_func(x + xc, -y + yc)
68 | self.pixel_drawing_func(-x + xc, -y + yc)
69 |
70 | # Checking and updating value of
71 | # decision parameter based on algorithm
72 | if d1 < 0:
73 | x += 1
74 | dx = dx + (2 * ry * ry)
75 | d1 = d1 + dx + (ry * ry)
76 | else:
77 | x += 1
78 | y -= 1
79 | dx = dx + (2 * ry * ry)
80 | dy = dy - (2 * rx * rx)
81 | d1 = d1 + dx - dy + (ry * ry)
82 | # Decision parameter of region 2
83 | d2 = (
84 | ((ry * ry) * ((x + 0.5) * (x + 0.5)))
85 | + ((rx * rx) * ((y - 1) * (y - 1)))
86 | - (rx * rx * ry * ry)
87 | )
88 |
89 | # Plotting points of region 2
90 | while y >= 0:
91 |
92 | # printing points based on 4-way symmetry
93 | self.pixel_drawing_func(x + xc, y + yc)
94 | self.pixel_drawing_func(-x + xc, y + yc)
95 | self.pixel_drawing_func(x + xc, -y + yc)
96 | self.pixel_drawing_func(-x + xc, -y + yc)
97 |
98 | # Checking and updating parameter
99 | # value based on algorithm
100 | if d2 > 0:
101 | y -= 1
102 | dy = dy - (2 * rx * rx)
103 | d2 = d2 + (rx * rx) - dy
104 | else:
105 | y -= 1
106 | x += 1
107 | dx = dx + (2 * ry * ry)
108 | dy = dy - (2 * rx * rx)
109 | d2 = d2 + dx - dy + (rx * rx)
110 |
111 | def circle(self, x: int, y: int, radius: int, theta: float = 0) -> None:
112 | """
113 | Draws a ellipse using the given pixel_drawing_func
114 |
115 | :param x: x coordinate of the center of circle
116 | :param y: y coordinate of the center of circle
117 | :param radius: radius of the circle
118 | :param theta: angle of rotation of circle, defaults to 0
119 | """
120 | radius = round(radius)
121 | self.ellipse(radius * 2, radius, x, y)
122 |
123 | # a rotating line to produce rotating effect
124 | center_x, center_y = x, y
125 | end_x, end_y = x + round(radius * 2 * math.sin(-theta)), y - round(
126 | radius * math.cos(-theta)
127 | )
128 | self.line([center_x, center_y], [end_x, end_y])
129 |
130 | # gap in the circle drawing to make rotating effect
131 | gap_x, gap_y = x + round(
132 | radius * 2 * math.sin(-(theta + math.radians(90)))
133 | ), y - round(radius * math.cos(-(theta + math.radians(90))))
134 | self.pixel_drawing_func(gap_x, gap_y, "")
135 | gap_x, gap_y = x + round(
136 | radius * 2 * math.sin(-(theta - math.radians(120)))
137 | ), y - round(radius * math.cos(-(theta - math.radians(120))))
138 | self.pixel_drawing_func(gap_x, gap_y, "")
139 |
--------------------------------------------------------------------------------
/src/phyterminal/main.py:
--------------------------------------------------------------------------------
1 | import curses
2 | import threading
3 | from typing import Any, Union
4 |
5 | import numpy as np
6 | import pymunk
7 | from keys import KBHit
8 | from shape import Shape
9 |
10 |
11 | def vertices(obj: Any) -> Union[list[pymunk.Vec2d], bool]:
12 | """
13 | Returns vertices of a polygon or a segment
14 |
15 | Returns the vertices of the object passed into the function,
16 | if the object is a pymunk.shapes.Poly or pymunk.Segment objects
17 |
18 | :param obj: object of pymunk.shapes.Poly or pymunk.Segment
19 | :return: list of coordinates, if found and object is vaild, else False
20 | """
21 | if isinstance(obj, pymunk.shapes.Poly):
22 | return [
23 | p.rotated(obj.body.angle) + obj.body.position for p in obj.get_vertices()
24 | ]
25 | elif isinstance(obj, pymunk.Segment):
26 | return [
27 | obj.body.position + obj.a.rotated(obj.body.angle),
28 | obj.body.position + obj.b.rotated(obj.body.angle),
29 | ]
30 | elif isinstance(obj, pymunk.Circle):
31 | return [obj.body.position, obj.body.angle, obj.radius]
32 | else:
33 | return False
34 |
35 |
36 | class Renderer:
37 | """The main rendering class for phyterminal"""
38 |
39 | def __init__(
40 | self, space: pymunk.space, meters_per_pixel: float, threaded_world: bool = False
41 | ):
42 | self.shape = Shape(pixel_drawing_func=self.set_world)
43 | self.meters_per_pixel = meters_per_pixel
44 | self._world = np.array([[""] * 1000] * 1000)
45 | self.space = space
46 | self.kb = KBHit()
47 | self.screen = curses.initscr()
48 | self.height = curses.LINES - 1
49 | self.width = curses.COLS - 1
50 | self.threaded_world = threaded_world
51 |
52 | # self.screen.timeout(0)
53 |
54 | def set_world(self, x: int, y: int, value: str = "█") -> None:
55 | """
56 | Sets the index of given index to the value
57 |
58 | :param x y: index to be changed to the given value in world numpy array
59 | :param value: the value to be set in the world numpy array
60 | """
61 | try:
62 | self._world[y, x] = value
63 | except IndexError:
64 | pass
65 |
66 | def body_frame_coords(self) -> None:
67 | """Calculating and Drawing objects on the screen from coordinates"""
68 | for i in self.space.shapes:
69 | if (
70 | (vertices1 := vertices(i))
71 | and (isinstance(i, pymunk.shapes.Poly))
72 | and getattr(i, "visible", True)
73 | ):
74 | # print(vertices1)
75 | vert = vertices1
76 | vert = [v / self.meters_per_pixel for v in vert]
77 | vert = [(int(v[0] * 2), self.height - int(v[1])) for v in vert]
78 | self.shape.polygon(vert)
79 | elif (
80 | (vertices1 := vertices(i))
81 | and (isinstance(i, pymunk.Segment))
82 | and getattr(i, "visible", True)
83 | ):
84 | vert = vertices1
85 | vert = [v / self.meters_per_pixel for v in vert]
86 | vert = [(int(v[0] * 2), self.height - int(v[1])) for v in vert]
87 | self.shape.line((vert[0][0], vert[0][1]), (vert[1][0], vert[1][1]))
88 | elif (
89 | (vertices1 := vertices(i))
90 | and (isinstance(i, pymunk.Circle))
91 | and getattr(i, "visible", True)
92 | ):
93 | vert = vertices1[0]
94 | vert = [v / self.meters_per_pixel for v in vert]
95 | vert = [(int(vert[0] * 2), self.height - int(vert[1]))]
96 | # print(len(vertices1),len(vert))
97 | # print(vert[0][0],vert[0][1],vertices1[2]/self.meters_per_pixel,vertices1[1])
98 | self.shape.circle(
99 | vert[0][0],
100 | vert[0][1],
101 | vertices1[2] / self.meters_per_pixel,
102 | vertices1[1],
103 | )
104 |
105 | def run_world(self) -> None:
106 | """
107 | Runs the simulation
108 |
109 | Starts the physics simulations, and the rendering of the physics objects.
110 | Can be in threaded mode for interactions with the simulation
111 | """
112 |
113 | def threaded(): # noqa: ANN201
114 | while True:
115 | if self.kb.kbhit():
116 | self.c = self.kb.getch()
117 | if ord(self.c) == 27:
118 | self.stop = True # ESC
119 | curses.endwin()
120 | break
121 | self.body_frame_coords()
122 | ys, xs = np.where(
123 | self._world[: (curses.LINES - 1), : (curses.COLS - 1)] == "█"
124 | )
125 | # print((curses.LINES - 1),(curses.COLS - 1))
126 | for y, x in zip(ys, xs):
127 | self.screen.addch(y, x, "█")
128 | self.set_world(x, y, "")
129 | # self._world = np.array([['']*1000]*1000)
130 | # key = self.screen.getch()
131 | # self.stringer(0,0,'mx, my = %i,%i,%i \r'%(mx,my,b))
132 | # self.space.bodies
133 | self.screen.refresh()
134 | self.space.step(1 / 45)
135 | self.screen.clear()
136 |
137 | if self.threaded_world:
138 | threading.Thread(target=threaded).start()
139 | else:
140 | threaded()
141 |
142 |
143 | if __name__ == "__main__":
144 | space = pymunk.Space()
145 | space.gravity = 0, -20
146 | space.damping = 0.9
147 |
148 | def create_box(mass, pos_x, pos_y, lenght, breath): # noqa: ANN001,ANN201
149 | """Just creates some boxes"""
150 | body1 = pymunk.Body(mass, 1)
151 | body1.position = pos_x, pos_y
152 | poly = pymunk.Poly.create_box(body1, size=(lenght, breath))
153 | poly.elasticity = 0.3
154 | poly.friction = 0.8
155 | space.add(body1, poly)
156 |
157 | def create_circ(mass, pos_x, pos_y, radius): # noqa: ANN001,ANN201
158 | """Just creates some circles"""
159 | body1 = pymunk.Body(mass, 1)
160 | body1.position = pos_x, pos_y
161 | poly = pymunk.Circle(body1, radius=radius)
162 | poly.elasticity = 1
163 | poly.friction = 0.8
164 | space.add(body1, poly)
165 | return body1
166 |
167 | ### ground
168 | # shape = pymunk.Segment(space.static_body, (5, 10), (595, 10), 1.0)
169 | # shape.friction = 1.0
170 | # shape.elasiticty = 0
171 | # space.add(shape)
172 |
173 | for i in range(8):
174 | for j in range(8 - (i)):
175 | create_box(0.1, 40 + 10 * i, 10 * (5 + j) - 35, 10, 10)
176 | c = create_circ(100, 250, 30, 10)
177 | c.velocity = (-155, -30)
178 | c.angular_velocity = -2000000
179 | b0 = space.static_body
180 | segment = pymunk.Segment(b0, (-120, 10), (540, 10), 1)
181 | segment.elasticity = 1
182 | segment.friction = 1.0
183 | space.add(segment)
184 | segment = pymunk.Segment(b0, (-0, 10), (-0, 200), 1)
185 | segment.elasticity = 1
186 | segment.friction = 1.0
187 | space.add(segment)
188 |
189 | a = Renderer(space, 1.5, threaded_world=False)
190 | # threading.Thread(target=a.mouser).start()
191 | a.run_world()
192 |
--------------------------------------------------------------------------------