Place circle at specific distance from endpoints of curve

Hi guys, I got a bit stuck again.
I’m trying to make a script which asks for user input to select an object and asks for a distance. Then I’d like to explode the curve and make a circle at that specific distance from the start and end point of each line. In the end I’d like to have a list of all those circles which I will use for further processing. Can anybody point me in the right direction and see what I’m doing wrong? Or general feedback on improving this is also welcome.

import rhinoscriptsyntax as rs

def PlaceCircles(curve,len,circles):
    length = len
    
    domain = rs.CurveDomain(curve)
    parameter = domain[0]+len
    parameter2 = domain[1]-len
    
    curvepoint = rs.EvaluateCurve(curve,parameter)
    curvepoint2 = rs.EvaluateCurve(curve,parameter2)
    
    circle = rs.AddCircle(curvepoint,3)
    circle2 = rs.AddCircle(curvepoint2,3)
    
    circles.append(circle)
    circles.append(circle2)

def CircleDist():
    objs = rs.GetObjects("Select objects",0,False,True)
    
    if not objs: return
    Distance = rs.GetReal("Distance from edge?")
    if not Distance: return
    
    for obj in objs:
        circles = []
        crvs = rs.ExplodeCurves(obj)
        
        for crv in crvs:
            circles = PlaceCircles(crv,Distance, circles)

CircleDist()

I wouldn’t use ‘len’ as a parameter as that is a keyword in python.

You assign circles = empty list, then you assign circles = PlaceCircles function while passing in circles.

I have to say it: ‘you are going around in circles’. :grin:

Since you are passing the list to the PlaceCircles function, you don’t need to grab the return value; indeed there isn’t one to grab. This works here:

import rhinoscriptsyntax as rs

def PlaceCircles(curve,distance,circles):
    length = distance
    
    domain = rs.CurveDomain(curve)
    print(domain)
    parameter = domain[0] + length
    parameter2 = domain[1] - length
    
    curvepoint = rs.EvaluateCurve(curve,parameter)
    curvepoint2 = rs.EvaluateCurve(curve,parameter2)
    
    circle = rs.AddCircle(curvepoint,3)
    circle2 = rs.AddCircle(curvepoint2,3)
    
    circles.append(circle)
    circles.append(circle2)

def CircleDist():
    objs = rs.GetObjects("Select objects",0,False,True)
    
    if not objs: return
    Distance = rs.GetReal("Distance from edge?")
    if not Distance: return
    
    for obj in objs:
        circles = []
        crvs = rs.ExplodeCurves(obj)
        
        for crv in crvs:
            PlaceCircles(crv,Distance, circles)
            
    print(circles)

CircleDist()

And results in:

image

The circles are in the circles list for further use.

One other thing I would investigate is if the domain of a curve is guaranteed to always be in document units. It seems to work for this example but it might be worth checking.

Good point.

True, I thought that was necessary for the circles list to be able to have the new items in it. Is there any downside by doing it like I did except for possible confusion for the programmer?

How do you check thath?

I’ve been trying this out and for a regular square example it does work now with your adjustments. But for the example in the file I attached below I get very weird results. Any ideas why that is? I put in a distance of 5 here.


190321 CircleDist problem shape.3dm (39.0 KB)

Hi,

You can try this :

import rhinoscriptsyntax as rs

def PlaceCircles(curve, distance):
    length = rs.CurveLength(curve)
    domain = rs.CurveDomain(curve)
    
    parameter =  domain[0]+distance/length*(domain[1]-domain[0])
    parameter2 = domain[1]-distance/length*(domain[1]-domain[0])
    
    curvepoint = rs.EvaluateCurve(curve, parameter)
    curvepoint2 = rs.EvaluateCurve(curve, parameter2)
    
    circle = rs.AddCircle(curvepoint, 3)
    circle2 = rs.AddCircle(curvepoint2, 3)
    
    circles.append((circle, circle2))

def CircleDist():
    objs = rs.GetObjects("Select objects", 0, False, True)
    
    if not objs: return
    distance = rs.GetReal("Distance from edge?")
    if not distance: return
    
    for obj in objs:
        crvs = rs.ExplodeCurves(obj)
        
        for crv in crvs:
            PlaceCircles(crv, distance)

circles = []
CircleDist()
print circles

This works, thanks!
But I think I’m not fully understanding that curvedomain thing.

The explanation here on what Domain does is not really helpful:
https://docs.mcneel.com/rhino/5/help/en-us/commands/domain.htm
"The Domain command reports the domain of a curve or surface.
A domain is the set of all possible input values to the function that defines the curve or surface."

That tells me nothing.

Domain is the measure of the extents of the “parameter space” of a curve or surface. It only has a relation to the actual length of of the curve in certain specific cases. A line of 10 units might have a domain of 0 to 10 - but you can’t count on that, it might be something else. For a line, the midpoint of the line will be the average of the domain start and end, but for other curves it will most likely not be.

If you need things spaced at specific distances along a curve that is not linear, you will need to use methods like rs.CurveLength() or rs.DivideCurveLength() to get true lengths/distances along the curve.

Domains and parameter space are very important when you get down into the nitty-gritty of evaluating curves and surfaces, the concept is a bit hard to get your head around at first… I think one of the better explanations was in David’s original Rhinoscript manual IIRC.

In relation to this:

If you imagine that a curve has a domain of say, 5.0 (start) to 25.0 (end), any “parameter” (a real number) between 5.0 and 25.0 will lie on that curve somewhere. The spacing of parameters along a curve is not linear except in the case of lines. In areas that are more highly curved, the parameters are “closer together”. Thus 15.0 - the mid-domain - will not necessarily be at the midpoint of the curve. It will however be somewhere on the curve between the start and end. A parameter of 0 or 30.0 will not be on the curve, as they’re outside of the domain interval.

1 Like

Thanks a lot for your explanation @Helvetosaur. Yeah I remember I’ve been reading more about this domain thing before. I get the concept of a mathematical domain but need to read a bit more on how it’s related to Nurbs curves in Rhino. Do I get it correctly if you’re referring to the information in this link: https://developer.rhino3d.com/guides/rhinoscript/primer-101/7-geometry/#77-nurbs-curves I get 157 matches when I search for “domain” on this page so I think this might give me some clarification.

This is what I don’t understand. I don’t get where it gets the start and end of the domain from. How come a curve not always starts at 0? Why/when are these weird values useful?

I think it depends on how the curves were made… If you just start drawing a simple curve, I think the domain goes from 0 to the curve length. However, if you have a segment of an exploded polycurve or a curve derived from a surface or trimmed, then it will inherit the domain of its parent. You can always “reparametrize” a curve to have any domain you want, or make the domain 0 to 1 which is often used in certain aspects when programming.

It is worth noting that in Python, camel case usually isn’t used for naming methods/functions, like PlaceCircles( ) or CircleDist( ). Instead, the convention is to use underscores and lower case notation (e.g. place_circles(), circle_dist()).
The use of camel case is mostly reserved for classes (i.e. MyClass).
Variable names follow the same conventions as function names.

Here’s a version of your script that works with multiple curve inputs and handles the remapping of your desired distances from the end points to the curve domains:

import rhinoscriptsyntax as rs


def fit(value, source_domain, target_domain):
    """Fits a number between a target domain that is relative to a number in the source domain.
    
    Args:
        value: number to be fitted
        source_domain: tuple containing the domain start and end
        target_domain: tuple containing the domain start and end
    
    Returns:
        The refitted value, or None, if the initial value is outside the source domain.
    """
    if (value < source_domain[0]) or (value > source_domain[1]):
        return
    else:
        source_range = source_domain[1] - source_domain[0]
        if source_range == 0:
            fitted_value = target_domain[0]
        else:
            target_range = target_domain[1] - target_domain[0]
            fitted_value = (((value - source_domain[0]) * target_range) / source_range) + target_domain[0]
        return fitted_value


def place_circles(curve, distance):
    """Places circles at a specific distance from end points of a curve.
    
    Args:
      curve: A curve to place circles on.
      distance: A distance from the end points to place circles at.
    """
    # Get the length of the curve
    curve_length = rs.CurveLength(curve)
    
    if distance > curve_length:
        raise ValueError("The distance (%.2f) cannot be bigger than the curve length (%.2f)." \
                        %(distance, curve_length))
    elif distance < 0.0:
        raise ValueError("The distance (%.2f) cannot be a negative number." %(distance))
    else:
        # Get the curve domain
        curve_domain = rs.CurveDomain(curve)
        # Remap the desired distance to the curve domain
        remapped_distance = fit(distance, (0.0, curve_length), curve_domain)
        # Remap the remapped distance to the normalized curve domain from 0.0 to 1.0
        normalized = fit(remapped_distance, curve_domain, (0.0, 1.0))
        normalized2 = fit(remapped_distance, curve_domain, (1.0, 0.0))
        # Convert the normalized curve parameter to a curve parameter within the curve domain
        curve_parameter = rs.CurveParameter(curve, normalized)
        curve_parameter2 = rs.CurveParameter(curve, normalized2)
        # Get the curve points for each curve parameter
        curve_point = rs.EvaluateCurve(curve, curve_parameter)
        curve_point2 = rs.EvaluateCurve(curve, curve_parameter2)
        # Create the circles
        circle = rs.AddCircle(curve_point, 3)
        circle2 = rs.AddCircle(curve_point2, 3) 


def main():
    """Places circles at a specific distances from end points of multiple curves."""
    # Get a single curve or a collection of curves
    object_ids = rs.GetObjects("Select some curves", rs.filter.curve)
    if not object_ids: return
    
    cleanup = [] # list for guids to cleanup at the end
    for i, obj_id in enumerate(object_ids): # loop each curve
        # Get the current curve colour
        current_colour = rs.ObjectColor(obj_id)
        # Display the currently selected curve yellow
        rs.ObjectColor(obj_id, (255, 255, 0))
        # Ask for the distance input for each curve individually
        if len(object_ids) > 1:
            distance = rs.GetReal("Selected curve %d distance from edge" %(i+1))
        else:
            distance = rs.GetReal("Selected curve distance from edge")
        # Return the currently selected curve to its original colour
        rs.ObjectColor(obj_id, current_colour)
        if not distance: return

        # Explode the curve
        curve_segment_ids = rs.ExplodeCurves(obj_id)
        # Loop each curve or curve segment and place circles
        for j, curve_id in enumerate(curve_segment_ids):
            cleanup.append(curve_id)
            try:
                # Try placing the circles
                place_circles(curve_id, distance)
            except ValueError as err:
                # Prints the errors raised by place_circles()
                if len(object_ids) > 1:
                    print "Selected curve %d, segment %d: %s" %(i+1, j+1, err)
                else:
                    print "Selected curve: %s" %(err)
    # Cleanup superfluous curve segments from the scene
    for guid in cleanup:
        rs.DeleteObject(guid)


if __name__ == "__main__":
    main()

You can imagine the domain of a curve as the distance between its endpoints. For a line or a simple curve this is mostly a domain from 0.0 to the curve length. For a polyline, a curve defined by multiple line segments, each segment has its own domain. The first one might have the domain 0.0 to its length, the second segment follows with the length of the previous segment, as its start domain, and the length of the previous segment added to its own length as its end domain, and so fourth.

In your script, you can simply remap your distance value to the curve domain. You know that your desired distance lies within the domain 0.0 to curve length, since that is how we humans understand curves. So you need to remap the distance within that domain, to the curves actual domain, which might for example be 36.456 to 52.136.
Now, that you have your distance in the curve domain 36.456 to 52.136, you can remap it again to a normalised curve domain from 0.0 to 1.0. We need to take this extra step, since the rs.CurveParameter(curve_id, normalized_parameter) needs a normalized parameter to work. With the resulting curve parameter you can now evaluate the curve for a point.

If you’re interested in learning more about the Python naming conventions, you can check out the very good Google Python styleguide or the already above linked, standard PEP8 styleguide.

1 Like

Very useful feedback and explanation, thanks!

Interesting. I haven’t had the opportunity to get into Python yet. I’m curious if there is some compelling reason that Python had to depart from several decades of camel casing in preceding languages, or was it just departure for novelty’s sake? I ask here because you seem to be pretty knowledgable.

When you pass the list (really a reference to the list) as a parameter, the receiving function can access the list. So when you append to it, the calling function knows about the append, because they are both looking at the same list. I think it is a valid method that works…I’m not against it, and pretty sure I see it used often, but not sure if everyone likes it. In Python ‘everything is an object’, so stuff gets passed around a lot.

Looks like others have expounded on the curve domain subject, so I will offer this instead:

If you are willing to cross into the realm of the rhinocommon, there is a function that will figure this out for you:

https://developer.rhino3d.com/api/RhinoCommon/html/M_Rhino_Geometry_Curve_LengthParameter.htm

I wrapped it in the function ‘parameter_from_length’ and integrated with your first code:

import scriptcontext as sc
import rhinoscriptsyntax as rs
import Rhino as R



def parameter_from_length(curve_guid, length):
    # get the geometery object from the guid
    curve_geo = rs.coercecurve(curve_guid)
    # this function returns 2 values
    ok, t_parameter = curve_geo.LengthParameter(length)
    if ok:
        return t_parameter
    else:
        return None

def CircleDist():
    objs = rs.GetObjects("Select objects",0,False,True)
    
    if not objs: return
    Distance = rs.GetReal("Distance from edge?")
    if not Distance: return
    
    circles = []
    for obj in objs:
        crvs = rs.ExplodeCurves(obj)
        for crv in crvs:
            length = rs.CurveLength(crv)
            # we want the length from start of curve to where the end circle should be
            end_length = length - Distance  # Should check here it is +
            
            # get the curve domain parameter that corresponds to Distance from start of curve
            start_t = parameter_from_length(crv, Distance)
            # get the curve domain parameter that corresponds to Distance away from end of curve
            end_t = parameter_from_length(crv, end_length)
            
            # add the circles
            if start_t:
                start_point = rs.EvaluateCurve(crv, start_t)
                circles.append(rs.AddCircle(start_point, 3))
            
            if end_t:
                end_point = rs.EvaluateCurve(crv, end_t)
                circles.append(rs.AddCircle(end_point, 3))
            
            
CircleDist()

It worked as expected on your problem curve.

Edit:

There is an even better function:

https://developer.rhino3d.com/api/RhinoCommon/html/M_Rhino_Geometry_Curve_PointAtLength.htm

That will give you a point3d instead of curve parameter. Here is a less rs.-y example, it doesn’t add the curve segments to the doc, which I’m guessing you probably don’t need:

def CircleDist():
    guids = rs.GetObjects("Select objects",0,False,True)
    if not guids: return
    
    distance = rs.GetReal("Distance from edge?")
    if not distance: return
    
    # we will put circle objects in this list, not the guids
    circles = []
    for guid in guids:
        curve_geo = rs.coercecurve(guid)
        segments = curve_geo.DuplicateSegments()
        for segment in segments:
            if distance <= segment.GetLength():
                start = segment.PointAtLength(distance)
                end = segment.PointAtLength(segment.GetLength()-distance)
                circles.append(R.Geometry.Circle(start, 10))
                circles.append(R.Geometry.Circle(end, 10))
    # in case you need the guids for something
    circle_guids = []
    # speedy drawing
    sc.doc.Views.RedrawEnabled = False
    for circle in circles:
        circle_guids.append(sc.doc.Objects.AddCircle(circle))
    sc.doc.Views.RedrawEnabled = True
    
    return circle_guids

CircleDist()

You’re welcome!

Haha, thanks @AlW, but I have no clue. Python isn’t that novel though. It was released to the public in the 1990s (whereas C++ is from the 80s and C from the 70s). The, in the Grasshopper community, very popular and much praised, but proprietary C# is even younger. It stems from the 2000s.