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 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.
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.
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
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.
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 …
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)
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
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.)
AllNumeric subclasses will inherit these methods.
Ie, … I do not have your Float class mofication and at my Ruby Console …
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.
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.
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.