feat(revcloud): complete editing workflow

Keep source planes intact and stored Mline offsets in sync.

Refs #626
This commit is contained in:
Hakan Seven committed 2026-08-20 20:33:57 +03:00
commit 667f20d604
27 files changed
+1706 -139

No files matched your search

+9 -1
View File
@@ -60,7 +60,15 @@ impl OpenCADStudio {
"REVCLOUD" => {
use crate::modules::draw::draw::revcloud::RevCloudCommand;
let cmd = RevCloudCommand::new();
let view_height = self.tabs[i].scene.camera.borrow().ortho_size() as f64 * 2.0;
let default_arc_length = (view_height * 0.0125).max(1.0e-6);
let sources = self.tabs[i]
.scene
.document
.entities()
.map(|entity| (entity.common().handle, entity.clone()))
.collect();
let cmd = RevCloudCommand::new(default_arc_length, sources);
self.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd));
}
+252 -18
View File
@@ -1,4 +1,6 @@
use acadrust::entities::{LwPolyline, LwVertex};
use cadkernel::geom2d::{signed_area, Curve, Polyline, PolylineVertex, Vec2};
use crate::t;
use crate::command::EntityTransform;
@@ -12,6 +14,8 @@ use crate::scene::model::object::{GripApply, GripDef, PropSection, PropValue, Pr
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;
/// Midpoint position on an arc segment defined by its bulge.
fn arc_midpoint(p0: [f64; 2], p1: [f64; 2], bulge: f64) -> [f64; 2] {
@@ -597,6 +601,219 @@ fn grips(pline: &LwPolyline) -> Vec<GripDef> {
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);
}
fn properties(pline: &LwPolyline) -> Vec<PropSection> {
let n = pline.vertices.len();
// The panel's Current Vertex focus, clamped to this polyline's range.
@@ -611,11 +828,39 @@ fn properties(pline: &LwPolyline) -> Vec<PropSection> {
let start_w = v.map_or(0.0, |v| v.start_width);
let end_w = v.map_or(0.0, |v| v.end_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 if cloud_arc_length.is_some() {
format!("{}", vi + 1)
} else {
format!("{} / {}", vi + 1, n)
};
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,
));
}
vec![
PropSection {
title: t!("Geometry").into_owned(),
@@ -633,30 +878,19 @@ fn properties(pline: &LwPolyline) -> Vec<PropSection> {
},
PropSection {
title: t!("Misc").into_owned(),
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,
},
},
],
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
+35 -6
View File
@@ -1,12 +1,12 @@
use acadrust::entities::MLine;
use crate::t;
use crate::command::EntityTransform;
use crate::entities::common::{edit_prop as edit, square_grip};
use crate::entities::traits::{Grippable, PropertyEditable, Transformable, RenderConvertible};
use crate::entities::traits::{Grippable, PropertyEditable, RenderConvertible, Transformable};
use crate::scene::convert::acad_to_render::{RenderEntity, RenderObject};
use crate::scene::model::object::{GripApply, GripDef, PropSection, PropValue, Property};
use crate::scene::model::wire_model::SnapHint;
use crate::t;
/// One drawn line of a multiline: the polyline for a single style element (or
/// an end cap), tagged with the element's colour and linetype so the
@@ -350,7 +350,7 @@ impl PropertyEditable for MLine {
.collect(),
},
},
edit(t!("Style scale").as_ref(), "ml_scale", self.scale_factor),
edit(t!("Scale").as_ref(), "ml_scale", self.scale_factor),
],
}]
}
@@ -384,13 +384,42 @@ impl PropertyEditable for MLine {
let Ok(v) = value.trim().parse::<f64>() else {
return;
};
match field {
"ml_scale" if v != 0.0 => self.scale_factor = v,
_ => {}
if field == "ml_scale" {
set_mline_scale(self, v);
}
}
}
fn set_mline_scale(mline: &mut MLine, scale: f64) {
let old = mline.scale_factor;
if !scale.is_finite() || scale == 0.0 || !old.is_finite() || old == 0.0 {
return;
}
let ratio = scale / old;
if !ratio.is_finite() {
return;
}
if mline
.vertices
.iter()
.flat_map(|vertex| &vertex.segments)
.filter_map(|segment| segment.parameters.first())
.any(|offset| !offset.is_finite() || !(offset * ratio).is_finite())
{
return;
}
for segment in mline
.vertices
.iter_mut()
.flat_map(|vertex| vertex.segments.iter_mut())
{
if let Some(offset) = segment.parameters.first_mut() {
*offset *= ratio;
}
}
mline.scale_factor = scale;
}
impl Transformable for MLine {
fn apply_transform(&mut self, t: &EntityTransform) {
crate::scene::view::transform::apply_standard_entity_transform(self, t, |entity, p1, p2| {
+5
View File
@@ -19,6 +19,11 @@ pub fn ui_name(e: &EntityType) -> &'static str {
EntityType::Ellipse(_) => "Ellipse",
EntityType::Spline(_) => "Spline",
EntityType::Helix(_) => "Helix",
EntityType::LwPolyline(polyline)
if crate::entities::lwpolyline::is_revision_cloud(polyline) =>
{
"Revcloud"
}
EntityType::LwPolyline(_) => "Polyline",
EntityType::Polyline(_) => "Polyline",
EntityType::Polyline2D(_) => "Polyline2D",
+5
View File
@@ -118,6 +118,11 @@ pub fn entity_type_name(et: &EntityType) -> &str {
EntityType::Polyline(_) => "Polyline",
EntityType::Polyline2D(_) => "Polyline2D",
EntityType::Polyline3D(_) => "Polyline3D",
EntityType::LwPolyline(polyline)
if crate::entities::lwpolyline::is_revision_cloud(polyline) =>
{
"Revcloud"
}
EntityType::LwPolyline(_) => "LwPolyline",
EntityType::Text(_) => "Text",
EntityType::MText(_) => "MText",
+25
View File
@@ -2070,6 +2070,19 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static
"RENAME: unknown type '{}'. Use LAYER BLOCK STYLE DIMSTYLE LINETYPE UCS VIEW" => Some(("styles", "rename-unknown-type-use-layer-block-style-dims")),
"REVCLOUD Specify next point (%{count} pts, Enter to close):" => Some(("draw", "revcloud-specify-next-point-count-pts-enter-to")),
"REVCLOUD Specify start point (arc length = %{arc_length}):" => Some(("draw", "revcloud-specify-start-point-arc-length-arc-le")),
"REVCLOUD First corner (%{style}, arc length %{length}): %{message}" => Some(("draw", "revcloud-first-corner")),
"REVCLOUD Opposite corner: %{message}" => Some(("draw", "revcloud-opposite-corner")),
"REVCLOUD Polygonal start point (%{style}, arc length %{length}): %{message}" => Some(("draw", "revcloud-polygonal-start-point")),
"REVCLOUD Next polygonal point (%{count} points, Enter to close): %{message}" => Some(("draw", "revcloud-next-polygonal-point")),
"REVCLOUD Freehand first point (%{style}, arc length %{length}): %{message}" => Some(("draw", "revcloud-freehand-first-point")),
"REVCLOUD Guide the cursor, then click or press Enter to close: %{message}" => Some(("draw", "revcloud-guide-cursor")),
"REVCLOUD Approximate arc chord length <%{length}>:" => Some(("draw", "revcloud-approximate-arc-chord-length")),
"REVCLOUD Style [Normal/Calligraphy] <%{style}>:" => Some(("draw", "revcloud-style")),
"REVCLOUD Select a closed circle, ellipse, polyline, or spline: %{message}" => Some(("draw", "revcloud-select-closed-curve")),
"REVCLOUD Reverse arc direction [Yes/No] <No>:" => Some(("draw", "revcloud-reverse-arc-direction")),
"REVCLOUD Select a closed revision-cloud polyline near the replacement start: %{message}" => Some(("draw", "revcloud-select-revision-cloud")),
"REVCLOUD Replacement point %{count}; finish on another cloud vertex:" => Some(("draw", "revcloud-replacement-point")),
"REVCLOUD Pick the side of the original cloud to erase:" => Some(("draw", "revcloud-pick-side")),
"REVERSE Select line, polyline or spline to reverse:" => Some(("modify", "reverse-select-line-polyline-or-spline-to-reve")),
"REVOLVE Angle of revolution <360>:" => Some(("insert", "revolve-angle-of-revolution-360")),
"REVOLVE Axis end point:" => Some(("insert", "revolve-axis-end-point")),
@@ -2141,6 +2154,18 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static
"Restored properties" => Some(("layers", "restored-properties")),
"Retry" => Some(("common", "retry")),
"Rev Cloud" => Some(("draw", "rev-cloud")),
"Calligraphy" => Some(("draw", "calligraphy")),
"First point" => Some(("draw", "first-point")),
"Freehand" => Some(("draw", "freehand")),
"Rectangular" => Some(("draw", "rectangular")),
"The selected path cannot form a revision cloud." => Some(("draw", "revcloud-path-cannot-form")),
"Select a closed revision-cloud polyline." => Some(("draw", "revcloud-select-closed-revision-cloud")),
"The selected polyline is not planar." => Some(("draw", "revcloud-polyline-not-planar")),
"The two corners must define a non-zero area." => Some(("draw", "revcloud-corners-non-zero-area")),
"Arc length must be greater than zero." => Some(("draw", "revcloud-arc-length-positive")),
"Enter a valid arc length." => Some(("draw", "revcloud-enter-valid-arc-length")),
"Select a supported closed curve." => Some(("draw", "revcloud-select-supported-closed-curve")),
"Select a closed circle, ellipse, polyline, or spline." => Some(("draw", "revcloud-select-closed-entity")),
"Reverse" => Some(("common", "reverse")),
"Revision" => Some(("model", "revision")),
"Revolve Entity" => Some(("model", "revolve-entity")),
File diff suppressed because it is too large. Load diff