Seeking Help to Export Edge Convexity/Concavity Degrees as Shown in SketchUp

Hello everyone,

I’ve been grappling with a particularly stubborn issue in SketchUp and could really use your collective expertise. My objective is simple: I want to export the degrees indicating whether edges are convex or concave, similar to what’s shown in theimage below. Essentially want to calculate the angle as viewed from outisde the building.

I’ve already ventured down several avenues:

  1. I’ve tried analysing the dihedral angles between adjacent face normals to determine the convexity or concavity of an edge. The idea was that if the angle between the normals is greater than 180 degrees, the edge is concave, and if less, convex.
  2. I attempted to use vectors pointing from the edge to a theoretical ‘outside’ point, using the direction of face normals to infer concavity or convexity.
  3. I’ve also utilised vector cross products between face center vectors and edge vectors, but this consistently mislabeled all edges as convex.

Throughout this process, I’ve encountered issues with zero-length vectors and angle normalisation that have thrown a wrench in the works. Here is an example of code I’ve tried, apologies I am self taught for the last 2 years so go easy on me please :slight_smile:

def label_edges
  model = Sketchup.active_model
  entities = model.active_entities
  edges = entities.grep(Sketchup::Edge)

  edges.each do |edge|
    next unless edge.faces.length == 2
    face1, face2 = edge.faces
    angle = face1.normal.angle_between(face2.normal)
    label = angle < Math::PI ? 'Convex' : 'Concave'
    entities.add_text(label, edge.bounds.center)
  end
end

I gotta think about the geometry of what you need. It’s easy for a person to see which case applies at each dihederal, but much harder to figure out using the available vectors and math…they tend to always get the acute angle.

Yes, tricky. I’ve spent many hours trying to figure this out!

My brain is being tickled by a quirk involving the Geom::Vector3d#angle_between method.

Searching the forum …

Okay, yes YOU also asked about this method’s quirkiness in a related topic on the concavity of edges in September of 2019. Julia Eneroth gave you an answer and a solution at that time …

2 Likes

Hi Dan, thank you. I do actually remember this and thought it was the answer but couldn’t get it to work in the end, actually gave up on the project then. Can you get it to work as I need?

It didn’t work for me just now either. I got 45 degrees for an obtuse dihedral that should have been 225. I don’t see yet why the answer would come out as 270 minus the real value, or equivalently, 180 minus the acute angle of 135. I need to think through the vector math and trig in that formula.

Here’s a snippet that I think will work, though it needs testing on more cases to make sure they don’t break the logic. You will need a bit of additional code to extract a desired edge object and pass it to this, but based on what you’ve writtenI believe you know how to do that. Invoke it as SLBPlugins::Dihedral.dihedral_angle(edge).
See comments in the file regarding how it works.

# encoding: utf-8

# Calculate the dihedral angle between two faces that share the given edge.
# Notes:
# - The return value is in radians so it can be used in other SketchUp 
# methods that need angles.  Use the .radians method to convert it to
# degrees if needed for display.
# - It is left to other code to identify an edge of interest
# - The edge must be shared by exactly two faces. An exception will raise if
# this is not true.
# - The angle is between two vectors lying flat in the two faces, and
# perpendicular to the given edge.
# - To handle the full range of angles, this uses the atan2 function.
# It returns values in the interval [-PI, PI], that is, for acute
# dihedrals the raw value will be in [0, PI] and for obtuse dihedrals
# it will be in [-PI, 0].  The code corrects for this to get a positive
# value for obtuse angles.

module SLBPlugins
  module Dihedral
    extend self
    def dihedral_angle(edge)
      raise ArgumentError, 'argument must be an edge used by two faces' if edge.faces.count != 2
      edge_vector = (edge.end.position - edge.start.position).normalize!
      normal_0 = edge.faces[0].normal
      normal_1 = edge.faces[1].normal
      x_axis = edge_vector * normal_0 # vector in the first face perpendicular to edge
      y_axis = normal_0 # vector perpendicular to first face
      face_2_vector = normal_1 * edge_vector # vector in the second face perpendicular to edge
      x = face_2_vector % x_axis # x value of second face vector
      y = face_2_vector % y_axis # y value of second face vector
      raw_angle = Math.atan2(y, x)
      puts raw_angle.radians
      raw_angle = (2.0 * Math::PI) + raw_angle if raw_angle < 0 # atan2 returns negative when the angle is > 180
      raw_angle
    end
  end
end

Edit: there may be a bug affected by the direction of the selected edge, which will cause the acute angle to be returned instead of the obtuse…

Edit 2: there is definitely a bug. The x_axis is reversed if the edge start and end are reversed, which causes acute and obtuse to be swapped. I need to think of a test that detects this situation and deals with it.

Thank you so much for looking at this for me.
Ive tried the following:

module SLBPlugins
  module Dihedral
    extend self
    def dihedral_angle(edge)
      raise ArgumentError, 'argument must be an edge used by two faces' if edge.faces.count != 2
      edge_vector = (edge.end.position - edge.start.position).normalize!
      normal_0 = edge.faces[0].normal
      normal_1 = edge.faces[1].normal
      x_axis = edge_vector * normal_0 # vector in the first face perpendicular to edge
      y_axis = normal_0 # vector perpendicular to first face
      face_2_vector = normal_1 * edge_vector # vector in the second face perpendicular to edge
      x = face_2_vector % x_axis # x value of second face vector
      y = face_2_vector % y_axis # y value of second face vector
      raw_angle = Math.atan2(y, x)
      puts raw_angle.radians
      raw_angle = (2.0 * Math::PI) + raw_angle if raw_angle < 0 # atan2 returns negative when the angle is > 180
      raw_angle
    end
  end
end

def calculate_and_label_dihedral_angles
  Sketchup.active_model.start_operation('Label Dihedral Angles', true)
  Sketchup.active_model.selection.grep(Sketchup::Edge).each do |edge|
    begin
      if edge.faces.length == 2
        angle_radians = SLBPlugins::Dihedral.dihedral_angle(edge)
        angle_degrees = angle_radians * (180 / Math::PI) # Convert radians to degrees
        midpoint = edge.bounds.center
        text = "#{angle_degrees.round(2)}°"
        Sketchup.active_model.entities.add_text(text, midpoint)
      end
    rescue ArgumentError => e
      puts e.message
    end
  end
  Sketchup.active_model.commit_operation
end

calculate_and_label_dihedral_angles

But unfortunatly the angles are not consistant.

I tried with a basic model:
Untitled.skp (203.9 KB)

Hmmm…I also see values that are not correct for your model. I’ll investigate. I have to go out shortly, so I likely won’t get back until tomorrow.

Here’s a revised version. It turns out the problem was that one can’t reliably predict which way a vector from the start to the end of an edge may point, but the code is sensitive to it. So I added tests to make sure vectors point the right way.

There are places where I have knowingly left code somewhat unrolled when statements could be chained together. That just made it easier to debug.

# encoding: utf-8

# Calculate the dihedral angle between two faces that share the given edge.
# Notes:
# - The return value is in radians so it can be used in other SketchUp 
# methods that need angles.  Use the .radians method to convert it to
# degrees if needed for display.
# - It is left to other code to identify an edge of interest
# - The edge must be shared by exactly two faces. An exception will raise if
# this is not true.
# - The angle is between two vectors lying flat in the two faces, and
# perpendicular to the given edge.

module SLBPlugins
  module Dihedral
    extend self
    def dihedral_angle(edge)
      raise ArgumentError, 'argument must be an edge' unless edge.is_a?(Sketchup::Edge)
      raise ArgumentError, 'argument must be an edge used by two faces' if edge.faces.count != 2
      edge_vector = (edge.end.position - edge.start.position).normalize! # unit vector along the selected edge
      face_0 = edge.faces[0]
      normal_0 = face_0.normal
      face_1 = edge.faces[1]
      normal_1 = face_1.normal
      
      # Create the x axis for coordinates used when calculating the angle via atan2
      x_axis = edge_vector * normal_0
      # The x_axis direction is sensitive to the direction of edge_vector, which depends
      # on how the geometry was drawn.  We need it to point into face_0, so check that 
      # is the case by generating a point that should be on face_0.
      # Use the mid point to avoid the possibility of getting an test point that lies atop
      # another edge, which would break the classify_point test below.
      mid_point = Geom.linear_combination(0.5, edge.start.position, 0.5, edge.end.position)
      test_point =  mid_point.offset(x_axis)
      x_axis.reverse! if face_0.classify_point(test_point) == Sketchup::Face::PointOutside
      
      # Create the y axis for the coordinates used when calculating the angle via atan2
      y_axis = normal_0
      
      # Create a vector lying in face_1 perpendicular to the edge.  We will calculate the
      # angle between this and the x_axis.  Once again we need to make sure this points
      # into face_1 rather than out of it.
      face_1_vector = normal_1 * edge_vector
      test_point_1 = mid_point.offset(face_1_vector)
      face_1_vector.reverse! if face_1.classify_point(test_point_1) == Sketchup::Face::PointOutside
      
      # The x and y coordinates are the projection lengths (dot products)
      # of face_1_vector with the two axes
      x = face_1_vector % x_axis 
      y = face_1_vector % y_axis
      raw_angle = Math.atan2(y, x)
      # Because atan2 returns a value in the range [-PI, PI], it will be negative for dihedral
      # angles greater than 180 degrees (PI).  Get the obtuse angle by subtracting from 360 (=2 * PI)
      raw_angle = (2.0 * Math::PI) + raw_angle if raw_angle < 0 # atan2 returns negative when the angle is > 180
      raw_angle
    end
  end
end

Edit: perhaps I should write “convex” instead of “obtuse” and “concave” instead of “acute”…

Thanks Steve, you’ve been very helpful and I really appreciate you looking at this for me.

Adding the labels I’m still getting a couple of rogue angles in there, am I doing something obviously wrong here? Thanks again for your help.

module SLBPlugins
  module Dihedral
    extend self

    def dihedral_angle(edge)
      # Validate input
      raise ArgumentError, 'Argument must be an edge' unless edge.is_a?(Sketchup::Edge)
      raise ArgumentError, 'Edge must be used by exactly two faces' if edge.faces.count != 2
      
      # Calculate unit vector along the edge
      edge_vector = (edge.end.position - edge.start.position).normalize
      
      # Get face normals
      normal_0 = edge.faces[0].normal
      normal_1 = edge.faces[1].normal
      
      # Create coordinate axes for angle calculation
      x_axis = edge_vector * normal_0
      x_axis.reverse! if edge.faces[0].classify_point(edge.end.position) != Sketchup::Face::PointInside
      y_axis = normal_0

      # Create vector in face_1 perpendicular to edge_vector
      face_1_vector = normal_1 * edge_vector
      face_1_vector.reverse! if edge.faces[1].classify_point(edge.end.position) != Sketchup::Face::PointInside
      
      # Calculate angle using atan2
      x = face_1_vector % x_axis
      y = face_1_vector % y_axis
      raw_angle = Math.atan2(y, x)
      
      # Correct for angles greater than 180 degrees
      raw_angle += 2.0 * Math::PI if raw_angle < 0
      raw_angle
    end

  end
end

class Float
  def radians
    self * (180.0 / Math::PI)
  end
end

def label_dihedral_angles
  model = Sketchup.active_model
  entities = model.active_entities
  edges = entities.grep(Sketchup::Edge)

  edges.each do |edge|
    next unless edge.faces.length == 2
    
    begin
      angle_radians = SLBPlugins::Dihedral.dihedral_angle(edge)
      angle_degrees = angle_radians.radians
      
      label = angle_degrees > 180 ? 'Concave' : 'Convex'
      text = "#{label} - #{angle_degrees.round(2)}°"
      
      entities.add_text(text, edge.bounds.center)
    rescue ArgumentError => e
      puts "Edge #{edge} error: #{e.message}"
    end
  end
end

label_dihedral_angles

DO NOT modify API or Ruby Classes!

Use a refinement if you must. See: refinements - Documentation for Ruby 3.2

However, you do not need to add #radians to the Float class. Float is a subclass of Numeric. The SketchUp API already has added #radians to the Numeric class (as well as many other conversion methods.)

All Numeric subclasses will inherit these methods.

Ie, … I do not have your Float class mofication and at my Ruby Console …

1.2.respond_to?(:radians)
#=> true

3.respond_to?(:radians)
#=> true

4.to_l.respond_to?(:radians)
#=> true

So, Float, Integer and Length instance objects all inherit the conversion methods.


When you define methods in the top-level ObjectSpace, they become a method of Object.

There is no reason to ever define methods outside your unique namespace module or it’s extension submodules. SketchUp Ruby is a shared environment. Please keep it clean.

@rinse04

Since you really don’t need the angle, something like the following might work. There are some ‘exceptions’ that I wasn’t sure what to do with. It works on a quick model I created…

# frozen_string_literal: true

module ConvexFaces
  class << self
    def run
      am = Sketchup.active_model
      if (edges = am.selection.grep Sketchup::Edge).empty?
        edges = am.entities.grep(Sketchup::Edge)
      end
      am.selection.clear
      edges.each do |edge|
        if (t = edge.faces).length == 2
          f1, f2 = t
        else # not sure what to do if > 2
          next
        end

        rev1, rev2 = edge.reversed_in?(f1), edge.reversed_in?(f2)
        
        if rev1 == rev2
          # flipped face, ?? action
          puts 'flipped'
          next
        elsif rev1 && !rev2
          f1, f2 = f2, f1
        end

        normal1, normal2 = f1.normal, f2.normal

        next if normal1.parallel? normal2

        cross = normal1 * normal2
        edge_vector = edge.end.position.vector_to(edge.start.position).normalize

        if cross.samedirection? edge_vector
          am.selection.add f1, f2
        end
      end
    end
  end
end
ConvexFaces.run

Can you be more specific about what sort of errant values you are seeing? Is this on the same model you shared earlier? If not can you please share the one that is showing issues?

BTW it’s not clear to me why you are processing every edge in the model. Surely there are many that don’t need to be labeled?

Your model has a ‘flipped’ face on the outside, and the ‘2nd floor’ face creates edges with more than two faces. These two issues make things difficult for the calculations.

Also, I think Math.asin(cross) or something similar will return the angle between the two faces in my code…

Apologies Dan, I have re-written my code.
Thanks Steve, looking at the attached I am ideally looking for the green circles for example to both say 270.
I am merely labelling all edges to check the logic works before I try and implement into a plugin.

Untitled.skp (214.4 KB)

No. sin(90 + x) == sin(90 - x), and sin(270 + x) == sin(270 - x). asin is not sufficient to resolve all quadrants.

That’s very strange. I get 270 for both of those edges when I do them one-by-one manually. Have you tried doing them one-by-one manually vs using your loop code?

Not the issue, but in your picture it looks like you have “Concave” and “Convex” swapped.

Yes I also get 270 for both when doing them individually, I wonder why this isn’t working when looping through them all

I found the problem: you copied the old version of my module into your code. Replace it with the latest one and the problem goes away.