#!/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