Classify the curves into inside curves and bounding curves

I am writing a script to classify nested curves. The goal is to identify the outer bounding curves (the red contours in the attached picture) and the inside curves (the blue ones).

My current script works, but it becomes very slow (or ‘laggy’) when processing a large number of objects. How can I optimize this script for better performance?

-- coding: utf-8 --

import rhinoscriptsyntax as rs
import scriptcontext as sc
import Rhino
import math

def cpcs_filt(rhino_object, geometry, component_index):
try:
if rs.IsCurve(rhino_object.Id) and rs.IsCurveClosed(rhino_object.Id) and rs.IsCurvePlanar(rhino_object.Id):
return True
except Exception:
pass
return False

def to_plane_xy(pt, plane):
v = pt - plane.Origin
x = v.X * plane.XAxis.X + v.Y * plane.XAxis.Y + v.Z * plane.XAxis.Z
y = v.X * plane.YAxis.X + v.Y * plane.YAxis.Y + v.Z * plane.YAxis.Z
return (x, y)

def point_in_polygon_strict(x, y, poly):
“”“Strict point-in-polygon: True only if strictly inside (not on edge/vertex).”“”
n = len(poly)
if n < 4:
return False
eps = 1e-9
j = n - 1
inside = False
for i in range(n):
xi, yi = poly[i]
xj, yj = poly[j]
# on vertex
if abs(x - xi) < eps and abs(y - yi) < eps:
return False
# on edge (colinear + between)
dx1 = x - xi; dy1 = y - yi
dx2 = xj - xi; dy2 = yj - yi
cross = dx1 * dy2 - dy1 * dx2
if abs(cross) < 1e-9:
if (min(xi,xj) - eps <= x <= max(xi,xj) + eps) and (min(yi,yj) - eps <= y <= max(yi,yj) + eps):
return False
# ray cast
if ((yi > y) != (yj > y)) and (x < (xj - xi) * (y - yi) / (yj - yi + 0.0) + xi):
inside = not inside
j = i
return inside

def sample_curve_points(crv, sample_count):
pts = 

try:
params = crv.DivideByCount(sample_count, True)
if params:
pts = [crv.PointAt(t) for t in params]
else:
dom = crv.Domain
pts = [crv.PointAt(dom.T0 + (dom.Length * i / float(sample_count))) for i in range(sample_count + 1)]
except Exception:
dom = crv.Domain
pts = [crv.PointAt(dom.T0 + (dom.Length * i / float(sample_count))) for i in range(sample_count + 1)]
return pts

def classify_by_nesting_parity(guids,sample_count=160):

items = []
for g in guids:
    crv = rs.coercecurve(g)
    if not crv or not crv.IsClosed:
        continue
    ok, plane = crv.TryGetPlane()
    if not ok:
        plane = Rhino.Geometry.Plane.WorldXY
    items.append((g, crv, plane))

if not items:
    print("No valid closed planar curves.")
    return [], []

# Build sampled polygon (in its own plane coords) and also keep sampled 3D points
polygons = {}  # guid -> (poly2_coords_list, plane, sample_pts3)
for g, crv, plane in items:
    pts3 = sample_curve_points(crv, sample_count)
    poly2 = [to_plane_xy(p, plane) for p in pts3]
    if poly2 and poly2[0] != poly2[-1]:
        poly2.append(poly2[0])
    polygons[g] = (poly2, plane, pts3)

inside = []
bounding = []

# For each curve A count how many other curves strictly contain it (all samples of A inside B)
total = len(items)
for idx, (gA, crvA, plA) in enumerate(items):
    # use the sample points already computed for A (if present)
    if gA in polygons:
        ptsA = polygons[gA][2]
    else:
        ptsA = sample_curve_points(crvA, sample_count)

    container_count = 0
    for gB, (polyB, plB, _) in polygons.items():
        if gB == gA:
            continue
        if not polyB:
            continue
        # test all sample points of A in B's polygon (project each point to B plane)
        all_inside = True
        for p in ptsA:
            x, y = to_plane_xy(p, plB)
            if not point_in_polygon_strict(x, y, polyB):
                all_inside = False
                break
        if all_inside:
            container_count += 1

    # classification by parity: odd -> inside; even -> bounding
    if (container_count % 2) == 1:
        inside.append(gA)
        try:
            rs.ObjectColor(gA, (0, 255, 0))
        except: pass
    else:
        bounding.append(gA)
        try:
            rs.ObjectColor(gA, (255, 0, 0))
        except: pass

print("Classified: inside = {}, bounding = {}  (sample_count={})".format(len(inside), len(bounding), sample_count))
return inside, bounding

def main():
guids = rs.GetObjects(“Select closed planar curves (filtered)”, rs.filter.curve,
custom_filter=cpcs_filt, preselect=True)
if not guids:
print(“No curves selected.”)
return 
, 

rs.EnableRedraw(False)
classify_by_nesting_parity(guids)
rs.EnableRedraw(True)

if name == “main”:
main()

I wrote something similar a number of years ago to try and automatically color nested curves for out laser cutters based oon the level of nesting. What I see from your image is that you want to classify the ‘outer bounding’ curves as red, but you show some of the inner curves as red also:

Certainly, the inner red curves also contain other other (green) curves, but some inner green curves also contain other curves. So I don’t understand how you want that to work…

Some rhinoscriptsyntax/RhinoCommon functions to look at that may reduce your code:

rhinoscriptsyntax
rs.PointInPlanarClosedCurve(point, curve)
rs.PlanarClosedCurveContainment(curve1, curve2)
rs.PlanarCurveCollision(curve1, curve2)

RhinoCommon
Rhino.Geometry.Curve.Contains
https://developer.rhino3d.com/api/rhinocommon/rhino.geometry.curve/contains
PlanarClosedCurveRelationship
https://developer.rhino3d.com/api/rhinocommon/rhino.geometry.curve/planarclosedcurverelationship#(curve,curve,plane,double)
PlanarCurveCollision
https://developer.rhino3d.com/api/rhinocommon/rhino.geometry.curve/planarcurvecollision

The most important thing for speed is being able to rapidly eliminate candidates that don’t match the containment criteria - for example when a curve is clearly oustide another. More advanced scripters/programmers than me would probably use an rTree (which I don’t know how to set up). Me, I first ran collision to see if the curves intersected (eliminate right away) and then for example all I needed to do was test one point on the curve for inside or outside…

Then also try to make sure you don’t classify curves as ‘contained’ more than once. A curve might be contained by multiple other curves, but it gets removed when the first containment is detected so it doesn’t need to be tested against any others…

Anyway, that’s all I have for now, my script was still not all that fast with a lot of curves because of the many testing iterations, but fortunately the files we were working on at the time mostly did not have thousands of objects.

Edit - I guess I should add that there will likely be someone here who has already ‘Grasshoppered’ this… or is working on it now.

you mean spaghettitized ? ;-D

dear @theanhkc07

@Helvetosaur already outlines the most important functions / references.

how often will you run the script, how many curves are involved ?
will this become part of daily workflows for many years or just …
→ do you really need a performance optimzied script ?
→ do you need error checking - for example when 2 closed curves intersect ?

dear @Helvetosaur @Tom_P
I followed Helvetosaur’s advice and rewrote the script with the help of ChatGPT. The new code is significantly faster than my initial version. I typically work with about 500 nested curves quite frequently. The new code takes around 5 seconds, whereas the old code took about 20 seconds, and the time increases exponentially as the number of curves grows. Here is my new code. Could everyone please take a look and see if there are any ways to further optimize the calculation time? Thank you all.

# -*- coding: utf-8 -*-
import rhinoscriptsyntax as rs
import Rhino


def cpcs_filt(rhino_object, geometry, component_index):
    try:
        if rs.IsCurve(rhino_object.Id) and rs.IsCurveClosed(rhino_object.Id) and rs.IsCurvePlanar(rhino_object.Id):
            return True
    except Exception:
        pass
    return False


def to_plane_xy(pt, plane):
    v = pt - plane.Origin
    x = v.X * plane.XAxis.X + v.Y * plane.XAxis.Y + v.Z * plane.XAxis.Z
    y = v.X * plane.YAxis.X + v.Y * plane.YAxis.Y + v.Z * plane.YAxis.Z
    return (x, y)


def point_in_polygon_strict(x, y, poly):
    n = len(poly)
    if n < 4:
        return False
    eps = 1e-9
    inside = False
    j = n - 1
    for i in range(n):
        xi, yi = poly[i]
        xj, yj = poly[j]
        if abs(x - xi) < eps and abs(y - yi) < eps:
            return False
        dx1 = x - xi; dy1 = y - yi
        dx2 = xj - xi; dy2 = yj - yi
        cross = dx1 * dy2 - dy1 * dx2
        if abs(cross) < eps:
            if (min(xi, xj) - eps <= x <= max(xi, xj) + eps) and (min(yi, yj) - eps <= y <= max(yi, yj) + eps):
                return False
        if ((yi > y) != (yj > y)):
            x_inter = (xj - xi) * (y - yi) / (yj - yi + 0.0) + xi
            if x < x_inter:
                inside = not inside
        j = i
    return inside


def sample_curve_points(crv, sample_count):
    pts = []
    try:
        params = crv.DivideByCount(sample_count, True)
        if params:
            pts = [crv.PointAt(t) for t in params]
        else:
            dom = crv.Domain
            pts = [crv.PointAt(dom.T0 + (dom.Length * float(i) / float(sample_count))) for i in range(sample_count + 1)]
    except Exception:
        dom = crv.Domain
        pts = [crv.PointAt(dom.T0 + (dom.Length * float(i) / float(sample_count))) for i in range(sample_count + 1)]
    return pts


def bbox_inflate(bbox, tol):
    if not bbox.IsValid:
        return bbox
    minp = Rhino.Geometry.Point3d(bbox.Min.X - tol, bbox.Min.Y - tol, bbox.Min.Z - tol)
    maxp = Rhino.Geometry.Point3d(bbox.Max.X + tol, bbox.Max.Y + tol, bbox.Max.Z + tol)
    return Rhino.Geometry.BoundingBox(minp, maxp)


def bbox_contains(small, big, eps=1e-9):
    if not (small.IsValid and big.IsValid):
        return False
    return (big.Min.X - eps <= small.Min.X and big.Min.Y - eps <= small.Min.Y and big.Min.Z - eps <= small.Min.Z
            and big.Max.X + eps >= small.Max.X and big.Max.Y + eps >= small.Max.Y and big.Max.Z + eps >= small.Max.Z)


def bbox_intersects(b1, b2, eps=1e-9):
    """Axis-aligned bounding-box intersection test (AABB).
    Returns True if b1 and b2 intersect. Works across RhinoCommon versions.
    """
    if not (b1.IsValid and b2.IsValid):
        return False
    if b1.Max.X < b2.Min.X - eps or b1.Min.X > b2.Max.X + eps:
        return False
    if b1.Max.Y < b2.Min.Y - eps or b1.Min.Y > b2.Max.Y + eps:
        return False
    if b1.Max.Z < b2.Min.Z - eps or b1.Min.Z > b2.Max.Z + eps:
        return False
    return True


def classify_by_nesting_rtree(guids, sample_count=160, bbox_inflate_tol=1e-6):
    items = []
    for g in guids:
        crv = rs.coercecurve(g)
        if not crv or not crv.IsClosed:
            continue
        ok, plane = crv.TryGetPlane()
        if not ok:
            plane = Rhino.Geometry.Plane.WorldXY
        bbox = crv.GetBoundingBox(True)
        items.append({'id': g, 'crv': crv, 'plane': plane, 'bbox': bbox})

    if not items:
        print("No valid closed planar curves.")
        return [], []

    # Build R-Tree
    try:
        rtree = Rhino.Geometry.RTree()
        for i, it in enumerate(items):
            bb = bbox_inflate(it['bbox'], bbox_inflate_tol)
            rtree.Insert(bb, i)
        use_rtree = True
    except Exception:
        rtree = None
        use_rtree = False

    # Precompute samples + 2D polygons
    for it in items:
        crv = it['crv']
        plane = it['plane']
        pts3 = sample_curve_points(crv, sample_count)
        poly2 = [to_plane_xy(p, plane) for p in pts3]
        if poly2 and poly2[0] != poly2[-1]:
            poly2.append(poly2[0])
        it['sample_pts'] = pts3
        it['poly2'] = poly2

    inside = []
    bounding = []

    for idxA, A in enumerate(items):
        ptsA = A['sample_pts']
        bboxA = bbox_inflate(A['bbox'], bbox_inflate_tol)

        candidate_indexes = []
        if use_rtree:
            hits = []
            # The RTree.Search callback signature differs across RhinoCommon versions.
            # Try several patterns but always collect integer indexes.
            try:
                def cb(args):
                    # RhinoCommon sometimes passes an event args with Id or Index
                    try:
                        hits.append(args.Id)
                    except Exception:
                        try:
                            hits.append(args.Index)
                        except Exception:
                            # older API might pass (id, box, userdata)
                            pass
                    return True
                rtree.Search(bboxA, cb)
            except Exception:
                # fallback: alternate signature
                try:
                    hits2 = []
                    rtree.Search(bboxA, lambda id, box, userdata: (hits2.append(id) or True))
                    hits = hits2
                except Exception:
                    # give up on rtree-search and consider all indexes
                    hits = list(range(len(items)))
            candidate_indexes = [h for h in hits if isinstance(h, int) and h != idxA]
        else:
            candidate_indexes = [i for i in range(len(items)) if i != idxA]

        # refine candidates by bbox containment/intersection using AABB functions
        refined = []
        for idxB in candidate_indexes:
            bbB = items[idxB]['bbox']
            # cheap world-axis containment test
            if bbox_contains(A['bbox'], bbB):
                refined.append(idxB)
            else:
                # use AABB intersection fallback
                if bbox_intersects(A['bbox'], bbB):
                    refined.append(idxB)

        container_count = 0
        for idxB in refined:
            B = items[idxB]
            polyB = B['poly2']
            plB = B['plane']
            if not polyB:
                continue
            all_inside = True
            for p in ptsA:
                x, y = to_plane_xy(p, plB)
                if not point_in_polygon_strict(x, y, polyB):
                    all_inside = False
                    break
            if all_inside:
                container_count += 1

        if (container_count % 2) == 1:
            inside.append(A['id'])
            try:
                rs.ObjectColor(A['id'], (0, 255, 0))
            except Exception:
                pass
        else:
            bounding.append(A['id'])
            try:
                rs.ObjectColor(A['id'], (255, 0, 0))
            except Exception:
                pass

    print("Classified (rtree used={}): inside = {}, bounding = {}  (sample_count={})".format(use_rtree, len(inside), len(bounding), sample_count))
    return inside, bounding


def main():
    guids = rs.GetObjects("Select closed planar curves (filtered)", filter=rs.filter.curve, preselect=True, custom_filter=cpcs_filt)
    if not guids:
        print("No curves selected.")
        return

    rs.EnableRedraw(False)
    try:
        classify_by_nesting_rtree(guids, sample_count=160, bbox_inflate_tol=1e-6)
    finally:
        rs.EnableRedraw(True)

if __name__ == "__main__":
    main()

can you upload a sample set of curves please ? i might find time to play around with them.
Just to mention: 50 seconds is wonderful to do a small exercise for your back and look outside the window - which is good for your brain and eyes ;-D

Here is a basic sample file with mortise holes that I am using to test the script.
test.3dm (274.1 KB)

My old script runs in about 1.4 seconds on your file here…

ColorNestedInsideOutside.py (5.3 KB)

(not bad for an eight-year-old script... :winking_face_with_tongue: )

Note also that it is mostly written in rhinoscriptsyntax. I imagine it could be possibly optimized further if I re-wrote it in RhinoCommon.

Thanks a lot Mitch. I will base on your code to try to optimize more with RhinoCommon.

i checked an older plug-in project (sorry - can t share) - that is a toolset for a laser-cutting company. One tool sorts the cutting order - “inner geometry first”. I only use
Curve.PlanarClosedCurveRelationship
and it s quite fast.

All sorting decisions are only made with Curve.PlanarClosedCurveRelationship
and your task takes 265ms for test.3dm file.

hier is a more difficult challenge:
293 curves that are organised in several levels of nesting.

sort_nested_testCrvs_293x.3dm (2.1 MB)

not fully optimized sorting here takes
Average on 10 random runs: 1831 ms

make sure, if you need precise performance values to randomly sort the input list, and run with a few different random sets of the same curves. For most (all?) sorting algorithms there are worst case inputs …

have fun coding - kind regards - tom