Files
OneCAD-Draft/src/entities/lwpolyline.rs
T

2051 lines
75 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<f64> {
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<f64> {
// 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<TangentGeom>,
) -> 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<TangentGeom>,
) -> 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<TangentGeom> = 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<TangentGeom> = 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<TangentGeom> = 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<TangentGeom>, 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<Vec<acadrust::EntityType>> {
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<GripDef> {
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<GripDef> = 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<f64> {
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<f64> {
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<Vec<[f64; 2]>> {
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<LwVertex> {
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<LwPolyline> {
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<f64>) -> Option<f64> {
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<PropSection> {
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 = <LwPolyline as crate::entities::traits::MassPropsCalc>::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<RenderEntity> {
Some(to_render(self, document.header.fill_mode))
}
}
impl crate::entities::traits::Grippable for LwPolyline {
fn grips(&self) -> Vec<crate::scene::model::object::GripDef> {
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<crate::scene::model::object::GripMenuItem> {
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<f64> {
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<crate::scene::model::object::PropSection> {
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<Vec<[f32; 2]>>) {
// 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::<f64>() / n as f64;
let y = p.vertices.iter().map(|v| v.location.y).sum::<f64>() / 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);
}
}