├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.txt ├── MANIFEST ├── README.md ├── TODO.md ├── examples ├── catapult.py ├── collision_handling.py ├── complex_shapes.py ├── drawing.py ├── gears.py ├── hello_world.py ├── lots_of_shapes.py ├── motors.py ├── observer_functions.py ├── pin_joints.py ├── pinball.py ├── pivot_joints.py ├── platform_game.py ├── rotary_spring.py ├── shape_misc.py ├── slip_motor.py ├── spring.py └── volcano.py ├── py2d ├── Bezier.py ├── FOV.py ├── FOVConverter.py ├── Math │ ├── Operations.py │ ├── Polygon.py │ ├── Transform.py │ ├── Vector.py │ └── __init__.py ├── Navigation.py ├── SVG.py ├── __init__.py └── test │ ├── Test_Math.py │ └── __init__.py ├── pyphysicssandbox ├── __init__.py ├── ball_shape.py ├── base_shape.py ├── box_shape.py ├── gear_joint.py ├── line_segment.py ├── motor_joint.py ├── pin_joint.py ├── pivot_joint.py ├── poly_shape.py ├── rotary_spring.py ├── slip_motor.py ├── spring_joint.py ├── text_shape.py └── util.py ├── setup.cfg └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Python template 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | env/ 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *,cover 48 | .hypothesis/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | 58 | # Flask stuff: 59 | instance/ 60 | .webassets-cache 61 | 62 | # Scrapy stuff: 63 | .scrapy 64 | 65 | # Sphinx documentation 66 | docs/_build/ 67 | 68 | # PyBuilder 69 | target/ 70 | 71 | # IPython Notebook 72 | .ipynb_checkpoints 73 | 74 | # pyenv 75 | .python-version 76 | 77 | # celery beat schedule file 78 | celerybeat-schedule 79 | 80 | # dotenv 81 | .env 82 | 83 | # virtualenv 84 | venv/ 85 | ENV/ 86 | 87 | # Spyder project settings 88 | .spyderproject 89 | 90 | # Rope project settings 91 | .ropeproject 92 | 93 | .gitignore 94 | .idea/ 95 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | All notable changes to this project will be documented in this file. 3 | 4 | ## [1.4.4] - 2023-04-13 5 | ### Added 6 | 7 | * Can now set shape x and y directly, not just through position (e.g. shape.x = 10) 8 | 9 | ### Changed 10 | 11 | * Elasticity may now be an int 12 | * Setting velocity should now work 13 | * Setting the position of cosmetic shapes works correctly 14 | * Static shapes now respect their rotation and movement for collisions 15 | 16 | ## [1.4.0] - 2017-01-12 17 | ### Added 18 | 19 | * Lots of shapes example 20 | * Drawing example 21 | * Function for changing default color of shapes 22 | * Ability to set constant velocity for shapes 23 | 24 | ### Changed 25 | 26 | * Improved handling of shapes that start outside the simulation 27 | * Documented set_margins 28 | * Sandbox now calculates larger margins if shapes start outside of those already set 29 | * Fixed per shape custom gravity 30 | 31 | ## [1.3.3] - 2016-12-24 32 | ### Added 33 | 34 | * Shape methods and properties example 35 | 36 | ### Changed 37 | 38 | * Text now properly draws in color 39 | * Text now properly draws at an angle 40 | 41 | ## [1.3.2] - 2016-12-20 42 | ### Added 43 | 44 | * Added many examples 45 | 46 | ### Changed 47 | 48 | * API now takes in counterclockwise angles and converts them to the clockwise angles the physics engine expects 49 | 50 | ## [1.3.1] - 2016-12-13 51 | ### Added 52 | 53 | * Exposed angle property of shapes 54 | * Added Hello World example 55 | 56 | ## [1.3.0] - 2016-12-13 57 | ### Added 58 | 59 | * Regular springs 60 | 61 | ### Changed 62 | 63 | * String representation of rotary springs now shows the actual angle of the spring 64 | 65 | ## [1.2] - 2016-12-13 66 | ### Added 67 | 68 | Everything! First version that actually installs properly. -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to PyPhysicsSandbox 2 | 3 | Thanks for considering contributing to the project! 4 | 5 | First off, before you start forking and making changes for a pull request, be sure you understand the purpose of the sandbox. This is for introductory programming students to be able to explore interesting physics simulations. 6 | 7 | This is not a toolset for programming a game, even though you can program one in it (good luck deploying it, though!) 8 | 9 | # Guidelines For Contributing 10 | 11 | ## Code Changes 12 | 13 | Pull requests that match the existing API style will be looked more favorably upon than ones that depart radically from it. 14 | 15 | ## Tutorials 16 | 17 | Keep tutorials basic. We want students to push their own boundaries, and not just copy and paste tutorial code. Tutorials should show basic concepts, but not put it all together into a complete project. 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2016 Jay Shaffstall 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | -------------------------------------------------------------------------------- /MANIFEST: -------------------------------------------------------------------------------- 1 | # file GENERATED by distutils, do NOT edit 2 | setup.cfg 3 | setup.py 4 | py2d\Bezier.py 5 | py2d\FOV.py 6 | py2d\FOVConverter.py 7 | py2d\Navigation.py 8 | py2d\SVG.py 9 | py2d\__init__.py 10 | py2d\Math\Operations.py 11 | py2d\Math\Polygon.py 12 | py2d\Math\Transform.py 13 | py2d\Math\Vector.py 14 | py2d\Math\__init__.py 15 | pyphysicssandbox\__init__.py 16 | pyphysicssandbox\ball_shape.py 17 | pyphysicssandbox\base_shape.py 18 | pyphysicssandbox\box_shape.py 19 | pyphysicssandbox\gear_joint.py 20 | pyphysicssandbox\line_segment.py 21 | pyphysicssandbox\motor_joint.py 22 | pyphysicssandbox\pin_joint.py 23 | pyphysicssandbox\pivot_joint.py 24 | pyphysicssandbox\poly_shape.py 25 | pyphysicssandbox\rotary_spring.py 26 | pyphysicssandbox\slip_motor.py 27 | pyphysicssandbox\spring_joint.py 28 | pyphysicssandbox\text_shape.py 29 | pyphysicssandbox\util.py 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Synopsis 2 | 3 | pyPhysicsSandbox is a simple wrapper around Pymunk that makes it easy to write code to explore 2D physics simulations. It's intended for use in introductory programming classrooms. 4 | 5 | Caution! The simulation does not behave well if you start out with shapes overlapping each other, especially if overlapping shapes are connected with joints. To have overlapping shapes connected by joints, set the group on each shape to the same number to disable collision detection between those shape. 6 | 7 | On the other hand, see the volcano example for a situation where overlapping shapes that collide with each other are useful. 8 | 9 | Shapes far enough outside the simulation window (generally, above or below by the height of the window, or to either side by the width of the window) are automatically removed from the simulation and their active property set to False. The distance can be modified, but be wary of making it too large...this keeps shapes that are not visible in the simulation and can slow the simulation down if the number of shapes grows too large. 10 | 11 | ## Code Example 12 | 13 | ```python 14 | from pyphysicssandbox import * 15 | 16 | window("My Window", 400, 300) 17 | gravity(0.0, 500.0) 18 | 19 | b1 = ball((100, 10), 30) 20 | b1.color = Color('green') 21 | b1.friction = 0.25 22 | 23 | b2 = static_ball((98, 100), 30) 24 | b2.color = Color('blue') 25 | 26 | box1 = static_rounded_box((0, 290), 400, 10, 3) 27 | box1.color = Color('red') 28 | 29 | tri1 = triangle((260, 35), (250, 35), (240, -15)) 30 | tri1.color = Color('red') 31 | 32 | poly1 = polygon(((195, 35), (245, 35), (220, -15))) 33 | poly1.color = Color('blue') 34 | poly1.wrap = True 35 | 36 | run() 37 | 38 | print('Done!') 39 | ``` 40 | 41 | ## Motivation 42 | 43 | My introductory programming students love writing physics simulations, but the previous physics engine we used did not expose enough features (pins and motors, for example) to be interesting enough to more advanced students. PyPhysicsSandbox retains the simplicity needed for intro programming students, but exposes more advanced tools. 44 | 45 | Also, being IDE agnostic, this library can be used with your favorite IDE. 46 | 47 | ## In Use At 48 | 49 | If you use PyPhysicsSandbox, let me know where and I'll add you here. 50 | 51 | [Muskingum University](http://muskingum.edu/) for their Intro to Computer Science course 52 | 53 | John Glenn High School for their after school coding club 54 | 55 | ## Summary of Features 56 | 57 | pyPhysicsSandbox provides an easy Python interface to a rigid-body physics sandbox. Features include: 58 | 59 | ### Shapes 60 | 61 | * Circles 62 | * Rectangles 63 | * Triangles 64 | * Solid Polygons (both convex and concave) 65 | * Line Segments 66 | * Text 67 | 68 | ### Constraints 69 | 70 | * Pivot Joints 71 | * Pin Joints 72 | * Motors 73 | * Slip Motors 74 | * Springs 75 | * Gears 76 | 77 | ### Other 78 | 79 | * User Specified Collision Handlers 80 | * User Specified Observer Functions 81 | * Disable Collisions Between Specific Objects 82 | * Custom Shape Properties - color, friction, gravity, damping, elasticity 83 | * Set shapes to constant velocities 84 | * Allow Shapes to Wrap Around the Screen 85 | * Conveyor Belt Like Behavior 86 | * Pasting One Shape Onto Another - so they behave as one shape 87 | * Hit shapes in a specific direction with a given force 88 | * Handles Thousands of Shapes 89 | * Built in debug output that can be turned on for individual shapes 90 | 91 | ## Tutorials 92 | 93 | Screencasts highlighting various features of the sandbox are available on the [PyPhysicsSandbox YouTube channel](https://www.youtube.com/channel/UCybNk1XwGtiPyiLVitMFmsQ) 94 | 95 | ## Installation 96 | 97 | ### Python 3 98 | 99 | https://www.python.org/ 100 | 101 | This library was written with Python 3.5, but should run on any Python 3. Python 3 must be installed first. 102 | 103 | ### pyPhysicsSandbox 104 | 105 | Given a suitable Python 3 installation, you should be able to install pyPhysicsSandbox by opening a command prompt in the Scripts folder of your Python installation and typing: 106 | 107 | ``` 108 | pip install pyphysicssandbox 109 | ``` 110 | 111 | ### Dependencies 112 | 113 | http://www.pygame.org/ 114 | http://www.pymunk.org/ 115 | 116 | Both pygame and pymunk should be automatically installed when you install pyPhysicsSandbox. If something goes wrong and you need to install them manually, see their respective sites for directions. 117 | 118 | ## API Reference 119 | 120 | ### Simulation-wide functions 121 | 122 | ```python 123 | window(caption, width, height) 124 | ``` 125 | 126 | Specifies the width and height and caption of the simulation window. Multiple calls to this overwrite the old values. You only get one window regardless. 127 | 128 | ```python 129 | set_margins(x, y) 130 | ``` 131 | 132 | Sets the minimum distance outside the visible window a shape can be and still be in the simulation. Outside of this distance the shape is deactivated. The default x margin is the window's width and the default y margin is the window's height. 133 | 134 | Note that if you create a shape and give it an initial position outside these margins, the simulation will expand the margins to include the shape. 135 | 136 | Use set_margins to increase the y margin particularly if you expect a shape on screen to be fired high above the top of the screen. 137 | 138 | ```python 139 | color(v) 140 | ``` 141 | 142 | Sets the default color for shapes drawn after this function is called. Color must be a string containing a valid color name. 143 | 144 | See https://sites.google.com/site/meticulosslacker/pygame-thecolors for a list of colors. Hover your mouse over a color to see its name. 145 | 146 | ```python 147 | gravity(x, y) 148 | ``` 149 | 150 | Sets the gravity of the simulation. Positive y is downward, positive x is rightward. Default is (0, 500). 151 | 152 | ```python 153 | resistance(v) 154 | ``` 155 | 156 | Sets how much velocity each object in the simulation keeps each second. Must be a floating point number. Default is 0.95. Values higher than 1.0 cause objects to increase in speed rather than lose it. A value of 1.0 means objects will not lose any velocity artificially. 157 | 158 | ```python 159 | add_observer(observer_func) 160 | ``` 161 | 162 | Provide a function of yours that will get called once per frame. In this function you can use the various objects you've created to either affect the simulation or simply measure something. 163 | 164 | You may call add_observer multiple times to add different observer functions. 165 | 166 | The function should be defined like this: 167 | 168 | ```python 169 | def function_name(keys): 170 | # do something each time step 171 | ``` 172 | 173 | The observer function must take a single parameter which is a 174 | list of keys pressed this step. To see if a particular key has 175 | been pressed, use something like this: 176 | 177 | ```python 178 | if constants.K_UP in keys: 179 | # do something based on the up arrow being pressed 180 | ``` 181 | 182 | ```python 183 | mouse_clicked () 184 | ``` 185 | 186 | Returns True if the mouse has been clicked this time step. Usable only in an observer function. 187 | 188 | ```python 189 | mouse_point () 190 | ``` 191 | 192 | Returns the current location of the mouse pointer as an (x, y) tuple. 193 | 194 | If the mouse is out of the simulation window, this will return the last location of the mouse that was in the simulation window. 195 | 196 | ```python 197 | num_shapes() 198 | ``` 199 | 200 | Returns the number of active shapes in the simulation. Mostly useful for debugging. 201 | 202 | ```python 203 | deactivate(shape) 204 | ``` 205 | 206 | Removes the given shape from the simulation. 207 | 208 | ```python 209 | reactivate(shape) 210 | ``` 211 | 212 | Adds the given shape back to the simulation. 213 | 214 | ```python 215 | add_collision(shape1, shape2, handler) 216 | ``` 217 | 218 | Tells the sandbox to call a function when the two given shapes collide. The handler function is called once per collision, at the very start of the collision. 219 | 220 | The handler function is passed three parameters. The first two are the colliding shapes, the third is the point of the collision, e.g.: 221 | 222 | ```python 223 | handler(shape1, shape2, p) 224 | ``` 225 | 226 | The handler function must return True to allow the collision to happen. If the handler returns False, then the collision will not happen. 227 | 228 | Note that you will never have a collision with a deactivated object or with a cosmetic object. 229 | 230 | ```python 231 | run(do_physics=True) 232 | ``` 233 | 234 | Call this after you have created all your shapes to actually run the simulation. This function returns only when the user has closed the simulation window. 235 | 236 | Pass False to this method to do the drawing but not activate physics. Useful for getting the scene right before running the simulation. 237 | 238 | ```python 239 | draw() 240 | ``` 241 | 242 | Call this after you have created all your shapes to draw the shapes. This function returns only when the user has closed the window. 243 | 244 | This is an alias for run(False). 245 | 246 | ### Shape creation functions 247 | 248 | All the shapes have both a static and cosmetic variation shown. 249 | 250 | Static shapes will interact with the physics simulation but will never move. Other shapes will collide with the static shapes, but the static shapes are immovable objects. 251 | 252 | Cosmetic shapes also will never move, but they also do not interact with the physics simulation in any way. Other shapes will fall through the cosmetic shapes. This means you may not also use a cosmetic shape as part of a paste_on call. 253 | 254 | ```python 255 | ball(p, radius, mass) 256 | static_ball(p, radius) 257 | cosmetic_ball(p, radius) 258 | ``` 259 | 260 | Create a ball object and return its instance. 261 | 262 | p is a tuple containing the x and y coordinates of the center of the ball. 263 | 264 | You can omit the mass parameter and the mass will be set proportional to the area of the shape. 265 | 266 | ```python 267 | box(p, width, height, mass) 268 | static_box(p, width, height) 269 | cosmetic_box(p, width, height) 270 | ``` 271 | 272 | Create a box object and return its instance. 273 | 274 | p is a tuple containing the x and y coordinates of the upper left corner of the box. 275 | 276 | You can omit the mass parameter and the mass will be set proportional to the area of the shape. 277 | 278 | ```python 279 | rounded_box(p, width, height, radius, mass) 280 | static_rounded_box(p, width, height, radius) 281 | cosmetic_rounded_box(p, width, height, radius) 282 | ``` 283 | 284 | Create a box object and returns its instance. These boxes are drawn with rounded corners. 285 | 286 | p is a tuple containing the x and y coordinates of the upper left corner of the box. 287 | radius is the radius of the corner curve. 3 works well, but you can pass any integer. 288 | 289 | You can omit the mass parameter and the mass will be set proportional to the area of the shape. 290 | 291 | ```python 292 | triangle(p1, p2, p3, mass) 293 | static_triangle(p1, p2, p3) 294 | cosmetic_triangle(p1, p2, p3) 295 | ``` 296 | 297 | Creates a triangle out of the given points and returns its instance. 298 | 299 | You can omit the mass parameter and the mass will be set proportional to the area of the shape. 300 | 301 | ```python 302 | polygon(vertices, mass) 303 | static_polygon(vertices) 304 | cosmetic_polygon(vertices) 305 | ``` 306 | 307 | Creates a closed polygon out of the given points and returns its instance. The last point is automatically connected back to the first point. 308 | 309 | vertices is a tuple of points, where each point is a tuple of x and y coordinates. The order of these points matters! 310 | 311 | You can omit the mass parameter and the mass will be set proportional to the area of the shape. 312 | 313 | ```python 314 | text(p, caption, mass) 315 | static_text(p, caption) 316 | cosmetic_text(p, caption) 317 | text_with_font(p, caption, font, size, mass) 318 | static_text_with_font(p, caption, font, size) 319 | cosmetic_text_with_font(p, caption, font, size) 320 | ``` 321 | 322 | Creates text that will interact with the world as if it were a rectangle. 323 | 324 | p is a tuple containing the x and y coordinates of the upper left corner of the text. 325 | 326 | You can omit the mass parameter and the mass will be set proportional to the area of the shape. 327 | 328 | To change the text set the object to a variable and use the `.text("new text here")` function. 329 | 330 | ```python 331 | line(p1, p2, thickness, mass) 332 | static_line(p1, p2, thickness) 333 | cosmetic_line(p1, p2, thickness) 334 | ``` 335 | 336 | Creates a line from coordinates p1 to coordinates p2 of the given thickness. 337 | 338 | You can omit the mass parameter and the mass will be set proportional to the area of the shape. 339 | 340 | ### Constraints 341 | 342 | Constraints will limit or control the motion of other shapes in some fashion. 343 | 344 | ```python 345 | pivot1 = pivot(p) 346 | pivot1.connect(other_shape) 347 | ``` 348 | 349 | Create a pivot joint at point p in the world. The other_shape should be a shape whose coordinates intersect the location of the pivot joint. 350 | 351 | The pivot joint pins the other shape to the background, not allowing it to fall. The other shape can rotate around the pivot joint. 352 | 353 | ```python 354 | gear1 = gear(shape1, shape2) 355 | ``` 356 | 357 | Creates a gear joint connecting the two shapes. A gear joint keeps the angle of the two shapes constant. As one shape rotates, the other rotates to match automatically. 358 | 359 | Note that the gear has no visible representation in the simulation. 360 | 361 | ```python 362 | motor(shape1, speed) 363 | ``` 364 | 365 | Creates a motor to give the shape a constant rotation. The direction of rotation is controlled by the sign of the speed. Positive speed is clockwise, negative speed is counter-clockwise. 366 | 367 | If you want other shapes to also rotate at the same rate, use a gear joint to connect them to the shape with the motor. 368 | 369 | The motor displays as a semicircle with a dot in the direction of rotation. 370 | 371 | ```python 372 | spring(p1, shape1, p2, shape2, length, stiffness, damping) 373 | ``` 374 | 375 | Creates a spring that connects two shapes at the given points. The spring wants to remain at the given length, but forces can make it be longer or shorter temporarily. 376 | 377 | ```python 378 | rotary_spring(shape1, shape2, angle, stiffness, damping) 379 | ``` 380 | 381 | Creates a spring that constrains the rotations of the given shapes. The angle between the two shapes prefers to be at the given angle, but may be varied by forces on the objects. The spring will bring the objects back to the desired angle. The initial positioning of the shapes is considered to be at an angle of 0. 382 | 383 | A normal scenario for this is for shape1 to be a shape rotating around shape2, which is a pivot joint or other static object, but play around with different ways of using rotary springs. 384 | 385 | ```python 386 | slip_motor(shape1, shape2, rest_angle, stiffness, damping, slip_angle, speed) 387 | ``` 388 | 389 | Creates a combination spring and motor. The motor will rotate shape1 around shape2 at the given speed. When shape1 reaches the slip angle it will spring back to the rest_angle. Then the motor will start to rotate the object again. 390 | 391 | ```python 392 | pin((100, 580), ball1, (150, 580), ball2) 393 | ``` 394 | 395 | Creates a pin joint between the two shapes at the given points. A pin joint creates a fixed separation between the two bodies (as if there were a metal pin connecting them). You'll get strange effects when wrapping these shapes. 396 | 397 | ###Shape Methods and Properties 398 | 399 | Each shape object that gets returned has some methods and properties that can be called to adjust the shape. 400 | 401 | ```python 402 | shape.debug=True 403 | ``` 404 | 405 | Turns on debug output for the given shape. Each time step the shape will print out information about its current location and other pertinent characteristics. 406 | 407 | ```python 408 | shape.hit(direction, position) 409 | ``` 410 | 411 | Hits the shape at the given position in the given direction. This is an instantaneous impulse. 412 | 413 | Direction is a tuple containing the x direction and y direction (in the same orientation as the gravity tuple). 414 | 415 | Position is a tuple containing the x and y position of the spot on the shape to hit. 416 | 417 | ```python 418 | shape.color=Color('blue') 419 | ``` 420 | 421 | Sets the color for the shape. The value must be a pygame Color instance. The default color is black. 422 | 423 | ```python 424 | shape.angle=90 425 | ``` 426 | 427 | Sets the angle for the shape. Can be used to start shapes off rotated. 428 | 429 | ```python 430 | shape.elasticity=0.0 431 | ``` 432 | 433 | Sets how bouncy the object is. The default is 0.9. 434 | 435 | ```python 436 | shape.friction=0.95 437 | ``` 438 | 439 | Sets how much friction the object should have. The default is 0.6. The Wikipedia article on friction has examples of values for different materials: https://en.wikipedia.org/wiki/Friction 440 | 441 | ```python 442 | shape.velocity=(200,0) 443 | ``` 444 | 445 | Sets a constant velocity for the shape. The shape will still interact with other shapes, but will always move in the given direction. 446 | 447 | To disable a constant velocity and return the shape to reacting to gravity normally, set the velocity to None. 448 | 449 | ```python 450 | shape.surface_velocity=(200,0) 451 | ``` 452 | 453 | Sets how much surface velocity the object should have. The default is (0, 0). 454 | 455 | This is the amount of movement objects touching this surface will have imparted to them. You can use this to set up a conveyor belt. 456 | 457 | ```python 458 | shape.wrap=True 459 | ``` 460 | 461 | Sets whether the shape should wrap when going off the edges of the screen or not. A True value means the shape can never be off screen, and if it starts off screen it's immediately brought on as if it were wrapping. 462 | 463 | This is a convenience function for setting wrap_x and wrap_y at the same time. 464 | 465 | ```python 466 | shape.wrap_x=True 467 | ``` 468 | 469 | Sets whether the shape should wrap when going off the sides of the screen or not. 470 | 471 | ```python 472 | shape.wrap_y+True 473 | ``` 474 | 475 | Sets whether the shape should wrap when going off the top or bottom of the screen or not. 476 | 477 | ```python 478 | shape.visible=False 479 | ``` 480 | 481 | Sets whether the shape draws itself or not. Defaults to True. Most useful to set this to False for joints you don't want shown on screen. 482 | 483 | ```python 484 | shape.group=1 485 | ``` 486 | 487 | Set to an integer. Shapes that share the same group number will not collide with each other. Useful to have overlapping objects connected by joints that do not make the physics crazy. 488 | 489 | ```python 490 | shape.gravity=(0,-300) 491 | ``` 492 | 493 | Set to an (x, y) vector in the same format as the overall gravity vector. This overrides the overall gravity for this shape only. 494 | 495 | ```python 496 | shape.damping=0.9 497 | ``` 498 | 499 | Set a damping value specific for this shape. This overrides the overall damping value for this shape only. 500 | 501 | ```python 502 | shape.paste_on(other_shape) 503 | ``` 504 | 505 | Paste one shape onto another shape. The coordinates for the shape must be inside that of the other_shape and their group must be set to the same value to disable collision detection between them. 506 | 507 | This can be used, for example, to draw some text inside a shape. 508 | 509 | This is only suitable for calling on actual shapes! The various joints already attach themselves to objects. 510 | 511 | ```python 512 | shape.inside(p) 513 | ``` 514 | 515 | Returns True is the given point is inside the given shape. Does not care if the shape is visible or not or active or not. 516 | 517 | ```python 518 | shape.draw_radius_line=True 519 | ``` 520 | 521 | Only for balls, this sets whether a line from the center of the ball to the 0 degree point on the outer edge is drawn. Defaults to False. Can be set to True to gauge rotation of the ball. 522 | 523 | ```python 524 | shape.text="Some text" 525 | ``` 526 | 527 | Only for text, this sets the text to be displayed. This will modify the box shape around the text for collision detection. 528 | 529 | ## Contributors 530 | 531 | See CONTRIBUTING.md 532 | 533 | ## License 534 | 535 | See LICENSE.md 536 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # Tasks for future versions 2 | 3 | * Generate better documentation than the monolithic readme 4 | 5 | * Screencasts for custom gravity, constant velocity 6 | 7 | 8 | -------------------------------------------------------------------------------- /examples/catapult.py: -------------------------------------------------------------------------------- 1 | from pyphysicssandbox import * 2 | 3 | WIN_WIDTH = 500 4 | WIN_HT = 600 5 | window("Catapult", WIN_WIDTH, WIN_HT) 6 | gravity(0.0, 500.0) 7 | 8 | # floor 9 | base = static_box((0, 500), WIN_WIDTH, 10) 10 | base.color = Color("black") 11 | 12 | # fulcrum 13 | triangle = polygon(((250, 450), (275, 500), (225, 500))) 14 | triangle.color = Color("green") 15 | 16 | # lever 17 | lever = polygon(((50, 390), (65, 390), (65, 430), (450, 430), (450, 450), (50, 450))) 18 | lever.color = Color("darkblue") 19 | lever.elasticity = 0.90 20 | 21 | ball1 = ball((90, 425), 5) 22 | ball1.color = Color("green") 23 | ball1.wrap = True 24 | 25 | ball4 = ball((110, 425), 5) 26 | ball4.color = Color("green") 27 | ball4.wrap = True 28 | 29 | ball5 = ball((120, 425), 5) 30 | ball5.color = Color("green") 31 | ball5.wrap = True 32 | 33 | # very heavy ball, off the top of the screen 34 | ball2 = ball((425, -200), 20, 200000) 35 | ball2.color = Color("darkgreen") 36 | 37 | text = text((90, 250), "Catapult") 38 | 39 | run(True) 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /examples/collision_handling.py: -------------------------------------------------------------------------------- 1 | """ 2 | An example of collision handling. The screencast developing this code can be found 3 | here: http://youtu.be/k3BR0qsB30E?hd=1 4 | """ 5 | 6 | from pyphysicssandbox import * 7 | import random 8 | 9 | def ball_hits_floor(ball1, floor, p): 10 | global count 11 | 12 | ball1.color = Color(random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)) 13 | 14 | #count += 1 15 | #if count > 10: 16 | # return False 17 | 18 | return True 19 | 20 | window('Collision Handling', 300, 300) 21 | count = 0 22 | 23 | floor = static_box((0, 290), 300, 10) 24 | floor.color = Color('blue') 25 | 26 | ball1 = ball((125, 100), 10, 100) 27 | ball1.color = Color('green') 28 | 29 | add_collision(ball1, floor, ball_hits_floor) 30 | 31 | run() 32 | -------------------------------------------------------------------------------- /examples/complex_shapes.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Use this as a guide for creating more complex shapes. Code for generating 3 | points on an ellipse and a hexagon are given. 4 | 5 | ''' 6 | from pyphysicssandbox import * 7 | 8 | import math 9 | 10 | window("More Complex Shapes", 800, 800) 11 | 12 | # Call this to get the points for a polygon to plot an ellipse centered 13 | # on the given point with the given width and height 14 | # The num_points is a suggestion, you'll get something close to that 15 | # number of points 16 | def ellipse_points(center_point, width, height, starting_angle, ending_angle, num_points): 17 | points = [] 18 | x = center_point[0] 19 | y = center_point[1] 20 | 21 | for angle in range(starting_angle, ending_angle, int((ending_angle-starting_angle)/num_points)): 22 | x1 = width * math.cos(math.radians(angle)) 23 | y1 = height * math.sin(math.radians(angle)) 24 | points.append((x+x1, y+y1)) 25 | 26 | return points 27 | 28 | ellipse = ellipse_points((150, 150), 100, 50, 0, 359, 50) 29 | polygon(ellipse) 30 | 31 | # Note that the ending point is only approximately at 32 | # 270 degrees 33 | half_ellipse = ellipse_points((250, 250), 100, 50, 90, 270, 50) 34 | polygon(half_ellipse) 35 | 36 | # The points generated don't need to be used in a polygon. If we 37 | # only want a curve, we can generate lines instead. 38 | curve = ellipse_points((350, 350), 100, 50, 90, 270, 50) 39 | for current in range(len(curve)-1): 40 | line(curve[current], curve[current+1], 5) 41 | 42 | # Call this to get the points for a polygon to plot 43 | # a hexagon centered on the given point. 44 | def hexagon_points(center_point, side_length): 45 | height = side_length * math.sqrt(3) 46 | x1 = center_point[0] - side_length//2 47 | y1 = center_point[1] - height//2 48 | x2 = center_point[0] + side_length//2 49 | y2 = center_point[1] - height//2 50 | x3 = center_point[0] + side_length 51 | y3 = center_point[1] 52 | x4 = center_point[0] + side_length//2 53 | y4 = center_point[1] + height//2 54 | x5 = center_point[0] - side_length//2 55 | y5 = center_point[1] + height//2 56 | x6 = center_point[0] - side_length 57 | y6 = center_point[1] 58 | 59 | return ((x1, y1), (x2, y2), (x3, y3), (x4, y4), (x5, y5), (x6, y6)) 60 | 61 | hexagon = hexagon_points((400, 600), 50) 62 | polygon(hexagon) 63 | 64 | draw () 65 | -------------------------------------------------------------------------------- /examples/drawing.py: -------------------------------------------------------------------------------- 1 | """ 2 | An example that shows how to use PyPhysicsSandbox for 3 | early drawing assignments. 4 | 5 | The screencast developing this code can be found here: 6 | 7 | """ 8 | from pyphysicssandbox import * 9 | import random 10 | 11 | window("A Bad Drawing Of A House", 400, 400) 12 | 13 | color('red') 14 | box((100, 200), 200, 200) 15 | color('white') 16 | box((190, 325), 25, 75) 17 | box((150, 250), 25, 25) 18 | box((250, 250), 25, 25) 19 | color('green') 20 | triangle((90, 200), (310, 200), (200, 25)) 21 | 22 | draw() 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /examples/gears.py: -------------------------------------------------------------------------------- 1 | """ 2 | An example of using gears. The screencast developing this code can be found 3 | here: http://youtu.be/uYDVxDHjjxc?hd=1 4 | """ 5 | 6 | from pyphysicssandbox import * 7 | 8 | def my_observer(keys): 9 | if constants.K_b in keys: 10 | ball1 = ball((125, 50), 10, 100) 11 | ball1.color = Color('green') 12 | 13 | window('Gears', 300, 300) 14 | 15 | arm1 = box((100, 90), 100, 10, 100) 16 | arm1.color = Color("yellow") 17 | 18 | pivot1 = pivot((150, 95)) 19 | pivot1.connect(arm1) 20 | 21 | arm2 = box((100, 200), 100, 10, 100) 22 | arm2.color = Color("blue") 23 | 24 | pivot2 = pivot((150, 205)) 25 | pivot2.connect(arm2) 26 | 27 | add_observer(my_observer) 28 | 29 | gear(arm1, arm2) 30 | 31 | run() 32 | -------------------------------------------------------------------------------- /examples/hello_world.py: -------------------------------------------------------------------------------- 1 | """ 2 | A traditional Hello World example for PyPhysicsSandbox. A screencast showing the development 3 | of this example can be found at: https://www.youtube.com/watch?v=xux3z2unaME 4 | """ 5 | 6 | from pyphysicssandbox import * 7 | 8 | window('Hello World', 300, 300) 9 | 10 | floor = static_box((0, 290), 300, 10) 11 | floor.color = Color('blue') 12 | 13 | caption = text((125, 15), 'Hello World!') 14 | caption.angle = 90 15 | caption.wrap = True 16 | 17 | run() 18 | 19 | -------------------------------------------------------------------------------- /examples/lots_of_shapes.py: -------------------------------------------------------------------------------- 1 | """ 2 | An example of a simulation using lots of shapes. The screencast developing this code can be found 3 | here: http://youtu.be/OrifB0AbxUU?hd=1 4 | """ 5 | 6 | from pyphysicssandbox import * 7 | import random 8 | 9 | width = 300 10 | height = 300 11 | how_many = 5000 12 | 13 | window('Lots of Shapes!', width, height) 14 | 15 | arm1 = box((100, 235), 100, 10, 100) 16 | arm1.color = Color("yellow") 17 | 18 | pivot1 = pivot((150, 240)) 19 | pivot1.connect(arm1) 20 | 21 | motor(arm1, 5) 22 | 23 | floor = static_box((0, 290), 300, 10) 24 | 25 | for i in range(how_many): 26 | ball1 = ball((random.randint(0, width), random.randint(0, height)), 5) 27 | ball1.color = Color(random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)) 28 | #ball1.wrap = True 29 | 30 | 31 | run () 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /examples/motors.py: -------------------------------------------------------------------------------- 1 | """ 2 | An example of using motors on shapes. The screencast developing this code can be found 3 | here: http://youtu.be/VtyRKKQBjfI?hd=1 4 | """ 5 | 6 | from pyphysicssandbox import * 7 | 8 | window('Motors', 300, 300) 9 | gravity(0, 0) 10 | #gravity(0, 200) 11 | 12 | wheel = ball((100, 100), 25) 13 | wheel.color = Color('blue') 14 | wheel.draw_radius_line = True 15 | motor(wheel, 5) 16 | 17 | line1 = line((100, 150), (100, 200), 5) 18 | motor(line1, 1) 19 | 20 | tri1 = triangle((170, 75), (150, 100), (190, 100)) 21 | tri1.color = Color('red') 22 | motor(tri1, -3) 23 | 24 | odd_shape = polygon(((200, 200), (185, 210), (170, 180), (210, 150))) 25 | odd_shape.color = Color('green') 26 | motor(odd_shape, 8) 27 | 28 | #floor = static_box((0, 290), 300, 10) 29 | 30 | run() 31 | -------------------------------------------------------------------------------- /examples/observer_functions.py: -------------------------------------------------------------------------------- 1 | """ 2 | An example of using observer functions. The screencast developing this code can be found 3 | here: http://youtu.be/XOQgwFivgR0?hd=1 4 | 5 | Observer functions execute each time step. The simulation runs at about 50 time steps per 6 | second. 7 | """ 8 | 9 | from pyphysicssandbox import * 10 | 11 | def my_observer(keys): 12 | global ball_point 13 | 14 | if constants.K_b in keys: 15 | ball1 = ball(ball_point, 10, 100) 16 | ball1.color = Color('green') 17 | 18 | if mouse_clicked(): 19 | ball_point = mouse_point() 20 | 21 | window('Observer Functions', 300, 300) 22 | 23 | arm1 = box((100, 150), 100, 10, 100) 24 | arm1.color = Color("yellow") 25 | 26 | pivot1 = pivot((150, 155)) 27 | pivot1.connect(arm1) 28 | 29 | ball_point = (125, 50) 30 | add_observer(my_observer) 31 | 32 | run() 33 | -------------------------------------------------------------------------------- /examples/pin_joints.py: -------------------------------------------------------------------------------- 1 | """ 2 | An example of using pin joints. A screencast showing the development 3 | of this example can be found at: http://youtu.be/Hq3y-ah6Lk0?hd=1 4 | """ 5 | 6 | from pyphysicssandbox import * 7 | 8 | window("Pin Joints", 690, 300) 9 | 10 | # floor 11 | base = static_line((-100, 300), (1000, 220), 5) 12 | base.color = Color("black") 13 | base.friction = 1.0 14 | base.elasticity = 0.0 15 | 16 | wheel1 = ball((45, 200), 25, 100) 17 | wheel1.color = Color(52, 219, 119) 18 | wheel1.friction = 1.5 19 | wheel1.elasticity = 0.0 20 | wheel1.draw_radius_line = True 21 | 22 | wheel2 = ball((155, 200), 25, 100) 23 | wheel2.color = Color(52, 219, 119) 24 | wheel2.friction = 1.5 25 | wheel2.elasticity = 0.0 26 | wheel2.draw_radius_line = True 27 | 28 | chassis = box((75, 160), 50, 30, 100) 29 | chassis.elasticity = 0.0 30 | 31 | # The pin joints must connect to the center of the wheel or 32 | # the car will flip as the wheels rotate. 33 | pin((45, 200), wheel1, (75, 160), chassis) 34 | pin((45, 200), wheel1, (75, 190), chassis) 35 | pin((155, 200), wheel2, (125, 160), chassis) 36 | pin((155, 200), wheel2, (125, 190), chassis) 37 | 38 | motor(wheel1, 4) 39 | motor(wheel2, 4) 40 | 41 | run(True) 42 | -------------------------------------------------------------------------------- /examples/pinball.py: -------------------------------------------------------------------------------- 1 | from pyphysicssandbox import * 2 | import random 3 | 4 | window('Pinball', 600, 600) 5 | gravity(0, 900) 6 | 7 | lines = [] 8 | lines.append(static_line((450, 500), (550, 50), 3)) 9 | lines.append(static_line((150, 500), (50, 50), 3)) 10 | lines.append(static_line((550, 50), (300, 0), 3)) 11 | lines.append(static_line((300, 0), (50, 50), 3)) 12 | #lines.append(static_line((300, 180), (200, 200), 1)) 13 | 14 | for line in lines: 15 | line.elasticity = 0.7 16 | line.group = 1 17 | 18 | tri1 = static_triangle((300, 180), (200, 200), (200, 100)) 19 | tri1.elasticity = 1.5 20 | 21 | r_pos_x = 150 22 | r_pos_y = 500 23 | 24 | r_flipper = polygon(((r_pos_x-20, r_pos_y-20), (r_pos_x+140, r_pos_y), (r_pos_x-20, r_pos_y+20)), 100) 25 | r_flipper.color = Color('blue') 26 | r_flipper.group = 1 27 | 28 | l_pos_x = 450 29 | l_pos_y = 500 30 | 31 | l_flipper = polygon(((l_pos_x+20, l_pos_y-20), (l_pos_x-140, l_pos_y), (l_pos_x+20, l_pos_y+20)), 100) 32 | l_flipper.color = Color('blue') 33 | l_flipper.group = 1 34 | 35 | r_pivot = pivot((r_pos_x, r_pos_y)) 36 | r_pivot.connect(r_flipper) 37 | rotary_spring(r_flipper, r_pivot, -0.15, 20000000, 900000) 38 | 39 | l_pivot = pivot((l_pos_x, l_pos_y)) 40 | l_pivot.connect(l_flipper) 41 | rotary_spring(l_flipper, l_pivot, 0.15, 20000000, 900000) 42 | 43 | 44 | def flipper_hit(keys): 45 | if mouse_clicked(): 46 | r_flipper.hit((0, -20000), (r_pos_x+120, r_pos_y)) 47 | l_flipper.hit((0, -20000), (l_pos_x-120, l_pos_y)) 48 | 49 | if constants.K_b in keys: 50 | ball1 = ball((250, 100), 25, 1) 51 | ball1.elasticity = 0.95 52 | add_collision(r_flipper, ball1, ball_flipped) 53 | 54 | if constants.K_t in keys: 55 | if tri1.active: 56 | deactivate(tri1) 57 | else: 58 | reactivate(tri1) 59 | 60 | if mouse_clicked() and tri1.inside(mouse_point()): 61 | if tri1.active: 62 | deactivate(tri1) 63 | else: 64 | reactivate(tri1) 65 | 66 | 67 | add_observer(flipper_hit) 68 | 69 | 70 | def ball_flipped(shape1, shape2, p): 71 | print('Collision') 72 | 73 | return True 74 | 75 | run() 76 | 77 | -------------------------------------------------------------------------------- /examples/pivot_joints.py: -------------------------------------------------------------------------------- 1 | """ 2 | An example of using pivot joints. A screencast showing the development 3 | of this example can be found at: http://youtu.be/k0EgioeURr0?hd=1 4 | """ 5 | 6 | from pyphysicssandbox import * 7 | 8 | window("Pivot Joints", 300, 300) 9 | 10 | arm1 = box((100, 50), 100, 10, 100) 11 | arm1.color = Color("yellow") 12 | 13 | arm2 = box((100, 135), 10, 100) 14 | arm2.color = Color("green") 15 | 16 | pivot1 = pivot((105, 55)) 17 | pivot1.connect(arm1) 18 | 19 | pivot2 = pivot((105, 185)) 20 | pivot2.connect(arm2) 21 | 22 | motor(arm1, -3) 23 | motor(arm2, 3) 24 | 25 | run() 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /examples/platform_game.py: -------------------------------------------------------------------------------- 1 | ''' 2 | An example of how to do a one-click platform game. 3 | PyPhysicsSandbox isn't designed for games, so we 4 | have to work around some of the physics engine 5 | features. 6 | 7 | For example, to provide continuous movement for the 8 | player, all the platforms are given the same surface 9 | velocity. 10 | 11 | This example just shows the possibilities. You could 12 | expand it to create power ups that allow jumping higher 13 | or farther, some platforms that move faster or slower, 14 | enemies that send the player back to the start, etc. 15 | ''' 16 | from pyphysicssandbox import * 17 | 18 | window_width = 1000 19 | window_height = 600 20 | 21 | window ("Simple platform game", window_width, window_height) 22 | 23 | # The x component of the jump 24 | jump_speed = 10000 25 | # How much of a boost do walls give? 26 | wall_boost = 5 27 | # How fast does the player move? 28 | move_speed = 100 29 | # Is the player on the ground? 30 | landed = True 31 | # Direction of movement 32 | # 1 for right, -1 for left 33 | direction = 1 34 | 35 | platforms = [] 36 | walls = [] 37 | 38 | def won_game(player, target, p): 39 | message = text_with_font((100, 100), "You Won!", "Comic Sans", 36) 40 | message.color = Color('red') 41 | deactivate (target) 42 | add_collision(player, message, landing) 43 | return False 44 | 45 | def observer(keys): 46 | global landed 47 | 48 | # This prevents jumping in mid-air 49 | if landed: 50 | if mouse_clicked() or constants.K_SPACE in keys: 51 | landed = False 52 | player.hit((jump_speed*direction, -50000), player.position) 53 | 54 | def landing(player, other, p): 55 | global landed 56 | landed = True 57 | return True 58 | 59 | def reverse_direction(player, other, p): 60 | global direction 61 | 62 | direction *= -1 63 | 64 | for platform in platforms: 65 | platform.surface_velocity = (move_speed*direction, 0) 66 | 67 | # if the player hits a wall in midair, give them a bounce 68 | if not landed: 69 | player.hit((jump_speed*wall_boost*direction, -50000), player.position) 70 | 71 | return True 72 | 73 | floor = static_box((0,window_height), window_width, 50) 74 | platforms.append(floor) 75 | 76 | left_wall = static_box((0,0), 5, window_height) 77 | walls.append(left_wall) 78 | right_wall = static_box((window_width-5, 0), 5, window_height) 79 | walls.append(right_wall) 80 | middle_wall = static_box((100, window_height-150), 5, 100) 81 | walls.append(middle_wall) 82 | 83 | platform = static_box ((window_width-100, window_height-50), 100, 5) 84 | platforms.append(platform) 85 | 86 | platform = static_box ((window_width-100, window_height-50), 100, 5) 87 | platforms.append(platform) 88 | 89 | platform = static_box ((window_width-250, window_height-100), 100, 5) 90 | platforms.append(platform) 91 | 92 | target = static_box((window_width-300, window_height-150), 20, 20) 93 | target.color = Color("red") 94 | 95 | player = box((20, window_height-20), 15, 15) 96 | player.elasticity = 0.0 97 | 98 | add_observer(observer) 99 | add_collision(player, target, won_game) 100 | 101 | for wall in walls: 102 | add_collision(player, wall, reverse_direction) 103 | 104 | for platform in platforms: 105 | platform.surface_velocity = (move_speed, 0) 106 | add_collision(player, platform, landing) 107 | 108 | run() 109 | -------------------------------------------------------------------------------- /examples/rotary_spring.py: -------------------------------------------------------------------------------- 1 | """ 2 | An example of using rotary springs. The screencast developing this code can be found 3 | here: http://youtu.be/4Fpp8Y5g-dQ?hd=1 4 | """ 5 | 6 | from pyphysicssandbox import * 7 | 8 | def my_observer(keys): 9 | if constants.K_b in keys: 10 | ball1 = ball((125, 50), 10, 100) 11 | ball1.color = Color('green') 12 | 13 | window('Rotary Springs', 300, 300) 14 | 15 | arm1 = box((100, 150), 100, 10, 100) 16 | arm1.color = Color("yellow") 17 | 18 | pivot1 = pivot((150, 155)) 19 | pivot1.connect(arm1) 20 | 21 | add_observer(my_observer) 22 | 23 | rotary_spring(arm1, pivot1, 0, 50000, 50000) 24 | #rotary_spring(arm1, pivot1, 0, 20000000, 900000 25 | 26 | run() 27 | -------------------------------------------------------------------------------- /examples/shape_misc.py: -------------------------------------------------------------------------------- 1 | """ 2 | An example of using shape methods and properties. The screencast developing this code can be found 3 | here: http://youtu.be/_eyE4xX_Gi8?hd=1 4 | """ 5 | 6 | from pyphysicssandbox import * 7 | 8 | def hit_ball(keys): 9 | if mouse_clicked(): 10 | ball1.hit((0, -400000), ball1.position) 11 | 12 | if constants.K_RIGHT in keys: 13 | floor.surface_velocity = (100, 0) 14 | elif constants.K_LEFT in keys: 15 | floor.surface_velocity = (-100, 0) 16 | 17 | window('Shape Methods & Properties', 300, 300) 18 | 19 | ball1 = ball((100, 100), 25) 20 | ball1.color = Color('blue') 21 | ball1.group = 1 22 | ball1.elasticity = 0.0 23 | 24 | text1 = text((85, 90), 'Hello') 25 | text1.group = 1 26 | text1.paste_on(ball1) 27 | 28 | #text1.debug=True 29 | 30 | floor = static_box((0, 290), 300, 10) 31 | floor.elasticity = 0.0 32 | 33 | add_observer(hit_ball) 34 | 35 | run() 36 | -------------------------------------------------------------------------------- /examples/slip_motor.py: -------------------------------------------------------------------------------- 1 | """ 2 | An example of using slip motors on shapes. The screencast developing this code can be found 3 | here: http://youtu.be/d_gK8Uk6xeM?hd=1 4 | """ 5 | 6 | from pyphysicssandbox import * 7 | 8 | window('Slip Motors', 300, 300) 9 | gravity(0, 0) 10 | 11 | arm1 = box((100, 100), 100, 10, 100) 12 | arm1.color = Color("yellow") 13 | 14 | pivot1 = pivot((105, 105)) 15 | pivot1.connect(arm1) 16 | 17 | # Play with the stiffness and damping values to get 18 | # different behaviors of the spring attached to the motor 19 | slip_motor(arm1, pivot1, 45, 20000000, 900000, -45, 3) 20 | 21 | run() 22 | -------------------------------------------------------------------------------- /examples/spring.py: -------------------------------------------------------------------------------- 1 | """ 2 | An example of using springs on shapes. The screencast developing this code can be found 3 | here: http://youtu.be/ohSsodGpbj4?hd=1 4 | """ 5 | 6 | from pyphysicssandbox import * 7 | 8 | window('Springs', 300, 300) 9 | 10 | wheel = ball((50, 50), 25) 11 | wheel.color = Color('blue') 12 | 13 | pivot1 = pivot((100, 100)) 14 | 15 | spring((100, 100), pivot1, (50, 50), wheel, 25, 20000, 1000) 16 | 17 | run() 18 | -------------------------------------------------------------------------------- /examples/volcano.py: -------------------------------------------------------------------------------- 1 | """ 2 | A simple example that shows how shapes initially placed overlapping will 3 | try to move so they are not overlapping. In this case we put too many 4 | balls into a very small area and let them find their way out. 5 | 6 | Note that we need to shift placement of the balls around a small area to 7 | get uniform expansion when the simulation starts. Putting them all on the 8 | exact same spot expands them only in a horizontal line. 9 | 10 | The screencast developing this code can be found here: 11 | http://youtu.be/F8qSSoBz_o8?hd=1 12 | """ 13 | from pyphysicssandbox import * 14 | import random 15 | 16 | window("A tiny volcano", 400, 400) 17 | 18 | static_line((225, 400), (175, 400), 15).color=Color('grey') 19 | static_line((225, 400), (225, 300), 15).color=Color('grey') 20 | static_line((175, 300), (175, 400), 15).color=Color('grey') 21 | static_line((220, 275), (225, 300), 15).color=Color('grey') 22 | static_line((175, 300), (180, 275), 15).color=Color('grey') 23 | 24 | # We have to spread the balls out a bit to get uniform expansion, 25 | # otherwise they all expand horizontally. 26 | for i in range(500): 27 | ball1 = ball((200+random.randint(-1,1), 350+random.randint(-1,1)), 5) 28 | #ball1.color = Color(random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)) 29 | ball1.color = Color('red') 30 | 31 | run() 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /py2d/Bezier.py: -------------------------------------------------------------------------------- 1 | """Bezier curve tools. 2 | 3 | All functions in this module assume the following naming: 4 | 5 | C1 6 | ____ 7 | / \ P2 8 | | \______| 9 | P1 10 | C2 11 | 12 | A bezier curve is formed between two points P1 and P2, with curve direction coming from control points Ci. 13 | 14 | References: 15 | www.caffeineowl.com/graphics/2d/vectorial/bezierintro.htm 16 | """ 17 | 18 | from py2d import distance_point_line 19 | 20 | 21 | def point_on_cubic_bezier(p1,p2,c1,c2,t): 22 | """Find a point on a cubic bezier curve, i.e. a bezier curve with two control points. 23 | 24 | @type p1: Vector 25 | @param p1: Start point of the curve 26 | 27 | @type p2: Vector 28 | @param p2: Stop point of the curve 29 | 30 | @type c1: Vector 31 | @param c1: Control point for point A 32 | 33 | @type c2: Vector 34 | @param c2: Control point for point B 35 | 36 | @type t: float 37 | @param t: Relative position on the bezier curve between 0 and 1 38 | """ 39 | 40 | one_minus_t = 1.0 - t 41 | one_minus_t_2 = one_minus_t * one_minus_t 42 | one_minus_t_3 = one_minus_t_2 * one_minus_t 43 | 44 | t_2 = t * t 45 | t_3 = t_2 * t 46 | 47 | return p1 * one_minus_t_3 + c1 * (3 * one_minus_t_2 * t) + c2 * (3 * one_minus_t * t_2) + p2 * t_3 48 | 49 | 50 | def subdivide_cubic_bezier(p1,p2,c1,c2,t): 51 | """Subdivide a cubic bezier curve and return the point on the curve plus new control points""" 52 | 53 | one_minus_t = 1.0 - t 54 | 55 | a = p1 * one_minus_t + c1 * t 56 | b = c1 * one_minus_t + c2 * t 57 | c = c2 * one_minus_t + p2 * t 58 | 59 | m = a * one_minus_t + b * t 60 | n = b * one_minus_t + c * t 61 | 62 | p = m * one_minus_t + n * t 63 | 64 | return a,m,p,n,c 65 | 66 | 67 | def flatten_cubic_bezier(p1,p2,c1,c2, max_divisions=None, max_flatness=0.1): 68 | out = [] 69 | 70 | if not __is_flat(max_divisions, max_flatness, __bezier_flatness(p1,p2,c1,c2)): 71 | a,m,p,n,c = subdivide_cubic_bezier(p1,p2,c1,c2,0.5) 72 | 73 | md_rec = max_divisions - 1 if max_divisions else None 74 | 75 | out.extend(flatten_cubic_bezier(p1,p,a,m, md_rec, max_flatness)) 76 | out.append(p) 77 | out.extend(flatten_cubic_bezier(p,p2,n,c, md_rec, max_flatness)) 78 | 79 | return out 80 | 81 | 82 | def point_on_quadratic_bezier(p1,p2,c,t): 83 | one_minus_t = 1.0 - t 84 | one_minus_t_2 = one_minus_t * one_minus_t 85 | 86 | t_2 = t * t 87 | 88 | return p1 * one_minus_t_2 + c * (2 * one_minus_t * t) + p2 * t_2 89 | 90 | 91 | def subdivide_quadratic_bezier(p1,p2,c,t): 92 | one_minus_t = 1.0 - t 93 | 94 | a = p1 * one_minus_t + c * t 95 | b = c * one_minus_t + p2 * t 96 | 97 | p = a * one_minus_t + b * t 98 | 99 | return a,p,b 100 | 101 | def flatten_quadratic_bezier(p1,p2,c, max_divisions=None, max_flatness=0.1): 102 | out = [] 103 | if not __is_flat(max_divisions, max_flatness, __bezier_flatness(p1,p2,c)): 104 | _a,_p,_c = subdivide_quadratic_bezier(p1,p2,c,0.5) 105 | 106 | md_rec = max_divisions - 1 if max_divisions else None 107 | 108 | out.extend(flatten_quadratic_bezier(p1,_p,_a, md_rec, max_flatness)) 109 | out.append(_p) 110 | out.extend(flatten_quadratic_bezier(_p,p2,_c, md_rec, max_flatness)) 111 | 112 | return out 113 | 114 | def __is_flat(max_divisions, max_flatness, flatness): 115 | return (max_divisions == 0) or (max_flatness != None and flatness <= max_flatness) 116 | 117 | def __bezier_flatness(p1,p2, *c): 118 | return max(distance_point_line(cp, p1, p2) for cp in c) 119 | -------------------------------------------------------------------------------- /py2d/FOV.py: -------------------------------------------------------------------------------- 1 | """Calculation of polygonal Field of View (FOV)""" 2 | import functools 3 | 4 | import py2d.Math 5 | 6 | class Vision: 7 | """Class for representing a polygonal field of vision (FOV). 8 | 9 | It requires a list of obstructors, given as line strips made of lists of vectors (i.e. we have a list of lists of vectors). 10 | The vision polygon will be cached as long as the eye position and obstructors don't change. 11 | 12 | >>> obs = [[ py2d.Math.Vector(2,4), py2d.Math.Vector(4, 1), py2d.Math.Vector(7, -2) ], 13 | ... [ py2d.py2d.Math.Vector(1,-2), py2d.Math.Vector(6, -3) ], 14 | ... [ py2d.Math.Vector(2.5,5), py2d.Math.Vector(3, 4) ]] 15 | >>> radius = 20 16 | >>> eye = py2d.Math.Vector(0,0) 17 | >>> boundary = py2d.Math.Polygon.regular(eye, radius, 4) 18 | >>> v = Vision(obs) 19 | >>> poly = v.get_vision(eye, radius, boundary) 20 | >>> poly.points[0:6] 21 | [Vector(4.000, 1.000), Vector(2.000, 4.000), Vector(2.000, 4.000), Vector(0.000, 20.000), Vector(0.000, 20.000), Vector(-20.000, 0.000)] 22 | >>> poly.points[6:] 23 | [Vector(-20.000, 0.000), Vector(-0.000, -20.000), Vector(-0.000, -20.000), Vector(1.000, -2.000), Vector(1.000, -2.000), Vector(6.000, -3.000), Vector(6.000, -3.000), Vector(7.000, -2.000), Vector(7.000, -2.000)] 24 | """ 25 | 26 | def __init__(self, obstructors, debug=False): 27 | """Create a new vision object. 28 | 29 | @type obstructors: list 30 | @param obstructors: A list of obstructors. Obstructors are a list of vectors, so this should be a list of lists. 31 | """ 32 | 33 | self.set_obstructors(obstructors) 34 | self.debug = debug 35 | self.debug_points = [] 36 | self.debug_linesegs = [] 37 | 38 | def set_obstructors(self, obstructors): 39 | """Set new obstructor data for the Vision object. 40 | 41 | This will also cause the vision polygon to become invalidated, resulting in a re-calculation the next time you access it. 42 | 43 | @type obstructors: list 44 | @param obstructors: A list of obstructors. Obstructors are a list of vectors, so this should be a list of lists. 45 | """ 46 | def flatten_list(l): 47 | return functools.reduce(lambda x,y: x+y, l) 48 | 49 | # concatenate list of lists of vectors to a list of vectors 50 | self.obs_points = flatten_list(obstructors) 51 | 52 | # convert obstructor line strips to lists of line segments 53 | self.obs_segs = flatten_list([ list(zip(strip, strip[1:])) for strip in obstructors ]) 54 | 55 | self.cached_vision = None 56 | self.cached_position = None 57 | self.cached_radius = None 58 | 59 | def get_vision(self, eye, radius, boundary): 60 | """Get a vision polygon for a given eye position and boundary Polygon. 61 | 62 | @type eye: Vector 63 | @param eye: The position of the viewer (normally the center of the boundary polygon) 64 | @type radius: float 65 | @param radius: The maximum vision radius (normally the radius of the boundary polygon) 66 | @type boundary: Polygon 67 | @param boundary: The boundary polygon that describes the maximal field of vision 68 | """ 69 | 70 | if self.cached_vision == None or (self.cached_position - eye).get_length_squared() > 1: 71 | self.calculate(eye, radius, boundary) 72 | 73 | return self.cached_vision 74 | 75 | 76 | def calculate(self, eye, radius, boundary): 77 | """Re-calculate the vision polygon. 78 | 79 | WARNING: You should only call this if you want to re-calculate the vision polygon for some reason. 80 | 81 | For normal usage, use L{get_vision} instead! 82 | """ 83 | 84 | self.cached_radius = radius 85 | self.cached_position = eye 86 | self.debug_points = [] 87 | self.debug_linesegs = [] 88 | 89 | radius_squared = radius * radius 90 | 91 | 92 | closest_points = lambda points, reference: sorted(points, key=lambda p: (p - reference).get_length_squared()) 93 | 94 | 95 | def sub_segment(small, big): 96 | return py2d.Math.distance_point_lineseg_squared(small[0], big[0], big[1]) < 0.0001 and py2d.Math.distance_point_lineseg_squared(small[1], big[0], big[1]) < 0.0001 97 | 98 | 99 | def segment_in_obs(seg): 100 | for line_segment in self.obs_segs: 101 | if sub_segment(seg, line_segment): 102 | return True 103 | return False 104 | 105 | def check_visibility(p): 106 | bpoints = set(boundary.points) 107 | 108 | if p not in bpoints: 109 | if (eye - p).get_length_squared() > radius_squared: return False 110 | if not boundary.contains_point(p): return False 111 | 112 | for line_segment in obs_segs: 113 | if py2d.Math.check_intersect_lineseg_lineseg(eye, p, line_segment[0], line_segment[1]): 114 | if line_segment[0] != p and line_segment[1] != p: 115 | return False 116 | 117 | return True 118 | 119 | def lineseg_in_radius(seg): 120 | return py2d.Math.distance_point_lineseg_squared(eye, seg[0], seg[1]) <= radius_squared 121 | 122 | obs_segs = filter(lineseg_in_radius, self.obs_segs) 123 | 124 | # add all obstruction points and boundary points directly visible from the eye 125 | visible_points = list(filter(check_visibility, set(self.obs_points + boundary.points ))) 126 | 127 | # find all obstructors intersecting the vision polygon 128 | boundary_intersection_points = py2d.Math.intersect_linesegs_linesegs(obs_segs, list(zip(boundary.points, boundary.points[1:])) + [(boundary.points[-1], boundary.points[0])]) 129 | 130 | if self.debug: self.debug_points.extend([(p, 0xFF0000) for p in visible_points]) 131 | if self.debug: self.debug_points.extend([(p, 0x00FFFF) for p in boundary_intersection_points]) 132 | 133 | # filter boundary_intersection_points to only include visible points 134 | # - need extra code here to handle points on obstructors! 135 | for line_segment in obs_segs: 136 | i = 0 137 | while i < len(boundary_intersection_points): 138 | p = boundary_intersection_points[i] 139 | 140 | if py2d.Math.distance_point_lineseg_squared(p, line_segment[0], line_segment[1]) > 0.0001 and py2d.Math.check_intersect_lineseg_lineseg(eye, p, line_segment[0], line_segment[1]): 141 | boundary_intersection_points.remove(p) 142 | else: 143 | i+=1 144 | 145 | visible_points += boundary_intersection_points 146 | 147 | poly = py2d.Math.Polygon() 148 | poly.add_points(visible_points) 149 | poly.sort_around(eye) 150 | 151 | i = 0 152 | while i < len(poly.points): 153 | p = poly.points[i-1] 154 | c = poly.points[i] 155 | n = poly.points[ (i+1) % len(poly.points) ] 156 | 157 | # intersect visible point with obstructors and boundary polygon 158 | intersections = set( 159 | py2d.Math.intersect_linesegs_ray(obs_segs, eye, c) + py2d.Math.intersect_poly_ray(boundary.points, eye, c)) 160 | 161 | intersections = [ip for ip in intersections if ip != c and boundary.contains_point(ip)] 162 | 163 | 164 | if self.debug: self.debug_points.extend([(pt, 0x00FF00) for pt in intersections]) 165 | if intersections: 166 | 167 | intersection = min(intersections, key=lambda p: (p - eye).length_squared) 168 | 169 | #if self.debug: self.debug_linesegs.append((0xFF00FF, [eye, intersection])) 170 | 171 | #if self.debug: print "%d prev: %s current: %s next: %s" % (i, p, c, n) 172 | 173 | sio_pc = segment_in_obs((p,c)) 174 | sio_cn = segment_in_obs((c,n)) 175 | 176 | if not sio_pc: 177 | #if self.debug: print "insert %s at %d" % (closest_intersection, i) 178 | poly.points.insert(i, intersection) 179 | i+=1 180 | 181 | 182 | # We might have wrongly inserted a point before because this insert was missing 183 | # and therefore the current-next check (incorrectly) yielded false. remove the point again 184 | if segment_in_obs((poly.points[i-3], poly.points[i-1])): 185 | #if self.debug: print "Fixing erroneous insert at %d" % (i-2) 186 | poly.points.remove(poly.points[i-2]) 187 | i-=1 188 | 189 | elif sio_pc and not sio_cn: 190 | 191 | #if self.debug: print "insert %s at %d (+)" % (closest_intersection, i+1) 192 | poly.points.insert(i+1, intersection) 193 | i+=1 194 | 195 | #elif self.debug: 196 | #print "no insert at %i" % i 197 | 198 | 199 | i+=1 200 | 201 | #if self.debug: print "%d %d" % (i, len(poly.points)) 202 | 203 | 204 | # handle border case where polypoint at 0 is wrongfully inserted before because poly was not finished at -1 205 | if segment_in_obs((poly.points[-1], poly.points[1])): 206 | poly.points[0], poly.points[1] = poly.points[1], poly.points[0] 207 | 208 | 209 | self.cached_vision = poly 210 | 211 | return poly 212 | 213 | 214 | 215 | -------------------------------------------------------------------------------- /py2d/FOVConverter.py: -------------------------------------------------------------------------------- 1 | """Conversion of map data structures to obstructor data""" 2 | import Math 3 | 4 | def convert_tilemap(width, height, blocking_function, tile_width, tile_height): 5 | """Convert a tile-based map file to obstructors for FOV calculation. 6 | 7 | >>> map_data = [[1,1,1,1,0,0,0,0], 8 | ... [1,1,1,1,0,1,1,1], 9 | ... [1,1,1,1,0,1,1,1], 10 | ... [1,1,0,0,0,1,1,1], 11 | ... [1,1,0,0,0,1,1,1], 12 | ... [0,0,0,0,0,1,1,1], 13 | ... [0,0,0,0,0,0,0,0]] 14 | >>> blocking_func = lambda x,y: map_data[x][y] 15 | >>> obstructors = convert_tilemap( len(map_data), len(map_data[0]), blocking_func, 1, 1) 16 | >>> print "\\n".join(map(str, obstructors)) 17 | [Vector(0.000, 0.000), Vector(5.000, 0.000), Vector(5.000, 2.000), Vector(3.000, 2.000), Vector(3.000, 4.000), Vector(0.000, 4.000), Vector(0.000, 0.000)] 18 | [Vector(1.000, 5.000), Vector(6.000, 5.000), Vector(6.000, 8.000), Vector(1.000, 8.000), Vector(1.000, 5.000)] 19 | [Vector(0.000, 4.000), Vector(3.000, 4.000), Vector(3.000, 2.000), Vector(5.000, 2.000), Vector(5.000, 0.000), Vector(7.000, 0.000), Vector(7.000, 8.000), Vector(6.000, 8.000), Vector(6.000, 5.000), Vector(1.000, 5.000), Vector(1.000, 8.000), Vector(0.000, 8.000), Vector(0.000, 4.000)] 20 | 21 | @type width: int 22 | @param width: The width of the map in tiles 23 | @type height: int 24 | @param height: The height of the map in tiles 25 | 26 | @type blocking_function: function(x,y) 27 | @param blocking_function: The function with parameters x,y used to determine whether the tile at x,y blocks light. e.g.: C{lambda x,y: map.get_tile(x,y).block_light} 28 | 29 | @type tile_width: float 30 | @param tile_width: The width of a single tile 31 | 32 | @type tile_height: float 33 | @param tile_height: The height of a single tile 34 | """ 35 | 36 | 37 | def find_clusters(): 38 | clusters = [[0 for i in range(height)] for j in range(width)] 39 | cluster_count = 0 40 | clusters_seen = set() 41 | 42 | def rename_cluster(old, new): 43 | #print "rename cluster %d --> %d" % (old, new) 44 | clusters_seen.remove(old) 45 | clusters_seen.add(new) 46 | 47 | for x in range(width): 48 | for y in range(height): 49 | if clusters[x][y] == old: 50 | clusters[x][y] = new 51 | 52 | for x in range(width): 53 | for y in range(height): 54 | 55 | block = blocking_function(x,y) 56 | block_left = blocking_function(x-1, y) if x > 0 else False 57 | block_top = blocking_function(x,y-1) if y > 0 else False 58 | 59 | 60 | #print "x: %d\ty:%d" % (x,y) 61 | 62 | # merge tiles to clusters of the same blocking status by 63 | # assigning cluster numbers to them. Iterate over whole 64 | # map array and compare blocking statuses of tiles to the 65 | # left and to the top of the current tile. If they have 66 | # the same blocking status, take their cluster information. 67 | # If cluster informations differ, merge the clusters. 68 | # If differing blocking information, create new cluster. 69 | 70 | if block == block_left and block == block_top and x > 0 and y > 0: 71 | # clusters both to the left and the top. 72 | # merge clusters by overwriting all occurrences of the left cluster with the top cluster 73 | new_cluster = clusters[x][y-1] 74 | old_cluster = clusters[x-1][y] 75 | 76 | if new_cluster != old_cluster: 77 | rename_cluster(old_cluster, new_cluster) 78 | clusters[x][y] = new_cluster 79 | 80 | #print "x: %d\ty:%d: join left %d -> top %d" % (x,y, old_cluster, new_cluster) 81 | elif block == block_left and x > 0: 82 | # add to left cluster 83 | clusters[x][y] = clusters[x-1][y] 84 | #print "x: %d\ty:%d: use left %d" % (x,y, clusters[x-1][y]) 85 | 86 | elif block == block_top and y > 0: 87 | # add to top cluster 88 | clusters[x][y] = clusters[x][y-1] 89 | #print "x: %d\ty:%d: use top %d" % (x,y, clusters[x][y-1]) 90 | 91 | else: 92 | # no adjacent clusters, create a new one 93 | cluster_count += 1 94 | #print "x: %d\ty:%d: new cluster %d" % (x,y, cluster_count) 95 | clusters[x][y] = cluster_count 96 | clusters_seen.add(cluster_count) 97 | 98 | 99 | 100 | 101 | 102 | translation = {} 103 | cs = list(clusters_seen) 104 | for i in range(len(clusters_seen)): 105 | translation[cs[i]] = i + 1 106 | 107 | for x in range(width): 108 | for y in range(height): 109 | old = clusters[x][y] 110 | if(old > 0): clusters[x][y] = translation[old] 111 | 112 | return clusters, clusters_seen 113 | 114 | 115 | def cluster_outline(cluster): 116 | 117 | def get_startpos(): 118 | for x in range(width): 119 | for y in range(height): 120 | if clusters[x][y] == cluster: 121 | return x,y 122 | 123 | start_x, start_y = get_startpos() 124 | 125 | # directions: 126 | # 0: going right at the top of the cluster, 127 | # 1: going down at the right of the cluster, 128 | # 2: going left at the bottom of the cluster, 129 | # 3: going up at the left of the cluster. 130 | direction = 0 131 | 132 | x, y = start_x, start_y 133 | outline = [Math.Vector(x * tile_width, y * tile_height)] 134 | 135 | while True: 136 | 137 | right = clusters[x+1][y] if x + 1 < width else 0 138 | top = clusters[x][y-1] if y > 0 else 0 139 | left = clusters[x-1][y] if x > 0 else 0 140 | bottom = clusters[x][y+1] if y + 1 < height else 0 141 | 142 | if direction == 0: 143 | # we are going right at the top of the cluster. 144 | 145 | if right == cluster and top != cluster: 146 | # everything is fine, follow along the path 147 | x += 1; 148 | elif top == cluster: 149 | # we have a cluster member at the top. 150 | # go up now and add top-left corner to outline 151 | direction = 3 152 | outline.append(Math.Vector((x) * tile_width, (y) * tile_height)) 153 | y -= 1; 154 | else: 155 | # we have no more cluster members to the right. 156 | # go down now and add top-right corner to outline 157 | direction = 1 158 | outline.append(Math.Vector((x+1) * tile_width, (y) * tile_height)) 159 | 160 | elif direction == 1: 161 | # we are going down at the right of the cluster. 162 | 163 | if bottom == cluster and right != cluster: 164 | # everything is fine, follow along the path 165 | y += 1; 166 | elif right == cluster: 167 | # we have a cluster member to the right. 168 | # go right now and add top-right corner to outline 169 | direction = 0 170 | outline.append(Math.Vector((x+1) * tile_width, (y) * tile_height)) 171 | x += 1; 172 | else: 173 | # we have no more cluster members at the bottom. 174 | # go left now and add bottom-right corner to outline 175 | direction = 2 176 | outline.append(Math.Vector((x+1) * tile_width, (y+1) * tile_height)) 177 | 178 | elif direction == 2: 179 | # we are going left at the bottom of the cluster. 180 | 181 | if left == cluster and bottom != cluster: 182 | # everything is fine, follow along the path 183 | x -= 1 184 | elif bottom == cluster: 185 | # we have a cluster member at the bottom. 186 | # go down now and add bottom-right corner to outline 187 | direction = 1 188 | outline.append(Math.Vector((x+1) * tile_width, (y+1) * tile_height)) 189 | y += 1; 190 | else: 191 | # we have no more cluster members at the left. 192 | # go up now and add bottom-left corner to outline 193 | direction = 3 194 | outline.append(Math.Vector((x) * tile_width, (y+1) * tile_height)) 195 | else: 196 | # we are going up at the left of the cluster. 197 | 198 | if top == cluster and left != cluster: 199 | # everything is fine, follow along the path 200 | y -= 1 201 | elif left == cluster: 202 | # we have a cluster member to the left. 203 | # go left now and add bottom-left corner to outline 204 | direction = 2 205 | outline.append(Math.Vector((x) * tile_width, (y+1) * tile_height)) 206 | x -= 1; 207 | else: 208 | # we have no more cluster members at the top. 209 | # go right now and add top-left corner to outline 210 | direction = 0 211 | outline.append(Math.Vector((x) * tile_width, (y) * tile_height)) 212 | 213 | # find if we've made a full loop and have come back to the start 214 | if direction == 0 and x == start_x and y == start_y: 215 | #outline += [outline[1]] 216 | break 217 | 218 | return outline 219 | 220 | 221 | clusters, clusters_seen = find_clusters() 222 | cluster_outlines = [] 223 | 224 | for cluster in range(1, len(clusters_seen) + 1): 225 | cluster_outlines += [cluster_outline(cluster)] 226 | 227 | return cluster_outlines 228 | 229 | -------------------------------------------------------------------------------- /py2d/Math/Operations.py: -------------------------------------------------------------------------------- 1 | def __intersect_line_line_u(p1, p2, q1, q2): 2 | 3 | d = (q2.y - q1.y) * (p2.x - p1.x) - (q2.x - q1.x) * (p2.y - p1.y) 4 | n1 = (q2.x - q1.x) * (p1.y - q1.y) - (q2.y - q1.y) * (p1.x - q1.x) 5 | n2 = (p2.x - p1.x) * (p1.y - q1.y) - (p2.y - p1.y) * (p1.x - q1.x) 6 | 7 | if d == 0: return None 8 | 9 | u_a = float(n1) / d 10 | u_b = float(n2) / d 11 | 12 | return (u_a, u_b) 13 | 14 | def intersect_poly_lineseg(poly_points, p1, p2): 15 | """Intersect a polygon and a line segment. 16 | 17 | @type poly_points: List 18 | @param poly_points: The list of points in the polygon 19 | 20 | @type p1: Vector 21 | @param p1: The starting point of the line segment 22 | 23 | @type p2: Vector 24 | @param p2: The ending point of the line segment 25 | 26 | @return: The list of intersection points or an empty list 27 | """ 28 | return intersect_linesegs_lineseg(list(zip(poly_points[0:], poly_points[1:])) + [(poly_points[-1], poly_points[0])], p1, p2) 29 | 30 | def intersect_poly_ray(poly_points, p1, p2): 31 | """Intersect a polygon and a ray 32 | 33 | @type poly_points: List 34 | @param poly_points: The list of points in the polygon 35 | 36 | @type p1: Vector 37 | @param p1: The starting point of the ray 38 | 39 | @type p2: Vector 40 | @param p2: The ending point of the ray 41 | 42 | @return: The list of intersection points or an empty list 43 | """ 44 | return intersect_linesegs_ray(list(zip(poly_points[0:], poly_points[1:])) + [(poly_points[-1], poly_points[0])], p1, p2) 45 | 46 | def intersect_line_line(p1, p2, q1, q2): 47 | """Intersect two lines 48 | 49 | @type p1: Vector 50 | @param p1: The first point of the first line 51 | 52 | @type p2: Vector 53 | @param p2: The second point of the first line 54 | 55 | @type q1: Vector 56 | @param q1: The first point of the second line 57 | 58 | @type q2: Vector 59 | @param q2: The second point of the second line 60 | 61 | @return: The point of intersection or None 62 | """ 63 | 64 | ll = __intersect_line_line_u(p1, p2, q1, q2) 65 | 66 | if ll == None: return None 67 | return Vector(p1.x + ll[0] * (p2.x - p1.x) , p1.y + ll[0] * (p2.y - p1.y) ) 68 | 69 | def intersect_lineseg_line(p1, p2, q1, q2): 70 | """Intersect a line segment and a line 71 | 72 | @type p1: Vector 73 | @param p1: The starting point of the line segment 74 | 75 | @type p2: Vector 76 | @param p2: The ending point of the line segment 77 | 78 | @type q1: Vector 79 | @param q1: The first point on the line 80 | 81 | @type q2: Vector 82 | @param q2: The second point on the line 83 | 84 | @return: The point of intersection or None 85 | """ 86 | 87 | ll = __intersect_line_line_u(p1, p2, q1, q2) 88 | 89 | if ll == None: return None 90 | if ll[0] < 0 or ll[0] > 1: return None 91 | 92 | return Vector(p1.x + ll[0] * (p2.x - p1.x) , p1.y + ll[0] * (p2.y - p1.y) ) 93 | 94 | def intersect_lineseg_ray(p1, p2, q1, q2): 95 | """Intersect a line segment and a ray 96 | 97 | @type p1: Vector 98 | @param p1: The starting point of the line segment 99 | 100 | @type p2: Vector 101 | @param p2: The ending point of the line segment 102 | 103 | @type q1: Vector 104 | @param q1: The first point on the ray 105 | 106 | @type q2: Vector 107 | @param q2: The second point on the ray 108 | 109 | @return: The point of intersection or None 110 | """ 111 | 112 | ll = __intersect_line_line_u(p1, p2, q1, q2) 113 | 114 | if ll == None: return None 115 | if ll[0] < 0 or ll[0] > 1: return None 116 | if ll[1] < 0: return None 117 | 118 | return Vector(p1.x + ll[0] * (p2.x - p1.x) , p1.y + ll[0] * (p2.y - p1.y) ) 119 | 120 | def intersect_linesegs_ray(segs, p1, p2): 121 | """Intersect a list of line segments and a ray 122 | 123 | @type segs: List 124 | @param segs: The list of line segments, i.e. a list of 2-tuples of vectors 125 | 126 | @type p1: Vector 127 | @param p1: The first point on the ray 128 | 129 | @type p2: Vector 130 | @param p2: The second point on the ray 131 | 132 | @return: The list of intersections or an empty list 133 | """ 134 | intersect_points = [] 135 | 136 | for line_segment in segs: 137 | intersect = intersect_lineseg_ray(line_segment[0], line_segment[1], p1, p2) 138 | if intersect: 139 | #if line_segment[0] != p2 and line_segment[1] != p2: 140 | intersect_points += [intersect] 141 | 142 | return intersect_points 143 | 144 | def intersect_linesegs_lineseg(segs, p1, p2): 145 | """Intersect a list of line segments and a line segment 146 | 147 | @type segs: List 148 | @param segs: The list of line segments, i.e. a list of 2-tuples of vectors 149 | 150 | @type p1: Vector 151 | @param p1: The first point on the line segment 152 | 153 | @type p2: Vector 154 | @param p2: The second point on the line segment 155 | 156 | @return: The list of intersections or an empty list 157 | """ 158 | intersect_points = [] 159 | 160 | for line_segment in segs: 161 | intersect = intersect_lineseg_lineseg(line_segment[0], line_segment[1], p1, p2) 162 | if intersect: 163 | if line_segment[0] != p2 and line_segment[1] != p2: 164 | intersect_points += [intersect] 165 | 166 | return intersect_points 167 | 168 | def intersect_poly_poly(poly_points1, poly_points2): 169 | """Intersect two polygons 170 | 171 | @type poly_points1: List 172 | @param poly_points1: The list of points of polygon 1 173 | 174 | @type poly_points2: List 175 | @param poly_points2: The list of points of polygon 2 176 | 177 | @return: The list of intersections or an empty list 178 | """ 179 | 180 | return intersect_linesegs_linesegs(list(zip(poly_points1[0:], poly_points1[1:])) + [(poly_points1[-1], poly_points1[0])], list(zip(poly_points2[0:], poly_points2[1:])) + [(poly_points2[-1], poly_points2[0])]) 181 | 182 | def intersect_linesegs_linesegs(segs1, segs2): 183 | """Intersect two lists of line segments 184 | 185 | @type segs1: List 186 | @param segs1: The first list of line segments, i.e. a list of 2-tuples of vectors 187 | 188 | @type segs2: List 189 | @param segs2: The second list of line segments, i.e. a list of 2-tuples of vectors 190 | 191 | @return: The list of intersections or an empty list 192 | """ 193 | intersect_points = [] 194 | for ls1 in segs1: 195 | intersect_points += intersect_linesegs_lineseg(segs2, ls1[0], ls1[1]) 196 | 197 | return intersect_points 198 | 199 | def intersect_lineseg_lineseg(p1, p2, q1, q2): 200 | """Intersect two line segments 201 | 202 | @type p1: Vector 203 | @param p1: The first point on the first line segment 204 | 205 | @type p2: Vector 206 | @param p2: The second point on the first line segment 207 | 208 | @type q1: Vector 209 | @param q1: The first point on the secondline segment 210 | 211 | @type q2: Vector 212 | @param q2: The second point on the second line segment 213 | """ 214 | 215 | if max(q1.x, q2.x) < min(p1.x, p2.x): return None 216 | if min(q1.x, q2.x) > max(p1.x, p2.x): return None 217 | if max(q1.y, q2.y) < min(p1.y, p2.y): return None 218 | if min(q1.y, q2.y) > max(p1.y, p2.y): return None 219 | 220 | ll = __intersect_line_line_u(p1, p2, q1, q2) 221 | 222 | if ll == None: return None 223 | if ll[0] < 0 or ll[0] > 1: return None 224 | if ll[1] < 0 or ll[1] > 1: return None 225 | 226 | return Vector(p1.x + ll[0] * (p2.x - p1.x) , p1.y + ll[0] * (p2.y - p1.y) ) 227 | 228 | def check_intersect_lineseg_lineseg(p1, p2, q1, q2): 229 | """Check if two line segments intersect - this can conserve memory if we don't need the intersection points 230 | 231 | @type p1: Vector 232 | @param p1: The first point on the first line segment 233 | 234 | @type p2: Vector 235 | @param p2: The second point on the first line segment 236 | 237 | 238 | @type q1: Vector 239 | @param q1: The first point on the secondline segment 240 | 241 | @type q2: Vector 242 | @param q2: The second point on the second line segment 243 | 244 | """ 245 | 246 | if max(q1.x, q2.x) < min(p1.x, p2.x): return False 247 | if min(q1.x, q2.x) > max(p1.x, p2.x): return False 248 | if max(q1.y, q2.y) < min(p1.y, p2.y): return False 249 | if min(q1.y, q2.y) > max(p1.y, p2.y): return False 250 | 251 | ll = __intersect_line_line_u(p1, p2, q1, q2) 252 | 253 | if ll == None: return False 254 | if ll[0] < 0 or ll[0] > 1: return False 255 | if ll[1] < 0 or ll[1] > 1: return False 256 | 257 | return True 258 | 259 | def distance_point_lineseg_squared(p, a, b): 260 | """Get the shortest distance from a point to a line segment. 261 | 262 | This can either be a perpendicular to a point on the line segment or the straight connection of p to one of the end points. 263 | 264 | @type p: Vector 265 | @param p: The point to compare to the line segment 266 | 267 | @type a: Vector 268 | @param a: The first point on the first line segment 269 | 270 | @type b: Vector 271 | @param b: The second point on the first line segment 272 | """ 273 | 274 | 275 | ap = p - a 276 | ab = b - a 277 | bp = p - b 278 | 279 | r = float(ap * ab) / ab.length_squared 280 | 281 | if r <= 0: return ap.length_squared 282 | if r >= 1: return bp.length_squared 283 | 284 | s = ((a.y - p.y) * (b.x - a.x) - (a.x - p.x) * (b.y - a.y)) 285 | 286 | return float(s * s) / ab.length_squared 287 | 288 | # ap_squared = (p - a).get_length_squared() 289 | # bp_squared = (p - b).get_length_squared() 290 | # ap_prime = a * b 291 | 292 | # perpendicular_squared = abs( ap_squared - ap_prime * ap_prime ) 293 | 294 | # return min(ap_squared, bp_squared, perpendicular_squared) 295 | 296 | 297 | def distance_point_line(p, a, b): 298 | return abs((p.x - a.x) * (b.y - a.y) - (p.y - a.y) * (b.x - a.x)) / math.sqrt((b.x - a.x) * (b.x - a.x) + (b.y - a.y) * (b.y - a.y)) 299 | 300 | def point_in_triangle(p, a,b,c): 301 | to = point_orientation(a,b,c) 302 | return point_orientation(a,b,p) == to and point_orientation(b,c,p) == to and point_orientation(a,p,c) == to 303 | 304 | def point_orientation(a,b,c): 305 | """Returns the orientation of the triangle a, b, c. 306 | 307 | Return True if a,b,c are oriented clock-wise. 308 | """ 309 | return (b.x - a.x) * (c.y - a.y) - (c.x - a.x) * (b.y - a.y) > 0 310 | 311 | -------------------------------------------------------------------------------- /py2d/Math/Polygon.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | from py2d.Math.Vector import * 3 | from py2d.Math.Operations import * 4 | 5 | def tip_decorator_pointy(a,b,c,d,is_cw): 6 | intersection = intersect_line_line(a,b,c,d) 7 | return [intersection] 8 | 9 | def tip_decorator_flat(a,b,c,d,is_cw): 10 | return [] 11 | 12 | 13 | class Polygon(object): 14 | """Class for 2D Polygons. 15 | 16 | A Polgon behaves like a list of points, but the last point in the list is assumed to be connected back to the first point. 17 | """ 18 | 19 | def __init__(self): 20 | """Create a new, empty Polygon object""" 21 | self.points = [] 22 | 23 | @staticmethod 24 | def regular(center, radius, points): 25 | """Create a regular polygon 26 | 27 | @type center: Vector 28 | @param center: The center point of the polygon 29 | 30 | @type radius: float 31 | @param radius: The radius of the polygon 32 | 33 | @type points: int 34 | @param points: The number of polygon points. 3 will create a triangle, 4 a square, and so on. 35 | """ 36 | 37 | angular_increment = 2 * math.pi / points 38 | 39 | p = Polygon() 40 | for i in range(points): 41 | p.add_point( Vector(center.x + radius * math.cos(i * angular_increment), center.y + radius * math.sin(i * angular_increment)) ) 42 | 43 | return p 44 | 45 | @staticmethod 46 | def from_pointlist(points): 47 | """Create a polygon from a list of points 48 | 49 | @type points: List 50 | @param points: List of Vectors that make up the polygon 51 | """ 52 | 53 | p = Polygon() 54 | p.points = points 55 | return p 56 | 57 | @staticmethod 58 | def from_tuples(tuples): 59 | """Create a polygon from 2-tuples 60 | 61 | @type tuples: List 62 | @param tuples: List of tuples of x,y coordinates 63 | """ 64 | 65 | p = Polygon() 66 | p.points = [ Vector(t[0], t[1]) for t in tuples ] 67 | return p 68 | 69 | def add_point(self, point): 70 | """Add a new point at the end of the polygon 71 | 72 | @type point: Vector 73 | @param point: The new Vector to add to the polygon 74 | """ 75 | self.points.append(point) 76 | 77 | def add_points(self, points): 78 | """Add multiple new points to the end of the polygon 79 | 80 | @type points: List 81 | @param points: A list of Vectors to add 82 | """ 83 | self.points.extend(points) 84 | 85 | def get_centerpoint(self): 86 | """Get the center of mass for the polygon""" 87 | 88 | xes = [p.x for p in self.points] 89 | yes = [p.y for p in self.points] 90 | 91 | return Vector(float(sum(xes)) / len(xes), float(sum(yes)) / len(yes) ) 92 | 93 | def sort_around(self, center): 94 | """Re-order points by their angle with respect to a certain center point""" 95 | 96 | def angle_from_origin(p): 97 | phi = math.acos(float(p.x) / p.get_length()) 98 | if p.y < 0: phi = 2 * math.pi - phi 99 | return phi 100 | 101 | 102 | self.points.sort(key=lambda p: angle_from_origin(p - center)) 103 | 104 | def __repr__(self): 105 | pts = ["(%.2f, %.2f)" % (p.x, p.y) for p in self.points] 106 | return "Polygon [%s]" % ", ".join(pts) 107 | 108 | def __getitem__(self, key): 109 | return self.points[key] 110 | 111 | def __setitem__(self, key, value): 112 | self.points[key] = value 113 | 114 | def __delitem__(self, key): 115 | del self.points[key] 116 | 117 | def __len__(self): 118 | return len(self.points) 119 | 120 | def __eq__(self, other): 121 | if not isinstance(other, Polygon): return False 122 | return self.points == other.points 123 | 124 | def clone(self): 125 | """Return a shallow copy of the polygon (points are not cloned)""" 126 | poly = Polygon() 127 | poly.points = [ p for p in self.points ] 128 | return poly 129 | 130 | def clone_ccw(self): 131 | p = self.clone() 132 | if p.is_clockwise(): p.flip() 133 | return p 134 | 135 | def clone_cw(self): 136 | p = self.clone() 137 | if not p.is_clockwise(): p.flip() 138 | return p 139 | 140 | @staticmethod 141 | def boolean_operation(polygon_a, polygon_b, operation): 142 | """Perform a boolean operation on two polygons. 143 | 144 | Reference: 145 | Avraham Margalit. An Algorithm for Computing the Union, Intersection or Difference of Two Polygons. 146 | Comput & Graphics VoI. 13, No 2, pp 167-183, 1989 147 | 148 | This implementation will only consider island-type polygons, so control tables are replaced by small boolean expressions. 149 | 150 | @type polygon_a: Polygon 151 | @param polygon_a: The first polygon 152 | 153 | @type polygon_b: Polygon 154 | @param polygon_b: The second polygon 155 | 156 | @type operation: char 157 | @param operation: The operation to perform. Either 'u' for union, 'i' for intersection, or 'd' for difference. 158 | """ 159 | 160 | def inorder_extend(v, v1, v2, ints): 161 | """Extend a sequence v by points ints that are on the segment v1, v2""" 162 | 163 | k, r = None, False 164 | if v1.x < v2.x: 165 | k = lambda i: i.x 166 | r = True 167 | elif v1.x > v2.x: 168 | k = lambda i: i.x 169 | r = False 170 | elif v1.y < v2.y: 171 | k = lambda i: i.y 172 | r = True 173 | else: 174 | k = lambda i: i.y 175 | r = False 176 | 177 | l = [ (p, 2) for p in sorted(ints, key=k, reverse=r) ] 178 | 179 | i = next((i for i, p in enumerate(v) if p[0] == v2), -1) 180 | assert(i>=0) 181 | 182 | for e in l: 183 | v.insert(i, e) 184 | 185 | if operation not in 'uid' or len(operation) > 1: raise ValueError("Operation must be 'u', 'i' or 'd'!") 186 | 187 | # for union and intersection, we want the same orientation on both polygons. for difference, we want different orientation. 188 | matching_orientation = polygon_a.is_clockwise() == polygon_b.is_clockwise() 189 | if matching_orientation != (operation != 'd'): 190 | 191 | polygon_b = polygon_b.clone() 192 | polygon_b.flip() 193 | 194 | # initialize vector rings 195 | v_a = [(p, polygon_b.contains_point(p)) for p in polygon_a.points] 196 | v_b = [(p, polygon_a.contains_point(p)) for p in polygon_b.points] 197 | 198 | 199 | # find all intersections 200 | intersections_a = defaultdict(list) 201 | intersections_b = defaultdict(list) 202 | for a1, a2 in list(zip(v_a, v_a[1:])) + [(v_a[-1], v_a[0])]: 203 | for b1, b2 in list(zip(v_b, v_b[1:])) + [(v_b[-1], v_b[0])]: 204 | i = intersect_lineseg_lineseg(a1[0],a2[0],b1[0],b2[0]) 205 | if i: 206 | intersections_a[(a1[0],a2[0])].append(i) 207 | intersections_b[(b1[0],b2[0])].append(i) 208 | 209 | 210 | # extend vector rings by intersections 211 | for k, v in intersections_a.iteritems(): 212 | inorder_extend(v_a, k[0], k[1], v) 213 | 214 | for k, v in intersections_b.iteritems(): 215 | inorder_extend(v_b, k[0], k[1], v) 216 | 217 | 218 | edge_fragments = defaultdict(list) 219 | 220 | def extend_fragments(v, poly, fragment_type): 221 | for v1, v2 in list(zip(v, v[1:])) + [(v[-1], v[0])]: 222 | if v1[1] == fragment_type or v2[1] == fragment_type: 223 | # one of the vertices is of the required type 224 | edge_fragments[v1[0]].append( v2[0] ) 225 | 226 | elif v1[1] == 2 and v2[1] == 2: 227 | # we have two boundary vertices 228 | m = (v1[0] + v2[0]) / 2.0 229 | t = poly.contains_point(m) 230 | if t == fragment_type or t == 2: 231 | edge_fragments[v1[0]].append( v2[0] ) 232 | 233 | fragment_type_a = 1 if operation == 'i' else 0 234 | fragment_type_b = 1 if operation != 'u' else 0 235 | 236 | extend_fragments(v_a, polygon_b, fragment_type_a) 237 | extend_fragments(v_b, polygon_a, fragment_type_b) 238 | 239 | def print_edge(): 240 | for k in edge_fragments.keys(): 241 | for v in edge_fragments[k]: 242 | print("%s -> %s" % (k, v)) 243 | 244 | 245 | 246 | 247 | output = [] 248 | while edge_fragments: 249 | start = edge_fragments.keys()[0] 250 | current = edge_fragments[start][0] 251 | sequence = [start] 252 | 253 | # follow along the edge fragments sequence 254 | while not current in sequence: 255 | sequence.append(current) 256 | current = edge_fragments[current][0] 257 | 258 | 259 | # get only the cyclic part of the sequence 260 | sequence = sequence[sequence.index(current):] 261 | 262 | for c,n in list(zip(sequence, sequence[1:])) + [(sequence[-1], sequence[0])]: 263 | edge_fragments[c].remove(n) 264 | 265 | if not edge_fragments[c]: 266 | del edge_fragments[c] 267 | 268 | 269 | 270 | output.append(Polygon.from_pointlist(Polygon.simplify_sequence(sequence))) 271 | 272 | return output 273 | 274 | @staticmethod 275 | def simplify_sequence(seq): 276 | """Simplify a point sequence so that no subsequent points are on the same line""" 277 | 278 | i = 0 279 | while i < len(seq): 280 | p, c, n = seq[i-1], seq[i], seq[(i + 1) % len(seq)] 281 | 282 | if p == c or c == n or p == n or distance_point_lineseg_squared(c, p, n) < EPSILON: 283 | del seq[i] 284 | else: 285 | i+=1 286 | return seq 287 | 288 | 289 | @staticmethod 290 | def union(polygon_a, polygon_b): 291 | """Get the union of polygon_a and polygon_b 292 | 293 | @type polygon_a: Polygon 294 | @param polygon_a: The first polygon 295 | 296 | @type polygon_b: Polygon 297 | @param polygon_b: The second polygon 298 | 299 | @return: A list of fragment polygons 300 | """ 301 | return Polygon.boolean_operation(polygon_a, polygon_b, 'u') 302 | 303 | @staticmethod 304 | def intersect(polygon_a, polygon_b): 305 | """Intersect the area of polygon_a and polygon_b 306 | 307 | @type polygon_a: Polygon 308 | @param polygon_a: The first polygon 309 | 310 | @type polygon_b: Polygon 311 | @param polygon_b: The second polygon 312 | 313 | @return: A list of fragment polygons 314 | """ 315 | return Polygon.boolean_operation(polygon_a, polygon_b, 'i') 316 | 317 | @staticmethod 318 | def subtract(polygon_a, polygon_b): 319 | """Subtract the area of polygon_b from polygon_a 320 | 321 | @type polygon_a: Polygon 322 | @param polygon_a: The first polygon 323 | 324 | @type polygon_b: Polygon 325 | @param polygon_b: The second polygon 326 | 327 | @return: A list of fragment polygons 328 | """ 329 | return Polygon.boolean_operation(polygon_a, polygon_b, 'd') 330 | 331 | 332 | @staticmethod 333 | def offset(polys, amount, tip_decorator=tip_decorator_pointy, debug_callback=None): 334 | """Shrink or grow a polygon by a given amount. 335 | 336 | Reference: 337 | Xiaorui Chen and Sara McMains. Polygon Offsetting by Computing Winding Numbers 338 | Proceedings of IDETC/CIE 2005. ASME 2005 International Design Engineering Technical Conferences & 339 | Computers and Information in Engineering Conference 340 | 341 | @type polys: List 342 | @param polys: The list of polygons to offset. Counter-clockwise polygons will be treated as islands, clockwise polygons as holes. 343 | 344 | @type amount: float 345 | @param amount: The amount to offset. Positive values will grow the polygon, negative values will shrink. 346 | 347 | @type tip_decorator: function 348 | @param tip_decorator: A function used for decorating tips generated in the offset polygon 349 | """ 350 | 351 | # fix passing a single polygon instead of a poly list 352 | if isinstance(polys, Polygon): polys = [polys] 353 | 354 | if amount == 0: return polys 355 | 356 | def offset_poly(poly): 357 | r = [] 358 | for i in range(len(poly.points)): 359 | c, n, n2 = poly.points[i], poly.points[ (i+1) % len(poly) ], poly.points[ (i+2) % len(poly) ] 360 | is_convex = point_orientation(c,n,n2) 361 | 362 | unit_normal = (n - c).normal().normalize() 363 | unit_normal2 = (n2 - n).normal().normalize() 364 | 365 | c_prime = c + unit_normal * amount 366 | n_prime = n + unit_normal * amount 367 | n2_prime = n2 + unit_normal2 * amount 368 | n_prime2 = n + unit_normal2 * amount 369 | 370 | r.append(c_prime) 371 | r.append(n_prime) 372 | 373 | if is_convex == (amount > 0): 374 | r.append(n) 375 | else: 376 | r.extend(tip_decorator(c_prime, n_prime, n_prime2, n2_prime, True)) 377 | 378 | 379 | return r 380 | 381 | 382 | def decompose(poly_points): 383 | """Decompose a possibly self-intersecting polygon into multiple simple polygons.""" 384 | 385 | def inorder_extend(v, v1, v2, ints): 386 | """Extend a sequence v by points ints that are on the segment v1, v2""" 387 | 388 | k, r = None, False 389 | if v1.x < v2.x: 390 | k = lambda i: i.x 391 | r = True 392 | elif v1.x > v2.x: 393 | k = lambda i: i.x 394 | r = False 395 | elif v1.y < v2.y: 396 | k = lambda i: i.y 397 | r = True 398 | else: 399 | k = lambda i: i.y 400 | r = False 401 | 402 | l = sorted(ints, key=k, reverse=r) 403 | i = next((i for i, p in enumerate(v) if p == v2), -1) 404 | assert(i>=0) 405 | 406 | for e in l: 407 | v.insert(i, e) 408 | 409 | pts = [p for p in poly_points] 410 | 411 | # find self-intersections 412 | ints = defaultdict(list) 413 | for i in range(len(pts)): 414 | for j in range(i+1, len(pts)): 415 | a = pts[i] 416 | b = pts[(i+1)%len(pts)] 417 | c = pts[j] 418 | d = pts[(j+1)%len(pts)] 419 | 420 | x = intersect_lineseg_lineseg(a, b, c, d) 421 | if x and x not in (a,b,c,d): 422 | ints[(a,b)].append( x ) 423 | ints[(c,d)].append( x ) 424 | 425 | 426 | # add self-intersection points to poly 427 | for k, v in ints.iteritems(): 428 | inorder_extend(pts, k[0], k[1], v) 429 | 430 | # build a list of loops 431 | out = [] 432 | while pts: 433 | 434 | # build up a list of seen points until we re-visit one - a loop! 435 | seen = [] 436 | for p in pts + [pts[0]]: 437 | if p not in seen: 438 | seen.append(p) 439 | else: 440 | break 441 | 442 | loop = seen[seen.index(p):] 443 | 444 | # remove the loop from pts 445 | for p in loop: 446 | pts.remove(p) 447 | 448 | out.append(loop) 449 | 450 | return out 451 | 452 | 453 | def winding_number(p, raw): 454 | 455 | # compute winding number of point 456 | #http://softsurfer.com/Archive/algorithm_0103/algorithm_0103.htm 457 | 458 | wn = 0 459 | for pp in raw: 460 | for a,b in list(zip(pp, pp[1:])) + [(pp[-1], pp[0])]: 461 | if a.y < p.y and b.y > p.y: 462 | i = intersect_lineseg_ray(a,b,p,p+VECTOR_X) 463 | if i and i.x > p.x: 464 | wn -= 1 465 | 466 | if a.y > p.y and b.y < p.y: 467 | i = intersect_lineseg_ray(a,b,p,p+VECTOR_X) 468 | if i and i.x > p.x: 469 | wn += 1 470 | return wn 471 | 472 | 473 | def find_point_in_poly(pts): 474 | # find point inside of pts according to http://www.exaflop.org/docs/cgafaq/cga2.html#Subject%202.06:%20How%20do%20I%20find%20a%20single%20point%20inside%20a%20simple%20polygonu 475 | 476 | if len(pts) == 3: return (pts[0] + pts[1] + pts[2]) / 3 477 | 478 | 479 | # find convex point v 480 | v = None 481 | for i in range(len(pts)): 482 | a, v, b = pts[i-1], pts[i], pts[(i+1) % len(pts)] 483 | if not point_orientation(a,v,b): break 484 | 485 | 486 | q_s = [ q for q in pts if q not in [a,v,b] and point_in_triangle(q, a,v,b) ] 487 | 488 | if len(pts) >= 5: 489 | dbg(v, 0x000000, "V") 490 | dbg(a, 0x000000, "A") 491 | dbg(b, 0x000000, "B") 492 | 493 | for q in q_s: 494 | dbg(q, 0x000000, "Q") 495 | 496 | if q_s: 497 | # return the midpoint of the shortest diagonal qv 498 | q = min(q_s, key=lambda q: (q-v).length_squared ) 499 | 500 | 501 | return (q - v) / 2.0 + v 502 | else: 503 | # no diagonal from v, return midpoint of ab instead 504 | return (b - a) / 2.0 + a 505 | 506 | 507 | 508 | def dbg(p, color, text): 509 | if debug_callback: 510 | debug_callback(p,color,text) 511 | 512 | 513 | raw = [] 514 | for poly in polys: 515 | 516 | offset = offset_poly(poly) 517 | decomp = decompose( offset ) 518 | 519 | raw.extend( decomp ) 520 | 521 | 522 | #print "\n-----------------\n" 523 | output = [] 524 | for poly in raw: 525 | 526 | poly = Polygon.simplify_sequence(poly) 527 | p = find_point_in_poly( poly ) 528 | wn = winding_number(p, raw) 529 | 530 | 531 | dbg(p, 0xffff00, "%d %d" % (wn, len(poly))) 532 | #print "%d %d" % (wn, len(poly)) 533 | 534 | # shrink: include poly in solution only if winding number of that region is greater than 1 535 | # grow: include only if winding number is 1 536 | if False or (amount < 0 and wn > 0) or (amount > 0 and wn == 1): 537 | output.append(Polygon.from_pointlist(poly)) 538 | 539 | 540 | 541 | return output 542 | 543 | @staticmethod 544 | def convex_decompose(polygon, holes=[], debug_callback=None): 545 | """Decompose a polygon into convex parts 546 | 547 | Reference: 548 | Jose Fernandez, Boglarka Toth, Lazaro Canovas and Blas Pelegrin. A practical algorithm for decomposing polygonal domains into convex polygons by diagonals 549 | Trabajos de Investigacion Operativa Volume 16, Number 2, 367-387. 550 | doi 10.1007/s11750-008-0055-2 551 | 552 | @type polygon: Polygon 553 | @param polygon: The possibly concave polygon to decompose. 554 | 555 | @type holes: List 556 | @param holes: A list of polygons inside of polygon to be considered as holes 557 | """ 558 | 559 | def dbg(p, c, t): 560 | if debug_callback: debug_callback(p,c,t) 561 | 562 | if polygon.is_self_intersecting(): return [] 563 | if polygon.is_convex() and not holes: return [polygon] 564 | 565 | if not polygon.is_clockwise(): polygon = polygon.clone().flip() 566 | 567 | p = [v for v in polygon.points] 568 | out = [] 569 | 570 | class G: pass 571 | g = G() 572 | g.del_index = 0 573 | 574 | def check_decomp(l, p_minus_l, p): 575 | """check the decomposition l of polygon p""" 576 | l_v = [p[v] for v in l] 577 | 578 | xes = [v.x for v in l_v] 579 | x_min,x_max = min(xes), max(xes) 580 | 581 | yes = [v.y for v in l_v] 582 | y_min,y_max = min(yes), max(yes) 583 | 584 | def is_notch(i): 585 | return not point_orientation(p[i-1], p[i], p[(i+1) % len(p)]) 586 | 587 | # extra criterion MP3: only accept if at least one of the diagonal points is a notch 588 | if not (is_notch(l[0]) or is_notch(l[-1])): return False 589 | if not Polygon.is_convex_s(l_v): return False 590 | 591 | # find only notches in p_minus_l that are within the axis-aligned bounding box of l 592 | pts = (v for v in p_minus_l if p[v].x <= x_max and p[v].x >= x_min and p[v].y <= y_max and p[v].y >= y_min and is_notch(v)) 593 | 594 | # decomposition is invalid if any point in p is in l 595 | if pts: 596 | for v in pts: 597 | if Polygon.contains_point_s(l_v,p[v]) == 1: return False 598 | 599 | return True 600 | else: 601 | return True 602 | 603 | def handle_holes(l, d_a, d_b): 604 | 605 | # TODO add hole handling! 606 | 607 | closest_hole = None 608 | intersecting = True 609 | 610 | 611 | # check if the polygon intersects a hole 612 | closest_intersection = None 613 | while intersecting: 614 | intersecting = False 615 | for hole in holes: 616 | for a,b in list(zip(hole, hole[1:])) + [(hole[-1],hole[0])]: 617 | i = intersect_lineseg_lineseg(d_a, d_b, a, b) 618 | if i and i not in [a,b]: 619 | if not closest_intersection or (closest_intersection - d_b).length_squared > (i-d_b).length_squared: 620 | closest_intersection = i 621 | d_a = min([a,b], key = lambda v: (v-d_b).length_squared) 622 | closest_hole = hole 623 | intersecting = True 624 | 625 | # check if the polygon contains a hole 626 | if not closest_hole: 627 | closest_intersection = None 628 | for hole in holes: 629 | if Polygon.contains_point_s(l, hole[0]): 630 | i = min(hole, key = lambda v: (v- d_b).length_squared) 631 | if not closest_intersection or (closest_intersection - d_b).length_squared > (i-d_b).length_squared: 632 | closest_intersection = i 633 | d_a = i 634 | closest_hole = hole 635 | 636 | if closest_hole: 637 | absorb_hole(d_b, closest_hole, d_a) 638 | return False 639 | else: 640 | return True 641 | 642 | def handle_holes_convex(): 643 | d_b = p[0] 644 | 645 | closest_intersection = None 646 | for hole in holes: 647 | i = min(hole, key=lambda v: (v-d_b).length_squared) 648 | if not closest_intersection or (closest_intersection - d_b).length_squared > (i-d_b).length_squared: 649 | closest_intersection = i 650 | d_a = i 651 | closest_hole = hole 652 | 653 | absorb_hole(d_b, closest_hole, d_a) 654 | 655 | 656 | def absorb_hole(d_b, closest_hole, d_a): 657 | 658 | holes.remove(closest_hole) 659 | if Polygon.is_clockwise_s(closest_hole): closest_hole = closest_hole.clone_ccw() 660 | 661 | i = closest_hole.points.index(d_a) 662 | j = p.index(d_b) 663 | 664 | extension = [d_b] + closest_hole.points[i:] + closest_hole.points[:i+1] 665 | 666 | p[j:j] = extension 667 | 668 | #print "hole!" 669 | 670 | 671 | 672 | 673 | def try_decompose(i_start): 674 | """try to decompose p by a convex polygon starting at index i_start""" 675 | 676 | lookat = 1 677 | 678 | 679 | # find the next notch index 680 | i_extend = next( ( i for i in itertools.chain(range(i_start+1, len(p)), range(0,i_start+1)) if not point_orientation( p[i-1], p[i], p[(i+1) % len(p)] ) ) ) 681 | 682 | # build provisional l 683 | l = list(range(i_start,i_extend+1)) if i_start < i_extend else list(range(i_start,len(p))) + list(range(0,i_extend+1)) 684 | 685 | #print "l=%s" % l 686 | 687 | # remove elements from the end of l until we have a valid decomposition 688 | p_minus_l = [k for k in range(len(p)) if k not in l] 689 | while len(l) > 2 and not check_decomp(l, p_minus_l, p): 690 | l_pop = l.pop() 691 | p_minus_l.insert(0, l_pop) 692 | 693 | #print "l'=%s" % l 694 | 695 | # try to extend l counter-clockwise - find next notch 696 | i_extend2 = next( ( i for i in itertools.chain((i_start,-1,-1), range(len(p)-1,i_start, -1)) if not point_orientation( p[i-1], p[i], p[(i+1) % len(p)] ) ) ) 697 | 698 | l2 = list(range(i_extend2,len(p))) + list(range(0,i_start)) if i_extend2 > i_start else list(range(i_extend2,i_start)) 699 | 700 | #print "l2=%s" % l2 701 | 702 | l = l2 + l 703 | 704 | # remove elements from the start of l until we have a valid decomposition 705 | p_minus_l = [k for k in range(len(p)) if k not in l] 706 | while len(l) > 2 and not check_decomp(l, p_minus_l, p): 707 | p_minus_l.append(l[0]) 708 | del l[0] 709 | 710 | 711 | #print "l*=%s" % l 712 | 713 | #print "i_start=%d, i_extend=%d, i_extend2=%d" % (i_start, i_extend, i_extend2) 714 | 715 | # do we still have enough points for a convex poly? if not, give up for this starting point 716 | if len(l) <= 2: return False 717 | 718 | # we now have a diagonal l[0] , l[-1] creating the convex poly l 719 | 720 | 721 | # Does the diagonal cut a hole or does the new polygon contain a hole? if so, incorporate and try again 722 | if not handle_holes( [p[v] for v in l], p[l[0]], p[l[-1]]): return False 723 | 724 | # we didn't cross or contain any holes, make a poly and remove extaneous points 725 | #print "poly! %s" % l 726 | 727 | out.append(Polygon.from_pointlist([p[k] for k in l])) 728 | for v in sorted(l[1:-1], reverse=True): 729 | dbg(p[v], (255,0,255), "del %d" % g.del_index) 730 | g.del_index += 1 731 | del p[v] 732 | 733 | return True 734 | 735 | #print "-----" 736 | 737 | if Polygon.is_convex_s(p) and holes: handle_holes_convex() 738 | 739 | i = 0 740 | while len(p) > 3 and not Polygon.is_convex_s(p): 741 | if not try_decompose(i): 742 | i+= 1 743 | 744 | if Polygon.is_convex_s(p) and holes: handle_holes_convex() 745 | 746 | #print "......" 747 | 748 | i = i % len(p) 749 | 750 | 751 | 752 | if len(p) >= 3: 753 | out.append(Polygon.from_pointlist(p)) 754 | elif len(p) > 0: 755 | raise Exception("There are some points left over: %s" % p) 756 | 757 | return out 758 | 759 | def is_self_intersecting(self): 760 | 761 | for i in range(len(self.points)): 762 | for j in range(i+1, len(self.points)): 763 | a = self.points[i] 764 | b = self.points[(i+1)%len(self.points)] 765 | c = self.points[j] 766 | d = self.points[(j+1)%len(self.points)] 767 | 768 | if not (b == c or d == a): 769 | if check_intersect_lineseg_lineseg(a, b, c, d): return True 770 | 771 | return False 772 | 773 | def is_clockwise(self): 774 | """Determines whether the polygon has a clock-wise orientation.""" 775 | return Polygon.is_clockwise_s(self.points) 776 | 777 | @staticmethod 778 | def is_clockwise_s(pts): 779 | # get index of point with minimal x value 780 | i_min = min(range(len(pts)), key=lambda i: pts[i].x) 781 | 782 | # get previous, current and next points 783 | a = pts[i_min-1] 784 | b = pts[i_min] 785 | c = pts[(i_min+1) % len(pts)] 786 | 787 | return point_orientation(a,b,c) 788 | 789 | 790 | def is_convex(self): 791 | """Determines whether the polygon is convex.""" 792 | return Polygon.is_convex_s(self.points) 793 | 794 | @staticmethod 795 | def is_convex_s(poly_points): 796 | """Determines whether a sequence of points forms a convex polygon.""" 797 | # get orientation of first point 798 | ori = point_orientation(poly_points[-1], poly_points[0], poly_points[1]) 799 | 800 | for i in range(1,len(poly_points)): 801 | p, c, n = poly_points[i-1], poly_points[i], poly_points[(i+1) % len(poly_points)] 802 | 803 | if point_orientation(p,c,n) != ori: 804 | return False 805 | 806 | return True 807 | 808 | 809 | def flip(self): 810 | """Reverses the orientation of the polygon""" 811 | self.points.reverse() 812 | return self 813 | 814 | def contains_point(self, p): 815 | """Checks if p is contained in the polygon, or on the boundary. 816 | 817 | @return: 0 if outside, 1 if in the polygon, 2 if on the boundary. 818 | """ 819 | return Polygon.contains_point_s(self.points, p) 820 | 821 | @staticmethod 822 | def contains_point_s(pts, p) : 823 | """Checks if the polygon defined by the point list pts contains the point p""" 824 | 825 | # see if we find a line segment that p is on 826 | for a,b in list(zip(pts[0:], pts[1:])) + [(pts[-1], pts[0])]: 827 | d = distance_point_lineseg_squared(p, a, b) 828 | if d < EPSILON * EPSILON: return 2 829 | 830 | # p is not on the boundary, cast ray and intersect to see if we are inside 831 | intersections = set(intersect_poly_ray(pts, p, p + Vector(1,0))) 832 | 833 | # filter intersection points that are boundary points 834 | for int_point in filter(lambda x: x in pts, intersections): 835 | 836 | i = pts.index(int_point) 837 | prv = pts[i-1] 838 | nxt = pts[(i+1) % len(pts)] 839 | 840 | if point_orientation(p, int_point, nxt) == point_orientation(p,int_point, prv): 841 | intersections.remove(int_point) 842 | 843 | # we are inside if we have an odd amount of polygon intersections 844 | return 1 if len(intersections) % 2 == 1 else 0 845 | 846 | def as_tuple_list(self): 847 | return [(p.x, p.y) for p in self.points] 848 | 849 | def get_width(self): 850 | return self.right - self.left 851 | 852 | def get_height(self): 853 | return self.bottom - self.top 854 | 855 | def get_left(self): 856 | return min(self.points, key=lambda p: p.x).x 857 | 858 | def get_right(self): 859 | return max(self.points, key=lambda p: p.x).x 860 | 861 | def get_top(self): 862 | return min(self.points, key=lambda p: p.y).y 863 | 864 | def get_bottom(self): 865 | return max(self.points, key=lambda p: p.y).y 866 | 867 | append = add_point 868 | extend = add_points 869 | 870 | center = property(get_centerpoint) 871 | 872 | left = property(get_left) 873 | right = property(get_right) 874 | 875 | top = property(get_top) 876 | bottom = property(get_bottom) 877 | 878 | width = property(get_width) 879 | height = property(get_height) 880 | -------------------------------------------------------------------------------- /py2d/Math/Transform.py: -------------------------------------------------------------------------------- 1 | class Transform(object): 2 | """Class for representing affine transformations""" 3 | 4 | def __init__(self, data): 5 | self.data = data 6 | 7 | @staticmethod 8 | def unit(): 9 | """Get a new unit tranformation""" 10 | return Transform([[1, 0, 0], 11 | [0, 1, 0], 12 | [0, 0, 1]]) 13 | 14 | @staticmethod 15 | def move(dx, dy): 16 | """Get a transformation that moves by dx, dy""" 17 | return Transform([[1, 0, dx], 18 | [0, 1, dy], 19 | [0, 0, 1]]) 20 | 21 | @staticmethod 22 | def rotate(phi): 23 | """Get a transformation that rotates by phi""" 24 | return Transform([[math.cos(phi), -math.sin(phi), 0], 25 | [math.sin(phi), math.cos(phi), 0], 26 | [0, 0, 1]]) 27 | 28 | @staticmethod 29 | def rotate_around(cx, cy, phi): 30 | """Get a transformation that rotates around (cx, cy) by phi""" 31 | return Transform.move(cx, cy) * Transform.rotate(phi) * Transform.move(-cx, -cy) 32 | 33 | @staticmethod 34 | def scale(sx, sy): 35 | """Get a transformation that scales by sx, sy""" 36 | return Transform([[sx, 0, 0], 37 | [0, sy, 0], 38 | [0, 0, 1]]) 39 | @staticmethod 40 | def mirror_x(): 41 | """Get a transformation that mirrors along the x axis""" 42 | return Transform([[-1, 0, 0], 43 | [ 0, 1, 0], 44 | [ 0, 0, 1]]) 45 | 46 | @staticmethod 47 | def mirror_y(): 48 | """Get a transformation that mirrors along the y axis""" 49 | return Transform([[ 1, 0, 0], 50 | [ 0,-1, 0], 51 | [ 0, 0, 1]]) 52 | 53 | def __add__(self, b): 54 | t = Transform() 55 | t.data = [[self.data[x][y] + b.data[x][y] for y in range(3)] for x in range(3)] 56 | return t 57 | 58 | def __sub__(self, b): 59 | t = Transform() 60 | t.data = [[self.data[x][y] - b.data[x][y] for y in range(3)] for x in range(3)] 61 | return t 62 | 63 | def __mul__(self, val): 64 | 65 | if isinstance(val, Vector): 66 | 67 | x = val.x * self.data[0][0] + val.y * self.data[0][1] + self.data[0][2] 68 | y = val.x * self.data[1][0] + val.y * self.data[1][1] + self.data[1][2] 69 | 70 | return Vector(x,y) 71 | 72 | elif isinstance(val, Transform): 73 | data = [[0 for y in range(3)] for x in range(3)] 74 | for i in range(3): 75 | for j in range(3): 76 | for k in range(3): 77 | data[i][j] += self.data[i][k] * val.data[k][j] 78 | 79 | return Transform(data) 80 | 81 | elif isinstance(val, Polygon): 82 | p_transform = [ self * v for v in val.points ] 83 | return Polygon.from_pointlist(p_transform) 84 | 85 | else: 86 | raise ValueError("Unknown multiplier: %s" % val) 87 | 88 | 89 | -------------------------------------------------------------------------------- /py2d/Math/Vector.py: -------------------------------------------------------------------------------- 1 | import math 2 | from collections import defaultdict 3 | 4 | class Vector(object): 5 | """Class for 2D Vectors. 6 | 7 | Vectors v have an x and y component that can be accessed multiple ways: 8 | 9 | - v.x, v.y 10 | - v[0], v[1] 11 | - x,y = v.as_tuple() 12 | 13 | """ 14 | 15 | def __init__(self, x, y): 16 | """Create a new vector object. 17 | 18 | @type x: float 19 | @param x: The X component of the vector 20 | 21 | @type y: float 22 | @param y: The Y component of the vector 23 | """ 24 | 25 | self.x = x 26 | self.y = y 27 | 28 | 29 | def get_length(self): 30 | """Get the length of the vector.""" 31 | return math.sqrt(self.get_length_squared()) 32 | 33 | def get_length_squared(self): 34 | """Get the squared length of the vector, not calculating the square root for a performance gain""" 35 | return self.x * self.x + self.y * self.y; 36 | 37 | def get_slope(self): 38 | """Get the slope of the vector, or float('inf') if x == 0""" 39 | if self.x == 0: return float('inf') 40 | return float(self.y)/self.x 41 | 42 | def normalize(self): 43 | """Return a normalized version of the vector that will always have a length of 1.""" 44 | return self / self.get_length() 45 | 46 | def clamp(self): 47 | """Return a vector that has the same direction than the current vector, but is never longer than 1.""" 48 | if self.get_length() > 1: 49 | return self.normalize() 50 | else: 51 | return self 52 | 53 | def clone(self): 54 | """Return a copy of this vector""" 55 | return Vector(self.x, self.y) 56 | 57 | def normal(self): 58 | """Return a normal vector of this vector""" 59 | return Vector(-self.y, self.x) 60 | 61 | def as_tuple(self): 62 | """Convert the vector to a non-object tuple""" 63 | return (self.x, self.y) 64 | 65 | def __add__(self, b): 66 | return Vector(self.x + b.x, self.y + b.y) 67 | 68 | def __sub__(self, b): 69 | return Vector(self.x - b.x, self.y - b.y) 70 | 71 | def __mul__(self, val): 72 | 73 | if isinstance(val, Vector): 74 | return self.x * val.x + self.y * val.y 75 | else: 76 | return Vector(self.x * val, self.y * val) 77 | 78 | def __div__(self, val): 79 | return Vector(self.x / val, self.y / val) 80 | 81 | def __repr__(self): 82 | return "Vector(%.3f, %.3f)" % (self.x, self.y) 83 | 84 | def __eq__(self, other): 85 | if not isinstance(other, Vector): return False 86 | d = self - other 87 | return abs(d.x) < EPSILON and abs(d.y) < EPSILON 88 | 89 | def __ne__(self, other): 90 | return not self.__eq__(other) 91 | 92 | def __hash__(self): 93 | return hash("%.4f %.4f" % (self.x, self.y)) 94 | 95 | def __getitem__(self, key): 96 | if key == 0: return self.x 97 | elif key == 1: return self.y 98 | else: raise KeyError('Invalid key: %s. Valid keys are 0 and 1 for x and y' % key) 99 | 100 | def __setitem__(self, key, value): 101 | if key == 0: self.x = value 102 | elif key == 1: self.y = value 103 | else: raise KeyError('Invalid key: %s. Valid keys are 0 and 1 for x and y' % key) 104 | 105 | length = property(get_length, None, None) 106 | length_squared = property(get_length_squared, None, None) 107 | 108 | slope = property(get_slope, None, None) 109 | 110 | VECTOR_NULL = Vector(0,0) 111 | VECTOR_X = Vector(1,0) 112 | VECTOR_Y = Vector(0,1) 113 | EPSILON = 0.0001 114 | -------------------------------------------------------------------------------- /py2d/Math/__init__.py: -------------------------------------------------------------------------------- 1 | """Math utilities for games""" 2 | 3 | 4 | -------------------------------------------------------------------------------- /py2d/Navigation.py: -------------------------------------------------------------------------------- 1 | """Navigation Mesh generation and navigation.""" 2 | 3 | import itertools 4 | from collections import defaultdict 5 | 6 | import py2d.Math 7 | 8 | def poly_midpoint_distance(poly_a, poly_b): 9 | """Polygon distance function that takes the euclidean distance between polygon midpoints.""" 10 | return (poly_a.get_centerpoint() - poly_b.get_centerpoint()).length 11 | 12 | class NavMesh(object): 13 | """Class for representing a navigation mesh""" 14 | 15 | def __init__(self, polygons): 16 | """Create a new navigation mesh""" 17 | self._polygons = polygons 18 | self.update_nav() 19 | 20 | @staticmethod 21 | def generate(boundary, walls=[], distance_function=poly_midpoint_distance): 22 | """Generate a new navigation mesh from a boundary polygon and a list of walls. 23 | 24 | The method will delete wall areas from the boundary polygon and then decompose the resulting polygon into convex polygons, generating a navigation graph in the process. 25 | 26 | @type boundary: Polygon 27 | @param boundary: The boundary of the navigable area. 28 | 29 | @type walls: List 30 | @param walls: List of Wall Polygons to subtract from the boundary polygon. These may intersect the polygon boundary or be properly inside the polygon. 31 | 32 | @type distance_function: Function 33 | @param distance_function: Function of the type f(p_a, p_b) that returns the distance between polygon objects p_a and p_b according to some metric. 34 | """ 35 | 36 | convex_decomp = py2d.Math.Polygon.convex_decompose(boundary, walls) 37 | 38 | # make NavPolygons out of the convex decomposition polygons 39 | polygons = [NavPolygon(poly) for poly in convex_decomp] 40 | 41 | # create dict of shared edges 42 | polygon_edges = defaultdict(list) 43 | for poly in polygons: 44 | for a,b in itertools.chain(zip(poly, poly[1:]), [(poly[-1],poly[0])]): 45 | c,d = (a,b) if a.x < b.x or (a.x == b.x and a.y < b.y ) else (b,a) 46 | polygon_edges[(c,d)].append(poly) 47 | 48 | # link polys that share edges 49 | for e, polys in polygon_edges.items(): 50 | for i, p_a in enumerate(polys): 51 | for p_b in polys[i+1:]: 52 | 53 | dist = distance_function(p_a, p_b) 54 | 55 | p_a.neighbors[p_b] = (dist, e) 56 | p_b.neighbors[p_a] = (dist, e) 57 | 58 | return NavMesh(polygons) 59 | 60 | 61 | def update_nav(self): 62 | """Pre-compute navigation data for the navigation mesh. 63 | 64 | This is called automatically upon mesh initialization, but you might want to call it if you have changed the navigation mesh. 65 | """ 66 | 67 | # initialize with simple distances 68 | self._nav_data = [ 69 | [ 70 | (q.neighbors[p][0], j) if p in q.neighbors.keys() else (float('inf'), None) 71 | for j, p in enumerate(self._polygons) 72 | ] 73 | for i, q in enumerate(self._polygons) 74 | ] 75 | 76 | # floyd-warshall algorithm to compute all-pair shortest paths 77 | for k in range(len(self._polygons)): 78 | for i in range( len(self._polygons) ): 79 | for j in range(len(self._polygons)): 80 | 81 | if k not in (i,j) and i != j: 82 | dist = self.get_data(i,j)[0] 83 | dist2 = self.get_data(i,k)[0] + self.get_data(k,j)[0] 84 | if dist2 < dist: 85 | self.set_data(i,j, (dist2, k)) 86 | 87 | def find_polygon(self, p): 88 | """Find the NavPolygon that contains p""" 89 | for poly in self._polygons: 90 | if poly.contains_point(p): 91 | return poly 92 | 93 | return None 94 | 95 | 96 | def get_path(self, start, stop): 97 | """Get a high-level path from start to stop. 98 | 99 | The path returned will be an optimal sequence of NavPolygons leading to the desired target. 100 | """ 101 | 102 | if isinstance(start, py2d.Math.Vector): start = self.find_polygon(start) 103 | if isinstance(stop, py2d.Math.Vector): stop = self.find_polygon(stop) 104 | 105 | if not (start and stop): return None 106 | 107 | def get_path_rec(i,j): 108 | 109 | if i == j: 110 | return [] 111 | 112 | d = self.get_data(i,j)[1] 113 | 114 | if d == j: 115 | return [j] 116 | 117 | elif d == None: 118 | return None 119 | 120 | else: 121 | return get_path_rec(i,d) + get_path_rec(d,j) 122 | 123 | i = self._polygons.index(start) 124 | j = self._polygons.index(stop) 125 | 126 | path = get_path_rec(i,j) 127 | 128 | if path == None: 129 | return None 130 | 131 | path = [i] + path 132 | 133 | return NavPath(self, [self._polygons[p] for p in path]) 134 | 135 | def get_data(self, i, j): 136 | return self._nav_data[i][j] 137 | 138 | def set_data(self, i, j, d): 139 | self._nav_data[i][j] = d 140 | 141 | def get_polygons(self): 142 | return self._polygons 143 | 144 | def get_nodes(self): 145 | return self._nodes 146 | 147 | polygons = property(get_polygons) 148 | nodes = property(get_nodes) 149 | 150 | 151 | class NavPolygon(py2d.Math.Polygon): 152 | """Polygon class with added navigation data""" 153 | def __init__(self, polygon): 154 | py2d.Math.Polygon.__init__(self) 155 | 156 | self.points = polygon.points 157 | self.neighbors = {} 158 | 159 | 160 | class NavPath(object): 161 | """Class representing a solved navigation path""" 162 | def __init__(self, mesh, polygons): 163 | self._mesh = mesh 164 | self._polygons = polygons 165 | 166 | 167 | def get_next_move_to(self, position, final_target): 168 | """Get the next point an agent following this path should move to. 169 | 170 | Reference: Simple Stupid Funnel Algorithm 171 | http://digestingduck.blogspot.com/2010/03/simple-stupid-funnel-algorithm.html 172 | 173 | @type position: Vector 174 | @param position: The position of the agent following the path 175 | """ 176 | 177 | current_polys = [] 178 | for poly in self._polygons: 179 | if poly.contains_point(position): 180 | current_polys.append(poly) 181 | 182 | i = max((self._polygons.index(p) for p in current_polys)) 183 | 184 | if i == len(self._polygons)-1: return final_target 185 | 186 | edge = self._polygons[i].neighbors[self._polygons[i+1]][1] 187 | 188 | left, right = (edge[0], edge[1]) if py2d.Math.point_orientation(position, edge[0], edge[1]) else (edge[1], edge[0]) 189 | 190 | for j in range(i+1, len(self._polygons)-1): 191 | edge = self._polygons[j].neighbors[self._polygons[j+1]][1] 192 | new_left, new_right = (edge[0], edge[1]) if py2d.Math.point_orientation(position, edge[0], edge[1]) else (edge[1], edge[0]) 193 | 194 | # make the funnel smaller 195 | if py2d.Math.point_orientation(position, left, new_left): left = new_left 196 | if not py2d.Math.point_orientation(position, left, right): 197 | return right 198 | 199 | 200 | if not py2d.Math.point_orientation(position, right, new_right): right = new_right 201 | if not py2d.Math.point_orientation(position, left, right): 202 | return left 203 | 204 | if py2d.Math.point_orientation(position, left, final_target): left = final_target 205 | if not py2d.Math.point_orientation(position, left, right): 206 | return right 207 | 208 | if not py2d.Math.point_orientation(position, right, final_target): right = final_target 209 | if not py2d.Math.point_orientation(position, left, right): 210 | return left 211 | 212 | return final_target 213 | 214 | def get_polygons(self): 215 | return self._polygons 216 | 217 | polygons = property(get_polygons) 218 | -------------------------------------------------------------------------------- /py2d/SVG.py: -------------------------------------------------------------------------------- 1 | """SVG to Polygon conversion 2 | 3 | This is still experimental code and only a tiny subset of SVG is supported. 4 | 5 | Testing files were generated using Inkscape. Make sure to use "Make selected nodes corner" on all polygons to be converted so they contain no problematic SVG path commands! 6 | """ 7 | 8 | import re 9 | import warnings 10 | from xml.etree import ElementTree 11 | 12 | from py2d import Polygon, Vector, Transform 13 | from py2d.Bezier import flatten_cubic_bezier 14 | 15 | def convert_svg(f, transform=Transform.unit(), bezier_max_divisions=None, bezier_max_flatness=0.1): 16 | """Convert an SVG file to a hash of Py2D Polygons. 17 | 18 | The hash keys will be the ids set to the corresponding elements in the SVG file. 19 | The hash value is a list of polygons, the first one being the outline polygon and all additional polygons being holes. 20 | 21 | @param f: File object or file name to a SVG file. 22 | 23 | @type transform: Transform 24 | @param transform: A transformation to apply to all polygons 25 | """ 26 | 27 | svg_ns = "http://www.w3.org/2000/svg" 28 | et = ElementTree.parse(f) 29 | 30 | def transform_element(e, transform): 31 | new_t = e.get("transform", "") 32 | 33 | nt = transform 34 | m = re.match(r'translate\((?P[0-9.-]+),(?P[0-9.-]+)\)', new_t) 35 | if m: 36 | x, y = float(m.group("x")), float(m.group("y")) 37 | nt = Transform.move(x,y) * nt 38 | 39 | m = re.match(r'matrix\((?P[0-9.-]+),(?P[0-9.-]+),(?P[0-9.-]+),(?P[0-9.-]+),(?P[0-9.-]+),(?P[0-9.-]+)\)', new_t) 40 | if m: 41 | a,b,c,d,e,f = ( float(m.group(l)) for l in "abcdef" ) 42 | 43 | import pdb; pdb.set_trace() 44 | nt = Transform([[ a, c, e ], 45 | [ b, d, f ], 46 | [ 0, 0, 1 ]]) * nt 47 | 48 | return nt 49 | 50 | def path_find(e, transform=Transform.unit()): 51 | """Generator function to recursively list all paths under an element e""" 52 | 53 | # yield path nodes within current element 54 | for p in e.iterfind("{%s}path" % svg_ns): 55 | yield (p, transform_element(p, transform)) 56 | 57 | # yield path nodes in subgroups 58 | for g in e.iterfind("{%s}g" % svg_ns): 59 | for p,t in path_find(g, transform_element(g, transform)): 60 | yield (p, t) 61 | 62 | 63 | def convert_element(e, transform): 64 | """Convert an SVG path element to one or multiple Py2D polygons.""" 65 | 66 | # get data from the element 67 | id = e.get("id") 68 | d = e.get("d") 69 | 70 | #print "CONVERTING %s: %s" % (id, d) 71 | 72 | def parse_commands(draw_commands): 73 | """Generator Function to parse a SVG draw command sequence into command and parameter tuples""" 74 | tokens = draw_commands.split(" ") 75 | while tokens: 76 | # find the next token that is a command 77 | par_index = next( i for i,v in enumerate(tokens[1:] + ["E"]) if re.match('^[a-zA-Z]$', v) ) + 1 78 | 79 | # first token should always be the command, rest the parameters 80 | cmd = tokens[0] 81 | pars = tokens[1:par_index] 82 | 83 | # remove the parsed tokens 84 | del tokens[:par_index] 85 | 86 | yield cmd, pars 87 | 88 | def parse_vec(s): 89 | x,y = s.split(",") 90 | return Vector(float(x), float(y)) 91 | 92 | 93 | polys = [] 94 | verts = [] 95 | relative_pos = Vector(0.0,0.0) 96 | last_control = None 97 | for cmd, pars in parse_commands(d): 98 | 99 | #print "cmd: %s, pars: %s" % (cmd, pars) 100 | 101 | if cmd == "m" or cmd == "l": 102 | for p in pars: 103 | relative_pos += parse_vec(p) 104 | verts.append(relative_pos) 105 | 106 | 107 | elif cmd == "M" or cmd == "L": 108 | for p in pars: 109 | relative_pos = parse_vec(p) 110 | verts.append(relative_pos) 111 | 112 | elif cmd == "c" or cmd == "C": 113 | # create cubic polybezier 114 | 115 | for i in range(0, len(pars), 3): 116 | c1, c2, b = parse_vec(pars[i]), parse_vec(pars[i+1]), parse_vec(pars[i+2]) 117 | 118 | if cmd == "c": 119 | # convert to relative 120 | c1 += relative_pos 121 | c2 += relative_pos 122 | b += relative_pos 123 | 124 | bez = flatten_cubic_bezier(relative_pos, b, c1, c2, bezier_max_divisions, bezier_max_flatness) 125 | 126 | last_control = c2 127 | relative_pos = b 128 | 129 | verts.extend(bez) 130 | 131 | elif cmd == "s" or cmd == "S": 132 | # shorthand / smooth cubic polybezier 133 | 134 | for i in range(0, len(pars), 2): 135 | c2, b = parse_vec(pars[i]), parse_vec(pars[i+1]) 136 | c1 = relative_pos + (relative_pos - last_control) 137 | 138 | if cmd == "s": 139 | # convert to relative 140 | c2 += relative_pos 141 | b += relative_pos 142 | 143 | bez = flatten_cubic_bezier(relative_pos, b, c1, c2, bezier_max_divisions, bezier_max_flatness) 144 | 145 | last_control = c2 146 | relative_pos = b 147 | 148 | verts.extend(bez) 149 | 150 | elif cmd == "z": 151 | # close line by only moving relative_pos to first vertex 152 | 153 | polys.append(transform * Polygon.from_pointlist(verts)) 154 | relative_pos = verts[0] 155 | verts = [] 156 | 157 | 158 | else: 159 | warnings.warn("Unrecognized SVG path command: %s - path skipped" % cmd) 160 | polys = [] 161 | break 162 | 163 | if verts: 164 | polys.append(transform * Polygon.from_pointlist(verts)) 165 | #print "----" 166 | 167 | return id, polys 168 | 169 | out = {} 170 | for p,tr in path_find(et.getroot(), transform): 171 | id, polys = convert_element(p,tr) 172 | out[id] = polys 173 | 174 | return out 175 | 176 | if __name__ == "__main__": 177 | print(convert_svg("py2d/examples/shapes.svg")) 178 | -------------------------------------------------------------------------------- /py2d/__init__.py: -------------------------------------------------------------------------------- 1 | from py2d import * 2 | -------------------------------------------------------------------------------- /py2d/test/Test_Math.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | class TestVector(unittest.TestCase): 4 | 5 | def setUp(self): 6 | 7 | 8 | self.u = Vector(3.0, 4.0) 9 | self.v = Vector(2.0, 3.0) 10 | self.w = Vector(0.5, 0.75) 11 | 12 | 13 | 14 | self.x = Vector(1, 0) 15 | self.y = Vector(0, 1) 16 | self.z = Vector(0, 0) 17 | 18 | def test_equality(self): 19 | self.assertNotEqual(self.v, self.w) 20 | self.assertEqual(self.v, Vector(2.0, 3.0)) 21 | 22 | def test_add(self): 23 | self.assertEqual( Vector(2.5, 3.75), self.v + self.w ) 24 | self.assertEqual( Vector(2.5, 3.75), self.w + self.v ) 25 | 26 | def test_sub(self): 27 | self.assertEqual( Vector(1.5, 2.25), self.v - self.w ) 28 | self.assertEqual( Vector(-1.5, -2.25), self.w - self.v ) 29 | 30 | def test_mul_with_scalar(self): 31 | self.assertEqual( Vector(1, 1.5), self.w * 2 ) 32 | self.assertEqual( Vector(1.0, 1.5), self.v * 0.5 ) 33 | 34 | def test_mul_dot_product(self): 35 | self.assertEqual( 3.25 , self.v * self.w ) 36 | 37 | def test_div(self): 38 | self.assertEqual( Vector(1.0, 1.5), self.v / 2 ) 39 | 40 | def test_tuple(self): 41 | a,b = self.v.as_tuple() 42 | self.assertEqual( 2.0, a ) 43 | self.assertEqual( 3.0, b ) 44 | 45 | def test_key_access(self): 46 | self.assertEqual( 0.5, self.w[0] ) 47 | self.assertEqual( 0.75, self.w[1] ) 48 | 49 | self.w[0] = 9001 50 | self.w[1] = 42 51 | self.assertEqual( 9001, self.w.x) 52 | self.assertEqual( 42, self.w.y) 53 | 54 | 55 | try: 56 | x = self.w[2] 57 | except KeyError: 58 | pass 59 | else: 60 | self.fail("Expected KeyError") 61 | 62 | 63 | try: 64 | x = self.w["x"] 65 | except KeyError: 66 | pass 67 | else: 68 | self.fail("Expected KeyError") 69 | 70 | try: 71 | self.w[2] = 4 72 | except KeyError: 73 | pass 74 | else: 75 | self.fail("Expected KeyError") 76 | 77 | try: 78 | self.w["x"] = 4 79 | except KeyError: 80 | pass 81 | else: 82 | self.fail("Expected KeyError") 83 | 84 | 85 | def test_length(self): 86 | self.assertEqual( 5, self.u.length ) 87 | self.assertEqual( 25, self.u.length_squared ) 88 | 89 | def test_clone(self): 90 | self.assertEqual( Vector(0.5, 0.75), self.w.clone() ) 91 | 92 | def test_clamp(self): 93 | self.assertEqual( Vector(0.6, 0.8), self.u.clamp() ) 94 | self.assertEqual( Vector(0,0), self.z.clamp() ) 95 | self.assertEqual( Vector(1,0), self.x.clamp() ) 96 | 97 | def test_normal(self): 98 | self.assertEqual( Vector(-4, 3), self.u.normal()) 99 | 100 | def test_repr(self): 101 | self.assertEqual( "Vector(3.000, 4.000)", str(self.u) ) 102 | 103 | def test_hash(self): 104 | self.assertEqual( hash(self.u), hash(Vector(3.0, 2.0) + self.y * 2) ) 105 | 106 | def test_slope(self): 107 | 108 | self.assertEqual(float('inf'), self.y.slope) 109 | self.assertEqual(1.5, self.v.slope) 110 | 111 | class TestPolygon(unittest.TestCase): 112 | 113 | def setUp(self): 114 | self.square = Polygon.regular( Vector( 10.0, 30.0 ), 3, 4 ) 115 | self.square2 = Polygon.regular( Vector( 5.0, 30.0), 4, 4 ) 116 | self.triangle = Polygon.regular( Vector( 12.0, 32.0 ), 5, 3 ) 117 | self.irregular = Polygon.from_pointlist( [ Vector(1, 1), Vector(0, 3), Vector(4, 5), Vector(3, 2) ] ) 118 | 119 | def test_pointlen(self): 120 | self.assertEqual(3, len(self.triangle)) 121 | self.assertEqual(4, len(self.square)) 122 | 123 | 124 | def test_add_point(self): 125 | self.square.add_point( Vector(1,1) ) 126 | self.assertEqual(5, len(self.square)) 127 | self.assertEqual( Vector(1,1), self.square[4]) 128 | 129 | def test_add_points(self): 130 | self.square.add_points( [Vector(1,1), Vector(2,2)] ) 131 | self.assertEqual(6, len(self.square)) 132 | self.assertEqual( Vector(1,1), self.square[4]) 133 | self.assertEqual( Vector(2,2), self.square[5]) 134 | 135 | def test_centerpoint(self): 136 | self.assertEqual( Vector( 12.0, 32.0 ), self.triangle.center ) 137 | 138 | 139 | def test_sort_around(self): 140 | pts = [ Vector( 1.0, 3.0 ), Vector( -1.0, -2.0), Vector(2, -3), Vector(-2, 3), Vector(1,0), Vector(-1,0), Vector(0,-1), Vector(0,1)] 141 | pts_sorted = [ pts[4], pts[0], pts[7], pts[3], pts[5], pts[1], pts[6], pts[2] ] 142 | pts_sorted2 = [ pts[3], pts[0], pts[7], pts[5], pts[1], pts[6], pts[4], pts[2] ] 143 | 144 | poly = Polygon.from_pointlist(pts) 145 | 146 | poly.sort_around( Vector(0,0) ) 147 | self.assertEqual( pts_sorted, poly.points ) 148 | 149 | poly.sort_around( Vector(10,10) ) 150 | self.assertEqual( pts_sorted2, poly.points ) 151 | 152 | def test_repr(self): 153 | self.assertEqual("Polygon [(13.00, 30.00), (10.00, 33.00), (7.00, 30.00), (10.00, 27.00)]", str(self.square)) 154 | 155 | def test_item_access(self): 156 | self.assertEqual( Vector(10, 33), self.square[1] ) 157 | 158 | self.square[0] = Vector(12,34) 159 | self.assertEqual(Vector(12,34), self.square.points[0]) 160 | 161 | def test_item_delete(self): 162 | del self.square[3] 163 | self.assertEqual(3, len(self.square)) 164 | 165 | 166 | def test_clone(self): 167 | self.assertEqual(self.square.clone(), self.square) 168 | 169 | 170 | def test_clockwise(self): 171 | self.assertFalse(self.irregular.is_clockwise()) 172 | 173 | def test_flip(self): 174 | self.irregular.flip() 175 | self.assertTrue(self.irregular.is_clockwise()) 176 | 177 | def test_contains_point(self): 178 | 179 | self.assertEqual(1, self.irregular.contains_point(Vector(2,2))) 180 | self.assertEqual(1, self.irregular.contains_point(Vector(1,2))) 181 | self.assertEqual(2, self.irregular.contains_point(Vector(2,4))) 182 | self.assertEqual(2, self.irregular.contains_point(Vector(1,1))) 183 | self.assertEqual(2, self.irregular.contains_point(Vector(2,1.5))) 184 | self.assertEqual(0, self.irregular.contains_point(Vector(0,4))) 185 | self.assertEqual(0, self.irregular.contains_point(Vector(0,1))) 186 | self.assertEqual(0, self.irregular.contains_point(Vector(0,0))) 187 | 188 | 189 | 190 | def test_union(self): 191 | union = Polygon.union(self.square, self.square2) 192 | expected = [ Polygon.from_pointlist([Vector(13, 30), Vector(10,33), Vector(8,31), Vector(5,34), Vector(1,30), Vector(5,26), Vector(8,29), Vector(10,27) ]) ] 193 | for p in union: 194 | p.sort_around(Vector(8,30)) 195 | 196 | self.assertEqual(expected, union) 197 | 198 | def test_intersection(self): 199 | intersection = Polygon.intersect(self.square, self.square2) 200 | for p in intersection: 201 | p.sort_around(Vector(8,30)) 202 | 203 | expected = [ Polygon.from_tuples([(9.00, 30.00), (8.00, 31.00), (7.00, 30.00), (8.00, 29.00)]) ] 204 | self.assertEqual(expected, intersection) 205 | 206 | def test_subtract(self): 207 | subtract = Polygon.subtract(self.square, self.square2) 208 | for p in subtract: 209 | p.sort_around(Vector(8,30)) 210 | 211 | expected = [ Polygon.from_tuples( [(13.00, 30.00), (9.00, 30.00), (10.00, 33.00), (8.00, 31.00), (8.00, 29.00), (10.00, 27.00)] )] 212 | self.assertEqual(expected, subtract) 213 | 214 | 215 | def test_offset(self): 216 | #self.square = Polygon.regular( Vector( 10.0, 30.0 ), 3, 4 ) 217 | #self.square2 = Polygon.regular( Vector( 5.0, 30.0), 4, 4 ) 218 | #self.triangle = Polygon.regular( Vector( 12.0, 32.0 ), 5, 3 ) 219 | 220 | self.assertEqual( [Polygon.regular( Vector(10, 30), 5, 4) ], Polygon.offset([self.square], 2.0) ) 221 | 222 | class TestIntersection(unittest.TestCase): 223 | def setUp(self): 224 | 225 | self.origin = Vector(0,0) 226 | self.x = Vector(1,0) 227 | self.y = Vector(0,1) 228 | 229 | self.a = Vector(1, 3) 230 | self.b = Vector(5, 1) 231 | 232 | self.c = Vector(4, 2) 233 | self.d = Vector(1, 1) 234 | self.e = Vector(2, 4) 235 | self.f = Vector(-1, 0) 236 | 237 | self.square = Polygon.from_pointlist([ Vector(3, 3), Vector(-3, 3), Vector(-3, -3), Vector(3, -3) ]) 238 | self.diamond = Polygon.regular(Vector(0,0), 5, 4) 239 | 240 | def test_intersect_lineseg_lineseg(self): 241 | self.assertTrue( check_intersect_lineseg_lineseg( self.a, self.b, self.origin, self.c ) ) 242 | self.assertFalse( check_intersect_lineseg_lineseg( self.a, self.b, self.origin, self.d ) ) 243 | 244 | 245 | self.assertEqual( Vector(3.5,1.75), intersect_lineseg_lineseg( self.a, self.b, self.origin, self.c ) ) 246 | 247 | def test_intersect_lineseg_ray(self): 248 | self.assertEqual( Vector(3, 2), intersect_lineseg_ray(self.a, self.b, self.f, self.d) ) 249 | self.assertEqual( None, intersect_lineseg_ray(self.a, self.b, self.d, self.f) ) 250 | 251 | 252 | def test_intersect_poly_lineseg(self): 253 | self.assertEqual( [ Vector(-3, -1.5), Vector(3, 1.5) ], intersect_poly_lineseg( self.square.points, self.origin - self.c, self.c ) ) 254 | self.assertEqual( [], intersect_poly_lineseg( self.diamond.points, self.origin, self.d ) ) 255 | 256 | def test_intersect_poly_ray(self): 257 | self.assertEqual( [ Vector(3, 1.5) ], intersect_poly_ray(self.square.points, self.origin, self.c) ) 258 | 259 | def test_intersect_line_line(self): 260 | self.assertEqual( Vector(3.5, 1.75), intersect_line_line( self.a, self.b, self.origin, self.c) ) 261 | self.assertEqual( None, intersect_line_line(self.a, self.a + self.x * 3, self.origin, self.x) ) 262 | 263 | def test_intersect_lineseg_line(self): 264 | self.assertEqual( Vector(1.4,2.8), intersect_lineseg_line(self.a, self.b, self.origin, self.e) ) 265 | self.assertEqual( None, intersect_lineseg_line(self.origin, self.e.clamp(), self.a, self.b) ) 266 | 267 | 268 | def test_intersect_poly_poly(self): 269 | self.assertEqual( [Vector(2, 3), Vector(-2, 3), Vector(-3, 2), Vector(-3, -2), Vector(-2, -3), Vector(2, -3), Vector(3, 2), Vector(3, -2)], intersect_poly_poly(self.square.points, self.diamond.points) ) 270 | 271 | 272 | def test_distance_point_lineseg_squared(self): 273 | self.assertEqual( 3.2, distance_point_lineseg_squared(self.d, self.a, self.b) ) 274 | self.assertEqual( 0, distance_point_lineseg_squared(Vector(2,4), Vector(0,3), Vector(4, 5)) ) 275 | self.assertNotEqual( 0, distance_point_lineseg_squared(Vector(2,2), Vector(3,2), Vector(1, 1)) ) 276 | 277 | if __name__ == '__main__': 278 | unittest.main() 279 | -------------------------------------------------------------------------------- /py2d/test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jshaffstall/PyPhysicsSandbox/d58539c1a2bb7b4b37a9a2dec1b378a361f88223/py2d/test/__init__.py -------------------------------------------------------------------------------- /pyphysicssandbox/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | pyPhysicsSandbox is a simple wrapper around Pymunk that makes it easy to write code to explore physics simulations. 3 | It's intended for use in introductory programming classrooms. 4 | 5 | Caution! The simulation does not behave well if you start out with shapes overlapping each other, especially if 6 | overlapping shapes are connected with joints. To have overlapping shapes connected by joints, set the group on 7 | each shape to the same number to disable collision detection between those shape. 8 | 9 | Shapes far enough outside the simulation window (generally, above or below by the height of the window, or to 10 | either side by the width of the window) are automatically removed from the simulation and their active property 11 | set to False. The distance can be modified, but be wary of making it too large...this keeps shapes that are not 12 | visible in the simulation and can slow the simulation down if the number of shapes grows too large. 13 | 14 | """ 15 | 16 | import pygame 17 | import pymunk 18 | import math 19 | 20 | from pygame import Color 21 | from pygame import constants 22 | 23 | __docformat__ = "reStructuredText" 24 | 25 | 26 | __all__ = ['window', 'add_observer', 'gravity', 'resistance', 'mouse_clicked', 27 | 'static_ball', 'ball', 'static_box', 'box', 'static_rounded_box', 28 | 'rounded_box', 'static_polygon', 'polygon', 'static_triangle', 29 | 'triangle', 'static_text', 'text', 'static_text_with_font', 30 | 'text_with_font', 'static_line', 'line', 'pivot', 'gear', 31 | 'motor', 'pin', 'rotary_spring', 'run', 'draw', 'Color', 32 | 'cosmetic_text', 'cosmetic_text_with_font', 'num_shapes', 33 | 'constants', 'deactivate', 'reactivate', 'mouse_point', 34 | 'add_collision', 'slip_motor', 'set_margins', 'cosmetic_box', 35 | 'cosmetic_rounded_box', 'cosmetic_ball', 'cosmetic_line', 36 | 'cosmetic_polygon', 'cosmetic_triangle', 'spring', 37 | 'color' 38 | ] 39 | 40 | 41 | pygame.init() 42 | 43 | space = pymunk.Space() 44 | space.gravity = (0.0, 500.0) 45 | space.damping = 0.95 46 | 47 | win_title = "Untitled" 48 | win_width = 500 49 | win_height = 500 50 | x_margin = win_width 51 | y_margin = win_height 52 | observers = [] 53 | clicked = False 54 | default_color = Color('black') 55 | 56 | shapes = {} 57 | 58 | 59 | def window(title, width, height): 60 | """Sets the caption, width, and height of the window that will 61 | appear when run () is executed. 62 | 63 | :param title: the caption of the window 64 | :type title: string 65 | :param width: the width of the window in pixels 66 | :type width: int 67 | :param height: the height of the window in pixels 68 | :type height: int 69 | 70 | """ 71 | global win_title 72 | global win_width 73 | global win_height 74 | global x_margin 75 | global y_margin 76 | 77 | win_title = title 78 | win_width = width 79 | win_height = height 80 | x_margin = win_width 81 | y_margin = win_height 82 | 83 | 84 | def add_observer(hook): 85 | """Adds an observer function to the simulation. Every observer 86 | function is called once per time step of the simulation (roughly 87 | 50 times a second). The function should be defined like this: 88 | 89 | def function_name(keys): 90 | # do something each time step 91 | 92 | To pass a function in use the name of the function without the 93 | parenthesis after it. 94 | 95 | The observer function must take a single parameter which is a 96 | list of keys pressed this step. To see if a particular key has 97 | been pressed, use something like this: 98 | 99 | if constants.K_UP in keys: 100 | # do something based on the up arrow being pressed 101 | 102 | :param hook: the observer function 103 | :type hook: function 104 | 105 | """ 106 | global observers 107 | 108 | observers.append(hook) 109 | 110 | 111 | def set_margins(x, y): 112 | """Sets the distance outside the simulation that shapes can be and remain active. 113 | This defaults to the window width and height. You can change it to either remove 114 | shapes more quickly when they're out of view, or to allow creating shapes farther 115 | outside the visible window. 116 | 117 | :param x: horizontal margin 118 | :param y: vertical margin 119 | """ 120 | global x_margin 121 | global y_margin 122 | 123 | x_margin = x 124 | y_margin = y 125 | 126 | 127 | def gravity(x, y): 128 | """Sets the direction and amount of gravity used by the simulation. 129 | Positive x is to the right, positive y is downward. This value can 130 | be changed during the run of the simulation. 131 | 132 | :param x: The horizontal gravity 133 | :type x: int 134 | :param y: The vertical gravity 135 | :type y: int 136 | 137 | """ 138 | space.gravity = (x, y) 139 | 140 | 141 | def color(c): 142 | """Sets the default color to use for shapes created after this 143 | call. The function may be called at any point to change the 144 | color for new shapes. 145 | 146 | To see available color names go to 147 | https://sites.google.com/site/meticulosslacker/pygame-thecolors 148 | and hover the mouse pointer over a color of interest. 149 | 150 | :param c: the color name as a string 151 | :type c: str 152 | """ 153 | global default_color 154 | 155 | default_color = Color(c) 156 | 157 | 158 | def resistance(v): 159 | """Sets the amount of velocity that all objects lose each second. 160 | This can be used to simulate air resistance. Resistance value 161 | defaults to 1.0. Values less than 1.0 cause objects to lose 162 | velocity over time, values greater than 1.0 cause objects to 163 | gain velocity over time. 164 | 165 | For example a value of .9 means the body will lose 10% of its 166 | velocity each second (.9 = 90% velocity retained each second). 167 | 168 | This value can be changed during the run of the simulation. 169 | 170 | :param v: The resistance value 171 | :type v: float 172 | 173 | """ 174 | space.damping = v 175 | 176 | 177 | def mouse_clicked(): 178 | """Returns True if the mouse has been clicked this time step. Usable only in an observer function. 179 | 180 | :rtype: bool 181 | 182 | """ 183 | return clicked 184 | 185 | 186 | def mouse_point(): 187 | """Returns the current location of the mouse pointer. 188 | 189 | If the mouse is out of the simulation window, this will return the last location of the mouse 190 | that was in the simulation window. 191 | 192 | :rtype: (x, y) 193 | """ 194 | return pygame.mouse.get_pos() 195 | 196 | 197 | def static_ball(p, radius): 198 | """Creates a ball that remains fixed in place. 199 | 200 | :param p: The center point of the ball 201 | :type p: (int, int) 202 | :param radius: The radius of the ball 203 | :type radius: int 204 | :rtype: shape 205 | 206 | """ 207 | return _ball(p, radius, 0, True) 208 | 209 | 210 | def ball(p, radius, mass=-1): 211 | """Creates a ball that reacts to gravity. 212 | 213 | :param p: The center point of the ball 214 | :type p: (int, int) 215 | :param radius: The radius of the ball 216 | :type radius: int 217 | :param mass: The mass of the shape (defaults to 1) 218 | :type mass: int 219 | :rtype: shape 220 | 221 | """ 222 | return _ball(p, radius, mass, False) 223 | 224 | 225 | def cosmetic_ball(p, radius): 226 | """Creates a ball that does not interact with the simulation in any way. 227 | 228 | :param p: The center point of the ball 229 | :type p: (int, int) 230 | :param radius: The radius of the ball 231 | :type radius: int 232 | :rtype: shape 233 | 234 | """ 235 | return _ball(p, radius, 0, False, True) 236 | 237 | 238 | def _ball(p, radius, mass, static=False, cosmetic=False): 239 | from .ball_shape import Ball 240 | 241 | if mass == -1: 242 | mass = math.pi*radius*radius 243 | 244 | result = Ball(space, p[0], p[1], radius, mass, static, cosmetic) 245 | result.color = default_color 246 | shapes[result.collision_type] = result 247 | 248 | return result 249 | 250 | 251 | def static_box(p, width, height): 252 | """Creates a box that remains fixed in place. 253 | 254 | :param p: The upper left corner of the box 255 | :type p: (int, int) 256 | :param width: The width of the box 257 | :type width: int 258 | :param height: The height of the box 259 | :type height: int 260 | :rtype: shape 261 | 262 | """ 263 | return _box(p, width, height, 0, True) 264 | 265 | 266 | def box(p, width, height, mass=-1): 267 | """Creates a box that reacts to gravity. 268 | 269 | :param p: The upper left corner of the box 270 | :type p: (int, int) 271 | :param width: The width of the box 272 | :type width: int 273 | :param height: The height of the box 274 | :type height: int 275 | :param mass: The mass of the shape (defaults to 1) 276 | :type mass: int 277 | :rtype: shape 278 | 279 | """ 280 | return _box(p, width, height, mass, False) 281 | 282 | 283 | def cosmetic_box(p, width, height): 284 | """Creates a box that does not react with the simulation in any way. 285 | 286 | :param p: The upper left corner of the box 287 | :type p: (int, int) 288 | :param width: The width of the box 289 | :type width: int 290 | :param height: The height of the box 291 | :type height: int 292 | :rtype: shape 293 | 294 | """ 295 | return _box(p, width, height, 0, False, 0, True) 296 | 297 | 298 | def _box(p, width, height, mass, static, radius=0, cosmetic=False): 299 | from .box_shape import Box 300 | 301 | if mass == -1: 302 | mass = width * height 303 | 304 | # Polygons expect x,y to be the center point 305 | x = p[0] + width / 2 306 | y = p[1] + height / 2 307 | 308 | result = Box(space, x, y, width, height, radius, mass, static, cosmetic) 309 | result.color = default_color 310 | shapes[result.collision_type] = result 311 | 312 | return result 313 | 314 | 315 | def static_rounded_box(p, width, height, radius): 316 | """Creates a box with rounded corners that remains fixed in place. 317 | 318 | :param p: The upper left corner of the box 319 | :type p: (int, int) 320 | :param width: The width of the box 321 | :type width: int 322 | :param height: The height of the box 323 | :type height: int 324 | :param radius: The radius of the rounded corners 325 | :type radius: int 326 | :rtype: shape 327 | 328 | """ 329 | return _box(p, width, height, 0, True, radius) 330 | 331 | 332 | def rounded_box(p, width, height, radius, mass=-1): 333 | """Creates a box with rounded corners that reacts to gravity. 334 | 335 | :param p: The upper left corner of the box 336 | :type p: (int, int) 337 | :param width: The width of the box 338 | :type width: int 339 | :param height: The height of the box 340 | :type height: int 341 | :param radius: The radius of the rounded corners 342 | :type radius: int 343 | :param mass: The mass of the shape (defaults to 1) 344 | :type mass: int 345 | :rtype: shape 346 | 347 | """ 348 | return _box(p, width, height, mass, False, radius) 349 | 350 | 351 | def cosmetic_rounded_box(p, width, height, radius): 352 | """Creates a box with rounded corners that does not interact with the simulation in any way. 353 | 354 | :param p: The upper left corner of the box 355 | :type p: (int, int) 356 | :param width: The width of the box 357 | :type width: int 358 | :param height: The height of the box 359 | :type height: int 360 | :param radius: The radius of the rounded corners 361 | :type radius: int 362 | :rtype: shape 363 | 364 | """ 365 | return _box(p, width, height, 0, False, radius, True) 366 | 367 | 368 | def static_polygon(vertices): 369 | """Creates a polygon that remains fixed in place. 370 | 371 | :param vertices: A tuple of points on the polygon 372 | :type vertices: ((int, int), (int, int), ...) 373 | :rtype: shape 374 | 375 | """ 376 | return _polygon(vertices, 0, True) 377 | 378 | 379 | def polygon(vertices, mass=-1): 380 | """Creates a polygon that reacts to gravity. 381 | 382 | :param vertices: A tuple of points on the polygon 383 | :type vertices: ((int, int), (int, int), ...) 384 | :param mass: The mass of the shape (defaults to 1) 385 | :type mass: int 386 | :rtype: shape 387 | 388 | """ 389 | return _polygon(vertices, mass, False) 390 | 391 | 392 | def cosmetic_polygon(vertices): 393 | """Creates a polygon that does not interact with the simulation in any way. 394 | 395 | :param vertices: A tuple of points on the polygon 396 | :type vertices: ((int, int), (int, int), ...) 397 | :rtype: shape 398 | 399 | """ 400 | return _polygon(vertices, 0, False, True) 401 | 402 | 403 | def _polygon(vertices, mass, static, cosmetic=False): 404 | from .poly_shape import Poly 405 | from .util import poly_centroid 406 | from .util import poly_area 407 | 408 | x, y = poly_centroid(vertices) 409 | 410 | if mass == -1: 411 | mass = poly_area(vertices) 412 | 413 | vertices = [(v[0] - x, v[1] - y) for v in vertices] 414 | result = Poly(space, x, y, vertices, 0, mass, static, cosmetic) 415 | result.color = default_color 416 | shapes[result.collision_type] = result 417 | 418 | return result 419 | 420 | 421 | def static_triangle(p1, p2, p3): 422 | """Creates a triangle that remains fixed in place. 423 | 424 | :param p1: The first point of the triangle 425 | :type p1: (int, int) 426 | :param p2: The second point of the triangle 427 | :type p2: (int, int) 428 | :param p3: The third point of the triangle 429 | :type p3: (int, int) 430 | :rtype: shape 431 | 432 | """ 433 | return _triangle(p1, p2, p3, 0, True) 434 | 435 | 436 | def triangle(p1, p2, p3, mass=-1): 437 | """Creates a triangle that reacts to gravity. 438 | 439 | :param p1: The first point of the triangle 440 | :type p1: (int, int) 441 | :param p2: The second point of the triangle 442 | :type p2: (int, int) 443 | :param p3: The third point of the triangle 444 | :type p3: (int, int) 445 | :param mass: The mass of the shape (defaults to 1) 446 | :type mass: int 447 | :rtype: shape 448 | 449 | """ 450 | return _triangle(p1, p2, p3, mass, False) 451 | 452 | 453 | def cosmetic_triangle(p1, p2, p3): 454 | """Creates a triangle that does not interact with the simulation in any way. 455 | 456 | :param p1: The first point of the triangle 457 | :type p1: (int, int) 458 | :param p2: The second point of the triangle 459 | :type p2: (int, int) 460 | :param p3: The third point of the triangle 461 | :type p3: (int, int) 462 | :rtype: shape 463 | 464 | """ 465 | return _triangle(p1, p2, p3, 0, False, True) 466 | 467 | 468 | def _triangle(p1, p2, p3, mass, static, cosmetic=False): 469 | from .poly_shape import Poly 470 | from .util import poly_area 471 | 472 | x1, y1 = p1 473 | x2, y2 = p2 474 | x3, y3 = p3 475 | 476 | x = (x1 + x2 + x3) / 3 477 | y = (y1 + y2 + y3) / 3 478 | vertices = ((x1 - x, y1 - y), (x2 - x, y2 - y), (x3 - x, y3 - y)) 479 | 480 | if mass == -1: 481 | mass = poly_area(vertices) 482 | 483 | result = Poly(space, x, y, vertices, 0, mass, static, cosmetic) 484 | result.color = default_color 485 | shapes[result.collision_type] = result 486 | 487 | return result 488 | 489 | 490 | def static_text(p, caption): 491 | """Creates a text rectangle that remains fixed in place, using 492 | Arial 12 point font. 493 | 494 | :param p: The upper left corner of the text rectangle 495 | :type p: (int, int) 496 | :param caption: The text to display 497 | :type caption: string 498 | :rtype: shape 499 | 500 | """ 501 | return _text(p, caption, 0, True) 502 | 503 | 504 | def text(p, caption, mass=-1): 505 | """Creates a text rectangle that reacts to gravity, using 506 | Arial 12 point font. 507 | 508 | :param p: The upper left corner of the text rectangle 509 | :type p: (int, int) 510 | :param caption: The text to display 511 | :type caption: string 512 | :param mass: The mass of the shape (defaults to 1) 513 | :type mass: int 514 | :rtype: shape 515 | 516 | """ 517 | return _text(p, caption, mass, False) 518 | 519 | 520 | def cosmetic_text(p, caption): 521 | """Creates text that displays on the screen but does not interact 522 | with other objects in any way. 523 | 524 | :param p: The upper left corner of the text 525 | :type p: (int, int) 526 | :param caption: The text to display 527 | :type caption: string 528 | :rtype: shape 529 | 530 | """ 531 | return _text(p, caption, 0, False, True) 532 | 533 | 534 | def _text(p, caption, mass, static, cosmetic=False): 535 | from .text_shape import Text 536 | 537 | if mass == -1: 538 | mass = 10 * len(caption) 539 | 540 | result = Text(space, p[0], p[1], caption, "Arial", 12, mass, static, cosmetic) 541 | result.color = default_color 542 | shapes[result.collision_type] = result 543 | 544 | return result 545 | 546 | 547 | def static_text_with_font(p, caption, font, size): 548 | """Creates a text rectangle that remains fixed in place. 549 | 550 | :param p: The upper left corner of the text rectangle 551 | :type p: (int, int) 552 | :param caption: The text to display 553 | :type caption: string 554 | :param font: The font family to use 555 | :type font: string 556 | :param size: The point size of the font 557 | :type size: int 558 | :rtype: shape 559 | 560 | """ 561 | return _text_with_font(p, caption, font, size, 0, True) 562 | 563 | 564 | def text_with_font(p, caption, font, size, mass=-1): 565 | """Creates a text rectangle that reacts to gravity. 566 | 567 | :param p: The upper left corner of the text rectangle 568 | :type p: (int, int) 569 | :param caption: The text to display 570 | :type caption: string 571 | :param font: The font family to use 572 | :type font: string 573 | :param size: The point size of the font 574 | :type size: int 575 | :param mass: The mass of the shape (defaults to 1) 576 | :type mass: int 577 | :rtype: shape 578 | 579 | """ 580 | return _text_with_font(p, caption, font, size, mass, False) 581 | 582 | 583 | def cosmetic_text_with_font(p, caption, font, size): 584 | """Creates text that displays on the screen but does not interact 585 | with other objects in any way. 586 | 587 | :param p: The upper left corner of the text 588 | :type p: (int, int) 589 | :param caption: The text to display 590 | :type caption: string 591 | :param font: The font family to use 592 | :type font: string 593 | :param size: The point size of the font 594 | :type size: int 595 | :rtype: shape 596 | 597 | """ 598 | return _text_with_font(p, caption, font, size, 0, False, True) 599 | 600 | 601 | def _text_with_font(p, caption, font, size, mass, static, cosmetic=False): 602 | from .text_shape import Text 603 | 604 | if mass == -1: 605 | mass = 10 * len(caption) 606 | 607 | result = Text(space, p[0], p[1], caption, font, size, mass, static, cosmetic) 608 | result.color = default_color 609 | shapes[result.collision_type] = result 610 | 611 | return result 612 | 613 | 614 | def static_line(p1, p2, thickness): 615 | """Creates a line segment that remains fixed in place. 616 | 617 | :param p1: The starting point of the line segement 618 | :type p1: (int, int) 619 | :param p2: The ending point of the line segement 620 | :type p2: (int, int) 621 | :param thickness: The thickness of the line segement 622 | :type thickness: int 623 | :rtype: shape 624 | 625 | """ 626 | return _line(p1, p2, thickness, 0, True) 627 | 628 | 629 | def line(p1, p2, thickness, mass=-1): 630 | """Creates a line segment that will react to gravity. 631 | 632 | :param p1: The starting point of the line segement 633 | :type p1: (int, int) 634 | :param p2: The ending point of the line segement 635 | :type p2: (int, int) 636 | :param thickness: The thickness of the line segement 637 | :type thickness: int 638 | :param mass: The mass of the shape (defaults to 1) 639 | :type mass: int 640 | :rtype: shape 641 | 642 | """ 643 | return _line(p1, p2, thickness, mass, False) 644 | 645 | 646 | def cosmetic_line(p1, p2, thickness): 647 | """Creates a line segment that does not interact with the simulation in any way. 648 | 649 | :param p1: The starting point of the line segement 650 | :type p1: (int, int) 651 | :param p2: The ending point of the line segement 652 | :type p2: (int, int) 653 | :param thickness: The thickness of the line segement 654 | :type thickness: int 655 | :rtype: shape 656 | 657 | """ 658 | return _line(p1, p2, thickness, 0, False, True) 659 | 660 | 661 | def _line(p1, p2, thickness, mass, static, cosmetic=False): 662 | from .line_segment import Line 663 | 664 | if mass == -1: 665 | mass = math.sqrt(math.pow(p1[0]-p2[0], 2)+math.pow(p1[1]-p2[1], 2))*thickness 666 | 667 | result = Line(space, p1, p2, thickness, mass, static, cosmetic) 668 | result.color = default_color 669 | shapes[result.collision_type] = result 670 | 671 | return result 672 | 673 | 674 | def pivot(p): 675 | """Creates a pivot joint around which shapes can freely rotate. 676 | Shapes must be connected to the pivot using the connect method 677 | on the returned shape. The pivot joint remains fixed in place. 678 | 679 | :param p: The point at which to place the pivot 680 | :type p: (int, int) 681 | :rtype: shape 682 | 683 | """ 684 | from .pivot_joint import Pivot 685 | 686 | result = Pivot(space, p[0], p[1]) 687 | result.color = default_color 688 | shapes[result.collision_type] = result 689 | 690 | return result 691 | 692 | 693 | def gear(shape1, shape2): 694 | """Connects two objects such that their rotations become the same. 695 | Can be used in conjunction with a motor on one shape to ensure the 696 | second shape rotates at the same speed as the first. 697 | 698 | :param shape1: The first shape to connect 699 | :type shape1: shape 700 | :param shape2: The second shape to connect 701 | :type shape2: shape 702 | :rtype: shape 703 | 704 | """ 705 | from .gear_joint import Gear 706 | 707 | result = Gear(space, shape1, shape2) 708 | result.color = default_color 709 | shapes[result.collision_type] = result 710 | 711 | return result 712 | 713 | 714 | def motor(shape1, speed=5): 715 | """Creates a constant rotation of the given shape around its 716 | center point. The direction of rotation is controlled by the 717 | sign of the speed. Positive speed is clockwise, negative speed 718 | is counter-clockwise. 719 | 720 | :param shape1: The shape to connect to the motor 721 | :type shape1: shape 722 | :param speed: The speed at which to rotate the shape 723 | :type speed: int 724 | :rtype: shape 725 | 726 | """ 727 | from .motor_joint import Motor 728 | 729 | result = Motor(space, shape1, speed) 730 | result.color = default_color 731 | shapes[result.collision_type] = result 732 | 733 | return result 734 | 735 | 736 | def pin(p1, shape1, p2, shape2): 737 | """Creates a connection between the shapes at the given positions. 738 | Those points on the shapes will remain that same distance apart, 739 | regardless of movement or rotation. 740 | 741 | :param p1: The point on the first shape 742 | :type p1: (int, int) 743 | :param shape1: The first shape to connect via the pin 744 | :type shape1: shape 745 | :param p2: The point on the second shape 746 | :type p2: (int, int) 747 | :param shape2: The second shape to connect via the pin 748 | :type shape2: shape 749 | :rtype: shape 750 | 751 | """ 752 | from .pin_joint import Pin 753 | 754 | result = Pin(space, p1, shape1, p2, shape2) 755 | result.color = default_color 756 | shapes[result.collision_type] = result 757 | 758 | return result 759 | 760 | 761 | def spring(p1, shape1, p2, shape2, length, stiffness, damping): 762 | """Creates a connection between the shapes at the given positions. 763 | Those points on the shapes will remain that same distance apart, 764 | regardless of movement or rotation. 765 | 766 | :param p1: The point on the first shape 767 | :type p1: (int, int) 768 | :param shape1: The first shape to connect via the pin 769 | :type shape1: shape 770 | :param p2: The point on the second shape 771 | :type p2: (int, int) 772 | :param shape2: The second shape to connect via the pin 773 | :type shape2: shape 774 | :param length: The length the spring wants to be 775 | :type length: float 776 | :param stiffness: The spring constant (Young’s modulus) 777 | :type stiffness: float 778 | :param damping: How soft to make the damping of the spring 779 | :type damping: float 780 | :rtype: shape 781 | 782 | """ 783 | from .spring_joint import Spring 784 | 785 | p1 = (p1[0]-shape1.position[0], p1[1]-shape1.position[1]) 786 | p2 = (p2[0]-shape2.position[0], p2[1]-shape2.position[1]) 787 | 788 | result = Spring(space, p1, shape1, p2, shape2, length, stiffness, damping) 789 | result.color = default_color 790 | shapes[result.collision_type] = result 791 | 792 | return result 793 | 794 | 795 | def slip_motor(shape1, shape2, rest_angle, stiffness, damping, slip_angle, speed): 796 | """Creates a combination spring and motor. The motor will rotate shape1 797 | around shape2 at the given speed. When shape1 reaches the slip angle it 798 | will spring back to the rest_angle. Then the motor will start to rotate 799 | the object again. 800 | 801 | :param shape1: The first shape to connect via the spring 802 | :type shape1: shape 803 | :param shape2: The second shape to connect via the spring 804 | :type shape2: shape 805 | :param rest_angle: The desired angle between the two objects 806 | :type rest_angle: float 807 | :param stiffness: the spring constant (Young's modulus) 808 | :type stiffness: float 809 | :param damping: the softness of the spring damping 810 | :type damping: float 811 | :param slip_angle: The angle at which to release the object 812 | :type slip_angle: float 813 | :param speed: The speed at which to rotate the shape 814 | :type speed: int 815 | :rtype: shape 816 | 817 | """ 818 | from .slip_motor import SlipMotor 819 | 820 | result = SlipMotor(space, shape1, shape2, rest_angle, stiffness, damping, slip_angle, speed) 821 | result.color = default_color 822 | shapes[result.collision_type] = result 823 | 824 | return result 825 | 826 | 827 | def rotary_spring(shape1, shape2, angle, stiffness, damping): 828 | """Creates a spring that constrains the rotations of the given shapes. 829 | The angle between the two shapes prefers to be at the given angle, but 830 | may be varied by forces on the objects. The spring will bring the objects 831 | back to the desired angle. The initial positioning of the shapes is considered 832 | to be at an angle of 0. 833 | 834 | :param shape1: The first shape to connect via the spring 835 | :type shape1: shape 836 | :param shape2: The second shape to connect via the spring 837 | :type shape2: shape 838 | :param angle: The desired angle between the two objects 839 | :type angle: float 840 | :param stiffness: the spring constant (Young's modulus) 841 | :type stiffness: float 842 | :param damping: the softness of the spring damping 843 | :type damping: float 844 | :rtype: shape 845 | 846 | """ 847 | from .rotary_spring import RotarySpring 848 | 849 | result = RotarySpring(space, shape1, shape2, angle, stiffness, damping) 850 | result.color = default_color 851 | shapes[result.collision_type] = result 852 | 853 | return result 854 | 855 | 856 | def num_shapes(): 857 | """Returns the number of active shapes in the simulation. 858 | 859 | :rtype int 860 | """ 861 | return len(shapes) 862 | 863 | 864 | def deactivate(shape): 865 | """Removes the given shape from the simulation. The shape will no longer 866 | display or interact with other objects 867 | 868 | :param shape: the shape to deactivate 869 | """ 870 | if not shape.active: 871 | return 872 | 873 | shape.deactivate() 874 | del shapes[shape.collision_type] 875 | 876 | 877 | def reactivate(shape): 878 | """The given shape will be reactivated. Its position and velocity will remain the same 879 | as it was when it was deactivated. 880 | 881 | :param shape: the shape to activate 882 | """ 883 | if shape.active: 884 | return 885 | 886 | shape.reactivate() 887 | shapes[shape.collision_type] = shape 888 | 889 | 890 | def add_collision(shape1, shape2, handler): 891 | """Tells the sandbox to call a function when the two given shapes collide. 892 | The handler function is called once per collision, at the very start of the 893 | collision. 894 | 895 | The handler function is passed three parameters. The first two are the 896 | colliding shapes, the third is the point of the collision, e.g.: 897 | 898 | handler(shape1, shape2, p) 899 | 900 | :param shape1: the first shape in the collision 901 | :param shape2: the other shape in the collision 902 | :param handler: the function to call 903 | :return: 904 | """ 905 | temp = space.add_collision_handler(shape1.collision_type, shape2.collision_type) 906 | temp.data['handler'] = handler 907 | temp.begin = handle_collision 908 | 909 | 910 | def handle_collision(arbiter, space, data): 911 | shape1 = shapes[arbiter.shapes[0].collision_type] 912 | shape2 = shapes[arbiter.shapes[1].collision_type] 913 | p = arbiter.contact_point_set.points[0].point_a 914 | 915 | return data['handler'](shape1, shape2, p) 916 | 917 | def _calc_margins(): 918 | global x_margin 919 | global y_margin 920 | 921 | for shape in shapes: 922 | x, y = shapes[shape].position 923 | x = abs(x) 924 | y = abs(y) 925 | 926 | if x_margin < x: 927 | x_margin = x 928 | 929 | if y_margin < y: 930 | y_margin = y 931 | 932 | 933 | def run(do_physics=True): 934 | """Call this after you have created all your shapes to actually run the simulation. 935 | This function returns only when the user has closed the simulation window. 936 | 937 | Pass False to this method to do the drawing but not activate physics. 938 | Useful for getting the scene right before running the simulation. 939 | 940 | :param do_physics: Should physics be activated or not 941 | :type do_physics: bool 942 | """ 943 | global clicked 944 | 945 | _calc_margins() 946 | 947 | screen = pygame.display.set_mode((win_width, win_height)) 948 | pygame.display.set_caption(win_title) 949 | clock = pygame.time.Clock() 950 | running = True 951 | 952 | while running: 953 | keys = [] 954 | clicked = False 955 | 956 | for event in pygame.event.get(): 957 | if event.type == pygame.QUIT: 958 | running = False 959 | 960 | if event.type == pygame.KEYDOWN: 961 | keys.append(event.key) 962 | 963 | if event.type == pygame.MOUSEBUTTONDOWN: 964 | clicked = True 965 | 966 | for observer in observers: 967 | observer(keys) 968 | 969 | screen.fill((255, 255, 255)) 970 | 971 | # Should automatically remove any shapes that are 972 | # far enough below the bottom edge of the window 973 | # that they won't be involved in anything visible 974 | shapes_to_remove = [] 975 | for collision_type, shape in shapes.items(): 976 | if shape.position.x > win_width + x_margin: 977 | shapes_to_remove.append(shape) 978 | 979 | if shape.position.x < -x_margin: 980 | shapes_to_remove.append(shape) 981 | 982 | if shape.position.y > win_height + y_margin: 983 | shapes_to_remove.append(shape) 984 | 985 | if shape.position.y < -y_margin: 986 | shapes_to_remove.append(shape) 987 | 988 | for shape in shapes_to_remove: 989 | deactivate(shape) 990 | 991 | # Also adjust positions for any shapes that are supposed 992 | # to wrap and have gone off an edge of the screen. 993 | for collision_type, shape in shapes.items(): 994 | if shape.wrap_x: 995 | if shape.position.x < 0: 996 | shape.position = (win_width - 1, shape.position.y) 997 | 998 | if shape.position.x >= win_width: 999 | shape.position = (0, shape.position.y) 1000 | 1001 | if shape.wrap_y: 1002 | if shape.position.y < 0: 1003 | shape.position = (shape.position.x, win_height - 1) 1004 | 1005 | if shape.position.y >= win_height: 1006 | shape.position = (shape.position.x, 0) 1007 | 1008 | # Now draw the shapes that are left 1009 | for collision_type, shape in shapes.items(): 1010 | shape.draw(screen) 1011 | 1012 | if do_physics: 1013 | space.step(1 / 50.0) 1014 | 1015 | pygame.display.flip() 1016 | clock.tick(50) 1017 | 1018 | pygame.quit() 1019 | 1020 | 1021 | def draw(): 1022 | """Call this after you have created all your shapes to actually draw them. 1023 | This function only returns after you close the window. 1024 | 1025 | This is an alias for run(False). 1026 | """ 1027 | run(False) 1028 | -------------------------------------------------------------------------------- /pyphysicssandbox/ball_shape.py: -------------------------------------------------------------------------------- 1 | import pygame 2 | import pymunk 3 | 4 | from .base_shape import BaseShape 5 | from .util import to_pygame 6 | 7 | 8 | class Ball(BaseShape): 9 | def __init__(self, space, x, y, radius, mass, static, cosmetic=False): 10 | if not cosmetic: 11 | moment = pymunk.moment_for_circle(mass, 0, radius) 12 | 13 | if static: 14 | self.body = pymunk.Body(mass, moment, pymunk.Body.STATIC) 15 | else: 16 | self.body = pymunk.Body(mass, moment) 17 | 18 | self.body.position = x, y 19 | self.shape = pymunk.Circle(self.body, radius) 20 | space.add(self.body, self.shape) 21 | 22 | self.static = static 23 | self._draw_radius_line = False 24 | self._x = x 25 | self._y = y 26 | self._radius = radius 27 | 28 | super().__init__(cosmetic) 29 | 30 | def _draw(self, screen): 31 | if self._cosmetic: 32 | p = (self._x, self._y) 33 | else: 34 | p = to_pygame(self.body.position) 35 | 36 | pygame.draw.circle(screen, self.color, p, int(self._radius), 0) 37 | 38 | if self.draw_radius_line: 39 | if self._cosmetic: 40 | p2 = (self._x+self._radius, self._y) 41 | else: 42 | circle_edge = self.body.position + pymunk.Vec2d(self.shape.radius, 0).rotated(self.body.angle) 43 | p2 = to_pygame(circle_edge) 44 | 45 | pygame.draw.lines(screen, pygame.Color('black'), False, [p, p2], 1) 46 | 47 | def _pin_points(self): 48 | x1 = self.body.position.x - self.shape.radius 49 | y1 = self.body.position.y 50 | x2 = self.body.position.x + self.shape.radius 51 | y2 = y1 52 | 53 | return (x1, y1), (x2, y2) 54 | 55 | def __repr__(self): 56 | prefix = 'ball' 57 | 58 | if self.static: 59 | prefix = 'static_ball' 60 | 61 | if self._cosmetic: 62 | prefix = 'cosmetic_ball' 63 | 64 | return prefix+': p(' + str(self.body.position.x) + ',' + str(self.body.position.y) + '), radius: ' + \ 65 | str(self.shape.radius) 66 | 67 | @property 68 | def draw_radius_line(self): 69 | return self._draw_radius_line 70 | 71 | @draw_radius_line.setter 72 | def draw_radius_line(self, value): 73 | if type(value) == bool: 74 | self._draw_radius_line = value 75 | else: 76 | print("draw_radius_line value must be a True or False") 77 | -------------------------------------------------------------------------------- /pyphysicssandbox/base_shape.py: -------------------------------------------------------------------------------- 1 | import pygame 2 | import pymunk 3 | import math 4 | 5 | from pyphysicssandbox import pin 6 | from pyphysicssandbox import win_width 7 | from pyphysicssandbox import win_height 8 | from pyphysicssandbox import space 9 | from pyphysicssandbox import add_observer 10 | 11 | 12 | class BaseShape: 13 | next_collision_type = 0 14 | 15 | def __init__(self, cosmetic=False): 16 | self._cosmetic = cosmetic 17 | 18 | if cosmetic: 19 | self.body = None 20 | self.shape = [] 21 | else: 22 | self.body.custom_gravity = space.gravity 23 | self.body.custom_damping = space.damping 24 | self.body.constant_velocity = None 25 | 26 | self.elasticity = 0.90 27 | self.friction = 0.6 28 | self._color = pygame.Color('black') 29 | self._wrap_x = False 30 | self._wrap_y = False 31 | self._active = True 32 | self._visible = True 33 | self._debug = False 34 | self.custom_velocity_func = False 35 | 36 | BaseShape.next_collision_type += 1 37 | self._collision_type = BaseShape.next_collision_type 38 | 39 | if type(self.shape) is list: 40 | for shape in self.shape: 41 | shape.collision_type = BaseShape.next_collision_type 42 | else: 43 | self.shape.collision_type = BaseShape.next_collision_type 44 | 45 | add_observer(self.observer) 46 | 47 | def observer(self, keys): 48 | if self._debug: 49 | print (repr(self)) 50 | 51 | def hit(self, direction, position): 52 | if self._cosmetic: 53 | return 54 | 55 | self.body.apply_impulse_at_world_point(direction, position) 56 | 57 | def has_own_body(self): 58 | return not self._cosmetic 59 | 60 | def inside(self, p): 61 | mask = pygame.Surface((win_width, win_height)) 62 | color = self.color 63 | self.color = pygame.Color('white') 64 | self._draw(mask) 65 | self.color = color 66 | 67 | mask.lock() 68 | pixel = mask.get_at(p) 69 | mask.unlock() 70 | 71 | return pixel == pygame.Color('white') 72 | 73 | def draw(self, screen): 74 | if self.visible: 75 | self._draw(screen) 76 | 77 | def paste_on(self, other_shape): 78 | p1, p2 = self._pin_points() 79 | 80 | pin(p1, self, p1, other_shape).visible = False 81 | pin(p2, self, p2, other_shape).visible = False 82 | 83 | @property 84 | def active(self): 85 | return self._active 86 | 87 | def deactivate(self): 88 | self._active = False 89 | 90 | if type(self.shape) is list: 91 | for s in self.shape: 92 | space.remove(s) 93 | 94 | if self.has_own_body(): 95 | space.remove(self.body) 96 | else: 97 | if self.has_own_body(): 98 | space.remove(self.shape, self.body) 99 | else: 100 | space.remove(self.shape) 101 | 102 | def reactivate(self): 103 | self._active = True 104 | 105 | if type(self.shape) is list: 106 | for s in self.shape: 107 | space.add(s) 108 | 109 | if self.has_own_body(): 110 | space.add(self.body) 111 | else: 112 | if self.has_own_body(): 113 | space.add(self.shape, self.body) 114 | else: 115 | space.add(self.shape) 116 | 117 | @property 118 | def angle(self): 119 | if self.body: 120 | return -math.degrees(self.body.angle) 121 | 122 | return 0.0 123 | 124 | @angle.setter 125 | def angle(self, value): 126 | if type(value) == float or type(value) == int: 127 | self.body.angle = math.radians(-value) 128 | space.reindex_shape(self.shape) 129 | else: 130 | print("Angle value must be a number") 131 | 132 | @property 133 | def debug(self): 134 | return self._debug 135 | 136 | @debug.setter 137 | def debug(self, value): 138 | if type(value) == bool: 139 | self._debug = value 140 | else: 141 | print("Debug value must be a boolean") 142 | 143 | @property 144 | def x(self): 145 | if self.body: 146 | return self.body.position[0] 147 | 148 | return self._x 149 | 150 | @property 151 | def y(self): 152 | if self.body: 153 | return self.body.position[1] 154 | 155 | return self._y 156 | 157 | @property 158 | def position(self): 159 | if self.body: 160 | return self.body.position 161 | 162 | return pymunk.vec2d.Vec2d(self._x, self._y) 163 | 164 | @x.setter 165 | def x(self, value): 166 | if type(value) == int: 167 | if self.body: 168 | self.body.position = (value, self.body.position[1]) 169 | space.reindex_shape(self.shape) 170 | else: 171 | self._x = value 172 | else: 173 | print("X value must be an int") 174 | 175 | @y.setter 176 | def y(self, value): 177 | if type(value) == int: 178 | if self.body: 179 | self.body.position = (self.body.position[0], value) 180 | space.reindex_shape(self.shape) 181 | else: 182 | self._y = value 183 | else: 184 | print("Y value must be an int") 185 | 186 | @position.setter 187 | def position(self, value): 188 | if type(value) == tuple and len(value) == 2: 189 | if self.body: 190 | self.body.position = value 191 | space.reindex_shape(self.shape) 192 | else: 193 | self._x = value[0] 194 | self._y = value[1] 195 | else: 196 | print("Position value must be a (x, y) tuple") 197 | 198 | @property 199 | def surface_velocity(self): 200 | if type(self.shape) is list: 201 | return self.shape[0].surface_velocity 202 | 203 | return self.shape.surface_velocity 204 | 205 | @surface_velocity.setter 206 | def surface_velocity(self, value): 207 | if type(value) == tuple and len(value)==2: 208 | if type(self.shape) is list: 209 | for shape in self.shape: 210 | shape.surface_velocity = value 211 | else: 212 | self.shape.surface_velocity = value 213 | else: 214 | print("Surface velocity value must be a (x, y) tuple") 215 | 216 | @property 217 | def elasticity(self): 218 | if type(self.shape) is list: 219 | return self.shape[0].elasticity 220 | 221 | return self.shape.elasticity 222 | 223 | @elasticity.setter 224 | def elasticity(self, value): 225 | if type(value) == int: 226 | value = float(value) 227 | 228 | if type(value) == float: 229 | if type(self.shape) is list: 230 | for shape in self.shape: 231 | shape.elasticity = value 232 | else: 233 | self.shape.elasticity = value 234 | else: 235 | print("Elasticity value must be a floating point value") 236 | 237 | @property 238 | def collision_type(self): 239 | return self._collision_type 240 | 241 | @property 242 | def friction(self): 243 | if type(self.shape) is list: 244 | return self.shape[0].friction 245 | 246 | return self.shape.friction 247 | 248 | @friction.setter 249 | def friction(self, value): 250 | if type(value) == float: 251 | if type(self.shape) is list: 252 | for shape in self.shape: 253 | shape.friction = value 254 | else: 255 | self.shape.friction = value 256 | else: 257 | print("Friction value must be a floating point value") 258 | 259 | @property 260 | def wrap(self): 261 | return self._wrap_x or self.wrap_y 262 | 263 | @wrap.setter 264 | def wrap(self, value): 265 | if type(value) == bool: 266 | self._wrap_x = value 267 | self._wrap_y = value 268 | else: 269 | print("Wrap value must be True or False") 270 | 271 | @property 272 | def wrap_x(self): 273 | return self._wrap_x 274 | 275 | @wrap_x.setter 276 | def wrap_x(self, value): 277 | if type(value) == bool: 278 | self._wrap_x = value 279 | else: 280 | print("Wrap value must be True or False") 281 | 282 | @property 283 | def wrap_y(self): 284 | return self._wrap_y 285 | 286 | @wrap_y.setter 287 | def wrap_y(self, value): 288 | if type(value) == bool: 289 | self._wrap_y = value 290 | else: 291 | print("Wrap value must be True or False") 292 | 293 | @property 294 | def visible(self): 295 | return self._visible 296 | 297 | @visible.setter 298 | def visible(self, value): 299 | if type(value) == bool: 300 | self._visible = value 301 | else: 302 | print("Visible value must be True or False") 303 | 304 | @property 305 | def color(self): 306 | return self._color 307 | 308 | @color.setter 309 | def color(self, value): 310 | if type(value) == pygame.Color: 311 | self._color = value 312 | else: 313 | print("Color value must be a Color instance") 314 | 315 | @property 316 | def group(self): 317 | if type(self.shape) is list: 318 | return self.shape[0].filter.group 319 | 320 | return self.shape.filter.group 321 | 322 | @group.setter 323 | def group(self, value): 324 | if type(value) == int: 325 | if type(self.shape) is list: 326 | for shape in self.shape: 327 | shape.filter = pymunk.ShapeFilter(group=value) 328 | else: 329 | self.shape.filter = pymunk.ShapeFilter(group=value) 330 | else: 331 | print("Group value must be an integer") 332 | 333 | def _check_velocity_func(self): 334 | if not self.custom_velocity_func: 335 | self.custom_velocity_func = True 336 | self.body.velocity_func = adjust_velocity 337 | 338 | @property 339 | def gravity(self): 340 | if self._cosmetic: 341 | return 0, 0 342 | 343 | return self.body.custom_gravity 344 | 345 | @gravity.setter 346 | def gravity(self, value): 347 | if self._cosmetic: 348 | return 349 | 350 | if type(value) == tuple and len(value) == 2: 351 | self.body.custom_gravity = value 352 | self._check_velocity_func() 353 | else: 354 | print("Gravity value must be a (x, y) tuple") 355 | 356 | @property 357 | def damping(self): 358 | if self._cosmetic: 359 | return 0, 0 360 | 361 | return self.body.custom_damping 362 | 363 | @damping.setter 364 | def damping(self, value): 365 | if self._cosmetic: 366 | return 367 | 368 | if type(value) == float: 369 | self.body.custom_damping = value 370 | self._check_velocity_func() 371 | else: 372 | print("Damping value must be a float") 373 | 374 | @property 375 | def velocity(self): 376 | if self._cosmetic: 377 | return 0, 0 378 | 379 | return self.body.velocity 380 | 381 | @velocity.setter 382 | def velocity(self, value): 383 | if self._cosmetic: 384 | return 385 | 386 | if type(value) == tuple and len(value) == 2: 387 | self.body.constant_velocity = value 388 | self._check_velocity_func() 389 | else: 390 | print("Velocity value must be an x,y tuple") 391 | 392 | def adjust_velocity(body, gravity, damping, dt): 393 | if body.constant_velocity: 394 | body.velocity = body.constant_velocity 395 | return 396 | 397 | return body.update_velocity(body, body.custom_gravity, pow(body.custom_damping, dt), dt) 398 | -------------------------------------------------------------------------------- /pyphysicssandbox/box_shape.py: -------------------------------------------------------------------------------- 1 | import pygame 2 | import pymunk 3 | import math 4 | 5 | from .base_shape import BaseShape 6 | 7 | 8 | class Box(BaseShape): 9 | def __init__(self, space, x, y, width, height, radius, mass, static, cosmetic=False): 10 | 11 | if not cosmetic: 12 | moment = pymunk.moment_for_box(mass, (width, height)) 13 | 14 | if static: 15 | self.body = pymunk.Body(mass, moment, pymunk.Body.STATIC) 16 | else: 17 | self.body = pymunk.Body(mass, moment) 18 | 19 | self.body.position = x, y 20 | self.shape = pymunk.Poly.create_box(self.body, (width, height), radius) 21 | space.add(self.body, self.shape) 22 | 23 | self.width = width 24 | self.height = height 25 | self.radius = radius 26 | self.static = static 27 | self._x = x 28 | self._y = y 29 | 30 | super().__init__(cosmetic) 31 | 32 | def _draw(self, screen): 33 | if self._cosmetic: 34 | x = self._x-self.width/2 35 | y = self._y-self.height/2 36 | 37 | ps = [(x, y), (x+self.width, y), (x+self.width, y+self.height), (x,y+self.height), (x, y)] 38 | else: 39 | ps = [self.body.local_to_world(v) for v in self.shape.get_vertices()] 40 | ps += [ps[0]] 41 | 42 | pygame.draw.polygon(screen, self.color, ps) 43 | pygame.draw.lines(screen, self.color, False, ps, self.radius) 44 | 45 | def _pin_points(self): 46 | x1 = self.body.position.x - (self.width/2) 47 | y1 = self.body.position.y + (self.height/2) 48 | x2 = x1 + self.width 49 | y2 = y1 50 | 51 | return (x1, y1), (x2, y2) 52 | 53 | def __repr__(self): 54 | prefix = 'box' 55 | 56 | if self.static: 57 | prefix = 'static_box' 58 | 59 | if self._cosmetic: 60 | prefix = 'cosmetic_box' 61 | 62 | return prefix+': p(' + str(self.body.position.x) + ',' + str(self.body.position.y) + '), width: ' + \ 63 | str(self.width) + ', height: ' + str(self.height) + ', angle: ' + str(self.angle) 64 | -------------------------------------------------------------------------------- /pyphysicssandbox/gear_joint.py: -------------------------------------------------------------------------------- 1 | import pymunk 2 | 3 | from .base_shape import BaseShape 4 | 5 | 6 | class Gear(BaseShape): 7 | def __init__(self, space, shape1, shape2, angle=0): 8 | # Associate the gear joint with the location of one of the bodies so 9 | # it is removed when that body is out of the simulation 10 | self.body = shape1.body 11 | self.shape = pymunk.GearJoint(shape1.body, shape2.body, angle, 1) 12 | super().__init__() 13 | 14 | space.add(self.shape) 15 | 16 | def has_own_body(self): 17 | return False 18 | 19 | def _draw(self, screen): 20 | pass 21 | 22 | def _pin_points(self): 23 | raise Exception('Do not use paste_on for gears') 24 | 25 | def __repr__(self): 26 | return 'gear: p(' + str(self.body.position.x) + ',' + str(self.body.position.y) + ')' 27 | 28 | -------------------------------------------------------------------------------- /pyphysicssandbox/line_segment.py: -------------------------------------------------------------------------------- 1 | import pygame 2 | import pymunk 3 | 4 | from .base_shape import BaseShape 5 | 6 | 7 | class Line(BaseShape): 8 | def __init__(self, space, p1, p2, thickness, mass, static, cosmetic=False): 9 | x = (p1[0] + p2[0]) / 2 10 | y = (p1[1] + p2[1]) / 2 11 | 12 | if not cosmetic: 13 | moment = pymunk.moment_for_segment(mass, (p1[0]-x, p1[1]-y), (p2[0]-x, p2[1]-y), thickness) 14 | 15 | if static: 16 | self.body = pymunk.Body(mass, moment, pymunk.Body.STATIC) 17 | else: 18 | self.body = pymunk.Body(mass, moment) 19 | 20 | self.body.position = x, y 21 | self.shape = pymunk.Segment(self.body, (p1[0]-x, p1[1]-y), (p2[0]-x, p2[1]-y), thickness) 22 | space.add(self.body, self.shape) 23 | 24 | self.radius = thickness 25 | self.static = static 26 | self._p1 = p1 27 | self._p2 = p2 28 | self._x = x; 29 | self._y = y 30 | 31 | super().__init__(cosmetic) 32 | 33 | def _draw(self, screen): 34 | if self._cosmetic: 35 | p1 = self._p1 36 | p2 = self._p2 37 | else: 38 | p1 = self.body.local_to_world(self.shape.a) 39 | p2 = self.body.local_to_world(self.shape.b) 40 | 41 | pygame.draw.line(screen, self.color, p1, p2, self.radius) 42 | 43 | def _pin_points(self): 44 | p1 = self.body.local_to_world(self.shape.a) 45 | p2 = self.body.local_to_world(self.shape.b) 46 | 47 | return p1, p2 48 | 49 | def __repr__(self): 50 | prefix = 'line' 51 | 52 | if self.static: 53 | prefix = 'static_line' 54 | 55 | if self._cosmetic: 56 | prefix = 'cosmetic_line' 57 | 58 | p1 = self.body.local_to_world(self.shape.a) 59 | p2 = self.body.local_to_world(self.shape.b) 60 | 61 | return prefix + ': p1(' + str(p1.x) + ',' + str(p1.y) + '), p2(' + str(p2.x) + ',' + str(p2.y) + ')' 62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /pyphysicssandbox/motor_joint.py: -------------------------------------------------------------------------------- 1 | import pygame 2 | import pymunk 3 | 4 | from .base_shape import BaseShape 5 | from .util import to_pygame 6 | 7 | 8 | class Motor(BaseShape): 9 | def __init__(self, space, shape1, speed): 10 | # Associate the motor with the location of one of the bodies so 11 | # it is removed when that body is out of the simulation 12 | self.body = shape1.body 13 | self.shape = pymunk.SimpleMotor(shape1.body, space.static_body, speed) 14 | super().__init__() 15 | 16 | space.add(self.shape) 17 | 18 | def has_own_body(self): 19 | return False 20 | 21 | def _draw(self, screen): 22 | p = to_pygame(self.body.position) 23 | radius = 10 24 | rect = pygame.Rect(p[0] - radius/2, p[1] - radius/2, radius, radius) 25 | 26 | pygame.draw.arc(screen, self.color, rect, 1, 6) 27 | 28 | if self.shape.rate > 0: 29 | pygame.draw.circle(screen, self.color, rect.topright, 2, 0) 30 | else: 31 | pygame.draw.circle(screen, self.color, rect.bottomright, 2, 0) 32 | 33 | def _pin_points(self): 34 | raise Exception('Do not use paste_on for motors') 35 | 36 | def __repr__(self): 37 | return 'motor: p(' + str(self.body.position.x) + ',' + str(self.body.position.y) + '), speed: ' + \ 38 | str(self.shape.speed) 39 | 40 | -------------------------------------------------------------------------------- /pyphysicssandbox/pin_joint.py: -------------------------------------------------------------------------------- 1 | import pygame 2 | import pymunk 3 | 4 | from .base_shape import BaseShape 5 | 6 | 7 | class Pin(BaseShape): 8 | def __init__(self, space, p1, shape1, p2, shape2): 9 | # Associate the pin with the location of one of the bodies so 10 | # it is removed when that body is out of the simulation 11 | self.body = shape1.body 12 | 13 | ax = p1[0] - shape1.body.position.x 14 | ay = p1[1] - shape1.body.position.y 15 | bx = p2[0] - shape2.body.position.x 16 | by = p2[1] - shape2.body.position.y 17 | 18 | self.shape = pymunk.PinJoint(shape1.body, shape2.body, (ax, ay), (bx, by)) 19 | super().__init__() 20 | 21 | space.add(self.shape) 22 | 23 | def has_own_body(self): 24 | return False 25 | 26 | def _draw(self, screen): 27 | p1 = self.shape.a.local_to_world(self.shape.anchor_a) 28 | p2 = self.shape.b.local_to_world(self.shape.anchor_b) 29 | 30 | pygame.draw.line(screen, self.color, p1, p2, 1) 31 | pygame.draw.circle(screen, self.color, (int(p1[0]), int(p1[1])), 2) 32 | pygame.draw.circle(screen, self.color, (int(p2[0]), int(p2[1])), 2) 33 | 34 | def _pin_points(self): 35 | raise Exception('Do not use paste_on for pins') 36 | 37 | def __repr__(self): 38 | p1 = self.body.local_to_world(self.shape.a) 39 | p2 = self.body.local_to_world(self.shape.b) 40 | 41 | return 'pin: p1(' + str(p1.x) + ',' + str(p1.y) + '), p2(' + str(p2.x) + ',' + str(p2.y) + ')' 42 | 43 | -------------------------------------------------------------------------------- /pyphysicssandbox/pivot_joint.py: -------------------------------------------------------------------------------- 1 | import pygame 2 | import pymunk 3 | 4 | from .base_shape import BaseShape 5 | from .util import to_pygame 6 | 7 | 8 | class Pivot(BaseShape): 9 | def __init__(self, space, x, y): 10 | self.body = pymunk.Body(body_type=pymunk.Body.STATIC) 11 | self.body.position = x, y 12 | self.shape = [] 13 | self.space = space 14 | super().__init__() 15 | 16 | space.add(self.body) 17 | 18 | def connect(self, shape): 19 | join = pymunk.PivotJoint(shape.body, self.body, self.body.position) 20 | self.shape.append(join) 21 | self.space.add(join) 22 | 23 | def _draw(self, screen): 24 | p = to_pygame(self.body.position) 25 | pygame.draw.circle(screen, self.color, p, 5, 0) 26 | 27 | def _pin_points(self): 28 | raise Exception('Do not use paste_on for pivots') 29 | 30 | def __repr__(self): 31 | return 'pivot: p(' + str(self.body.position.x) + ',' + str(self.body.position.y) + ')' 32 | 33 | -------------------------------------------------------------------------------- /pyphysicssandbox/poly_shape.py: -------------------------------------------------------------------------------- 1 | import pygame 2 | import pymunk 3 | 4 | from .base_shape import BaseShape 5 | from py2d.Math.Polygon import * 6 | 7 | 8 | class Poly(BaseShape): 9 | def __init__(self, space, x, y, vertices, radius, mass, static, cosmetic=False): 10 | if not cosmetic: 11 | moment = pymunk.moment_for_poly(mass, vertices, (0, 0), radius) 12 | 13 | if static: 14 | self.body = pymunk.Body(mass, moment, pymunk.Body.STATIC) 15 | else: 16 | self.body = pymunk.Body(mass, moment) 17 | 18 | self.body.position = x, y 19 | self.static = static 20 | 21 | temp = Polygon.from_tuples(vertices) 22 | polys = Polygon.convex_decompose(temp) 23 | 24 | shapes = [] 25 | 26 | for poly in polys: 27 | shapes.append(pymunk.Poly(self.body, poly.as_tuple_list(), None, radius)) 28 | 29 | self.shape = shapes 30 | space.add(self.body, *self.shape) 31 | 32 | self.radius = radius 33 | self._x = x 34 | self._y = y 35 | self._vertices = vertices 36 | 37 | super().__init__(cosmetic) 38 | 39 | def _draw(self, screen): 40 | if self._cosmetic: 41 | pygame.draw.polygon(screen, self.color, [(v[0] + self._x, v[1] + self._y) for v in self._vertices]) 42 | else: 43 | for shape in self.shape: 44 | ps = [self.body.local_to_world(v) for v in shape.get_vertices()] 45 | 46 | pygame.draw.polygon(screen, self.color, ps) 47 | 48 | def _pin_points(self): 49 | x1 = self.body.position.x-5 50 | y1 = self.body.position.y 51 | x2 = self.body.position.x+5 52 | y2 = y1 53 | 54 | return (x1, y1), (x2, y2) 55 | 56 | def __repr__(self): 57 | prefix = 'polygon' 58 | 59 | if self.static: 60 | prefix = 'static_polygon' 61 | 62 | if self._cosmetic: 63 | prefix = 'cosmetic_polygon' 64 | 65 | return prefix + ': center(' + str(self.body.position.x) + ',' + str(self.body.position.y) + ')' 66 | -------------------------------------------------------------------------------- /pyphysicssandbox/rotary_spring.py: -------------------------------------------------------------------------------- 1 | import pymunk 2 | import math 3 | 4 | from .base_shape import BaseShape 5 | 6 | 7 | class RotarySpring(BaseShape): 8 | def __init__(self, space, shape1, shape2, angle, stiffness, damping): 9 | # Associate the joint with the location of one of the bodies so 10 | # it is removed when that body is out of the simulation 11 | self.body = shape1.body 12 | self.shape = pymunk.DampedRotarySpring(shape1.body, shape2.body, math.radians(-angle), stiffness, damping) 13 | super().__init__() 14 | 15 | space.add(self.shape) 16 | 17 | def has_own_body(self): 18 | return False 19 | 20 | def _draw(self, screen): 21 | pass 22 | 23 | def _pin_points(self): 24 | raise Exception('Do not use draw_on for rotary springs') 25 | 26 | def __repr__(self): 27 | return 'rotary_spring: p(' + str(self.body.position.x) + ',' + str(self.body.position.y) + '), rest angle: ' + \ 28 | str(-math.degrees(self.shape.rest_angle)) 29 | 30 | 31 | -------------------------------------------------------------------------------- /pyphysicssandbox/slip_motor.py: -------------------------------------------------------------------------------- 1 | import pygame 2 | import pymunk 3 | import math 4 | 5 | from .rotary_spring import RotarySpring 6 | from .motor_joint import Motor 7 | from pyphysicssandbox import add_observer 8 | from pyphysicssandbox import deactivate 9 | from pyphysicssandbox import reactivate 10 | 11 | 12 | class SlipMotor(Motor): 13 | def __init__(self, space, shape1, shape2, rest_angle, stiffness, damping, slip_angle, speed): 14 | super().__init__(space, shape1, speed) 15 | 16 | self._spring = RotarySpring(space, shape1, shape2, rest_angle, stiffness, damping) 17 | self._slip_angle = -slip_angle 18 | self._rest_angle = -rest_angle 19 | 20 | def observer(self, keys): 21 | super().observer(keys) 22 | 23 | degrees = math.degrees(self.body.angle) 24 | 25 | if self.active: 26 | if self.shape.rate < 0: 27 | if degrees <= self._slip_angle: 28 | deactivate(self) 29 | else: 30 | if degrees >= self._slip_angle: 31 | deactivate(self) 32 | else: 33 | if self.shape.rate > 0: 34 | if degrees <= self._rest_angle: 35 | reactivate(self) 36 | else: 37 | if degrees >= self._rest_angle: 38 | reactivate(self) 39 | 40 | def _pin_points(self): 41 | raise Exception('Do not use paste_on for slip motors') 42 | 43 | def __repr__(self): 44 | return 'slip motor: p(' + str(self.body.position.x) + ',' + str(self.body.position.y) + '), rest angle: ' + \ 45 | str(-self._rest_angle) + ' slip angle ' + str(-self._slip_angle) 46 | -------------------------------------------------------------------------------- /pyphysicssandbox/spring_joint.py: -------------------------------------------------------------------------------- 1 | import pymunk 2 | import math 3 | 4 | from .base_shape import BaseShape 5 | 6 | 7 | class Spring(BaseShape): 8 | def __init__(self, space, p1, shape1, p2, shape2, length, stiffness, damping): 9 | # Associate the joint with the location of one of the bodies so 10 | # it is removed when that body is out of the simulation 11 | self.body = shape1.body 12 | self.shape = pymunk.DampedSpring(shape1.body, shape2.body, p1, p2, length, stiffness, damping) 13 | super().__init__() 14 | 15 | space.add(self.shape) 16 | 17 | def has_own_body(self): 18 | return False 19 | 20 | def _draw(self, screen): 21 | pass 22 | 23 | def _pin_points(self): 24 | raise Exception('Do not use draw_on for springs') 25 | 26 | def __repr__(self): 27 | p1 = (self.shape.anchor_a[0] + self.shape.a.position[0], self.shape.anchor_a[1] + self.shape.a.position[1]) 28 | p2 = (self.shape.anchor_b[0] + self.shape.b.position[0], self.shape.anchor_b[1] + self.shape.b.position[1]) 29 | 30 | return 'spring: p1(' + str(p1[0]) + ',' + str(p1[1]) + '), p2(' + str(p2[0]) + ',' + str(p2[1]) + \ 31 | '), length: ' + str(math.sqrt(math.pow(p1[0]-p2[0], 2)+math.pow(p1[1]-p2[1], 2))) 32 | -------------------------------------------------------------------------------- /pyphysicssandbox/text_shape.py: -------------------------------------------------------------------------------- 1 | import pygame 2 | import pymunk 3 | import math 4 | 5 | from .box_shape import Box 6 | from .base_shape import BaseShape 7 | 8 | 9 | class Text(Box): 10 | def __init__(self, space, x, y, caption, font_name, font_size, mass, static, cosmetic=False): 11 | self.font = pygame.font.SysFont(font_name, font_size) 12 | width, height = self.font.size(caption) 13 | height -= self.font.get_ascent() 14 | 15 | self.caption = caption 16 | self.space = space 17 | self.static = static 18 | 19 | box_x = x + width / 2 20 | box_y = y + height / 2 21 | self._x = box_x 22 | self._y = box_y 23 | 24 | super().__init__(space, box_x, box_y, width, height, 3, mass, static, cosmetic) 25 | 26 | self.label = self.font.render(self.caption, True, self.color) 27 | 28 | def _draw(self, screen): 29 | degrees = self.angle 30 | rotated = pygame.transform.rotate(self.label, degrees) 31 | 32 | size = rotated.get_rect() 33 | screen.blit(rotated, (self.position.x-(size.width/2), self.position.y-(size.height/2))) 34 | 35 | def __repr__(self): 36 | prefix = 'box' 37 | 38 | if self.static: 39 | prefix = 'static_box' 40 | 41 | if self._cosmetic: 42 | prefix = 'cosmetic_box' 43 | 44 | return prefix+': p(' + str(self.position[0]) + ',' + str(self.position[1]) + '), caption: ' + self.caption + \ 45 | ', angle: ' + str(self.angle) 46 | 47 | @BaseShape.color.setter 48 | def color(self, value): 49 | BaseShape.color.fset(self, value) 50 | self.label = self.font.render(self.caption, True, self.color) 51 | 52 | @property 53 | def text(self): 54 | return self.caption 55 | 56 | @text.setter 57 | def text(self, value): 58 | if type(value) == str: 59 | self.caption = value 60 | self.label = self.font.render(self.caption, True, self.color) 61 | 62 | if not self._cosmetic: 63 | width, height = self.font.size(value) 64 | height -= self.font.get_ascent() 65 | 66 | moment = pymunk.moment_for_box(self.body.mass, (width, height)) 67 | 68 | if self.static: 69 | body = pymunk.Body(self.body.mass, moment, pymunk.Body.STATIC) 70 | else: 71 | body = pymunk.Body(self.body.mass, moment) 72 | 73 | body.position = self.position 74 | shape = pymunk.Poly.create_box(body, (width, height), self.radius) 75 | self.width = width 76 | self.height = height 77 | 78 | self.space.remove(self.body, self.shape) 79 | self.body = body 80 | self.shape = shape 81 | self.space.add(self.body, self.shape) 82 | else: 83 | print("Text value must be a string") 84 | 85 | -------------------------------------------------------------------------------- /pyphysicssandbox/util.py: -------------------------------------------------------------------------------- 1 | def to_pygame(p): 2 | # Converts pymunk body position into pygame coordinate tuple 3 | return int(p.x), int(p.y) 4 | 5 | 6 | def poly_centroid(vertices): 7 | centroid = [0, 0] 8 | area = 0.0 9 | 10 | for i in range(len(vertices)): 11 | x0, y0 = vertices[i] 12 | 13 | if i == len(vertices) - 1: 14 | x1, y1 = vertices[0] 15 | else: 16 | x1, y1 = vertices[i + 1] 17 | 18 | a = (x0 * y1 - x1 * y0) 19 | area += a 20 | centroid[0] += (x0 + x1) * a 21 | centroid[1] += (y0 + y1) * a 22 | 23 | area *= 0.5 24 | centroid[0] /= (6.0 * area) 25 | centroid[1] /= (6.0 * area) 26 | 27 | return centroid 28 | 29 | 30 | def poly_area(vertices): 31 | n = len(vertices) # of corners 32 | a = 0.0 33 | for i in range(n): 34 | j = (i + 1) % n 35 | a += abs(vertices[i][0] * vertices[j][1] - vertices[j][0] * vertices[i][1]) 36 | result = a / 2.0 37 | return result 38 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from distutils.core import setup 2 | setup( 3 | name = 'pyphysicssandbox', 4 | packages = ['pyphysicssandbox', 'py2d', 'py2d.Math'], 5 | version = '1.4.4', 6 | description = 'A simple Python physics sandbox for intro programming students', 7 | author = 'Jay Shaffstall', 8 | author_email = 'jshaffstall@gmail.com', 9 | url = 'https://github.com/jshaffstall/PyPhysicsSandbox', 10 | download_url = 'https://github.com/jshaffstall/PyPhysicsSandbox/tarball/1.4.4', 11 | keywords = ['physics'], 12 | classifiers = [], 13 | install_requires = ['pygame','pymunk'], 14 | ) 15 | --------------------------------------------------------------------------------