AndyObtiva/glimmer-dsl-swt

View on GitHub
samples/elaborate/mandelbrot_fractal.rb

Summary

Maintainability
B
6 hrs
Test Coverage
# Copyright (c) 2007-2024 Andy Maleh
#
# 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:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# 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.

require 'glimmer-dsl-swt'
require 'complex'
require 'concurrent/executor/fixed_thread_pool'
require 'concurrent/utility/processor_counter'
require 'concurrent/array'

# Mandelbrot multi-threaded implementation leveraging all processor cores.
class Mandelbrot
  DEFAULT_STEP = 0.0030
  Y_START = -1.0
  Y_END = 1.0
  X_START = -2.0
  X_END = 0.5
  PROGRESS_MAX = 40
  
  class << self
    attr_accessor :progress, :work_in_progress
    attr_writer :processor_count
  
    def for(max_iterations:, zoom:, background: false)
      key = [max_iterations, zoom]
      creation_mutex.synchronize do
        unless flyweight_mandelbrots.keys.include?(key)
          flyweight_mandelbrots[key] = new(max_iterations: max_iterations, zoom: zoom, background: background)
        end
      end
      flyweight_mandelbrots[key].background = background
      flyweight_mandelbrots[key]
    end
    
    def flyweight_mandelbrots
      @flyweight_mandelbrots ||= {}
    end
    
    def creation_mutex
      @creation_mutex ||= Mutex.new
    end
    
    def processor_count
      @processor_count ||= Concurrent.processor_count
    end
  end
  
  attr_accessor :max_iterations, :background
  attr_reader :zoom, :points_calculated
  alias points_calculated? points_calculated
  
  # max_iterations is the maximum number of Mandelbrot calculation iterations
  # zoom is how much zoom there is on the Mandelbrot points from the default view of zoom 1
  # background indicates whether to do calculation in the background for caching purposes,
  # thus utilizing less CPU cores to avoid disrupting user experience
  def initialize(max_iterations:, zoom: 1.0, background: false)
    @max_iterations = max_iterations
    @zoom = zoom
  end
  
  def step
    DEFAULT_STEP / zoom
  end
    
  def y_array
    @y_array ||= Y_START.step(Y_END, step).to_a
  end
  
  def x_array
    @x_array ||= X_START.step(X_END, step).to_a
  end
  
  def height
    y_array.size
  end
  
  def width
    x_array.size
  end
  
  def points
    @points ||= calculate_points
  end
  
  def calculate_points
    puts "Background calculation activated at zoom #{zoom}" if @background
    if @points_calculated
      puts "Points calculated already. Returning previously calculated points..."
      return @points
    end
    @thread_pool = Concurrent::FixedThreadPool.new(Mandelbrot.processor_count, fallback_policy: :discard)
    @points = Concurrent::Array.new(height)
    Mandelbrot.work_in_progress = "Calculating Mandelbrot Points for Zoom #{zoom}x"
    Mandelbrot.progress = 0
    point_index = 0
    point_count = width*height
    height.times do |y|
      @points[y] ||= Concurrent::Array.new(width)
      width.times do |x|
        @thread_pool.post do
          @points[y][x] = calculate(x_array[x], y_array[y]).last
          point_index += 1
          Mandelbrot.progress += 1 if (point_index.to_f / point_count.to_f)*PROGRESS_MAX >= Mandelbrot.progress
        end
      end
    end
    @thread_pool.shutdown
    @thread_pool.wait_for_termination
    Mandelbrot.progress = PROGRESS_MAX
    @points_calculated = true
    @points
  end
  
  # Calculates a Mandelbrot point, borrowing some open-source code from:
  # https://github.com/gotbadger/ruby-mandelbrot
  def calculate(x,y)
    base_case = [Complex(x,y), 0]
    Array.new(max_iterations, base_case).inject(base_case) do |prev ,base|
      z, itr = prev
      c, _ = base
      val = z*z + c
      itr += 1 unless val.abs < 2
      [val, itr]
    end
  end
end

class MandelbrotFractal
  include Glimmer::UI::CustomShell
    
  COMMAND = OS.mac? ? :command : :ctrl
  
  attr_accessor :mandelbrot_shell_title
      
  option :zoom, default: 1.0
  
  before_body do
    Display.app_name = 'Mandelbrot Fractal'
    # pre-calculate mandelbrot image
    @mandelbrot_image = build_mandelbrot_image
  end
  
  after_body do
    observe(Mandelbrot, :work_in_progress) do
      update_mandelbrot_shell_title!
    end
    observe(Mandelbrot, :zoom) do
      update_mandelbrot_shell_title!
    end
    # pre-calculate zoomed mandelbrot images even before the user zooms in
    puts 'Starting background calculation thread...'
    @thread = Thread.new do
      future_zoom = 1.5
      loop do
        puts "Creating mandelbrot for background calculation at zoom: #{future_zoom}"
        the_mandelbrot = Mandelbrot.for(max_iterations: color_palette.size - 1, zoom: future_zoom, background: true)
        pixels = the_mandelbrot.calculate_points
        build_mandelbrot_image(mandelbrot_zoom: future_zoom)
        @canvas.cursor = :cross unless @canvas.disposed?
        future_zoom += 0.5
      end
    end
  end
  
  body {
    shell(:no_resize) {
      grid_layout
      text <= [self, :mandelbrot_shell_title]
      minimum_size mandelbrot.width + 29, mandelbrot.height + 77
      image @mandelbrot_image
            
      on_shell_closed do
        @thread.kill # should not be dangerous in this case
        puts "Mandelbrot background calculation stopped!"
      end
      
      progress_bar {
        layout_data :fill, :center, true, false

        minimum 0
        maximum Mandelbrot::PROGRESS_MAX
        selection <= [Mandelbrot, :progress]
      }
          
      @scrolled_composite = scrolled_composite {
        layout_data :fill, :fill, true, true
        @canvas = canvas {
          image @mandelbrot_image
          cursor :no
          
          on_mouse_down do
            @drag_detected = false
            @canvas.cursor = :hand
          end
          
          on_drag_detected do |drag_detect_event|
            @drag_detected = true
            @drag_start_x = drag_detect_event.x
            @drag_start_y = drag_detect_event.y
          end
          
          on_mouse_move do |mouse_event|
            if @drag_detected
              origin = @scrolled_composite.origin
              new_x = origin.x - (mouse_event.x - @drag_start_x)
              new_y = origin.y - (mouse_event.y - @drag_start_y)
              @scrolled_composite.set_origin(new_x, new_y)
            end
          end
          
          on_mouse_up do |mouse_event|
            if !@drag_detected
              origin = @scrolled_composite.origin
              @location_x = mouse_event.x
              @location_y = mouse_event.y
              if mouse_event.button == 1
                zoom_in
              elsif mouse_event.button > 2
                zoom_out
              end
            end
            @canvas.cursor = can_zoom_in? ? :cross : :no
            @drag_detected = false
          end
          
        }
      }
      
      menu_bar {
        menu {
          text '&View'
          
          menu_item {
            text 'Zoom &In'
            accelerator COMMAND, '+'
            
            on_widget_selected do
              zoom_in
            end
          }
          
          menu_item {
            text 'Zoom &Out'
            accelerator COMMAND, '-'
            
            on_widget_selected do
              zoom_out
            end
          }
          
          menu_item {
            text '&Reset Zoom'
            accelerator COMMAND, '0'
            
            on_widget_selected do
              perform_zoom(mandelbrot_zoom: 1.0)
            end
          }
        }
        menu {
          text '&Cores'
          
          Concurrent.processor_count.times do |n|
            processor_number = n + 1
            menu_item(:radio) {
              text "&#{processor_number}"
                
              case processor_number
              when 0..9
                accelerator COMMAND, processor_number.to_s
              when 10..19
                accelerator COMMAND, :shift, (processor_number - 10).to_s
              when 20..29
                accelerator COMMAND, :alt, (processor_number - 20).to_s
              end
              
              selection true if processor_number == Concurrent.processor_count
              
              on_widget_selected do
                Mandelbrot.processor_count = processor_number
              end
            }
          end
        }
        menu {
          text '&Help'
          
          menu_item {
            text '&Instructions'
            accelerator COMMAND, :shift, :i
            
            on_widget_selected do
              display_help_instructions
            end
          }
        }
      }
    }
  }
      
  def update_mandelbrot_shell_title!
    new_title = "Mandelbrot Fractal - Zoom #{zoom}x (Calculated Max: #{flyweight_mandelbrot_images.keys.max}x)"
    new_title += " - #{Mandelbrot.work_in_progress}" if Mandelbrot.work_in_progress
    self.mandelbrot_shell_title = new_title
  end
  
  def build_mandelbrot_image(mandelbrot_zoom: nil)
    mandelbrot_zoom ||= zoom
    unless flyweight_mandelbrot_images.keys.include?(mandelbrot_zoom)
      the_mandelbrot = mandelbrot(mandelbrot_zoom: mandelbrot_zoom)
      width = the_mandelbrot.width
      height = the_mandelbrot.height
      pixels = the_mandelbrot.points
      Mandelbrot.work_in_progress = "Consuming Points To Build Image for Zoom #{mandelbrot_zoom}x"
      Mandelbrot.progress = Mandelbrot::PROGRESS_MAX + 1
      point_index = 0
      point_count = width*height
      # invoke as a top-level parentless keyword to avoid nesting under any widget
      new_mandelbrot_image = image(width, height, top_level: true) { |x, y|
        point_index += 1
        Mandelbrot.progress -= 1 if (Mandelbrot::PROGRESS_MAX - (point_index.to_f / point_count.to_f)*Mandelbrot::PROGRESS_MAX) < Mandelbrot.progress
        pixel_color_index = pixels[y][x]
        color_palette[pixel_color_index]
      }
      Mandelbrot.progress = 0
      flyweight_mandelbrot_images[mandelbrot_zoom] = new_mandelbrot_image
      update_mandelbrot_shell_title!
    end
    flyweight_mandelbrot_images[mandelbrot_zoom]
  end
  
  def flyweight_mandelbrot_images
    @flyweight_mandelbrot_images ||= {}
  end
  
  def mandelbrot(mandelbrot_zoom: nil)
    mandelbrot_zoom ||= zoom
    Mandelbrot.for(max_iterations: color_palette.size - 1, zoom: mandelbrot_zoom)
  end
  
  def color_palette
    if @color_palette.nil?
      @color_palette = [[0, 0, 0]] + 40.times.map { |i| [255 - i*5, 255 - i*5, 55 + i*5] }
      @color_palette = @color_palette.map { |color_data| rgb(*color_data).swt_color }
    end
    @color_palette
  end
    
  def zoom_in
    if can_zoom_in?
      perform_zoom(zoom_delta: 0.5)
      @canvas.cursor = can_zoom_in? ? :cross : :no
    end
  end
  
  def can_zoom_in?
    flyweight_mandelbrot_images.keys.include?(zoom + 0.5)
  end
  
  def zoom_out
    perform_zoom(zoom_delta: -0.5)
  end
  
  def perform_zoom(zoom_delta: 0, mandelbrot_zoom: nil)
    mandelbrot_zoom ||= self.zoom + zoom_delta
    @canvas.cursor = :wait
    last_zoom = self.zoom
    self.zoom = [mandelbrot_zoom, 1.0].max
    @canvas.clear_shapes(dispose_images: false)
    @mandelbrot_image = build_mandelbrot_image
    body_root.content {
      image @mandelbrot_image
    }
    @canvas.content {
      image @mandelbrot_image
    }
    @canvas.set_size @mandelbrot_image.bounds.width, @mandelbrot_image.bounds.height
    @scrolled_composite.set_min_size(Point.new(@mandelbrot_image.bounds.width, @mandelbrot_image.bounds.height))
    if @location_x && @location_y
      # center on mouse click location
      factor = (zoom / last_zoom)
      @scrolled_composite.set_origin(factor*@location_x - @scrolled_composite.client_area.width/2.0, factor*@location_y - @scrolled_composite.client_area.height/2.0)
      @location_x = @location_y = nil
    end
    update_mandelbrot_shell_title!
    @canvas.cursor = :cross
  end
  
  def display_help_instructions
    message_box(body_root) {
      text 'Mandelbrot Fractal - Help'
      message <<~MULTI_LINE_STRING
        The Mandelbrot Fractal precalculates zoomed renderings in the background. Wait if you hit a zoom level that is not calculated yet.

        Left-click to zoom in.
        Right-click to zoom out.
        Scroll or drag to pan.
        Adjust cores to get a more responsive interaction.
        
        Enjoy!
      MULTI_LINE_STRING
    }.open
  end
    
end

MandelbrotFractal.launch