fix(offset): handle curved self-intersections

Closes #544
This commit is contained in:
Hakan Seven 2026-07-30 00:06:02 +03:00
commit d495e31d37
3 changed files with 287 additions and 167 deletions

26
Cargo.lock generated
View file

@ -19,6 +19,7 @@ dependencies = [
"acadrust", "acadrust",
"bincode", "bincode",
"bytemuck", "bytemuck",
"cavalier_contours",
"clap", "clap",
"console_error_panic_hook", "console_error_panic_hook",
"cosmic-text", "cosmic-text",
@ -898,6 +899,16 @@ dependencies = [
"wayland-client", "wayland-client",
] ]
[[package]]
name = "cavalier_contours"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31cab9e73a5a3533d3d6fb36818e8735dd033e080fa70a63d90846c8183708c9"
dependencies = [
"num-traits",
"static_aabb2d_index",
]
[[package]] [[package]]
name = "cbc" name = "cbc"
version = "0.1.2" version = "0.1.2"
@ -3259,7 +3270,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b577e2d69827c4740cba2b52efaad1c4cc7c73042860b199710b3575c68438d" checksum = "0b577e2d69827c4740cba2b52efaad1c4cc7c73042860b199710b3575c68438d"
dependencies = [ dependencies = [
"bytecount", "bytecount",
"memchr 1.0.2", "memchr 2.8.3",
"nom 8.0.0", "nom 8.0.0",
] ]
@ -4768,7 +4779,7 @@ dependencies = [
"security-framework", "security-framework",
"security-framework-sys", "security-framework-sys",
"webpki-root-certs", "webpki-root-certs",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@ -5278,6 +5289,15 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "static_aabb2d_index"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1204bd3057a2225dc60d157bd921aaeaa4d318527bd32af4678cad39e5279849"
dependencies = [
"num-traits",
]
[[package]] [[package]]
name = "static_assertions" name = "static_assertions"
version = "1.1.0" version = "1.1.0"
@ -6920,7 +6940,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"windows-sys 0.59.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]

View file

@ -83,6 +83,8 @@ ttf-parser = "0.25"
# render through our own wire pipeline. # render through our own wire pipeline.
cosmic-text = "0.15" cosmic-text = "0.15"
lyon_tessellation = "1.0.20" lyon_tessellation = "1.0.20"
# Exact line/arc polyline offsets with self-intersection slicing and stitching.
cavalier_contours = "=0.7.0"
[dev-dependencies] [dev-dependencies]
# Shader-interface regression tests. This is already present transitively # Shader-interface regression tests. This is already present transitively

View file

@ -19,6 +19,14 @@ use acadrust::entities::{
Spline as SplineEnt, XLine as XLineEnt, Spline as SplineEnt, XLine as XLineEnt,
}; };
use acadrust::{EntityType, Handle}; use acadrust::{EntityType, Handle};
use cavalier_contours::core::math::Vector2 as CavVector2;
use cavalier_contours::polyline::internal::pline_offset::{
create_raw_offset_polyline, slices_from_dual_raw_offsets, stitch_slices_together,
};
use cavalier_contours::polyline::{
seg_tangent_vector, PlineOffsetOptions, PlineSource, PlineSourceMut,
Polyline as CavPolyline,
};
use glam::{DVec3, Vec3}; use glam::{DVec3, Vec3};
use crate::command::{CadCommand, CmdResult}; use crate::command::{CadCommand, CmdResult};
@ -162,178 +170,267 @@ fn offset_arc(a: &ArcEnt, dist: f64, side_pt: Vec3) -> Option<EntityType> {
// ── LwPolyline offset ────────────────────────────────────────────────────── // ── LwPolyline offset ──────────────────────────────────────────────────────
// //
// Algorithm: // A raw exact line/arc offset can fold over itself at concave corners or when
// 1. Offset every segment by `dist` in the direction perpendicular to it // the distance is larger than a narrow part of the polyline. The selected
// (sign is determined once from the first non-degenerate segment + side_pt). // algorithm splits that raw curve at every intersection, rejects slices whose
// 2. Reconnect adjacent offset segments: // distance to the source is below the requested offset, and stitches the
// - Open: first / last vertex use the raw offset endpoints; // remaining slices. This can legitimately return several disconnected
// interior vertices are the intersection of adjacent offset segments. // polylines.
// - Closed: every vertex is the intersection of the previous and next
// offset segments. const OFFSET_POS_EPS: f64 = 1e-5;
// 3. Bulge values are preserved from the original vertices (arc segments const OFFSET_JOIN_EPS: f64 = 1e-4;
// keep the same angle; the radius changes implicitly via the new chord
// length — a minor approximation acceptable for modest offsets). /// Convert an acad LWPOLYLINE to the line/arc representation used by the
/// topology pass. Coordinates are translated and divided by `dist`, so the
/// offset passed to the algorithm is always ±1. This avoids fixed-epsilon
/// failures on tiny drawings and on UTM-scale coordinates.
fn normalized_offset_source(
p: &LwPolyline,
dist: f64,
) -> Option<(CavPolyline<f64>, [f64; 2])> {
let first = p.vertices.first()?;
let origin = [first.location.x, first.location.y];
let normalize = |point: [f64; 2]| {
[
(point[0] - origin[0]) / dist,
(point[1] - origin[1]) / dist,
]
};
fn offset_lwpolyline(p: &LwPolyline, dist: f64, side_pt: Vec3) -> Option<EntityType> {
let n = p.vertices.len(); let n = p.vertices.len();
if n < 2 { if n < 2 {
return None; return None;
} }
let segment_count = if p.is_closed { n } else { n - 1 };
let mut source = if p.is_closed {
CavPolyline::new_closed()
} else {
CavPolyline::new()
};
let n_segs = if p.is_closed { n } else { n - 1 }; for index in 0..segment_count {
let start = &p.vertices[index];
let end = &p.vertices[(index + 1) % n];
let p0 = [start.location.x, start.location.y];
let p1 = [end.location.x, end.location.y];
let bulge = if start.bulge.is_finite() {
start.bulge
} else {
0.0
};
let q0 = normalize(p0);
// Determine the offset sign (which side the left normal `(-dy, dx)` is // CavalierContours represents arcs up to a half turn per segment.
// scaled toward). // Split major bulge arcs at their exact midpoint; both halves retain
let sign: f64 = if p.is_closed { // the original circle and traversal direction.
// For a closed loop the side is unambiguous: a pick inside the loop if bulge.abs() > 1.0 {
// offsets inward, outside offsets outward. Decide that with a if let Some(arc) =
// point-in-polygon test and map it to the normal via the winding — crate::entities::common::BulgeArc::from_bulge(p0, p1, bulge)
// the left normal points inward for a CCW loop. (The first-segment
// heuristic used for open paths misreads a pick placed *beside* the
// shape: it is outside the loop yet on the inner half-plane of the
// first edge's infinite line, so a CCW rectangle offset outward by a
// side pick wrongly collapsed inward.)
let pts: Vec<[f64; 2]> = p
.vertices
.iter()
.map(|v| [v.location.x, v.location.y])
.collect();
// Signed area ×2: > 0 ⇒ counter-clockwise.
let mut area2 = 0.0;
for i in 0..pts.len() {
let a = pts[i];
let b = pts[(i + 1) % pts.len()];
area2 += a[0] * b[1] - b[0] * a[1];
}
let ccw = area2 > 0.0;
// Ray-cast point-in-polygon for the pick point.
let (sx, sy) = (side_pt.x as f64, side_pt.y as f64);
let mut inside = false;
let mut j = pts.len() - 1;
for i in 0..pts.len() {
let (xi, yi) = (pts[i][0], pts[i][1]);
let (xj, yj) = (pts[j][0], pts[j][1]);
if ((yi > sy) != (yj > sy))
&& (sx < (xj - xi) * (sy - yi) / (yj - yi) + xi)
{ {
inside = !inside; let half_bulge = (arc.sweep / 8.0).tan();
let midpoint = normalize(arc.sample(0.5));
source.add(q0[0], q0[1], half_bulge);
source.add(midpoint[0], midpoint[1], half_bulge);
continue;
} }
j = i;
} }
// left normal inward ⇔ CCW; want inward ⇔ pick is inside.
if inside == ccw { source.add(q0[0], q0[1], bulge);
1.0 }
if !p.is_closed {
let last = &p.vertices[n - 1];
let point = normalize([last.location.x, last.location.y]);
source.add(point[0], point[1], 0.0);
}
let source = source
.remove_repeat_pos(OFFSET_POS_EPS)
.unwrap_or(source);
(source.vertex_count() >= 2).then_some((source, origin))
}
/// CavalierContours deliberately connects diverging line offsets with a round
/// arc. OFFSETGAPTYPE=0 (and OpenCADStudio's previous behavior) instead extends
/// the two lines to a sharp projected intersection. Replace only those
/// generated line-line connection arcs before the self-intersection pass.
fn sharpen_line_connections(raw: &mut CavPolyline<f64>, source: &CavPolyline<f64>) {
loop {
let count = raw.vertex_data.len();
if count < 4 {
return;
}
let mut changed = false;
for index in 0..count {
if !raw.is_closed && index == 0 {
continue;
}
let next = if index + 1 < count {
index + 1
} else if raw.is_closed {
0
} else { } else {
-1.0 continue;
} };
let after = if next + 1 < count {
next + 1
} else if raw.is_closed {
0
} else { } else {
// Open path: no inside/outside, so use the side of the first continue;
// non-degenerate segment relative to the pick. };
(0..n_segs).find_map(|i| { let previous = if index > 0 {
let v0 = &p.vertices[i]; index - 1
let v1 = &p.vertices[(i + 1) % n]; } else if raw.is_closed {
let dx = v1.location.x - v0.location.x; count - 1
let dy = v1.location.y - v0.location.y; } else {
let len = (dx * dx + dy * dy).sqrt(); continue;
if len < 1e-12 {
return None;
}
let vx = side_pt.x as f64 - v0.location.x;
let vy = side_pt.y as f64 - v0.location.y;
let cross = dx * vy - dy * vx;
Some(if cross >= 0.0 { 1.0 } else { -1.0 })
})?
}; };
// Offset each segment. A segment may be degenerate (zero length) → None. let arc_start = raw.vertex_data[index];
struct OffSeg { let arc_end = raw.vertex_data[next];
p0: [f64; 2], if arc_start.bulge.abs() < OFFSET_POS_EPS
p1: [f64; 2], || raw.vertex_data[previous].bulge.abs() >= OFFSET_POS_EPS
|| arc_end.bulge.abs() >= OFFSET_POS_EPS
{
continue;
} }
let segs: Vec<Option<OffSeg>> = (0..n_segs) let Some(connection) = crate::entities::common::BulgeArc::from_bulge(
.map(|i| { [arc_start.x, arc_start.y],
let v0 = &p.vertices[i]; [arc_end.x, arc_end.y],
let v1 = &p.vertices[(i + 1) % n]; arc_start.bulge,
let dx = v1.location.x - v0.location.x; ) else {
let dy = v1.location.y - v0.location.y; continue;
let len = (dx * dx + dy * dy).sqrt(); };
if len < 1e-12 { if (connection.radius - 1.0).abs() > OFFSET_JOIN_EPS {
return None; continue;
} }
let ox = sign * (-dy / len) * dist; let generated_at_source_vertex = source.iter_vertexes().any(|vertex| {
let oy = sign * (dx / len) * dist; let dx = vertex.x - connection.center[0];
Some(OffSeg { let dy = vertex.y - connection.center[1];
p0: [v0.location.x + ox, v0.location.y + oy], dx * dx + dy * dy <= OFFSET_JOIN_EPS * OFFSET_JOIN_EPS
p1: [v1.location.x + ox, v1.location.y + oy], });
}) if !generated_at_source_vertex {
continue;
}
let before = raw.vertex_data[previous];
let after_vertex = raw.vertex_data[after];
let Some(point) = isect_lines(
[before.x, before.y],
[arc_start.x, arc_start.y],
[arc_end.x, arc_end.y],
[after_vertex.x, after_vertex.y],
) else {
continue;
};
raw.vertex_data[index].x = point[0];
raw.vertex_data[index].y = point[1];
raw.vertex_data[index].bulge = 0.0;
raw.vertex_data.remove(next);
changed = true;
break;
}
if !changed {
return;
}
}
}
fn cleaned_parallel_offset(
source: &CavPolyline<f64>,
signed_offset: f64,
) -> Vec<CavPolyline<f64>> {
let options = PlineOffsetOptions {
handle_self_intersects: true,
pos_equal_eps: OFFSET_POS_EPS,
slice_join_eps: OFFSET_JOIN_EPS,
offset_dist_eps: OFFSET_JOIN_EPS,
..Default::default()
};
let source_index = source.create_approx_aabb_index();
let mut raw: CavPolyline<f64> =
create_raw_offset_polyline(source, signed_offset, OFFSET_POS_EPS);
if raw.is_empty() {
return Vec::new();
}
let mut dual: CavPolyline<f64> =
create_raw_offset_polyline(source, -signed_offset, OFFSET_POS_EPS);
sharpen_line_connections(&mut raw, source);
sharpen_line_connections(&mut dual, source);
let slices = slices_from_dual_raw_offsets(
source,
&raw,
&dual,
&source_index,
signed_offset,
&options,
);
stitch_slices_together::<_, f64, CavPolyline<f64>>(
&raw,
&slices,
source.is_closed(),
raw.vertex_count(),
&options,
)
}
fn offset_lwpolylines(p: &LwPolyline, dist: f64, side_pt: Vec3) -> Vec<EntityType> {
let dist = dist.abs();
if dist < 1e-12 {
return Vec::new();
}
let Some((source, origin)) = normalized_offset_source(p, dist) else {
return Vec::new();
};
let side = CavVector2::new(
(side_pt.x as f64 - origin[0]) / dist,
(side_pt.y as f64 - origin[1]) / dist,
);
let Some(closest) = source.closest_point(side, OFFSET_POS_EPS) else {
return Vec::new();
};
let start_index = closest.seg_start_index;
let tangent = seg_tangent_vector(
source.at(start_index),
source.at(source.next_wrapping_index(start_index)),
closest.seg_point,
);
let toward_pick = side - closest.seg_point;
let cross = tangent.x * toward_pick.y - tangent.y * toward_pick.x;
let signed_offset = if cross >= 0.0 { 1.0 } else { -1.0 };
cleaned_parallel_offset(&source, signed_offset)
.into_iter()
.filter(|result| result.vertex_count() >= 2)
.map(|result| {
let mut new_polyline = p.clone();
new_polyline.common.handle = Handle::NULL;
new_polyline.is_closed = result.is_closed();
new_polyline.vertices = result
.iter_vertexes()
.map(|vertex| {
let mut output = LwVertex::from_coords(
origin[0] + vertex.x * dist,
origin[1] + vertex.y * dist,
);
output.bulge = vertex.bulge;
output
}) })
.collect(); .collect();
EntityType::LwPolyline(new_polyline)
let m = segs.len(); })
.collect()
// Helper: corner vertex from the intersection of two consecutive offset segments.
let corner = |prev: &OffSeg, curr: &OffSeg| -> [f64; 2] {
isect_lines(prev.p0, prev.p1, curr.p0, curr.p1).unwrap_or([
(prev.p1[0] + curr.p0[0]) * 0.5,
(prev.p1[1] + curr.p0[1]) * 0.5,
])
};
let mut new_verts: Vec<LwVertex> = Vec::new();
if p.is_closed {
for i in 0..m {
let prev_idx = (i + m - 1) % m;
let prev = match &segs[prev_idx] {
Some(s) => s,
None => continue,
};
let curr = match &segs[i] {
Some(s) => s,
None => continue,
};
let pt = corner(prev, curr);
let mut v = LwVertex::from_coords(pt[0], pt[1]);
v.bulge = p.vertices[i].bulge;
new_verts.push(v);
}
} else {
// First vertex
if let Some(s) = &segs[0] {
let mut v = LwVertex::from_coords(s.p0[0], s.p0[1]);
v.bulge = p.vertices[0].bulge;
new_verts.push(v);
}
// Interior vertices
for i in 1..m {
let prev = match &segs[i - 1] {
Some(s) => s,
None => continue,
};
let curr = match &segs[i] {
Some(s) => s,
None => continue,
};
let pt = corner(prev, curr);
let mut v = LwVertex::from_coords(pt[0], pt[1]);
v.bulge = p.vertices[i].bulge;
new_verts.push(v);
}
// Last vertex
if let Some(s) = &segs[m - 1] {
new_verts.push(LwVertex::from_coords(s.p1[0], s.p1[1]));
}
} }
if new_verts.len() < 2 { #[cfg(test)]
return None; fn offset_lwpolyline(p: &LwPolyline, dist: f64, side_pt: Vec3) -> Option<EntityType> {
} offset_lwpolylines(p, dist, side_pt).into_iter().next()
let mut new_p = p.clone();
new_p.common.handle = Handle::NULL;
new_p.vertices = new_verts;
Some(EntityType::LwPolyline(new_p))
} }
// ── Ellipse offset ───────────────────────────────────────────────────────── // ── Ellipse offset ─────────────────────────────────────────────────────────
@ -446,16 +543,16 @@ fn offset_spline(spl: &SplineEnt, dist: f64, side_pt: Vec3) -> Option<EntityType
// ── Dispatch ─────────────────────────────────────────────────────────────── // ── Dispatch ───────────────────────────────────────────────────────────────
fn compute_offset(entity: &EntityType, dist: f64, side_pt: Vec3) -> Option<EntityType> { fn compute_offsets(entity: &EntityType, dist: f64, side_pt: Vec3) -> Vec<EntityType> {
match entity { match entity {
EntityType::Line(l) => offset_line(l, dist, side_pt), EntityType::Line(l) => offset_line(l, dist, side_pt).into_iter().collect(),
EntityType::Circle(c) => offset_circle(c, dist, side_pt), EntityType::Circle(c) => offset_circle(c, dist, side_pt).into_iter().collect(),
EntityType::Arc(a) => offset_arc(a, dist, side_pt), EntityType::Arc(a) => offset_arc(a, dist, side_pt).into_iter().collect(),
EntityType::LwPolyline(p) => offset_lwpolyline(p, dist, side_pt), EntityType::LwPolyline(p) => offset_lwpolylines(p, dist, side_pt),
EntityType::Ellipse(e) => offset_ellipse(e, dist, side_pt), EntityType::Ellipse(e) => offset_ellipse(e, dist, side_pt).into_iter().collect(),
EntityType::Spline(s) => offset_spline(s, dist, side_pt), EntityType::Spline(s) => offset_spline(s, dist, side_pt).into_iter().collect(),
EntityType::XLine(x) => offset_xline(x, dist, side_pt), EntityType::XLine(x) => offset_xline(x, dist, side_pt).into_iter().collect(),
_ => None, _ => Vec::new(),
} }
} }
@ -703,7 +800,7 @@ pub struct OffsetCommand {
preselected: Vec<EntityType>, preselected: Vec<EntityType>,
} }
/// The entity types `compute_offset` can offset. /// The entity types `compute_offsets` can offset.
pub fn is_offsettable(e: &EntityType) -> bool { pub fn is_offsettable(e: &EntityType) -> bool {
matches!( matches!(
e, e,
@ -832,7 +929,7 @@ impl CadCommand for OffsetCommand {
.entity_index.get(&self.all_entities, handle) .entity_index.get(&self.all_entities, handle)
.cloned(); .cloned();
// Accept every type compute_offset can offset — including XLine (#296), // Accept every type compute_offsets can offset — including XLine (#296),
// and Ellipse/Spline whose offset functions existed but weren't reachable. // and Ellipse/Spline whose offset functions existed but weren't reachable.
match entity { match entity {
Some(e) if is_offsettable(&e) => { Some(e) if is_offsettable(&e) => {
@ -949,9 +1046,7 @@ impl CadCommand for OffsetCommand {
if mag < 1e-9 { if mag < 1e-9 {
continue; continue;
} }
if let Some(new_entity) = compute_offset(entity, mag, pt.as_vec3()) { news.extend(compute_offsets(entity, mag, pt.as_vec3()));
news.push(new_entity);
}
} }
if news.is_empty() { if news.is_empty() {
return CmdResult::NeedPoint; return CmdResult::NeedPoint;
@ -992,16 +1087,19 @@ impl CadCommand for OffsetCommand {
_ => return vec![], _ => return vec![],
}; };
let mut wires = Vec::new(); let mut wires = Vec::new();
for (n, entity) in targets.iter().enumerate() { for (target_index, entity) in targets.iter().enumerate() {
let mag = locked.unwrap_or_else(|| perp_distance(entity, pt.as_vec3())); let mag = locked.unwrap_or_else(|| perp_distance(entity, pt.as_vec3()));
if mag < 1e-9 { if mag < 1e-9 {
continue; continue;
} }
if let Some(result) = compute_offset(entity, mag, pt.as_vec3()) { for (result_index, result) in compute_offsets(entity, mag, pt.as_vec3())
.into_iter()
.enumerate()
{
let pts = entity_wire_pts(&result); let pts = entity_wire_pts(&result);
if !pts.is_empty() { if !pts.is_empty() {
wires.push(WireModel::solid( wires.push(WireModel::solid(
format!("offset_preview_{n}"), format!("offset_preview_{target_index}_{result_index}"),
pts, pts,
WireModel::CYAN, WireModel::CYAN,
false, false,