From 397fb38292ccc62c82f836ce67fe352161fa23da Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sat, 8 Aug 2026 16:01:43 +0300 Subject: [PATCH] refactor(offset): take polyline offsetting from cadkernel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Cargo.lock | 8 +- Cargo.toml | 3 +- crates/ocs_plugin_api/Cargo.toml | 2 +- crates/ocs_web_worker/Cargo.toml | 2 +- src/entities/common.rs | 88 +--------- src/modules/draw/modify/offset.rs | 277 +++--------------------------- 6 files changed, 34 insertions(+), 346 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 579ed390..364fd334 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index ec4addc0..f6372fba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/crates/ocs_plugin_api/Cargo.toml b/crates/ocs_plugin_api/Cargo.toml index ecdd761d..bcb564cf 100644 --- a/crates/ocs_plugin_api/Cargo.toml +++ b/crates/ocs_plugin_api/Cargo.toml @@ -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 } diff --git a/crates/ocs_web_worker/Cargo.toml b/crates/ocs_web_worker/Cargo.toml index d217c29e..a693f1ca 100644 --- a/crates/ocs_web_worker/Cargo.toml +++ b/crates/ocs_web_worker/Cargo.toml @@ -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"] } diff --git a/src/entities/common.rs b/src/entities/common.rs index 8a18702c..573c7531 100644 --- a/src/entities/common.rs +++ b/src/entities/common.rs @@ -582,90 +582,12 @@ pub fn parse_angle_deg(value: &str) -> Option { 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 { - 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 diff --git a/src/modules/draw/modify/offset.rs b/src/modules/draw/modify/offset.rs index f707344b..7c56a11c 100644 --- a/src/modules/draw/modify/offset.rs +++ b/src/modules/draw/modify/offset.rs @@ -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 { @@ -178,248 +156,35 @@ fn offset_arc(a: &ArcEnt, dist: f64, side_pt: Vec3) -> Option { // 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; 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, source: &CavPolyline) { - 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, - signed_offset: f64, -) -> Vec> { - 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 = - create_raw_offset_polyline(source, signed_offset, OFFSET_POS_EPS); - if raw.is_empty() { - return Vec::new(); - } - let mut dual: CavPolyline = - 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>( - &raw, - &slices, - source.is_closed(), - raw.vertex_count(), - &options, - ) -} - fn offset_lwpolylines(p: &LwPolyline, dist: f64, side_pt: Vec3) -> Vec { - 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 })