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:
parent
06a0fc4ddf
commit
397fb38292
6 changed files with 35 additions and 347 deletions
8
Cargo.lock
generated
8
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue