A tool window for the rectangular sketch pattern (#799)
sketch.pattern.rectangular gets a tool window with both quantities and spacings and an optional direction, instead of a click flow with a fixed count. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
e540832626
commit
3ea8562c5e
9 files changed
+353
-30
No files matched your search
@@ -14,8 +14,10 @@
|
||||
//! size guessed at 20 percent of the shorter leg and then opened a bare parameter editor, so
|
||||
//! the design changed before any number was asked for. `sketch.chamfer.distance_angle` sent a
|
||||
//! hard-coded 45 degrees and `sketch.chamfer.two_distance` forced its second distance equal to
|
||||
//! the first, leaving both values unreachable from the UI. The sketch patterns are the same
|
||||
//! gap and join this module later, each its own change.
|
||||
//! the first, leaving both values unreachable from the UI. The rectangular pattern joins them
|
||||
//! too, whose click tool sent a hard-coded count of 3 and could reach neither the command's
|
||||
//! second count nor either spacing, so a user who wanted five instances got three and deleted
|
||||
//! two. The circular pattern is the same gap and joins this module later.
|
||||
//!
|
||||
//! The Chamfer Type choice is a row of icon buttons rather than a drop-down: the modes differ
|
||||
//! in shape, and a picture of each shape names it better than a line of text.
|
||||
@@ -24,15 +26,19 @@ use egui::Pos2;
|
||||
use serde_json::{Map, Value, json};
|
||||
use solidcraft_engine::Sel;
|
||||
use solidcraft_engine::doc::expr::Kind as ValueKind;
|
||||
use solidcraft_engine::geom::{Vec2, Vec3};
|
||||
|
||||
use crate::SolveApp;
|
||||
use crate::dialogs::{CHAMFER_TYPES, Kind, check, field, icon_choice, row_label};
|
||||
use crate::dialogs::{CHAMFER_TYPES, Dialog, Kind, check, field, icon_choice, row_label};
|
||||
use crate::selection::{AXES, CURVES, POINTS, SelInput};
|
||||
use crate::viewport::{Hit, Proj};
|
||||
|
||||
/// The three chamfer types, drawn as the cut each one makes, in `CHAMFER_TYPES` order.
|
||||
const CHAMFER_TYPE_ICONS: [&str; 3] = ["sketch_chamfer_equal", "sketch_chamfer_two", "sketch_chamfer_angle"];
|
||||
|
||||
/// The engine's own cap on the copies one sketch pattern may make.
|
||||
const MAX_COPIES: f64 = 500.0;
|
||||
|
||||
/// A sketch modify tool window's values.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Mod {
|
||||
@@ -52,6 +58,9 @@ pub enum Mod {
|
||||
/// turn about the centre, and whether the originals stay behind. The command already
|
||||
/// carries the copy flag, so the Create Copy row needs no engine change.
|
||||
Move { x: String, y: String, angle: String, copy: bool },
|
||||
/// `sketch.pattern.rectangular`: how many instances along the picked direction and how far
|
||||
/// apart, then the same along the perpendicular direction the command derives from it.
|
||||
PatternRect { count: String, spacing: String, count2: String, spacing2: String },
|
||||
/// `sketch.fillet` and the three `sketch.chamfer.*` commands. `chamfer` chooses between a
|
||||
/// fillet and a chamfer and never changes while the window is open; `ctype` chooses which
|
||||
/// of the three chamfer commands runs (0 equal distance, 1 two distances, 2 distance and
|
||||
@@ -68,6 +77,7 @@ impl Mod {
|
||||
Mod::Offset { .. } => "OFFSET",
|
||||
Mod::Mirror => "MIRROR",
|
||||
Mod::Move { .. } => "MOVE/COPY",
|
||||
Mod::PatternRect { .. } => "RECTANGULAR PATTERN",
|
||||
Mod::Corner { chamfer: false, .. } => "FILLET",
|
||||
Mod::Corner { .. } => "CHAMFER",
|
||||
}
|
||||
@@ -80,6 +90,7 @@ impl Mod {
|
||||
Mod::Offset { distance, .. } => Some(("Distance", ValueKind::Length, distance)),
|
||||
Mod::Move { x, .. } => Some(("X Distance", ValueKind::Length, x)),
|
||||
Mod::Mirror => None,
|
||||
Mod::PatternRect { spacing, .. } => Some(("Spacing", ValueKind::Length, spacing)),
|
||||
Mod::Corner { chamfer: false, size, .. } => Some(("Radius", ValueKind::Length, size)),
|
||||
Mod::Corner { size, .. } => Some(("Distance", ValueKind::Length, size)),
|
||||
}
|
||||
@@ -93,6 +104,9 @@ fn inputs(k: &Mod) -> Vec<SelInput> {
|
||||
// AXES is the mask that takes a straight sketch curve (`selection::SelInput::accepts`).
|
||||
Mod::Mirror => vec![SelInput::new("Entities", CURVES | POINTS, true), SelInput::new("Mirror line", AXES, false)],
|
||||
Mod::Move { .. } => vec![SelInput::new("Entities", CURVES | POINTS, true), SelInput::new("Center", POINTS, false)],
|
||||
// The direction is optional: `sketch.pattern.rectangular` documents sketch x as its
|
||||
// default, the way the engine reads it.
|
||||
Mod::PatternRect { .. } => vec![SelInput::new("Entities", CURVES | POINTS, true), SelInput::new("Direction", AXES, false)],
|
||||
// A fillet rounds any number of corners to one radius (`points`). Each chamfer command
|
||||
// cuts one corner, so its input holds one.
|
||||
Mod::Corner { chamfer: false, .. } => vec![SelInput::new("Corners", POINTS, true)],
|
||||
@@ -115,6 +129,7 @@ pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
|
||||
"sketch.offset" => Mod::Offset { distance: "2 mm".into(), chain: true, flip: false },
|
||||
"sketch.mirror" => Mod::Mirror,
|
||||
"sketch.move" => Mod::Move { x: "0 mm".into(), y: "0 mm".into(), angle: "0 deg".into(), copy: false },
|
||||
"sketch.pattern.rectangular" => Mod::PatternRect { count: "3".into(), spacing: "20 mm".into(), count2: "1".into(), spacing2: "20 mm".into() },
|
||||
"sketch.fillet" => corner(false, 0),
|
||||
"sketch.chamfer.equal_distance" => corner(true, 0),
|
||||
"sketch.chamfer.two_distance" => corner(true, 1),
|
||||
@@ -161,6 +176,13 @@ pub fn rows(ui: &mut egui::Ui, k: &mut Mod) -> bool {
|
||||
check(ui, copy);
|
||||
ui.end_row();
|
||||
}
|
||||
Mod::PatternRect { count, spacing, count2, spacing2 } => {
|
||||
for (label, value) in [("Quantity", count), ("Spacing", spacing), ("Quantity 2", count2), ("Spacing 2", spacing2)] {
|
||||
row_label(ui, label);
|
||||
enter |= field(ui, value);
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
Mod::Corner { chamfer, ctype, size, distance2, angle, flip } => {
|
||||
if *chamfer {
|
||||
row_label(ui, "Chamfer Type");
|
||||
@@ -191,6 +213,36 @@ pub fn rows(ui: &mut egui::Ui, k: &mut Mod) -> bool {
|
||||
enter
|
||||
}
|
||||
|
||||
/// The in-plane direction a picked origin axis or straight sketch curve gives the active
|
||||
/// sketch, as `sketch.pattern.rectangular` reads its `dir`. None when there is no active
|
||||
/// sketch, or when the axis stands perpendicular to the sketch plane and so has no direction
|
||||
/// in it.
|
||||
fn sketch_dir(app: &SolveApp, sel: &Sel) -> Option<Vec2> {
|
||||
let dir = crate::dialogs::axis_of(app, sel)?.1;
|
||||
let plane = app.session.world_state().sketch(app.session.active_sketch?)?.plane;
|
||||
Vec2::new(dir.dot(plane.x), dir.dot(plane.y)).normalized()
|
||||
}
|
||||
|
||||
/// The world direction a sketch modify window's dragged value grows along, for the viewport's
|
||||
/// value box and its drag arrow (the same machinery `dialogs_sheet::arrow` feeds). The
|
||||
/// rectangular pattern's spacing follows the picked direction, or the sketch's own x axis, which
|
||||
/// is what the command uses when nothing is picked. Every other variant keeps the arrow
|
||||
/// `canvas::anchor` already works out for it.
|
||||
pub fn arrow(app: &SolveApp, d: &Dialog) -> Option<Vec3> {
|
||||
let Kind::SketchModify(k) = &d.kind else { return None };
|
||||
match k {
|
||||
Mod::PatternRect { .. } => {
|
||||
let plane = app.session.world_state().sketch(app.session.active_sketch?)?.plane;
|
||||
let dir = match d.inputs.get(1).and_then(|i| i.items.first()) {
|
||||
Some(sel) => sketch_dir(app, sel)?,
|
||||
None => Vec2::X,
|
||||
};
|
||||
Some(plane.dir_to_world(dir))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// What the first value means for this corner type.
|
||||
fn size_label(chamfer: bool, ctype: usize) -> &'static str {
|
||||
if !chamfer {
|
||||
@@ -287,6 +339,33 @@ fn signed_length(app: &SolveApp, text: &str, flip: bool) -> Result<f64, String>
|
||||
Ok(if flip { -v } else { v })
|
||||
}
|
||||
|
||||
/// A typed count as the command wants it: the field evaluated against the document's
|
||||
/// parameters, then a whole number the engine accepts.
|
||||
///
|
||||
/// The sketch patterns read `count` and `angle` with `num`, which takes a JSON number and
|
||||
/// nothing else. An expression string would be refused outright for `count`, and silently
|
||||
/// replaced by the 360 degree default for `angle`. That silent substitution is the defect
|
||||
/// upstream #218 reports for `solid.pattern.circular`'s axis origin. The window therefore
|
||||
/// evaluates every value here and sends numbers.
|
||||
fn whole(app: &SolveApp, label: &str, text: &str) -> Result<u64, String> {
|
||||
let v = app.session.doc.eval(text.trim(), ValueKind::Unitless).map_err(|e| format!("{label}: {e}"))?;
|
||||
if !v.is_finite() || !(1.0..=MAX_COPIES).contains(&v) || (v - v.round()).abs() > 1e-9 {
|
||||
return Err(format!("{label} must be a whole number from 1 to {MAX_COPIES:.0}"));
|
||||
}
|
||||
Ok(v.round() as u64)
|
||||
}
|
||||
|
||||
/// A typed length as the command wants it, in millimetres. A bare number means the design's
|
||||
/// units, as a typed sketch value does everywhere else. `value` above reads an empty row as
|
||||
/// zero, which a Move window wants and a pattern spacing does not.
|
||||
fn length(app: &SolveApp, label: &str, text: &str) -> Result<f64, String> {
|
||||
let doc = &app.session.doc;
|
||||
let v = doc.eval(&doc.with_design_unit(text.trim(), ValueKind::Length), ValueKind::Length).map_err(|e| format!("{label}: {e}"))?;
|
||||
if !v.is_finite() {
|
||||
return Err(format!("{label} must be a number"));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
/// Corner point ids picked in input `i`, within the active sketch. A `POINTS` pick is stored as
|
||||
/// "<sketch id>:<point id>"; a point selected without the window carries the bare id.
|
||||
fn corner_ids(app: &SolveApp, inputs: &[SelInput], i: usize) -> Vec<String> {
|
||||
@@ -393,6 +472,33 @@ pub fn commands(app: &SolveApp, k: &Mod, inputs: &[SelInput], extra: &Map<String
|
||||
}
|
||||
("sketch.move", p)
|
||||
}
|
||||
Mod::PatternRect { count, spacing, count2, spacing2 } => {
|
||||
let entities = entity_ids(inputs, 0);
|
||||
if entities.is_empty() {
|
||||
return Err("Select the sketch curves or points to pattern first".into());
|
||||
}
|
||||
let n1 = whole(app, "Quantity", count)?;
|
||||
let s1 = length(app, "Spacing", spacing)?;
|
||||
let n2 = whole(app, "Quantity 2", count2)?;
|
||||
let s2 = length(app, "Spacing 2", spacing2)?;
|
||||
if n1 > 1 && s1.abs() <= 1e-9 {
|
||||
return Err("Spacing must be a non-zero number".into());
|
||||
}
|
||||
if n2 > 1 && s2.abs() <= 1e-9 {
|
||||
return Err("Spacing 2 must be a non-zero number".into());
|
||||
}
|
||||
if n1.saturating_mul(n2) > MAX_COPIES as u64 {
|
||||
return Err(format!("Quantity and Quantity 2 together must make at most {MAX_COPIES:.0} instances"));
|
||||
}
|
||||
let mut p = json!({"entities": entities, "count": n1, "spacing": s1, "count2": n2, "spacing2": s2});
|
||||
// A picked direction that has no component in the sketch plane is reported rather
|
||||
// than quietly replaced by the default.
|
||||
if let Some(sel) = inputs.get(1).and_then(|i| i.items.first()) {
|
||||
let d = sketch_dir(app, sel).ok_or("The direction must be an axis or a straight sketch curve that lies in the sketch plane")?;
|
||||
p["dir"] = json!([d.x, d.y]);
|
||||
}
|
||||
("sketch.pattern.rectangular", p)
|
||||
}
|
||||
Mod::Corner { chamfer: false, size, .. } => {
|
||||
let corners = corner_ids(app, inputs, 0);
|
||||
if corners.is_empty() {
|
||||
|
||||
Reference in new issue
Block a user