///|
/// SweptProfile::new generates a new Shape by sweeping a 2D profile along a 3D path.
/// By default, it generates end caps, making it a topologically-closed solid.
///
/// When `straight_holes` is true the profile's holes keep the orientation of the
/// first path point instead of following the path frame, which gives straight
/// bores through twisted sweeps (e.g. a hex socket in a herringbone gear).
///
/// When `straight_outline` is true (the default) the outer boundary keeps the
/// orientation of the first path point, which gives a straight outer wall while
/// the holes twist with the path (e.g. a smooth cylindrical shell around a
/// herringbone ring gear). Set it to false when the outer boundary itself must
/// follow the path, such as a thread or a coil wire.
pub fn SweptProfile::new(
  path~ : SweepPath,
  profile~ : SweepProfile,
  make_start_cap~ : Bool = true,
  make_end_cap~ : Bool = true,
  straight_holes~ : Bool = false,
  straight_outline~ : Bool = true,
  smooth_helix~ : Bool = false,
  corner_override~ : (Array[Vec3], Array[Vec3])? = None,
) -> Shape {
  Shape::SweptProfile({
    path,
    profile,
    make_start_cap,
    make_end_cap,
    straight_holes,
    straight_outline,
    smooth_helix,
    corner_override,
  })
}

///|
fn compile_swept_profile(
  ctx : StepContext,
  node : SceneNode,
  s : SweptProfile,
  reverse~ : Bool = false,
  parent~ : Transform? = None,
) -> Array[@step.Ref] {
  let xform = node_transform(node, parent)
  let path = s.path
  let profile = s.profile
  if path.points.length() < 2 || profile.vertices.length() < 3 {
    return []
  }
  if s.smooth_helix {
    return compile_smooth_helix(ctx, node, s, reverse~, parent~)
  }
  let num_path_pts = path.points.length()

  // Profile loops: outer boundary first, then any holes.
  let loops : Array[Array[Vec3]] = [
    profile.vertices,
    ..profile.holes.filter(fn(h) { h.length() >= 3 }),
  ]
  let num_loops = loops.length()

  // 1. Calculate profile-local to global (centered at origin)
  let prof_z = v_norm(profile.normal_dir)
  let prof_x = v_norm(profile.right_dir)
  let prof_y = v_norm(v_cross(prof_z, prof_x))

  // global to profile-local (centered at origin)
  let m_global_to_prof = Matrix3x3::{
    m00: prof_x.0,
    m01: prof_x.1,
    m02: prof_x.2,
    m10: prof_y.0,
    m11: prof_y.1,
    m12: prof_y.2,
    m20: prof_z.0,
    m21: prof_z.1,
    m22: prof_z.2,
  }

  // Vertices in profile-local coordinate system (relative to profile.center).
  // All loops are flattened into one array; `loop_offsets` records where each
  // loop starts (within a single profile slice).
  let local_loops = loops.map(fn(lp) {
    lp.map(fn(v) { apply_matrix(m_global_to_prof, v_sub(v, profile.center)) })
  })
  let loop_offsets : Array[Int32] = []
  let local_verts : Array[Vec3] = []
  let is_hole : Array[Bool] = []
  let mut loop_index = 0
  for lp in local_loops {
    loop_offsets.push(local_verts.length())
    for v in lp {
      local_verts.push(v)
      is_hole.push(loop_index > 0)
    }
    loop_index = loop_index + 1
  }
  let num_prof_verts = local_verts.length()

  // Holes and/or the outer boundary can optionally stay parallel to the sweep
  // axis instead of rotating with the path frame. Each then uses the frame at
  // the first path point.
  let t0 = v_norm(path.tangents[0])
  let n0 = v_norm(path.normals[0])
  let r0 = v_norm(v_cross(t0, n0))

  // 2. Transform local_verts to each path point.
  let pts_raw : Array[Vec3] = []
  for i in 0.. Unit {
    let key = if a < b { "\{a}:\{b}" } else { "\{b}:\{a}" }
    if seen.get(key).unwrap_or(false) {
      return
    }
    seen.set(key, true)
    let start = if a < b { a } else { b }
    let end = if a < b { b } else { a }
    let dir_raw = v_norm(v_sub(pts[end], pts[start]))
    let dir_ref = ctx.direction(dir_raw.0, dir_raw.1, dir_raw.2)
    let vec_ref = ctx.vector(dir_ref, 1.0)
    let line_ref = ctx.line(cp_refs[start], vec_ref)
    let ec_ref = ctx.edge_curve(vp_refs[start], vp_refs[end], line_ref, true)
    edges.push(EdgeInfo::{ start, end, edge: ec_ref, })
  }

  // Profile edges (at each path point)
  for i in 0.. Array[Int32] {
    let lo = loop_offsets[li]
    let n = local_loops[li].length()
    let cycle = Array::make(n, 0)
    for j in 0..