├── .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 | [![Phyterminal update 1](https://img.youtube.com/vi/igII7gpNs4I/0.jpg)](http://www.youtube.com/watch?v=igII7gpNs4I) 7 | 8 | ### Update 2: Circles 9 | [![Phyterminal update 2](https://img.youtube.com/vi/aXxD_0Oplyw/0.jpg)](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 | --------------------------------------------------------------------------------