I asked ChatGPT to draw spiral stairs with Grasshopper

It did a pretty good job considering all the parameters. The script went trough a few iterations which took around 1 hour of work.
It’s impressive how well it works for architectural elements such as this one.

import Rhino.Geometry as rg
import math

# Inputs:
# Center         : Point3d
# Radius         : float
# PoleRadius     : float
# TotalHeight    : float
# Turns          : float
# RiserHeight    : float
# StringerHeight : float
# HandrailHeight : float
# HandrailRadius : float
# PostRadius     : float

# Outputs:
# Steps
# Pole
# Stringer
# Handrail
# Posts
# Debug

Steps = []
Posts = []
Pole = None
Stringer = None
Handrail = None
Debug = []

tol = 0.01
clearance = Radius * 0.001

if Center is None:
    Center = rg.Point3d.Origin

if Radius is None or Radius <= 0:
    Debug.append("Radius must be larger than 0.")

elif PoleRadius is None or PoleRadius <= 0:
    Debug.append("PoleRadius must be larger than 0.")

elif TotalHeight is None or TotalHeight <= 0:
    Debug.append("TotalHeight must be larger than 0.")

elif Turns is None or Turns <= 0:
    Debug.append("Turns must be larger than 0.")

elif RiserHeight is None or RiserHeight <= 0:
    Debug.append("RiserHeight must be larger than 0.")

elif StringerHeight is None or StringerHeight <= 0:
    Debug.append("StringerHeight must be larger than 0.")

elif HandrailHeight is None or HandrailHeight <= 0:
    Debug.append("HandrailHeight must be larger than 0.")

elif HandrailRadius is None or HandrailRadius <= 0:
    Debug.append("HandrailRadius must be larger than 0.")

elif PostRadius is None or PostRadius <= 0:
    Debug.append("PostRadius must be larger than 0.")

elif PoleRadius + clearance >= Radius:
    Debug.append("PoleRadius plus clearance must be smaller than Radius.")

else:
    step_count = int(math.ceil(TotalHeight / RiserHeight))
    actual_riser = TotalHeight / step_count

    total_angle = Turns * 2.0 * math.pi
    angle_step = total_angle / step_count

    outer_radius = Radius
    inner_radius = PoleRadius + clearance

    step_thickness = actual_riser * 0.15
    handrail_path_radius = outer_radius

    Debug.append("Step count: {}".format(step_count))
    Debug.append("Actual riser height: {:.2f}".format(actual_riser))
    Debug.append("Inner radius: {:.2f}".format(inner_radius))
    Debug.append("Outer radius: {:.2f}".format(outer_radius))

    # ------------------------------------------------------------
    # STEPS + POSTS
    # ------------------------------------------------------------

    for i in range(step_count):
        z = Center.Z + i * actual_riser

        a0 = i * angle_step
        a1 = (i + 1) * angle_step
        amid = (a0 + a1) * 0.5

        p0 = rg.Point3d(Center.X + inner_radius * math.cos(a0), Center.Y + inner_radius * math.sin(a0), z)
        p1 = rg.Point3d(Center.X + outer_radius * math.cos(a0), Center.Y + outer_radius * math.sin(a0), z)
        p2 = rg.Point3d(Center.X + outer_radius * math.cos(a1), Center.Y + outer_radius * math.sin(a1), z)
        p3 = rg.Point3d(Center.X + inner_radius * math.cos(a1), Center.Y + inner_radius * math.sin(a1), z)

        crv = rg.Polyline([p0, p1, p2, p3, p0]).ToNurbsCurve()
        breps = rg.Brep.CreatePlanarBreps(crv)

        if breps and len(breps) > 0:
            tread = breps[0]
            path = rg.LineCurve(p0, p0 + rg.Vector3d(0, 0, -step_thickness))
            step = tread.Faces[0].CreateExtrusion(path, True)

            if step:
                Steps.append(step)

        px = Center.X + handrail_path_radius * math.cos(amid)
        py = Center.Y + handrail_path_radius * math.sin(amid)

        base_pt = rg.Point3d(px, py, z)
        post_height = HandrailHeight + (actual_riser * 0.5) + HandrailRadius

        post_circle = rg.Circle(rg.Plane(base_pt, rg.Vector3d.ZAxis), PostRadius)
        post_surface = rg.Surface.CreateExtrusion(
            post_circle.ToNurbsCurve(),
            rg.Vector3d(0, 0, post_height)
        )

        if post_surface:
            post = post_surface.ToBrep()
            post = post.CapPlanarHoles(tol)
            Posts.append(post)

    # ------------------------------------------------------------
    # CENTRAL POLE
    # ------------------------------------------------------------

    pole_circle = rg.Circle(
        rg.Plane(rg.Point3d(Center.X, Center.Y, Center.Z), rg.Vector3d.ZAxis),
        PoleRadius
    )

    pole_surface = rg.Surface.CreateExtrusion(
        pole_circle.ToNurbsCurve(),
        rg.Vector3d(0, 0, TotalHeight)
    )

    if pole_surface:
        Pole = pole_surface.ToBrep()
        Pole = Pole.CapPlanarHoles(tol)

    # ------------------------------------------------------------
    # EXTERIOR STRINGER
    # ------------------------------------------------------------

    top_pts = []
    bottom_pts = []

    samples = max(step_count * 8, 64)

    for j in range(samples + 1):
        t = float(j) / float(samples)
        angle = t * total_angle

        z_top = Center.Z + t * TotalHeight
        z_bottom = z_top - StringerHeight

        x = Center.X + outer_radius * math.cos(angle)
        y = Center.Y + outer_radius * math.sin(angle)

        top_pts.append(rg.Point3d(x, y, z_top))
        bottom_pts.append(rg.Point3d(x, y, z_bottom))

    top_crv = rg.Curve.CreateInterpolatedCurve(top_pts, 3)
    bottom_crv = rg.Curve.CreateInterpolatedCurve(bottom_pts, 3)

    if top_crv and bottom_crv:
        lofts = rg.Brep.CreateFromLoft(
            [top_crv, bottom_crv],
            rg.Point3d.Unset,
            rg.Point3d.Unset,
            rg.LoftType.Normal,
            False
        )

        if lofts and len(lofts) > 0:
            Stringer = lofts[0]

    # ------------------------------------------------------------
    # HANDRAIL
    # ------------------------------------------------------------

    rail_pts = []

    for j in range(samples + 1):
        t = float(j) / float(samples)
        angle = t * total_angle

        z = Center.Z + t * TotalHeight + HandrailHeight

        x = Center.X + handrail_path_radius * math.cos(angle)
        y = Center.Y + handrail_path_radius * math.sin(angle)

        rail_pts.append(rg.Point3d(x, y, z))

    rail_crv = rg.Curve.CreateInterpolatedCurve(rail_pts, 3)

    if rail_crv:
        pipe = rg.Brep.CreatePipe(
            rail_crv,
            HandrailRadius,
            False,
            rg.PipeCapMode.Round,
            True,
            tol,
            tol
        )

        if pipe and len(pipe) > 0:
            Handrail = pipe[0]

Probably stole parts of my GH definition from more than 10 years ago…

@Helvetosaur :laughing: could be

But most likely it just read the API docs.

now ask it to create a:

  • winding double-u stair, with
  • minimum 3" inside tread length, with
  • closed stringer, with
  • sliders that adjust the top and bottom tread widths, with
  • custom handrail profile from to-scale referenced curve in rhino, with
  • gooseneck transition on interior handrail

i uhh… need it for a friend… :wink:

@BTH , somethig like this?

import Rhino.Geometry as rg
import Rhino
import math

# Inputs:
# OuterU
# InnerU
# MaxHeight
# RiserHeight
# StringerHeight
# HandrailHeight
# BalusterGap

# Outputs:
# Treads
# Risers
# RiserLines
# InnerStringer
# OuterStringer
# Stringers
# InnerHandrail
# OuterHandrail
# Handrails
# Balusters
# BalusterSpaced
# Stair
# Info

Treads = []
Risers = []
RiserLines = []
InnerStringer = []
OuterStringer = []
Stringers = []
InnerHandrail = None
OuterHandrail = None
Handrails = []
Balusters = []
BalusterSpaced = []
Stair = []
Info = ""

tol = Rhino.RhinoDoc.ActiveDoc.ModelAbsoluteTolerance if Rhino.RhinoDoc.ActiveDoc else 0.001


def pt_and_t_at_factor(crv, f):
    f = max(0.0, min(1.0, f))
    ok, t = crv.LengthParameter(crv.GetLength() * f)
    if not ok:
        t = crv.Domain.ParameterAt(f)
    return crv.PointAt(t), t


def make_planar(points):
    clean = []
    for p in points:
        if len(clean) == 0 or clean[-1].DistanceTo(p) > tol:
            clean.append(p)

    if len(clean) < 3:
        return None

    if clean[0].DistanceTo(clean[-1]) > tol:
        clean.append(clean[0])

    breps = rg.Brep.CreatePlanarBreps(rg.Polyline(clean).ToNurbsCurve(), tol)
    if breps and len(breps) > 0:
        return breps[0]

    return None


def lerp_angle(a, b, t):
    diff = b - a
    while diff > math.pi:
        diff -= 2.0 * math.pi
    while diff < -math.pi:
        diff += 2.0 * math.pi
    return a + diff * t


def intersect_ray_with_curve(origin, direction, crv):
    direction.Unitize()

    bbox = crv.GetBoundingBox(True)
    diag = bbox.Diagonal.Length
    if diag <= 0:
        diag = 10000

    ray = rg.Line(origin, origin + direction * diag * 10.0)
    ray_crv = rg.LineCurve(ray)

    events = rg.Intersect.Intersection.CurveCurve(ray_crv, crv, tol, tol)

    if events is None or events.Count == 0:
        return None, None

    best_pt = None
    best_t = None
    best_dist = None

    for e in events:
        pt = e.PointA
        v = pt - origin

        if v * direction <= 0:
            continue

        d = origin.DistanceTo(pt)

        if best_dist is None or d < best_dist:
            best_dist = d
            best_pt = pt
            best_t = e.ParameterB

    return best_pt, best_t


def curve_segment_points(crv, t0, t1, z, reverse_result=False):
    pts = []

    reverse_param = False
    if t1 < t0:
        t0, t1 = t1, t0
        reverse_param = True

    pts.append(crv.PointAt(t0))

    ok, pl = crv.TryGetPolyline()
    if ok:
        mids = []
        for p in pl:
            ok_cp, t = crv.ClosestPoint(p)
            if ok_cp and t > t0 + tol and t < t1 - tol:
                mids.append((t, p))

        mids.sort(key=lambda x: x[0])

        for t, p in mids:
            pts.append(p)
    else:
        samples = 8
        for k in range(1, samples):
            a = float(k) / float(samples)
            t = t0 + (t1 - t0) * a
            pts.append(crv.PointAt(t))

    pts.append(crv.PointAt(t1))

    if reverse_param:
        pts.reverse()

    if reverse_result:
        pts.reverse()

    return [rg.Point3d(p.X, p.Y, z) for p in pts]


def curve_segment_points_sloped(crv, t0, t1, z0, z1, reverse_result=False):
    raw = []

    reverse_param = False
    if t1 < t0:
        t0, t1 = t1, t0
        z0, z1 = z1, z0
        reverse_param = True

    raw.append((t0, crv.PointAt(t0)))

    ok, pl = crv.TryGetPolyline()
    if ok:
        mids = []
        for p in pl:
            ok_cp, t = crv.ClosestPoint(p)
            if ok_cp and t > t0 + tol and t < t1 - tol:
                mids.append((t, p))

        mids.sort(key=lambda x: x[0])

        for t, p in mids:
            raw.append((t, p))
    else:
        samples = 8
        for k in range(1, samples):
            a = float(k) / float(samples)
            t = t0 + (t1 - t0) * a
            raw.append((t, crv.PointAt(t)))

    raw.append((t1, crv.PointAt(t1)))

    if reverse_param:
        raw.reverse()

    pts = []

    for t, p in raw:
        if abs(t1 - t0) <= tol:
            a = 0.0
        else:
            a = (t - t0) / (t1 - t0)

        z = z0 + (z1 - z0) * a
        pts.append(rg.Point3d(p.X, p.Y, z))

    if reverse_result:
        pts.reverse()

    return pts


def make_stringer_straight_cut(crv, params, height, actual_riser):
    panels = []

    if height <= 0:
        return panels

    if params is None or len(params) < 2:
        return panels

    for i in range(len(params) - 1):
        z0 = actual_riser * i
        z1 = actual_riser * (i + 1)

        top_pts = curve_segment_points_sloped(crv, params[i], params[i + 1], z0, z1)

        for j in range(len(top_pts) - 1):
            a = top_pts[j]
            b = top_pts[j + 1]

            a_low = rg.Point3d(a.X, a.Y, a.Z - height)
            b_low = rg.Point3d(b.X, b.Y, b.Z - height)

            panel = make_planar([a, b, b_low, a_low])
            if panel:
                panels.append(panel)

    return panels


def make_handrail_polyline_and_balusters(crv, params, actual_riser, handrail_height):
    rail_pts = []
    baluster_lines = []

    if handrail_height <= 0:
        return None, baluster_lines

    if params is None or len(params) < 2:
        return None, baluster_lines

    for i in range(len(params) - 1):
        z0 = actual_riser * i
        z1 = actual_riser * (i + 1)

        base_pts = curve_segment_points_sloped(
            crv,
            params[i],
            params[i + 1],
            z0,
            z1,
            reverse_result=False
        )

        for p in base_pts:
            if len(rail_pts) == 0 or rail_pts[-1].DistanceTo(p) > tol:
                rail_pts.append(
                    rg.Point3d(
                        p.X,
                        p.Y,
                        p.Z + handrail_height
                    )
                )

    for i in range(len(params)):
        p = crv.PointAt(params[i])
        z = actual_riser * i

        base = rg.Point3d(p.X, p.Y, z)
        rail_pt = rg.Point3d(p.X, p.Y, z + handrail_height)

        baluster_lines.append(rg.LineCurve(base, rail_pt))

    handrail = None
    if len(rail_pts) > 1:
        handrail = rg.PolylineCurve(rail_pts)

    return handrail, baluster_lines


def make_spaced_balusters(crv, params, actual_riser, handrail_height, gap):
    lines = []

    if handrail_height <= 0:
        return lines

    if gap is None or gap <= 0:
        return lines

    if params is None or len(params) < 2:
        return lines

    path_pts = []

    for i in range(len(params) - 1):
        z0 = actual_riser * i
        z1 = actual_riser * (i + 1)

        seg_pts = curve_segment_points_sloped(
            crv,
            params[i],
            params[i + 1],
            z0,
            z1,
            reverse_result=False
        )

        for p in seg_pts:
            if len(path_pts) == 0 or path_pts[-1].DistanceTo(p) > tol:
                path_pts.append(p)

    if len(path_pts) < 2:
        return lines

    path = rg.PolylineCurve(path_pts)
    length = path.GetLength()

    if length <= tol:
        return lines

    count = int(math.floor(length / gap))

    distances = [0.0]

    for i in range(1, count + 1):
        d = gap * i
        if d < length - tol:
            distances.append(d)

    distances.append(length)

    for d in distances:
        ok, t = path.LengthParameter(d)
        if not ok:
            continue

        base = path.PointAt(t)
        top = rg.Point3d(base.X, base.Y, base.Z + handrail_height)

        lines.append(rg.LineCurve(base, top))

    return lines


if OuterU is None or InnerU is None:
    Info = "OuterU and InnerU are required."

elif MaxHeight <= 0 or RiserHeight <= 0:
    Info = "MaxHeight and RiserHeight must be positive."

else:
    if StringerHeight is None:
        StringerHeight = 0.0

    if HandrailHeight is None:
        HandrailHeight = 0.0

    if BalusterGap is None:
        BalusterGap = 100.0

    step_count = int(math.ceil(float(MaxHeight) / float(RiserHeight)))
    actual_riser = float(MaxHeight) / step_count

    straight_count = int(round(step_count * 0.25))
    wind_start = straight_count
    wind_end = step_count - straight_count

    start_inner, start_inner_t = pt_and_t_at_factor(InnerU, 0.0)
    start_outer, start_outer_t = pt_and_t_at_factor(OuterU, 0.0)

    end_inner, end_inner_t = pt_and_t_at_factor(InnerU, 1.0)
    end_outer, end_outer_t = pt_and_t_at_factor(OuterU, 1.0)

    start_vec = start_outer - start_inner
    end_vec = end_outer - end_inner

    start_angle = math.atan2(start_vec.Y, start_vec.X)
    end_angle = math.atan2(end_vec.Y, end_vec.X)

    InnerPts = []
    OuterPts = []
    InnerParams = []
    OuterParams = []

    for i in range(step_count + 1):
        f = float(i) / float(step_count)
        inner_pt, inner_t = pt_and_t_at_factor(InnerU, f)

        if i <= wind_start:
            angle = start_angle
        elif i >= wind_end:
            angle = end_angle
        else:
            local = float(i - wind_start) / float(wind_end - wind_start)
            smooth = 0.5 - 0.5 * math.cos(local * math.pi)
            angle = lerp_angle(start_angle, end_angle, smooth)

        dir_vec = rg.Vector3d(math.cos(angle), math.sin(angle), 0)
        dir_vec.Unitize()

        outer_pt, outer_t = intersect_ray_with_curve(inner_pt, dir_vec, OuterU)

        if outer_pt is None:
            outer_pt, outer_t = pt_and_t_at_factor(OuterU, f)

        InnerPts.append(inner_pt)
        OuterPts.append(outer_pt)
        InnerParams.append(inner_t)
        OuterParams.append(outer_t)

        RiserLines.append(
            rg.LineCurve(
                rg.Point3d(inner_pt.X, inner_pt.Y, 0),
                rg.Point3d(outer_pt.X, outer_pt.Y, 0)
            )
        )

    for i in range(1, step_count + 1):
        inner_pt = InnerPts[i]
        outer_pt = OuterPts[i]

        z0 = actual_riser * (i - 1)
        z1 = actual_riser * i

        riser = make_planar([
            rg.Point3d(inner_pt.X, inner_pt.Y, z0),
            rg.Point3d(outer_pt.X, outer_pt.Y, z0),
            rg.Point3d(outer_pt.X, outer_pt.Y, z1),
            rg.Point3d(inner_pt.X, inner_pt.Y, z1)
        ])

        if riser:
            Risers.append(riser)

    for i in range(step_count):
        z = actual_riser * i

        inner_seg = curve_segment_points(
            InnerU,
            InnerParams[i],
            InnerParams[i + 1],
            z,
            reverse_result=True
        )

        outer_seg = curve_segment_points(
            OuterU,
            OuterParams[i],
            OuterParams[i + 1],
            z,
            reverse_result=False
        )

        boundary = []

        boundary.append(rg.Point3d(InnerPts[i].X, InnerPts[i].Y, z))
        boundary.append(rg.Point3d(OuterPts[i].X, OuterPts[i].Y, z))

        for p in outer_seg:
            boundary.append(p)

        boundary.append(rg.Point3d(InnerPts[i + 1].X, InnerPts[i + 1].Y, z))

        for p in inner_seg:
            boundary.append(p)

        tread = make_planar(boundary)
        if tread:
            Treads.append(tread)

    OuterStringer = make_stringer_straight_cut(
        OuterU,
        OuterParams,
        StringerHeight,
        actual_riser
    )

    InnerStringer = make_stringer_straight_cut(
        InnerU,
        InnerParams,
        StringerHeight,
        actual_riser
    )

    OuterHandrail, outer_balusters = make_handrail_polyline_and_balusters(
        OuterU,
        OuterParams,
        actual_riser,
        HandrailHeight
    )

    InnerHandrail, inner_balusters = make_handrail_polyline_and_balusters(
        InnerU,
        InnerParams,
        actual_riser,
        HandrailHeight
    )

    if InnerHandrail:
        Handrails.append(InnerHandrail)

    if OuterHandrail:
        Handrails.append(OuterHandrail)

    inner_balusters_spaced = make_spaced_balusters(
        InnerU,
        InnerParams,
        actual_riser,
        HandrailHeight,
        BalusterGap
    )

    outer_balusters_spaced = make_spaced_balusters(
        OuterU,
        OuterParams,
        actual_riser,
        HandrailHeight,
        BalusterGap
    )

    Balusters = inner_balusters + outer_balusters
    BalusterSpaced = inner_balusters_spaced + outer_balusters_spaced

    Stringers = InnerStringer + OuterStringer
    Stair = Treads + Risers + Stringers + Handrails + Balusters + BalusterSpaced

    Info = "Steps: {0}, actual riser: {1:.2f}, stringer height: {2:.2f}, handrail height: {3:.2f}, baluster gap: {4:.2f}".format(
        step_count,
        actual_riser,
        StringerHeight,
        HandrailHeight,
        BalusterGap
    )