Sketchup::Layers. How do you sort it now with folders, as on the UI?

In my usual study, the question arose as to how the order of the layers and folders in the user interface could be replicated? ([Github] )

Since the version of 2018(?), the layer names are sorted naturally on the UI.
(The layer names on SU2017 ordered as e.g.: 1, 11, 22, 3 on later versions shown as 1, 3, 11, 22)

In Ruby the “normal” sort method will give us the former. I was curious how can we got the natural?
But actually sorting is a bit more complicated when you want to consider the parent-child folder-layer structure.

So I wrote a code snippet that is suitable for giving a kind of answer to the above questions.

The code will create a an array of hashes (with a nested array of hashes, if relevant) to represent the structure, then print it to the console and open a HtmlDialog with a simple table.
While folders don’t have much sense in older versions, the code is still compatible with the 2017 version.

Notes:

  • I’m using natcmp.rb natural order comparison of two strings, please see the copyright notes inside the code.

  • I’m also using Tabulator to display the result. I love it! :slight_smile: (Tabulator directly loaded from the UNPKG CDN servers, so first run could be slow. I usually give it packaged with the extension.)

  • I’m not sure what will influenced (with the sorting) if the the layer folder names are same…but please be aware of notes from documentation of sort method ( The result is not guaranteed to be stable. When the comparison of two elements returns 0 , the order of the elements is unpredictable.)

  • The provided code just quickly tested on SU2017 and SU2021.1 on Windows, and does not have a special purpose, just for fun and for learning. Use your own risk!

  • Other sources: Sketchup::Layer, Sketchup::LayerFolder, Sketchup::Layers

Sorry for the crazy names of layers… :wink:

sorter

Code

Dezmo_test_sort_layers.rb (6.8 KB)

# encoding: UTF-8

# <!-- SortLayers Copyright 2021, Dezmo  -->

require 'pp.rb'
require 'json.rb'
module Dezmo
  module TestSortLayers
    extend self
  
    SUVER200 ||= Sketchup.version.to_f >= 20.0
    SUVER210 ||= Sketchup.version.to_f >= 21.0
    LAYER0 ||= SUVER200 ? "Untagged" : "Layer0"
    
    def build_layers_hierarchy(layers)
      if !SUVER210 || layers.count_folders == 0
        layers_root = []
        layers.each{|layer| 
          layers_root<<layer_hash(layer)
        }
        return sorter(layers_root)
      else
        folders_hierarchy = []
        layers.each_folder{|folder| 
          folders_hierarchy<<folder_hash(folder)
        }
        layers_root = []
        layers.each_layer{|layer| 
          layers_root<<layer_hash(layer)
        }
      end
      sorter(folders_hierarchy) + sorter(layers_root)
    end

    def layer_hash(layer)
      layer_hash = {}
      layer_hash[:name] = SUVER200 ? layer.display_name : layer.name
      layer_hash[:object] = layer
      layer_hash
    end

    def folder_hash(folder)
      folder_hash = {}
      folder_hash[:name] = folder.name
      folder_hash[:object] = folder
      if folder.folders[0] || folder.layers[0]
        folder_hash[:_children] = []
        folder.each_folder{|c_folder| 
          folder_hash[:_children]<<folder_hash(c_folder)
        }
        layers_children = []
        folder.each_layer{|layer| 
          layers_children<<layer_hash(layer)
        }
        folder_hash[:_children] = sorter(folder_hash[:_children]) + sorter(layers_children)
      end
      folder_hash
    end
    
    def sort_layers
      model = Sketchup.active_model
      layers = model.layers
      layers_h_natsort = build_layers_hierarchy(layers)
      layers_h_UIsort = layers_h_natsort.unshift(
        layers_h_natsort.delete_at(layers_h_natsort.index{ |h| 
          h[:name] == LAYER0 
        })
      )
      puts
      puts "Layers hierarchy:"
      pp layers_h_UIsort
      display_result_tatulator(layers_h_UIsort)
      nil
    end
    
    def sorter(array)
      array.sort{ |a,b| natcmp(a[:name], b[:name], true) }
      # On SU 2017 to match to UI:
      # array.sort{ |a,b| a[:name]<=> b[:name] }
    end
    
    def display_result_tatulator(array)
      json = array.to_json
      html = %{
        <!DOCTYPE html>
        <html>
          <head>
            <link href="https://unpkg.com/tabulator-tables@4.9.3/dist/css/tabulator.min.css" rel="stylesheet">
            <script type="text/javascript" src="https://unpkg.com/tabulator-tables@4.9.3/dist/js/tabulator.min.js"></script>
            <style type='text/css'>
              body{ 
                font:100% sans-serif, Helvetica, Arial, Segoe UI;
              }
            </style>
          </head>
          <body>
            <div id="example-table"></div>
            <script>
              var tabledata = JSON.parse('#{json}');
              var table = new Tabulator("#example-table", {
                  data:tabledata,
                  autoColumns:true,
                  headerSort:false,
                  dataTree:true,
                  dataTreeStartExpanded:true,
              });
            </script>
          </body>
        </html>
      }

      @dialog = UI::HtmlDialog.new(
        :dialog_title => "Layers hierarchy test © Dezmo 2021",
        :preferences_key => "Dezmo_example_Layers_hierarchy",
        :width => 600,
        :height => 600,
        :left => 100,
        :top => 100,
        :style => UI::HtmlDialog::STYLE_DIALOG
      )
      @dialog.set_html(html)
      @dialog.show
    end
    
    
    #The method below is not mine, but I made a little changes to adapt
    #    see: "...by dezmo" comments
    # downloaded from below mentioned link
    # ############################################################### #
    
    # natcmp.rb
    #
    # Natural order comparison of two strings
    # e.g. "my_prog_v1.1.0" < "my_prog_v1.2.0" < "my_prog_v1.10.0"
    # which does not follow alphabetically
    #
    # Based on Martin Pool's "Natural Order String Comparison" originally written in C
    # http://sourcefrog.net/projects/natsort/
    #
    # This implementation is Copyright (C) 2003 by Alan Davies
    # <cs96and_AT_yahoo_DOT_co_DOT_uk>
    #
    # This software is provided 'as-is', without any express or implied
    # warranty.  In no event will the authors be held liable for any damages
    # arising from the use of this software.
    #
    # Permission is granted to anyone to use this software for any purpose,
    # including commercial applications, and to alter it and redistribute it
    # freely, subject to the following restrictions:
    #
    # 1. The origin of this software must not be misrepresented; you must not
    #    claim that you wrote the original software. If you use this software
    #    in a product, an acknowledgment in the product documentation would be
    #    appreciated but is not required.
    # 2. Altered source versions must be plainly marked as such, and must not be
    #    misrepresented as being the original software.
    # 3. This notice may not be removed or altered from any source distribution.

    ## #class String   # commented out by dezmo

    # 'Natural order' comparison of two strings
    
    # def String.natcmp(str1, str2, caseInsensitive=false)  # commented out by dezmo
    def natcmp(str1, str2, caseInsensitive=false) # new by dezmo
      str1, str2 = str1.dup, str2.dup
      compareExpression = /^(\D*)(\d*)(.*)$/

      if caseInsensitive
        str1.downcase!
        str2.downcase!
      end

      # Remove all whitespace
      str1.gsub!(/\s*/, '')
      str2.gsub!(/\s*/, '')

      while (str1.length > 0) or (str2.length > 0) do
        # Extract non-digits, digits and rest of string
        str1 =~ compareExpression
        chars1, num1, str1 = $1.dup, $2.dup, $3.dup

        str2 =~ compareExpression
        chars2, num2, str2 = $1.dup, $2.dup, $3.dup

        # Compare the non-digits
        case (chars1 <=> chars2)
          when 0 # Non-digits are the same, compare the digits...
            # If either number begins with a zero, then compare alphabetically,
            # otherwise compare numerically
            if (num1[0] != 48) and (num2[0] != 48)
              num1, num2 = num1.to_i, num2.to_i
            end

            case (num1 <=> num2)
              when -1 then return -1
              when 1 then return 1
            end
          when -1 then return -1
          when 1 then return 1
        end # case

      end # while

      # Strings are naturally equal
      return 0
    end # natcmp
  # end # class String  # commented out by dezmo
  
  end# module TestSortLayers
end# module Dezmo
Dezmo::TestSortLayers.sort_layers

Ps: If someone didn’t know: The API retains the use of “Layer” for compatibility and is synonymous with “Tag”.
:beers:

3 Likes

A nice exercise in programming !

I am having a quick look at this.

I see a few challenges for coders in a shared Ruby environment such as SketchUp’s.

  1. The natcmp.rb implementation modifies the Ruby core String class. This is a big “no-no” in a shared environment.
    This could be implemented in a subclass of String with the natcmp method being called by an overridden <=> method. Coders would coerce a collection of layer names into a collection of this new NatString objects before calling sort.
    Another option could be a refinement.

  2. This implementation strips out ALL whitespace, not just leading whitespace.
    This is not a good thing as spaces are characters and have their ordinal place in the sorting world. That place is absolute first, before any punctuation, numerics, letters or any other symbol character.
    This should be an option at the very least, as the C implementation only ignores leading whitespace.

That is why I’m commented out the “class around” and using the the method only. I guess, (hope) I did right…(?)

Does the refinement.will work on SU2017? Recently I studied some trials and what was worked on SU2021 did nothing on SU2017. Should be there on Ruby version 2.2.4 (I guess it is introduced on 2.0) but does it use different syntax? (I might have to find my files and post the example…)

I guess this is the part of the code in a quiestion

# Remove all whitespace
      str1.gsub!(/\s*/, '')
      str2.gsub!(/\s*/, '')

I need to look more closely at the Ruby regular expressions, I’ve always skipped … :blush:

The method should not be defined in the ObjectSpace because it would become global and percolate into every object class.

The simplest thing to do is wrap it in your extension submodule or add it to one of your custom classes.

Yes but I think (in early 2.x versions) the using method must be called from the top level ObjectSpace and is in effect for the remainder of the .rb file from which it is called and affects any modules or classes in that file.

In later Ruby versions you can call it from inside a module or class and it is only then in effect within that module or class. It’s effect is still limited to the file from which it is called (even if that module or class is opened for edit in another .rb file.)

Yes … other examples:

        # Remove just leading whitespace:
        str1.gsub!(/^\s*/, '')
        str2.gsub!(/^\s*/, '') # str2.lstrip!

        # Remove just trailing whitespace:
        str1.gsub!(/\s*$/, '')
        str2.gsub!(/\s*$/, '') # or str2.rstrip!

        # Remove both leading and trailing whitespace:
        str1.strip!
        str2.strip!
1 Like

I found a booboo in the code. It appears it was written back in the Ruby days prior to Ruby 2.0.

The line (58):

        if (num1[0] != 48) and (num2[0] != 48)

… will not work in Ruby 2.0 or higher. (Both expressions will always be true.)
String#[] was changed to return the single character at position 0 in Ruby 2.0.

Prior to 2.0 it returned the ordinal of the ASCII character at the given position.

For Ruby 2.x, the following will work:

        if (num1[0].ord != 48) and (num2[0].ord != 48)

But this will not work for < 2.0, because the String#ord method was added in Ruby 2.0 to give a workaround for the changed behavior of String#[] with a single integer argument.

The following will work in all versions:

        if (num1[0,1] != '0') and (num2[0,1] != '0') # Ruby all versions

… OR …

        if (num1[0,1] != 48.chr) and (num2[0,1] != 48.chr) # Ruby all versions

… OR …

        if !(num1 =~ /^0/) and !(num2 =~ /^0/) # Ruby all versions
1 Like

Wow. Nice, thanks. I wouldn’t have found out that myself … :+1:

1 Like

Dan, maybe my head is even “heavier” than usual today … but I’m lost… :blush:
Most probably because I do not understand what really the ObjectSpace is?

Sure the original natcmp.rb modifies the String class, that’s clear. But I copied the method from it to my isolated MyNameTopLevelNamespace and to its module MyCurentPlugin
like below. So it should not “percolate” to anywhere. Do you agree?

module MyNameTopLevelNamespace
  module MyCurentPlugin
    extend self

    def my_method
      #my code
      a = "First"
      b = "Some"
      compassion = natcmp(a, b, true)
    end
    
    def other_meth
      #blabla
      
    end
    
    def natcmp(str1, str2, caseInsensitive=false)
      #original code
      
    end

  end
end

Or, You just wanted to give me a general explanation?

Then it means - since I’m confused - that some base things is missing from my picture of Ruby. Most probably the main parts of the “canvas”… :anguished: :disappointed_relieved:
I must learn harder…

CORRECT

Well everything in Ruby is either an object or a reference to an object.

When you open the native Ruby Console, the scope that you are within is …
" that particular instance of Object called ‘main’ "

(The “main” is actually the name of the initial running function in all C programs, and in this case it’s the main() function of the Ruby interpreter which is written in C.)
This also is the “top level” scope that rb files are evaluated within.

So if you define a method or a local or instance variable by itself (unwrapped) in the console, it becomes a method or variable of Object. Since everything is a descendant of Object the methods or variables in effect become global and percolate into every other object.

(If you define an unwrapped local variable in a rb file, the require() method will remove it after the code of the file runs. This is done to protect the ObjectSpace.)

You might see warnings about defining @instance or @@class_variables in the top level ObjectSpace. (There is talk of not allowing this by the Ruby Core guys.)

2 Likes

Which it did not really need to do since it defined a class method (and not an instance method.)

It could just as easily have been a library module that could be either called with qualification, or mixed into custom modules or classes.

Ex:

module TextTools::NatSort

  module_function

  def natcmp(str1, str2, caseInsensitive = false)
    # method statements from natcmp.rb
  end

  def natsort(enum, ignore_case = false)
    if !enum.respond_to?(:sort)
      fail(TypeError, "Sortable enumerable collection expected.", caller)
    end
    enum.sort { |a,b| natcmp(a,b,ignore_case) }
  end

end

Now we could use it via full qualification …

require("TextTools/NatSort")
module SomeAuthor
  module SomeExtension
    extend self

    def some_method(array)
      return TextTools::NatSort::natsort(array)
    end

  end
end

OR, we could use it via inclusion …

require("TextTools/NatSort")
module SomeAuthor
  module SomeExtension

    class Custom
      include TextTools::NatSort
      def some_method(array)
        return natsort(array)
      end
    end

  end
end
1 Like

Dan, I really appreciate your patience! Thank you very much for the explanations! :+1: :beers:

1 Like

I’m trying to deal with the refinement (beside many other things), found my old files and adapted to being on-topic.

Just discovered that the refinement (as I did below) will work only in SU version 2021.x, but does not in older ones.
Do you have an idea if we can make it compatible back to SU2017?
Actually I just using the console (copy-paste the code) and does not loading the file… maybe that is also influencing the result?

#dirty
module Dezmo
  module RefinedModel
    refine Sketchup::Model do
      def layers_sorted
        layers.sort
      end
    end
  end 
  
  module TestLayersSortWithRefine
    extend self
    using RefinedModel
    
    def layers_in_order
      model = Sketchup.active_model
      if model.respond_to?(:layers_sorted)
        # RUBY_VERSION >= 2.7.1 
        puts "Unsorted: #{model.layers.map(&:name)}"
        puts "Sorted  : #{model.layers_sorted.map(&:name)}" 
      else
        # RUBY_VERSION <= 2.5.5 
        puts "Unsorted: #{model.layers.map(&:name)}"
        puts "Can not give a sorted Layers :-("
      end
      nil
    end
    
  end #module TestLayersSortWithRefine
end #module Dezmo
Dezmo::TestLayersSortWithRefine.layers_in_order

The problem is with the Object#respond_to? method.

In older Ruby versions the Object#respond_to? method could not “see” the object’s refinements. It was fixed in recent Ruby versions.

Try calling model.layers_sorted in Ruby 2.5.5 anyway.

1 Like

Doh! :flushed:
I’d rather go and hike and relax a little… :wink:

module Dezmo
  module RefinedModel
    refine Sketchup::Model do
      def layers_sorted
        layers.sort
      end
    end
  end 
  
  module TestLayersSortWithRefine
    extend self
    using RefinedModel
    
    def layers_in_order
      model = Sketchup.active_model
      puts "Unsorted: #{model.layers.map(&:name)}"
      puts "Sorted  : #{model.layers_sorted.map(&:name)}" 
      nil
    end
    
  end #module TestLayersSortWithRefine
end #module Dezmo
Dezmo::TestLayersSortWithRefine.layers_in_order

Console (SU 2020):

Unsorted: [“Layer0”, “2”, “3”, “1”]
Sorted : [“1”, “2”, “3”, “Layer0”]

RUBY_VERSION
2.5.5

Console (SU 2017):

Unsorted: [“Layer0”, “2”, “3”, “1”]
Sorted : [“1”, “2”, “3”, “Layer0”]

RUBY_VERSION
2.2.4

Thanks Dan! :beers:

No problem. I knew this because last week I was going through the various Ruby builds NEWS docs.

It was fixed in Ruby v2.6.0.

1 Like

And for more in depth info on Refinements …

https://bugs.ruby-lang.org/projects/ruby-master/wiki/RefinementsSpec

1 Like

I see.
I just wanted to ask…, because I used Kernel#send too in my other code --what you did not saw, but You :man_superhero: gave me answer already. :+1:

Any indirect method access such as Kernel#send, Kernel#method, and Kernel#respond_to? shall not honor refinements in the caller context during method lookup. NOTE: This behavior will be changed in the future.

Actually the refinement, what I did is let say okay, but the problems are mentioned in above quoted text… :beers:

Yea, but IMO that specification does not get updated frequently enough.
So I think that quote is behind (out of date.)

1 Like

Yes, yes, but still explaining why I had (and what was) the problem whit the older Ruby versions.

1 Like