forked from 1b-agent/opencadstudio-upstream
feat(revcloud): complete editing workflow
Keep source planes intact and stored Mline offsets in sync. Refs #626
This commit is contained in:
commit
667f20d604
27 files changed
+1706
-139
No files matched your search
@@ -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
@@ -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
@@ -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| {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")),
|
||||
|
||||
+875
-114
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user