// Copyright (c) 2026 BigSaltyMan. BSL-1.0; see LICENSE.
// Open traversal/caps adapted from Clipper2 2.0.1,
// Copyright (c) Angus Johnson 2010-2025. Checked containers/budgets are MoonClip.
///|
pub(all) enum CapType {
Butt
Square
Round
} derive(Eq, Debug)
///|
fn stroke_cap(
output : Path64,
p : Point64,
normal : OffsetVector,
radius : Double,
cap : CapType,
arc : OffsetArc?,
budget : CallBudget,
) -> Unit raise ScanAbort {
offset_spend(budget, 0, 1)
match cap {
Butt => {
offset_push(
output,
p.x.to_double() - radius * normal.x,
p.y.to_double() - radius * normal.y,
budget,
)
offset_push(
output,
p.x.to_double() + radius * normal.x,
p.y.to_double() + radius * normal.y,
budget,
)
}
Square =>
offset_square(output, p, p, normal, normal, radius, budget, is_cap=true)
Round => {
guard arc is Some(arc) else {
raise ScanAbort(
NumericFailure(
location(StrokeGenerate, None, None),
"Missing round cap parameters",
),
)
}
offset_round(
output,
p,
{ x: -normal.x, y: -normal.y, },
normal,
radius,
@math.PI,
arc,
budget,
)
}
}
}
///|
fn stroke_prepare(path : Path64, budget : CallBudget) -> Path64 raise ScanAbort {
let work = match
scan_check_inputs(
[path],
[],
budget.limits,
spent_work=budget.stats.work,
raw_stage=StrokeGenerate,
) {
Ok(work) => work
Err(error) => raise ScanAbort(error)
}
offset_spend(budget, 1, work)
let output : Path64 = []
for point in path {
offset_spend(budget, 0, 1)
if output.is_empty() || output[output.length() - 1] != point {
offset_spend(budget, 1, 1)
output.push(point)
}
}
output
}
///|
fn stroke_generate(
path : Path64,
radius : Double,
cap : CapType,
options : OffsetOptions,
budget : CallBudget,
) -> Paths64 raise ScanAbort {
let n = path.length()
let threshold = if n > 2 && options.join == Miter {
2.0 /
offset_finite(
options.miter_limit * options.miter_limit,
stage=budget.generate_stage,
)
} else {
0.0
}
let arc = if cap == Round || (n > 2 && options.join == Round) {
Some(
offset_arc_parameters(
radius,
options.arc_tolerance,
stage=budget.generate_stage,
),
)
} else {
None
}
offset_spend(budget, n + 1, n)
let normals : Array[OffsetVector] = Array::makei(n, fn(i) raise ScanAbort {
if i == n - 1 {
{ x: 0.0, y: 0.0, }
} else {
offset_unit(path[i], path[i + 1], stage=budget.generate_stage)
}
})
offset_spend(budget, 3, 1)
let output : Path64 = []
stroke_cap(output, path[0], normals[0], radius, cap, arc, budget)
for j = 1; j < n - 1; j = j + 1 {
offset_corner(
output,
path[j],
path[j - 1],
normals[j - 1],
normals[j],
radius,
options,
threshold,
budget,
arc?,
)
}
// Reverse each directed edge normal without reversing or closing the route.
for i = n - 1; i > 0; i = i - 1 {
offset_spend(budget, 0, 1)
normals[i] = { x: -normals[i - 1].x, y: -normals[i - 1].y, }
}
normals[0] = normals[n - 1]
stroke_cap(output, path[n - 1], normals[n - 1], radius, cap, arc, budget)
for j = n - 2; j > 0; j = j - 1 {
offset_corner(
output,
path[j],
path[j + 1],
normals[j + 1],
normals[j],
radius,
options,
threshold,
budget,
arc?,
)
}
[output]
}
///|
// Shared open-route preparation and generation; select the final representation
// while cleanup still belongs to the scan. Empty routes never start a scan.
fn[T] stroke_compute(
path : Path64,
width : Double,
cap : CapType,
options : OffsetOptions,
limits : ScanLimits,
extract : (ScanState) -> T raise ScanAbort,
empty : (CallBudget) -> T raise ScanAbort,
budget? : CallBudget,
) -> Result[T, ClipError] {
if width.is_nan() || width.is_inf() || width < 0.0 {
return Err(
InvalidInput(
location(StrokeParameters, None, None),
"Stroke width must be finite and non-negative",
),
)
}
if offset_options_error(options, StrokeParameters) is Some(error) {
return Err(error)
}
let budget = match budget {
Some(value) => value
None =>
new_call_budget(
limits,
generate_stage=StrokeGenerate,
cleanup_stage=StrokeCleanup,
)
}
if budget.limits.input.max_input_paths < 0 ||
budget.limits.input.max_input_vertices < 0 ||
budget.limits.max_nodes < 0 ||
budget.limits.max_work < 0 {
return Err(
InvalidInput(
location(StrokeParameters, None, None),
"Stroke limits must be non-negative",
),
)
}
let prepared = stroke_prepare(path, budget) catch {
ScanAbort(error) => return Err(error)
}
if width < 1.0 || prepared.length() < 2 {
return Ok(empty(budget)) catch { ScanAbort(error) => Err(error) }
}
let generated = stroke_generate(prepared, width / 2.0, cap, options, budget) catch {
ScanAbort(error) => return Err(error)
}
boolean_compute(
generated,
[],
Union,
budget.limits,
Positive,
extract,
budget~,
internal_input=true,
)
}
///|
fn stroke_execute(
path : Path64,
width : Double,
cap : CapType,
options : OffsetOptions,
limits : ScanLimits,
budget? : CallBudget,
) -> Result[Paths64, ClipError] {
stroke_compute(
path,
width,
cap,
options,
limits,
fn(state) raise ScanAbort {
output_extract(state, preserve_collinear=false)
},
fn(_) { [] },
budget?,
)
}
///|
fn stroke_tree_execute(
path : Path64,
width : Double,
cap : CapType,
options : OffsetOptions,
limits : ScanLimits,
budget? : CallBudget,
) -> Result[PolyTree64, ClipError] {
stroke_compute(
path,
width,
cap,
options,
limits,
tree_build,
tree_empty,
budget?,
)
}
///|
/// Final filled hierarchy of a single open route. Introduced in 0.2.0.
pub fn stroke_tree(
path : Path64,
width : Double,
cap? : CapType = Butt,
options? : OffsetOptions = Default::default(),
limits? : Limits = Default::default(),
) -> Result[PolyTree64, ClipError] {
stroke_tree_execute(path, width, cap, options, {
input: limits,
max_nodes: 500000,
max_work: 5000000,
})
}
///|
/// Stroke a single open route with full width, two sides at width / 2.
/// Width below 1 and routes with fewer than two effective points produce no area.
pub fn stroke_polyline(
path : Path64,
width : Double,
cap? : CapType = Butt,
options? : OffsetOptions = Default::default(),
limits? : Limits = Default::default(),
) -> Result[Paths64, ClipError] {
stroke_execute(path, width, cap, options, {
input: limits,
max_nodes: 500000,
max_work: 5000000,
})
}