-
Notifications
You must be signed in to change notification settings - Fork 13
/
Copy pathmain.py
301 lines (249 loc) · 10.4 KB
/
main.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
# -*- coding: utf-8 -*-
"""
Cloth simulation with custom physics implementation:
Force of gravity, air resistance, elastic restoration force
Controls:
Pick up and move a node by dragging it with the mouse
Press A while dragging a node to toggle its affixed status
Press S while dragging a node to snip its vertical connection
Press Q to decrease wind force / increase wind to the left
Press W to increase wind force / increase wind to the right
Press R to respawn cloth nodes
Press D to toggle debug render mode
Press Escape to exit
"""
# pylint: disable=no-member
# pylint: disable=invalid-name
# pylint: disable=c-extension-no-member
from __future__ import annotations
import math
from dataclasses import dataclass
from typing import List, Optional, Tuple
import pygame
POLYGON_COLOUR = (50, 50, 50)
LINE_COLOUR = (255, 255, 255)
FPS = 500
TICK = 0.01
MIN_MASS = 0.01
NODE_RADIUS = 3
NODE_MASS = 400
GRAVITY = 10_000
MIN_FORCE_THRESHOLD = 0
AIR_FRICTION_COEFFICIENT = 500
ELASTICITY = 1_000_000
Colour = Tuple[int, int, int]
def calculate_acceleration(force: float, speed: float, mass: float) -> float:
"""
Calculates acceleration from the specified force and mass,
factoring in air resistance drag force calculated from the specified (current) speed.
"""
air_resistance = min(abs(force), calculate_air_resistance_force(speed))
drag_direction = 1 if speed > 0 else 0
total_force = force - air_resistance * drag_direction
if abs(total_force) < MIN_FORCE_THRESHOLD:
total_force = 0
return total_force / max(mass, MIN_MASS)
def calculate_air_resistance_force(speed: float) -> float:
"""
Returns the air resistance (drag force) based on the specified speed
Note: this equation is a simplification of the actual equation:
F = (1/2) p v² C A, representing (1/2) p C A as AIR_FRICTION_COEFFICIENT
source: https://en.wikipedia.org/wiki/Drag_(physics)#The_drag_equation
"""
return AIR_FRICTION_COEFFICIENT * speed**2
@dataclass
class NodeConnection:
"""Encapsulates a node between two nodes to handle elastic restoration force physics"""
start: Node
end: Node
elasticity: float = 0
def set_elastic_restoring_forces(self) -> None:
"""Calculates symmetric elastic forces in x and y directions using Hook's law
F = k x where k is the elastic constant (here the elasticity of the connection)
and x the displacement from rest position.
The calculated force is split evenly between both nodes of the connection.
"""
self._set_elastic_restoring_y_forces()
self._set_elastic_restoring_x_forces()
def _set_elastic_restoring_y_forces(self) -> None:
length = self.start.y - self.end.y
force = length * self.elasticity / 2
self.end.elastic_restoring_y_force += force
self.start.elastic_restoring_y_force -= force
def _set_elastic_restoring_x_forces(self) -> None:
length = self.start.x - self.end.x
force = length * self.elasticity / 2
self.end.elastic_restoring_x_force += force
self.start.elastic_restoring_x_force -= force
@dataclass
class Node:
"""Node class with custom physics implementation"""
mass: float
x: float = 0
y: float = 0
speed_x: float = 0
speed_y: float = 0
previous_node_y_connection: Optional[NodeConnection] = None
previous_node_x_connection: Optional[NodeConnection] = None
affixed: bool = False
draw_debug = True
def __post_init__(self):
self.elastic_restoring_y_force: float = 0
self.elastic_restoring_x_force: float = 0
def reset(self):
"""Resets the node's elastic forces"""
self.elastic_restoring_y_force = 0
self.elastic_restoring_x_force = 0
def update(self):
"""Updates the node force, speed and position"""
if self.affixed:
return
self.speed_y += self._calculate_vertical_acceleration() * TICK
self.speed_x += self._calculate_horizontal_acceleration() * TICK
self.y += self.speed_y * TICK
self.x += self.speed_x * TICK
def render(self, screen: pygame.surface.Surface) -> None:
"""Renders the node using a four-sided polygon"""
if self.affixed and self.draw_debug:
pygame.draw.circle(screen, (255, 0, 0), self.position, NODE_RADIUS)
if not self.previous_node_x_connection or not self.previous_node_y_connection:
return
conn = self.previous_node_x_connection.start.previous_node_y_connection
if not conn:
return
points = [
self.previous_node_x_connection.start.position,
self.position,
self.previous_node_y_connection.start.position,
conn.start.position,
]
polygon_colour = self._determine_polygon_colour(points)
pygame.draw.polygon(screen, polygon_colour, points)
# polygon outline
if self.draw_debug:
pygame.draw.polygon(screen, LINE_COLOUR, points, width=1)
def _determine_polygon_colour(self, points: List[Tuple[float, float]]) -> Colour:
if self.draw_debug:
return POLYGON_COLOUR
# using area of trapezium as area approximation
# this assumes that vertical sides are more likely to be parallel due to gravity.
y1, y3, y4, y2 = [point[1] for point in points]
height = 0.5 * ((y1 - y2) + (y3 - y4))
area = abs((self.x - self.previous_node_x_connection.start.x) * height) # type: ignore
max_area = 2000
area_colour_scaling = 11
blue = min(255, min(area, max_area) / area_colour_scaling)
green = min(255, int(blue) + 50)
polygon_colour = (0, green, int(blue))
return polygon_colour
def _calculate_vertical_acceleration(self) -> float:
"""
Calculates the force of gravity using Newton's second law of motion:
F = m a, where m is the node mass and a the acceleration due to gravity.
"""
force = self.mass * GRAVITY + self.elastic_restoring_y_force
return calculate_acceleration(force, self.speed_y, self.mass)
def _calculate_horizontal_acceleration(self) -> float:
force = self.elastic_restoring_x_force
return calculate_acceleration(force, self.speed_x, self.mass)
def set_elastic_restoring_force(self) -> None:
"""
Sets vertical and horizontal elastic forces.
Comment out horizontal forces for the initial (also cool-looking) simulation
"""
if self.previous_node_y_connection is not None:
self.previous_node_y_connection.set_elastic_restoring_forces()
if self.previous_node_x_connection is not None:
self.previous_node_x_connection.set_elastic_restoring_forces()
@property
def position(self):
"""The position of the node"""
return (self.x, self.y)
def connect_nodes(nodes: List[Node], elasticity: float) -> None:
"""Connects all specified nodes into"""
if len(nodes) < 2:
return
nodes[0].affixed = True
for previous_node, node in zip(nodes, nodes[1:]):
node.previous_node_y_connection = NodeConnection(
start=previous_node, end=node, elasticity=elasticity
)
def connect_ropes(ropes: List[List[Node]], elasticity: float) -> None:
"""Connects each "rope" (vertically-connected nodes) horizontally to form a connected grid"""
if len(ropes) < 2:
return
for nodes in zip(*ropes):
node: Node
for previous_node, node in zip(nodes, nodes[1:]):
node.previous_node_x_connection = NodeConnection(
start=previous_node, end=node, elasticity=elasticity
)
def update_nodes(nodes: List[Node], wind: float) -> None:
"""Updates node physics by calculating node forces, speeds and positions"""
for node in nodes:
node.reset()
for node in nodes:
node.elastic_restoring_x_force = wind
for node in nodes:
node.set_elastic_restoring_force()
for node in nodes:
node.update()
def init_cloth_nodes() -> List[Node]:
"""Initializes a grid of interconnected nodes to simulate cloth, then returns all nodes"""
ropes: List[List[Node]] = []
for x in range(200, 600, 25):
nodes = [Node(x=x, y=50, mass=NODE_MASS) for _ in range(10)]
connect_nodes(nodes, elasticity=ELASTICITY)
ropes.append(nodes)
connect_ropes(ropes, elasticity=ELASTICITY / 2)
return [node for rope in ropes for node in rope]
def main():
"""Main function"""
pygame.init()
screen = pygame.display.set_mode((800, 600))
clock = pygame.time.Clock()
nodes = init_cloth_nodes()
font = pygame.font.SysFont("Arial", 20)
wind: float = 0
terminated = False
selected_node: Optional[Node] = None
while not terminated:
for event in pygame.event.get():
if event.type == pygame.QUIT:
terminated = True
elif event.type == pygame.MOUSEBUTTONDOWN:
selected_node = min(
nodes, key=lambda x: math.dist(pygame.mouse.get_pos(), x.position)
)
elif event.type == pygame.MOUSEBUTTONUP:
selected_node = None
elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE:
terminated = True
if event.key == pygame.K_r:
nodes = init_cloth_nodes()
elif event.key == pygame.K_q:
wind -= 1_000_000
elif event.key == pygame.K_w:
wind += 1_000_000
elif event.key == pygame.K_a and selected_node:
selected_node.affixed = not selected_node.affixed
selected_node = None
elif event.key == pygame.K_s and selected_node:
selected_node.previous_node_y_connection = None
elif event.key == pygame.K_d:
Node.draw_debug = not Node.draw_debug
if selected_node is not None:
selected_node.x, selected_node.y = pygame.mouse.get_pos()
update_nodes(nodes, wind)
screen.fill((0, 0, 0))
for node in nodes:
node.render(screen)
screen.blit(font.render(f"Wind: {wind//1000}k", True, LINE_COLOUR), (700, 50))
screen.blit(
font.render(f"Debug: {Node.draw_debug}", True, LINE_COLOUR), (700, 75)
)
pygame.display.flip()
clock.tick(FPS)
if __name__ == "__main__":
main()