refactor(offset): take polyline offsetting from cadkernel

The offset core moves out: normalising the source, the cavalier_contours
call, the sharp-corner join fixup and the conversion back. What stays is
entity work — reading an LwPolyline in, writing offset LwPolylines out,
and the per-type offsets for lines, arcs, circles, ellipses and splines,
which are analytic and never needed the polyline machinery.

`BulgeArc` moves with it, since the offset preprocessing splits over-half-
turn arcs and cannot work without it. `entities::common` now re-exports it
from the kernel, so the twelve modules already reaching for
`entities::common::BulgeArc` are untouched.

`norm_rad` was a fourth copy of angle normalisation, after the three
removed from trim, fillet and explode. It now aliases the kernel's.

cavalier_contours leaves this crate's manifest: nothing here calls it any
more, and it arrives through the kernel's `offset` feature instead, which
acadifc forwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-08-08 16:01:43 +03:00
commit 397fb38292
6 changed files with 35 additions and 347 deletions

8
Cargo.lock generated
View file

@ -20,7 +20,6 @@ dependencies = [
"ashpd",
"bincode",
"bytemuck",
"cavalier_contours",
"clap",
"console_error_panic_hook",
"cosmic-text 0.15.0",
@ -86,7 +85,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
[[package]]
name = "acadifc"
version = "0.5.0"
source = "git+https://github.com/OpenAEC-Foundation/acadifc.git?rev=08a3d71#08a3d71f567cd72ee1ae3e7a8099054646756747"
source = "git+https://github.com/OpenAEC-Foundation/acadifc.git?rev=0a61cf9#0a61cf98b6736e070e02be3bc7a1818ff7522095"
dependencies = [
"acadrust",
"base64 0.22.1",
@ -946,7 +945,10 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cadkernel"
version = "0.1.0"
source = "git+https://github.com/HakanSeven12/cadkernel.git#46e76ccb737a3682ffccf5117e89879ebfd1e227"
source = "git+https://github.com/HakanSeven12/cadkernel.git#9c7b2b75ab76806d086f2e7eca17750771150dba"
dependencies = [
"cavalier_contours",
]
[[package]]
name = "calloop"

View file

@ -38,7 +38,7 @@ env_logger = "0.11"
# The CAD stack is reached through acadifc, which re-exports the codec and
# the geometry kernel. Aliased to `acadrust` so existing `use acadrust::…`
# paths keep resolving.
acadrust = { package = "acadifc", git = "https://github.com/OpenAEC-Foundation/acadifc.git", rev = "08a3d71", features = ["serde"] }
acadrust = { package = "acadifc", git = "https://github.com/OpenAEC-Foundation/acadifc.git", rev = "0a61cf9", features = ["serde", "offset"] }
dwg-thumbnailer = { path = "crates/dwg-thumbnailer" }
flate2 = "1"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] }
@ -57,7 +57,6 @@ fontdb = "0.23"
ttf-parser = "0.25"
cosmic-text = "0.15"
lyon_tessellation = "1.0.20"
cavalier_contours = "=0.7.0"
[dev-dependencies]
naga = { version = "27", features = ["wgsl-in"] }

View file

@ -9,7 +9,7 @@ license = "GPL-3.0-only"
# Pulled in only by the `host` feature, which adds the `acadrust`-typed
# `HostApi` runtime surface. The default crate stays dependency-free so engine
# crates and external tooling can depend on the manifest/ribbon contract cheaply.
acadrust = { package = "acadifc", git = "https://github.com/OpenAEC-Foundation/acadifc.git", rev = "08a3d71", optional = true, features = ["serde"] }
acadrust = { package = "acadifc", git = "https://github.com/OpenAEC-Foundation/acadifc.git", rev = "0a61cf9", optional = true, features = ["serde"] }
# Runtime IPC and serialization (host feature only).
interprocess = { version = "2", optional = true }

View file

@ -8,7 +8,7 @@ publish = false
crate-type = ["cdylib"]
[dependencies]
acadrust = { package = "acadifc", git = "https://github.com/OpenAEC-Foundation/acadifc.git", rev = "08a3d71", features = ["serde"] }
acadrust = { package = "acadifc", git = "https://github.com/OpenAEC-Foundation/acadifc.git", rev = "0a61cf9", features = ["serde"] }
bincode = "1.3"
console_error_panic_hook = "0.1"
getrandom = { version = "0.3", features = ["wasm_js"] }

View file

@ -582,90 +582,12 @@ pub fn parse_angle_deg(value: &str) -> Option<f64> {
Some(if neg { -total } else { total })
}
/// Bulge → arc geometry for a polyline segment.
/// Bulge → arc geometry for a polyline segment, from the kernel.
///
/// DXF/DWG polyline arcs are encoded as a bulge factor on each vertex —
/// `bulge = tan(theta/4)` where `theta` is the included angle of the arc
/// from `p0` to `p1`. Sign convention: positive bulge = CCW from p0 to p1,
/// negative = CW. `|bulge| = 1` is a half-circle.
///
/// This struct centralises the (formerly duplicated) math that takes
/// `(p0, p1, bulge)` and produces the canonical `(center, radius,
/// start_angle, sweep)` quadruple. Callsites pick the fields they need.
#[derive(Clone, Copy, Debug)]
pub struct BulgeArc {
pub center: [f64; 2],
pub radius: f64,
/// Angle from center to p0 (atan2, range -π..π).
pub start_angle: f64,
/// Angle from center to p1 (atan2, range -π..π).
pub end_angle: f64,
/// Signed sweep from p0 to p1. Positive ⇒ CCW (bulge > 0),
/// negative ⇒ CW (bulge < 0). For exact half-turns the sign of
/// `bulge` decides the direction.
pub sweep: f64,
}
impl BulgeArc {
/// Build from endpoints + bulge. Returns `None` for degenerate input
/// (chord ≈ 0 or |bulge| ≈ 0).
pub fn from_bulge(p0: [f64; 2], p1: [f64; 2], bulge: f64) -> Option<Self> {
let chord_x = p1[0] - p0[0];
let chord_y = p1[1] - p0[1];
let chord_len = (chord_x * chord_x + chord_y * chord_y).sqrt();
if chord_len < 1e-12 || bulge.abs() < 1e-12 {
return None;
}
let b = bulge;
let b2 = b * b;
// r = chord · (1 + b²) / (4·|b|)
let r = chord_len * (1.0 + b2) / (4.0 * b.abs());
// d_perp = signed distance from chord midpoint to arc center
// = r · (1 - b²) / (1 + b²) = r · cos(theta/2)
let d_perp = r * (1.0 - b2) / (1.0 + b2);
let mx = (p0[0] + p1[0]) * 0.5;
let my = (p0[1] + p1[1]) * 0.5;
// Left perpendicular to chord (90° CCW).
let perp_x = -chord_y / chord_len;
let perp_y = chord_x / chord_len;
let sign = b.signum();
let cx = mx + sign * d_perp * perp_x;
let cy = my + sign * d_perp * perp_y;
let a0 = (p0[1] - cy).atan2(p0[0] - cx);
let a1 = (p1[1] - cy).atan2(p1[0] - cx);
// Wrap sweep to match bulge sign: bulge>0 ⇒ positive (CCW),
// bulge<0 ⇒ negative (CW). Falls back to ±τ for half-turns.
const TAU: f64 = std::f64::consts::TAU;
let mut sweep = a1 - a0;
if bulge > 0.0 {
if sweep <= 0.0 {
sweep += TAU;
}
} else if sweep >= 0.0 {
sweep -= TAU;
}
if sweep.abs() < 1e-9 {
sweep = if bulge > 0.0 { TAU } else { -TAU };
}
Some(Self {
center: [cx, cy],
radius: r,
start_angle: a0,
end_angle: a1,
sweep,
})
}
/// Sample a point on the arc at parameter `t ∈ [0, 1]`. `t=0` ↦ p0,
/// `t=1` ↦ p1, walks along the signed sweep direction.
pub fn sample(&self, t: f64) -> [f64; 2] {
let a = self.start_angle + self.sweep * t;
[
self.center[0] + self.radius * a.cos(),
self.center[1] + self.radius * a.sin(),
]
}
}
/// Re-exported rather than imported at each call site so the twelve modules
/// that already reach for `entities::common::BulgeArc` keep working, and so
/// there is one obvious place to see that the maths moved out.
pub use acadrust::kernel::geom2d::BulgeArc;
/// Triangulate the solid bands a `wide_fills` returns into the flat WCS f64
/// triangle list `TruckEntity::pick_tris` carries, so a wide polyline is

View file

@ -19,13 +19,11 @@ use acadrust::entities::{
Spline as SplineEnt, XLine as XLineEnt,
};
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,
// Polyline offsetting, and the angle normalisation that goes with it, come
// from the kernel; only the entity conversion stays here.
use acadrust::kernel::geom2d::{
normalize_angle as norm_rad, offset_polyline, Polyline as KernelPolyline,
PolylineVertex as KernelVertex,
};
use glam::{DVec3, Vec3};
use crate::t;
@ -48,26 +46,6 @@ pub fn tool() -> ToolDef {
}
}
// ── Geometry helpers ────────────────────────────────────────────────────────
/// Infinite-line intersection in 2D. Returns the point or None if parallel.
fn isect_lines(p0: [f64; 2], p1: [f64; 2], q0: [f64; 2], q1: [f64; 2]) -> Option<[f64; 2]> {
let dx = p1[0] - p0[0];
let dy = p1[1] - p0[1];
let ex = q1[0] - q0[0];
let ey = q1[1] - q0[1];
let det = dx * ey - dy * ex;
if det.abs() < 1e-10 {
return None;
}
let t = ((q0[0] - p0[0]) * ey - (q0[1] - p0[1]) * ex) / det;
Some([p0[0] + t * dx, p0[1] + t * dy])
}
fn norm_rad(a: f64) -> f64 {
((a % TAU) + TAU) % TAU
}
// ── Line offset ────────────────────────────────────────────────────────────
fn offset_line(l: &LineEnt, dist: f64, side_pt: Vec3) -> Option<EntityType> {
@ -178,248 +156,35 @@ fn offset_arc(a: &ArcEnt, dist: f64, side_pt: Vec3) -> Option<EntityType> {
// remaining slices. This can legitimately return several disconnected
// polylines.
const OFFSET_POS_EPS: f64 = 1e-5;
const OFFSET_JOIN_EPS: f64 = 1e-4;
/// 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,
]
};
let n = p.vertices.len();
if n < 2 {
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()
};
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);
// CavalierContours represents arcs up to a half turn per segment.
// Split major bulge arcs at their exact midpoint; both halves retain
// the original circle and traversal direction.
if bulge.abs() > 1.0 {
if let Some(arc) =
crate::entities::common::BulgeArc::from_bulge(p0, p1, bulge)
{
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;
}
}
source.add(q0[0], q0[1], bulge);
}
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 {
continue;
};
let after = if next + 1 < count {
next + 1
} else if raw.is_closed {
0
} else {
continue;
};
let previous = if index > 0 {
index - 1
} else if raw.is_closed {
count - 1
} else {
continue;
};
let arc_start = raw.vertex_data[index];
let arc_end = raw.vertex_data[next];
if arc_start.bulge.abs() < OFFSET_POS_EPS
|| raw.vertex_data[previous].bulge.abs() >= OFFSET_POS_EPS
|| arc_end.bulge.abs() >= OFFSET_POS_EPS
{
continue;
}
let Some(connection) = crate::entities::common::BulgeArc::from_bulge(
[arc_start.x, arc_start.y],
[arc_end.x, arc_end.y],
arc_start.bulge,
) else {
continue;
};
if (connection.radius - 1.0).abs() > OFFSET_JOIN_EPS {
continue;
}
let generated_at_source_vertex = source.iter_vertexes().any(|vertex| {
let dx = vertex.x - connection.center[0];
let dy = vertex.y - connection.center[1];
dx * dx + dy * dy <= OFFSET_JOIN_EPS * OFFSET_JOIN_EPS
});
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 source = KernelPolyline {
closed: p.is_closed,
vertices: p
.vertices
.iter()
.map(|v| KernelVertex {
position: [v.location.x, v.location.y],
bulge: if v.bulge.is_finite() { v.bulge } else { 0.0 },
})
.collect(),
};
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)
offset_polyline(&source, dist, [side_pt.x as f64, side_pt.y as f64])
.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.is_closed = result.closed;
new_polyline.vertices = result
.iter_vertexes()
.vertices
.iter()
.map(|vertex| {
let mut output = LwVertex::from_coords(
origin[0] + vertex.x * dist,
origin[1] + vertex.y * dist,
);
let mut output =
LwVertex::from_coords(vertex.position[0], vertex.position[1]);
output.bulge = vertex.bulge;
output
})