Complete freehand sketch workflow
This commit is contained in:
parent
bb49f92e99
commit
e3bfbf1590
11 changed files with 614 additions and 102 deletions
4
Cargo.lock
generated
4
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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() && 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"] });
|
||||
|
|
|
|||
Loading…
Reference in a new issue