Home | Ruby | Modeling Gravity with Ruby |   Submit

Listing: gravity.rb


Click here to download original file.

#!/usr/bin/ruby -w
=begin
/***************************************************************************
 *   Copyright (C) 2006, Paul Lutus                                        *
 *                                                                         *
 *   This program is free software; you can redistribute it and/or modify  *
 *   it under the terms of the GNU General Public License as published by  *
 *   the Free Software Foundation; either version 2 of the License, or     *
 *   (at your option) any later version.                                   *
 *                                                                         *
 *   This program is distributed in the hope that it will be useful,       *
 *   but WITHOUT ANY WARRANTY; without even the implied warranty of        *
 *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the         *
 *   GNU General Public License for more details.                          *
 *                                                                         *
 *   You should have received a copy of the GNU General Public License     *
 *   along with this program; if not, write to the                         *
 *   Free Software Foundation, Inc.,                                       *
 *   59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.             *
 ***************************************************************************/
=end
require 'gravityui_ui'
PROGRAM_VERSION = "1.4"
# subclass of QFrame to control painting updates
class MyFrame < Qt::Frame
   def initialize(parent,a,b)
      super(a,b)
      @parent = parent
      setPaletteBackgroundColor(Qt::black)
      Qt::ToolTip.add(self, trUtf8("Drag mouse to rotate, zoom with wheel") )
   end
   # redraw image on these events
   def paintEvent(*e)
      @parent.draw_image
   end
   def resizeEvent(*e)
      @parent.draw_image
   end
end
# a class for routines and constants of common utility
class CommonCode
   CommonCode::ToRad = Math::PI / 180.0
   CommonCode::ToDeg = 180.0 / Math::PI
   def CommonCode::fmt_num(n)
      sprintf("%.2e",n)
   end
end
# solar system data class, all units mks
class SolarSystem
   SolarSystem::Data=<<-EOF
   "Name","OrbitRad","BodyRad","Mass","OrbitVel"
   "Sun",0,695000000,1.989E+030,0
   "Mercury",57900000000,2440000,3.33E+023,47900
   "Venus",108000000000,6050000,4.869E+024,35000
   "Earth",150000000000,6378140,5.976E+024,29800
   "Mars",227940000000,3397200,6.421E+023,24100
   "Jupiter",778330000000,71492000,1.9E+027,13100
   "Saturn",1429400000000,60268000,5.688E+026,9640
   "Uranus",2870990000000,25559000,8.686E+025,6810
   "Neptune",4504300000000,24746000,1.024E+026,5430
   "Pluto",5913520000000,1137000,1.27E+022,4740
   EOF
end
# a Cartesian 3D vector class
# with a number of important operator overrides
class Cart3
   attr_accessor :x,:y,:z
   def initialize(x = 0,y = 0,z = 0)
      if(x.class == self.class)
         @x = x.x
         @y = x.y
         @z = x.z
      else
         @x = x
         @y = y
         @z = z
      end
   end
   def -(e)
      Cart3.new(@x - e.x,@y - e.y,@z - e.z)
   end
   def +(e)
      Cart3.new(@x + e.x,@y + e.y,@z + e.z)
   end
   def *(e)
      if(e.class != self.class)
         # multiply by scalar
         Cart3.new(@x * e,@y * e,@z * e)
      else
         # multiply by vector
         Cart3.new(@x * e.x,@y * e.y,@z * e.z)
      end
   end
   def /(e)
      if(e.class != self.class)
         # divide by scalar
         Cart3.new(@x / e,@y / e,@z / e)
      else
         # divide by vector
         Cart3.new(@x / e.x,@y / e.y,@z / e.z)
      end
   end
   # sum of squares
   def sumsq
      @x*@x+@y*@y+@z*@z
   end
   def to_s
      "[#{CommonCode::fmt_num(@x)},#{CommonCode::fmt_num(@y)},#{CommonCode::fmt_num(@z)}]"
   end
end # class Cart3
=begin
Planet, a simple data class
name = string
radius = float
pos = Cart3
vel = Cart3
mass = float
=end
class Planet
   attr_accessor :name,:radius,:pos,:vel,:mass
   def initialize(name,radius,pos,vel,mass = 0)
      @name = name.gsub(/"/,"")
      @radius = radius
      @pos = pos
      @vel = vel
      @mass = mass
   end
   def to_s
      "#{@name},#{CommonCode::fmt_num(@radius)},#{@pos},#{@vel},#{CommonCode::fmt_num(@mass)}"
   end
end
# RotationMatrix performs 3D rotations and perspective
class RotationMatrix
   # perspective depth cue for 3D -> 2D transformation
   PerspectiveDepth = 16
   # empirical constant for anaglyphic rotation
   AnaglyphScale = 0.03
   RotationMatrix::ToRad = Math::PI / 180.0
   RotationMatrix::ToDeg = 180.0 / Math::PI
   # populate 3D matrix with values for x,y,z rotations
   def populate_matrix(xa,ya)
      # create trig values
      @sy = Math.sin(xa * RotationMatrix::ToRad);
      @cy = Math.cos(xa * RotationMatrix::ToRad);
      @sx = Math.sin(ya * RotationMatrix::ToRad);
      @cx = Math.cos(ya * RotationMatrix::ToRad);
   end
   # 3D -> 2D, add perspective cue,
   # perform anaglyph position change if specified
   def convert_3d_to_2d(v,anaglyph_flag = 0)
      v.x = (v.x * (PerspectiveDepth + v.z))/PerspectiveDepth
      v.x += v.z * anaglyph_flag * AnaglyphScale if anaglyph_flag
      v.y = (v.y * (PerspectiveDepth + v.z))/PerspectiveDepth
   end
   # rotate a 3D point using matrix values
   def rotate(v)
      # borrowed from my "Apple World" 1979
      hf = (v.x * @sx - v.z * @cx)
      py = v.y * @cy + @sy * hf
      px = v.x * @cx + v.z * @sx
      pz = -v.y * @sy + @cy * hf
      v.x = px; v.y = py; v.z = pz
   end
end # class RotationMatrix
=begin
Gravitational force f, Newtons, between two masses M and m:
f = G M m
   ------
    r^2
G = universal gravitational constant, 6.6742e-11 N m^2 / kg^2
M,m = masses of the two bodies, kilograms
r = radius (distance) between M and m, meters
acceleration, m/s, a for a force f and a mass m:
a = f/m
The shorthand version drops one of the masses
for a slight speed improvement, and goes
directly to acceleration a:
a = G M
   -----
    r^2
BUT the shorthand form assumes one
of the masses is infinite
In a numerical simulation using time slice dt:
velocity v += a * dt
position p += v * dt
All mks units
=end
class OrbitalPhysics
   # The all-important force of gravity
   OrbitalPhysics::G = 6.6742e-11 # N m^2 / kg^-2
   def OrbitalPhysics::compute_acceleration(pa,pb,dt)
      # don't compute self-gravitation
      if(pa != pb)
         radius = pa.pos - pb.pos
         sumsq = radius.sumsq
         hypot = Math.sqrt(sumsq)
         acc = -G * pb.mass / sumsq
         # this line assigns the acceleration to
         # the x,y,z velocity components along the
         # radius pa - pb without using trig functions
         pa.vel += radius / hypot * acc * dt
      end
   end
   def OrbitalPhysics::process_planets(planet_list,dt,symmetric = false)
      if(symmetric)
         # compute gravitation interactively for all bodies
         # very slow ... requires p^2 time
         planet_list.each do |p1|
            planet_list.each do |p2|
               compute_acceleration(p1,p2,dt)
            end
            p1.pos += p1.vel * dt
         end
      else
         # compute gravitation only wrt the sun
         # which is assumed to be first member of array
         sun = planet_list.first
         planet_list.each do |planet|
            compute_acceleration(planet,sun,dt)
            planet.pos += planet.vel * dt
         end
      end
   end
end # class OrbitalPhysics
# main class
class Gravity < GravityUI
   slots 'perform_orbit_calc()' # for timer assignment
   Gravity::PlanetColors = [ Qt::white,Qt::yellow,Qt::cyan,Qt::Color.new(128,128,255),
   Qt::red,Qt::green,Qt::magenta,Qt::blue ]
   Gravity::AnimStrings = [ "1 hour","2 hours","4 hours","8 hours","16 hours",
   "1 day","2 days","4 days","8 days","16 days","32 days","64 days","128 days","256 days" ]
   Gravity::CometStrings = [ "1","2","4","8","16","32","64","128","256","512" ]
   def initialize(app)
      super()
      title = self.class.name + " " + PROGRAM_VERSION
      setCaption(title)
      @app = app
      @total_time_hours = 0
      # this sets the timer delay
      @anim_time = 1
      # initial drawing scale, change with mouse wheel
      @drawing_scale = 6e-12
      @rotx = -20
      @roty = 0
      @planet_list = []
      @time_step = nil
      @pixmap = nil
      @erase = true
      @anaglyph_mode = false
      @symmetric = false
      @timer = nil
      @rotator = RotationMatrix.new
      # replace automatically created QFrame with my own
      @GravityUILayout.remove(@graphicPane)
      @graphicPane.setHidden(true)
      @graphicPane = MyFrame.new(self,centralWidget(), "graphicPane")
      @GravityUILayout.addMultiCellWidget(@graphicPane, 0, 0, 0, 10)
      @timeStepComboBox.insertStringList(AnimStrings)
      @timeStepComboBox.setCurrentText("16 hours")
      @cometComboBox.insertStringList(CometStrings)
      @cometComboBox.setCurrentText("16")
      set_time_step
      @solarSystemCheckBox.setChecked(true)
      @pixelsSpinBox.setValue(5)
      @min_draw_radius = 5
      load_objects
   end
   def set_time_step
      s = @timeStepComboBox.currentText()
      v,units = s.split(" ")
      v = v.to_i
      v *= 3600 if units =~ /hour/i
      v *= 86400 if units =~ /day/i
      @time_step = v
   end
   def show_status
      y = @total_time_hours
      h = y % 24
      y /= 24
      y *= 100
      d = (y % 36525) / 100
      y /= 36525
      str = sprintf("%6dy %03dd %02dh",y,d,h)
      statusBar.message(str)
   end
   def draw_planets(xsize,ysize,pixpainter,anaglyph_flag = nil,td_color = nil)
      if(anaglyph_flag)
         pixpainter.setBrush(td_color)
         pixpainter.setPen(td_color)
      end
      i = 0
      @planet_list.each do |planet|
         v = planet.pos * @drawing_scale
         @rotator.rotate(v)
         @rotator.convert_3d_to_2d(v,anaglyph_flag)
         sxa = @x_screen_center + (v.x * @screen_scale)
         sya = @y_screen_center - (v.y * @screen_scale)
         if(sxa >= 0 && sxa < xsize && sya >= 0 && sya < ysize)
            # fake the sun's radius for aesthetics
            sr = (i == 0)?4e7:(planet.radius)
            s = Cart3.new(sr * @drawing_scale,0,-planet.pos.z * @drawing_scale);
            @rotator.convert_3d_to_2d(s)
            s.x *= @screen_scale * 100
            s.x = (s.x < @min_draw_radius)?@min_draw_radius:s.x
            sc = s.x/2
            unless(anaglyph_flag)
               col = PlanetColors[i % PlanetColors.size]
               pixpainter.setBrush(col)
               pixpainter.setPen(col)
            end
            pixpainter.drawEllipse(sxa-sc,sya-sc,s.x,s.x)
         end
         i += 1
      end
   end
   def draw_image(erase = false)
      if(@planet_list)
         show_status
         @rotator.populate_matrix(@rotx,@roty)
         xsize,ysize = @graphicPane.width,@graphicPane.height
         # create an off-screen pixmap for image drawing
         # whenever a change requires it
         if(@pixmap == nil || xsize != @old_xsize || ysize != @old_ysize)
            @pixmap = Qt::Pixmap.new(xsize,ysize)
            @old_xsize = xsize
            @old_ysize = ysize
            @x_screen_center = xsize / 2
            @y_screen_center = ysize / 2
            @screen_scale = (@x_screen_center > @y_screen_center)?@y_screen_center:@x_screen_center
            @pixmap.fill(Qt::black)
         end
         # set image background to black
         @pixmap.fill(Qt::black) if @erase || erase
         pixpainter = Qt::Painter.new
         pixpainter.begin(@pixmap)
         if(@anaglyph_mode)
            # In 3D mode, let overlapping red & cyan lines appear white
            pixpainter.setRasterOp(Qt::OrROP)
            # draw complete, rotated right-hand and left-hand
            # images in cyan and red for anaglyphic glasses
            draw_planets(xsize,ysize,pixpainter,-1,Qt::cyan) # right eye image
            draw_planets(xsize,ysize,pixpainter,1,Qt::red) # left eye image
         else
            # draw image once
            draw_planets(xsize,ysize,pixpainter)
         end
         pixpainter.end
         # move the completed image to the screen
         bitBlt(@graphicPane,0,0,@pixmap)
         @total_time_hours += @time_step / 3600
      end
   end
   def perform_orbit_calc
      OrbitalPhysics::process_planets(@planet_list,@time_step,@symmetric)
      draw_image
   end
   def toggle_animation
      if(@timer != nil)
         @timer.stop
         @timer = nil
      else
         @total_time_hours = 0
         @timer = Qt::Timer.new(self)
         Qt::Object.connect(@timer, SIGNAL('timeout()'), self, SLOT('perform_orbit_calc()'))
         @timer.start(@anim_time,false)
         draw_image(true)
      end
      @runStopButton.setText((@timer == nil)?"Run":"Stop")
   end
   def load_comets()
      n = @cometComboBox.currentText.to_i
      1.upto(n) do |i|
         name = "comet#{i}"
         ca = rand(360) # angle in x-z plane
         cr = rand(4e11) + 4e11 # distance from sun
         pos = Cart3.new(cr * Math.sin(ca * CommonCode::ToRad),0,cr * Math.cos(ca * CommonCode::ToRad))
         # comet initial velocity
         v = (rand(200) + 100) * 50.0
         v = (i % 2 == 1)?-v:v
         vel = Cart3.new(0,v,0)
         comet = Planet.new(name,1e3,pos,vel,1e9)
         @planet_list << comet
      end
   end
   def load_orbital_data(data,sun_only = false)
      data = data.split("\n")
      data.shift # drop header line
      data.each do |line|
         fields = line.split(",")
         pos = Cart3.new(-fields[1].to_f,0,0)
         vel = Cart3.new(0,0,fields[4].to_f)
         planet = Planet.new(fields[0],fields[2].to_f,pos,vel,fields[3].to_f)
         @planet_list << planet
         break if sun_only
      end
   end
   def load_objects
      @planet_list = []
      sun_only = !@solarSystemCheckBox.isChecked
      load_orbital_data(SolarSystem::Data,sun_only)
      load_comets if @cometsCheckBox.isChecked
      draw_image(true)
   end
   # data can be easily read from a file
   # not used in this version
   def load_file(sun_only = false)
      fn = Qt::FileDialog.getOpenFileName( nil, nil, self)
      if(fn != nil)
         data = File.read(fn)
         load_orbital_data(data,sun_only)
      end
   end
   # close application cleanly
   def close(*x)
      @timer.stop if @timer != nil
      @app.exit
   end
   # mouse events
   def mouseMoveEvent (e)
      # rotate image by dragging mouse
      dx = (e.y - @mouse_press_x) / 2
      dy = (e.x - @mouse_press_y) / 2
      @rotx = @mouse_press_rx - dx
      @roty = @mouse_press_ry - dy
      draw_image(true)
   end
   def mousePressEvent (e)
      # set up to control rotation
      # by dragging mouse
      @mouse_press_rx = @rotx
      @mouse_press_ry = @roty
      @mouse_press_x = e.y
      @mouse_press_y = e.x
      draw_image(true)
   end
   def wheelEvent (e)
      # change drawing scale using mouse wheel
      v = e.delta.to_f
      v = 1.0 - (v/500.0)
      @drawing_scale *= v
      draw_image(true)
   end
   # control actions
   def stepButton_clicked(*k)
      toggle_animation if @timer
      perform_orbit_calc
   end
   def runStopButton_clicked(*k)
      toggle_animation
   end
   def solarSystemCheckBox_clicked(*k)
      load_objects
   end
   def cometsCheckBox_clicked(*k)
      load_objects
   end
   def timeStepComboBox_activated(*k)
      set_time_step
   end
   def anaglyphicCheckBox_clicked(*k)
      @anaglyph_mode = !@anaglyph_mode
      draw_image(true)
   end
   def pixelsSpinBox_valueChanged(*k)
      @min_draw_radius = pixelsSpinBox.text.to_i
      draw_image(true)
   end
   def cometComboBox_activated(*k)
      cometsCheckBox.setChecked(true)
      load_objects
      draw_image(true)
   end
   def trailsCheckBox_clicked(*k)
      @erase = !trailsCheckBox.isChecked
   end
   def symmCheckBox_clicked(*k)
      @symmetric = symmCheckBox.isChecked
   end
end # class Gravity
# launch Qt application
if $0 == __FILE__
   app = Qt::Application.new(ARGV)
   w = Gravity.new(app)
   app.mainWidget = w
   w.show
   app.exec
end
    

Home | Ruby | Modeling Gravity with Ruby |   Submit