Complete freehand sketch workflow

This commit is contained in:
ramox81 2026-08-20 16:31:22 +03:00
commit e3bfbf1590
11 changed files with 614 additions and 102 deletions

4
Cargo.lock generated
View file

@ -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",

View file

@ -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"] }

View file

@ -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"

View file

@ -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"

View file

@ -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<Message> {
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);

View file

@ -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));
}

View file

@ -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,

View file

@ -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;

View file

@ -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::<f64>()
.map(|x| {
Some(v) => match v.parse::<f64>() {
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::<i16>() {
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::<f64>() {
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."

View file

@ -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

View file

@ -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<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>,
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<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;
}
let points: Vec<DVec3> = points
/// 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);
}
}
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<EntityType> {
if points.len() < 2 {
return Vec::new();
}
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
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(|p| LwVertex::new(Vector2::new(p.x, p.y)))
.map(|point| LwVertex::new(Vector2::new(point.x, point.y)))
.collect();
pl.normal = Vector3::UNIT_Z;
Some(self.plane.place_entity(EntityType::LwPolyline(pl)))
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<EntityType> {
self.strokes
.iter()
.filter_map(|stroke| self.build_stroke(stroke))
.flat_map(|stroke| self.build_stroke(stroke))
.collect()
}
fn take_entities(&mut self) -> Vec<EntityType> {
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<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 {
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<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
}
}
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<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),
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;
fn wants_text_input(&self) -> bool {
true
}
// 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);
fn point_step_accepts_keywords(&self) -> bool {
self.stage == InputStage::Drawing
}
// 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 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() && 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)
}
}
}
// ── Autocomplete registry ─────────────────────────────────
inventory::submit!(crate::command::CommandRegistration { names: &["SKETCH"] }); // SketchCommand
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))
}
}
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<DVec3> {
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<DVec3> {
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"] });