feat: complete freehand sketch workflow

Co-authored-by: ramox81 <[email protected]>
This commit is contained in:
Hakan Sevenandramox81 committed 2026-08-21 00:17:44 +03:00
commit 464971969c
32 files changed
+926 -113

No files matched your search

+521 -98
View File
@@ -1,33 +1,19 @@
// 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.
// Freehand sketch tool.
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 cadkernel::geom2d::NurbsCurve;
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,54 +23,336 @@ 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<Self> {
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<Vec<DVec3>>,
/// 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<DVec3>,
last_recorded_endpoint: Option<DVec3>,
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,
last_recorded_endpoint: 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<EntityType> {
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;
}
/// Record real pointer positions at the configured minimum spacing.
fn sample_to(&mut self, point: DVec3) {
let Some(stroke) = self.strokes.last_mut() else {
return;
};
let Some(cursor) = stroke.last().copied() else {
stroke.push(point);
return;
};
let distance = cursor.distance(point);
if distance.is_finite() && distance + 1.0e-12 >= self.increment {
stroke.push(point);
}
let points: Vec<DVec3> = 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 lift_pen(&mut self) {
if !self.pen_down {
return;
}
if let Some(cursor) = self.last_cursor {
self.finish_stroke(cursor);
} else {
self.pen_down = false;
}
}
fn connect(&mut self) {
self.lift_pen();
let endpoint = self
.strokes
.iter()
.rev()
.find_map(|stroke| stroke.last().copied())
.or(self.last_recorded_endpoint);
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 fit_curve(&self, points: &[DVec3]) -> Option<(NurbsCurve, f64)> {
let local: Vec<DVec3> = 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)))
let elevation = local.first()?.z;
let points: Vec<[f64; 2]> = local.iter().map(|point| [point.x, point.y]).collect();
NurbsCurve::fit_polyline(&points, self.fit_distance())
.map(|curve| (curve, elevation))
}
/// All strokes that have enough points, as committable entities.
fn build_all(&self) -> Vec<EntityType> {
self.strokes
fn fit_distance(&self) -> f64 {
self.tolerance * self.increment
}
fn build_stroke(&self, points: &[DVec3]) -> Vec<EntityType> {
if points.len() < 2 {
return Vec::new();
}
let local: Vec<DVec3> = points
.iter()
.filter_map(|stroke| self.build_stroke(stroke))
.collect()
.map(|point| self.plane.to_local(*point))
.collect();
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 Some((curve, elevation)) = self.fit_curve(points) else {
return Vec::new();
};
let mut spline = Spline {
degree: curve.degree() as i32,
knots: curve.knots().to_vec(),
control_points: curve
.control_points()
.iter()
.map(|point| Vector3::new(point[0], point[1], elevation))
.collect(),
fit_tolerance: self.fit_distance(),
normal: Vector3::UNIT_Z,
..Default::default()
};
spline.flags.planar = true;
vec![self.plane.place_entity(EntityType::Spline(spline))]
}
}
}
fn take_entities(&mut self) -> Vec<EntityType> {
self.lift_pen();
let mut entities = Vec::new();
let mut endpoint = None;
for stroke in &self.strokes {
let built = self.build_stroke(stroke);
if !built.is_empty() {
endpoint = stroke.last().copied();
entities.extend(built);
}
}
if !entities.is_empty() {
self.last_recorded_endpoint = endpoint;
}
self.strokes.clear();
self.pen_down = false;
self.erasing = false;
self.erase_engaged = false;
entities
}
fn preview(&self, cursor: Option<DVec3>) -> Option<WireModel> {
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 {
match self.fit_curve(&points) {
Some((curve, elevation)) => curve
.tessellate(16)
.into_iter()
.map(|point| {
self.plane
.to_world(DVec3::new(point[0], point[1], elevation))
})
.collect(),
None => points,
}
} 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 {
self.lift_pen();
} else if let Some(cursor) = self.last_cursor {
self.begin_stroke(cursor);
}
}
fn drawing_options(&self) -> Vec<CmdOption> {
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
}
}
@@ -98,72 +366,227 @@ 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 = t!(self.sketch_type.label()),
increment = self.increment
)
.into_owned(),
InputStage::Drawing => t!(
"SKETCH (%{type}, increment %{increment}) Pen up — click to lower the pen:",
type = t!(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<CmdOption> {
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),
if self.stage != InputStage::Drawing {
self.stage = InputStage::Drawing;
return CmdResult::NeedPoint;
}
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<WireModel> {
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<CmdResult> {
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::<f64>() {
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::<f64>() {
if value.is_finite() && (0.0..=1.0).contains(&value) {
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.lift_pen();
self.erasing = false;
self.stage = InputStage::Type;
CmdResult::NeedPoint
}
"I" | "INCREMENT" => {
self.lift_pen();
self.erasing = false;
self.stage = InputStage::Increment;
CmdResult::NeedPoint
}
"TO" | "TOLERANCE" if self.sketch_type == SketchType::Spline => {
self.lift_pen();
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.lift_pen();
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<WireModel> {
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() && (0.0..=1.0).contains(&value) {
value
} else {
0.5
}
}
fn to_vector3(point: DVec3) -> Vector3 {
Vector3::new(point.x, point.y, point.z)
}
inventory::submit!(crate::command::CommandRegistration { names: &["SKETCH"] });