Home | Ruby | Modeling Gravity with Ruby |     Share This Page

Listing: gravity.rb


Click here to download original file.

#!/usr/bin/ruby -w

=begin
/***************************************************************************
 *   Copyright (C) 2008, 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 'gravity_ui'

PROGRAM_VERSION = "1.5"

# main class

class Gravity < GravityGlade

   Gravity::PlanetColors = [ Gdk::Color.parse("white"),Gdk::Color.parse("yellow"),
      Gdk::Color.parse("cyan"), Gdk::Color.new(128 * 256,128 * 256,255 * 256),
      Gdk::Color.parse("red"),Gdk::Color.parse("green"),
   Gdk::Color.parse("magenta"),Gdk::Color.parse("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(path,xxx,name)
      super(path,xxx,name)
      get_widgets()
      @program_name = self.class.name
      @program_title = @program_name + " " + PROGRAM_VERSION
      GravityUI().set_title(@program_title)

      @total_time_hours = 0
      @initialized = false
      # this sets the sleep interval (units are seconds)
      @anim_time = 0.005
      @anim_thread = nil
      @anim_flag = false
      # 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
      @nice = true
      @mouse_down = false

      @color_cyan = Gdk::Color.parse("cyan")
      @color_red = Gdk::Color.parse("red")
      @color_black = Gdk::Color.parse("black")
      @rotator = RotationMatrix.new
      @time_step_combobox_manager = ComboBoxManager.new(time_step_combobox(),AnimStrings,"16 hours")
      set_time_step(true)
      @comet_combobox_manager = ComboBoxManager.new(comet_combobox(),CometStrings,"16")
      solar_system_checkbutton.active = true
      comets_checkbutton.active = true
      # graphic_pane.signal_connect("expose_event") do
      #   draw_image
      # end
      @min_draw_radius = 5
      pixels_spinbutton.value = @min_draw_radius
      load_objects(true)
      @initialized = true
   end

   def get_widgets()
      @glade.widget_names.each do |name|
         # create accessor methods for each defined widget
         eval("def #{name}() return @glade.get_widget(\"#{name}\") end")
      end
   end

   def Gravity::message_dialog(window,message,inquiry = false)
      if inquiry
         dlg = Gtk::MessageDialog.new(nil,
         Gtk::MessageDialog::MODAL,
         Gtk::MessageDialog::QUESTION,
         Gtk::MessageDialog::BUTTONS_YES_NO,
         message)
      else # just an alert
         dlg = Gtk::MessageDialog.new(nil,
         Gtk::MessageDialog::MODAL,
         Gtk::MessageDialog::INFO,
         Gtk::MessageDialog::BUTTONS_OK,
         message)
      end
      dlg.set_title(window.class.name)
      response = dlg.run
      dlg.destroy
      return response == Gtk::MessageDialog::RESPONSE_YES || response == Gtk::MessageDialog::RESPONSE_OK
   end

   def close()
      Gtk.main_quit
   end


   def set_time_step(force = false)
      if(@initialized || force)
         s = @time_step_combobox_manager.active_string()
         v,units = s.split(" ")
         v = v.to_i
         v *= 3600 if units =~ /hour/i
         v *= 86400 if units =~ /day/i
         @time_step = v
      end
   end

   def show_status
      y = @total_time_hours
      h = y % 24
      y /= 24
      y *= 100
      d = (y % 36525) / 100
      y /= 36525
      str = sprintf("Elapsed time: %04dy %03dd %02dh",y,d,h)
      status_bar().text = str
   end

   def draw_planets(xsize,ysize,gc,anaglyph_flag = nil,td_color = nil)
      if(anaglyph_flag)
         gc.rgb_fg_color = 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]
               gc.rgb_fg_color = col
            end
            @pixmap.draw_arc(gc,true,sxa-sc,sya-sc,s.x,s.x,0,23040)
         end
         i += 1
      end
   end

   def fill_block(gc,color,sx,sy,wx,wy)
      gc.fill = Gdk::GC::Fill::SOLID
      gc.rgb_fg_color = color
      @pixmap.draw_rectangle(gc,true,sx,sy,wx+2,wy+2)
   end

   def draw_image(erase = false)
      if(@initialized && @planet_list)
         show_status
         @rotator.populate_matrix(@rotx,@roty)
         alloc = graphic_pane().allocation
         xsize,ysize = alloc.width,alloc.height
         # create an off-screen pixmap for image drawing
         # whenever a change requires it
         if(@pixmap == nil || xsize != @old_xsize || ysize != @old_ysize)
            @pixmap = Gdk::Pixmap.new(graphic_pane().window,xsize,ysize,-1)
            @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

         end
         gc = Gdk::GC.new(@pixmap)
         # set image background to black
         fill_block(gc,@color_black,0,0,@old_xsize,@old_ysize) if @erase || erase
         if(@anaglyph_mode)
            # In 3D mode, let overlapping red & cyan lines appear white
            gc.function = Gdk::GC::OR
            # draw complete, rotated right-hand and left-hand
            # images in cyan and red for anaglyphic glasses
            draw_planets(xsize,ysize,gc,-1,@color_cyan) # right eye image
            draw_planets(xsize,ysize,gc,1,@color_red) # left eye image
            gc.function = Gdk::GC::COPY
         else
            # draw image once
            draw_planets(xsize,ysize,gc)
         end
         # pixpainter.end
         # move the completed image to the screen
         graphic_pane().window.draw_drawable(gc,@pixmap,0,0,0,0,@old_xsize,@old_ysize)
         @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(@anim_flag)
         @anim_flag = false
         while(@anim_thread.alive?)
         end
      else
         @total_time_hours = 0
         @anim_flag = true
         @anim_thread = Thread.new {
            while @anim_flag
               # must have some thread "sleep" time
               # or GUI updates will stop
               sleep (@nice)?@anim_time:0.002
               perform_orbit_calc()
            end
         }
      end
      run_stop_button.label = ((@anim_thread == nil)?"Run":"Stop")
      draw_image(true)
   end

   def load_comets(force = false)
      if(@initialized || force)
         n = @comet_combobox_manager.active_string().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
   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(force = false)
      @planet_list = []
      sun_only = !solar_system_checkbutton.active?
      load_orbital_data(SolarSystem::Data,sun_only)
      load_comets(force) if comets_checkbutton.active?
      draw_image(true)
   end

   def beep
      Gdk.beep
   end

   # close application cleanly

   def close(*x)
      Gtk.main_quit
   end

   # mouse events

   def mouseMoveEvent (e)
      if(@mouse_down)
         # 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
   end

   def mousePressEvent (e)
      # set up to control rotation
      # by dragging mouse
      @mouse_down = true
      @mouse_press_rx = @rotx
      @mouse_press_ry = @roty
      @mouse_press_x = e.y
      @mouse_press_y = e.x
      draw_image(true)
   end

   def mouseReleaseEvent (e)
      @mouse_down = false
      # set up to control rotation
      # by dragging mouse
      draw_image(true)
   end

   def wheelEvent (e)
      # change drawing scale using mouse wheel
      # get mouse wheel delta
      v = (e.direction == Gdk::EventScroll::DOWN)?-1:1
      v = v.to_f
      v = 1.0 + (v/10.0)
      @drawing_scale *= v
      draw_image(true)
   end

   # action handlers

   def on_GravityUI_delete_event(widget, arg0)
      close
   end

   def on_quit_button_clicked(widget)
      close
   end

   def on_run_stop_button_clicked(widget)
      toggle_animation
   end

   def on_eventbox1_button_press_event(widget, arg0)
      mousePressEvent(arg0)
   end

   def on_eventbox1_button_release_event(widget, arg0)
      mouseReleaseEvent(arg0)
   end

   def on_eventbox1_motion_notify_event(widget, arg0)
      mouseMoveEvent(arg0)
   end

   def on_eventbox1_scroll_event(widget, arg0)
      wheelEvent(arg0)
   end

   def on_time_step_combobox_changed(widget)
      set_time_step
   end
   def on_anaglyphic_checkbutton_toggled(widget)
      @anaglyph_mode = anaglyphic_checkbutton.active?
      draw_image(true)
   end
   def on_comet_combobox_changed(widget)
      comets_checkbutton.active = true
      load_objects
      draw_image(true)
   end
   def on_step_button_clicked(widget)
      toggle_animation if @anim_flag
      perform_orbit_calc
   end
   def on_pixels_spinbutton_value_changed(widget)
      @min_draw_radius = pixels_spinbutton.value
      draw_image(true)
   end
   def on_solar_system_checkbutton_toggled(widget)
      load_objects
   end
   def on_trails_checkbutton_toggled(widget)
      @erase = !trails_checkbutton.active?
   end

   def on_nice_checkbutton_toggled(widget)
      @nice = nice_checkbutton.active?
   end
   def on_comets_checkbutton_toggled(widget)
      load_objects
   end

   def on_graphic_pane_expose_event(widget, arg0)
      draw_image(true)
   end


end # class Gravity

class ComboBoxManager
   def initialize(box,list,default = nil)
      @box = box
      @hash = {}
      index = 0
      # a placeholder item is required to get around
      # a bug in the Glade designer that won't create
      # a sane combobox without it. So first,
      # remove the placeholder item
      @box.remove_text(0)
      list.each do |item|
         @box.append_text(item)
         @hash[item] = index
         index += 1
      end
      if (@hash[default])
         @box.set_active(@hash[default])
      else
         @box.set_active(0)
      end
   end
   def active_string()
      return @box.active_text
   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 program
if __FILE__ == $0
   # Set values as your own application.
   PROG_PATH = "gravity.glade"
   PROG_NAME = "Gravity"
   Gravity.new(PROG_PATH, nil, PROG_NAME)
   Gtk.main
end

Home | Ruby | Modeling Gravity with Ruby |     Share This Page