// Copyright (c) 2026 BigSaltyMan. BSL-1.0; see LICENSE.
// Normal, corner and arc formulas adapted from Clipper2 2.0.1,
// Copyright (c) Angus Johnson 2010-2025. Container and budget plumbing is MoonClip.
///|
pub(all) enum JoinType {
Miter
Bevel
Square
Round
} derive(Eq, Debug)
///|
pub struct OffsetOptions {
join : JoinType
miter_limit : Double
arc_tolerance : Double
} derive(Eq, Debug)
///|
pub fn OffsetOptions::new(
join? : JoinType = Miter,
miter_limit? : Double = 2.0,
arc_tolerance? : Double = 0.0,
) -> OffsetOptions {
{ join, miter_limit, arc_tolerance, }
}
///|
pub impl Default for OffsetOptions with fn default() {
OffsetOptions::new()
}
///|
priv struct OffsetVector {
x : Double
y : Double
}
///|
fn offset_spend(
budget : CallBudget,
nodes : Int,
work : Int,
) -> Unit raise ScanAbort {
budget_spend(budget, nodes, work, location(budget.generate_stage, None, None))
}
///|
fn offset_finite(
value : Double,
stage? : Stage = OffsetGenerate,
) -> Double raise ScanAbort {
if value.is_nan() || value.is_inf() {
raise ScanAbort(
NumericFailure(
location(stage, None, None),
"Non-finite offset intermediate",
),
)
}
value
}
///|
// std::round semantics, deliberately separate from scan ties-to-even projection.
fn offset_coordinate(
value : Double,
axis : Axis,
stage? : Stage = OffsetGenerate,
) -> Int64 raise ScanAbort {
let value = offset_finite(value, stage~)
let loc = location(stage, None, None)
// Check before conversion; this strict interior of Int64 also handles huge finite delta.
if value < -9.0e18 || value > 9.0e18 {
raise ScanAbort(
NumericFailure(loc, "Offset coordinate cannot be represented as Int64"),
)
}
let rounded = if value < 0.0 {
@double.ceil(value - 0.5)
} else {
@double.floor(value + 0.5)
}
let coordinate = rounded.to_int64()
if coordinate < -COORDINATE_LIMIT || coordinate > COORDINATE_LIMIT {
raise ScanAbort(CoordinateOutOfRange(loc, axis, coordinate))
}
coordinate
}
///|
fn offset_push(
path : Path64,
x : Double,
y : Double,
budget : CallBudget,
) -> Unit raise ScanAbort {
let x = offset_coordinate(x, X, stage=budget.generate_stage)
let y = offset_coordinate(y, Y, stage=budget.generate_stage)
offset_spend(budget, 1, 1)
path.push(Point64::new(x, y))
}
///|
fn offset_unit(
a : Point64,
b : Point64,
stage? : Stage = OffsetGenerate,
) -> OffsetVector raise ScanAbort {
let dx = (b.x - a.x).to_double()
let dy = (b.y - a.y).to_double()
if dx == 0.0 && dy == 0.0 {
return { x: 0.0, y: 0.0, }
}
let inverse = offset_finite(1.0 / (dx * dx + dy * dy).sqrt(), stage~)
{ x: dy * inverse, y: -(dx * inverse), }
}
///|
fn offset_miter(
path : Path64,
p : Point64,
a : OffsetVector,
b : OffsetVector,
delta : Double,
cosine : Double,
budget : CallBudget,
) -> Unit raise ScanAbort {
let q = offset_finite(delta / (cosine + 1.0), stage=budget.generate_stage)
offset_push(
path,
p.x.to_double() + (a.x + b.x) * q,
p.y.to_double() + (a.y + b.y) * q,
budget,
)
}
///|
// Source's segment-clamped line intersection, including its parallel fallback.
fn offset_intersect(
a : OffsetVector,
b : OffsetVector,
c : OffsetVector,
d : OffsetVector,
fallback : OffsetVector,
stage? : Stage = OffsetGenerate,
) -> OffsetVector raise ScanAbort {
let dx1 = b.x - a.x
let dy1 = b.y - a.y
let dx2 = d.x - c.x
let dy2 = d.y - c.y
let determinant = offset_finite(dy1 * dx2 - dy2 * dx1, stage~)
if determinant == 0.0 {
return fallback
}
let t = offset_finite(
((a.x - c.x) * dy2 - (a.y - c.y) * dx2) / determinant,
stage~,
)
if t <= 0.0 {
a
} else if t >= 1.0 {
b
} else {
{ x: a.x + t * dx1, y: a.y + t * dy1, }
}
}
///|
fn offset_square(
path : Path64,
p : Point64,
previous : Point64,
a : OffsetVector,
b : OffsetVector,
delta : Double,
budget : CallBudget,
is_cap? : Bool = false,
) -> Unit raise ScanAbort {
let vx = -a.y + b.y
let vy = a.x - b.x
let h = (vx * vx + vy * vy).sqrt()
let v : OffsetVector = if is_cap {
{ x: b.y, y: -b.x, }
} else if h.abs() < 0.001 {
{ x: 0.0, y: 0.0, }
} else {
let inverse = 1.0 / h
{ x: vx * inverse, y: vy * inverse, }
}
let q : OffsetVector = {
x: p.x.to_double() + delta.abs() * v.x,
y: p.y.to_double() + delta.abs() * v.y,
}
let first : OffsetVector = { x: q.x + delta * v.y, y: q.y + delta * -v.x, }
let second : OffsetVector = { x: q.x + delta * -v.y, y: q.y + delta * v.x, }
let third : OffsetVector = {
x: previous.x.to_double() + a.x * delta,
y: previous.y.to_double() + a.y * delta,
}
let fourth : OffsetVector = if is_cap {
{ x: third.x + v.x * delta, y: third.y + v.y * delta, }
} else {
{ x: p.x.to_double() + a.x * delta, y: p.y.to_double() + a.y * delta, }
}
let ip = offset_intersect(
first,
second,
third,
fourth,
q,
stage=budget.generate_stage,
)
if is_cap {
offset_push(path, q.x + (q.x - ip.x), q.y + (q.y - ip.y), budget)
offset_push(path, ip.x, ip.y, budget)
} else {
offset_push(path, ip.x, ip.y, budget)
offset_push(path, q.x + (q.x - ip.x), q.y + (q.y - ip.y), budget)
}
}
///|
priv struct OffsetArc {
step_sin : Double
step_cos : Double
steps_per_rad : Double
}
///|
// Fixed Clipper2 automatic tolerance, radius cap and global rotation step.
fn offset_arc_parameters(
delta : Double,
tolerance : Double,
stage? : Stage = OffsetGenerate,
) -> OffsetArc raise ScanAbort {
let radius = offset_finite(delta.abs(), stage~)
if radius < 0.5 {
raise ScanAbort(
NumericFailure(
location(stage, None, None),
"Arc parameters require radius at least 0.5",
),
)
}
let effective = if tolerance > 1.0e-12 {
if tolerance < radius {
tolerance
} else {
radius
}
} else {
radius * 0.002
}
let cap = offset_finite(radius * @math.PI, stage~)
let argument = 1.0 - effective / radius
// With sub-ULP tolerance acos(1)==0: source min(+Infinity, cap) selects cap.
// Express that branch explicitly, without creating a non-finite intermediate.
let steps = if argument == 1.0 {
cap
} else {
let requested = offset_finite(@math.PI / @math.acos(argument), stage~)
if requested < cap {
requested
} else {
cap
}
}
let step = offset_finite(2.0 * @math.PI / steps, stage~)
let sine = offset_finite(@math.sin(step), stage~)
{
step_sin: if delta < 0.0 {
-sine
} else {
sine
},
step_cos: offset_finite(@math.cos(step), stage~),
steps_per_rad: steps / (2.0 * @math.PI),
}
}
///|
// No charge here: offset_push charges every attempted sample, even equal grid points.
// Preflight the complete corner before any point allocation or integer conversion.
fn offset_arc_steps(
arc : OffsetArc,
angle : Double,
budget : CallBudget,
) -> Int raise ScanAbort {
let steps = offset_finite(
@double.ceil(
arc.steps_per_rad *
offset_finite(angle, stage=budget.generate_stage).abs(),
),
stage=budget.generate_stage,
)
if steps < 0.0 {
raise ScanAbort(
NumericFailure(
location(budget.generate_stage, None, None),
"Negative arc step count",
),
)
}
let points = if steps < 1.0 { 2.0 } else { steps + 1.0 }
let loc = location(budget.generate_stage, None, None)
if points > (budget.limits.max_nodes - budget.stats.nodes).to_double() {
raise ScanAbort(
ResourceLimitExceeded(loc, InternalNodes, budget.limits.max_nodes),
)
}
if points > (budget.limits.max_work - budget.stats.work).to_double() {
raise ScanAbort(
ResourceLimitExceeded(loc, ScanWork, budget.limits.max_work),
)
}
if steps > 2147483646.0 {
raise ScanAbort(
NumericFailure(loc, "Arc step count cannot be represented as Int"),
)
}
steps.to_int()
}
///|
fn offset_round(
path : Path64,
p : Point64,
a : OffsetVector,
b : OffsetVector,
delta : Double,
angle : Double,
arc : OffsetArc,
budget : CallBudget,
) -> Unit raise ScanAbort {
let steps = offset_arc_steps(arc, angle, budget)
let mut x = a.x * delta
let mut y = a.y * delta
offset_push(path, p.x.to_double() + x, p.y.to_double() + y, budget)
for i = 1; i < steps; i = i + 1 {
// Both components use the old vector, exactly as PointD's source constructor.
let next_x = x * arc.step_cos - arc.step_sin * y
y = x * arc.step_sin + y * arc.step_cos
x = next_x
offset_push(path, p.x.to_double() + x, p.y.to_double() + y, budget)
}
// Direct endpoint; do not accumulate rotations into the terminal normal.
offset_push(
path,
p.x.to_double() + b.x * delta,
p.y.to_double() + b.y * delta,
budget,
)
}
///|
fn offset_corner(
path : Path64,
p : Point64,
previous : Point64,
a : OffsetVector,
b : OffsetVector,
delta : Double,
options : OffsetOptions,
threshold : Double,
budget : CallBudget,
arc? : OffsetArc,
) -> Unit raise ScanAbort {
offset_spend(budget, 0, 1)
let raw_sine = offset_finite(
b.y * a.x - a.y * b.x,
stage=budget.generate_stage,
)
let sine = if raw_sine < -1.0 {
-1.0
} else if raw_sine > 1.0 {
1.0
} else {
raw_sine
}
let cosine = offset_finite(b.x * a.x + b.y * a.y, stage=budget.generate_stage)
if cosine > -0.999 && sine * delta < 0.0 {
offset_push(
path,
p.x.to_double() + a.x * delta,
p.y.to_double() + a.y * delta,
budget,
)
offset_push(path, p.x.to_double(), p.y.to_double(), budget)
offset_push(
path,
p.x.to_double() + b.x * delta,
p.y.to_double() + b.y * delta,
budget,
)
} else if cosine > 0.999 && options.join != Round {
offset_miter(path, p, a, b, delta, cosine, budget)
} else {
match options.join {
Square => offset_square(path, p, previous, a, b, delta, budget)
Round => {
let arc = match arc {
Some(value) => value
None =>
offset_arc_parameters(
delta,
options.arc_tolerance,
stage=budget.generate_stage,
)
}
offset_round(
path,
p,
a,
b,
delta,
offset_finite(@math.atan2(sine, cosine), stage=budget.generate_stage),
arc,
budget,
)
}
Miter =>
if cosine > threshold - 1.0 {
offset_miter(path, p, a, b, delta, cosine, budget)
} else {
offset_square(path, p, previous, a, b, delta, budget)
}
Bevel => {
offset_push(
path,
p.x.to_double() + delta * a.x,
p.y.to_double() + delta * a.y,
budget,
)
offset_push(
path,
p.x.to_double() + delta * b.x,
p.y.to_double() + delta * b.y,
budget,
)
}
}
}
}
///|
fn offset_generate(
paths : Paths64,
delta : Double,
options : OffsetOptions,
budget : CallBudget,
) -> Paths64 raise ScanAbort {
let threshold = match options.join {
Miter =>
2.0 /
offset_finite(
options.miter_limit * options.miter_limit,
stage=budget.generate_stage,
)
Bevel | Square | Round => 0.0
}
let arc = if options.join == Round {
Some(
offset_arc_parameters(
delta,
options.arc_tolerance,
stage=budget.generate_stage,
),
)
} else {
None
}
offset_spend(budget, 1, 1)
offset_spend(budget, paths.length(), paths.length())
let result : Paths64 = []
for path in paths {
offset_spend(budget, path.length() + 2, path.length())
let normals = Array::makei(path.length(), fn(i) raise ScanAbort {
offset_unit(
path[i],
path[(i + 1) % path.length()],
stage=budget.generate_stage,
)
})
let output : Path64 = []
for j = 0; j < path.length(); j = j + 1 {
let k = (j + path.length() - 1) % path.length()
offset_corner(
output,
path[j],
path[k],
normals[k],
normals[j],
delta,
options,
threshold,
budget,
arc?,
)
}
result.push(output)
}
result
}
///|
fn offset_options_error(options : OffsetOptions, stage : Stage) -> ClipError? {
let loc = location(stage, None, None)
if options.miter_limit.is_nan() ||
options.miter_limit.is_inf() ||
options.miter_limit < 2.0 {
return Some(InvalidInput(loc, "Miter limit must be finite and at least 2"))
}
if options.arc_tolerance.is_nan() ||
options.arc_tolerance.is_inf() ||
options.arc_tolerance < 0.0 {
return Some(
InvalidInput(loc, "Arc tolerance must be finite and non-negative"),
)
}
None
}
///|
// Only the result extraction differs between flat and tree outputs. The empty
// callback reuses the normalization result for flat output without extra fees.
fn[T] offset_compute(
paths : Paths64,
delta : Double,
fill_rule : FillRule,
options : OffsetOptions,
limits : ScanLimits,
extract : (ScanState) -> T raise ScanAbort,
empty : (Paths64, CallBudget) -> T raise ScanAbort,
budget? : CallBudget,
) -> Result[T, ClipError] {
let loc = location(OffsetParameters, None, None)
if delta.is_nan() || delta.is_inf() {
return Err(InvalidInput(loc, "Offset delta must be finite"))
}
if offset_options_error(options, OffsetParameters) is Some(error) {
return Err(error)
}
let budget = match budget {
Some(value) => value
None => new_call_budget(limits)
}
let limits = budget.limits
let rule = match fill_rule {
EvenOdd => Parity
NonZero => SignedNonZero
}
// A small distance still normalizes all operands. Extract the final tree
// here, while this scan owns cleanup; no second scan or flat snapshot.
if delta.abs() < 0.5 {
return boolean_compute(paths, [], Union, limits, rule, extract, budget~)
}
let normalized = match
boolean_compute(
paths,
[],
Union,
limits,
rule,
fn(state) raise ScanAbort {
output_extract(state, preserve_collinear=false)
},
budget~,
) {
Ok(value) => value
Err(error) => return Err(error)
}
// Normalization already produced independent, positive outer / negative hole rings.
if normalized.is_empty() {
return Ok(empty(normalized, budget)) catch {
ScanAbort(error) => Err(error)
}
}
let generated = offset_generate(normalized, delta, options, budget) catch {
ScanAbort(error) => return Err(error)
}
boolean_compute(
generated,
[],
Union,
limits,
Positive,
extract,
budget~,
internal_input=true,
)
}
///|
fn offset_execute(
paths : Paths64,
delta : Double,
fill_rule : FillRule,
options : OffsetOptions,
limits : ScanLimits,
budget? : CallBudget,
) -> Result[Paths64, ClipError] {
offset_compute(
paths,
delta,
fill_rule,
options,
limits,
fn(state) raise ScanAbort {
output_extract(state, preserve_collinear=false)
},
fn(paths, _) { paths },
budget?,
)
}
///|
fn offset_tree_execute(
paths : Paths64,
delta : Double,
fill_rule : FillRule,
options : OffsetOptions,
limits : ScanLimits,
budget? : CallBudget,
) -> Result[PolyTree64, ClipError] {
offset_compute(
paths,
delta,
fill_rule,
options,
limits,
tree_build,
fn(_, budget) raise ScanAbort { tree_empty(budget) },
budget?,
)
}
///|
/// Final offset region with geometric parentage. Introduced in 0.2.0.
pub fn offset_tree(
paths : Paths64,
delta : Double,
fill_rule? : FillRule = NonZero,
options? : OffsetOptions = Default::default(),
limits? : Limits = Default::default(),
) -> Result[PolyTree64, ClipError] {
offset_tree_execute(paths, delta, fill_rule, options, {
input: limits,
max_nodes: 500000,
max_work: 5000000,
})
}
///|
/// Closed filled-region offset. Normalize all inputs together before offsetting.
/// NonZero by default; positive delta expands outers and shrinks holes.
pub fn offset_polygons(
paths : Paths64,
delta : Double,
fill_rule? : FillRule = NonZero,
options? : OffsetOptions = Default::default(),
limits? : Limits = Default::default(),
) -> Result[Paths64, ClipError] {
offset_execute(paths, delta, fill_rule, options, {
input: limits,
max_nodes: 500000,
max_work: 5000000,
})
}