diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index c26073815..576812c9b 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -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)); } diff --git a/src/modules/draw/draw/revcloud.rs b/src/modules/draw/draw/revcloud.rs index da872b8af..a51e02da4 100644 --- a/src/modules/draw/draw/revcloud.rs +++ b/src/modules/draw/draw/revcloud.rs @@ -1,18 +1,18 @@ -// REVCLOUD command — draw a revision cloud (arc-bumped closed polyline). -// -// Workflow: pick polygon corners (like PLINE), press Enter to close. -// Each segment of the polygon is subdivided into arc bumps (bulge = 0.5). -// Minimum arc length = `arc_length` parameter. +// REVCLOUD command — create or modify arc-bumped lightweight polylines. -use acadrust::entities::LwPolyline; +use std::sync::atomic::{AtomicU64, AtomicU8, Ordering}; + +use acadrust::entities::{LwPolyline, LwVertex}; use acadrust::types::Vector2; -use acadrust::{entities::LwVertex, EntityType}; +use acadrust::{EntityType, Handle}; use glam::DVec3; -use crate::t; +use rustc_hash::FxHashMap; -use crate::command::{CadCommand, CmdResult, WorkingPlane}; +use crate::command::{CadCommand, CmdOption, CmdResult, WorkingPlane}; +use crate::entities::curve::{curve_points, entity_curve}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; +use crate::t; pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../../assets/icons/revcloud.svg")); @@ -25,22 +25,366 @@ pub fn tool() -> ToolDef { } } -const DEFAULT_ARC_LEN: f64 = 1.0; // default arc bump length +const DEFAULT_ARC_LENGTH: f64 = 1.0; +const BUMP_BULGE: f64 = 0.5; + +static LAST_ARC_LENGTH: AtomicU64 = AtomicU64::new(0); +static LAST_CREATION: AtomicU8 = AtomicU8::new(1); +static LAST_STYLE: AtomicU8 = AtomicU8::new(0); + +#[derive(Clone, Copy, PartialEq, Eq)] +enum CreationMode { + Freehand, + Rectangular, + Polygonal, +} + +impl CreationMode { + fn remembered() -> Self { + match LAST_CREATION.load(Ordering::Relaxed) { + 0 => Self::Freehand, + 2 => Self::Polygonal, + _ => Self::Rectangular, + } + } + + fn remember(self) { + let value = match self { + Self::Freehand => 0, + Self::Rectangular => 1, + Self::Polygonal => 2, + }; + LAST_CREATION.store(value, Ordering::Relaxed); + } + +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum CloudStyle { + Normal, + Calligraphy, +} + +impl CloudStyle { + fn remembered() -> Self { + if LAST_STYLE.load(Ordering::Relaxed) == 1 { + Self::Calligraphy + } else { + Self::Normal + } + } + + fn remember(self) { + LAST_STYLE.store((self == Self::Calligraphy) as u8, Ordering::Relaxed); + } + + fn label(self) -> &'static str { + match self { + Self::Normal => "Normal", + Self::Calligraphy => "Calligraphy", + } + } +} + +struct PendingCloud { + entity: EntityType, + replacement: Option, +} + +struct ModifyState { + handle: Handle, + source: EntityType, + vertices: Vec, + start: usize, + end: Option, + replacement: Vec, +} + +enum Stage { + Create, + ArcLength, + Style, + Object, + Reverse(PendingCloud), + ModifySelect, + ModifyDraw(ModifyState), + ModifyErase(ModifyState), +} pub struct RevCloudCommand { points: Vec, arc_length: f64, + creation: CreationMode, + style: CloudStyle, + stage: Stage, + tracing: bool, + sources: FxHashMap, plane: WorkingPlane, + message: Option<&'static str>, } impl RevCloudCommand { - pub fn new() -> Self { + pub fn new(default_arc_length: f64, sources: FxHashMap) -> Self { + let remembered = f64::from_bits(LAST_ARC_LENGTH.load(Ordering::Relaxed)); + let arc_length = if remembered.is_finite() && remembered > 0.0 { + remembered + } else if default_arc_length.is_finite() && default_arc_length > 0.0 { + default_arc_length + } else { + DEFAULT_ARC_LENGTH + }; + LAST_ARC_LENGTH.store(arc_length.to_bits(), Ordering::Relaxed); Self { - points: vec![], - arc_length: DEFAULT_ARC_LEN, + points: Vec::new(), + arc_length, + creation: CreationMode::remembered(), + style: CloudStyle::remembered(), + stage: Stage::Create, + tracing: false, + sources, plane: WorkingPlane::default(), + message: None, } } + + fn set_creation(&mut self, mode: CreationMode) { + self.creation = mode; + self.creation.remember(); + self.stage = Stage::Create; + self.points.clear(); + self.tracing = false; + self.message = None; + } + + fn local_points(&self, points: &[DVec3]) -> Vec { + points + .iter() + .map(|point| self.plane.to_local(*point)) + .collect() + } + + fn cloud_from_world_points(&self, points: &[DVec3], reverse: bool) -> Option { + let local = self.local_points(points); + let cloud = make_revcloud(&local, self.arc_length, self.style, reverse)?; + Some(self.plane.place_entity(EntityType::LwPolyline(cloud))) + } + + fn prepare_cloud(&mut self, points: &[DVec3], replacement: Option) -> CmdResult { + let Some(mut entity) = self.cloud_from_world_points(points, false) else { + self.message = Some("The selected path cannot form a revision cloud."); + return CmdResult::NeedPoint; + }; + if let Some(handle) = replacement { + if let Some(source) = self.sources.get(&handle) { + *entity.common_mut() = source.common().clone(); + entity.common_mut().handle = Handle::NULL; + } + } + self.stage = Stage::Reverse(PendingCloud { + entity, + replacement, + }); + self.message = None; + CmdResult::NeedPoint + } + + fn finish_pending(pending: PendingCloud, reverse: bool) -> CmdResult { + let entity = if reverse { + reverse_cloud_entity(pending.entity) + } else { + pending.entity + }; + if let Some(handle) = pending.replacement { + CmdResult::ReplaceEntity(handle, vec![entity]) + } else { + CmdResult::CommitAndExit(entity) + } + } + + fn object_points(&self, entity: &EntityType) -> Option> { + let closed = match entity { + EntityType::Circle(_) => true, + EntityType::Ellipse(ellipse) => ellipse.is_full(), + EntityType::LwPolyline(polyline) => polyline.is_closed, + EntityType::Polyline2D(polyline) => polyline.is_closed(), + EntityType::Spline(spline) => spline.flags.closed || spline.flags.periodic, + _ => false, + }; + if !closed { + return None; + } + let curve = entity_curve(entity)?; + let mut points: Vec = curve_points(&curve) + .into_iter() + .map(DVec3::from_array) + .collect(); + if points.len() >= 2 + && points[0].distance(*points.last().unwrap()) <= self.arc_length * 0.05 + { + points.pop(); + } + (points.len() >= 3).then_some(points) + } + + fn rectangle_points(&self, first: DVec3, opposite: DVec3) -> Option> { + let first = self.plane.to_local(first); + let opposite = self.plane.to_local(opposite); + if (first.x - opposite.x).abs() <= f64::EPSILON + || (first.y - opposite.y).abs() <= f64::EPSILON + { + return None; + } + Some( + [ + DVec3::new(first.x, first.y, first.z), + DVec3::new(opposite.x, first.y, first.z), + DVec3::new(opposite.x, opposite.y, first.z), + DVec3::new(first.x, opposite.y, first.z), + ] + .into_iter() + .map(|point| self.plane.to_world(point)) + .collect(), + ) + } + + fn preview(entity: &EntityType, name: &str) -> Option { + let curve = entity_curve(entity)?; + let points = curve_points(&curve); + (points.len() >= 2).then(|| { + WireModel::solid_f64(name.to_string(), points, WireModel::CYAN, false) + }) + } + + fn preview_world_points(&self, points: &[DVec3], name: &str) -> Option { + let entity = self.cloud_from_world_points(points, false)?; + Self::preview(&entity, name) + } + + fn start_modify(&mut self, handle: Handle, picked: DVec3) -> CmdResult { + let Some(source) = self.sources.get(&handle).cloned() else { + self.message = Some("Select a closed revision-cloud polyline."); + return CmdResult::NeedPoint; + }; + let EntityType::LwPolyline(polyline) = &source else { + self.message = Some("Select a closed revision-cloud polyline."); + return CmdResult::NeedPoint; + }; + if !polyline.is_closed || polyline.vertices.len() < 3 { + self.message = Some("Select a closed revision-cloud polyline."); + return CmdResult::NeedPoint; + } + let Some(curve) = entity_curve(&source) else { + self.message = Some("The selected polyline is not planar."); + return CmdResult::NeedPoint; + }; + let vertices: Vec = polyline + .vertices + .iter() + .map(|vertex| { + DVec3::from_array(curve.plane.point_at([ + vertex.location.x, + vertex.location.y, + ])) + }) + .collect(); + let start = vertices + .iter() + .enumerate() + .min_by(|(_, left), (_, right)| { + left.distance_squared(picked) + .total_cmp(&right.distance_squared(picked)) + }) + .map(|(index, _)| index) + .unwrap_or(0); + self.stage = Stage::ModifyDraw(ModifyState { + handle, + source, + vertices: vertices.clone(), + start, + end: None, + replacement: vec![vertices[start]], + }); + self.message = None; + CmdResult::NeedPoint + } + + fn modify_endpoint(state: &ModifyState, point: DVec3, tolerance: f64) -> Option { + state + .vertices + .iter() + .enumerate() + .filter(|(index, _)| *index != state.start) + .filter_map(|(index, vertex)| { + let distance = vertex.distance(point); + (distance <= tolerance).then_some((index, distance)) + }) + .min_by(|left, right| left.1.total_cmp(&right.1)) + .map(|(index, _)| index) + } + + fn path_indices(count: usize, from: usize, to: usize) -> Vec { + let mut indices = vec![from]; + let mut current = from; + while current != to && indices.len() <= count { + current = (current + 1) % count; + indices.push(current); + } + indices + } + + fn path_distance(vertices: &[DVec3], indices: &[usize], point: DVec3) -> f64 { + indices + .windows(2) + .map(|pair| distance_to_segment(point, vertices[pair[0]], vertices[pair[1]])) + .fold(f64::INFINITY, f64::min) + } + + fn modified_cloud(&self, state: ModifyState, erase_point: DVec3) -> Option { + let end = state.end?; + let count = state.vertices.len(); + let forward = Self::path_indices(count, state.start, end); + let backward = Self::path_indices(count, end, state.start); + let erase_forward = Self::path_distance(&state.vertices, &forward, erase_point) + <= Self::path_distance(&state.vertices, &backward, erase_point); + + let mut guide = Vec::new(); + if erase_forward { + guide.extend(backward.iter().map(|index| state.vertices[*index])); + guide.extend(state.replacement.iter().skip(1).copied()); + } else { + guide.extend(forward.iter().map(|index| state.vertices[*index])); + guide.extend(state.replacement.iter().rev().skip(1).copied()); + } + remove_adjacent_duplicates(&mut guide, self.arc_length * 1.0e-6); + let style = style_from_entity(&state.source).unwrap_or(self.style); + let local = self.local_points(&guide); + let mut entity = EntityType::LwPolyline(make_revcloud( + &local, + self.arc_length, + style, + false, + )?); + entity = self.plane.place_entity(entity); + let common = state.source.common().clone(); + *entity.common_mut() = common; + entity.common_mut().handle = Handle::NULL; + Some(PendingCloud { + entity, + replacement: Some(state.handle), + }) + } + + fn common_options(&self) -> Vec { + vec![ + CmdOption::new("Arc length", "A"), + CmdOption::new("Object", "O"), + CmdOption::new("Rectangular", "R"), + CmdOption::new("Polygonal", "P"), + CmdOption::new("Freehand", "F"), + CmdOption::new("Style", "S"), + CmdOption::new("Modify", "M"), + ] + } } impl CadCommand for RevCloudCommand { @@ -53,123 +397,500 @@ impl CadCommand for RevCloudCommand { } fn prompt(&self) -> String { - if self.points.is_empty() { - t!( - "REVCLOUD Specify start point (arc length = %{arc_length}):", - arc_length = format!("{:.2}", self.arc_length) + let message = self.message.map_or("", |message| message); + match &self.stage { + Stage::Create => match self.creation { + CreationMode::Rectangular if self.points.is_empty() => t!( + "REVCLOUD First corner (%{style}, arc length %{length}): %{message}", + style = self.style.label(), + length = format!("{:.4}", self.arc_length), + message = message + ) + .into_owned(), + CreationMode::Rectangular => { + t!("REVCLOUD Opposite corner: %{message}", message = message).into_owned() + } + CreationMode::Polygonal if self.points.is_empty() => t!( + "REVCLOUD Polygonal start point (%{style}, arc length %{length}): %{message}", + style = self.style.label(), + length = format!("{:.4}", self.arc_length), + message = message + ) + .into_owned(), + CreationMode::Polygonal => t!( + "REVCLOUD Next polygonal point (%{count} points, Enter to close): %{message}", + count = self.points.len(), + message = message + ) + .into_owned(), + CreationMode::Freehand if !self.tracing => t!( + "REVCLOUD Freehand first point (%{style}, arc length %{length}): %{message}", + style = self.style.label(), + length = format!("{:.4}", self.arc_length), + message = message + ) + .into_owned(), + CreationMode::Freehand => t!( + "REVCLOUD Guide the cursor, then click or press Enter to close: %{message}", + message = message + ) + .into_owned(), + }, + Stage::ArcLength => t!( + "REVCLOUD Approximate arc chord length <%{length}>:", + length = format!("{:.4}", self.arc_length) ) - .into_owned() - } else { - t!( - "REVCLOUD Specify next point (%{count} pts, Enter to close):", - count = self.points.len() + .into_owned(), + Stage::Style => t!( + "REVCLOUD Style [Normal/Calligraphy] <%{style}>:", + style = self.style.label() ) - .into_owned() + .into_owned(), + Stage::Object => t!( + "REVCLOUD Select a closed circle, ellipse, polyline, or spline: %{message}", + message = message + ) + .into_owned(), + Stage::Reverse(_) => { + t!("REVCLOUD Reverse arc direction [Yes/No] :").into_owned() + } + Stage::ModifySelect => t!( + "REVCLOUD Select a closed revision-cloud polyline near the replacement start: %{message}", + message = message + ) + .into_owned(), + Stage::ModifyDraw(state) => t!( + "REVCLOUD Replacement point %{count}; finish on another cloud vertex:", + count = state.replacement.len() + ) + .into_owned(), + Stage::ModifyErase(_) => { + t!("REVCLOUD Pick the side of the original cloud to erase:").into_owned() + } } } - fn on_point(&mut self, pt: DVec3) -> CmdResult { - self.points.push(pt); - CmdResult::NeedPoint + fn options(&self) -> Vec { + match &self.stage { + Stage::Create if self.points.is_empty() => self.common_options(), + Stage::Create if self.creation == CreationMode::Polygonal => { + let mut options = vec![CmdOption::new("Undo", "U")]; + if self.points.len() >= 3 { + options.push(CmdOption::enter("Close")); + } + options + } + Stage::Create if self.creation == CreationMode::Freehand && self.tracing => { + vec![CmdOption::enter("Close")] + } + Stage::Create => Vec::new(), + Stage::ArcLength => Vec::new(), + Stage::Style => vec![ + CmdOption::new("Normal", "N"), + CmdOption::new("Calligraphy", "C"), + ], + Stage::Object | Stage::ModifySelect => vec![CmdOption::new("Back", "B")], + Stage::Reverse(_) => vec![ + CmdOption::new("Yes", "Y"), + CmdOption::new("No", "N"), + ], + Stage::ModifyDraw(state) => { + if state.replacement.len() > 1 { + vec![CmdOption::new("Undo", "U"), CmdOption::new("First point", "F")] + } else { + Vec::new() + } + } + Stage::ModifyErase(_) => Vec::new(), + } + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + self.message = None; + match &mut self.stage { + Stage::Create => match self.creation { + CreationMode::Rectangular => { + if self.points.is_empty() { + self.points.push(point); + CmdResult::NeedPoint + } else { + let first = self.points[0]; + let Some(points) = self.rectangle_points(first, point) else { + self.message = Some("The two corners must define a non-zero area."); + return CmdResult::NeedPoint; + }; + self.points.clear(); + self.prepare_cloud(&points, None) + } + } + CreationMode::Polygonal => { + self.points.push(point); + CmdResult::NeedPoint + } + CreationMode::Freehand => { + if !self.tracing { + self.points.clear(); + self.points.push(point); + self.tracing = true; + CmdResult::NeedPoint + } else if self.points.len() >= 3 { + self.tracing = false; + let points = self.points.clone(); + self.points.clear(); + self.prepare_cloud(&points, None) + } else { + CmdResult::NeedPoint + } + } + }, + Stage::ModifyDraw(state) => { + let tolerance = self.arc_length.max(1.0e-6) * 0.75; + if state.replacement.len() >= 2 { + if let Some(end) = Self::modify_endpoint(state, point, tolerance) { + state.end = Some(end); + state.replacement.push(state.vertices[end]); + let state = match std::mem::replace(&mut self.stage, Stage::Create) { + Stage::ModifyDraw(state) => state, + _ => unreachable!(), + }; + self.stage = Stage::ModifyErase(state); + return CmdResult::NeedPoint; + } + } + state.replacement.push(point); + CmdResult::NeedPoint + } + Stage::ModifyErase(_) => { + let state = match std::mem::replace(&mut self.stage, Stage::Create) { + Stage::ModifyErase(state) => state, + _ => unreachable!(), + }; + let Some(pending) = self.modified_cloud(state, point) else { + return CmdResult::Cancel; + }; + self.stage = Stage::Reverse(pending); + CmdResult::NeedPoint + } + _ => CmdResult::NeedPoint, + } } fn on_enter(&mut self) -> CmdResult { - if self.points.len() < 3 { - return CmdResult::Cancel; + match &self.stage { + Stage::Create if self.creation == CreationMode::Polygonal && self.points.len() >= 3 => { + let points = self.points.clone(); + self.points.clear(); + self.prepare_cloud(&points, None) + } + Stage::Create if self.creation == CreationMode::Freehand && self.points.len() >= 3 => { + self.tracing = false; + let points = self.points.clone(); + self.points.clear(); + self.prepare_cloud(&points, None) + } + Stage::ArcLength | Stage::Style => { + self.stage = Stage::Create; + CmdResult::NeedPoint + } + Stage::Reverse(_) => { + let stage = std::mem::replace(&mut self.stage, Stage::Create); + match stage { + Stage::Reverse(pending) => Self::finish_pending(pending, false), + _ => unreachable!(), + } + } + _ => CmdResult::Cancel, } - let local: Vec = self - .points - .iter() - .map(|point| self.plane.to_local(*point)) - .collect(); - let entity = make_revcloud(&local, self.arc_length); - CmdResult::CommitAndExit(self.plane.place_entity(entity)) } - fn on_mouse_move(&mut self, pt: DVec3) -> Option { - let pt = pt.as_vec3(); - if self.points.is_empty() { - return None; + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } + + fn wants_text_input(&self) -> bool { + true + } + + fn point_step_accepts_keywords(&self) -> bool { + matches!(self.stage, Stage::Create | Stage::ModifyDraw(_)) + } + + fn on_text_input(&mut self, text: &str) -> Option { + let input = text.trim(); + if matches!(self.stage, Stage::ArcLength) { + if let Ok(value) = input.parse::() { + if value.is_finite() && value > 0.0 { + self.arc_length = value; + LAST_ARC_LENGTH.store(value.to_bits(), Ordering::Relaxed); + self.stage = Stage::Create; + self.message = None; + } else { + self.message = Some("Arc length must be greater than zero."); + } + } else { + self.message = Some("Enter a valid arc length."); + } + return Some(CmdResult::NeedPoint); + } + let keyword = input.to_ascii_uppercase(); + if matches!(self.stage, Stage::Style) { + self.style = match keyword.as_str() { + "N" | "NORMAL" => CloudStyle::Normal, + "C" | "CALLIGRAPHY" => CloudStyle::Calligraphy, + _ => return None, + }; + self.style.remember(); + self.stage = Stage::Create; + return Some(CmdResult::NeedPoint); + } + if matches!(keyword.as_str(), "F" | "FIRST") { + if let Stage::ModifyDraw(state) = &mut self.stage { + state.replacement.truncate(1); + return Some(CmdResult::NeedPoint); + } + } + match &self.stage { + Stage::Reverse(_) => { + let reverse = match keyword.as_str() { + "Y" | "YES" => true, + "N" | "NO" => false, + _ => return None, + }; + let stage = std::mem::replace(&mut self.stage, Stage::Create); + return Some(match stage { + Stage::Reverse(pending) => Self::finish_pending(pending, reverse), + _ => unreachable!(), + }); + } + Stage::Object | Stage::ModifySelect if keyword == "B" || keyword == "BACK" => { + self.stage = Stage::Create; + return Some(CmdResult::NeedPoint); + } + Stage::ModifyDraw(_) if keyword == "U" || keyword == "UNDO" => { + return self.on_undo_step(); + } + Stage::Create => match keyword.as_str() { + "A" | "ARC" | "ARCLENGTH" => self.stage = Stage::ArcLength, + "O" | "OBJECT" => { + self.stage = Stage::Object; + self.points.clear(); + } + "R" | "RECTANGULAR" | "RECTANGLE" => { + self.set_creation(CreationMode::Rectangular) + } + "P" | "POLYGONAL" | "POLYGON" => { + self.set_creation(CreationMode::Polygonal) + } + "F" | "FREEHAND" => self.set_creation(CreationMode::Freehand), + "S" | "STYLE" => self.stage = Stage::Style, + "M" | "MODIFY" => { + self.stage = Stage::ModifySelect; + self.points.clear(); + } + "U" | "UNDO" => return self.on_undo_step(), + _ => return None, + }, + _ => return None, + } + Some(CmdResult::NeedPoint) + } + + fn needs_entity_pick(&self) -> bool { + matches!(self.stage, Stage::Object | Stage::ModifySelect) + } + + fn entity_pick_highlights_hover(&self) -> bool { + self.needs_entity_pick() + } + + fn on_entity_pick(&mut self, handle: Handle, point: DVec3) -> CmdResult { + match self.stage { + Stage::Object => { + let Some(entity) = self.sources.get(&handle) else { + self.message = Some("Select a supported closed curve."); + return CmdResult::NeedPoint; + }; + let Some(points) = self.object_points(entity) else { + self.message = Some("Select a closed circle, ellipse, polyline, or spline."); + return CmdResult::NeedPoint; + }; + self.prepare_cloud(&points, Some(handle)) + } + Stage::ModifySelect => self.start_modify(handle, point), + _ => CmdResult::Cancel, + } + } + + fn on_hover_entity(&mut self, handle: Handle, _point: DVec3) -> Vec { + if !matches!(self.stage, Stage::Object) { + return Vec::new(); + } + let Some(points) = self + .sources + .get(&handle) + .and_then(|entity| self.object_points(entity)) + else { + return Vec::new(); + }; + self.preview_world_points(&points, "revcloud_object_preview") + .into_iter() + .collect() + } + + fn on_undo_step(&mut self) -> Option { + match &mut self.stage { + Stage::Create if !self.points.is_empty() => { + self.points.pop(); + if self.points.is_empty() { + self.tracing = false; + } + Some(CmdResult::NeedPoint) + } + Stage::ModifyDraw(state) if state.replacement.len() > 1 => { + state.replacement.pop(); + Some(CmdResult::NeedPoint) + } + _ => None, + } + } + + fn on_mouse_move(&mut self, point: DVec3) -> Option { + match &mut self.stage { + Stage::Create if self.creation == CreationMode::Freehand && self.tracing => { + let spacing = (self.arc_length * 0.25).max(1.0e-6); + if self.points.last().is_none_or(|last| last.distance(point) >= spacing) { + self.points.push(point); + } + let points = self.points.clone(); + self.preview_world_points(&points, "revcloud_freehand_preview") + } + Stage::Create if self.creation == CreationMode::Rectangular => { + let first = *self.points.first()?; + let points = self.rectangle_points(first, point)?; + self.preview_world_points(&points, "revcloud_rectangular_preview") + } + Stage::Create if self.creation == CreationMode::Polygonal && !self.points.is_empty() => { + let mut points = self.points.clone(); + points.push(point); + self.preview_world_points(&points, "revcloud_polygonal_preview") + } + Stage::Reverse(pending) => Self::preview(&pending.entity, "revcloud_reverse_preview"), + Stage::ModifyDraw(state) => { + let mut points = state.replacement.clone(); + points.push(point); + self.preview_world_points(&points, "revcloud_modify_preview") + } + _ => None, } - let mut preview_pts: Vec<[f32; 3]> = self - .points - .iter() - .map(|p| [p.x as f32, p.y as f32, p.z as f32]) - .collect(); - preview_pts.push([pt.x, pt.y, pt.z]); - preview_pts.push([ - self.points[0].x as f32, - self.points[0].y as f32, - self.points[0].z as f32, - ]); - Some(WireModel { - taper_widths: Vec::new(), - world_width: 0.0, - depth_override: None, - fill_is_3d: false, - fill_is_2d_solid: false, - render_instance: None, - pick_tris: Vec::new(), - pick_tris_low: Vec::new(), - dash_from_start: false, - dash_align_end: None, - text_verts: Vec::new(), - name: "revcloud_preview".into(), - points: preview_pts, - points_low: Vec::new(), - color: WireModel::CYAN, - selected: false, - pattern_length: 0.0, - pattern: [0.0; 8], - line_weight_px: 1.0, - snap_pts: vec![], - tangent_geoms: vec![], - aci: 0, - key_vertices: vec![], - aabb: WireModel::UNBOUNDED_AABB, - plinegen: true, - fill_tris: vec![], - fill_tris_low: Vec::new(), - }) } } -fn make_revcloud(pts: &[DVec3], arc_len: f64) -> EntityType { - let n = pts.len(); - let mut vertices: Vec = Vec::new(); - - // For each edge, subdivide into arc bumps (bulge ≈ 0.5) - let bump_bulge = 0.5f64; // tan(included_angle/4) ≈ 0.5 → ~53° arc - - for i in 0..n { - let p0 = pts[i]; - let p1 = pts[(i + 1) % n]; - let seg_len = ((p1.x - p0.x).powi(2) + (p1.y - p0.y).powi(2)).sqrt(); - if seg_len < 1e-6 { +fn make_revcloud( + points: &[DVec3], + arc_length: f64, + style: CloudStyle, + reverse: bool, +) -> Option { + if points.len() < 3 || !arc_length.is_finite() || arc_length <= 0.0 { + return None; + } + let area = signed_area(points); + if area.abs() <= f64::EPSILON { + return None; + } + let outward = if area > 0.0 { -1.0 } else { 1.0 }; + let bulge = BUMP_BULGE * outward * if reverse { -1.0 } else { 1.0 }; + let mut vertices = Vec::new(); + for index in 0..points.len() { + let start = points[index]; + let end = points[(index + 1) % points.len()]; + let segment_length = start.distance(end); + if segment_length <= arc_length * 1.0e-6 { continue; } - - // How many full arcs fit? - let num_arcs = ((seg_len / arc_len).round() as usize).max(1); - let step_x = (p1.x - p0.x) / num_arcs as f64; - let step_y = (p1.y - p0.y) / num_arcs as f64; - - for j in 0..num_arcs { - let x = p0.x + step_x * j as f64; - let y = p0.y + step_y * j as f64; // DXF Y - let mut v = LwVertex::new(Vector2::new(x, y)); - v.bulge = bump_bulge; - vertices.push(v); + let arc_count = (segment_length / arc_length).round().max(1.0) as usize; + let chord = segment_length / arc_count as f64; + for arc_index in 0..arc_count { + let t = arc_index as f64 / arc_count as f64; + let point = start.lerp(end, t); + let mut vertex = LwVertex::new(Vector2::new(point.x, point.y)); + vertex.bulge = bulge; + if style == CloudStyle::Calligraphy { + vertex.start_width = chord * 0.04; + vertex.end_width = chord * 0.16; + } + vertices.push(vertex); } } - - let mut p = LwPolyline::new(); - p.is_closed = true; - p.elevation = pts.first().map_or(0.0, |point| point.z); - p.vertices = vertices; - EntityType::LwPolyline(p) + if vertices.len() < 3 { + return None; + } + let mut polyline = LwPolyline::new(); + polyline.is_closed = true; + polyline.elevation = points[0].z; + polyline.vertices = vertices; + Some(polyline) } +fn reverse_cloud_entity(mut entity: EntityType) -> EntityType { + if let EntityType::LwPolyline(polyline) = &mut entity { + for vertex in &mut polyline.vertices { + vertex.bulge = -vertex.bulge; + } + } + entity +} -// ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["REVCLOUD"] }); // RevCloudCommand +fn style_from_entity(entity: &EntityType) -> Option { + let EntityType::LwPolyline(polyline) = entity else { + return None; + }; + Some(if polyline + .vertices + .iter() + .any(|vertex| vertex.start_width > 0.0 || vertex.end_width > 0.0) + { + CloudStyle::Calligraphy + } else { + CloudStyle::Normal + }) +} + +fn signed_area(points: &[DVec3]) -> f64 { + let origin = points[0]; + points[1..] + .windows(2) + .map(|pair| { + let left = pair[0] - origin; + let right = pair[1] - origin; + left.x * right.y - left.y * right.x + }) + .sum::() + * 0.5 +} + +fn distance_to_segment(point: DVec3, start: DVec3, end: DVec3) -> f64 { + let direction = end - start; + let squared = direction.length_squared(); + if squared <= f64::EPSILON { + return point.distance(start); + } + let t = ((point - start).dot(direction) / squared).clamp(0.0, 1.0); + point.distance(start + direction * t) +} + +fn remove_adjacent_duplicates(points: &mut Vec, tolerance: f64) { + let mut index = 1; + while index < points.len() { + if points[index - 1].distance(points[index]) <= tolerance { + points.remove(index); + } else { + index += 1; + } + } + if points.len() >= 2 && points[0].distance(*points.last().unwrap()) <= tolerance { + points.pop(); + } +} + +inventory::submit!(crate::command::CommandRegistration { names: &["REVCLOUD"] });