///|
/// 38-planetary-gearbox
///
/// A print-in-place herringbone planetary gearbox, ported from
/// `github.com/gmlewis/go-bjk/cmd/make-planetary-gearbox` and the Blackjack
/// `HerringboneGear` node (`herringbone_gear.lua`).
///
/// The center "sun" gear, three orbiting "planet" gears, and the enclosing
/// inward-facing "ring" gear are all herringbone gears. The ring gear is
/// generated as the envelope of the mating planet gear, exactly as the Lua node
/// does it.
///
/// Gear terminology and formulas follow the HerringboneGear node:
///
///   pitch radius = gear_module * num_teeth / 2
///   base radius  = pitch radius * cos(pressure_angle)
///   root radius  = pitch radius - 1.25 * module
///   outer radius = pitch radius + module
///
/// For a planetary set with a sun, Np planet gears, and an internal ring gear:
///
///   ring = sun + 2*planet
///   (sun + ring) % planets == 0
///
/// With all four inner gears identical (planet == sun) the second condition
/// becomes sun % 3 == 0 for three planets, so the default here is 12 teeth.
///
/// ## Why the STEP file is small
///
/// The Blackjack node evaluates every gear as a triangle mesh: it walks the
/// full gear length in `(num_elbows + 1) * 2 * resolution` horizontal slices,
/// regenerating every tooth facet per slice. Here each gear's 2D outline is
/// built exactly once and then swept through `num_elbows + 1` linearly twisted
/// extrusions - only the elbow vertices are needed as cross-sections, so the
/// model is much smaller than the equivalent STL. Non-coplanar swept bands are
/// split into planar triangles so every STEP face is planar and the model
/// tessellates without gaps. The exported design is Z-up (the gears are built
/// with their axes along +Y and rotated +90° about X), so it sits flat on a
/// build plate: 30 mm tall with a 136 mm footprint. Pass `--yUp` to keep the
/// native Y-up frame.
///
/// Usage (stdout):
///   moon run --target native examples/38-planetary-gearbox -- > gearbox.step
///
/// Usage (write file):
///   ./examples/run-example.sh 38 --numTeeth 12 -o gearbox.step
///
/// Parameter mapping (Go flag -> this program):
///   -mod   --module          -gl  --gearLength      -ha  --helixAngle
///   -hr    --holeRadius      -ht  --holeType        -ne  --numElbows
///   -nt    --numTeeth        -planets --numPlanets  -planetTeeth --planetTeeth
///   -pa    --pressureAngle   -ringTeeth --ringTeeth -tol --tolerance
///   -outerThickness --outerThickness                -res --resolution
///   -o     --out
///   (-obj, -stl, -swapyz, -repo and -debug are mesh-export options and have
///   no STEP equivalent.)

///|
let deg : Double = @math.PI / 180.0

///|
let points_in_involute : Int32 = 20

///|
let two_pi : Double = 2.0 * @math.PI

///|
/// Maximum profile rotation (degrees) between adjacent swept cross-sections.
/// Each adjacent pair becomes a ring of planar quads approximating the twisted
/// surface, so this bounds how far a quad can cut inside the true helix.
/// 24 deg keeps the chord sagitta below ~2% of the gear radius, which is about
/// the faceting the Blackjack mesh uses at its default settings.
let max_sweep_step_deg : Double = 24.0

///|
struct GearDims {
  num_teeth : Int32
  pitch_radius : Double
  base_radius : Double
  root_radius : Double
  outer_radius : Double
}

///|
struct PlanetPlacement {
  x : Double
  z : Double
  phi_deg : Double
}

///|
struct GearboxConfig {
  gear_module : Double
  gear_length : Double
  helix_angle_deg : Double
  helix_angle_sun : Double
  pressure_angle : Double
  num_elbows : Int32
  resolution : Int32
  hole_type : String
  hole_radius : Double
  tolerance : Double
  outer_thickness : Double
  sun : GearDims
  planet : GearDims
  ring : GearDims
  ring_teeth : Int32
  center_distance : Double
  center_delta : Double
  sun_direction : Double
  planet_direction : Double
  planets : Array[PlanetPlacement]
}

///|
struct GearParams {
  gear_module : Double
  gear_length : Double
  helix_angle : Double
  hole_radius : Double
  hole_type : String
  num_elbows : Int32
  num_teeth : Int32
  num_planets : Int32
  planet_teeth : Int32
  pressure_angle : Double
  ring_teeth : Int32
  tolerance : Double
  outer_thickness : Double
  resolution : Int32
}

///|
struct RingGeometry {
  /// Inner (toothed) profile in the XZ plane, clockwise.
  inner : Array[(Double, Double)]
  outer_radius : Double
  /// Angular phase that aligns the ring teeth with planet 0.
  phase : Double
  ring_scale : Double
  max_helix_mate : Double
}

// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------

///|
async fn main {
  let base_args_spec = {
    "module": @cli.opt_double(
      3.0,
      short='m',
      help="Gear module (tooth size), see https://www.stlgears.com/theory#module",
    ),
    "gearLength": @cli.opt_double(30.0, short='g', help="Gear length (mm)"),
    "helixAngle": @cli.opt_double(
      30.0,
      short='a',
      help="Helix angle in degrees (clamped to 0..45)",
    ),
    "holeRadius": @cli.opt_double(
      10.0,
      short='r',
      help="Hole minimum radius (mm)",
    ),
    "holeType": @cli.opt_string(
      short='t',
      help="Hole type: None, Squared, Hexagonal, Octagonal, Circular",
    ),
    "numElbows": @cli.opt_int(
      4,
      short='e',
      help="Number of elbows in each herringbone gear",
    ),
    "numTeeth": @cli.opt_int(
      12,
      short='n',
      help="Number of teeth on the sun gear",
    ),
    "numPlanets": @cli.opt_int(
      3,
      short='p',
      help="Number of orbiting planet gears",
    ),
    "planetTeeth": @cli.opt_int(
      0,
      short='P',
      help="Number of teeth on each planet gear (0 means same as --numTeeth)",
    ),
    "pressureAngle": @cli.opt_double(
      20.0,
      short='A',
      help="Pressure angle in degrees",
    ),
    "ringTeeth": @cli.opt_int(
      0,
      short='R',
      help="Number of teeth on the ring gear (0 means sun + 2*planet)",
    ),
    "tolerance": @cli.opt_double(
      0.25,
      short='T',
      help="Target minimum 3D-printer clearance between meshing flanks (mm)",
    ),
    "outerThickness": @cli.opt_double(
      10.0,
      short='w',
      help="Radial wall thickness from the closest ring notch to the outer surface (mm)",
    ),
    "resolution": @cli.opt_int(
      9,
      short='s',
      help="Number of points in each curved section of the tooth outline",
    ),
    "yUp": @cli.flag(
      help="Keep the native Y-up orientation (gear axes along +Y) instead of rotating the design flat for printing",
    ),
  }
  let args_spec = @common.with_output_args(
    base_args_spec,
    blend=false,
    bpy=false,
  )
  guard @cli.parse(
      args_spec,
      prog="38-planetary-gearbox",
      description="Generate a print-in-place herringbone planetary gearbox.",
    )
    is Some(args) else {
    return
  }
  let hole_type = args.string_opt("holeType").unwrap_or("None")
  let params : GearParams = {
    gear_module: args.double("module"),
    gear_length: args.double("gearLength"),
    helix_angle: args.double("helixAngle"),
    hole_radius: args.double("holeRadius"),
    hole_type,
    num_elbows: args.int("numElbows"),
    num_teeth: args.int("numTeeth"),
    num_planets: args.int("numPlanets"),
    planet_teeth: args.int("planetTeeth"),
    pressure_angle: args.double("pressureAngle"),
    ring_teeth: args.int("ringTeeth"),
    tolerance: args.double("tolerance"),
    outer_thickness: args.double("outerThickness"),
    resolution: args.int("resolution"),
  }
  let output = @common.output_paths(args)
  let y_up = args.flag("yUp")
  let cfg = compute_config(params)
  let design = build_design(cfg, args.documentation(), y_up~)
  if output.step is Some(_) {
    print_config(cfg)
  }
  @common.write_output(design, output)
}

///|
async fn fail(msg : String) -> Unit {
  @cli.eprintln("error: " + msg)
  abort("")
}

///|
async fn print_config(cfg : GearboxConfig) -> Unit {
  @cli.eprintln(
    "sun:    teeth=\{cfg.sun.num_teeth} pitch_radius=\{cfg.sun.pitch_radius} base_radius=\{cfg.sun.base_radius} root_radius=\{cfg.sun.root_radius} outer_radius=\{cfg.sun.outer_radius}",
  )
  @cli.eprintln(
    "planet: teeth=\{cfg.planet.num_teeth} pitch_radius=\{cfg.planet.pitch_radius} base_radius=\{cfg.planet.base_radius} root_radius=\{cfg.planet.root_radius} outer_radius=\{cfg.planet.outer_radius}",
  )
  @cli.eprintln(
    "ring:   teeth=\{cfg.ring.num_teeth} pitch_radius=\{cfg.ring.pitch_radius} base_radius=\{cfg.ring.base_radius} nominal_tooth_tip=\{cfg.ring.outer_radius} nominal_notch=\{cfg.ring.root_radius}",
  )
  @cli.eprintln(
    "center distance (sun-planet and ring-planet): \{cfg.center_distance} (zero-backlash center distance + \{cfg.center_delta})",
  )
  @cli.eprintln(
    "target minimum clearance: \{cfg.tolerance}, outer thickness: \{cfg.outer_thickness}",
  )
  for i, p in cfg.planets {
    @cli.eprintln(
      "planet \{i}: pos=(\{p.x}, 0, \{p.z}) rotation=\{p.phi_deg} deg",
    )
  }
  @cli.eprintln(
    "helix angle: \{cfg.helix_angle_deg} deg (sun generator: \{cfg.helix_angle_sun} deg)",
  )
}

// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------

///|
async fn compute_config(p : GearParams) -> GearboxConfig {
  if p.gear_module <= 0.0 {
    fail("--module must be > 0")
  }
  if p.gear_length <= 0.0 {
    fail("--gearLength must be > 0")
  }
  if p.num_teeth < 6 {
    fail("--numTeeth must be >= 6 (Blackjack's HerringboneGear minimum)")
  }
  if p.num_elbows < 1 {
    fail("--numElbows must be >= 1")
  }
  if p.resolution < 1 {
    fail("--resolution must be >= 1")
  }
  if p.num_planets < 1 {
    fail("--numPlanets must be >= 1")
  }
  if p.pressure_angle < 1.0 {
    fail("--pressureAngle must be >= 1")
  }
  if p.tolerance < 0.0 {
    fail("--tolerance must be >= 0")
  }
  if p.outer_thickness <= 0.0 {
    fail("--outerThickness must be > 0")
  }
  if p.hole_type == "Hollow" {
    fail(
      "--holeType=Hollow describes an open shell (no top or bottom cap) and cannot be written as a solid STEP body; use None or one of Squared, Hexagonal, Octagonal, Circular",
    )
  }

  let planet_teeth = if p.planet_teeth == 0 {
    p.num_teeth
  } else {
    p.planet_teeth
  }
  if planet_teeth < 6 {
    fail("--planetTeeth must be >= 6 (or 0 to use --numTeeth)")
  }

  let ring_teeth = if p.ring_teeth == 0 {
    p.num_teeth + 2 * planet_teeth
  } else {
    p.ring_teeth
  }
  if ring_teeth != p.num_teeth + 2 * planet_teeth {
    fail(
      "--ringTeeth (\{ring_teeth}) must equal --numTeeth + 2*--planetTeeth = \{p.num_teeth} + 2*\{planet_teeth} = \{p.num_teeth + 2 * planet_teeth} for a standard internal mesh",
    )
  }

  // Assembly condition for equally-spaced planets.
  if (p.num_teeth + ring_teeth) % p.num_planets != 0 {
    let suggested = suggest_planet_teeth(p.num_teeth, p.num_planets)
    fail(
      "tooth counts (sun=\{p.num_teeth}, planet=\{planet_teeth}, ring=\{ring_teeth}) cannot be equally spaced in \{p.num_planets} positions: (sun+ring)=\{p.num_teeth + ring_teeth} must be divisible by \{p.num_planets}.\n  With all four inner gears identical, this requires --numTeeth to be divisible by 3.\n  Try: --numTeeth 12, --numTeeth 15, or --planetTeeth \{suggested} (ring=\{p.num_teeth + 2 * suggested})",
    )
  }

  let pa = p.pressure_angle * deg
  let ha = clamp(p.helix_angle, 0.0, 45.0) * deg

  let sun = new_gear_dims(p.gear_module, pa, p.num_teeth)
  let planet = new_gear_dims(p.gear_module, pa, planet_teeth)
  let ring_nominal = new_gear_dims(p.gear_module, pa, ring_teeth)
  // Nominal internal-gear radii for reporting.
  let ring : GearDims = {
    ..ring_nominal,
    root_radius: ring_nominal.pitch_radius + p.gear_module,
    outer_radius: ring_nominal.pitch_radius - 1.25 * p.gear_module,
  }

  if p.hole_type != "None" {
    if p.hole_radius >= sun.root_radius || p.hole_radius >= planet.root_radius {
      fail(
        "--holeRadius (\{p.hole_radius}) must be less than the gear root radius (sun=\{sun.root_radius}, planet=\{planet.root_radius})",
      )
    }
  }

  // Increase the sun/planet center distance so the actual normal flank
  // clearance is the requested tolerance. The same center distance is used
  // for the ring envelope.
  let center_delta = if p.tolerance > 0.0 { p.tolerance * 2.0 } else { 0.0 }
  let center_distance = sun.pitch_radius + planet.pitch_radius + center_delta

  // Match the helix angle at the pitch circle. The Lua node defines the helix
  // angle at the outer radius, so the pitch-circle angle is
  // tan(beta_pitch) = tan(ha) * N/(N+2).
  let tan_beta_pitch = @math.tan(ha) *
    planet_teeth.to_double() /
    (planet_teeth + 2).to_double()
  let helix_angle_sun = clamp(
    @math.atan(
      tan_beta_pitch * (p.num_teeth + 2).to_double() / p.num_teeth.to_double(),
    ) /
    deg,
    0.0,
    45.0,
  )

  // Sun phase is 0: tooth 0 of the sun points along +X.
  let sun_direction = 1.0
  let planet_direction = -1.0 // opposite to the sun for an external mesh

  // Place the planets and compute their rotations.
  let planets : Array[PlanetPlacement] = []
  for j in 0.. GearDims {
  let pitch = gear_module * num_teeth.to_double() / 2.0
  {
    num_teeth,
    pitch_radius: pitch,
    base_radius: pitch * @math.cos(pressure_angle),
    root_radius: pitch - 1.25 * gear_module,
    outer_radius: pitch + gear_module,
  }
}

///|
/// suggest_planet_teeth returns a planet tooth count close to sun_teeth that
/// makes (sun + ring) = 2*(sun + planet) divisible by the number of planets.
fn suggest_planet_teeth(sun_teeth : Int32, planets : Int32) -> Int32 {
  for offset in 1..<20 {
    for candidate in [sun_teeth - offset, sun_teeth + offset] {
      if candidate < 6 {
        continue
      }
      let ring = sun_teeth + 2 * candidate
      if (sun_teeth + ring) % planets == 0 {
        return candidate
      }
    }
  }
  sun_teeth
}

// ---------------------------------------------------------------------------
// Design
// ---------------------------------------------------------------------------

///|
fn build_design(
  cfg : GearboxConfig,
  documentation : String,
  y_up~ : Bool = false,
) -> @cad.Design {
  let mut design = @cad.Design::new(
    name="planetary-gearbox",
    description="Print-in-place herringbone planetary gearbox",
    documentation~,
  )

  // Sun gear.
  //
  // Fabrication sites and most slicers treat +Z as up, while the gear axes here
  // run along +Y. Rotate the whole design +90 deg about X to lay it flat on the
  // build plate: Rx(90) maps (x, y, z) -> (x, -z, y), so the 30 mm gear length
  // becomes the print height instead of the 136 mm ring diameter. `--yUp`
  // keeps the native orientation.
  let sun_outline = gear_outline(
    cfg.gear_module,
    cfg.pressure_angle,
    cfg.sun.num_teeth,
    cfg.resolution,
  )
  let sun_max_rot = @math.tan(deg2rad(cfg.helix_angle_sun)) *
    (cfg.gear_length / 2.0) /
    cfg.sun.outer_radius
  let sun_node = make_gear_shape(
      sun_outline,
      gear_holes(cfg, cfg.sun),
      cfg.gear_length,
      cfg.num_elbows,
      sun_max_rot,
      cfg.sun_direction,
      straight_holes=true,
      straight_outline=false,
    )
    .with_name("sun")
    .with_color(@cad.Rgb::orange())
  let sun_node = if y_up { sun_node } else { sun_node.rotate_x(90.0) }

  // Planets.
  let planet_outline = gear_outline(
    cfg.gear_module,
    cfg.pressure_angle,
    cfg.planet.num_teeth,
    cfg.resolution,
  )
  let planet_holes = gear_holes(cfg, cfg.planet)
  let planet_max_rot = @math.tan(deg2rad(cfg.helix_angle_deg)) *
    (cfg.gear_length / 2.0) /
    cfg.planet.outer_radius
  let planet_nodes : Array[@cad.SceneNode] = []
  for j, p in cfg.planets {
    let node = make_gear_shape(
        planet_outline,
        planet_holes,
        cfg.gear_length,
        cfg.num_elbows,
        planet_max_rot,
        cfg.planet_direction,
        straight_holes=true,
        straight_outline=false,
      )
      .with_name("planet-\{j}")
      .with_color(@cad.Rgb::blue())
    // The Lua rotates about +Y as (x,z) -> (x cos t - z sin t, x sin t + z cos t),
    // which is the CAD rotate_y by -t. Under the global Rx(90) above that
    // becomes Rz(-t)*Rx(90), and the placement (x, 0, z) becomes (x, -z, 0).
    let placed = if y_up {
      node.rotate_y(-p.phi_deg).translate(x=p.x, z=p.z)
    } else {
      node.rotate_x(90.0).rotate_z(-p.phi_deg).translate(x=p.x, y=-p.z)
    }
    planet_nodes.push(placed)
  }

  // Ring gear: a smooth outer cylinder with the inward-facing toothed profile
  // as a through hole. Its twist follows the mating planet at the pitch point.
  let ring = ring_geometry(cfg)
  let outer_circle = circle_outline(ring.outer_radius, cfg.resolution * 8, true)
  let inner_ccw = ring.inner.map(fn(pt) { rotate_xz(pt, ring.phase) }).rev()
  let ring_node = make_gear_shape(
      outer_circle,
      [inner_ccw],
      cfg.gear_length,
      cfg.num_elbows,
      ring.ring_scale * ring.max_helix_mate,
      cfg.planet_direction,
    )
    .with_name("ring")
    .with_color(@cad.Rgb::grey())
  let ring_node = if y_up { ring_node } else { ring_node.rotate_x(90.0) }

  design = design.add(sun_node)
  for node in planet_nodes {
    design = design.add(node)
  }
  design.add(ring_node)
}

///|
/// make_gear_shape builds a single solid by sweeping the gear outline through
/// `num_elbows + 1` linearly twisted extrusions.
///
/// `outline` and `holes` are in the XZ plane. `max_rotation` is the profile
/// rotation at an elbow vertex (radians); the first cross-section is at
/// rotation 0 and each following elbow alternates between 0 and max_rotation,
/// which reproduces the HerringboneGear's zig-zag.
///
/// `straight_outline` and `straight_holes` keep the corresponding loop aligned
/// with the first cross-section. External gears twist their outline and keep
/// their bore straight; the ring gear twists its toothed hole and keeps the
/// outer wall straight.
fn make_gear_shape(
  outline : Array[(Double, Double)],
  holes : Array[Array[(Double, Double)]],
  gear_length : Double,
  num_elbows : Int32,
  max_rotation : Double,
  direction : Double,
  straight_holes~ : Bool = false,
  straight_outline~ : Bool = true,
) -> @cad.Shape {
  let path = gear_sweep_path(gear_length, num_elbows, max_rotation, direction)
  let profile = @cad.SweepProfile::new(
    profile_points(outline),
    holes=holes.map(fn(hole) { profile_points(hole) }),
    normal_dir=(0.0, 0.0, 1.0),
    right_dir=(1.0, 0.0, 0.0),
  )
  @cad.SweptProfile::new(path~, profile~, straight_holes~, straight_outline~)
}

///|
/// gear_sweep_path returns the zig-zag cross-section path through the gear
/// length. Each of the `num_elbows + 1` chunks twists linearly from its start
/// rotation to its end rotation.
///
/// A chunk is subdivided into enough steps that each ring of swept side faces
/// spans at most `max_sweep_step_deg` of profile rotation. A ring of planar
/// quads approximates the twisted (helical) surface; when a single quad spans
/// a large rotation its corners are far from coplanar and the face folds
/// through itself. That is harmless for typical gears (one step at module 3),
/// but a small module with a long gear twists hundreds of degrees per chunk -
/// module 0.5 with gearLength 30 rotates 141.8 deg per elbow - and needs
/// subdivision to stay valid.
fn gear_sweep_path(
  gear_length : Double,
  num_elbows : Int32,
  max_rotation : Double,
  direction : Double,
) -> @cad.SweepPath {
  let chunks = num_elbows + 1
  let max_step = max_sweep_step_deg * deg
  let steps = if max_rotation <= max_step {
    1
  } else {
    let n = @math.ceil(max_rotation / max_step).to_int()
    if n < 1 {
      1
    } else {
      n
    }
  }
  let total = chunks * steps
  let points : Array[@cad.Vec3] = []
  let normals : Array[@cad.Vec3] = []
  let tangents : Array[@cad.Vec3] = []
  for i in 0..<(total + 1) {
    let y = gear_length * i.to_double() / total.to_double()
    let chunk = if i == total { chunks - 1 } else { i / steps }
    let within = if i == total {
      1.0
    } else {
      (i % steps).to_double() / steps.to_double()
    }
    let start_rotation = if chunk % 2 == 0 { 0.0 } else { max_rotation }
    let end_rotation = if chunk % 2 == 0 { max_rotation } else { 0.0 }
    let helix_rotation = start_rotation +
      (end_rotation - start_rotation) * within
    let angle = -direction * helix_rotation
    points.push((0.0, y, 0.0))
    normals.push((@math.cos(angle), 0.0, @math.sin(angle)))
    tangents.push((0.0, 1.0, 0.0))
  }
  { points, normals, tangents, }
}

///|
/// profile_points converts XZ-plane points into the sweep profile frame.
///
/// With `normal_dir=(0,0,1)` and `right_dir=(1,0,0)`, a profile point
/// (x, y, 0) maps to `x*right + y*normal`, so passing (-z, x, 0) makes the
/// swept cross-section equal to `rotate_xz((x, z), angle)`.
fn profile_points(pts : Array[(Double, Double)]) -> Array[@cad.Vec3] {
  pts.map(fn(pt) { (-pt.1, pt.0, 0.0) })
}

// ---------------------------------------------------------------------------
// Gear outlines
// ---------------------------------------------------------------------------

///|
/// gear_outline returns the complete external-gear boundary in the XZ plane,
/// clockwise, exactly as the HerringboneGear node builds its tooth faces:
/// involute flanks, tip arc, mirrored flank, and the root arc to the next
/// tooth.
fn gear_outline(
  gear_module : Double,
  pressure_angle_deg : Double,
  num_teeth : Int32,
  resolution : Int32,
) -> Array[(Double, Double)] {
  let pa = deg2rad(pressure_angle_deg)
  let n = num_teeth.to_double()
  let pitch_radius = gear_module * n / 2.0
  let base_radius = pitch_radius * @math.cos(pa)
  let root_radius = pitch_radius - 1.25 * gear_module
  let outer_radius = pitch_radius + gear_module

  // Angular extent of the tooth on the base radius.
  let backlash = 0.25 * @math.PI * @math.cos(pa) / (n * gear_module)
  let pitch_point = involute_xz(
    base_radius,
    involute_theta(base_radius, pitch_radius),
  )
  let face_angle = @math.atan2(pitch_point.1, pitch_point.0)
  let backlash_angle = backlash / (2.0 * pitch_radius)
  let center_angle = @math.PI / (2.0 * n) + face_angle - backlash_angle

  // Angles over which the involute is used.
  let start_angle = involute_theta(
    base_radius,
    if root_radius > base_radius {
      root_radius
    } else {
      base_radius
    },
  )
  let stop_angle = involute_theta(base_radius, outer_radius)
  let d_theta = (stop_angle - start_angle) / points_in_involute.to_double()

  let inv : Array[(Double, Double)] = []
  if root_radius < base_radius {
    inv.push(rotate_xz((root_radius, 0.0), -center_angle))
  }
  let mut angle = start_angle
  for _i in 0..<(points_in_involute + 1) {
    inv.push(rotate_xz(involute_xz(base_radius, angle), -center_angle))
    angle = angle + d_theta
  }
  let num_facets = inv.length()

  // Arc across the outer tooth edge.
  let arc_tip = inv[num_facets - 1]
  let arc_tip_angle = Double::abs(@math.atan2(arc_tip.1, arc_tip.0))
  for i in 1.. Array[(Double, Double)] {
  let pa = deg2rad(pressure_angle_deg)
  let n = num_teeth.to_double()
  let pitch_radius = gear_module * n / 2.0
  let base_radius = pitch_radius * @math.cos(pa)
  let face_angle = @math.tan(pa) - pa
  let backlash = 0.25 * @math.PI * @math.cos(pa) / (n * gear_module)
  let backlash_angle = backlash / (2.0 * pitch_radius)
  let center_angle = @math.PI / (2.0 * n) + face_angle - backlash_angle

  let start_angle = involute_theta(
    base_radius,
    if root_radius > base_radius {
      root_radius
    } else {
      base_radius
    },
  )
  let stop_angle = involute_theta(base_radius, outer_radius)
  let d_theta = (stop_angle - start_angle) / points_in_involute.to_double()

  let inv : Array[(Double, Double)] = []
  if root_radius < base_radius {
    inv.push(rotate_xz((root_radius, 0.0), -center_angle))
  }
  let mut angle = start_angle
  for _i in 0..<(points_in_involute + 1) {
    inv.push(rotate_xz(involute_xz(base_radius, angle), -center_angle))
    angle = angle + d_theta
  }
  let num_facets = inv.length()

  // Arc across the outer tooth edge.
  let arc_tip = inv[num_facets - 1]
  let arc_tip_angle = Double::abs(@math.atan2(arc_tip.1, arc_tip.0))
  for i in 1.. RingGeometry {
  let gear_module = cfg.gear_module
  let n = cfg.ring_teeth.to_double()
  let ring_pitch = gear_module * n / 2.0
  let mate_pitch = gear_module * cfg.planet.num_teeth.to_double() / 2.0
  let mate_outer = mate_pitch + gear_module

  // Compensate for an increased ring/planet center distance.
  let nominal_center = ring_pitch - mate_pitch
  let center = if cfg.center_distance <= 0.0 {
    nominal_center
  } else {
    cfg.center_distance
  }
  let center_delta = if center > nominal_center {
    center - nominal_center
  } else {
    0.0
  }

  // Internal gear addendum/dedendum: use the standard shortened internal
  // addendum (tooth tip 1.0*module inside the pitch circle) and root/notch
  // 1.0*module outside it.
  let phantom_root = ring_pitch - gear_module
  let phantom_tip = ring_pitch + gear_module
  let profile = external_gear_boundary(
    gear_module,
    cfg.pressure_angle,
    cfg.ring_teeth,
    phantom_root,
    phantom_tip,
    cfg.resolution,
  )
  let profile = clean_polygon(profile, 1.0e-4)

  let mut max_r = 0.0
  for pt in profile {
    let r = radius_of(pt)
    if r > max_r {
      max_r = r
    }
  }

  // Apply the clearance as a smooth radial offset. A radial offset of d
  // produces a normal flank clearance of d*sin(pressure_angle).
  let radial_offset = center_delta +
    cfg.tolerance / @math.sin(deg2rad(cfg.pressure_angle))
  let offset_profile = profile.map(fn(pt) {
    let r = radius_of(pt)
    if r > 0.0 {
      let s = (r + radial_offset) / r
      (pt.0 * s, pt.1 * s)
    } else {
      pt
    }
  })
  let inner = clean_polygon(offset_profile, 1.0e-4)
  let outer_radius = max_r + radial_offset + cfg.outer_thickness

  // Align the phantom's tooth centers with the mating planet's tooth centers
  // at the ring/planet pitch point.
  let mate_pitch_angle = two_pi / cfg.planet.num_teeth.to_double()
  let ring_pitch_angle = two_pi / n
  let mate_rotation = deg2rad(cfg.planets[0].phi_deg)
  let mut phase = ring_pitch_angle * dmod(mate_rotation / mate_pitch_angle, 1.0)
  if phase < 0.0 {
    phase = phase + ring_pitch_angle
  }

  let max_helix_mate = @math.tan(deg2rad(cfg.helix_angle_deg)) *
    (cfg.gear_length / 2.0) /
    mate_outer
  {
    inner,
    outer_radius,
    phase,
    ring_scale: mate_pitch / ring_pitch,
    max_helix_mate,
  }
}

///|
/// gear_holes returns the central hole outlines for an external gear, wound
/// counter-clockwise (opposite to the gear outline).
fn gear_holes(
  cfg : GearboxConfig,
  g : GearDims,
) -> Array[Array[(Double, Double)]] {
  let hole_type = cfg.hole_type
  if hole_type == "None" {
    []
  } else if hole_type == "Circular" {
    [circle_outline(cfg.hole_radius, g.num_teeth * cfg.resolution, false)]
  } else if hole_type == "Squared" {
    [polygon_hole(4, cfg.hole_radius)]
  } else if hole_type == "Hexagonal" {
    [polygon_hole(6, cfg.hole_radius)]
  } else if hole_type == "Octagonal" {
    [polygon_hole(8, cfg.hole_radius)]
  } else {
    []
  }
}

///|
/// polygon_hole returns a regular polygon with the given inradius, with its
/// vertices on the X axis (matching the HerringboneGear hole generators).
fn polygon_hole(sides : Int32, inradius : Double) -> Array[(Double, Double)] {
  let circumradius = inradius / @math.cos(@math.PI / sides.to_double())
  let pts : Array[(Double, Double)] = []
  for i in 0.. Array[(Double, Double)] {
  let n = if segments < 3 { 3 } else { segments }
  let pts : Array[(Double, Double)] = []
  for i in 0.. Double {
  degrees * deg
}

///|
fn clamp(value : Double, lo : Double, hi : Double) -> Double {
  if value < lo {
    lo
  } else if value > hi {
    hi
  } else {
    value
  }
}

///|
fn frac(x : Double) -> Double {
  x - x.floor()
}

///|
fn dmod(a : Double, b : Double) -> Double {
  a - b * (a / b).floor()
}

///|
fn norm_angle(a : Double) -> Double {
  let mut angle = dmod(a, two_pi)
  if angle < 0.0 {
    angle = angle + two_pi
  }
  angle
}

///|
fn radius_of(pt : (Double, Double)) -> Double {
  Double::sqrt(pt.0 * pt.0 + pt.1 * pt.1)
}

///|
fn sq_dist(a : (Double, Double), b : (Double, Double)) -> Double {
  let dx = a.0 - b.0
  let dz = a.1 - b.1
  dx * dx + dz * dz
}

///|
/// rotate_xz rotates a point in the XZ plane by `theta`, matching the Lua
/// node's rotate_point.
fn rotate_xz(pt : (Double, Double), theta : Double) -> (Double, Double) {
  let c = @math.cos(theta)
  let s = @math.sin(theta)
  (c * pt.0 - s * pt.1, s * pt.0 + c * pt.1)
}

///|
fn involute_xz(radius : Double, theta : Double) -> (Double, Double) {
  let c = @math.cos(theta)
  let s = @math.sin(theta)
  (radius * (c + theta * s), radius * (s - theta * c))
}

///|
fn involute_theta(radius : Double, distance : Double) -> Double {
  let x = distance / radius
  Double::sqrt(x * x - 1.0)
}

///|
/// clean_polygon removes consecutive and seam points closer than eps
/// (squared distance eps2), preventing degenerate STEP faces.
fn clean_polygon(
  pts : Array[(Double, Double)],
  eps2 : Double,
) -> Array[(Double, Double)] {
  let out : Array[(Double, Double)] = []
  for pt in pts {
    let keep = out.length() == 0 || sq_dist(out[out.length() - 1], pt) > eps2
    if keep {
      out.push(pt)
    }
  }
  while out.length() > 1 && sq_dist(out[0], out[out.length() - 1]) <= eps2 {
    let _ = out.pop()
  }
  out
}