use acadrust::entities::{LwPolyline, LwVertex}; use cadkernel::geom2d::{ signed_area, Curve, Polyline, PolylineVertex, RectangleFrame, Tolerance, Vec2, }; use crate::t; use crate::command::EntityTransform; use crate::entities::common::{ edit_prop as edit, edit_scalar_prop as edit_scalar, format_area, format_length, parse_f64, rectangle_grip, ro_prop as ro, square_grip, stepper_prop as stepper, VARIES_LABEL, }; use crate::entities::traits::RenderConvertible; use crate::scene::convert::acad_to_render::{extrusion_wall_tris, RenderEntity, RenderObject}; use crate::scene::model::object::{GripApply, GripDef, PropSection, PropValue, Property}; use crate::scene::model::wire_model::TangentGeom; const TAU: f64 = std::f64::consts::TAU; const REVCLOUD_BULGE: f64 = 0.5; const MAX_REVCLOUD_VERTICES: usize = 100_000; const WIDTH_EPSILON: f64 = 1.0e-9; pub(crate) fn vertex_segment_widths(vertex: &LwVertex, constant_width: f64) -> (f64, f64) { if vertex.start_width > WIDTH_EPSILON || vertex.end_width > WIDTH_EPSILON { (vertex.start_width, vertex.end_width) } else { (constant_width, constant_width) } } pub(crate) fn lwpolyline_global_width(pline: &LwPolyline) -> Option { let count = pline.vertices.len(); let seg_count = if pline.is_closed { count } else { count.saturating_sub(1) }; if seg_count == 0 { return Some(pline.constant_width); } let (w0_start, w0_end) = vertex_segment_widths(&pline.vertices[0], pline.constant_width); if (w0_start - w0_end).abs() > 1e-6 { return None; } let w0 = w0_start; for i in 1..seg_count { let (sw, ew) = vertex_segment_widths(&pline.vertices[i], pline.constant_width); if (sw - ew).abs() > 1e-6 || (sw - w0).abs() > 1e-6 { return None; } } Some(w0) } /// Midpoint position on an arc segment defined by its bulge. fn arc_midpoint(p0: [f64; 2], p1: [f64; 2], bulge: f64) -> [f64; 2] { match crate::entities::common::BulgeArc::from_bulge(p0, p1, bulge) { Some(arc) => arc.sample(0.5), None => [(p0[0] + p1[0]) * 0.5, (p0[1] + p1[1]) * 0.5], } } /// Compute the DXF bulge for an arc that passes through p0, mid_pt, and p1. /// Returns None when the three points are collinear (straight segment). /// /// The sign and sweep follow the p0 → mid → p1 traversal orientation, which /// keeps it the exact inverse of `BulgeArc::from_bulge` + `sample(0.5)` (a /// CCW traversal = positive bulge). It used to key off which chord side the /// midpoint lies on — the opposite convention — so every grip-drag frame /// flipped the arc to the other side and the radius jumped wildly instead of /// following the cursor (#339). fn bulge_from_midpoint(p0: [f64; 2], p1: [f64; 2], mid: [f64; 2]) -> Option { // Circumcircle of (p0, mid, p1) let ax = 2.0 * (mid[0] - p0[0]); let ay = 2.0 * (mid[1] - p0[1]); let bx = 2.0 * (p1[0] - p0[0]); let by = 2.0 * (p1[1] - p0[1]); let ca = mid[0] * mid[0] + mid[1] * mid[1] - p0[0] * p0[0] - p0[1] * p0[1]; let cb = p1[0] * p1[0] + p1[1] * p1[1] - p0[0] * p0[0] - p0[1] * p0[1]; let det = ax * by - ay * bx; if det.abs() < 1e-12 { return None; // collinear } let cx = (ca * by - cb * ay) / det; let cy = (ax * cb - bx * ca) / det; let a0 = (p0[1] - cy).atan2(p0[0] - cx); let a1 = (p1[1] - cy).atan2(p1[0] - cx); // Arc direction = orientation of the p0 → mid → p1 turn. let cross = (mid[0] - p0[0]) * (p1[1] - mid[1]) - (mid[1] - p0[1]) * (p1[0] - mid[0]); if cross == 0.0 { return None; } // Central angle measured along that direction, in (0, TAU). let sweep = if cross > 0.0 { (a1 - a0).rem_euclid(TAU) } else { (a0 - a1).rem_euclid(TAU) }; let bulge = (sweep / 4.0).tan(); Some(if cross > 0.0 { bulge } else { -bulge }) } /// Bulges for the two consecutive arcs `p0 → mid` and `mid → p1` on the /// circle through all three points. Used while placing a vertex inserted into /// an arc segment so both halves keep meeting at the cursor. fn split_bulges_from_point( p0: [f64; 2], mid: [f64; 2], p1: [f64; 2], original_bulge: f64, ) -> (f64, f64) { let d0 = (mid[0] - p0[0]).hypot(mid[1] - p0[1]); let d1 = (p1[0] - mid[0]).hypot(p1[1] - mid[1]); if d0 < 1e-12 { return (0.0, original_bulge); } if d1 < 1e-12 { return (original_bulge, 0.0); } let ax = 2.0 * (mid[0] - p0[0]); let ay = 2.0 * (mid[1] - p0[1]); let bx = 2.0 * (p1[0] - p0[0]); let by = 2.0 * (p1[1] - p0[1]); let ca = mid[0] * mid[0] + mid[1] * mid[1] - p0[0] * p0[0] - p0[1] * p0[1]; let cb = p1[0] * p1[0] + p1[1] * p1[1] - p0[0] * p0[0] - p0[1] * p0[1]; let det = ax * by - ay * bx; if det.abs() < 1e-12 { return (0.0, 0.0); } let cx = (ca * by - cb * ay) / det; let cy = (ax * cb - bx * ca) / det; let a0 = (p0[1] - cy).atan2(p0[0] - cx); let am = (mid[1] - cy).atan2(mid[0] - cx); let a1 = (p1[1] - cy).atan2(p1[0] - cx); let cross = (mid[0] - p0[0]) * (p1[1] - mid[1]) - (mid[1] - p0[1]) * (p1[0] - mid[0]); let bulge = |from: f64, to: f64| { if cross > 0.0 { ((to - from).rem_euclid(TAU) / 4.0).tan() } else { -((from - to).rem_euclid(TAU) / 4.0).tan() } }; (bulge(a0, am), bulge(am, a1)) } /// Refit the two arc segments around a newly inserted vertex. The caller /// ensures this placement originated from an arc, so straight-segment Add /// Vertex remains two straight segments when the cursor moves off the chord. pub(crate) fn refit_added_arc_vertex( entity: &mut acadrust::EntityType, vertex_id: usize, original_bulge: f64, ) { match entity { acadrust::EntityType::LwPolyline(polyline) => { let n = polyline.vertices.len(); if vertex_id == 0 || vertex_id >= n { return; } let next = if vertex_id + 1 < n { vertex_id + 1 } else if polyline.is_closed { 0 } else { return; }; let prev = vertex_id - 1; let p0 = polyline.vertices[prev].location; let mid = polyline.vertices[vertex_id].location; let p1 = polyline.vertices[next].location; let (first, second) = split_bulges_from_point( [p0.x, p0.y], [mid.x, mid.y], [p1.x, p1.y], original_bulge, ); polyline.vertices[prev].bulge = first.clamp(-1e6, 1e6); polyline.vertices[vertex_id].bulge = second.clamp(-1e6, 1e6); } acadrust::EntityType::Polyline2D(polyline) => { let n = polyline.vertices.len(); if vertex_id == 0 || vertex_id >= n { return; } let next = if vertex_id + 1 < n { vertex_id + 1 } else if polyline.is_closed() { 0 } else { return; }; let prev = vertex_id - 1; let p0 = &polyline.vertices[prev].location; let mid = &polyline.vertices[vertex_id].location; let p1 = &polyline.vertices[next].location; let (first, second) = split_bulges_from_point( [p0.x, p0.y], [mid.x, mid.y], [p1.x, p1.y], original_bulge, ); polyline.vertices[prev].bulge = first.clamp(-1e6, 1e6); polyline.vertices[vertex_id].bulge = second.clamp(-1e6, 1e6); } _ => {} } } /// Tessellate a thick polyline segment list into NaN-separated Lines geometry. /// Shared by LwPolyline and Polyline2D thickness paths. fn thick_segments( seg_data: &[(f64, f64, f64, f64)], // (x0, y0, x1, y1) per seg — or use run of (x,y,bulge) path_pts: &[[f64; 3]], thickness: f64, normal: (f64, f64, f64), key_verts: Vec<[f64; 3]>, tangents: Vec, ) -> RenderEntity { let (nx, ny, nz) = normal; let t = thickness; let off = |p: [f64; 3]| -> [f64; 3] { [p[0] + t * nx, p[1] + t * ny, p[2] + t * nz] }; let mut pts: Vec<[f64; 3]> = Vec::with_capacity(path_pts.len() * 2 + seg_data.len() * 3 + 4); // Bottom path pts.extend_from_slice(path_pts); pts.push([f64::NAN; 3]); // Top path for &p in path_pts { pts.push(off(p)); } // Walls at each vertex (seg_data.0/.1 = start x/y of each seg, last seg appends its end too) if !seg_data.is_empty() { pts.push([f64::NAN; 3]); for (k, &(x0, y0, _x1, _y1)) in seg_data.iter().enumerate() { let pb = key_verts[k]; let _ = (x0, y0); // key_verts already has correct WCS pts.push(pb); pts.push(off(pb)); if k + 1 < seg_data.len() { pts.push([f64::NAN; 3]); } } // Last wall at the final vertex if let Some(&last) = key_verts.last() { pts.push([f64::NAN; 3]); pts.push(last); pts.push(off(last)); } } RenderEntity { pick_tris: extrusion_wall_tris(path_pts, [t * nx, t * ny, t * nz]), object: RenderObject::Lines(pts), snap_pts: vec![], tangent_geoms: tangents, key_vertices: key_verts, fill_tris: vec![], } } /// Extrude a *wide* LwPolyline's band into a 3-D tube: each band segment's outer /// and inner boundary rises by `thickness` (along the normal) into a wall, /// leaving the two radial segment-end edges open (they are internal to the /// band). The flat bottom band is already drawn by the wide-fill path; this /// adds the vertical walls and their outline. Used instead of `thick_segments` /// (which extrudes only the centreline) when the polyline carries a width. fn thick_wide_band( pl: &LwPolyline, thickness: f64, to_wcs: &dyn Fn(f64, f64) -> (f64, f64, f64), normal: (f64, f64, f64), key_verts: Vec<[f64; 3]>, tangents: Vec, ) -> RenderEntity { let (origin, fills) = wide_fills(pl); let (fill_tris, lines) = crate::entities::common::thick_band_tube(origin, &fills, thickness, normal, to_wcs); RenderEntity { pick_tris: fill_tris.clone(), object: RenderObject::Lines(lines), snap_pts: vec![], tangent_geoms: tangents, key_vertices: key_verts, fill_tris, } } fn to_render(pline: &LwPolyline, fill_mode: bool) -> RenderEntity { let verts = &pline.vertices; if verts.is_empty() { return RenderEntity { pick_tris: Vec::new(), object: RenderObject::Lines(Vec::new()), snap_pts: vec![], tangent_geoms: vec![], key_vertices: vec![], fill_tris: vec![], }; } let elev = pline.elevation; let normal = (pline.normal.x, pline.normal.y, pline.normal.z); let count = verts.len(); let seg_count = if pline.is_closed { count } else { count - 1 }; // Convert OCS (x, y, elevation) to a WCS point. let to_wcs = |x: f64, y: f64| -> (f64, f64, f64) { crate::scene::view::transform::ocs_point_to_wcs((x, y, elev), normal) }; let to_pt = |v: &LwVertex| -> [f64; 3] { let (wx, wy, wz) = to_wcs(v.location.x, v.location.y); [wx, wy, wz] }; let band_verts = band_verts(pline); if !fill_mode { let mut boundary = crate::entities::common::wide_band_outline( &band_verts, pline.is_closed, !pline.plinegen, &to_wcs, ); if !boundary.points.is_empty() { if pline.thickness.abs() > 1e-10 { boundary = crate::entities::common::extrude_wide_band_outline( boundary, [ pline.thickness * normal.0, pline.thickness * normal.1, pline.thickness * normal.2, ], ); } let (tangent_geoms, key_vertices) = centerline_metadata(pline, &to_wcs); return RenderEntity { pick_tris: Vec::new(), object: RenderObject::BoundaryLines { points: boundary.points, stations: boundary.stations, point_segments: boundary.point_segments, station_pieces: boundary.station_pieces, source_length: boundary.source_length, plinegen: pline.plinegen, }, snap_pts: vec![], tangent_geoms, key_vertices, fill_tris: vec![], }; } } if pline.thickness.abs() > 1e-10 { let mut path: Vec<[f64; 3]> = Vec::new(); let mut kv: Vec<[f64; 3]> = Vec::new(); let mut tgs: Vec = Vec::new(); let mut seg_data: Vec<(f64, f64, f64, f64)> = Vec::new(); // First vertex let (w0x, w0y, w0z) = to_wcs(verts[0].location.x, verts[0].location.y); path.push([w0x, w0y, w0z]); kv.push([w0x, w0y, w0z]); for i in 0..seg_count { let va = &verts[i]; let vb = &verts[(i + 1) % count]; let (ox0, oy0) = (va.location.x, va.location.y); let (ox1, oy1) = (vb.location.x, vb.location.y); let bulge = va.bulge; if bulge.abs() < 1e-9 { let (wx, wy, wz) = to_wcs(ox1, oy1); path.push([wx, wy, wz]); let p1_pt = path[path.len() - 2]; let p2_pt = *path.last().unwrap(); tgs.push(TangentGeom::Line { p1: [p1_pt[0] as f32, p1_pt[1] as f32, p1_pt[2] as f32], p2: [p2_pt[0] as f32, p2_pt[1] as f32, p2_pt[2] as f32], }); } else if let Some(arc) = crate::entities::common::BulgeArc::from_bulge([ox0, oy0], [ox1, oy1], bulge) { tgs.push(crate::entities::common::bulge_arc_to_tangent(&arc, &to_wcs, normal)); for s in arc .tessellate_angle(cadkernel::tessellation::DEFAULT_ANGLE) .into_iter() .skip(1) { let (wx, wy, wz) = to_wcs(s[0], s[1]); path.push([wx, wy, wz]); } } let (wbx, wby, wbz) = to_wcs(ox1, oy1); kv.push([wbx, wby, wbz]); seg_data.push((ox0, oy0, ox1, oy1)); } // A wide polyline extrudes its whole band into a tube (outer + inner // walls); a zero-width one just extrudes its centreline. let is_wide = pline.constant_width > 1e-9 || pline .vertices .iter() .any(|v| v.start_width > 1e-9 || v.end_width > 1e-9); if is_wide { return thick_wide_band(pline, pline.thickness, &to_wcs, normal, kv, tgs); } return thick_segments(&seg_data, &path, pline.thickness, normal, kv, tgs); } // A wide polyline whose per-vertex widths VARY renders a smooth taper — // handled here, before the PLINEGEN split, so both cases get it. A // uniform-width polyline falls through to the constant-band paths below. if tapered_band_verts(&band_verts).is_some() { let mut kv: Vec<[f64; 3]> = Vec::new(); let mut tgs: Vec = Vec::new(); for i in 0..seg_count { let v0 = &verts[i]; let v1 = &verts[(i + 1) % count]; let p0 = to_pt(v0); let p1 = to_pt(v1); if i == 0 { kv.push([p0[0], p0[1], p0[2]]); } kv.push([p1[0], p1[1], p1[2]]); if v0.bulge.abs() < 1e-9 { tgs.push(TangentGeom::Line { p1: [p0[0] as f32, p0[1] as f32, p0[2] as f32], p2: [p1[0] as f32, p1[1] as f32, p1[2] as f32], }); } else if let Some(arc) = crate::entities::common::BulgeArc::from_bulge( [v0.location.x, v0.location.y], [v1.location.x, v1.location.y], v0.bulge, ) { tgs.push(crate::entities::common::bulge_arc_to_tangent(&arc, &to_wcs, normal)); } } let (pts, widths) = crate::entities::common::tapered_band_points( &band_verts, pline.is_closed, &to_wcs, ); let (fill_origin, fills) = wide_fills(pline); return RenderEntity { pick_tris: crate::entities::common::wide_band_tris(fill_origin, &fills), object: RenderObject::TaperedLines(pts, widths), snap_pts: vec![], tangent_geoms: tgs, key_vertices: kv, fill_tris: vec![], }; } // plinegen=false: NaN-separated segments so the linetype pattern restarts per vertex. if !pline.plinegen { let mut pts: Vec<[f64; 3]> = Vec::new(); let mut tgs: Vec = Vec::new(); let mut kv: Vec<[f64; 3]> = Vec::new(); let to_f32 = |p: [f64; 3]| -> [f32; 3] { [p[0] as f32, p[1] as f32, p[2] as f32] }; for i in 0..seg_count { let va = &verts[i]; let vb = &verts[(i + 1) % count]; let (ox0, oy0) = (va.location.x, va.location.y); let (ox1, oy1) = (vb.location.x, vb.location.y); let bulge = va.bulge; let (wx0, wy0, wz0) = to_wcs(ox0, oy0); let p_start = [wx0, wy0, wz0]; pts.push(p_start); if i == 0 { kv.push(p_start); } if bulge.abs() < 1e-9 { let (wx1, wy1, wz1) = to_wcs(ox1, oy1); let p_end = [wx1, wy1, wz1]; pts.push(p_end); kv.push(p_end); tgs.push(TangentGeom::Line { p1: to_f32(p_start), p2: to_f32(p_end), }); } else if let Some(arc) = crate::entities::common::BulgeArc::from_bulge([ox0, oy0], [ox1, oy1], bulge) { for s in arc .tessellate_angle(cadkernel::tessellation::DEFAULT_ANGLE) .into_iter() .skip(1) { let (wx, wy, wz) = to_wcs(s[0], s[1]); pts.push([wx, wy, wz]); } let (wx1, wy1, wz1) = to_wcs(ox1, oy1); kv.push([wx1, wy1, wz1]); tgs.push(crate::entities::common::bulge_arc_to_tangent(&arc, &to_wcs, normal)); } if i + 1 < seg_count { pts.push([f64::NAN; 3]); } } let (fill_origin, fills) = wide_fills(pline); return RenderEntity { pick_tris: crate::entities::common::wide_band_tris(fill_origin, &fills), object: RenderObject::SegmentedLines(pts), snap_pts: vec![], tangent_geoms: tgs, key_vertices: kv, fill_tris: vec![], }; } let (tangents, key_verts) = centerline_metadata(pline, &to_wcs); let (fill_origin, fills) = wide_fills(pline); RenderEntity { pick_tris: crate::entities::common::wide_band_tris(fill_origin, &fills), // Sampled through the polyline's own curve, so a bulge stays an // arc rather than becoming the chord across it. object: RenderObject::Lines( crate::entities::curve::lwpolyline_curve(pline) .map(|planar| crate::entities::curve::curve_points(&planar)) .unwrap_or_default(), ), snap_pts: vec![], tangent_geoms: tangents, key_vertices: key_verts, fill_tris: vec![], } } /// Effective segment widths for an LwPolyline band. fn band_verts(pline: &LwPolyline) -> Vec<([f64; 2], f64, f64, f64)> { let c = pline.constant_width; pline .vertices .iter() .map(|v| { let (sw, ew) = vertex_segment_widths(v, c); ([v.location.x, v.location.y], v.bulge, sw, ew) }) .collect() } fn tapered_band_verts( band: &[([f64; 2], f64, f64, f64)], ) -> Option<&[([f64; 2], f64, f64, f64)]> { let w0 = band.first().map_or(0.0, |v| v.2); let varies = band .iter() .any(|&(_, _, sw, ew)| (sw - w0).abs() > 1e-9 || (ew - w0).abs() > 1e-9); // Only a genuine, non-zero-width taper takes the per-point path. if varies && w0.max(band.iter().map(|v| v.3).fold(0.0, f64::max)) > 1e-9 { Some(band) } else { None } } fn centerline_metadata( pline: &LwPolyline, to_wcs: &dyn Fn(f64, f64) -> (f64, f64, f64), ) -> (Vec, Vec<[f64; 3]>) { let count = pline.vertices.len(); let segment_count = if pline.is_closed { count } else { count.saturating_sub(1) }; let mut tangents = Vec::with_capacity(segment_count); let mut key_vertices = Vec::with_capacity(segment_count + 1); for index in 0..segment_count { let start = &pline.vertices[index]; let end = &pline.vertices[(index + 1) % count]; let p0 = to_wcs(start.location.x, start.location.y); let p1 = to_wcs(end.location.x, end.location.y); if start.bulge.abs() < 1e-9 { tangents.push(TangentGeom::Line { p1: [p0.0 as f32, p0.1 as f32, p0.2 as f32], p2: [p1.0 as f32, p1.1 as f32, p1.2 as f32], }); } else if let Some(arc) = crate::entities::common::BulgeArc::from_bulge( [start.location.x, start.location.y], [end.location.x, end.location.y], start.bulge, ) { let normal = (pline.normal.x, pline.normal.y, pline.normal.z); tangents.push(crate::entities::common::bulge_arc_to_tangent(&arc, to_wcs, normal)); } if index == 0 { key_vertices.push([p0.0, p0.1, p0.2]); } key_vertices.push([p1.0, p1.1, p1.2]); } (tangents, key_vertices) } /// Split at vertex `idx`: a closed polyline re-opens there (one piece); an /// open one splits into two (interior vertices only). `None` when invalid. pub(crate) fn break_at_vertex( p: &acadrust::LwPolyline, idx: usize, ) -> Option> { use acadrust::EntityType; let n = p.vertices.len(); if n < 3 || idx >= n { return None; } if p.is_closed { let mut verts = Vec::with_capacity(n + 1); verts.extend_from_slice(&p.vertices[idx..]); verts.extend_from_slice(&p.vertices[..=idx]); let mut out = p.clone(); out.common.handle = acadrust::Handle::NULL; out.is_closed = false; out.vertices = verts; return Some(vec![EntityType::LwPolyline(out)]); } if idx == 0 || idx == n - 1 { return None; } let mut a = p.clone(); a.common.handle = acadrust::Handle::NULL; a.vertices = p.vertices[..=idx].to_vec(); if let Some(last) = a.vertices.last_mut() { last.bulge = 0.0; } let mut b = p.clone(); b.common.handle = acadrust::Handle::NULL; b.vertices = p.vertices[idx..].to_vec(); Some(vec![EntityType::LwPolyline(a), EntityType::LwPolyline(b)]) } fn grips(pline: &LwPolyline) -> Vec { let elev = pline.elevation; let n = pline.vertices.len(); let seg_count = if pline.is_closed { n } else { n.saturating_sub(1) }; let mut out: Vec = pline .vertices .iter() .enumerate() .map(|(i, v)| square_grip(i, glam::DVec3::new(v.location.x, v.location.y, elev))) .collect(); // One mid-segment stretch grip per segment (straight or arc). The // marker is a small box rotated along the chord direction so the // shape itself signals which way the segment runs. for i in 0..seg_count { let v0 = &pline.vertices[i]; let v1 = &pline.vertices[(i + 1) % n]; let (mx, my) = if v0.bulge.abs() < 1e-9 { ( (v0.location.x + v1.location.x) * 0.5, (v0.location.y + v1.location.y) * 0.5, ) } else { let m = arc_midpoint( [v0.location.x, v0.location.y], [v1.location.x, v1.location.y], v0.bulge, ); (m[0], m[1]) }; let dx = (v1.location.x - v0.location.x) as f32; let dy = (v1.location.y - v0.location.y) as f32; let mut g = rectangle_grip(n + i, glam::DVec3::new(mx, my, elev), [dx, dy]); // An arc's mid grip re-fits the arc THROUGH the cursor, so it must // drag in Absolute mode; only a straight segment's grip translates. // Feeding per-frame Translate deltas through the recomputed arc // midpoint deadlocks the moment the cursor crosses the chord (#339). g.is_midpoint = v0.bulge.abs() < 1e-9; out.push(g); } out } pub(crate) fn is_revision_cloud(pline: &LwPolyline) -> bool { if !pline.is_closed || pline.vertices.len() < 3 { return false; } let mut sign = 0.0_f64; let mut magnitudes = Vec::with_capacity(pline.vertices.len()); for vertex in &pline.vertices { let bulge = vertex.bulge; if !bulge.is_finite() || bulge.abs() < 1.0e-9 { return false; } if sign == 0.0 { sign = bulge.signum(); } else if bulge.signum() != sign { return false; } magnitudes.push(bulge.abs()); } magnitudes.sort_by(f64::total_cmp); let median = magnitudes[magnitudes.len() / 2]; (0.35..=0.65).contains(&median) && magnitudes .iter() .filter(|value| (**value - median).abs() <= 0.15) .count() * 5 >= magnitudes.len() * 4 } fn straight_guide(pline: &LwPolyline) -> Curve { Curve::Polyline(Polyline { vertices: pline .vertices .iter() .map(|vertex| PolylineVertex::straight([vertex.location.x, vertex.location.y])) .collect(), closed: true, }) } fn revision_cloud_arc_length(pline: &LwPolyline) -> Option { if !is_revision_cloud(pline) { return None; } let total = straight_guide(pline).length(); (total.is_finite() && total > 0.0) .then_some(total / pline.vertices.len() as f64) } fn cloud_anchors(curve: &Curve) -> Vec { let segments = curve.segment_count(); let mut anchors = vec![0.0]; if segments <= 1 { return anchors; } let delta = 1.0e-6 / segments as f64; let corner_cosine = 30.0_f64.to_radians().cos(); for index in 1..segments { let t = index as f64 / segments as f64; let incoming = Vec2::from(curve.tangent_at(t - delta)).normalize(); let outgoing = Vec2::from(curve.tangent_at(t + delta)).normalize(); if incoming .zip(outgoing) .is_some_and(|(left, right)| left.dot(right) < corner_cosine) { anchors.push(curve.length_to(t)); } } anchors } fn cloud_points(curve: &Curve, requested: f64) -> Option> { if !curve.is_closed() || !requested.is_finite() || requested <= 0.0 { return None; } let total = curve.length(); if !total.is_finite() || total <= 0.0 { return None; } let anchors = cloud_anchors(curve); let spans: Vec<(f64, f64)> = anchors .iter() .enumerate() .map(|(index, start)| { let end = anchors.get(index + 1).copied().unwrap_or(total); (*start, end) }) .collect(); let mut counts = Vec::with_capacity(spans.len()); let mut count_total = 0_usize; for (start, end) in &spans { let count = ((*end - *start) / requested).round().max(1.0); if !count.is_finite() || count > MAX_REVCLOUD_VERTICES as f64 { return None; } count_total = count_total.checked_add(count as usize)?; if count_total > MAX_REVCLOUD_VERTICES { return None; } counts.push(count as usize); } if count_total < 3 { let longest = spans .iter() .enumerate() .max_by(|(_, left), (_, right)| { (left.1 - left.0).total_cmp(&(right.1 - right.0)) })? .0; counts[longest] += 3 - count_total; } let mut points = Vec::new(); for ((start, end), count) in spans.into_iter().zip(counts) { for step in 0..count { points.push(curve.point_at_distance( start + (end - start) * step as f64 / count as f64, )); } } let tolerance = requested.max(total) * 1.0e-12; points.dedup_by(|right, left| Vec2::from(*left).distance((*right).into()) <= tolerance); if points.len() >= 2 && Vec2::from(points[0]).distance((*points.last()?).into()) <= tolerance { points.pop(); } (points.len() >= 3).then_some(points) } fn cloud_vertices( points: &[[f64; 2]], bulge: f64, width_ratios: Option<(f64, f64)>, ) -> Vec { points .iter() .enumerate() .map(|(index, point)| { let next = points[(index + 1) % points.len()]; let chord = Vec2::from(*point).distance(next.into()); let mut vertex = LwVertex::from_coords(point[0], point[1]); vertex.bulge = bulge; if let Some((start, end)) = width_ratios { vertex.start_width = start * chord; vertex.end_width = end * chord; } vertex }) .collect() } pub(crate) fn revision_cloud_from_curve( curve: &Curve, requested: f64, reverse: bool, width_ratios: Option<(f64, f64)>, ) -> Option { let points = cloud_points(curve, requested)?; let area = signed_area(&points); if !area.is_finite() || area.abs() <= f64::EPSILON { return None; } let sign = if area > 0.0 { 1.0 } else { -1.0 }; let bulge = REVCLOUD_BULGE * sign * if reverse { -1.0 } else { 1.0 }; let mut cloud = LwPolyline::new(); cloud.is_closed = true; cloud.vertices = cloud_vertices(&points, bulge, width_ratios); Some(cloud) } fn median(mut values: Vec) -> Option { values.sort_by(f64::total_cmp); values.get(values.len() / 2).copied() } fn set_revision_cloud_arc_length(pline: &mut LwPolyline, requested: f64) { if !is_revision_cloud(pline) || !requested.is_finite() || requested <= 0.0 { return; } let guide = straight_guide(pline); let points = match cloud_points(&guide, requested) { Some(points) => points, None => return, }; let sign = pline.vertices[0].bulge.signum(); let magnitude = match median( pline .vertices .iter() .map(|vertex| vertex.bulge.abs()) .collect(), ) { Some(value) => value, None => return, }; let mut starts = Vec::new(); let mut ends = Vec::new(); for (index, vertex) in pline.vertices.iter().enumerate() { let next = &pline.vertices[(index + 1) % pline.vertices.len()]; let chord = Vec2::new(vertex.location.x, vertex.location.y) .distance(Vec2::new(next.location.x, next.location.y)); if chord > 0.0 && (vertex.start_width > 0.0 || vertex.end_width > 0.0) { starts.push(vertex.start_width / chord); ends.push(vertex.end_width / chord); } } let width_ratios = match (median(starts), median(ends)) { (Some(start), Some(end)) => Some((start, end)), _ => None, }; pline.vertices = cloud_vertices(&points, magnitude * sign, width_ratios); } pub(crate) fn rectangle_frame( pline: &LwPolyline, ) -> Option<(RectangleFrame, cadkernel::space::Plane)> { let planar = crate::entities::curve::lwpolyline_curve(pline)?; let Curve::Polyline(polyline) = &planar.curve else { return None; }; let rectangle = polyline.rectangle_frame(Tolerance::default())?; Some((rectangle, planar.plane)) } pub(crate) fn is_rectangle(pline: &LwPolyline) -> bool { rectangle_frame(pline).is_some() } fn properties(pline: &LwPolyline) -> Vec { let n = pline.vertices.len(); // The panel's Current Vertex focus, clamped to this polyline's range. let vi = if n == 0 { 0 } else { crate::scene::view::dispatch::prop_current_vertex().min(n - 1) }; let v = pline.vertices.get(vi); let vx = v.map_or(0.0, |v| v.location.x); let vy = v.map_or(0.0, |v| v.location.y); let (start_w, end_w) = v.map_or((0.0, 0.0), |v| { vertex_segment_widths(v, pline.constant_width) }); let mp = ::mass_props(pline); let cloud_arc_length = revision_cloud_arc_length(pline); let vertex_label = if n == 0 { "—".to_string() } else { format!("{}", vi + 1) }; let mut misc_props = vec![ Property { label: t!("Closed").into_owned(), field: "closed", value: PropValue::BoolToggle { field: "closed", value: pline.is_closed, }, }, Property { label: t!("Linetype generation").into_owned(), field: "plinegen", value: PropValue::BoolToggle { field: "plinegen", value: pline.plinegen, }, }, ]; if let Some(arc_length) = cloud_arc_length { misc_props.push(edit( t!("Arc length").as_ref(), "revcloud_arc_length", arc_length, )); } let global_width_prop = match lwpolyline_global_width(pline) { Some(gw) => edit(t!("Global width").as_ref(), "global_width", gw), None => Property { label: t!("Global width").into_owned(), field: "global_width", value: PropValue::EditText(VARIES_LABEL.to_string()), }, }; let mut geometry_props = vec![ stepper(t!("Current Vertex").as_ref(), "current_vertex", vertex_label), edit(t!("Vertex X").as_ref(), "vertex_x", vx), edit(t!("Vertex Y").as_ref(), "vertex_y", vy), edit_scalar(t!("Bulge").as_ref(), "bulge", v.map_or(0.0, |vertex| vertex.bulge)), ]; if !is_rectangle(pline) { geometry_props.push(edit(t!("Start segment width").as_ref(), "start_width", start_w)); geometry_props.push(edit(t!("End segment width").as_ref(), "end_width", end_w)); } geometry_props.extend([ global_width_prop, edit(t!("Elevation").as_ref(), "elevation", pline.elevation), ro(t!("Area").as_ref(), "area", format_area(mp.area)), ro(t!("Length").as_ref(), "length", format_length(mp.perimeter)), ]); vec![ PropSection { title: t!("Geometry").into_owned(), props: geometry_props, }, PropSection { title: t!("Misc").into_owned(), props: misc_props, }, ] } fn apply_geom_prop(pline: &mut LwPolyline, field: &str, value: &str) { match field { "revcloud_arc_length" => { if let Some(value) = parse_f64(value) { set_revision_cloud_arc_length(pline, value); } return; } "closed" => { pline.is_closed = if value == "toggle" { !pline.is_closed } else { value == "true" }; // Closing a polyline whose last vertex already sits on the first // would stack two control points there (a polyline drawn back to // its start point and then closed) — drop the duplicate so the // closing segment replaces it (#421). if pline.is_closed && pline.vertices.len() > 2 { let (first, last) = (&pline.vertices[0], pline.vertices.last().unwrap()); let d2 = (first.location.x - last.location.x).powi(2) + (first.location.y - last.location.y).powi(2); if d2 < 1e-12 { pline.vertices.pop(); } } return; } "plinegen" => { pline.plinegen = if value == "toggle" { !pline.plinegen } else { value == "true" }; return; } _ => {} } let Some(v) = parse_f64(value) else { return; }; // Per-vertex edits target the vertex the panel is focused on. let n = pline.vertices.len(); let vi = if n == 0 { 0 } else { crate::scene::view::dispatch::prop_current_vertex().min(n - 1) }; match field { "elevation" => pline.elevation = v, "global_width" if v.is_finite() && v >= 0.0 => { pline.constant_width = v; for vtx in &mut pline.vertices { vtx.start_width = v; vtx.end_width = v; } } "vertex_x" => { if let Some(vtx) = pline.vertices.get_mut(vi) { vtx.location.x = v; } } "vertex_y" => { if let Some(vtx) = pline.vertices.get_mut(vi) { vtx.location.y = v; } } "start_width" if v.is_finite() && v >= 0.0 => { if pline.constant_width > WIDTH_EPSILON { let c = pline.constant_width; for vtx in &mut pline.vertices { if vtx.start_width <= WIDTH_EPSILON && vtx.end_width <= WIDTH_EPSILON { vtx.start_width = c; vtx.end_width = c; } } pline.constant_width = 0.0; } if let Some(vtx) = pline.vertices.get_mut(vi) { vtx.start_width = v; } } "end_width" if v.is_finite() && v >= 0.0 => { if pline.constant_width > WIDTH_EPSILON { let c = pline.constant_width; for vtx in &mut pline.vertices { if vtx.start_width <= WIDTH_EPSILON && vtx.end_width <= WIDTH_EPSILON { vtx.start_width = c; vtx.end_width = c; } } pline.constant_width = 0.0; } if let Some(vtx) = pline.vertices.get_mut(vi) { vtx.end_width = v; } } "bulge" => { if v.is_finite() { if let Some(vtx) = pline.vertices.get_mut(vi) { vtx.bulge = v.clamp(-1.0e6, 1.0e6); } } } _ => {} } } fn apply_grip(pline: &mut LwPolyline, grip_id: usize, apply: GripApply) { let n = pline.vertices.len(); if grip_id < n { // Vertex position grip let v = &mut pline.vertices[grip_id]; match apply { GripApply::Absolute(p) => { v.location.x = p.x as f64; v.location.y = p.y as f64; } GripApply::Translate(d) => { v.location.x += d.x as f64; v.location.y += d.y as f64; } } } else { // Mid-segment stretch grip for segment (grip_id - n). // Straight segments translate both endpoints by the drag delta // (the shared vertices then carry along whichever adjacent // segments share them). Arc segments adjust their bulge from // the new midpoint position. let seg = grip_id - n; let count = if pline.is_closed { n } else { n.saturating_sub(1) }; if seg >= count { return; } let i0 = seg; let i1 = (seg + 1) % n; let is_arc = pline.vertices[i0].bulge.abs() >= 1e-9; if !is_arc { let d = match apply { GripApply::Translate(d) => [d.x as f64, d.y as f64], GripApply::Absolute(p) => { let old_mid = ( (pline.vertices[i0].location.x + pline.vertices[i1].location.x) * 0.5, (pline.vertices[i0].location.y + pline.vertices[i1].location.y) * 0.5, ); [p.x as f64 - old_mid.0, p.y as f64 - old_mid.1] } }; pline.vertices[i0].location.x += d[0]; pline.vertices[i0].location.y += d[1]; pline.vertices[i1].location.x += d[0]; pline.vertices[i1].location.y += d[1]; return; } let new_mid: [f64; 2] = match apply { GripApply::Absolute(p) => [p.x as f64, p.y as f64], GripApply::Translate(d) => { let v0 = &pline.vertices[i0]; let v1 = &pline.vertices[i1]; let old = arc_midpoint( [v0.location.x, v0.location.y], [v1.location.x, v1.location.y], v0.bulge, ); [old[0] + d.x as f64, old[1] + d.y as f64] } }; let p0 = [pline.vertices[i0].location.x, pline.vertices[i0].location.y]; let p1 = [pline.vertices[i1].location.x, pline.vertices[i1].location.y]; if let Some(new_bulge) = bulge_from_midpoint(p0, p1, new_mid) { pline.vertices[i0].bulge = new_bulge.clamp(-1e6, 1e6); } } } fn kernel_polyline(polyline: &LwPolyline) -> Polyline { Polyline { vertices: polyline .vertices .iter() .map(|vertex| PolylineVertex { position: [vertex.location.x, vertex.location.y], bulge: vertex.bulge, }) .collect(), closed: polyline.is_closed, } } fn edit_polyline_geometry( polyline: &mut LwPolyline, edit: impl FnOnce(&mut Polyline) -> bool, ) { let mut geometry = kernel_polyline(polyline); if edit(&mut geometry) { for (target, source) in polyline.vertices.iter_mut().zip(geometry.vertices) { target.location = acadrust::types::Vector2::new(source.position[0], source.position[1]); target.bulge = source.bulge; } } } fn move_segment_parallel(polyline: &mut LwPolyline, segment: usize, offset: f64) { edit_polyline_geometry(polyline, |geometry| { cadkernel::geom2d::move_polyline_segment_parallel(geometry, segment, offset) }); } fn resize_arc_concentrically(polyline: &mut LwPolyline, segment: usize, offset: f64) { edit_polyline_geometry(polyline, |geometry| { geometry .segment_arc(segment) .is_some_and(|arc| cadkernel::geom2d::resize_polyline_arc( geometry, segment, arc.radius + offset, )) }); } fn resize_arc_segment_radius(polyline: &mut LwPolyline, segment: usize, radius: f64) { edit_polyline_geometry(polyline, |geometry| { cadkernel::geom2d::resize_polyline_arc(geometry, segment, radius) }); } fn apply_transform(pline: &mut LwPolyline, t: &EntityTransform) { crate::scene::view::transform::apply_standard_entity_transform(pline, t, |entity, p1, p2| { for v in &mut entity.vertices { crate::scene::view::transform::reflect_xy_point(&mut v.location.x, &mut v.location.y, p1, p2); // Bulge encodes which side the arc bows to; a reflection // reverses it or every curved segment flips to the wrong side. v.bulge = -v.bulge; } }); } impl RenderConvertible for LwPolyline { fn to_render(&self, document: &acadrust::CadDocument) -> Option { Some(to_render(self, document.header.fill_mode)) } } impl crate::entities::traits::Grippable for LwPolyline { fn grips(&self) -> Vec { grips(self) } fn apply_grip(&mut self, grip_id: usize, apply: crate::scene::model::object::GripApply) { apply_grip(self, grip_id, apply); } fn grip_menu(&self, grip_id: usize) -> Vec { use crate::scene::model::object::{GripMenuAction, GripMenuItem}; let n = self.vertices.len(); if grip_id < n { // Vertex grip. Break only where a split is possible: any vertex // of a closed polyline, interior vertices of an open one. let breakable = n >= 3 && (self.is_closed || (grip_id > 0 && grip_id < n - 1)); let mut items = Vec::new(); if is_rectangle(self) { items.push(GripMenuItem { label: "Resize", action: GripMenuAction::RectangleResize, }); } items.extend([ GripMenuItem { label: "Stretch", action: GripMenuAction::Stretch, }, GripMenuItem { label: "Add Vertex", action: GripMenuAction::AddVertex, }, GripMenuItem { label: "Remove Vertex", action: GripMenuAction::RemoveVertex, }, ]); if breakable { items.push(GripMenuItem { label: "Break", action: GripMenuAction::BreakVertex, }); } return items; } // Segment midpoint grip. let seg = grip_id - n; let is_arc = self .vertices .get(seg) .map_or(false, |v| v.bulge.abs() >= 1e-9); let convert = if is_arc { GripMenuItem { label: "Convert to Line", action: GripMenuAction::ConvertToLine, } } else { GripMenuItem { label: "Convert to Arc", action: GripMenuAction::ConvertToArc, } }; let mut items = Vec::new(); if is_arc { items.push(GripMenuItem { label: "Radius", action: GripMenuAction::Radius, }); } else { items.push(GripMenuItem { label: "Move Parallel", action: GripMenuAction::MoveParallel, }); } if is_rectangle(self) && seg < 4 { // Moving an edge changes the dimension perpendicular to it. items.push(GripMenuItem { label: if seg % 2 == 0 { "Height" } else { "Width" }, action: if seg % 2 == 0 { GripMenuAction::RectangleHeight } else { GripMenuAction::RectangleWidth }, }); } items.extend([ GripMenuItem { label: "Stretch", action: GripMenuAction::Stretch, }, GripMenuItem { label: "Add Vertex", action: GripMenuAction::AddVertex, }, convert, ]); items } fn grip_menu_value_prompt( &self, grip_id: usize, action: crate::scene::model::object::GripMenuAction, ) -> Option<&'static str> { use crate::scene::model::object::GripMenuAction as A; let n = self.vertices.len(); if action == A::MoveParallel && grip_id >= n { let seg = grip_id - n; let count = if self.is_closed { n } else { n.saturating_sub(1) }; return (seg < count && self.vertices[seg].bulge.abs() < 1.0e-9) .then_some("Parallel offset"); } if action == A::Radius && grip_id >= n { let seg = grip_id - n; let count = if self.is_closed { n } else { n.saturating_sub(1) }; return (seg < count && self.vertices[seg].bulge.abs() >= 1.0e-9) .then_some("New radius"); } (is_rectangle(self) && n == 4 && (n..n + 4).contains(&grip_id) && matches!(action, A::RectangleWidth | A::RectangleHeight)) .then_some(match action { A::RectangleWidth => "New width", _ => "New height", }) } fn grip_menu_point_value( &self, grip_id: usize, action: crate::scene::model::object::GripMenuAction, point: glam::DVec3, ) -> Option { use crate::scene::model::object::GripMenuAction as A; if action == A::Radius { let n = self.vertices.len(); let seg = grip_id.checked_sub(n)?; let plane = crate::entities::curve::lwpolyline_curve(self)?.plane; let point = Vec2::from(plane.project(point.to_array())?); return cadkernel::geom2d::polyline_arc_radius_from_point( &kernel_polyline(self), seg, point.into(), ); } if action == A::MoveParallel { let n = self.vertices.len(); let seg = grip_id.checked_sub(n)?; let plane = crate::entities::curve::lwpolyline_curve(self)?.plane; let point = Vec2::from(plane.project(point.to_array())?); return cadkernel::geom2d::polyline_segment_parallel_offset( &kernel_polyline(self), seg, point.into(), ); } let (frame, plane) = rectangle_frame(self)?; let seg = grip_id.checked_sub(4)?; if seg >= 4 { return None; } let axis = match (seg % 2, action) { (0, A::RectangleHeight) => Vec2::from(frame.height_axis), (1, A::RectangleWidth) => Vec2::from(frame.width_axis), _ => return None, }; let opposite_mid = { let a = self.vertices[(seg + 2) % 4].location; let b = self.vertices[(seg + 3) % 4].location; Vec2::new((a.x + b.x) * 0.5, (a.y + b.y) * 0.5) }; let point = Vec2::from(plane.project(point.to_array())?); let value = (point - opposite_mid).dot(axis).abs(); (value > Tolerance::default().linear()).then_some(value) } fn apply_grip_menu_value( &mut self, grip_id: usize, action: crate::scene::model::object::GripMenuAction, value: f64, ) { use crate::scene::model::object::GripMenuAction as A; if action == A::Radius { if let Some(seg) = grip_id.checked_sub(self.vertices.len()) { resize_arc_segment_radius(self, seg, value); } return; } if action == A::MoveParallel { if let Some(seg) = grip_id.checked_sub(self.vertices.len()) { if self.vertices.get(seg).is_some_and(|vertex| vertex.bulge.abs() < 1.0e-9) { move_segment_parallel(self, seg, value); } } return; } if value <= Tolerance::default().linear() { return; } let Some((frame, _)) = rectangle_frame(self) else { return; }; let Some(seg) = grip_id.checked_sub(4) else { return; }; if seg >= 4 { return; } let axis = match (seg % 2, action) { (0, A::RectangleHeight) => frame.height_axis, (1, A::RectangleWidth) => frame.width_axis, _ => return, }; let i0 = seg; let i1 = (seg + 1) % 4; let o0 = (seg + 3) % 4; let o1 = (seg + 2) % 4; let selected_mid = (self.vertices[i0].location + self.vertices[i1].location) * 0.5; let opposite_mid = (self.vertices[o0].location + self.vertices[o1].location) * 0.5; let delta = selected_mid - opposite_mid; let signed_distance = delta.x * axis[0] + delta.y * axis[1]; if signed_distance.abs() <= Tolerance::default().linear() { return; } let direction = acadrust::types::Vector2::new(axis[0], axis[1]) * signed_distance.signum(); self.vertices[i0].location = self.vertices[o0].location + direction * value; self.vertices[i1].location = self.vertices[o1].location + direction * value; } fn apply_grip_menu(&mut self, grip_id: usize, action: crate::scene::model::object::GripMenuAction) { use crate::scene::model::object::GripMenuAction as A; let n = self.vertices.len(); match action { A::Stretch => {} A::AddVertex => { if n == 0 { return; } // The last vertex of an open polyline extends the path. Start // the provisional vertex on the endpoint; the grip driver then // moves it with the cursor until the placement click. if grip_id == n - 1 && !self.is_closed { self.vertices[grip_id].bulge = 0.0; let mut new_v = self.vertices[grip_id].clone(); new_v.bulge = 0.0; new_v.vertex_id = 0; self.vertices.push(new_v); return; } // Insert a provisional vertex midway along the following // segment. Arc segments are split into two arcs; the grip // driver refits both halves while the cursor moves. let (i0, i1) = if grip_id < n { let i0 = grip_id; let i1 = (grip_id + 1) % n; (i0, i1) } else { let seg = grip_id - n; (seg, (seg + 1) % n) }; if i1 == 0 && !self.is_closed { return; } let v0 = self.vertices[i0]; let v1 = self.vertices[i1]; let midpoint = if v0.bulge.abs() >= 1e-9 { arc_midpoint( [v0.location.x, v0.location.y], [v1.location.x, v1.location.y], v0.bulge, ) } else { [ (v0.location.x + v1.location.x) * 0.5, (v0.location.y + v1.location.y) * 0.5, ] }; let mut new_v = v0; new_v.location.x = midpoint[0]; new_v.location.y = midpoint[1]; new_v.vertex_id = 0; let (effective_start, effective_end) = vertex_segment_widths(&v0, self.constant_width); let middle_width = (effective_start + effective_end) * 0.5; self.vertices[i0].end_width = middle_width; new_v.start_width = middle_width; if v0.bulge.abs() >= 1e-9 { let half_bulge = (v0.bulge.atan() * 0.5).tan(); self.vertices[i0].bulge = half_bulge; new_v.bulge = half_bulge; } let insert_at = (i0 + 1).min(self.vertices.len()); self.vertices.insert(insert_at, new_v); } A::RemoveVertex if grip_id < n && self.vertices.len() > 2 => { self.vertices.remove(grip_id); } A::ConvertToArc if grip_id >= n => { if let Some(v) = self.vertices.get_mut(grip_id - n) { if v.bulge.abs() < 1e-9 { v.bulge = 0.5; } } } A::ConvertToLine if grip_id >= n => { if let Some(v) = self.vertices.get_mut(grip_id - n) { v.bulge = 0.0; } } _ => {} } } } impl crate::entities::traits::PropertyEditable for LwPolyline { fn geometry_properties( &self, _text_style_names: &[String], ) -> Vec { properties(self) } fn apply_geom_prop(&mut self, field: &str, value: &str) { apply_geom_prop(self, field, value); } } impl crate::entities::traits::Transformable for LwPolyline { fn apply_transform(&mut self, t: &crate::command::EntityTransform) { apply_transform(self, t); } } /// Solid-fill bands for a wide LwPolyline, plus the `world_origin` they are /// relative to. The band vertices are small f32 offsets from `origin` (the /// first vertex), so the relative-to-eye hatch fill keeps sub-unit precision at /// UTM-scale coordinates — building them in absolute f32 collapsed the band into /// a string of squares far from the origin. pub(crate) fn wide_fills(pl: &acadrust::entities::LwPolyline) -> ([f64; 2], Vec>) { // Codes 43 / 40 / 41 store the band's FULL width, and // `polyline_segment_fill` offsets ±hw about the centreline — so halve it. // Feeding the stored width in whole draws every wide polyline twice as wide // as the file asks for, which on a donut (a closed 2-vertex bulge-1 polyline) // shows up as a disc 1.5× its real radius. let verts = &pl.vertices; let n = verts.len(); if n < 2 { return ([0.0; 2], vec![]); } let origin = [verts[0].location.x, verts[0].location.y]; let seg_count = if pl.is_closed { n } else { n - 1 }; let mut out = Vec::new(); for i in 0..seg_count { let v0 = &verts[i]; let v1 = &verts[(i + 1) % n]; let (sw, ew) = vertex_segment_widths(v0, pl.constant_width); let hw0 = sw as f32 * 0.5; let hw1 = ew as f32 * 0.5; if hw0 < 1e-6 && hw1 < 1e-6 { continue; } let p0 = [ (v0.location.x - origin[0]) as f32, (v0.location.y - origin[1]) as f32, ]; let p1 = [ (v1.location.x - origin[0]) as f32, (v1.location.y - origin[1]) as f32, ]; if let Some(poly) = crate::entities::common::polyline_segment_fill(p0, p1, hw0, hw1, v0.bulge as f32) { out.push(poly); } } (origin, out) } impl crate::entities::traits::MassPropsCalc for acadrust::entities::LwPolyline { fn mass_props(&self) -> crate::entities::traits::MassProps { let p = self; let n = p.vertices.len(); if n < 2 { return crate::entities::traits::MassProps { area: 0.0, perimeter: 0.0, cx: 0.0, cy: 0.0, }; } // Kernel measurement includes bulges and closes open curves only for area. let curve = crate::entities::curve::lwpolyline_curve(p) .expect("an LwPolyline with at least two vertices has a planar curve"); let area = curve.curve.enclosed_area().abs(); let perimeter = curve.length(); let center = curve .curve .enclosed_centroid() .unwrap_or_else(|| { let x = p.vertices.iter().map(|v| v.location.x).sum::() / n as f64; let y = p.vertices.iter().map(|v| v.location.y).sum::() / n as f64; [x, y] }); let [cx, cy, _] = curve.plane.point_at(center); crate::entities::traits::MassProps { area, perimeter, cx, cy, } } } #[cfg(test)] mod tests { use super::*; use crate::entities::traits::{Grippable, PropertyEditable}; use crate::scene::model::object::GripMenuAction; use acadrust::entities::{LwPolyline, LwVertex}; use acadrust::{Vector2, Vector3}; fn make_test_lwpolyline(count: usize, constant_width: f64) -> LwPolyline { let mut pl = LwPolyline::default(); pl.constant_width = constant_width; for i in 0..count { pl.vertices.push(LwVertex::new(Vector2::new(i as f64 * 10.0, 0.0))); } pl } fn make_test_rectangle() -> LwPolyline { let mut pl = LwPolyline::default(); pl.is_closed = true; pl.vertices = [(0.0, 0.0), (10.0, 0.0), (10.0, 5.0), (0.0, 5.0)] .into_iter() .map(|(x, y)| LwVertex::new(Vector2::new(x, y))) .collect(); pl } #[test] fn rectangle_midpoint_offers_dimension_before_stretch() { let pl = make_test_rectangle(); let bottom = pl.grip_menu(4); assert_eq!(bottom[0].action, GripMenuAction::MoveParallel); assert_eq!(bottom[1].action, GripMenuAction::RectangleHeight); assert_eq!(bottom[2].action, GripMenuAction::Stretch); let right = pl.grip_menu(5); assert_eq!(right[0].action, GripMenuAction::MoveParallel); assert_eq!(right[1].action, GripMenuAction::RectangleWidth); assert_eq!(right[2].action, GripMenuAction::Stretch); } #[test] fn arc_midpoint_offers_radius_first_without_move_parallel() { let mut pl = make_test_lwpolyline(2, 0.0); pl.vertices[0].bulge = 0.5; let menu = pl.grip_menu(2); assert_eq!(menu[0].action, GripMenuAction::Radius); assert_eq!(menu[1].action, GripMenuAction::Stretch); assert!(!menu.iter().any(|item| item.action == GripMenuAction::MoveParallel)); } #[test] fn rectangle_corner_offers_resize_before_stretch() { let pl = make_test_rectangle(); let corner = pl.grip_menu(0); assert_eq!(corner[0].action, GripMenuAction::RectangleResize); assert_eq!(corner[1].action, GripMenuAction::Stretch); } #[test] fn rectangle_dimension_edit_keeps_opposite_edge_fixed() { let mut pl = make_test_rectangle(); pl.apply_grip_menu_value(6, GripMenuAction::RectangleHeight, 8.0); assert_eq!(pl.vertices[0].location, Vector2::new(0.0, 0.0)); assert_eq!(pl.vertices[1].location, Vector2::new(10.0, 0.0)); assert_eq!(pl.vertices[2].location, Vector2::new(10.0, 8.0)); assert_eq!(pl.vertices[3].location, Vector2::new(0.0, 8.0)); assert!(is_rectangle(&pl)); } #[test] fn rectangle_point_preview_reports_full_dimension() { let pl = make_test_rectangle(); let value = pl.grip_menu_point_value( 5, GripMenuAction::RectangleWidth, glam::DVec3::new(14.0, 2.5, 0.0), ); assert_eq!(value, Some(14.0)); } #[test] fn rectangle_point_preview_uses_entity_plane() { let mut pl = make_test_rectangle(); pl.normal = Vector3::new(0.0, 1.0, 0.0); pl.elevation = 5.0; let (_, plane) = rectangle_frame(&pl).expect("rectangle frame"); let point = glam::DVec3::from_array(plane.point_at([14.0, 2.5])); let value = pl.grip_menu_point_value(5, GripMenuAction::RectangleWidth, point); assert_eq!(value, Some(14.0)); } fn polyline(points: &[(f64, f64)], closed: bool) -> LwPolyline { let mut pl = LwPolyline::default(); pl.is_closed = closed; pl.vertices = points .iter() .map(|&(x, y)| LwVertex::new(Vector2::new(x, y))) .collect(); pl } #[test] fn move_parallel_reconnects_internal_edge_to_adjacent_lines() { let mut pl = polyline( &[(0.0, 0.0), (2.0, 2.0), (8.0, 2.0), (10.0, 0.0)], false, ); move_segment_parallel(&mut pl, 1, 2.0); assert_eq!(pl.vertices[0].location, Vector2::new(0.0, 0.0)); assert_eq!(pl.vertices[1].location, Vector2::new(4.0, 4.0)); assert_eq!(pl.vertices[2].location, Vector2::new(6.0, 4.0)); assert_eq!(pl.vertices[3].location, Vector2::new(10.0, 0.0)); } #[test] fn moving_a_line_parallel_only_changes_the_connected_arcs_length() { let mut pl = polyline(&[(-5.0, 0.0), (0.0, 0.0), (5.0, 5.0)], false); pl.vertices[1].bulge = -(std::f64::consts::FRAC_PI_2 * 0.25).tan(); let before = crate::entities::common::BulgeArc::from_bulge( [0.0, 0.0], [5.0, 5.0], pl.vertices[1].bulge, ).unwrap(); move_segment_parallel(&mut pl, 0, 1.0); let after = crate::entities::common::BulgeArc::from_bulge( [pl.vertices[1].location.x, pl.vertices[1].location.y], [pl.vertices[2].location.x, pl.vertices[2].location.y], pl.vertices[1].bulge, ).unwrap(); assert!(Vec2::from(after.center).distance(Vec2::from(before.center)) < 1.0e-9); assert!((after.radius - before.radius).abs() < 1.0e-9); assert_eq!(pl.vertices[2].location, Vector2::new(5.0, 5.0)); assert!((after.sweep - before.sweep).abs() > 1.0e-6); assert!((pl.vertices[0].location.y - 1.0).abs() < 1.0e-9); assert!((pl.vertices[1].location.y - 1.0).abs() < 1.0e-9); } #[test] fn radius_offsets_arc_concentrically() { let mut pl = polyline(&[(0.0, 0.0), (10.0, 0.0)], false); pl.vertices[0].bulge = 1.0; let original = crate::entities::common::BulgeArc::from_bulge( [0.0, 0.0], [10.0, 0.0], pl.vertices[0].bulge, ) .unwrap(); resize_arc_concentrically(&mut pl, 0, 2.0); let moved = crate::entities::common::BulgeArc::from_bulge( [pl.vertices[0].location.x, pl.vertices[0].location.y], [pl.vertices[1].location.x, pl.vertices[1].location.y], pl.vertices[0].bulge, ) .unwrap(); assert!(Vec2::from(moved.center).distance(original.center.into()) < 1.0e-9); assert!((moved.radius - original.radius - 2.0).abs() < 1.0e-9); assert_eq!(moved.sweep.signum(), original.sweep.signum()); } #[test] fn radius_allows_an_open_arc_with_a_duplicate_end_vertex() { let mut pl = polyline(&[(0.0, 0.0), (10.0, 0.0), (10.0, 0.0)], false); pl.vertices[0].bulge = 1.0; resize_arc_concentrically(&mut pl, 0, 2.0); let moved = crate::entities::common::BulgeArc::from_bulge( [pl.vertices[0].location.x, pl.vertices[0].location.y], [pl.vertices[1].location.x, pl.vertices[1].location.y], pl.vertices[0].bulge, ).unwrap(); assert!((moved.radius - 7.0).abs() < 1.0e-9); assert_eq!(pl.vertices[2].location, pl.vertices[1].location); } #[test] fn resizing_an_end_arc_only_changes_the_connected_arcs_length() { let shared = Vec2::new(3.0, 4.0); let mut pl = polyline(&[(-5.0, 0.0), (shared.x, shared.y), (11.0, 0.0)], false); let ccw_bulge = |center: Vec2, start: Vec2, end: Vec2| { let a0 = (start.y - center.y).atan2(start.x - center.x); let a1 = (end.y - center.y).atan2(end.x - center.x); ((a1 - a0).rem_euclid(TAU) * 0.25).tan() }; pl.vertices[0].bulge = ccw_bulge(Vec2::ZERO, Vec2::new(-5.0, 0.0), shared); pl.vertices[1].bulge = ccw_bulge(Vec2::new(6.0, 0.0), shared, Vec2::new(11.0, 0.0)); let before = crate::entities::common::BulgeArc::from_bulge( [shared.x, shared.y], [11.0, 0.0], pl.vertices[1].bulge, ).unwrap(); resize_arc_concentrically(&mut pl, 0, 1.0); let after = crate::entities::common::BulgeArc::from_bulge( [pl.vertices[1].location.x, pl.vertices[1].location.y], [pl.vertices[2].location.x, pl.vertices[2].location.y], pl.vertices[1].bulge, ).unwrap(); assert!(Vec2::from(after.center).distance(Vec2::from(before.center)) < 1.0e-9); assert!((after.radius - before.radius).abs() < 1.0e-9); assert_eq!(pl.vertices[2].location, Vector2::new(11.0, 0.0)); assert!((after.sweep - before.sweep).abs() > 1.0e-6); } #[test] fn radius_menu_resizes_a_corner_as_a_tangent_fillet() { let mut pl = polyline( &[(0.0, 0.0), (8.0, 0.0), (10.0, 2.0), (10.0, 10.0)], false, ); pl.vertices[1].bulge = (std::f64::consts::FRAC_PI_2 / 4.0).tan(); let grip = pl.vertices.len() + 1; assert!(pl.grip_menu(grip).iter().any(|item| item.action == GripMenuAction::Radius)); pl.apply_grip_menu_value(grip, GripMenuAction::Radius, 4.0); assert_eq!(pl.vertices[0].location, Vector2::new(0.0, 0.0)); assert!((pl.vertices[1].location.x - 6.0).abs() < 1.0e-9); assert!(pl.vertices[1].location.y.abs() < 1.0e-9); assert!((pl.vertices[2].location.x - 10.0).abs() < 1.0e-9); assert!((pl.vertices[2].location.y - 4.0).abs() < 1.0e-9); assert_eq!(pl.vertices[3].location, Vector2::new(10.0, 10.0)); let arc = crate::entities::common::BulgeArc::from_bulge( [pl.vertices[1].location.x, pl.vertices[1].location.y], [pl.vertices[2].location.x, pl.vertices[2].location.y], pl.vertices[1].bulge, ).unwrap(); assert!((arc.radius - 4.0).abs() < 1.0e-9); assert!(arc.sweep > 0.0); } #[test] fn radius_cursor_tracks_the_fillet_midpoint_ray() { let mut pl = polyline( &[(0.0, 0.0), (8.0, 0.0), (10.0, 2.0), (10.0, 10.0)], false, ); pl.vertices[1].bulge = (std::f64::consts::FRAC_PI_2 / 4.0).tan(); let arc = crate::entities::common::BulgeArc::from_bulge( [pl.vertices[1].location.x, pl.vertices[1].location.y], [pl.vertices[2].location.x, pl.vertices[2].location.y], pl.vertices[1].bulge, ).unwrap(); let midpoint = Vec2::from(arc.sample(0.5)); let cursor = Vec2::new(10.0, 0.0) + (midpoint - Vec2::new(10.0, 0.0)) * 2.0; let value = pl.grip_menu_point_value( pl.vertices.len() + 1, GripMenuAction::Radius, glam::DVec3::new(cursor.x, cursor.y, 0.0), ).unwrap(); assert!((value - 4.0).abs() < 1.0e-9); } #[test] fn radius_on_an_open_endpoint_arc_stays_concentric() { let mut pl = polyline(&[(0.0, 0.0), (10.0, 0.0)], false); pl.vertices[0].bulge = 1.0; resize_arc_segment_radius(&mut pl, 0, 8.0); let arc = crate::entities::common::BulgeArc::from_bulge( [pl.vertices[0].location.x, pl.vertices[0].location.y], [pl.vertices[1].location.x, pl.vertices[1].location.y], pl.vertices[0].bulge, ).unwrap(); assert!((arc.radius - 8.0).abs() < 1.0e-9); assert!(Vec2::from(arc.center).distance(Vec2::new(5.0, 0.0)) < 1.0e-9); assert!(arc.sweep > 0.0); } #[test] fn radius_edit_ignores_an_invalid_segment_grip() { let mut pl = polyline(&[(0.0, 0.0), (10.0, 0.0)], false); pl.vertices[0].bulge = 1.0; let before = pl.clone(); pl.apply_grip_menu_value(99, GripMenuAction::Radius, 8.0); assert_eq!(pl, before); } #[test] fn radius_on_a_non_tangent_internal_arc_keeps_center_and_side_angles() { let mut pl = polyline( &[(0.0, 0.0), (8.0, 0.0), (10.0, 2.0), (10.0, 10.0)], false, ); pl.vertices[1].bulge = 1.0; let before = crate::entities::common::BulgeArc::from_bulge( [8.0, 0.0], [10.0, 2.0], pl.vertices[1].bulge, ).unwrap(); resize_arc_segment_radius(&mut pl, 1, 4.0); let after = crate::entities::common::BulgeArc::from_bulge( [pl.vertices[1].location.x, pl.vertices[1].location.y], [pl.vertices[2].location.x, pl.vertices[2].location.y], pl.vertices[1].bulge, ).unwrap(); assert!(Vec2::from(after.center).distance(Vec2::from(before.center)) < 1.0e-9); assert!((after.radius - 4.0).abs() < 1.0e-9); let incoming = Vec2::new( pl.vertices[1].location.x - pl.vertices[0].location.x, pl.vertices[1].location.y - pl.vertices[0].location.y, ); let outgoing = Vec2::new( pl.vertices[3].location.x - pl.vertices[2].location.x, pl.vertices[3].location.y - pl.vertices[2].location.y, ); assert!(incoming.cross(Vec2::new(1.0, 0.0)).abs() < 1.0e-9); assert!(outgoing.cross(Vec2::new(0.0, 1.0)).abs() < 1.0e-9); } #[test] fn move_parallel_translates_free_endpoint_of_open_polyline() { let mut pl = polyline(&[(0.0, 0.0), (6.0, 0.0), (8.0, 2.0)], false); move_segment_parallel(&mut pl, 0, 2.0); assert_eq!(pl.vertices[0].location, Vector2::new(0.0, 2.0)); assert_eq!(pl.vertices[1].location, Vector2::new(8.0, 2.0)); assert_eq!(pl.vertices[2].location, Vector2::new(8.0, 2.0)); } #[test] fn move_parallel_wraps_closed_polyline_indices() { let mut pl = polyline(&[(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0)], true); move_segment_parallel(&mut pl, 3, -1.0); assert_eq!(pl.vertices[0].location, Vector2::new(-1.0, 0.0)); assert_eq!(pl.vertices[3].location, Vector2::new(-1.0, 4.0)); assert_eq!(pl.vertices[1].location, Vector2::new(4.0, 0.0)); assert_eq!(pl.vertices[2].location, Vector2::new(4.0, 4.0)); } #[test] fn move_parallel_rejects_parallel_adjacent_line() { let mut pl = polyline(&[(0.0, 0.0), (2.0, 0.0), (5.0, 0.0), (6.0, 2.0)], false); let original = pl.vertices.clone(); move_segment_parallel(&mut pl, 1, 1.0); assert_eq!(pl.vertices, original); } #[test] fn test_lwpolyline_uniform_width_shows_global_width() { let pl = make_test_lwpolyline(3, 5.0); assert_eq!(lwpolyline_global_width(&pl), Some(5.0)); let props = pl.geometry_properties(&[]); let geom_props = &props[0].props; let gw = geom_props.iter().find(|p| p.field == "global_width").unwrap(); match &gw.value { PropValue::EditText(val) => assert_eq!(parse_f64(val), Some(5.0)), _ => panic!("expected EditText"), } } #[test] fn test_lwpolyline_varying_vertex_width_shows_varies() { let mut pl = make_test_lwpolyline(3, 5.0); pl.vertices[0].start_width = 2.0; pl.vertices[0].end_width = 3.0; assert_eq!(lwpolyline_global_width(&pl), None); let props = pl.geometry_properties(&[]); let geom_props = &props[0].props; let gw = geom_props.iter().find(|p| p.field == "global_width").unwrap(); match &gw.value { PropValue::EditText(val) => assert_eq!(val, VARIES_LABEL), _ => panic!("expected EditText with VARIES_LABEL"), } } #[test] fn test_lwpolyline_modifying_global_width_sets_all_vertex_widths() { let mut pl = make_test_lwpolyline(3, 5.0); pl.vertices[0].start_width = 2.0; pl.vertices[0].end_width = 3.0; pl.apply_geom_prop("global_width", "10.0"); assert_eq!(pl.constant_width, 10.0); for v in &pl.vertices { assert_eq!(v.start_width, 10.0); assert_eq!(v.end_width, 10.0); } assert_eq!(lwpolyline_global_width(&pl), Some(10.0)); } #[test] fn test_lwpolyline_modifying_segment_width_materializes_constant_width() { let mut pl = make_test_lwpolyline(3, 5.0); // Initially constant_width is 5.0, vertices start/end are 0.0 assert_eq!(pl.vertices[0].start_width, 0.0); // Edit start width of current vertex (vertex 0) to 2.0 pl.apply_geom_prop("start_width", "2.0"); // Vertex 0 start width should be 2.0, end width materialized to 5.0 assert_eq!(pl.vertices[0].start_width, 2.0); assert_eq!(pl.vertices[0].end_width, 5.0); // Vertex 1 and 2 should have materialized 5.0 instead of collapsing to 0.0 assert_eq!(pl.vertices[1].start_width, 5.0); assert_eq!(pl.vertices[1].end_width, 5.0); // Global width must now be VARIES assert_eq!(lwpolyline_global_width(&pl), None); } }