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
@@ -101,9 +101,10 @@ pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option<Vec3>)> {
|
||||
Kind::SketchModify(crate::sketch_modify_ui::Mod::Move { .. }) => {
|
||||
(at, app.session.active_sketch.and_then(|id| st.sketch(id)).and_then(|ss| ss.plane.x.normalized()))
|
||||
}
|
||||
// Offset's arrow is already computed above, per picked curve; keep it rather than the
|
||||
// default below.
|
||||
Kind::SketchModify(_) => (at, normal),
|
||||
// The rectangular pattern's spacing grows along the direction it was given, which the
|
||||
// module works out per variant. Offset's arrow is already computed above, per picked
|
||||
// curve, and `arrow` returns None for it so that one stands.
|
||||
Kind::SketchModify(_) => (at, crate::sketch_modify_ui::arrow(app, d).or(normal)),
|
||||
_ => (at, None),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -247,6 +247,8 @@
|
||||
"ui.profile_plane": "Profilebene",
|
||||
"ui.profiles": "Profile",
|
||||
"ui.project": "Projizieren",
|
||||
"ui.quantity": "Anzahl",
|
||||
"ui.quantity_2": "Anzahl 2",
|
||||
"ui.quit": "Beenden",
|
||||
"ui.radius": "Radius",
|
||||
"ui.radius_type": "Radius-Typ",
|
||||
@@ -286,6 +288,8 @@
|
||||
"ui.slot": "Langloch",
|
||||
"ui.smooth": "Glatt",
|
||||
"ui.snap": "Einrasten",
|
||||
"ui.spacing": "Abstand",
|
||||
"ui.spacing_2": "Abstand 2",
|
||||
"ui.sphere": "Sphäre",
|
||||
"ui.spline": "Spline",
|
||||
"ui.split_body": "Körper trennen",
|
||||
|
||||
@@ -525,6 +525,8 @@
|
||||
"ui.profiles": "Profiles",
|
||||
"ui.project": "Project",
|
||||
"ui.project_face_edges_sketch": "Project face edges into new sketches",
|
||||
"ui.quantity": "Quantity",
|
||||
"ui.quantity_2": "Quantity 2",
|
||||
"ui.quit": "Quit",
|
||||
"ui.radial_context_menu": "Radial context menu",
|
||||
"ui.radius": "Radius",
|
||||
@@ -573,6 +575,8 @@
|
||||
"ui.snap": "Snap",
|
||||
"ui.space_mouse_hint": "Move and rotate the view together; sketches use pan and zoom only.",
|
||||
"ui.space_mouse_navigate": "Navigate with SpaceNavigator / SpaceMouse",
|
||||
"ui.spacing": "Spacing",
|
||||
"ui.spacing_2": "Spacing 2",
|
||||
"ui.sphere": "Sphere",
|
||||
"ui.spline": "Spline",
|
||||
"ui.split_body": "Split Body",
|
||||
|
||||
@@ -525,6 +525,8 @@
|
||||
"ui.profiles": "プロファイル",
|
||||
"ui.project": "投影",
|
||||
"ui.project_face_edges_sketch": "新規スケッチに面のエッジを自動投影",
|
||||
"ui.quantity": "数量",
|
||||
"ui.quantity_2": "数量 2",
|
||||
"ui.quit": "終了",
|
||||
"ui.radial_context_menu": "パイ メニュー",
|
||||
"ui.radius": "半径",
|
||||
@@ -573,6 +575,8 @@
|
||||
"ui.snap": "スナップ",
|
||||
"ui.space_mouse_hint": "視点の移動と回転を同時に行います(スケッチ中はパンとズームのみ)",
|
||||
"ui.space_mouse_navigate": "SpaceNavigator / SpaceMouseで視点を操作",
|
||||
"ui.spacing": "間隔",
|
||||
"ui.spacing_2": "間隔 2",
|
||||
"ui.sphere": "球",
|
||||
"ui.spline": "スプライン",
|
||||
"ui.split_body": "ボディを分割",
|
||||
|
||||
@@ -247,6 +247,8 @@
|
||||
"ui.profile_plane": "프로파일 평면",
|
||||
"ui.profiles": "프로파일",
|
||||
"ui.project": "투영",
|
||||
"ui.quantity": "수량",
|
||||
"ui.quantity_2": "수량 2",
|
||||
"ui.quit": "종료",
|
||||
"ui.radius": "반지름",
|
||||
"ui.radius_type": "반지름 유형",
|
||||
@@ -286,6 +288,8 @@
|
||||
"ui.slot": "슬롯",
|
||||
"ui.smooth": "부드럽게",
|
||||
"ui.snap": "스냅",
|
||||
"ui.spacing": "간각",
|
||||
"ui.spacing_2": "간각 2",
|
||||
"ui.sphere": "구",
|
||||
"ui.spline": "스플라인",
|
||||
"ui.split_body": "바디 분할",
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -4,12 +4,14 @@
|
||||
//! mirror line and committed at once, with nothing on screen saying what was picked.
|
||||
//! `sketch.move` had no toolbar placement, no tool and no dialog either. Its only route into
|
||||
//! the UI was the sketch context menu, and that went through the same empty-parameter
|
||||
//! fallback, so the item reported an engine error.
|
||||
//! fallback, so the item reported an engine error. The rectangular pattern's click tool sent
|
||||
//! a hard-coded count of 3 and could reach neither the command's second count nor either
|
||||
//! spacing.
|
||||
|
||||
use serde_json::json;
|
||||
use solidcraft_engine::Sel;
|
||||
|
||||
use crate::dialogs::Kind;
|
||||
use crate::dialogs::{Kind, apply_commands};
|
||||
use crate::scenario::Harness;
|
||||
use crate::selection::{AXES, CURVES, POINTS, SelInput};
|
||||
use crate::sketch_modify_ui::{Mod, commands};
|
||||
@@ -395,3 +397,125 @@ fn the_x_distance_gets_a_drag_arrow_along_the_sketch_x_axis() {
|
||||
let dir = dir.expect("a sketch move's X Distance has a direction to drag along");
|
||||
assert!((dir - x).len() < 1e-9, "{dir:?} is not the sketch x axis {x:?}");
|
||||
}
|
||||
|
||||
/// A sketch holding one circle, with the window for `cmd` open on it.
|
||||
fn open(cmd: &str) -> (crate::scenario::Harness, String) {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.run("sketch.create", json!({"plane": "XY"})).unwrap();
|
||||
h.app.run("sketch.circle.center", json!({"center": [0, 0], "radius": 5})).unwrap();
|
||||
h.settle();
|
||||
let sid = h.app.session.active_sketch.unwrap();
|
||||
let id = h.app.session.model.state().sketch(sid).unwrap().sketch.curves[0].id.clone();
|
||||
h.step(&json!({"start": cmd})).unwrap();
|
||||
(h, id)
|
||||
}
|
||||
|
||||
fn rect(h: &mut crate::scenario::Harness, count: &str, spacing: &str, count2: &str, spacing2: &str) {
|
||||
let d = h.app.dialog.as_mut().expect("the window opened");
|
||||
if let Kind::SketchModify(Mod::PatternRect { count: c1, spacing: s1, count2: c2, spacing2: s2 }) = &mut d.kind {
|
||||
*c1 = count.into();
|
||||
*s1 = spacing.into();
|
||||
*c2 = count2.into();
|
||||
*s2 = spacing2.into();
|
||||
} else {
|
||||
panic!("not the rectangular pattern window: {:?}", d.kind);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rectangular_pattern_window_opens_instead_of_a_click_tool() {
|
||||
let (h, _) = open("sketch.pattern.rectangular");
|
||||
assert!(h.app.tool.is_none(), "the click tool still takes the command");
|
||||
let d = h.app.dialog.as_ref().expect("the window opened");
|
||||
assert!(matches!(d.kind, Kind::SketchModify(Mod::PatternRect { .. })), "{:?}", d.kind);
|
||||
assert_eq!(d.inputs.iter().map(|i| i.label).collect::<Vec<_>>(), vec!["Entities", "Direction"]);
|
||||
// Nothing is committed by opening the window.
|
||||
let sid = h.app.session.active_sketch.unwrap();
|
||||
assert_eq!(h.app.session.model.state().sketch(sid).unwrap().sketch.curves.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_counts_and_spacings_reach_the_command_with_the_second_direction() {
|
||||
let (mut h, id) = open("sketch.pattern.rectangular");
|
||||
h.app.dialog.as_mut().unwrap().inputs[0].items = vec![Sel::SketchCurve { id }];
|
||||
rect(&mut h, "5", "20", "2", "15 mm");
|
||||
let d = h.app.dialog.clone().unwrap();
|
||||
let cmds = apply_commands(&h.app, &d).unwrap();
|
||||
assert_eq!(cmds.len(), 1);
|
||||
assert_eq!(cmds[0].0, "sketch.pattern.rectangular");
|
||||
// The click tool could only ever send a count of 3 and no second count at all.
|
||||
assert_eq!(cmds[0].1["count"], json!(5));
|
||||
assert_eq!(cmds[0].1["spacing"], json!(20.0));
|
||||
assert_eq!(cmds[0].1["count2"], json!(2));
|
||||
assert_eq!(cmds[0].1["spacing2"], json!(15.0));
|
||||
assert!(cmds[0].1.get("dir").is_none(), "an unpicked direction leaves the command's own default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_picked_axis_becomes_an_in_plane_direction() {
|
||||
let (mut h, id) = open("sketch.pattern.rectangular");
|
||||
h.app.dialog.as_mut().unwrap().inputs[0].items = vec![Sel::SketchCurve { id }];
|
||||
h.app.dialog.as_mut().unwrap().inputs[1].items = vec![Sel::Axis { name: "Y".into() }];
|
||||
rect(&mut h, "3", "10", "1", "10");
|
||||
let d = h.app.dialog.clone().unwrap();
|
||||
let cmds = apply_commands(&h.app, &d).unwrap();
|
||||
assert_eq!(cmds[0].1["dir"], json!([0.0, 1.0]), "sketch y on the XY plane");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_axis_perpendicular_to_the_sketch_is_reported_not_silently_replaced() {
|
||||
let (mut h, id) = open("sketch.pattern.rectangular");
|
||||
h.app.dialog.as_mut().unwrap().inputs[0].items = vec![Sel::SketchCurve { id }];
|
||||
h.app.dialog.as_mut().unwrap().inputs[1].items = vec![Sel::Axis { name: "Z".into() }];
|
||||
rect(&mut h, "3", "10", "1", "10");
|
||||
let d = h.app.dialog.clone().unwrap();
|
||||
let e = apply_commands(&h.app, &d).unwrap_err();
|
||||
assert!(e.contains("sketch plane"), "{e}");
|
||||
}
|
||||
|
||||
/// Upstream #218 is the same shape: `solid.pattern.circular` took expressions in its axis origin
|
||||
/// and stored `[0, 0, 0]`. The sketch patterns read `count` and `angle` with `num`, which takes a
|
||||
/// JSON number only, so the window evaluates every field itself.
|
||||
#[test]
|
||||
fn a_count_expression_is_evaluated_rather_than_sent_as_a_string() {
|
||||
let (mut h, id) = open("sketch.pattern.rectangular");
|
||||
h.app.run("parameters.add", json!({"name": "n", "expression": "4", "unit": ""})).unwrap();
|
||||
h.app.dialog.as_mut().unwrap().inputs[0].items = vec![Sel::SketchCurve { id }];
|
||||
rect(&mut h, "n", "10", "1", "10");
|
||||
let d = h.app.dialog.clone().unwrap();
|
||||
let cmds = apply_commands(&h.app, &d).unwrap();
|
||||
assert_eq!(cmds[0].1["count"], json!(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_counts_and_an_empty_selection_are_refused_before_the_command_runs() {
|
||||
let (mut h, id) = open("sketch.pattern.rectangular");
|
||||
rect(&mut h, "5", "20", "1", "20");
|
||||
let d = h.app.dialog.clone().unwrap();
|
||||
assert!(apply_commands(&h.app, &d).unwrap_err().contains("Select"));
|
||||
h.app.dialog.as_mut().unwrap().inputs[0].items = vec![Sel::SketchCurve { id }];
|
||||
for bad in ["0", "2.5", "501", "wat"] {
|
||||
rect(&mut h, bad, "20", "1", "20");
|
||||
let d = h.app.dialog.clone().unwrap();
|
||||
assert!(apply_commands(&h.app, &d).is_err(), "count {bad} was accepted");
|
||||
}
|
||||
rect(&mut h, "5", "0", "1", "20");
|
||||
let d = h.app.dialog.clone().unwrap();
|
||||
assert!(apply_commands(&h.app, &d).unwrap_err().contains("Spacing"));
|
||||
}
|
||||
|
||||
/// The spacing is a geometric value, so it gets the viewport's value box and a drag arrow
|
||||
/// through the existing `canvas::anchor` machinery: along the sketch's own x by default, along
|
||||
/// the picked direction once one is picked.
|
||||
#[test]
|
||||
fn the_spacing_gets_a_canvas_arrow_along_the_pattern_direction() {
|
||||
let (mut h, id) = open("sketch.pattern.rectangular");
|
||||
h.app.dialog.as_mut().unwrap().inputs[0].items = vec![Sel::SketchCurve { id }];
|
||||
let d = h.app.dialog.clone().unwrap();
|
||||
let (_, dir) = crate::canvas::anchor(&h.app, &d).expect("an anchor on the picked curve");
|
||||
assert_eq!(dir, Some(solidcraft_engine::geom::Vec3::X));
|
||||
h.app.dialog.as_mut().unwrap().inputs[1].items = vec![Sel::Axis { name: "Y".into() }];
|
||||
let d = h.app.dialog.clone().unwrap();
|
||||
let (_, dir) = crate::canvas::anchor(&h.app, &d).expect("an anchor on the picked curve");
|
||||
assert_eq!(dir, Some(solidcraft_engine::geom::Vec3::Y));
|
||||
}
|
||||
@@ -361,7 +361,7 @@ fn mode(id: &str) -> Option<Mode> {
|
||||
"sketch.circle.three_tangent" => Mode::Entities(3),
|
||||
"sketch.constrainer" | "sketch.constraint.polygon" => Mode::Entities(0),
|
||||
"sketch.centerline" => Mode::Entities(1),
|
||||
"sketch.pattern.circular" | "sketch.pattern.rectangular" => Mode::EntitiesThen,
|
||||
"sketch.pattern.circular" => Mode::EntitiesThen,
|
||||
"sketch.project_to_surface" | "sketch.intersection_curve" | "sketch.spun_profile" => Mode::Steps,
|
||||
_ => return None,
|
||||
})
|
||||
@@ -420,7 +420,6 @@ pub fn hint(id: &str) -> Option<String> {
|
||||
"sketch.constraint.polygon" => "Polygon: click the lines of a closed chain; right-click to apply",
|
||||
"sketch.centerline" => "Centerline: click lines",
|
||||
"sketch.pattern.circular" => "Circular pattern: click entities, right-click, then click the centre",
|
||||
"sketch.pattern.rectangular" => "Rectangular pattern: click entities, right-click, then click two points (direction and spacing)",
|
||||
"sketch.project_to_surface" => "Project to surface: click curves of other sketches, right-click, then click the face",
|
||||
"sketch.intersection_curve" => "Intersection curve: click two faces",
|
||||
"sketch.spun_profile" => "Spun profile: click the body, then the axis line",
|
||||
@@ -721,26 +720,14 @@ pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) {
|
||||
// Stage 2 (after a right-click) is marked by a "|" pick.
|
||||
if tool.picks.last().is_some_and(|x| x == "|") {
|
||||
let ents: Vec<String> = tool.picks.iter().filter(|x| *x != "|").cloned().collect();
|
||||
match cmd {
|
||||
"sketch.pattern.circular" => {
|
||||
if let Some(p) = at {
|
||||
let _ = app.run(cmd, json!({"entities": ents, "center": arg(&p), "count": 6}));
|
||||
tool.picks.clear();
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(p) = at {
|
||||
tool.pts.push(p);
|
||||
if tool.pts.len() >= 2 {
|
||||
let d = tool.pts[1].0 - tool.pts[0].0;
|
||||
if d.len() > 1e-9 {
|
||||
let _ = app.run(cmd, json!({"entities": ents, "dir": xy(d), "count": 3, "spacing": d.len()}));
|
||||
}
|
||||
tool.picks.clear();
|
||||
tool.pts.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
// `sketch.pattern.rectangular` left this flow for a tool window: its count was
|
||||
// hard-coded to 3 here and the command's second count and spacing could not be
|
||||
// reached at all. The circular pattern is the last command still on it.
|
||||
if cmd == "sketch.pattern.circular"
|
||||
&& let Some(p) = at
|
||||
{
|
||||
let _ = app.run(cmd, json!({"entities": ents, "center": arg(&p), "count": 6}));
|
||||
tool.picks.clear();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user