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