Help with parallel lines extension code

Hello. I have been making a script / extension to create parallel lines either side of a selected edge or guide line. It’s fairly simple but very useful. Select an edge or guide line on a face, run “Parallel Lines Tool” script and a dialog appears asking what the offset should be. It mostly works, but weirdly when it is used on lines that are on faces perpendicular to the Y axis, the parallel lines are not created flush with face either side of the selected line as they should be, but rather in front and behind. Any help would be appreciated! I am using Sketckup Make 2017 on a Mac.

require 'sketchup.rb'

module ParallelLines
  class ParallelLinesTool
    def initialize
      @offset = 100.mm # Default offset in millimeters
    end

    def activate
      model = Sketchup.active_model
      selection = model.selection
      
      if selection.empty?
        UI.messagebox("Please select an edge or guide line before running the tool.")
        model.select_tool(nil)
        return
      end
      
      entity = selection[0]
      
      if entity.is_a?(Sketchup::Edge)
        @selected_edge = entity
        create_parallel_for_edge(entity)
      elsif entity.is_a?(Sketchup::ConstructionLine)
        @selected_edge = entity
        create_parallel_for_construction(entity)
      else
        UI.messagebox("Please select an edge or guide line. Selected: #{entity.class}")
      end
      
      # End the tool
      model.select_tool(nil)
    end

    def create_parallel_for_edge(edge)
      model = Sketchup.active_model
      
      result = UI.inputbox(["Offset distance (mm)"], [@offset.to_mm.to_s], "Enter Offset Distance")
      return unless result
      
      begin
        @offset = result[0].to_f.mm
      rescue => e
        UI.messagebox("Invalid distance value")
        return
      end
      
      model.start_operation('Create Parallel Lines', true)
      
      begin
        start_point = edge.start.position
        end_point = edge.end.position
        
        vector = end_point.vector_to(start_point)
        vector.normalize!
        
        faces = edge.faces
        if faces.empty?
          normal = Geom::Vector3d.new(0, 0, 1)
        else
          normal = faces[0].normal
        end
        normal.normalize!
        
        perp_vector = vector.cross(normal)
        perp_vector.normalize!
        perp_vector.length = @offset
        
        [-1, 1].each do |side|
          offset_vec = perp_vector.clone
          offset_vec.length = @offset * side
          
          new_start = start_point.offset(offset_vec)
          new_end = end_point.offset(offset_vec)
          
          model.active_entities.add_line(new_start, new_end)
        end
        
        model.commit_operation
        
      rescue => e
        puts "Error: #{e.message}"
        puts e.backtrace
        model.abort_operation
        UI.messagebox("Error creating parallel lines: #{e.message}")
      end
    end

    def find_working_plane_normal(model, point, direction)
      # Test rays in multiple directions to find the working plane
      test_directions = []
      
      # Create a coordinate system based on the guide line direction
      z_axis = Geom::Vector3d.new(0, 0, 1)
      x_axis = Geom::Vector3d.new(1, 0, 0)
      y_axis = Geom::Vector3d.new(0, 1, 0)
      
      # Test perpendicular vectors in all major planes
      test_directions << direction.cross(z_axis)
      test_directions << direction.cross(x_axis)
      test_directions << direction.cross(y_axis)
      
      # Remove any invalid vectors (in case direction was parallel to a test vector)
      test_directions.reject! { |v| !v.valid? || v.length == 0 }
      
      # Normalize and set length for all test vectors
      test_directions.each do |vec|
        vec.normalize!
        vec.length = 10000.mm
      end
      
      # Add reverse vectors
      test_directions += test_directions.map(&:reverse)
      
      # Find the closest face that's perpendicular to the guide line
      closest_face = nil
      closest_distance = nil
      
      test_directions.each do |test_vector|
        hit = model.raytest(point, test_vector)
        next unless hit
        
        hit_point, entity = hit
        distance = point.distance(hit_point)
        
        if entity.is_a?(Sketchup::Face)
          # Check if the face is roughly perpendicular to the guide line
          angle = entity.normal.angle_between(direction)
          if (angle - 90.degrees).abs < 1.degrees
            if closest_distance.nil? || distance < closest_distance
              closest_distance = distance
              closest_face = entity
            end
          end
        elsif entity.is_a?(Sketchup::Edge)
          entity.faces.each do |face|
            angle = face.normal.angle_between(direction)
            if (angle - 90.degrees).abs < 1.degrees
              if closest_distance.nil? || distance < closest_distance
                closest_distance = distance
                closest_face = face
              end
            end
          end
        end
      end
      
      # If we found a face, use its normal
      return closest_face.normal if closest_face
      
      # If no face was found, create a sensible default normal
      # Try to use the most appropriate axis based on the guide line direction
      if direction.parallel?(z_axis)
        return x_axis
      elsif direction.parallel?(x_axis)
        return y_axis
      else
        return z_axis
      end
    end

    def create_parallel_for_construction(cline)
      model = Sketchup.active_model
      
      result = UI.inputbox(["Offset distance (mm)"], [@offset.to_mm.to_s], "Enter Offset Distance")
      return unless result
      
      begin
        @offset = result[0].to_f.mm
      rescue => e
        UI.messagebox("Invalid distance value")
        return
      end
      
      model.start_operation('Create Parallel Lines', true)
      
      begin
        direction = cline.direction
        point = cline.position
        
        # Get the normal of the working plane
        normal = find_working_plane_normal(model, point, direction)
        
        # If we didn't find a good normal at the first point, try another point
        if !normal
          test_point = point.offset(direction, 100.mm)
          normal = find_working_plane_normal(model, test_point, direction)
        end
        
        normal.normalize!
        
        # Create perpendicular vector that lies in the face plane
        perp_vector = direction.cross(normal)
        perp_vector.normalize!
        perp_vector.length = @offset
        
        # Create parallel construction lines
        [-1, 1].each do |side|
          offset_vec = perp_vector.clone
          offset_vec.length = @offset * side
          new_point = point.offset(offset_vec)
          model.active_entities.add_cline(new_point, direction)
        end
        
        model.commit_operation
        
      rescue => e
        puts "Error: #{e.message}"
        puts e.backtrace
        model.abort_operation
        UI.messagebox("Error creating parallel lines: #{e.message}")
      end
    end
  end

  # Create menu items
  unless file_loaded?(__FILE__)
    menu = UI.menu('Plugins')
    menu.add_item('Parallel Lines Tool') {
      Sketchup.active_model.select_tool(ParallelLinesTool.new)
    }
    
    file_loaded(__FILE__)
  end
end

FYI, if you look at: Top Level Namespace — SketchUp Ruby API Documentation
you’ll see that the SketchUp Ruby API defines the following global 3D constants:

ORIGIN
X_AXIS
Y_AXIS
Z_AXIS

Thanks. I did try renaming just in case there was a conflict but no change unfortunately.

A quick observation. One of the reasons why this approach is such a challenge is because it is not “SketchUppy”.

Meaning that the tool does not have the user indicate the offset vector (and distance) using the mouse and inferencing.

To see how it can work in a “SketchUppy” way, select an edge on a face. (Either a bounding edge of a bisecting edge.)
Then hit “M” to activate the Move tool, and tap CTRL (win) | Command (mac) to switch to copy mode.
Click on the edge and slide the copy in a direction whilst keeping the “On Face” inference locked. (When you see the inference, you can tap the SHIFT key to lock it.)
If you desire to enter a specific numeric offset, then just type it and hit ENTER. (No need to click on the VCB.) Type the value after you have indicated the direction.

1 Like

I see what you mean but this script works so well on all other axes, on both edges and and guide lines. The problem is only affecting the Y axis so to me that suggests there is a bias in the code but I just cannot see it. Perhaps there is a bias in the way Sketchup deals with face normals on the Y axis that I am unaware of.

Hmmm… this would indicate perhaps the “bias” is in the find_working_plane_normal() method?

You mention “working on axes”, but when I think of drawing or offsetting an edge, it is on a plane which is usually described with two axes. Ie, the XY plane, which has a positive normal equal to (or parallel with the Z_AXIS.)

Please clarify, when you mention Y axis are you meaning the plane whose normal is equal to or parallel to the XZ plane?


EDIT: I just tested it with a face on the XZ plane. The edges worked fine, but it was the guidelines that were offset on the YZ plane instead of the XZ plane.
It makes sense that the “bias” is within the find_working_plane_normal() method, as it is not used by the edge scenario.

Sorry if I was not clear. Yes it seems even on the XY plane the problem occurs, if the line is drawn along the X. But for a line on the XY plane drawn along the Y, it works correctly. I’ll take a closer look at find_working_plane_normal() thanks

After looking at this awhile, I’d first try to find a face that is associated with the cline:


def get_face_normal_for_cline(model, cline)
  # Can only manually select a cline in the active entities
  # so, grep active entities faces:
  faces = model.active_entities.grep(Sketchup::Face)
  # find a face plane that the cline position lays upon:
  face = faces.find do |face|
    # Reject any face that is not parallel to the cline:
    next unless face.normal.perpendicular?(cline.direction)
    face.classify_point(cline.position) != Sketchup::Face::PointNotOnPlane
  end
  # if found return face's normal vector:
  return face.normal if face
  # if still not found prompt the user for a direction:
  choice = UI.inputbox(
    ["Normal Vector"], ["Z_AXIS"], ["X_AXIS|Y_AXIS|Z_AXIS"], "Choose Plane"
  )
  return nil unless choice
  case choice[0]
  when "X_AXIS" then X_AXIS
  when "Y_AXIS" then Y_AXIS
  when "Z_AXIS" then Z_AXIS
  end
end ### get_face_normal_for_cline()

I didn’t have any success when it came to the guide lines. As you said, they behave differently. The script seems to work in all situations when using edges, so I can live with that. Thanks for giving it some thought.

I don’t understand why there would be more ambiguity with a specific axis. Guide lines running along one axis, or guide lines that are on a face that is perpendicular to that axis. Anyway, too much head scratching for me. The tool works on edges, that will have to do!

I’m not satisfied!! I carry the burden of failure and long for a solution…

Would you be willing to share the solution?

Oh, you said “I see what is wrong with the guides code” and I thought you meant you had found what was wrong with the guides code.

I signed up to the forum yesterday after days of struggling with the code in the hope that someone would be able to help and that as a result others might benefit from a useful extension that adds functionality to Sketchup. I certainly was not expecting someone to reply saying they had figured out the problem, withhold the solution and to say “Figure it out yourself”. That’s just not a good way to behave.

Sorry if I disappointed you. I was frustrated that you seemed to be ignoring the heart of what I wrote.

If you could be bothered to instrument your code, you would quickly discover that a single selected edge or guide does not provide enough information to resolve all possible situations. Your code only works for “nice” cases. The solution is to rethink your approach to require a bit more information, e.g. a selected face to go with the edge or guide.

That said, I’m out of this topic.

The script seems to work flawlessly on edges that are on face, so a simple selection does provide enough information. The issue is specifically related to guide lines, which are managed differently in a way I do not understand.

Generally, the consensus here is that it is always better for the original coder’s brain to figure out where their code went wrong. Ie, it is a better learning experience.
Included in this is the need for coders to learn the basics of debugging their own code in Ruby.

You’d be surprised at the number of posters that come here asking for a complete code solution without first even making the simplest of attempts. This is usually considered a peeve here, although occasionally the problem is interesting enough that one of the coders here takes up the challenge.

The main difference is that guides are not associated with any other Drawingelement primitive. They can be encapsulated within a group or component.

This is why I suggested (above) that at the beginning of the create_parallel_for_construction() method, that your code first use my get_face_normal_for_cline() method instead of calling find_working_plane_normal() to see if the guideline (cline) was laying across a face in the active entities.

I’ll test it now (as I didn’t have time yesterday.)

Thanks Dan. I can understand how people may come on here to get others to write scripts for them and that must be annoying. But I assure you I have invested a lot of time on this, and now it has got over my head and admittedly became a bit frustrating. I don’t know of any other free extension that does this, so I thought it might be a benefit for others too, especially if the guide line functionality could be added.

Okay, it works as I had predicted for a guideline when the code basically bypasses the find_working_plane_normal() method and instead just searches for a face and tests whether the point associated with the cline is on the plane of the face.

JohnBulter_DrawParallelLines_main.rb (9.5 KB)

JohnBulter_DrawParallelLines_changes.txt (1.6 KB) - markdown text

The file I setup to test the 3 main axis and a rotated face:
JB_parallel_lines_test.skp (55.9 KB)

Changes:

  • Wrapped this within a unique JohnButler namespace module.
  • Added a VERSION constant at "2.0.1"
  • As this is not actually a tool interface, I’ve changed a few things so it is treated as a command, (which is what it is. See below comments on why it is not a tool.)
  • The extension submodule is now: DrawParallelLines
  • The command class is now: ParallelLinesCommand
  • The command menu label is now: "Draw Parallel Lines..." (The ellipses is standard UX that means it opens a dialog interface.)
  • Added a right-click context command item. Basically, when you right-click a single object or primitive in the model it is selected and becomes the context for all commands on the popup menu. So, now you can just right-click an edge or guideline and access the command from the popup context menu. (Time saver on a 28" UHD display like mine.)
  • Change @offset to a class variable @@offset so each instance of the command will use the most recently used value as set by the user. (It was being reset to the default each time the command was used.)
  • Added in the get_face_normal_for_cline() method to search for a face associated with a guideline. (Basically, skips the use of the find_working_plane_normal method.)
  • Added a @model instance variable so that this reference need not be passed around or re-referenced in the command’s methods.
  • The class’ instance methods are now in alpha order excepting the constructor’s initialize().

Why it is not a tool ... ( click to expand )

This is not really a tool interface; it is a command. Meaning, the code is basically sequential and does not act like a tool.

  • It does not use any tool callbacks, does not leverage the VCB (ie, Measurements toolbar) for offset input instead implementing an inputbox.
  • It does not have any tool states and does not stay in the tool and reset after each use like a tool interface should. (i.e., a tool would go back to the select edge or cline state as the initial tool state. A tool would reset when the ESC key is clicked.)
  • It does not set a cursor so it looks weird if the user uses custom system cursor scheme. Instead of the SketchUp select cursor, the system arrow cursor is shown. (I use extra large bright orange system cursor scheme that I drew many years ago. So I know immediately when a Ruby tool does not set the cursor.) This is avoided now that the code does not set the command interface as the active tool.
  • A tool generally uses interactive mouse movements and clicks or drags, often at multiple states (stages) of the task.
4 Likes

This is brilliant. A complete rewrite really. A third less code and does away with all my different approaches. Thank you!

1 Like