Drawing something more complex than a cube with Ruby

In between reading various programming tutorials and beating my head against a wall I tried to draw something, and immediately discovered I still don’t know what I’m doing in any way. But hey, at least it’s colour coded.

I’m trying to draw a staircase using a variable for the number of steps, but I can’t figure out how to draw the middle of the stair. The top and bottom geometry will remain fixed, but how do I create the variable middle? You can see below how I thought it should work, but it doesn’t.

What’s the best way to do this? I haven’t found it yet.

Thanks.

    def self.create_stair
    model = Sketchup.active_model # Open model
    ent = model.entities # All entities in model
    sel = model.selection # Current selection

    model.start_operation('Create Stair', true)
    new_comp_def = Sketchup.active_model.definitions.add("Stair") # Create new component
    entities = new_comp_def.entities
    @numrise = 5
    @rise = 7.25
    @run = 10
    @width = 42
    rise2 = @rise
    run2 = @run
    points = [
        Geom::Point3d.new(0,   0,   0),
        Geom::Point3d.new(0, 0, @rise),
        Geom::Point3d.new(@run, 0, @rise),
        for r in 1..@numrise-1 do
          Geom::Point3d.new(run2, 0, rise2+@rise),
          Geom::Point3d.new(run2+@run, 0, rise2+@rise),
          rise2 = rise2 + @rise
          run2 = run2 + @run
        end
        Geom::Point3d.new(run2*@numrise,   0,   0)
        ]
    face = entities.add_face(points)
    face.reverse! if face.normal.z < 0 # Reverse normal
    face.pushpull(@width)
    tr = Geom::Transformation.new # Empty transformation
    cube = Sketchup.active_model.active_entities.add_instance(new_comp_def, tr) # Place stringer
    model.commit_operation
    end
    self.create_stair

What does it do instead, if it does “not” work?

Do you know what this means? (Read the Object Oriented Programming chapter here.) In Ruby, variables starting with @ are instance variables, they add state to a class instance. That means they make only sense if you have a class that you instantiate (or a stateful module) and only if you want to re-use variable values across different methods. Such methods behave differently not only depending on the method parameters but also on the values to which these instance variables were previously set. That means a method’s behavior depends on the class instance’s history. The more instance variables you use (without legitimate need), the more becomes your code unpredictable.
In this case you are just assigning a value, not reading/using the value it had before (so it could be a constant). You should probably change it into a local variable without @.

Where do these points go, if you neither assign them to variables, nor put them into some collection (array) or pass them somewhere as parameters? For readability, better write the for-loop outside of the points array, and add these new points into the points array with points << Geom::Point3d.new(run2, 0, rise2+@rise)

Don’t do this if you already have a reference to (e.g.) model and you want to use the same. The method Sketchup.active_model literally means “give me a reference to whatever model is currently active (out of possibly many models). The API gives no guarantees that it returns the same reference over and over again. In programming, we need to accurately stick to the guarantees that an API gives and not assume more. Even if by experience we know that, so far, SketchUp implements only one singleton model.
Just use new_comp_def = model.definitions.add("Stair") and entities.add_instance(new_comp_def, tr). One could even pass the model reference to this method as a parameter.

When you ran your code you should have gotten a syntax error (and probably asked about it first):

SyntaxError: (eval):20: syntax error, unexpected ',', expecting keyword_end
        Geom::Point3d.new(run2, 0, rise2+@rise),
                                                ^
(eval):21: syntax error, unexpected ',', expecting keyword_end
        Geom::Point3d.new(run2+@run, 0, rise2+@rise),
                                                     ^
(eval):25: syntax error, unexpected tCONSTANT, expecting ']'
      Geom::Point3d.new(run2*@numrise,   0,   0)
          ^
(eval):26: syntax error, unexpected ']', expecting keyword_end
      ]
       ^

That tells you that the comma in line 20 is invalid. Ruby assumes you want to enumerate the next array element in points but forgot to end the for-loop.

module TheCraftyHippo
  def self.create_stair
    model = Sketchup.active_model # Open model
    ent = model.entities # All entities in model
    sel = model.selection # Current selection
  
    model.start_operation('Create Stair', true)
    new_comp_def = model.definitions.add('Stair') # Create new component
    entities = new_comp_def.entities
    numrise = 5
    rise = 7.25
    run = 10
    width = 42
    points = []
    # Add the bottom left corner (origin)
    points << Geom::Point3d.new(0, 0, 0)
    current_rise = 0
    current_run = 0
    # Add the steps
    (numrise).times do
      points << Geom::Point3d.new(current_run,     0, current_rise+rise)
      points << Geom::Point3d.new(current_run+run, 0, current_rise+rise)
      current_rise = current_rise + rise
      current_run = current_run + run
    end
    # Add the bottom right corner (fixed run2*numrise to run2)
    points << Geom::Point3d.new(current_run, 0, 0)
    # Create the face and extrude it into width
    face = entities.add_face(points)
    face.reverse! if face.normal.z < 0 # Reverse normal
    face.pushpull(width)
    tr = IDENTITY # Empty transformation
    cube = entities.add_instance(new_comp_def, IDENTITY) # Place stringer
    model.commit_operation
  rescue StandardError => e
    # Don't leave unfinished interim operations (adding faces etc.) if this method failed.
    model.abort_operation
    # Re-raise the error so that we know about it.
    raise(e)
  end
end
TheCraftyHippo.create_stair

I can see the middle of the stair! So otherwise the code was good!
stairs

You could even do the following and have less confusing variables:

    # Add the bottom left corner (origin)
    points << ORIGIN
    # Add the steps
    numrise.times do |i|
      points << Geom::Point3d.new(run*i,     0, rise*(i+1))
      points << Geom::Point3d.new(run*(i+1), 0, rise*(i+1))
    end
    # Add the bottom right corner
    points << Geom::Point3d.new(run*numrise, 0, 0)
2 Likes

That is good advice especially as the Mac version of SketchUp can already have multiple models, one for each window. If you want your extension to run on a Mac you have to be careful with this.

I wrote this example to see what could go wrong. The timer is to simulate something that takes a long time to process. The more time your script takes to run, the more time the user has to switch windows.

module GM
  module ModelFix::ModelTest
    @@count
    @@id

    extend self
    
    def init()
      @@count = 1
      @@points = [[0,0,0], [10,0,0], [10,10,0], [0,10,0]]
    end #init
        
    def draw_cube
      puts "draw_cube #{@@count}"
      ents = Sketchup.active_model.active_entities
      cubeGroup = ents.add_group
      face = cubeGroup.entities.add_face(@@points)
      face.pushpull(-10)
      
      tran = Geom::Transformation.new([10*(@@count - 1), 0, 0])
      cubeGroup.transform! tran
      @@count += 1
      if @@count > 12
        @@count = 1
        UI.stop_timer(@@id)
      end #if
    end #draw_cube
    
    def test_model
      @@id = UI.start_timer(0.5, true) { draw_cube }
    end #test_model
    
    init()
  end #ModelFix::ModelTest
end #GM


It just draws 12 cubes in a row, one every 0.5 seconds, but if you switch windows during the process, the cubes will be placed in whatever the top window is at the time. In the GIF, the first time it runs I leave the top left window active and it draws all cubes in that window. The next time it runs I switch to different windows with the mouse and the cubes are placed in whichever window is on top at the time.

1 Like

The point was mainly about adhering to what an API guarantees, and only what it guarantees, and avoiding assumptions from experience (“when I used it 10 times, it always worked like that”). It is important that this rule applies to any other method as well. Another typical assumption is that if an enum implements (currently) two values, you assume that if a variable is not the first value it must be the other (but “other” is ambiguous for an enum when it is extended).

Your example is right (and a bit off-topic for this thread)! Unfortunately in the Windows version of the API, the active_model has been implemented as a singleton method which even (sometimes) re-uses the same reference after opening a new model. That means code does not break, when beginners use it incorrectly. Usually humans tend to not take a constraint seriously and respect it if it is (almost) never met in practice. This will then become noticeable when new technology is introduced or another platform-support added (e.g. 4K screens or here a future multi-document-interface for SketchUp on Windows) and everything breaks.

But can you test your example with sleep instead of timers because timers are asynchronous, which most long-running ruby plugins are not.

Yes I do. The simple stair here is one element of the stair. @numrise is one of the fundamental variables that needs to be passed to multiple methods in order to draw all the elements of the stair. I grabbed it and copied it into the method for pasting into the forums.

Thanks for this. I hadn’t come across this method of passing items into an array yet. I knew that the answer must be something along these lines but haven’t developed the ability to “think” in Ruby yet.

Interesting! Thanks for pointing that out.

1 Like

Using sleep or just a loop running a million times for the delay does not cause the problem I showed above using UI.start_timer. The cubes just get put in the window that was on top when I start the script.

1 Like

So, keeping on learning, running into ‘how do you do that’s?’…

I have a square, and I want to make the corners of my square round. I can draw the arc for my round corner, and I can draw the square, no problem, but I can’t figure out how to get a face that combines them both.

 roundcorner = entities.add_arc(Geom::Point3d.new(0.25, 0.25, 0), Geom::Vector3d.new(1, 0, 0), Geom::Vector3d.new(0, 0, 1), 0.25, 3.14159, 4.71239, 6)
 corner1 = roundcorner.collect{|e|e.vertices}.flatten.uniq
 points = []
 points << corner1

I found this code (which didn’t work), so I printed the array to find out what’s going on, which when I run it within my code puts out this:

#Sketchup::Vertex:0x0001d329e16628
#Sketchup::Vertex:0x0001d329e16600
#Sketchup::Vertex:0x0001d329e165b0
#Sketchup::Vertex:0x0001d329e16538
#Sketchup::Vertex:0x0001d329e164e8
#Sketchup::Vertex:0x0001d329e16498
#Sketchup::Vertex:0x0001d329e16448
(0", 13", 0")
(10", 13", 0")
(10", 8.625", 0")
and so on and soforth.

So I’ve gotten the points into the array, they’re just not… points. So it sorta worked.

How do I convert those vertex’s into points? I haven’t figured it out yet.

Thanks.

P.S. I also figured out how to convert radians to degrees, I just haven’t changed it yet.

The Sketchup::Vertex class has a position() instance method that returns a Geom::Point3d object corresponding to the vertex’s position in 3D space.

Take any one of the edges returned by entities.add_arc() and call curve() upon it getting the ArcCurve object, then just call it’s vertices() method to return an array (of um vertices,) then map them to points using the position method…

points = roundcorner[0].curve.vertices.map(&:position)

It may be easier for you to draw the 4 arcs, then use the arccurve’s first_edge / last_edge (start & end ) points and draw straight edges between the arcs (from the last_edge.end to the next corner’s arc’s first_edge.start, doing this 4 times.

Then once the entire perimeter is enclosed, you call find_faces() upon one of the perimeter loop’s edges, and a face will be created within it.

Thanks for the reply, but after an hour of trying and searching, I can’t figure it out. Here’s my modified code as per your suggestion.

roundcorner1 = entities.add_arc(
  Geom::Point3d.new(0.25, 0.25, 0), 
  Geom::Vector3d.new(1, 0, 0), 
  Geom::Vector3d.new(0, 0, 1), 
  0.25, # radius
  180.degrees, 
  270.degrees, 
  6) # segments
#corner1 = roundcorner1.collect{|e|e.vertices}.flatten.uniq
points = []
#points << Geom::Point3d.new(0, 0, 0)
points << roundcorner1[0].curve.vertices.map(&:position)
points << Geom::Point3d.new(0, 13, 0)
points << Geom::Point3d.new(10, 13, 0)
points << Geom::Point3d.new(10, 8.625, 0)
points << Geom::Point3d.new(10.375, 8.625, 0)
points << Geom::Point3d.new(10.375, 4.375, 0)
points << Geom::Point3d.new(10, 4.375, 0)      
points << Geom::Point3d.new(10, 0, 0)
puts points
face = entities.add_face(points)

And the ruby console output.

(0", 0.25", 0")
(0.008519", 0.185295", 0")
(0.033494", 0.125", 0")
(0.073223", 0.073223", 0")
(0.125", 0.033494", 0")
(0.185295", 0.008519", 0")
(0.25", 0", 0")
(0", 13", 0")
(10", 13", 0")
(10", 8.625", 0")
(10.375", 8.625", 0")
(10.375", 4.375", 0")
(10", 4.375", 0")
(10", 0", 0")
“wrong number of values in array”

What am I doing wrong? Is there a simpler method of accomplishing this? I did try the arccurve method you suggested, but I couldn’t figure it out yet. But I’d like to understand what’s wrong with this first.

Thanks.

Use puts(points.inspect) and find your error. Note that SketchUp can understand a point either as Geom::Point3d or as an array of exactly 3 numerical values.

No I did not suggest this code … using Array#<<()

points = []
points << roundcorner1[0].curve.vertices.map(&:position)

I clearly gave you the code the creates a vertices array (by calling the #vertices method,) then maps that array to a new array of point3d objects …

points = roundcorner1[0].curve.vertices.map(&:position)

Since this call chain creates arrays you do not need to start with a literal empty array.
Instead, you simply assign the output of that call chain to a reference (points in your case.)

And indeed I can. Doing that clearly shows an array within an array.

Such is reading comprehension at midnight.

Yet clearly I still don’t understand what’s going now. Now the code seems to run without error, it’s the behaviour that doesn’t make sense. Running the fixed code now result in this:

Capture

After staring at that for a while, I thought to myself that I needed to reverse the direction of the arc, so that everything flows around the square in the proper order. That indeed turns out to be the problem, but I couldn’t figure out how to fix it with the arc, so I reversed the direction in which I was drawing around the square instead, and I got a round corner finally! Yay me!

I don’t like it though. Using this method, how do I get 4 round corners on my square? Hmmm.

I’m going to study the find_faces method that Dan suggested and give that a try.

An hour and a half after writing that last sentence, the behaviour of arcs still eludes me.

Clearly something very profound and simple is happening here that those of us without the revelation of Ruby simply are incapable of comprehending…

  roundcorner1 = entities.add_arc(
      Geom::Point3d.new(0.25, 0.25, 0), 
      X_AXIS, 
      Z_AXIS, 
      0.25, 
      270.degrees, 
      180.degrees, 
      8)
  roundcorner2 = entities.add_arc(
      Geom::Point3d.new(1-0.25, 0.25, 0), 
      X_AXIS, 
      Z_AXIS, 
      0.25, 
      360.degrees, 
      270.degrees, 
      8)
  roundcorner3 = entities.add_arc(
      Geom::Point3d.new(1-0.25, 1-0.25, 0), 
      X_AXIS, 
      Z_AXIS, 
      0.25, 
      0.degrees, 
      90.degrees, 
      8)
  roundcorner4 = entities.add_arc(
      Geom::Point3d.new(0.25, 1-0.25, 0), 
      X_AXIS, 
      Z_AXIS, 
      0.25, 
      180.degrees, 
      90.degrees, 
      8) 
  vx1 = roundcorner1[0].end
  vx2 = roundcorner2[0].start
  edge1 = entities.add_line(vx1, vx2)
  vx3 = roundcorner2[0].end
  vx4 = roundcorner3[0].start
  edge2 = entities.add_line(vx3, vx4)

I am finding this both extremely frustrating and thoroughly amusing at the same time. How can this be this hard!! lol

AHA! The .end and .start applies to the specific element in the array, not the whole array itself!

This one is slow to comprehend. Don’t mind me.

Yes! You created, or #add_arc() returns, an array of edges.
Those methods are instance methods of the Sketchup::Edge class:

The Ruby core Array class has different methods to get at the extremity members:

… also using the square bracket [] (aka #slice) method:

  • my_ary[0] returns first member
  • my_ary[-1] returns last member

But you can also get the the first and last edges directly from the ArcCurve object (as it itself is a wrapper class around a collection of edges associated with a center point and radius):

… so it is not actually necessary to even collect all the vertices of the curves, ie …

# Collect an array of arcs from the roundcorner edge collections:
arcs = [
  roundcorner1[0].curve, roundcorner2[0].curve,
  roundcorner3[0].curve, roundcorner4[0].curve
]
# Assuming all arcs drawn in the same direction, about the face's normal:
# (Actually assuming they are numbered in the order they were drawn, so that
#  the connecting sides will also be drawn in the same direction as the arcs.)
for i in 1..4
  entities.add_line(
    arcs[i-1].last_edge.end.position,
    arcs[ i==4 ? 0 : i ].first_edge.start.position
  )
end
# add the face
arcs[0].first_edge.find_faces

Not so much Ruby as quirks with SketchUp and it’s geometry.

You should draw the arcs in the same direction or else you may end up with twisted “bow tie” faces later on.

It also matters which way you draw curve perimeters as to which way the face will be drawn (normalwise).
I think that if you draw clockwise (looking down) the faces will be facing down (their normal vector [0,0,-1].)

Beware of the ground plane. SketchUp likes to make faces on the ground plane face downwards in preparation for push-pulling upwards later. (So that the resultant bottom face is then facing outward of the manifold solid.)

The first thing is simplify the math by using simple integers to learn how to get the code correct.

Then (when it is working) wrap it in a method with arguments that position the rounded square and set it’s size and corner radius. Replace the integer values with variables or values derived from the passed in arguments.
Then you have a method you can call repeatedly with different arguments as to size and position.

Thank You for the reply. Clearly reading about this (and yes, I am reading through the Pragmatic Programmer) and trying to use it are two different things.

Which would work well on a true 4 sided object (ie: square), but since the whole ‘square with round corners’ thing is an oversimplification of the object I’m working on, wouldn’t work in this case. I’m wondering if there is ever an end to the variation of methods to achieve something, and how you’re supposed to know which is best in any random circumstance, lol.

This is the behaviour that is still confusing me, it’s interesting that you would mention it. Here is the code for the 4 corners:

roundcorner1 = entities.add_arc(
  Geom::Point3d.new(0.25, 0.25, 0), 
  X_AXIS, 
  Z_AXIS, 
  0.25, 
  270.degrees, 
  180.degrees, 
  8)
roundcorner2 = entities.add_arc(
  Geom::Point3d.new(1-0.25, 0.25, 0), 
  X_AXIS, 
  Z_AXIS, 
  0.25, 
  360.degrees, 
  270.degrees, 
  8)
roundcorner3 = entities.add_arc(
  Geom::Point3d.new(1-0.25, 1-0.25, 0), 
  X_AXIS, 
  Z_AXIS, 
  0.25, 
  0.degrees, 
  90.degrees, 
  8)
roundcorner4 = entities.add_arc(
  Geom::Point3d.new(0.25, 1-0.25, 0), 
  X_AXIS, 
  Z_AXIS, 
  0.25, 
  180.degrees, 
  90.degrees, 
  8) 

Taking the starting pair of angles for corner1 (270° and 180°), it would seem logical to add 90° to each additional corner to get them into position, which hold true for corner2 and corner4, but doesn’t hold true for corner3! Yet all 4 corners share the same axis and vector. So it leaves me confused as to how Sketchup considers which angles are which. Even now, if you asked me to draw an arc, I’d punch in random angles until I get what I want because I haven’t figured it out.

As you can see in the line drawing code, the drawing direction is inconsistent, and could easily result in that ‘bowtie’ face you mentioned:

vx1 = roundcorner1[0].start
vx2 = roundcorner2[7].end
edge1 = entities.add_line(vx1, vx2)
vx3 = roundcorner2[0].start
vx4 = roundcorner3[0].start
edge2 = entities.add_line(vx3, vx4)
vx5 = roundcorner3[7].end
vx6 = roundcorner4[7].end
edge3 = entities.add_line(vx5, vx6)
vx7 = roundcorner4[0].start
vx8 = roundcorner1[7].end
edge4 = entities.add_line(vx7, vx8)
edge4.find_faces

Which brings me to my final conundrum for the evening. Using find_faces on edge4 leaves me with no reference for my pushpull. So naturally I thought face = edge4.find_faces would work, which can then be pulled with face.pushpull(), but it doesn’t work. So I looked it up and came up with face = entities.grep(Sketchup::Face), which gave me this:

"undefined method `pushpull' for [#<Deleted Entity:0x5820ed88>]:Array"

sigh I’ve been stuck trying to draw corners on a square for 2 days now! :smiley:

Ruby was designed to be multi-paradigm. So there are always multiple ways.

Easiest to read and understand is usually best (unless extreme speed is needed, which is not usually the case in user commands.)

Corner 3 is being drawn counter-clockwise (looking down.)
The other 3 are being drawn clockwise.

If you want the arcs “swept” clockwise, then also draw them in that order.
You’ve drawn the arcs counter-clockwise around the perimeter, but “swept” the arcs all clockwise (except for #3.)