791 lines
40 KiB
Rust
791 lines
40 KiB
Rust
//! Tool windows for sketch-space modify commands, on the `dialogs_surface` pattern (#19): one
|
|
//! `Mod` enum behind one `Kind::SketchModify` variant, grown by one variant and one arm per
|
|
//! command rather than by a new module each time. Scale came first, whose toolbar button
|
|
//! reached no tool and no dialog at all, so a press ran `sketch.scale` with no parameters and
|
|
//! the engine answered "`factor` must be a positive number". Offset joins it here (upstream
|
|
//! issue #162 item 1: "Offset does not create a constraint, the size of the offset seems
|
|
//! random"), and it now offers the same One Side, Two Sides and Symmetric directions
|
|
//! `solid.extrude` already names. Mirror joins them here, which ran as a bare click flow: entities until a
|
|
//! right-click, then one more click for the line, with nothing on screen saying what was
|
|
//! picked. Move joins them too, which had no toolbar placement, no tool and no dialog: its only
|
|
//! route into the UI was the sketch context menu, and that went through the same
|
|
//! empty-parameter fallback and reported an engine error. `Mod::Corner` joins them here,
|
|
//! serving `sketch.fillet` and the three `sketch.chamfer.*` commands together, the way
|
|
//! `solid.fillet` and `solid.chamfer` already share one window. Before it, a click committed a
|
|
//! 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 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.
|
|
|
|
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 solidcraft_engine::sketch::PatternAlignment;
|
|
|
|
use crate::SolveApp;
|
|
use crate::dialogs::{CHAMFER_TYPES, Dialog, Kind, check, field, icon_choice, pattern_toggle, row_label};
|
|
use crate::selection::{AXES, CURVES, SKETCH_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;
|
|
|
|
/// The offset directions, in the words and the order `solid.extrude` already uses for them.
|
|
/// One Side offsets the way the distance points. Two Sides adds an independent distance the
|
|
/// other way. Symmetric applies the one distance each way.
|
|
pub(crate) const OFFSET_MODES: [&str; 3] = ["One Side", "Two Sides", "Symmetric"];
|
|
const OFFSET_MODE_ICONS: [&str; 3] = ["offset_one_side", "offset_two_sides", "offset_symmetric"];
|
|
/// Index of Two Sides in [`OFFSET_MODES`] (the mode with a second distance).
|
|
const TWO_SIDES: usize = 1;
|
|
/// Index of Symmetric in [`OFFSET_MODES`].
|
|
const SYMMETRIC: usize = 2;
|
|
|
|
/// Whether an offset mode index is Symmetric (which hides Flip and offsets both ways).
|
|
pub(crate) fn offset_symmetric(mode: usize) -> bool {
|
|
mode == SYMMETRIC
|
|
}
|
|
|
|
/// A sketch modify tool window's values.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub enum Mod {
|
|
/// `sketch.scale`: the factor the picked entities scale by. The base point is the second
|
|
/// input; with none picked the command scales about the sketch origin, as its own default
|
|
/// already does.
|
|
Scale { factor: String },
|
|
/// `sketch.offset`: the curves to offset, the distance typed and whether the connected
|
|
/// chain follows. A positive distance is left of the chain, as the command defines it;
|
|
/// `flip` reverses whatever sign the field holds. `mode` indexes [`OFFSET_MODES`], and
|
|
/// `distance2` is the other side's distance that Two Sides asks for.
|
|
Offset { distance: String, distance2: String, mode: usize, chain: bool, flip: bool },
|
|
/// `sketch.mirror`: the entities and the line they reflect in. The command has no number of
|
|
/// its own, so the window's whole job is to show what is picked and let a pick be changed
|
|
/// before anything is committed.
|
|
Mirror,
|
|
/// `sketch.move`: how far the picked entities move along the sketch's own axes, how far they
|
|
/// 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 a second picked direction (perpendicular by default).
|
|
PatternRect {
|
|
count: String,
|
|
spacing: String,
|
|
count2: String,
|
|
spacing2: String,
|
|
alignment: PatternAlignment,
|
|
alignment2: PatternAlignment,
|
|
reversed: bool,
|
|
reversed2: bool,
|
|
},
|
|
/// `sketch.pattern.circular`: how many instances around the picked centre, and the total
|
|
/// angle they span.
|
|
PatternCirc { count: String, angle: String, alignment: PatternAlignment, reversed: bool },
|
|
/// `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
|
|
/// angle). `size` is the radius or the first distance, `distance2` the second distance and
|
|
/// `angle` the angle from the first line. `flip` names the two lines in the other order, so
|
|
/// the first distance lands on the other leg of the corner.
|
|
Corner { chamfer: bool, ctype: usize, size: String, distance2: String, angle: String, flip: bool },
|
|
}
|
|
|
|
impl Mod {
|
|
pub fn previews(&self) -> bool {
|
|
matches!(self, Self::Mirror | Self::PatternRect { .. } | Self::PatternCirc { .. } | Self::Offset { .. })
|
|
}
|
|
|
|
pub fn title(&self) -> &'static str {
|
|
match self {
|
|
Mod::Scale { .. } => "SKETCH SCALE",
|
|
Mod::Offset { .. } => "OFFSET",
|
|
Mod::Mirror => "MIRROR",
|
|
Mod::Move { .. } => "MOVE/COPY",
|
|
Mod::PatternRect { .. } => "RECTANGULAR PATTERN",
|
|
Mod::PatternCirc { .. } => "CIRCULAR PATTERN",
|
|
Mod::Corner { chamfer: false, .. } => "FILLET",
|
|
Mod::Corner { .. } => "CHAMFER",
|
|
}
|
|
}
|
|
|
|
/// The value on the canvas.
|
|
pub fn primary(&mut self) -> Option<(&'static str, ValueKind, &mut String)> {
|
|
match self {
|
|
Mod::Scale { factor } => Some(("Factor", ValueKind::Unitless, factor)),
|
|
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::PatternCirc { angle, .. } => Some(("Angle", ValueKind::Angle, angle)),
|
|
Mod::Corner { chamfer: false, size, .. } => Some(("Radius", ValueKind::Length, size)),
|
|
Mod::Corner { size, .. } => Some(("Distance", ValueKind::Length, size)),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn inputs(k: &Mod) -> Vec<SelInput> {
|
|
match k {
|
|
Mod::Scale { .. } => vec![SelInput::new("Entities", CURVES | SKETCH_POINTS, true), SelInput::new("Base point", SKETCH_POINTS, false)],
|
|
Mod::Offset { .. } => vec![SelInput::new("Curves", CURVES, true)],
|
|
// AXES is the mask that takes a straight sketch curve (`selection::SelInput::accepts`).
|
|
Mod::Mirror => vec![SelInput::new("Entities", CURVES | SKETCH_POINTS, true), SelInput::new("Mirror line", AXES, false)],
|
|
Mod::Move { .. } => vec![SelInput::new("Entities", CURVES | SKETCH_POINTS, true), SelInput::new("Center", SKETCH_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 | SKETCH_POINTS, true),
|
|
SelInput::new("Direction", AXES, false),
|
|
SelInput::new("Direction 2", AXES, false),
|
|
],
|
|
// The centre is required: `sketch.pattern.circular` has no default for it.
|
|
Mod::PatternCirc { .. } => vec![SelInput::new("Entities", CURVES | SKETCH_POINTS, true), SelInput::new("Center", SKETCH_POINTS, false)],
|
|
// A fillet rounds any number of corners to one radius (`points`). Each chamfer command
|
|
// cuts one corner: its point, or its two lines. Two lines name the corner between them
|
|
// where more lines meet at its point (#1824), for both.
|
|
Mod::Corner { chamfer: false, .. } => vec![SelInput::new("Corners", SKETCH_POINTS | CURVES, true)],
|
|
Mod::Corner { .. } => vec![SelInput::new("Corner", SKETCH_POINTS | CURVES, true)],
|
|
}
|
|
}
|
|
|
|
/// The dialog for a sketch modify command.
|
|
pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
|
|
let corner = |chamfer: bool, ctype: usize| Mod::Corner {
|
|
chamfer,
|
|
ctype,
|
|
size: "2 mm".into(),
|
|
distance2: "2 mm".into(),
|
|
angle: "45 deg".into(),
|
|
flip: false,
|
|
};
|
|
let k = match id {
|
|
"sketch.scale" => Mod::Scale { factor: "2".into() },
|
|
"sketch.offset" => Mod::Offset { distance: "2 mm".into(), distance2: "2 mm".into(), mode: 0, 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(),
|
|
alignment: PatternAlignment::Right,
|
|
alignment2: PatternAlignment::Right,
|
|
reversed: false,
|
|
reversed2: false,
|
|
},
|
|
"sketch.pattern.circular" => {
|
|
Mod::PatternCirc { count: "6".into(), angle: "360 deg".into(), alignment: PatternAlignment::Right, reversed: false }
|
|
}
|
|
"sketch.fillet" => corner(false, 0),
|
|
"sketch.chamfer.equal_distance" => corner(true, 0),
|
|
"sketch.chamfer.two_distance" => corner(true, 1),
|
|
"sketch.chamfer.distance_angle" => corner(true, 2),
|
|
_ => return None,
|
|
};
|
|
let i = inputs(&k);
|
|
Some((Kind::SketchModify(k), i))
|
|
}
|
|
|
|
/// Sketch modify inputs accept only the sketch being edited. Origin axes remain valid
|
|
/// rectangular-pattern directions, while Hole continues using its separate POINTS mask.
|
|
pub fn candidate(d: &Dialog, session: &solidcraft_engine::Session, hit: &Hit) -> Option<Sel> {
|
|
let active = session.active_sketch?;
|
|
match hit {
|
|
Hit::SketchCurve { sketch, .. } | Hit::SketchPoint { sketch, .. } if *sketch != active => None,
|
|
_ => d.active_input()?.accepts(hit, |_, _| false),
|
|
}
|
|
}
|
|
|
|
/// Reopen the same pattern window with its stored parameters. OK reaches the same engine
|
|
/// command with the pattern id, so previews and programmatic edits share the implementation.
|
|
pub fn edit_pattern(app: &mut SolveApp, id: &str) -> bool {
|
|
use solidcraft_engine::sketch::SketchPatternKind;
|
|
let Some(sid) = app.session.active_sketch else { return false };
|
|
let state = app.session.world_state();
|
|
let Some(sk) = state.sketch(sid).map(|s| &s.sketch) else { return false };
|
|
let Some(pattern) = sk.patterns.iter().find(|p| p.id == id) else { return false };
|
|
let kind = match &pattern.kind {
|
|
SketchPatternKind::Circular { count, angle, alignment, reversed, .. } => Mod::PatternCirc {
|
|
count: count.to_string(),
|
|
angle: format!("{angle} deg"),
|
|
alignment: if *alignment == PatternAlignment::Left { PatternAlignment::Right } else { *alignment },
|
|
reversed: *reversed ^ (*alignment == PatternAlignment::Left),
|
|
},
|
|
SketchPatternKind::Rectangular { count, spacing, count2, spacing2, alignment, alignment2, reversed, reversed2, .. } => Mod::PatternRect {
|
|
count: count.to_string(),
|
|
spacing: format!("{spacing} mm"),
|
|
count2: count2.to_string(),
|
|
spacing2: format!("{spacing2} mm"),
|
|
alignment: if *alignment == PatternAlignment::Left { PatternAlignment::Right } else { *alignment },
|
|
alignment2: if *alignment2 == PatternAlignment::Left { PatternAlignment::Right } else { *alignment2 },
|
|
reversed: *reversed ^ (*alignment == PatternAlignment::Left),
|
|
reversed2: *reversed2 ^ (*alignment2 == PatternAlignment::Left),
|
|
},
|
|
};
|
|
let mut dialog = Dialog::new(Kind::SketchModify(kind.clone()), inputs(&kind));
|
|
if let Some(input) = dialog.inputs.first_mut() {
|
|
input.items = pattern
|
|
.entities
|
|
.iter()
|
|
.map(|id| if sk.curve_index(id).is_some() { Sel::SketchCurve { id: id.clone() } } else { Sel::SketchPoint { id: id.clone() } })
|
|
.collect();
|
|
}
|
|
match &pattern.kind {
|
|
SketchPatternKind::Circular { center, .. } => {
|
|
if let Some(input) = dialog.inputs.get_mut(1) {
|
|
input.items = vec![Sel::SketchPoint { id: center.clone() }];
|
|
}
|
|
}
|
|
SketchPatternKind::Rectangular { dir, dir2, direction, direction2, .. } => {
|
|
dialog.extra.insert("dir".into(), json!(dir));
|
|
dialog.extra.insert("dir2".into(), json!(dir2));
|
|
for (index, id) in [(1, direction), (2, direction2)] {
|
|
if let Some(id) = id
|
|
&& let Some(input) = dialog.inputs.get_mut(index)
|
|
{
|
|
input.items = vec![Sel::SketchCurve { id: id.clone() }];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
dialog.extra.insert("pattern".into(), json!(id));
|
|
app.tool = None;
|
|
app.dialog = Some(dialog);
|
|
true
|
|
}
|
|
|
|
pub fn double_click_pattern(app: &mut SolveApp, pos: Pos2) -> bool {
|
|
let Some(glyph) = crate::sketch_tools::glyph_at(pos) else { return false };
|
|
let Some(sid) = app.session.active_sketch else { return false };
|
|
let id = app
|
|
.session
|
|
.world_state()
|
|
.sketch(sid)
|
|
.and_then(|ss| ss.sketch.patterns.iter().find(|p| p.instances.iter().any(|i| i.constraints.contains(&glyph))).map(|p| p.id.clone()));
|
|
id.is_some_and(|id| edit_pattern(app, &id))
|
|
}
|
|
|
|
/// Place a circular pattern centre in empty sketch space without creating a document point.
|
|
pub fn pick_position(d: &mut Dialog, session: &solidcraft_engine::Session, point: Vec3) -> bool {
|
|
if !matches!(d.kind, Kind::SketchModify(Mod::PatternCirc { .. })) || d.active != 1 {
|
|
return false;
|
|
}
|
|
d.pick(session, Sel::Vertex { body: String::new(), point });
|
|
true
|
|
}
|
|
|
|
/// The rows after the inputs; true when Enter was pressed in a field.
|
|
pub fn rows(ui: &mut egui::Ui, k: &mut Mod) -> bool {
|
|
let mut enter = false;
|
|
match k {
|
|
Mod::Scale { factor } => {
|
|
row_label(ui, "Factor");
|
|
enter |= field(ui, factor);
|
|
ui.end_row();
|
|
}
|
|
Mod::Offset { distance, distance2, mode, chain, flip } => {
|
|
row_label(ui, "Direction");
|
|
icon_choice(ui, &OFFSET_MODES, &OFFSET_MODE_ICONS, mode);
|
|
ui.end_row();
|
|
row_label(ui, "Distance");
|
|
enter |= field(ui, distance);
|
|
ui.end_row();
|
|
if *mode == TWO_SIDES {
|
|
row_label(ui, "Distance 2");
|
|
enter |= field(ui, distance2);
|
|
ui.end_row();
|
|
}
|
|
// Symmetric offsets both sides, so there is no side left for Flip to swap.
|
|
if *mode != SYMMETRIC {
|
|
row_label(ui, "Flip");
|
|
check(ui, flip);
|
|
ui.end_row();
|
|
}
|
|
row_label(ui, "Chain");
|
|
check(ui, chain);
|
|
ui.end_row();
|
|
}
|
|
// Mirror has no value of its own: its inputs are the whole window.
|
|
Mod::Mirror => {}
|
|
Mod::Move { x, y, angle, copy } => {
|
|
row_label(ui, "X Distance");
|
|
enter |= field(ui, x);
|
|
ui.end_row();
|
|
row_label(ui, "Y Distance");
|
|
enter |= field(ui, y);
|
|
ui.end_row();
|
|
row_label(ui, "Angle");
|
|
enter |= field(ui, angle);
|
|
ui.end_row();
|
|
row_label(ui, "Create Copy");
|
|
check(ui, copy);
|
|
ui.end_row();
|
|
}
|
|
// Its two direction columns include both the reference picks and values.
|
|
Mod::PatternRect { .. } => {}
|
|
Mod::PatternCirc { count, angle, alignment, reversed } => {
|
|
for (label, value) in [("Quantity", count), ("Total angle", angle)] {
|
|
row_label(ui, label);
|
|
enter |= field(ui, value);
|
|
ui.end_row();
|
|
}
|
|
row_label(ui, "Alignment");
|
|
ui.horizontal(|ui| {
|
|
pattern_toggle(ui, "Alignment", "Flip direction", "pattern_flip", reversed);
|
|
let mut centered = *alignment == PatternAlignment::Center;
|
|
pattern_toggle(ui, "Alignment", "Center", "pattern_center", &mut centered);
|
|
*alignment = if centered { PatternAlignment::Center } else { PatternAlignment::Right };
|
|
});
|
|
ui.end_row();
|
|
row_label(ui, "");
|
|
ui.add(
|
|
egui::Label::new(
|
|
egui::RichText::new(crate::i18n::tr_label(
|
|
"Counts include the original. Center balances copies; Flip chooses the extra side for even totals.",
|
|
))
|
|
.small(),
|
|
)
|
|
.wrap(),
|
|
);
|
|
ui.end_row();
|
|
}
|
|
Mod::Corner { chamfer, ctype, size, distance2, angle, flip } => {
|
|
if *chamfer {
|
|
row_label(ui, "Chamfer Type");
|
|
icon_choice(ui, &CHAMFER_TYPES, &CHAMFER_TYPE_ICONS, ctype);
|
|
ui.end_row();
|
|
}
|
|
row_label(ui, size_label(*chamfer, *ctype));
|
|
enter |= field(ui, size);
|
|
ui.end_row();
|
|
if *chamfer && *ctype == 1 {
|
|
row_label(ui, "Distance 2");
|
|
enter |= field(ui, distance2);
|
|
ui.end_row();
|
|
}
|
|
if *chamfer && *ctype == 2 {
|
|
row_label(ui, "Angle");
|
|
enter |= field(ui, angle);
|
|
ui.end_row();
|
|
}
|
|
// With two equal distances there is nothing to swap.
|
|
if *chamfer && *ctype > 0 {
|
|
row_label(ui, "Flip");
|
|
check(ui, flip);
|
|
ui.end_row();
|
|
}
|
|
}
|
|
}
|
|
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 { alignment, reversed, .. } => {
|
|
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(if (*alignment == PatternAlignment::Left) ^ *reversed { -dir } else { dir }))
|
|
}
|
|
// A total angle is a rotation. The viewport draws a straight drag arrow and a rotation
|
|
// handle only for Revolve, through machinery tied to its own extents, so the circular
|
|
// pattern takes the value box alone until that manipulator is made general.
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// What the first value means for this corner type.
|
|
fn size_label(chamfer: bool, ctype: usize) -> &'static str {
|
|
if !chamfer {
|
|
"Radius"
|
|
} else if ctype == 0 {
|
|
"Distance"
|
|
} else {
|
|
"Distance 1"
|
|
}
|
|
}
|
|
|
|
/// The corner under the cursor while the Fillet or Chamfer window is open: the hover hit and
|
|
/// the selection it stands for.
|
|
///
|
|
/// Corners are resolved through `sketch_tools::corner_point` rather than the ordinary
|
|
/// nearest-point test. A rectangle drawn from the origin leaves its own endpoint on top of the
|
|
/// fixed origin point, and the nearest-point test returns the origin, which ends no two curves.
|
|
/// That resolution chooses among the endpoints that actually make a corner instead.
|
|
pub(crate) fn corner_hit(app: &SolveApp, proj: &Proj, pos: Pos2) -> Option<(Hit, Sel)> {
|
|
let Some(Kind::SketchModify(Mod::Corner { chamfer, .. })) = app.dialog.as_ref().map(|d| &d.kind) else { return None };
|
|
let sketch = app.session.active_sketch?;
|
|
let (at, id) = crate::sketch_tools::corner_point(app, proj, pos, !*chamfer)?;
|
|
let sel = Sel::SketchPoint { id: id.clone() };
|
|
Some((Hit::SketchPoint { sketch, id, at }, sel))
|
|
}
|
|
|
|
/// What input `i` holds.
|
|
fn items(inputs: &[SelInput], i: usize) -> &[Sel] {
|
|
inputs.get(i).map(|x| x.items.as_slice()).unwrap_or(&[])
|
|
}
|
|
|
|
/// A local point id. Qualified ids supplied by scripts or older selections are also accepted;
|
|
/// commands check that their sketch prefix identifies the sketch being edited.
|
|
pub(crate) fn point_id<'a>(session: &solidcraft_engine::Session, sel: &'a Sel) -> Option<&'a str> {
|
|
let Sel::SketchPoint { id } = sel else { return None };
|
|
let Some((owner, point)) = id.split_once(':') else { return Some(id) };
|
|
let active = session.active_sketch?;
|
|
// Custom local ids may themselves contain ':'. Preserve them before interpreting a
|
|
// legacy qualified selection, whose owner must match the sketch being edited.
|
|
if session.world_state().sketch(active).is_some_and(|sk| sk.sketch.point_index(id).is_some()) {
|
|
Some(id)
|
|
} else {
|
|
(owner.parse::<u64>().ok() == Some(active)).then_some(point)
|
|
}
|
|
}
|
|
|
|
/// The curves and lone points picked in input `i`, as the engine's `entities` list.
|
|
fn entity_ids(session: &solidcraft_engine::Session, inputs: &[SelInput], i: usize) -> Vec<String> {
|
|
items(inputs, i)
|
|
.iter()
|
|
.filter_map(|x| match x {
|
|
Sel::SketchCurve { id } => Some(id.clone()),
|
|
_ => point_id(session, x).map(str::to_string),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Sketch curve ids picked in input `i`.
|
|
fn curve_ids(inputs: &[SelInput], i: usize) -> Vec<String> {
|
|
items(inputs, i).iter().filter_map(|x| if let Sel::SketchCurve { id } = x { Some(id.clone()) } else { None }).collect()
|
|
}
|
|
|
|
/// A typed scale factor as the command wants it: the field evaluated against the document's
|
|
/// parameters, in the range the command accepts. Checking the range here lets the window say
|
|
/// so in its own error line instead of the status bar.
|
|
fn factor(app: &SolveApp, text: &str) -> Result<f64, String> {
|
|
let v = app.session.doc.eval(text.trim(), ValueKind::Unitless).map_err(|e| format!("Factor: {e}"))?;
|
|
if !(v.is_finite() && v > 1e-9 && v < 1e6) {
|
|
return Err("Factor must be a positive number".into());
|
|
}
|
|
Ok(v)
|
|
}
|
|
|
|
/// A typed field as the command wants it: evaluated against the document's parameters, so an
|
|
/// expression works, with a bare number read in the design's own units. An empty field is zero,
|
|
/// the way an untouched row should leave the geometry where it is.
|
|
fn value(app: &SolveApp, text: &str, kind: ValueKind, label: &str) -> Result<f64, String> {
|
|
let text = text.trim();
|
|
if text.is_empty() {
|
|
return Ok(0.0);
|
|
}
|
|
let doc = &app.session.doc;
|
|
let v = doc.eval(&doc.with_design_unit(text, kind), kind).map_err(|e| format!("{label}: {e}"))?;
|
|
if !v.is_finite() {
|
|
return Err(format!("{label} must be a number"));
|
|
}
|
|
Ok(v)
|
|
}
|
|
|
|
/// A typed distance as the command wants it: the field evaluated against the document's
|
|
/// parameters, with the sign reversed when Flip is set. A bare number means the design's units,
|
|
/// as a typed sketch value does everywhere else.
|
|
fn signed_length(app: &SolveApp, label: &str, text: &str, flip: bool) -> 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() || v.abs() <= 1e-9 {
|
|
return Err(format!("{label} must be a non-zero number"));
|
|
}
|
|
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)
|
|
}
|
|
/// The curve ids picked in input `i` (a corner's two lines, #1824).
|
|
fn line_ids(inputs: &[SelInput], i: usize) -> Vec<String> {
|
|
items(inputs, i)
|
|
.iter()
|
|
.filter_map(|x| match x {
|
|
Sel::SketchCurve { id } => Some(id.clone()),
|
|
_ => None,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Corner point ids within the active sketch, including qualified script selections.
|
|
fn corner_ids(app: &SolveApp, inputs: &[SelInput], i: usize) -> Vec<String> {
|
|
items(inputs, i).iter().filter_map(|sel| point_id(&app.session, sel).map(str::to_string)).collect()
|
|
}
|
|
|
|
/// A corner size evaluated against the document's parameters, which every corner command needs
|
|
/// to be strictly positive. A bare number means the design's units for a length and degrees for
|
|
/// an angle, as every other typed sketch value does. `value` above is the Move window's reading
|
|
/// of a field, where an empty row means zero and a negative distance is a direction; neither is
|
|
/// a radius or a chamfer leg, so this one is its own.
|
|
fn corner_size(app: &SolveApp, label: &str, text: &str, kind: ValueKind) -> Result<f64, String> {
|
|
let doc = &app.session.doc;
|
|
let v = doc.eval(&doc.with_design_unit(text.trim(), kind), kind).map_err(|e| format!("{label}: {e}"))?;
|
|
if !v.is_finite() || v <= 1e-9 {
|
|
return Err(format!("{label} must be a positive number"));
|
|
}
|
|
Ok(v)
|
|
}
|
|
|
|
/// The corner as a chamfer command takes it. Unflipped it is the point itself, and the command
|
|
/// resolves the two lines in curve order. Flipped it names those two lines in the other order,
|
|
/// so `distance` applies to the other leg.
|
|
fn corner_ref(app: &SolveApp, point: &str, flip: bool) -> Result<Value, String> {
|
|
if !flip {
|
|
return Ok(json!({ "point": point }));
|
|
}
|
|
let gone = || "that corner is no longer in the sketch".to_string();
|
|
let sketch = app.session.active_sketch.ok_or("open a sketch first")?;
|
|
let state = app.session.world_state();
|
|
let sk = &state.sketch(sketch).ok_or_else(gone)?.sketch;
|
|
let index = sk.resolve_point(point).ok_or_else(gone)?;
|
|
let (a, b) = crate::sketch_tools::corner_curves(sk, index, false).ok_or("a chamfer needs a corner of exactly two lines")?;
|
|
let id = |c: usize| sk.curves.get(c).map(|x| x.id.clone()).ok_or_else(gone);
|
|
Ok(json!({"a": id(b)?, "b": id(a)?}))
|
|
}
|
|
|
|
/// A typed total angle as the command wants it, in degrees. A bare number means degrees, as a
|
|
/// typed angle does everywhere else. `sketch.pattern.circular` takes the full turn as 360 and
|
|
/// refuses anything outside that range.
|
|
fn total_angle(app: &SolveApp, label: &str, text: &str) -> Result<f64, String> {
|
|
let v = app.session.doc.eval(text.trim(), ValueKind::Angle).map_err(|e| format!("{label}: {e}"))?.to_degrees();
|
|
if !v.is_finite() || v.abs() <= 1e-9 || v.abs() > 360.0 {
|
|
return Err(format!("{label} must be more than 0 and at most 360 degrees"));
|
|
}
|
|
Ok(v)
|
|
}
|
|
|
|
/// The bare sketch point id of a single point pick, as a command's `center` or `base` reads it.
|
|
/// `point_id` above takes one `Sel`; this one takes the first pick of an input.
|
|
fn picked_point(session: &solidcraft_engine::Session, inputs: &[SelInput], i: usize) -> Option<String> {
|
|
items(inputs, i).first().and_then(|sel| point_id(session, sel)).map(str::to_string)
|
|
}
|
|
|
|
/// The command a sketch modify dialog makes. Scale, Move, and patterns send evaluated
|
|
/// numbers. Offset and Fillet retain expressions in persistent driving dimensions.
|
|
pub fn commands(app: &SolveApp, k: &Mod, inputs: &[SelInput], extra: &Map<String, Value>) -> Result<Vec<(String, Value)>, String> {
|
|
if inputs.iter().flat_map(|input| &input.items).any(|sel| matches!(sel, Sel::SketchPoint { .. }) && point_id(&app.session, sel).is_none()) {
|
|
return Err("Select points of the sketch being edited".into());
|
|
}
|
|
let (cmd, mut params) = match k {
|
|
Mod::Scale { factor: text } => {
|
|
let entities = entity_ids(&app.session, inputs, 0);
|
|
if entities.is_empty() {
|
|
return Err("Select the sketch curves or points to scale first".into());
|
|
}
|
|
let f = factor(app, text)?;
|
|
let mut p = json!({"entities": entities, "factor": f});
|
|
if let Some(base) = items(inputs, 1).first().and_then(|sel| point_id(&app.session, sel)) {
|
|
p["base"] = json!(base);
|
|
}
|
|
("sketch.scale", p)
|
|
}
|
|
Mod::Offset { distance, distance2, mode, chain, flip } => {
|
|
let curves = curve_ids(inputs, 0);
|
|
if curves.is_empty() {
|
|
return Err("Select the curves to offset first".into());
|
|
}
|
|
// Symmetric hides Flip, so a flip left over from another mode does not travel with it.
|
|
signed_length(app, "Distance", distance, *flip && *mode != SYMMETRIC)?;
|
|
let mut params = json!({"curves": curves, "distance": distance.trim(), "flip": *flip && *mode != SYMMETRIC, "chain": chain});
|
|
if let Value::Object(m) = &mut params {
|
|
if *mode == SYMMETRIC {
|
|
m.insert("symmetric".into(), json!(true));
|
|
}
|
|
if *mode == TWO_SIDES {
|
|
let d2 = signed_length(app, "Distance 2", distance2, false)?;
|
|
m.insert("distance2".into(), json!(if d2 > 0.0 { distance2.trim().to_string() } else { format!("-({})", distance2.trim()) }));
|
|
}
|
|
}
|
|
("sketch.offset", params)
|
|
}
|
|
Mod::Mirror => {
|
|
let entities = entity_ids(&app.session, inputs, 0);
|
|
if entities.is_empty() {
|
|
return Err("Select the sketch curves or points to mirror first".into());
|
|
}
|
|
// The AXES mask also takes an origin axis, which is not a curve of this sketch and
|
|
// cannot be a mirror line.
|
|
let line = match items(inputs, 1).first() {
|
|
Some(Sel::SketchCurve { id }) => id.clone(),
|
|
Some(_) => return Err("The mirror line must be a straight curve of this sketch, not an origin axis".into()),
|
|
None => return Err("Select the mirror line".into()),
|
|
};
|
|
if entities.contains(&line) {
|
|
return Err("The mirror line cannot also be mirrored".into());
|
|
}
|
|
("sketch.mirror", json!({"entities": entities, "line": line}))
|
|
}
|
|
Mod::Move { x, y, angle, copy } => {
|
|
let entities = entity_ids(&app.session, inputs, 0);
|
|
if entities.is_empty() {
|
|
return Err("Select the sketch curves or points to move first".into());
|
|
}
|
|
let dx = value(app, x, ValueKind::Length, "X Distance")?;
|
|
let dy = value(app, y, ValueKind::Length, "Y Distance")?;
|
|
let deg = value(app, angle, ValueKind::Angle, "Angle")?.to_degrees();
|
|
if dx.abs() < 1e-9 && dy.abs() < 1e-9 && deg.abs() < 1e-9 {
|
|
return Err("Type a distance or an angle to move by".into());
|
|
}
|
|
let mut p = json!({"entities": entities, "translate": [dx, dy], "angle": deg, "copy": copy});
|
|
if let Some(center) = items(inputs, 1).first().and_then(|sel| point_id(&app.session, sel)) {
|
|
p["center"] = json!(center);
|
|
}
|
|
("sketch.move", p)
|
|
}
|
|
Mod::PatternRect { count, spacing, count2, spacing2, alignment, alignment2, reversed, reversed2 } => {
|
|
let entities = entity_ids(&app.session, 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, "alignment": alignment, "alignment2": alignment2, "reversed": reversed, "reversed2": reversed2});
|
|
// A picked direction that has no component in the sketch plane is reported rather
|
|
// than quietly replaced by the default.
|
|
for (index, vector_key, line_key) in [(1, "dir", "direction"), (2, "dir2", "direction2")] {
|
|
if let Some(sel) = inputs.get(index).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[vector_key] = json!([d.x, d.y]);
|
|
p[line_key] = match sel {
|
|
Sel::SketchCurve { id } => json!(id),
|
|
_ => Value::Null,
|
|
};
|
|
} else {
|
|
p[line_key] = Value::Null;
|
|
}
|
|
}
|
|
("sketch.pattern.rectangular", p)
|
|
}
|
|
Mod::PatternCirc { count, angle, alignment, reversed } => {
|
|
let entities = entity_ids(&app.session, inputs, 0);
|
|
if entities.is_empty() {
|
|
return Err("Select the sketch curves or points to pattern first".into());
|
|
}
|
|
let center = match items(inputs, 1).first() {
|
|
Some(Sel::Vertex { body, point }) if body.is_empty() => {
|
|
let plane =
|
|
app.session.world_state().sketch(app.session.active_sketch.ok_or("open a sketch first")?).ok_or("sketch is missing")?.plane;
|
|
let p = plane.to_local(*point);
|
|
json!([p.x, p.y])
|
|
}
|
|
_ => json!(picked_point(&app.session, inputs, 1).ok_or("Select the centre the pattern turns around first")?),
|
|
};
|
|
let n = whole(app, "Quantity", count)?;
|
|
if n < 2 {
|
|
return Err("Quantity must be at least 2".into());
|
|
}
|
|
let a = total_angle(app, "Total angle", angle)?;
|
|
(
|
|
"sketch.pattern.circular",
|
|
json!({"entities": entities, "center": center, "count": n, "angle": a, "alignment": alignment, "reversed": reversed}),
|
|
)
|
|
}
|
|
Mod::Corner { chamfer: false, size, .. } => {
|
|
let corners = corner_ids(app, inputs, 0);
|
|
let lines = line_ids(inputs, 0);
|
|
// The radius is checked where it is evaluated, against the document's parameters.
|
|
match (corners.is_empty(), lines.as_slice()) {
|
|
(true, []) => return Err("select the corners to round first".into()),
|
|
(false, []) => ("sketch.fillet", json!({"points": corners, "radius": size.trim()})),
|
|
// Two lines name their corner even where other lines end at its point (#1824).
|
|
(true, [a, b]) => ("sketch.fillet", json!({"a": a, "b": b, "radius": size.trim()})),
|
|
_ => return Err("select corner points, or the two lines of one corner".into()),
|
|
}
|
|
}
|
|
Mod::Corner { ctype, size, distance2, angle, flip, .. } => {
|
|
let flipped = *flip && *ctype > 0;
|
|
let mut p = match (corner_ids(app, inputs, 0).as_slice(), line_ids(inputs, 0).as_slice()) {
|
|
([point], []) => corner_ref(app, point, flipped)?,
|
|
// Flip swaps the lines, so `distance` applies to the other leg.
|
|
([], [a, b]) if flipped => json!({"a": b, "b": a}),
|
|
([], [a, b]) => json!({"a": a, "b": b}),
|
|
([], []) => return Err("select the corner to cut first".into()),
|
|
_ => return Err("select one corner point, or the two lines of one corner".into()),
|
|
};
|
|
p["distance"] = json!(corner_size(app, size_label(true, *ctype), size, ValueKind::Length)?);
|
|
match ctype {
|
|
1 => {
|
|
p["distance2"] = json!(corner_size(app, "Distance 2", distance2, ValueKind::Length)?);
|
|
("sketch.chamfer.two_distance", p)
|
|
}
|
|
2 => {
|
|
// The command takes degrees; an evaluated angle is in radians.
|
|
p["angle"] = json!(corner_size(app, "Angle", angle, ValueKind::Angle)?.to_degrees());
|
|
("sketch.chamfer.distance_angle", p)
|
|
}
|
|
_ => ("sketch.chamfer.equal_distance", p),
|
|
}
|
|
}
|
|
};
|
|
if let Value::Object(m) = &mut params {
|
|
for (key, value) in extra {
|
|
m.entry(key.clone()).or_insert_with(|| value.clone());
|
|
}
|
|
}
|
|
Ok(vec![(cmd.to_string(), params)])
|
|
}
|