From e3bfbf15904136ddb14e7ddcfa67af3aeb48106f Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:31:22 +0300 Subject: [PATCH] Complete freehand sketch workflow --- Cargo.lock | 4 +- Cargo.toml | 4 +- crates/ocs_plugin_api/Cargo.toml | 4 +- crates/ocs_web_worker/Cargo.toml | 2 +- src/app/command_driver.rs | 18 + src/app/commands/draw.rs | 7 +- src/app/commands/layers.rs | 1 + src/app/commands/mod.rs | 2 + src/app/commands/styleprops.rs | 39 +- src/command.rs | 7 + src/modules/draw/draw/sketch.rs | 640 ++++++++++++++++++++++++++----- 11 files changed, 620 insertions(+), 108 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 86a9e334..ab064d08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -72,7 +72,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.4.1" -source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=0975677#0975677029f2b472759db00e9692421a5831ad00" +source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=66a54bd#66a54bd4a231ce88b23560e8aa0feb8c85164019" dependencies = [ "ahash 0.8.12", "anyhow", @@ -878,7 +878,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=3d5de36#3d5de36cb5b391f17da924c5f80e34a0cdc1d948" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=5d5fffc#5d5fffcec482cdc0ac33acc080a6a30888b4dd29" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 3aaed7a5..f793eb22 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,8 +27,8 @@ glam = { version = "0.33", features = ["bytemuck"] } rfd = "0.17" clap = { version = "4", features = ["derive"] } env_logger = "0.11" -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "0975677", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "3d5de36", features = ["acis", "offset"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "66a54bd", features = ["serde"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "5d5fffc", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } diff --git a/crates/ocs_plugin_api/Cargo.toml b/crates/ocs_plugin_api/Cargo.toml index 5a540bdc..1e2d611f 100644 --- a/crates/ocs_plugin_api/Cargo.toml +++ b/crates/ocs_plugin_api/Cargo.toml @@ -14,7 +14,7 @@ serde = { version = "1", features = ["derive"] } # Pulled in only by the `host` feature, which adds the `acadrust`-typed # `HostApi` runtime surface. The default crate stays dependency-free so engine # crates and external tooling can depend on the manifest/ribbon contract cheaply. -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "0975677", optional = true, features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "66a54bd", optional = true, features = ["serde"] } # Runtime IPC and serialization (host feature only). interprocess = { version = "2", optional = true } @@ -37,7 +37,7 @@ serde_json = "1" serde = { version = "1", features = ["derive"] } cargo-lock = "11" # acadrust is scanned at build time to generate the embedded type registry. -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "0975677", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "66a54bd", features = ["serde"] } [dev-dependencies] serde_json = "1" diff --git a/crates/ocs_web_worker/Cargo.toml b/crates/ocs_web_worker/Cargo.toml index a38821d6..b8bb0be6 100644 --- a/crates/ocs_web_worker/Cargo.toml +++ b/crates/ocs_web_worker/Cargo.toml @@ -8,7 +8,7 @@ publish = false crate-type = ["cdylib"] [dependencies] -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "0975677", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "66a54bd", features = ["serde"] } bincode = "1.3" serde = { version = "1", features = ["derive"] } console_error_panic_hook = "0.1" diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index a3411a7d..3301b8d3 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -642,6 +642,23 @@ impl OpenCADStudio { /// Pure-selection commands (SELECTALL, QSELECT, …) run without an active /// command, so `was_active` is false and their selection is preserved. pub(super) fn apply_cmd_result(&mut self, result: CmdResult) -> Task { + let settings = self.tabs[self.active_tab] + .active_cmd + .as_ref() + .and_then(|command| command.sketch_settings()); + if let Some((sketch_type, increment, tolerance)) = settings { + let tab = &mut self.tabs[self.active_tab]; + let header = &mut tab.scene.document.header; + let changed = header.sketch_type != sketch_type + || (header.sketch_increment - increment).abs() > f64::EPSILON + || (header.sketch_tolerance - tolerance).abs() > f64::EPSILON; + if changed { + header.sketch_type = sketch_type; + header.sketch_increment = increment; + header.sketch_tolerance = tolerance; + tab.dirty = true; + } + } let was_active = self.tabs[self.active_tab].active_cmd.is_some(); let preserve_selection = matches!(result, CmdResult::Relaunch(..) | CmdResult::Dispatch(..)); @@ -785,6 +802,7 @@ impl OpenCADStudio { self.commit_entity(entity); } self.tabs[i].dirty = true; + self.tabs[i].scene.clear_preview_wire(); let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); if let Some(p) = prompt { self.command_line.push_info(&p); diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index c2607381..d19862ef 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -475,7 +475,12 @@ impl OpenCADStudio { "SKETCH" => { use crate::modules::draw::draw::sketch::SketchCommand; - let new_cmd = SketchCommand::new(); + let header = &self.tabs[i].scene.document.header; + let new_cmd = SketchCommand::new( + header.sketch_type, + header.sketch_increment, + header.sketch_tolerance, + ); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } diff --git a/src/app/commands/layers.rs b/src/app/commands/layers.rs index a0220654..23afb5cb 100644 --- a/src/app/commands/layers.rs +++ b/src/app/commands/layers.rs @@ -914,6 +914,7 @@ impl OpenCADStudio { &mut header.text_height, &mut header.trace_width, &mut header.sketch_increment, + &mut header.sketch_tolerance, &mut header.thickness, &mut header.polyline_width, &mut header.fillet_radius, diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 174653f8..688a7ee0 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -518,6 +518,8 @@ inventory::submit!(crate::command::CommandRegistration { "HALOGAP", "TRACEWID", "SKETCHINC", + "SKPOLY", + "SKTOLERANCE", // Reset selected entities' overrides to follow their layer. "SETBYLAYER", // Remove duplicate objects; set drawing base point; audit integrity; diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 5eba1ee6..c085c6cd 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -923,6 +923,8 @@ impl OpenCADStudio { | "HALOGAP" | "TRACEWID" | "SKETCHINC" + | "SKPOLY" + | "SKTOLERANCE" ) => { return self.dispatch_styleprops(&format!("SETVAR {cmd}"), i); @@ -945,7 +947,7 @@ impl OpenCADStudio { let value = it.next().map(|s| s.trim().to_string()); if name.is_empty() || name == "?" { self.command_line.push_info( - "SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG ATTREQ ATTDIA DIMASSOC ANGBASE ANGDIR | CLAYER CELTYPE TEXTSTYLE (read-only)", + "SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG ATTREQ ATTDIA DIMASSOC ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE | CLAYER CELTYPE TEXTSTYLE (read-only)", ); } else { // Parse a boolean given as 0/1 or ON/OFF. @@ -1628,15 +1630,38 @@ impl OpenCADStudio { None => Ok((format!("TRACEWID = {}", h.trace_width), false)), }, "SKETCHINC" => match &value { - Some(v) => v - .parse::() - .map(|x| { + Some(v) => match v.parse::() { + Ok(x) if x.is_finite() && x > 0.0 => { h.sketch_increment = x; - (format!("SKETCHINC = {x}"), true) - }) - .map_err(|_| "SETVAR: numeric value required.".into()), + Ok((format!("SKETCHINC = {x}"), true)) + } + _ => Err("SETVAR: positive numeric value required.".into()), + }, None => Ok((format!("SKETCHINC = {}", h.sketch_increment), false)), }, + "SKPOLY" => match &value { + Some(v) => match v.parse::() { + Ok(x @ 0..=2) => { + h.sketch_type = x; + Ok((format!("SKPOLY = {x}"), true)) + } + _ => Err("SETVAR: integer value from 0 to 2 required.".into()), + }, + None => Ok((format!("SKPOLY = {}", h.sketch_type), false)), + }, + "SKTOLERANCE" => match &value { + Some(v) => match v.parse::() { + Ok(x) if x.is_finite() && x >= 0.0 => { + h.sketch_tolerance = x; + Ok((format!("SKTOLERANCE = {x}"), true)) + } + _ => Err("SETVAR: non-negative numeric value required.".into()), + }, + None => Ok(( + format!("SKTOLERANCE = {}", h.sketch_tolerance), + false, + )), + }, "CLAYER" => match &value { Some(_) => Err( "SETVAR: CLAYER is read-only here — use the CLAYER command." diff --git a/src/command.rs b/src/command.rs index 2faf989e..41bcf867 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1818,6 +1818,13 @@ pub trait CadCommand: Send { false } + /// Drawing-persisted settings owned by the freehand sketch command. + /// The host mirrors them into the document header after every consumed + /// input so Type/Increment/Tolerance changes survive the command. + fn sketch_settings(&self) -> Option<(i16, f64, f64)> { + None + } + /// Returns `true` when the active text prompt expects free-form prose /// that can legitimately contain whitespace (the body of a TEXT / /// MTEXT / DDEDIT entity, an attribute default value, etc.). For diff --git a/src/modules/draw/draw/sketch.rs b/src/modules/draw/draw/sketch.rs index cc56eb51..52d10dfd 100644 --- a/src/modules/draw/draw/sketch.rs +++ b/src/modules/draw/draw/sketch.rs @@ -1,33 +1,24 @@ // Freehand sketch tool — interactive command. // -// Command: SKETCH — freehand sketching. A click toggles the pen down/up. While -// the pen is DOWN, moving the cursor records its position into the current -// stroke whenever it has travelled more than a small fixed threshold, and a -// cyan preview wire of the current stroke is shown. A click while the pen is -// down lifts it, ending the current stroke; the next click starts a fresh one. -// Enter commits every recorded stroke (each stroke of two or more points -// becomes one lightweight polyline of straight segments); Esc cancels. -// -// Geometry is built like the wide-line / ring tools: each stroke is one -// LwPolyline whose vertices carry the sampled XY positions and whose elevation -// is the first sample's Z. +// A sketch is recorded as equally spaced samples in the active working plane. +// The persisted SKPOLY setting chooses line segments, a lightweight polyline, +// or a fit-point spline. Multiple temporary strokes may be recorded while the +// command remains active; Exit records the remaining strokes and finishes, +// while Quit discards only the unrecorded strokes. -use acadrust::entities::{LwPolyline, LwVertex}; +use acadrust::entities::{Line, LwPolyline, LwVertex, Spline}; use acadrust::types::{Vector2, Vector3}; use acadrust::EntityType; -use crate::t; - -use crate::command::{CadCommand, CmdResult, WorkingPlane}; -use crate::modules::{IconKind, ModuleEvent, ToolDef}; -use crate::scene::model::wire_model::WireModel; use glam::DVec3; -// Minimum cursor travel (drawing units) before a new sample is recorded. -const SAMPLE_EPSILON: f64 = 0.5; +use crate::command::{CadCommand, CmdOption, CmdResult, WorkingPlane}; +use crate::modules::{IconKind, ModuleEvent, ToolDef}; +use crate::scene::model::wire_model::WireModel; +use crate::t; -// ── Ribbon definition ───────────────────────────────────────────────────── +const MIN_INCREMENT: f64 = 1.0e-9; -#[allow(dead_code)] // ribbon definition ready for wiring; command works via the command line +#[allow(dead_code)] pub fn tool() -> ToolDef { ToolDef { id: "SKETCH", @@ -37,55 +28,299 @@ pub fn tool() -> ToolDef { } } -// ── Command implementation ──────────────────────────────────────────────── +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SketchType { + Line, + Polyline, + Spline, +} + +impl SketchType { + fn from_header(value: i16) -> Self { + match value { + 0 => Self::Line, + 2 => Self::Spline, + _ => Self::Polyline, + } + } + + fn header_value(self) -> i16 { + match self { + Self::Line => 0, + Self::Polyline => 1, + Self::Spline => 2, + } + } + + fn label(self) -> &'static str { + match self { + Self::Line => "Line", + Self::Polyline => "Polyline", + Self::Spline => "Spline", + } + } + + fn parse(value: &str) -> Option { + match value.trim().to_ascii_uppercase().as_str() { + "L" | "LINE" => Some(Self::Line), + "P" | "PLINE" | "POLYLINE" => Some(Self::Polyline), + "S" | "SPLINE" => Some(Self::Spline), + _ => None, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum InputStage { + Drawing, + Type, + Increment, + Tolerance, +} pub struct SketchCommand { - /// All completed strokes plus, while the pen is down, the one being drawn - /// as the last element. Each stroke is its own list of sampled points. strokes: Vec>, - /// True while the pen is down and samples accumulate into the current stroke. pen_down: bool, + erasing: bool, + erase_engaged: bool, + stage: InputStage, + sketch_type: SketchType, + increment: f64, + tolerance: f64, + last_cursor: Option, plane: WorkingPlane, } impl SketchCommand { - pub fn new() -> Self { + pub fn new(sketch_type: i16, increment: f64, tolerance: f64) -> Self { Self { strokes: Vec::new(), pen_down: false, + erasing: false, + erase_engaged: false, + stage: InputStage::Drawing, + sketch_type: SketchType::from_header(sketch_type), + increment: valid_increment(increment), + tolerance: valid_tolerance(tolerance), + last_cursor: None, plane: WorkingPlane::default(), } } - /// Build one lightweight polyline from a stroke's sampled points, or `None` - /// when the stroke has too few points to form a segment. - fn build_stroke(&self, points: &[DVec3]) -> Option { - if points.len() < 2 { - return None; + fn begin_stroke(&mut self, point: DVec3) { + self.strokes.push(vec![point]); + self.pen_down = true; + self.erasing = false; + self.erase_engaged = false; + } + + /// Add samples at exact `increment` intervals along the latest pointer + /// movement. A sparse stream of mouse events therefore produces the same + /// chord density as a dense stream instead of one long segment per event. + fn sample_to(&mut self, point: DVec3) { + let Some(stroke) = self.strokes.last_mut() else { + return; + }; + let Some(mut cursor) = stroke.last().copied() else { + stroke.push(point); + return; + }; + loop { + let delta = point - cursor; + let distance = delta.length(); + if !distance.is_finite() || distance + 1.0e-12 < self.increment { + break; + } + cursor += delta / distance * self.increment; + stroke.push(cursor); } - let points: Vec = points + } + + fn finish_stroke(&mut self, point: DVec3) { + self.sample_to(point); + if let Some(stroke) = self.strokes.last_mut() { + if stroke + .last() + .is_some_and(|last| last.distance(point) > 1.0e-9) + { + stroke.push(point); + } + } + self.pen_down = false; + } + + fn connect(&mut self) { + let endpoint = self + .strokes + .iter() + .rev() + .find_map(|stroke| stroke.last().copied()); + if let Some(endpoint) = endpoint { + self.begin_stroke(endpoint); + } + } + + /// Erasing is armed when the pointer reaches the latest temporary sample. + /// Moving back over the recorded path then removes samples one by one. + fn erase_at(&mut self, point: DVec3) { + let threshold = (self.increment * 0.45).max(1.0e-6); + loop { + let Some(stroke) = self.strokes.last_mut() else { + self.erase_engaged = false; + break; + }; + let Some(last) = stroke.last().copied() else { + self.strokes.pop(); + continue; + }; + if !self.erase_engaged { + if last.distance(point) <= threshold { + self.erase_engaged = true; + } else { + break; + } + } + if last.distance(point) <= threshold { + stroke.pop(); + if stroke.is_empty() { + self.strokes.pop(); + self.erase_engaged = false; + } + } else { + break; + } + } + } + + fn build_stroke(&self, points: &[DVec3]) -> Vec { + if points.len() < 2 { + return Vec::new(); + } + let local: Vec = points .iter() .map(|point| self.plane.to_local(*point)) .collect(); - let elevation = points[0].z; - let mut pl = LwPolyline::new(); - pl.is_closed = false; - pl.elevation = elevation; - pl.vertices = points - .iter() - .map(|p| LwVertex::new(Vector2::new(p.x, p.y))) - .collect(); - pl.normal = Vector3::UNIT_Z; - Some(self.plane.place_entity(EntityType::LwPolyline(pl))) + match self.sketch_type { + SketchType::Line => local + .windows(2) + .map(|pair| { + self.plane.place_entity(EntityType::Line(Line::from_points( + to_vector3(pair[0]), + to_vector3(pair[1]), + ))) + }) + .collect(), + SketchType::Polyline => { + let mut polyline = LwPolyline::new(); + polyline.elevation = local[0].z; + polyline.normal = Vector3::UNIT_Z; + polyline.vertices = local + .iter() + .map(|point| LwVertex::new(Vector2::new(point.x, point.y))) + .collect(); + vec![self + .plane + .place_entity(EntityType::LwPolyline(polyline))] + } + SketchType::Spline => { + let fit = simplify_points(&local, self.tolerance); + if fit.len() < 2 { + return Vec::new(); + } + let mut spline = Spline { + degree: (fit.len().saturating_sub(1).min(3)) as i32, + fit_points: fit.iter().copied().map(to_vector3).collect(), + fit_tolerance: self.tolerance, + normal: Vector3::UNIT_Z, + ..Default::default() + }; + spline.flags.planar = true; + vec![self.plane.place_entity(EntityType::Spline(spline))] + } + } } - /// All strokes that have enough points, as committable entities. fn build_all(&self) -> Vec { self.strokes .iter() - .filter_map(|stroke| self.build_stroke(stroke)) + .flat_map(|stroke| self.build_stroke(stroke)) .collect() } + + fn take_entities(&mut self) -> Vec { + let entities = self.build_all(); + self.strokes.clear(); + self.pen_down = false; + self.erasing = false; + self.erase_engaged = false; + entities + } + + fn preview(&self, cursor: Option) -> Option { + let mut combined = Vec::<[f64; 3]>::new(); + for (index, stroke) in self.strokes.iter().enumerate() { + let mut points = stroke.clone(); + if self.pen_down && index + 1 == self.strokes.len() { + if let Some(cursor) = cursor { + if points + .last() + .is_some_and(|last| last.distance(cursor) > 1.0e-9) + { + points.push(cursor); + } + } + } + if points.len() < 2 { + continue; + } + let display = if self.sketch_type == SketchType::Spline { + spline_preview_points(&simplify_points(&points, self.tolerance)) + } else { + points + }; + if display.len() < 2 { + continue; + } + if !combined.is_empty() { + combined.push([f64::NAN; 3]); + } + combined.extend(display.into_iter().map(|point| [point.x, point.y, point.z])); + } + (combined.len() >= 2).then(|| { + WireModel::solid_f64("sketch_preview".to_string(), combined, WireModel::CYAN, false) + }) + } + + fn toggle_pen(&mut self) { + if self.pen_down { + if let Some(cursor) = self.last_cursor { + self.finish_stroke(cursor); + } else { + self.pen_down = false; + } + } else if let Some(cursor) = self.last_cursor { + self.begin_stroke(cursor); + } + } + + fn drawing_options(&self) -> Vec { + let mut options = vec![ + CmdOption::new("Pen", "PEN"), + CmdOption::new("Type", "TYPE"), + CmdOption::new("Increment", "INCREMENT"), + ]; + if self.sketch_type == SketchType::Spline { + options.push(CmdOption::new("Tolerance", "TOLERANCE")); + } + options.extend([ + CmdOption::new("Record", "RECORD"), + CmdOption::new(if self.erasing { "Stop erasing" } else { "Erase" }, "ERASE"), + CmdOption::new("Connect", "CONNECT"), + CmdOption::new("Exit", "EXIT"), + CmdOption::new("Quit", "QUIT"), + ]); + options + } } impl CadCommand for SketchCommand { @@ -98,72 +333,291 @@ impl CadCommand for SketchCommand { } fn prompt(&self) -> String { - if self.pen_down { - t!("SKETCH Pen down — move to sketch, click to lift, Enter to record:").into_owned() - } else { - t!("SKETCH Pen up — click to lower the pen, Enter to record:").into_owned() + match self.stage { + InputStage::Type => t!("SKETCH Object type [Line/Polyline/Spline]:").into_owned(), + InputStage::Increment => t!( + "SKETCH Record increment <%{value}>:", + value = self.increment + ) + .into_owned(), + InputStage::Tolerance => t!( + "SKETCH Spline fit tolerance <%{value}>:", + value = self.tolerance + ) + .into_owned(), + InputStage::Drawing if self.erasing => { + t!("SKETCH Erase — move backward over the temporary sketch:").into_owned() + } + InputStage::Drawing if self.pen_down => t!( + "SKETCH (%{type}, increment %{increment}) Pen down — move to sketch:", + type = self.sketch_type.label(), + increment = self.increment + ) + .into_owned(), + InputStage::Drawing => t!( + "SKETCH (%{type}, increment %{increment}) Pen up — click to lower the pen:", + type = self.sketch_type.label(), + increment = self.increment + ) + .into_owned(), } } - fn on_point(&mut self, pt: DVec3) -> CmdResult { - if self.pen_down { - // Lift the pen: the current stroke is finished. A new click later - // begins a fresh stroke. - self.pen_down = false; + fn options(&self) -> Vec { + match self.stage { + InputStage::Drawing => self.drawing_options(), + InputStage::Type => vec![ + CmdOption::new("Line", "LINE"), + CmdOption::new("Polyline", "POLYLINE"), + CmdOption::new("Spline", "SPLINE"), + ], + InputStage::Increment | InputStage::Tolerance => Vec::new(), + } + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + self.last_cursor = Some(point); + if self.stage != InputStage::Drawing { + return CmdResult::NeedPoint; + } + if self.erasing { + self.erasing = false; + self.erase_engaged = false; + } else if self.pen_down { + self.finish_stroke(point); } else { - // Lower the pen: start a new stroke seeded with this point. - self.strokes.push(vec![pt]); - self.pen_down = true; + self.begin_stroke(point); } CmdResult::NeedPoint } fn on_enter(&mut self) -> CmdResult { - let entities = self.build_all(); - match entities.len() { - 0 => CmdResult::Cancel, - 1 => CmdResult::CommitAndExit(entities.into_iter().next().unwrap()), - _ => CmdResult::ReplaceMany(vec![], entities), + let entities = self.take_entities(); + if entities.is_empty() { + CmdResult::Cancel + } else { + CmdResult::CommitEntitiesAndExit(entities) } } fn on_escape(&mut self) -> CmdResult { + self.strokes.clear(); CmdResult::Cancel } - fn on_mouse_move(&mut self, pt: DVec3) -> Option { - if !self.pen_down { - return None; - } - // The current stroke is the last one pushed while the pen is down. - let stroke = self.strokes.last_mut()?; - // Record the sample only once it has moved past the threshold from the - // last recorded point, so the polyline isn't flooded with near-duplicate - // vertices. - let record = match stroke.last() { - Some(last) => last.distance(pt) > SAMPLE_EPSILON, - None => true, - }; - if record { - stroke.push(pt); - } - // Preview the current stroke, including the (possibly unrecorded) cursor - // position so the line tracks the pointer smoothly. - let mut pts: Vec<[f64; 3]> = stroke.iter().map(|p| [p.x, p.y, p.z]).collect(); - if !record { - pts.push([pt.x, pt.y, pt.z]); - } - if pts.len() < 2 { - return None; - } - Some(WireModel::solid_f64( - "rubber_band".to_string(), - pts, - WireModel::CYAN, - false, + fn wants_text_input(&self) -> bool { + true + } + + fn point_step_accepts_keywords(&self) -> bool { + self.stage == InputStage::Drawing + } + + fn sketch_settings(&self) -> Option<(i16, f64, f64)> { + Some(( + self.sketch_type.header_value(), + self.increment, + self.tolerance, )) } + + fn on_text_input(&mut self, text: &str) -> Option { + let input = text.trim(); + if input.is_empty() { + return None; + } + match self.stage { + InputStage::Type => { + if let Some(sketch_type) = SketchType::parse(input) { + self.sketch_type = sketch_type; + self.stage = InputStage::Drawing; + } + Some(CmdResult::NeedPoint) + } + InputStage::Increment => { + if let Ok(value) = input.parse::() { + if value.is_finite() && value > 0.0 { + self.increment = value.max(MIN_INCREMENT); + self.stage = InputStage::Drawing; + } + } + Some(CmdResult::NeedPoint) + } + InputStage::Tolerance => { + if let Ok(value) = input.parse::() { + if value.is_finite() && value >= 0.0 { + self.tolerance = value; + self.stage = InputStage::Drawing; + } + } + Some(CmdResult::NeedPoint) + } + InputStage::Drawing => { + let keyword = input.to_ascii_uppercase(); + let result = match keyword.as_str() { + "P" | "PEN" => { + self.toggle_pen(); + CmdResult::NeedPoint + } + "T" | "TYPE" => { + self.pen_down = false; + self.erasing = false; + self.stage = InputStage::Type; + CmdResult::NeedPoint + } + "I" | "INCREMENT" => { + self.pen_down = false; + self.erasing = false; + self.stage = InputStage::Increment; + CmdResult::NeedPoint + } + "TO" | "TOLERANCE" if self.sketch_type == SketchType::Spline => { + self.pen_down = false; + self.erasing = false; + self.stage = InputStage::Tolerance; + CmdResult::NeedPoint + } + "R" | "RECORD" => { + let entities = self.take_entities(); + if entities.is_empty() { + CmdResult::NeedPoint + } else { + CmdResult::CommitEntities(entities) + } + } + "E" | "ERASE" => { + self.pen_down = false; + self.erasing = !self.erasing; + self.erase_engaged = false; + CmdResult::NeedPoint + } + "C" | "CONNECT" => { + self.connect(); + CmdResult::NeedPoint + } + "X" | "EXIT" => { + let entities = self.take_entities(); + if entities.is_empty() { + CmdResult::Cancel + } else { + CmdResult::CommitEntitiesAndExit(entities) + } + } + "Q" | "QUIT" => { + self.strokes.clear(); + CmdResult::Cancel + } + _ => { + if let Some(sketch_type) = SketchType::parse(&keyword) { + self.sketch_type = sketch_type; + } + CmdResult::NeedPoint + } + }; + Some(result) + } + } + } + + fn on_mouse_move(&mut self, point: DVec3) -> Option { + self.last_cursor = Some(point); + if self.stage == InputStage::Drawing { + if self.pen_down { + self.sample_to(point); + } else if self.erasing { + self.erase_at(point); + } + } + self.preview(Some(point)) + } } -// ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["SKETCH"] }); // SketchCommand +fn valid_increment(value: f64) -> f64 { + if value.is_finite() && value > 0.0 { + value.max(MIN_INCREMENT) + } else { + 0.1 + } +} + +fn valid_tolerance(value: f64) -> f64 { + if value.is_finite() && value >= 0.0 { + value + } else { + 0.0 + } +} + +fn to_vector3(point: DVec3) -> Vector3 { + Vector3::new(point.x, point.y, point.z) +} + +fn point_segment_distance_squared(point: DVec3, start: DVec3, end: DVec3) -> f64 { + let segment = end - start; + let length_squared = segment.length_squared(); + if length_squared <= f64::EPSILON { + return point.distance_squared(start); + } + let t = ((point - start).dot(segment) / length_squared).clamp(0.0, 1.0); + point.distance_squared(start + segment * t) +} + +/// Iterative Ramer-Douglas-Peucker reduction used by spline sketches. The +/// tolerance therefore changes both the stored fit-point count and the curve, +/// rather than being a display-only property. +fn simplify_points(points: &[DVec3], tolerance: f64) -> Vec { + if points.len() <= 2 || tolerance <= 0.0 { + return points.to_vec(); + } + let tolerance_squared = tolerance * tolerance; + let mut keep = vec![false; points.len()]; + keep[0] = true; + keep[points.len() - 1] = true; + let mut ranges = vec![(0usize, points.len() - 1)]; + while let Some((start, end)) = ranges.pop() { + if end <= start + 1 { + continue; + } + let mut farthest = None; + let mut farthest_distance = tolerance_squared; + for index in start + 1..end { + let distance = point_segment_distance_squared(points[index], points[start], points[end]); + if distance > farthest_distance { + farthest = Some(index); + farthest_distance = distance; + } + } + if let Some(index) = farthest { + keep[index] = true; + ranges.push((start, index)); + ranges.push((index, end)); + } + } + points + .iter() + .copied() + .zip(keep) + .filter_map(|(point, keep)| keep.then_some(point)) + .collect() +} + +fn spline_preview_points(points: &[DVec3]) -> Vec { + if points.len() < 2 { + return points.to_vec(); + } + let spline = Spline { + degree: (points.len().saturating_sub(1).min(3)) as i32, + fit_points: points.iter().copied().map(to_vector3).collect(), + ..Default::default() + }; + crate::entities::curve::spline_curve(&spline) + .map(|curve| { + crate::entities::curve::curve_points(&curve) + .into_iter() + .map(|point| DVec3::new(point[0], point[1], point[2])) + .collect() + }) + .unwrap_or_else(|| points.to_vec()) +} + +inventory::submit!(crate::command::CommandRegistration { names: &["SKETCH"] });