Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2107 lines
91 KiB
Rust
2107 lines
91 KiB
Rust
//! Command dialogs (shown at the right of the viewport). A dialog collects values and selection
|
||
//! inputs (geometry picked in the viewport, never typed), then runs its command; it never
|
||
//! changes the design itself. Things selected before the command starts become its input.
|
||
|
||
use std::hash::{Hash, Hasher};
|
||
|
||
use egui::{Color32, RichText, vec2};
|
||
use serde_json::{Value, json};
|
||
use solvecraft_engine::Sel;
|
||
use solvecraft_engine::Session;
|
||
use solvecraft_engine::doc::expr::Kind as ValueKind;
|
||
use solvecraft_engine::doc::{AxisRef, Direction, FeatureKind, HoleKind, Operation, PatternKind, PlaneRef, ProfileSel};
|
||
use solvecraft_engine::geom::Vec3;
|
||
|
||
use crate::SolveApp;
|
||
use crate::selection::{self, AXES, Accept, BODIES, CURVES, EDGES, FACES, PLANAR_FACES, PLANES, PROFILES, SelInput};
|
||
use crate::theme::Tokens;
|
||
use crate::viewport::Hit;
|
||
|
||
const OPS: [&str; 4] = ["new", "join", "cut", "intersect"];
|
||
const OP_LABELS: [&str; 4] = ["New Body", "Join", "Cut", "Intersect"];
|
||
const DIRS: [&str; 4] = ["positive", "negative", "symmetric", "positive"];
|
||
const DIR_LABELS: [&str; 4] = ["One side", "Flip", "Symmetric", "Two sides"];
|
||
/// The "Two sides" direction (a second distance the other way).
|
||
pub const TWO_SIDES: usize = 3;
|
||
const HOLE_TYPES: [&str; 3] = ["simple", "counterbore", "countersink"];
|
||
const HOLE_LABELS: [&str; 3] = ["Simple", "Counterbore", "Countersink"];
|
||
|
||
#[derive(Clone, Debug)]
|
||
pub enum Kind {
|
||
/// Pick a plane or planar face; the sketch starts on it at once.
|
||
Sketch,
|
||
Extrude {
|
||
distance: String,
|
||
direction: usize,
|
||
operation: usize,
|
||
/// The operation follows the geometry (into a body: cut, out of one: join, else new)
|
||
/// until the user picks one.
|
||
auto_op: bool,
|
||
/// The other side's distance (two sides).
|
||
distance2: String,
|
||
/// Taper angle (empty: none).
|
||
taper: String,
|
||
},
|
||
Revolve {
|
||
angle: String,
|
||
operation: usize,
|
||
},
|
||
Fillet {
|
||
radius: String,
|
||
chamfer: bool,
|
||
chain: bool,
|
||
},
|
||
Shell {
|
||
thickness: String,
|
||
},
|
||
Draft {
|
||
angle: String,
|
||
},
|
||
Mirror,
|
||
/// Rectangular pattern of bodies: along one direction, optionally a second.
|
||
PatternRect {
|
||
count: String,
|
||
spacing: String,
|
||
count2: String,
|
||
spacing2: String,
|
||
},
|
||
/// Circular pattern of bodies around an axis.
|
||
PatternCirc {
|
||
count: String,
|
||
angle: String,
|
||
},
|
||
/// Loft through profiles of different sketches, in pick order.
|
||
Loft {
|
||
operation: usize,
|
||
},
|
||
/// Sweep a profile along a path of sketch curves.
|
||
Sweep {
|
||
operation: usize,
|
||
},
|
||
/// A construction plane offset from a plane.
|
||
OffsetPlane {
|
||
offset: String,
|
||
},
|
||
/// A construction plane through an axis at an angle to a plane.
|
||
AnglePlane {
|
||
angle: String,
|
||
},
|
||
/// Split a body by a plane.
|
||
Split,
|
||
/// Scale bodies uniformly.
|
||
Scale {
|
||
factor: String,
|
||
},
|
||
/// Move planar faces along their normals.
|
||
OffsetFaces {
|
||
distance: String,
|
||
},
|
||
/// A cosmetic thread on a cylindrical face.
|
||
Thread {
|
||
designation: String,
|
||
length: String,
|
||
},
|
||
/// A rib (one wall along open curves) or a web (a wall per curve).
|
||
Rib {
|
||
web: bool,
|
||
thickness: String,
|
||
depth: String,
|
||
flip: bool,
|
||
},
|
||
/// Raise or sink profiles of a sketch on a face.
|
||
Emboss {
|
||
depth: String,
|
||
deboss: bool,
|
||
},
|
||
/// Move planar faces onto a target plane or face.
|
||
ReplaceFace,
|
||
/// Move bodies so a face or point meets another.
|
||
Align {
|
||
flip: bool,
|
||
},
|
||
/// Remove bodies from here on in the timeline.
|
||
Remove,
|
||
/// Pattern bodies along a path of sketch curves.
|
||
PathPattern {
|
||
count: String,
|
||
spacing: String,
|
||
},
|
||
/// A pipe (round, optionally hollow) along sketch curves.
|
||
Pipe {
|
||
diameter: String,
|
||
wall: String,
|
||
},
|
||
/// A stock block around bodies.
|
||
Stock {
|
||
margin: String,
|
||
},
|
||
/// Application preferences (applied as they change; kept between runs).
|
||
Preferences,
|
||
/// Physical material (and with it the appearance) of bodies.
|
||
Material {
|
||
index: usize,
|
||
},
|
||
/// Section Analysis: cut the view by a plane, moved along its normal.
|
||
Section {
|
||
offset: String,
|
||
flip: bool,
|
||
},
|
||
/// Measure one or two picked items (vertices, edges, faces): the result of the last
|
||
/// measurement and the items it was for.
|
||
Measure {
|
||
result: Option<Value>,
|
||
of: Vec<Sel>,
|
||
},
|
||
/// Move bodies by a distance along X, Y and Z.
|
||
Move {
|
||
x: String,
|
||
y: String,
|
||
z: String,
|
||
},
|
||
Hole {
|
||
diameter: String,
|
||
depth: String,
|
||
kind: usize,
|
||
cb_diameter: String,
|
||
cb_depth: String,
|
||
cs_diameter: String,
|
||
cs_angle: String,
|
||
},
|
||
Primitive {
|
||
cmd: &'static str,
|
||
fields: Vec<(&'static str, String)>,
|
||
operation: usize,
|
||
},
|
||
Combine {
|
||
operation: usize,
|
||
keep_tools: bool,
|
||
},
|
||
Params {
|
||
new_name: String,
|
||
new_expr: String,
|
||
},
|
||
EditParam {
|
||
name: String,
|
||
expr: String,
|
||
},
|
||
Rename {
|
||
feature: u64,
|
||
name: String,
|
||
},
|
||
/// Delete a feature that others depend on.
|
||
ConfirmDelete {
|
||
feature: u64,
|
||
with: Vec<String>,
|
||
fail: Vec<String>,
|
||
},
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
pub struct Dialog {
|
||
pub kind: Kind,
|
||
/// Selection inputs, in order; clicks in the viewport go to the active one.
|
||
pub inputs: Vec<SelInput>,
|
||
pub active: usize,
|
||
pub error: Option<String>,
|
||
/// Editing an existing feature (its id, and the timeline marker to restore afterwards).
|
||
pub editing: Option<(u64, Option<usize>)>,
|
||
/// Give the on-canvas value box the keyboard (and select its text) on the next frame.
|
||
pub focus: bool,
|
||
/// Parameters the dialog doesn't show but the command needs (kept when editing).
|
||
pub extra: serde_json::Map<String, Value>,
|
||
}
|
||
|
||
/// Can a selection go into an input that accepts `a`? (Planar-ness is checked when picking.)
|
||
fn fits(a: Accept, s: &Sel) -> bool {
|
||
match s {
|
||
Sel::Profile { .. } => a & PROFILES != 0,
|
||
Sel::Edge { .. } => a & EDGES != 0,
|
||
Sel::Face { .. } => a & (FACES | PLANAR_FACES) != 0,
|
||
Sel::Body { .. } => a & BODIES != 0,
|
||
Sel::Plane { .. } => a & PLANES != 0,
|
||
Sel::Axis { .. } => a & AXES != 0,
|
||
Sel::SketchCurve { .. } => a & (AXES | CURVES) != 0,
|
||
Sel::Vertex { .. } => a & selection::VERTICES != 0,
|
||
Sel::Feature { .. } | Sel::SketchPoint { .. } => false,
|
||
}
|
||
}
|
||
|
||
impl Dialog {
|
||
fn new(kind: Kind, inputs: Vec<SelInput>) -> Dialog {
|
||
Dialog { kind, inputs, active: 0, error: None, editing: None, focus: true, extra: serde_json::Map::new() }
|
||
}
|
||
|
||
pub fn for_command(app: &SolveApp, id: &str) -> Option<Dialog> {
|
||
let s = &app.session;
|
||
let has_bodies = !s.model.state().bodies.is_empty();
|
||
let mut d = match id {
|
||
"SketchCreate" => Dialog::new(Kind::Sketch, vec![SelInput::new("Plane", PLANES | PLANAR_FACES, false)]),
|
||
"Extrude" => Dialog::new(
|
||
Kind::Extrude {
|
||
distance: "10 mm".into(),
|
||
direction: 0,
|
||
operation: usize::from(has_bodies),
|
||
auto_op: true,
|
||
distance2: "10 mm".into(),
|
||
taper: String::new(),
|
||
},
|
||
vec![SelInput::new("Profiles", PROFILES | PLANAR_FACES, true)],
|
||
),
|
||
"Revolve" => Dialog::new(
|
||
Kind::Revolve { angle: "360 deg".into(), operation: 0 },
|
||
vec![SelInput::new("Profiles", PROFILES, true), SelInput::new("Axis", AXES, false)],
|
||
),
|
||
"FusionFilletEdgesCommand" => {
|
||
Dialog::new(Kind::Fillet { radius: "2 mm".into(), chamfer: false, chain: true }, vec![SelInput::new("Edges", EDGES | FACES, true)])
|
||
}
|
||
"FusionChamferCommand" => {
|
||
Dialog::new(Kind::Fillet { radius: "1 mm".into(), chamfer: true, chain: true }, vec![SelInput::new("Edges", EDGES | FACES, true)])
|
||
}
|
||
"FusionShellBodyCommand" => Dialog::new(Kind::Shell { thickness: "2 mm".into() }, vec![SelInput::new("Faces", FACES, true)]),
|
||
"FusionDraftCommand" => Dialog::new(
|
||
Kind::Draft { angle: "5 deg".into() },
|
||
vec![SelInput::new("Faces", FACES, true), SelInput::new("Neutral plane", PLANES | PLANAR_FACES, false)],
|
||
),
|
||
"MirrorCommand" => {
|
||
Dialog::new(Kind::Mirror, vec![SelInput::new("Bodies", BODIES, true), SelInput::new("Mirror plane", PLANES | PLANAR_FACES, false)])
|
||
}
|
||
"FusionRibCommand" | "FusionWebCommand" => Dialog::new(
|
||
Kind::Rib { web: id == "FusionWebCommand", thickness: "2 mm".into(), depth: String::new(), flip: false },
|
||
vec![SelInput::new("Curves", CURVES, true)],
|
||
),
|
||
"EmbossCmd" => Dialog::new(Kind::Emboss { depth: "1 mm".into(), deboss: false }, vec![SelInput::new("Profiles", PROFILES, true)]),
|
||
"FusionReplaceFaceCommand" => Dialog::new(
|
||
Kind::ReplaceFace,
|
||
vec![SelInput::new("Faces", PLANAR_FACES, true), SelInput::new("Target", PLANES | PLANAR_FACES, false)],
|
||
),
|
||
"AlignCmd" => Dialog::new(
|
||
Kind::Align { flip: false },
|
||
vec![
|
||
SelInput::new("Bodies", BODIES, true),
|
||
SelInput::new("From", FACES | selection::VERTICES, false),
|
||
SelInput::new("To", FACES | selection::VERTICES, false),
|
||
],
|
||
),
|
||
"SoftDeleteCommand" => Dialog::new(Kind::Remove, vec![SelInput::new("Bodies", BODIES, true)]),
|
||
"PatternOnPath" => Dialog::new(
|
||
Kind::PathPattern { count: "4".into(), spacing: "20 mm".into() },
|
||
vec![SelInput::new("Objects", BODIES, true), SelInput::new("Path", CURVES, true)],
|
||
),
|
||
"PrimitivePipe" => Dialog::new(Kind::Pipe { diameter: "5 mm".into(), wall: String::new() }, vec![SelInput::new("Path", CURVES, true)]),
|
||
"StockModelCommand" => Dialog::new(Kind::Stock { margin: "2 mm".into() }, vec![SelInput::new("Bodies (all if none)", BODIES, true)]),
|
||
"ConstructionPlaneOffsetFromPlaneCommand" => {
|
||
Dialog::new(Kind::OffsetPlane { offset: "10 mm".into() }, vec![SelInput::new("Plane", PLANES, false)])
|
||
}
|
||
"ConstructionPlaneAtAngleCommand" => Dialog::new(
|
||
Kind::AnglePlane { angle: "45 deg".into() },
|
||
vec![SelInput::new("Plane", PLANES, false), SelInput::new("Axis", AXES, false)],
|
||
),
|
||
"FusionSplitBodyCommand" => Dialog::new(
|
||
Kind::Split,
|
||
vec![SelInput::new("Body to split", BODIES, false), SelInput::new("Splitting plane", PLANES | PLANAR_FACES, false)],
|
||
),
|
||
"ModifyScale" => Dialog::new(Kind::Scale { factor: "2".into() }, vec![SelInput::new("Bodies", BODIES, true)]),
|
||
"FusionOffsetFacesCommand" => {
|
||
Dialog::new(Kind::OffsetFaces { distance: "2 mm".into() }, vec![SelInput::new("Faces", PLANAR_FACES, true)])
|
||
}
|
||
"FusionThreadCommand" => {
|
||
Dialog::new(Kind::Thread { designation: String::new(), length: String::new() }, vec![SelInput::new("Cylindrical face", FACES, false)])
|
||
}
|
||
"PhysicalMaterialCommand" => Dialog::new(Kind::Material { index: 1 }, vec![SelInput::new("Bodies", BODIES, true)]),
|
||
"FusionHalfSectionViewCommand" => {
|
||
Dialog::new(Kind::Section { offset: "0 mm".into(), flip: false }, vec![SelInput::new("Plane", PLANES | PLANAR_FACES, false)])
|
||
}
|
||
"MeasureCommand" => {
|
||
Dialog::new(Kind::Measure { result: None, of: Vec::new() }, vec![SelInput::new("Items", FACES | EDGES | selection::VERTICES, true)])
|
||
}
|
||
"PatternRectangular" => Dialog::new(
|
||
Kind::PatternRect { count: "3".into(), spacing: "20 mm".into(), count2: "1".into(), spacing2: "20 mm".into() },
|
||
vec![SelInput::new("Objects", BODIES, true), SelInput::new("Direction", AXES, false), SelInput::new("Direction 2", AXES, false)],
|
||
),
|
||
"PatternCircular" => Dialog::new(
|
||
Kind::PatternCirc { count: "6".into(), angle: "360 deg".into() },
|
||
vec![SelInput::new("Objects", BODIES, true), SelInput::new("Axis", AXES, false)],
|
||
),
|
||
"SolidLoft" => Dialog::new(Kind::Loft { operation: usize::from(has_bodies) }, vec![SelInput::new("Profiles", PROFILES, true)]),
|
||
"Sweep" => Dialog::new(
|
||
Kind::Sweep { operation: usize::from(has_bodies) },
|
||
vec![SelInput::new("Profile", PROFILES, true), SelInput::new("Path", CURVES, true)],
|
||
),
|
||
"FusionMoveCommand" => {
|
||
Dialog::new(Kind::Move { x: "0 mm".into(), y: "0 mm".into(), z: "10 mm".into() }, vec![SelInput::new("Bodies", BODIES, true)])
|
||
}
|
||
"FusionHoleCommand" => Dialog::new(hole_defaults(), vec![SelInput::new("Position", FACES, true)]),
|
||
"PrimitiveBox" => Dialog::new(
|
||
Kind::Primitive {
|
||
cmd: "PrimitiveBox",
|
||
fields: vec![("length", "20".into()), ("width", "20".into()), ("height", "20".into())],
|
||
operation: 0,
|
||
},
|
||
vec![],
|
||
),
|
||
"PrimitiveCylinder" => Dialog::new(
|
||
Kind::Primitive { cmd: "PrimitiveCylinder", fields: vec![("diameter", "20".into()), ("height", "20".into())], operation: 0 },
|
||
vec![],
|
||
),
|
||
"PrimitiveSphere" => {
|
||
Dialog::new(Kind::Primitive { cmd: "PrimitiveSphere", fields: vec![("diameter", "20".into())], operation: 0 }, vec![])
|
||
}
|
||
"PrimitiveTorus" => Dialog::new(
|
||
Kind::Primitive { cmd: "PrimitiveTorus", fields: vec![("major", "20".into()), ("minor", "5".into())], operation: 0 },
|
||
vec![],
|
||
),
|
||
"FusionCombineCommand" => Dialog::new(
|
||
Kind::Combine { operation: 1, keep_tools: false },
|
||
vec![SelInput::new("Target body", BODIES, false), SelInput::new("Tool bodies", BODIES, true)],
|
||
),
|
||
"ChangeParameterCommand" => Dialog::new(Kind::Params { new_name: String::new(), new_expr: String::new() }, vec![]),
|
||
_ => return None,
|
||
};
|
||
// Pre-selection: what is selected now becomes the input (first input that takes it); a
|
||
// face or edge stands for its body where bodies are wanted.
|
||
for sel in &s.selection {
|
||
let as_body = match sel {
|
||
Sel::Face { body, .. } | Sel::Edge { body, .. } | Sel::Vertex { body, .. } => Some(Sel::Body { name: body.clone() }),
|
||
_ => None,
|
||
};
|
||
if let Some(b) = as_body
|
||
&& let Some(inp) = d.inputs.iter_mut().find(|i| i.accept == BODIES && (i.multi || i.items.is_empty()))
|
||
{
|
||
if !inp.items.contains(&b) {
|
||
inp.items.push(b);
|
||
}
|
||
continue;
|
||
}
|
||
if let Some(inp) = d.inputs.iter_mut().find(|i| fits(i.accept, sel) && (i.multi || i.items.is_empty())) {
|
||
inp.items.push(sel.clone());
|
||
}
|
||
}
|
||
// With nothing pre-selected, a single profile in the sketch being used is taken.
|
||
if matches!(d.kind, Kind::Extrude { .. } | Kind::Revolve { .. })
|
||
&& let Some(inp) = d.inputs.first_mut()
|
||
&& inp.items.is_empty()
|
||
{
|
||
inp.items = default_profiles(s);
|
||
}
|
||
d.advance();
|
||
Some(d)
|
||
}
|
||
|
||
pub fn preferences() -> Dialog {
|
||
Dialog::new(Kind::Preferences, vec![])
|
||
}
|
||
|
||
pub fn rename(feature: u64, name: &str) -> Dialog {
|
||
Dialog::new(Kind::Rename { feature, name: name.to_string() }, vec![])
|
||
}
|
||
|
||
pub fn confirm_delete(feature: u64, with: Vec<String>, fail: Vec<String>) -> Dialog {
|
||
Dialog::new(Kind::ConfirmDelete { feature, with, fail }, vec![])
|
||
}
|
||
|
||
pub fn edit_param(s: &Session, name: &str) -> Dialog {
|
||
Dialog::new(Kind::EditParam { name: name.into(), expr: s.doc.param(name).map(|p| p.expr.clone()).unwrap_or_default() }, vec![])
|
||
}
|
||
|
||
/// Make the first single input still empty (after the filled ones) active.
|
||
fn advance(&mut self) {
|
||
if let Some(i) = self.inputs.iter().position(|i| i.items.is_empty()) {
|
||
self.active = i;
|
||
} else {
|
||
self.active = self.active.min(self.inputs.len().saturating_sub(1));
|
||
}
|
||
}
|
||
|
||
/// Does this dialog make a feature that can be previewed live?
|
||
pub fn previews(&self) -> bool {
|
||
matches!(
|
||
self.kind,
|
||
Kind::Extrude { .. }
|
||
| Kind::Revolve { .. }
|
||
| Kind::Fillet { .. }
|
||
| Kind::Shell { .. }
|
||
| Kind::Draft { .. }
|
||
| Kind::Mirror
|
||
| Kind::Split
|
||
| Kind::Rib { .. }
|
||
| Kind::Emboss { .. }
|
||
| Kind::ReplaceFace
|
||
| Kind::Align { .. }
|
||
| Kind::Remove
|
||
| Kind::PathPattern { .. }
|
||
| Kind::Pipe { .. }
|
||
| Kind::Stock { .. }
|
||
| Kind::Scale { .. }
|
||
| Kind::OffsetFaces { .. }
|
||
| Kind::PatternRect { .. }
|
||
| Kind::PatternCirc { .. }
|
||
| Kind::Loft { .. }
|
||
| Kind::Sweep { .. }
|
||
| Kind::Move { .. }
|
||
| Kind::Hole { .. }
|
||
| Kind::Primitive { .. }
|
||
| Kind::Combine { .. }
|
||
)
|
||
}
|
||
|
||
/// The main value (shown on the canvas too): label, kind and the expression.
|
||
pub fn primary(&mut self) -> Option<(&'static str, ValueKind, &mut String)> {
|
||
Some(match &mut self.kind {
|
||
Kind::Extrude { distance, .. } => ("Distance", ValueKind::Length, distance),
|
||
Kind::Revolve { angle, .. } => ("Angle", ValueKind::Angle, angle),
|
||
Kind::Fillet { radius, chamfer, .. } => (if *chamfer { "Distance" } else { "Radius" }, ValueKind::Length, radius),
|
||
Kind::Shell { thickness } => ("Thickness", ValueKind::Length, thickness),
|
||
Kind::Draft { angle } => ("Angle", ValueKind::Angle, angle),
|
||
Kind::Hole { diameter, .. } => ("Diameter", ValueKind::Length, diameter),
|
||
Kind::Move { z, .. } => ("Z", ValueKind::Length, z),
|
||
Kind::PatternRect { spacing, .. } => ("Spacing", ValueKind::Length, spacing),
|
||
Kind::PatternCirc { angle, .. } => ("Angle", ValueKind::Angle, angle),
|
||
Kind::Section { offset, .. } => ("Distance", ValueKind::Length, offset),
|
||
Kind::OffsetPlane { offset } => ("Distance", ValueKind::Length, offset),
|
||
Kind::Rib { thickness, .. } => ("Thickness", ValueKind::Length, thickness),
|
||
Kind::Emboss { depth, .. } => ("Depth", ValueKind::Length, depth),
|
||
Kind::PathPattern { spacing, .. } => ("Spacing", ValueKind::Length, spacing),
|
||
Kind::Pipe { diameter, .. } => ("Diameter", ValueKind::Length, diameter),
|
||
Kind::Stock { margin } => ("Margin", ValueKind::Length, margin),
|
||
Kind::AnglePlane { angle } => ("Angle", ValueKind::Angle, angle),
|
||
Kind::Scale { factor } => ("Scale", ValueKind::Unitless, factor),
|
||
Kind::OffsetFaces { distance } => ("Distance", ValueKind::Length, distance),
|
||
_ => return None,
|
||
})
|
||
}
|
||
|
||
pub fn wants_picks(&self) -> bool {
|
||
!self.inputs.is_empty()
|
||
}
|
||
|
||
pub fn active_input(&self) -> Option<&SelInput> {
|
||
self.inputs.get(self.active)
|
||
}
|
||
|
||
/// Everything the inputs hold (for highlighting).
|
||
pub fn items(&self) -> Vec<Sel> {
|
||
self.inputs.iter().flat_map(|i| i.items.iter().cloned()).collect()
|
||
}
|
||
|
||
pub fn highlight_key(&self) -> u64 {
|
||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||
serde_json::to_string(&self.items()).unwrap_or_default().hash(&mut h);
|
||
self.active.hash(&mut h);
|
||
h.finish()
|
||
}
|
||
|
||
/// The selection a hit would add to the active input.
|
||
pub fn candidate(&self, s: &Session, h: &Hit) -> Option<Sel> {
|
||
self.active_input()?.accepts(h, |body, face| planar_face(s, body, face).is_some())
|
||
}
|
||
|
||
/// A pick in the viewport (already accepted by the active input).
|
||
pub fn pick(&mut self, s: &Session, sel: Sel) {
|
||
let chain = matches!(self.kind, Kind::Fillet { chain: true, .. });
|
||
let mut picked = vec![sel.clone()];
|
||
// Tangent chain: an edge brings the edges that continue it smoothly.
|
||
if chain
|
||
&& let Sel::Edge { body, index, .. } = &sel
|
||
&& let Some(b) = s.model.state().body(body)
|
||
{
|
||
let m = b.mesh();
|
||
picked = m
|
||
.tangent_chain(*index, 2f64.to_radians())
|
||
.into_iter()
|
||
.filter_map(|i| m.edges.get(i).map(|e| Sel::Edge { body: body.clone(), index: i, point: polyline_mid(e) }))
|
||
.collect();
|
||
}
|
||
// Extrude and revolve take profiles of one sketch.
|
||
if let Sel::Profile { sketch, .. } = &sel
|
||
&& !matches!(self.kind, Kind::Loft { .. })
|
||
&& let Some(inp) = self.inputs.get_mut(self.active)
|
||
{
|
||
inp.items.retain(|x| !matches!(x, Sel::Profile { sketch: s2, .. } if s2 != sketch));
|
||
}
|
||
let Some(inp) = self.inputs.get_mut(self.active) else { return };
|
||
inp.toggle(picked);
|
||
self.focus = true;
|
||
// A measurement is between two items: a third pick starts over from the last one.
|
||
if matches!(self.kind, Kind::Measure { .. }) && inp.items.len() > 2 {
|
||
let keep = inp.items.split_off(inp.items.len() - 1);
|
||
inp.items = keep;
|
||
}
|
||
if !inp.multi && !inp.items.is_empty() && self.active + 1 < self.inputs.len() {
|
||
self.advance();
|
||
}
|
||
self.error = None;
|
||
}
|
||
|
||
/// Box selection result: replace (or add to) the active input.
|
||
pub fn take_box(&mut self, sels: Vec<Sel>, add: bool) {
|
||
let Some(inp) = self.inputs.get_mut(self.active) else { return };
|
||
if !add {
|
||
inp.items.clear();
|
||
}
|
||
for x in sels {
|
||
if fits(inp.accept, &x) && !inp.items.contains(&x) {
|
||
inp.items.push(x);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Profiles of the sketch a feature would use (active, else the last), when there is just one.
|
||
fn default_profiles(s: &Session) -> Vec<Sel> {
|
||
let st = s.model.state();
|
||
let sid = s.active_sketch.or_else(|| s.doc.features.iter().rev().find(|f| matches!(f.kind, FeatureKind::Sketch { .. })).map(|f| f.id));
|
||
match sid.and_then(|id| st.sketch(id).map(|ss| (id, ss.profiles.len()))) {
|
||
Some((id, 1)) => vec![Sel::Profile { sketch: id, index: 0 }],
|
||
_ => Vec::new(),
|
||
}
|
||
}
|
||
|
||
/// The plane of a planar body face: (point on it, unit normal).
|
||
pub fn planar_face(s: &Session, body: &str, face: usize) -> Option<(Vec3, Vec3)> {
|
||
let st = s.model.state();
|
||
let b = st.body(body)?;
|
||
let tol = (b.body.size() * 1e-3).max(1e-3);
|
||
let f = b.body.faces(tol).ok()?.into_iter().find(|f| f.index == face)?;
|
||
Some((f.centroid, f.plane_normal?))
|
||
}
|
||
|
||
fn polyline_mid(pts: &[Vec3]) -> Vec3 {
|
||
let len: f64 = pts.windows(2).map(|w| w[0].dist(w[1])).sum();
|
||
let mut acc = 0.0;
|
||
for w in pts.windows(2) {
|
||
let l = w[0].dist(w[1]);
|
||
if acc + l >= len * 0.5 && l > 0.0 {
|
||
return w[0].lerp(w[1], (len * 0.5 - acc) / l);
|
||
}
|
||
acc += l;
|
||
}
|
||
pts.first().copied().unwrap_or_default()
|
||
}
|
||
|
||
fn pt(v: Vec3) -> Value {
|
||
json!([v.x, v.y, v.z])
|
||
}
|
||
|
||
/// A plane input as a command parameter: an origin/construction plane name or a face plane.
|
||
fn plane_value(s: &Session, sel: Option<&Sel>) -> Option<Value> {
|
||
match sel? {
|
||
Sel::Plane { name } => Some(json!(name)),
|
||
Sel::Face { body, index, point } => {
|
||
let (_, n) = planar_face(s, body, *index)?;
|
||
Some(json!({"origin": pt(*point), "normal": pt(n)}))
|
||
}
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// A value field; true when Enter was pressed in it.
|
||
fn field(ui: &mut egui::Ui, s: &mut String) -> bool {
|
||
let r = ui.text_edit_singleline(s);
|
||
r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))
|
||
}
|
||
|
||
fn combo(ui: &mut egui::Ui, id: &str, labels: &[&str], sel: &mut usize) {
|
||
egui::ComboBox::from_id_salt(id).selected_text(labels.get(*sel).copied().unwrap_or("")).width(FIELD_W).show_ui(ui, |ui| {
|
||
for (i, l) in labels.iter().enumerate() {
|
||
ui.selectable_value(sel, i, *l);
|
||
}
|
||
});
|
||
}
|
||
|
||
fn title(k: &Kind) -> &'static str {
|
||
match k {
|
||
Kind::Sketch => "CREATE SKETCH",
|
||
Kind::Extrude { .. } => "EXTRUDE",
|
||
Kind::Revolve { .. } => "REVOLVE",
|
||
Kind::Fillet { chamfer: false, .. } => "FILLET",
|
||
Kind::Fillet { chamfer: true, .. } => "CHAMFER",
|
||
Kind::Shell { .. } => "SHELL",
|
||
Kind::Draft { .. } => "DRAFT",
|
||
Kind::Mirror => "MIRROR",
|
||
Kind::Measure { .. } => "MEASURE",
|
||
Kind::Material { .. } => "PHYSICAL MATERIAL",
|
||
Kind::Preferences => "PREFERENCES",
|
||
Kind::OffsetPlane { .. } => "OFFSET PLANE",
|
||
Kind::Rib { web: false, .. } => "RIB",
|
||
Kind::Rib { web: true, .. } => "WEB",
|
||
Kind::Emboss { .. } => "EMBOSS",
|
||
Kind::ReplaceFace => "REPLACE FACE",
|
||
Kind::Align { .. } => "ALIGN",
|
||
Kind::Remove => "REMOVE",
|
||
Kind::PathPattern { .. } => "PATTERN ON PATH",
|
||
Kind::Pipe { .. } => "PIPE",
|
||
Kind::Stock { .. } => "STOCK",
|
||
Kind::AnglePlane { .. } => "PLANE AT ANGLE",
|
||
Kind::Split => "SPLIT BODY",
|
||
Kind::Scale { .. } => "SCALE",
|
||
Kind::OffsetFaces { .. } => "OFFSET FACE",
|
||
Kind::Thread { .. } => "THREAD",
|
||
Kind::Section { .. } => "SECTION ANALYSIS",
|
||
Kind::PatternRect { .. } => "RECTANGULAR PATTERN",
|
||
Kind::PatternCirc { .. } => "CIRCULAR PATTERN",
|
||
Kind::Loft { .. } => "LOFT",
|
||
Kind::Sweep { .. } => "SWEEP",
|
||
Kind::Move { .. } => "MOVE",
|
||
Kind::Hole { .. } => "HOLE",
|
||
Kind::Primitive { cmd, .. } => match *cmd {
|
||
"PrimitiveBox" => "BOX",
|
||
"PrimitiveCylinder" => "CYLINDER",
|
||
"PrimitiveSphere" => "SPHERE",
|
||
_ => "TORUS",
|
||
},
|
||
Kind::Combine { .. } => "COMBINE",
|
||
Kind::Params { .. } => "PARAMETERS",
|
||
Kind::EditParam { .. } => "EDIT DIMENSION",
|
||
Kind::Rename { .. } => "RENAME",
|
||
Kind::ConfirmDelete { .. } => "DELETE FEATURE",
|
||
}
|
||
}
|
||
|
||
fn hint(inp: &SelInput) -> &'static str {
|
||
let a = inp.accept;
|
||
if a & PROFILES != 0 {
|
||
"click profiles in the view"
|
||
} else if a & EDGES != 0 {
|
||
"click edges or faces"
|
||
} else if a & PLANES != 0 {
|
||
"click a plane or planar face"
|
||
} else if a & AXES != 0 {
|
||
"click an axis or sketch line"
|
||
} else if a & CURVES != 0 {
|
||
"click sketch curves"
|
||
} else if a & BODIES != 0 {
|
||
"click bodies"
|
||
} else {
|
||
"click faces"
|
||
}
|
||
}
|
||
|
||
/// Selection input rows: label, "N selected" (or a hint) and a clear button; clicking a row
|
||
/// makes it the active input.
|
||
fn input_rows(d: &mut Dialog, ui: &mut egui::Ui) {
|
||
let t = Tokens::get();
|
||
let mut clear: Option<usize> = None;
|
||
let mut activate: Option<usize> = None;
|
||
for (i, inp) in d.inputs.iter().enumerate() {
|
||
ui.label(inp.label);
|
||
ui.horizontal(|ui| {
|
||
let active = i == d.active;
|
||
let text = if inp.items.is_empty() {
|
||
RichText::new(hint(inp)).color(if active { t.warning } else { t.text_dim })
|
||
} else {
|
||
RichText::new(format!("{} selected", inp.items.len())).color(t.text)
|
||
};
|
||
// Not in the Tab order: Tab moves between values.
|
||
let b = egui::Button::new(text)
|
||
.fill(if active { t.accent_soft } else { Color32::TRANSPARENT })
|
||
.min_size(vec2(FIELD_W, 22.0))
|
||
.sense(egui::Sense::CLICK);
|
||
if ui.add(b).on_hover_text("Click to make this the input that viewport picks go to").clicked() {
|
||
activate = Some(i);
|
||
}
|
||
if !inp.items.is_empty() && ui.small_button("×").on_hover_text("Clear the selection").clicked() {
|
||
clear = Some(i);
|
||
}
|
||
});
|
||
ui.end_row();
|
||
}
|
||
if let Some(i) = activate {
|
||
d.active = i;
|
||
}
|
||
if let Some(i) = clear
|
||
&& let Some(inp) = d.inputs.get_mut(i)
|
||
{
|
||
inp.items.clear();
|
||
d.active = i;
|
||
}
|
||
}
|
||
|
||
/// Width limits of a docked dialog: shrink-wrapped to its content within these.
|
||
const DIALOG_MIN_W: f32 = 260.0;
|
||
const DIALOG_MAX_W: f32 = 380.0;
|
||
/// Width of value fields and choice boxes in a dialog.
|
||
const FIELD_W: f32 = 140.0;
|
||
|
||
/// Measure again when the picked items changed.
|
||
fn update_measure(app: &SolveApp, d: &mut Dialog) {
|
||
let items: Vec<Sel> = sels(d, 0).to_vec();
|
||
if let Kind::Measure { result, of } = &mut d.kind
|
||
&& *of != items
|
||
{
|
||
*result = if items.is_empty() { None } else { Some(app.session.measure_items(&items)) };
|
||
*of = items;
|
||
}
|
||
}
|
||
|
||
/// The preferences, applied as they change.
|
||
fn preferences_rows(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||
ui.label("Theme");
|
||
let mut theme = usize::from(!app.ui.dark);
|
||
combo(ui, "pref_theme", &["Dark", "Light"], &mut theme);
|
||
app.ui.dark = theme == 0;
|
||
ui.end_row();
|
||
ui.label("Grid");
|
||
ui.checkbox(&mut app.ui.show_grid, "");
|
||
ui.end_row();
|
||
ui.label("Perspective view");
|
||
ui.checkbox(&mut app.ui.perspective, "");
|
||
ui.end_row();
|
||
ui.label("Project face edges into new sketches");
|
||
let mut auto = app.session.auto_project;
|
||
if ui.checkbox(&mut auto, "").changed() {
|
||
let _ = app.run("sketch.auto_project", json!({ "value": auto }));
|
||
}
|
||
ui.end_row();
|
||
ui.label("Click selects whole bodies");
|
||
ui.checkbox(&mut app.ui.pick_bodies, "");
|
||
ui.end_row();
|
||
}
|
||
|
||
fn fmt_mm(v: &Value) -> String {
|
||
v.as_f64().map(|x| format!("{x:.3} mm")).unwrap_or_default()
|
||
}
|
||
|
||
/// The measurement in the dialog.
|
||
fn measure_rows(ui: &mut egui::Ui, r: Option<&Value>) {
|
||
let t = Tokens::get();
|
||
let Some(r) = r else {
|
||
ui.label("");
|
||
ui.label(RichText::new("pick one or two vertices, edges or faces").color(t.text_dim));
|
||
ui.end_row();
|
||
return;
|
||
};
|
||
if let Some(e) = r.get("error").and_then(Value::as_str) {
|
||
ui.label("");
|
||
ui.label(RichText::new(e).color(t.error));
|
||
ui.end_row();
|
||
return;
|
||
}
|
||
for (i, it) in r["items"].as_array().into_iter().flatten().enumerate() {
|
||
let what = match it["type"].as_str() {
|
||
Some("edge") => format!("Length {}", fmt_mm(&it["length_mm"])),
|
||
Some("face") => format!("Area {:.3} mm²", it["area_mm2"].as_f64().unwrap_or(0.0)),
|
||
Some("vertex") => {
|
||
let p = &it["position"];
|
||
format!("At {:.3}, {:.3}, {:.3}", p[0].as_f64().unwrap_or(0.0), p[1].as_f64().unwrap_or(0.0), p[2].as_f64().unwrap_or(0.0))
|
||
}
|
||
_ => String::new(),
|
||
};
|
||
ui.label(format!("Item {}", i + 1));
|
||
ui.label(what);
|
||
ui.end_row();
|
||
}
|
||
if r.get("distance_mm").is_some() {
|
||
ui.label(RichText::new("Distance").strong());
|
||
ui.label(RichText::new(fmt_mm(&r["distance_mm"])).strong());
|
||
ui.end_row();
|
||
let d = &r["delta"];
|
||
for (k, l) in ["ΔX", "ΔY", "ΔZ"].iter().enumerate() {
|
||
ui.label(*l);
|
||
ui.label(format!("{:.3} mm", d[k].as_f64().unwrap_or(0.0).abs()));
|
||
ui.end_row();
|
||
}
|
||
}
|
||
if let Some(a) = r["angle_deg"].as_f64() {
|
||
ui.label(RichText::new("Angle").strong());
|
||
ui.label(RichText::new(format!("{a:.3}°")).strong());
|
||
ui.end_row();
|
||
}
|
||
}
|
||
|
||
/// Keep an automatic extrude operation in step with the geometry.
|
||
fn auto_operation(app: &SolveApp, d: &mut Dialog) {
|
||
if !matches!(d.kind, Kind::Extrude { auto_op: true, .. }) {
|
||
return;
|
||
}
|
||
let Some(op) =
|
||
dialog_commands(app, d).ok().and_then(|c| c.into_iter().next()).and_then(|(_, p)| solvecraft_engine::auto_operation(&app.session, &p))
|
||
else {
|
||
return;
|
||
};
|
||
if let Kind::Extrude { operation, .. } = &mut d.kind
|
||
&& let Some(i) = OPS.iter().position(|o| *o == op)
|
||
{
|
||
*operation = i;
|
||
}
|
||
}
|
||
|
||
/// Show the active dialog (if any), docked flush to the right edge of the viewport just below
|
||
/// the view cube, sized to its content.
|
||
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||
let Some(mut d) = app.dialog.take() else { return };
|
||
auto_operation(app, &mut d);
|
||
update_measure(app, &mut d);
|
||
let t = Tokens::get();
|
||
let vp = app.viewport.rect.unwrap_or_else(|| ctx.content_rect());
|
||
let anchor = egui::pos2(vp.right(), vp.top() + crate::viewport::VIEW_CUBE_CLEARANCE);
|
||
let mut keep = true;
|
||
let mut ok = false;
|
||
let mut cancel = false;
|
||
let mut enter = false;
|
||
let wide = matches!(d.kind, Kind::Params { .. });
|
||
let heading = if d.editing.is_some() { format!("EDIT {}", title(&d.kind)) } else { title(&d.kind).to_string() };
|
||
let frame = egui::Frame::window(&ctx.global_style()).corner_radius(egui::CornerRadius { nw: 6, sw: 6, ne: 0, se: 0 }).shadow(egui::Shadow {
|
||
offset: [-2, 2],
|
||
blur: 8,
|
||
spread: 0,
|
||
color: Color32::from_black_alpha(40),
|
||
});
|
||
egui::Window::new(RichText::new(heading).strong().size(13.0))
|
||
.id(egui::Id::new("sc_dialog"))
|
||
.frame(frame)
|
||
.pivot(egui::Align2::RIGHT_TOP)
|
||
.fixed_pos(anchor)
|
||
.constrain(false)
|
||
.auto_sized()
|
||
.scroll([false, true])
|
||
.collapsible(false)
|
||
.max_height((vp.bottom() - anchor.y - 8.0).max(120.0))
|
||
.open(&mut keep)
|
||
.show(ctx, |ui| {
|
||
ui.spacing_mut().text_edit_width = FIELD_W;
|
||
let margin = 2.0 * ui.style().spacing.window_margin.leftf();
|
||
ui.set_min_width(if wide { 480.0 } else { DIALOG_MIN_W } - margin);
|
||
ui.set_max_width(if wide { 560.0 } else { DIALOG_MAX_W } - margin);
|
||
egui::Grid::new("sc_dialog_grid").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| {
|
||
input_rows(&mut d, ui);
|
||
match &mut d.kind {
|
||
Kind::Sketch => {}
|
||
Kind::Extrude { distance, direction, operation, auto_op, distance2, taper } => {
|
||
ui.label("Direction");
|
||
combo(ui, "ex_dir", &DIR_LABELS, direction);
|
||
ui.end_row();
|
||
ui.label("Distance");
|
||
enter |= field(ui, distance);
|
||
ui.end_row();
|
||
if *direction == TWO_SIDES {
|
||
ui.label("Distance 2");
|
||
enter |= field(ui, distance2);
|
||
ui.end_row();
|
||
} else if *direction < 2 {
|
||
ui.label("Taper angle");
|
||
let r = ui.add(egui::TextEdit::singleline(taper).hint_text("0 deg"));
|
||
enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||
ui.end_row();
|
||
}
|
||
ui.label("Operation");
|
||
let before = *operation;
|
||
combo(ui, "ex_op", &OP_LABELS, operation);
|
||
// A choice made by hand sticks.
|
||
if *operation != before {
|
||
*auto_op = false;
|
||
}
|
||
ui.end_row();
|
||
}
|
||
Kind::Revolve { angle, operation } => {
|
||
ui.label("Angle");
|
||
enter |= field(ui, angle);
|
||
ui.end_row();
|
||
ui.label("Operation");
|
||
combo(ui, "rv_op", &OP_LABELS, operation);
|
||
ui.end_row();
|
||
}
|
||
Kind::Fillet { radius, chamfer, chain } => {
|
||
ui.label(if *chamfer { "Distance" } else { "Radius" });
|
||
enter |= field(ui, radius);
|
||
ui.end_row();
|
||
ui.label("Tangent chain");
|
||
ui.checkbox(chain, "");
|
||
ui.end_row();
|
||
}
|
||
Kind::Shell { thickness } => {
|
||
ui.label("Inside thickness");
|
||
enter |= field(ui, thickness);
|
||
ui.end_row();
|
||
}
|
||
Kind::Draft { angle } => {
|
||
ui.label("Angle");
|
||
enter |= field(ui, angle);
|
||
ui.end_row();
|
||
}
|
||
Kind::Mirror => {}
|
||
Kind::Measure { result, .. } => measure_rows(ui, result.as_ref()),
|
||
Kind::Preferences => preferences_rows(app, ui),
|
||
Kind::OffsetPlane { offset } => {
|
||
ui.label("Distance");
|
||
enter |= field(ui, offset);
|
||
ui.end_row();
|
||
}
|
||
Kind::AnglePlane { angle } => {
|
||
ui.label("Angle");
|
||
enter |= field(ui, angle);
|
||
ui.end_row();
|
||
}
|
||
Kind::Split | Kind::ReplaceFace | Kind::Remove => {}
|
||
Kind::Rib { thickness, depth, flip, .. } => {
|
||
ui.label("Thickness");
|
||
enter |= field(ui, thickness);
|
||
ui.end_row();
|
||
ui.label("Depth");
|
||
let r = ui.add(egui::TextEdit::singleline(depth).hint_text("to the next face"));
|
||
enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||
ui.end_row();
|
||
ui.label("Flip");
|
||
ui.checkbox(flip, "");
|
||
ui.end_row();
|
||
}
|
||
Kind::Emboss { depth, deboss } => {
|
||
ui.label("Depth");
|
||
enter |= field(ui, depth);
|
||
ui.end_row();
|
||
ui.label("Type");
|
||
let mut i = usize::from(*deboss);
|
||
combo(ui, "emb_mode", &["Emboss", "Deboss"], &mut i);
|
||
*deboss = i == 1;
|
||
ui.end_row();
|
||
}
|
||
Kind::Align { flip } => {
|
||
ui.label("Flip");
|
||
ui.checkbox(flip, "");
|
||
ui.end_row();
|
||
}
|
||
Kind::PathPattern { count, spacing } => {
|
||
for (l, v) in [("Quantity", count), ("Spacing", spacing)] {
|
||
ui.label(l);
|
||
enter |= field(ui, v);
|
||
ui.end_row();
|
||
}
|
||
}
|
||
Kind::Pipe { diameter, wall } => {
|
||
ui.label("Diameter");
|
||
enter |= field(ui, diameter);
|
||
ui.end_row();
|
||
ui.label("Wall");
|
||
let r = ui.add(egui::TextEdit::singleline(wall).hint_text("solid"));
|
||
enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||
ui.end_row();
|
||
}
|
||
Kind::Stock { margin } => {
|
||
ui.label("Margin");
|
||
enter |= field(ui, margin);
|
||
ui.end_row();
|
||
}
|
||
Kind::Scale { factor } => {
|
||
ui.label("Scale factor");
|
||
enter |= field(ui, factor);
|
||
ui.end_row();
|
||
}
|
||
Kind::OffsetFaces { distance } => {
|
||
ui.label("Distance");
|
||
enter |= field(ui, distance);
|
||
ui.end_row();
|
||
}
|
||
Kind::Thread { designation, length } => {
|
||
ui.label("Size");
|
||
let r = ui.add(egui::TextEdit::singleline(designation).hint_text("fits the face, e.g. M8"));
|
||
enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||
ui.end_row();
|
||
ui.label("Length");
|
||
let r = ui.add(egui::TextEdit::singleline(length).hint_text("whole face"));
|
||
enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||
ui.end_row();
|
||
}
|
||
Kind::Material { index } => {
|
||
let names: Vec<&str> = solvecraft_engine::doc::MATERIALS.iter().map(|(n, _)| *n).collect();
|
||
ui.label("Material");
|
||
combo(ui, "mat", &names, index);
|
||
ui.end_row();
|
||
}
|
||
Kind::Section { offset, flip } => {
|
||
ui.label("Distance");
|
||
enter |= field(ui, offset);
|
||
ui.end_row();
|
||
ui.label("Flip");
|
||
ui.checkbox(flip, "");
|
||
ui.end_row();
|
||
}
|
||
Kind::PatternRect { count, spacing, count2, spacing2 } => {
|
||
for (l, v) in [("Quantity", count), ("Spacing", spacing), ("Quantity 2", count2), ("Spacing 2", spacing2)] {
|
||
ui.label(l);
|
||
enter |= field(ui, v);
|
||
ui.end_row();
|
||
}
|
||
}
|
||
Kind::PatternCirc { count, angle } => {
|
||
for (l, v) in [("Quantity", count), ("Total angle", angle)] {
|
||
ui.label(l);
|
||
enter |= field(ui, v);
|
||
ui.end_row();
|
||
}
|
||
}
|
||
Kind::Loft { operation } => {
|
||
ui.label("Operation");
|
||
combo(ui, "lf_op", &OP_LABELS, operation);
|
||
ui.end_row();
|
||
}
|
||
Kind::Sweep { operation } => {
|
||
ui.label("Operation");
|
||
combo(ui, "sw_op", &OP_LABELS, operation);
|
||
ui.end_row();
|
||
}
|
||
Kind::Move { x, y, z } => {
|
||
for (l, v) in [("X distance", x), ("Y distance", y), ("Z distance", z)] {
|
||
ui.label(l);
|
||
enter |= field(ui, v);
|
||
ui.end_row();
|
||
}
|
||
}
|
||
Kind::Hole { diameter, depth, kind, cb_diameter, cb_depth, cs_diameter, cs_angle } => {
|
||
ui.label("Type");
|
||
combo(ui, "hole_kind", &HOLE_LABELS, kind);
|
||
ui.end_row();
|
||
ui.label("Diameter");
|
||
enter |= field(ui, diameter);
|
||
ui.end_row();
|
||
ui.label("Depth");
|
||
let r = ui.add(egui::TextEdit::singleline(depth).hint_text("through all"));
|
||
enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||
ui.end_row();
|
||
if *kind == 1 {
|
||
ui.label("Counterbore Ø");
|
||
enter |= field(ui, cb_diameter);
|
||
ui.end_row();
|
||
ui.label("Counterbore depth");
|
||
enter |= field(ui, cb_depth);
|
||
ui.end_row();
|
||
}
|
||
if *kind == 2 {
|
||
ui.label("Countersink Ø");
|
||
enter |= field(ui, cs_diameter);
|
||
ui.end_row();
|
||
ui.label("Countersink angle");
|
||
enter |= field(ui, cs_angle);
|
||
ui.end_row();
|
||
}
|
||
}
|
||
Kind::Primitive { fields, operation, .. } => {
|
||
for (k, v) in fields.iter_mut() {
|
||
ui.label(*k);
|
||
enter |= field(ui, v);
|
||
ui.end_row();
|
||
}
|
||
ui.label("Operation");
|
||
combo(ui, "pr_op", &OP_LABELS, operation);
|
||
ui.end_row();
|
||
}
|
||
Kind::Combine { operation, keep_tools } => {
|
||
ui.label("Operation");
|
||
let mut op = operation.saturating_sub(1);
|
||
combo(ui, "cb_op", &OP_LABELS[1..], &mut op);
|
||
*operation = op + 1;
|
||
ui.end_row();
|
||
ui.label("Keep tools");
|
||
ui.checkbox(keep_tools, "");
|
||
ui.end_row();
|
||
}
|
||
Kind::Params { new_name, new_expr } => params_table(app, ui, new_name, new_expr),
|
||
Kind::EditParam { name, expr } => {
|
||
ui.label(name.as_str());
|
||
let r = ui.text_edit_singleline(expr);
|
||
if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||
ok = true;
|
||
}
|
||
r.request_focus();
|
||
ui.end_row();
|
||
}
|
||
Kind::Rename { name, .. } => {
|
||
ui.label("Name");
|
||
let r = ui.text_edit_singleline(name);
|
||
if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||
ok = true;
|
||
}
|
||
r.request_focus();
|
||
ui.end_row();
|
||
}
|
||
Kind::ConfirmDelete { with, fail, .. } => {
|
||
if !with.is_empty() {
|
||
ui.label("Also deletes");
|
||
ui.label(with.join(", "));
|
||
ui.end_row();
|
||
}
|
||
if !fail.is_empty() {
|
||
ui.label("Will fail");
|
||
ui.label(RichText::new(fail.join(", ")).color(t.warning));
|
||
ui.end_row();
|
||
}
|
||
}
|
||
}
|
||
if let Some(e) = d.error.as_ref().or(app.preview.error.as_ref()) {
|
||
ui.label("");
|
||
ui.add(egui::Label::new(RichText::new(e.as_str()).color(t.error)).wrap());
|
||
ui.end_row();
|
||
}
|
||
});
|
||
ui.add_space(6.0);
|
||
ui.horizontal(|ui| {
|
||
if !matches!(d.kind, Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. } | Kind::Preferences)
|
||
&& ui
|
||
.add(
|
||
egui::Button::new(
|
||
RichText::new(if matches!(d.kind, Kind::ConfirmDelete { .. }) { "Delete" } else { "OK" }).color(Color32::WHITE),
|
||
)
|
||
.fill(t.accent)
|
||
.min_size(vec2(70.0, 24.0)),
|
||
)
|
||
.clicked()
|
||
{
|
||
ok = true;
|
||
}
|
||
let close = if matches!(d.kind, Kind::Params { .. } | Kind::Measure { .. } | Kind::Preferences) { "Close" } else { "Cancel" };
|
||
if ui.add(egui::Button::new(close).min_size(vec2(70.0, 24.0))).clicked() {
|
||
cancel = true;
|
||
}
|
||
if app.preview.busy {
|
||
ui.add(egui::Spinner::new().size(14.0)).on_hover_text("Updating the preview");
|
||
}
|
||
});
|
||
});
|
||
// Keyboard: Enter applies (from a value field, or with nothing focused), Esc cancels.
|
||
let (enter_free, esc) = ctx.input(|i| (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape)));
|
||
let nothing_focused = ctx.memory(|m| m.focused().is_none());
|
||
let canvas_enter = enter_free && ctx.memory(|m| m.had_focus_last_frame(egui::Id::new("sc_canvas_value")));
|
||
let enter_applies = !matches!(d.kind, Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. } | Kind::ConfirmDelete { .. } | Kind::Preferences);
|
||
if enter_applies && (enter || canvas_enter || (enter_free && nothing_focused)) {
|
||
ok = true;
|
||
}
|
||
if esc {
|
||
cancel = true;
|
||
}
|
||
// A plane picked for a new sketch starts it right away.
|
||
if matches!(d.kind, Kind::Sketch)
|
||
&& let Some(sel) = d.inputs.first().and_then(|i| i.items.first()).cloned()
|
||
{
|
||
start_sketch(app, &sel);
|
||
cancel = true;
|
||
}
|
||
if ok {
|
||
match run_dialog(app, &d) {
|
||
Ok(()) => cancel = true,
|
||
Err(e) => d.error = Some(e),
|
||
}
|
||
}
|
||
if keep && !cancel {
|
||
app.dialog = Some(d);
|
||
} else if let Some((_, marker)) = d.editing {
|
||
// Editing done: put the timeline marker back where it was.
|
||
let _ = app.run("timeline.rollTo", marker.map(|m| json!({ "position": m })).unwrap_or_else(|| json!({})));
|
||
}
|
||
}
|
||
|
||
/// Close the dialog without applying it (Esc, Cancel). An edit puts the timeline marker back.
|
||
pub fn cancel(app: &mut SolveApp) {
|
||
if let Some(d) = app.dialog.take()
|
||
&& let Some((_, marker)) = d.editing
|
||
{
|
||
let _ = app.run("timeline.rollTo", marker.map(|m| json!({ "position": m })).unwrap_or_else(|| json!({})));
|
||
}
|
||
}
|
||
|
||
fn hole_defaults() -> Kind {
|
||
Kind::Hole {
|
||
diameter: "5 mm".into(),
|
||
depth: String::new(),
|
||
kind: 0,
|
||
cb_diameter: "9 mm".into(),
|
||
cb_depth: "3 mm".into(),
|
||
cs_diameter: "10 mm".into(),
|
||
cs_angle: "90 deg".into(),
|
||
}
|
||
}
|
||
|
||
fn params_table(app: &mut SolveApp, ui: &mut egui::Ui, new_name: &mut String, new_expr: &mut String) {
|
||
let t = Tokens::get();
|
||
ui.label(RichText::new("Name").strong());
|
||
ui.label(RichText::new("Expression · value").strong());
|
||
ui.end_row();
|
||
let (vals, errs) = app.session.doc.param_values();
|
||
let params = app.session.doc.params.clone();
|
||
for p in params {
|
||
ui.label(if p.model { RichText::new(&p.name).color(t.text_dim) } else { RichText::new(&p.name) });
|
||
ui.horizontal(|ui| {
|
||
let id = egui::Id::new(("pexpr", &p.name));
|
||
let mut e = ui.data(|dd| dd.get_temp::<String>(id)).unwrap_or_else(|| p.expr.clone());
|
||
let r = ui.add(egui::TextEdit::singleline(&mut e).desired_width(160.0));
|
||
if r.changed() {
|
||
ui.data_mut(|dd| dd.insert_temp(id, e.clone()));
|
||
}
|
||
if r.lost_focus() && e != p.expr {
|
||
let _ = app.run("ChangeParameterCommand", json!({"name": p.name, "expression": e}));
|
||
ui.data_mut(|dd| dd.remove::<String>(id));
|
||
}
|
||
let v = vals.get(&p.name).map(|v| if p.unit == "deg" { format!("{:.3}°", v.v.to_degrees()) } else { format!("{:.4} {}", v.v, p.unit) });
|
||
match (v, errs.get(&p.name)) {
|
||
(_, Some(e)) => ui.label(RichText::new(e).color(t.error)),
|
||
(Some(v), None) => ui.label(RichText::new(v).color(t.text_dim)),
|
||
_ => ui.label(""),
|
||
};
|
||
});
|
||
ui.end_row();
|
||
}
|
||
ui.add(egui::TextEdit::singleline(new_name).hint_text("new name").desired_width(110.0));
|
||
ui.horizontal(|ui| {
|
||
ui.add(egui::TextEdit::singleline(new_expr).hint_text("expression, e.g. 25 mm").desired_width(160.0));
|
||
if ui.button("Add").clicked()
|
||
&& !new_name.is_empty()
|
||
&& app.run("ChangeParameterCommand", json!({"name": new_name, "expression": new_expr})).is_ok()
|
||
{
|
||
new_name.clear();
|
||
new_expr.clear();
|
||
}
|
||
});
|
||
ui.end_row();
|
||
}
|
||
|
||
/// Start a sketch on a picked plane or planar face and turn the view to face it.
|
||
pub fn start_sketch(app: &mut SolveApp, sel: &Sel) {
|
||
let plane = match sel {
|
||
Sel::Plane { name } => json!(name),
|
||
Sel::Face { point, .. } => json!({"face": pt(*point)}),
|
||
_ => return,
|
||
};
|
||
let before = app.cam;
|
||
if app.run("SketchCreate", json!({ "plane": plane })).is_ok() {
|
||
app.pre_sketch_cam = Some(before);
|
||
look_at_sketch(app);
|
||
}
|
||
}
|
||
|
||
/// Animate the camera to look straight at the active sketch plane.
|
||
pub fn look_at_sketch(app: &mut SolveApp) {
|
||
let st = app.session.model.state();
|
||
if let Some(ss) = app.session.active_sketch.and_then(|id| st.sketch(id)) {
|
||
let mut to = app.cam.looking_from(ss.plane.normal());
|
||
// Keep the sketch's own x axis to the right when looking straight down or up.
|
||
if ss.plane.normal().z.abs() > 0.999 {
|
||
let x = ss.plane.x;
|
||
to.yaw = if ss.plane.normal().z > 0.0 { (-x.y).atan2(x.x) } else { x.y.atan2(x.x) };
|
||
}
|
||
to.target = ss.plane.origin;
|
||
app.animate_to(to);
|
||
}
|
||
}
|
||
|
||
fn sels(d: &Dialog, i: usize) -> &[Sel] {
|
||
d.inputs.get(i).map(|x| x.items.as_slice()).unwrap_or(&[])
|
||
}
|
||
|
||
fn run_dialog(app: &mut SolveApp, d: &Dialog) -> Result<(), String> {
|
||
for (cmd, params) in apply_commands(app, d)? {
|
||
app.run(&cmd, params)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// The commands OK runs (an edit rebuilds its feature in place); the live preview runs the same.
|
||
pub fn apply_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, String> {
|
||
let cmds = dialog_commands(app, d)?;
|
||
if let Some((id, _)) = d.editing {
|
||
let (cmd, params) = cmds.into_iter().next().ok_or("nothing to apply")?;
|
||
return Ok(vec![("timeline.redefine".into(), json!({"feature": id, "command": cmd, "params": params}))]);
|
||
}
|
||
Ok(cmds)
|
||
}
|
||
|
||
/// The commands (id, parameters) a dialog's OK runs.
|
||
fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, String> {
|
||
let s = &app.session;
|
||
let need = |i: usize, what: &str| -> Result<(), String> { if sels(d, i).is_empty() { Err(format!("select {what} first")) } else { Ok(()) } };
|
||
let profiles = || -> (Value, Value) {
|
||
let ps: Vec<(u64, usize)> =
|
||
sels(d, 0).iter().filter_map(|x| if let Sel::Profile { sketch, index } = x { Some((*sketch, *index)) } else { None }).collect();
|
||
(ps.first().map(|x| json!(x.0)).unwrap_or(Value::Null), json!(ps.iter().map(|x| x.1).collect::<Vec<_>>()))
|
||
};
|
||
let body_names =
|
||
|i: usize| -> Vec<String> { sels(d, i).iter().filter_map(|x| if let Sel::Body { name } = x { Some(name.clone()) } else { None }).collect() };
|
||
let face_points = |i: usize| -> Vec<Value> {
|
||
sels(d, i).iter().filter_map(|x| if let Sel::Face { point, .. } = x { Some(pt(*point)) } else { None }).collect()
|
||
};
|
||
let (cmd, params): (&str, Value) = match &d.kind {
|
||
Kind::Extrude { distance, direction, operation, distance2, taper, .. } => {
|
||
need(0, "profiles or a planar face")?;
|
||
let mut common = json!({"distance": distance, "direction": DIRS.get(*direction).copied().unwrap_or("positive"), "operation": OPS.get(*operation).copied().unwrap_or("new")});
|
||
if *direction == TWO_SIDES {
|
||
common["distance2"] = json!(distance2);
|
||
} else if *direction < 2 && !taper.trim().is_empty() {
|
||
common["taper"] = json!(taper);
|
||
}
|
||
let with = |extra: Value| -> Value {
|
||
let mut p = common.clone();
|
||
if let (Value::Object(m), Value::Object(e)) = (&mut p, extra) {
|
||
m.extend(e);
|
||
for (k, v) in &d.extra {
|
||
m.entry(k.clone()).or_insert_with(|| v.clone());
|
||
}
|
||
}
|
||
p
|
||
};
|
||
let mut out = Vec::new();
|
||
let (sketch, idx) = profiles();
|
||
if !sketch.is_null() {
|
||
out.push(("Extrude".to_string(), with(json!({"sketch": sketch, "profiles": idx}))));
|
||
}
|
||
// Each planar body face extrudes on its own.
|
||
for p in face_points(0) {
|
||
out.push(("Extrude".to_string(), with(json!({ "face": p }))));
|
||
}
|
||
return Ok(out);
|
||
}
|
||
Kind::Revolve { angle, operation } => {
|
||
need(0, "profiles")?;
|
||
need(1, "an axis")?;
|
||
let axis = match sels(d, 1).first() {
|
||
Some(Sel::Axis { name }) => json!(name),
|
||
Some(Sel::SketchCurve { id }) => json!(id),
|
||
_ => return Err("select an axis".into()),
|
||
};
|
||
let (sketch, idx) = profiles();
|
||
(
|
||
"Revolve",
|
||
json!({"sketch": sketch, "profiles": idx, "axis": axis, "angle": angle, "operation": OPS.get(*operation).copied().unwrap_or("new")}),
|
||
)
|
||
}
|
||
Kind::Fillet { radius, chamfer, .. } => {
|
||
need(0, "edges")?;
|
||
// Faces stand for all their edges.
|
||
let st = s.model.state();
|
||
let mut pts: Vec<Vec3> = Vec::new();
|
||
for x in sels(d, 0) {
|
||
match x {
|
||
Sel::Edge { point, .. } => pts.push(*point),
|
||
Sel::Face { body, index, .. } => {
|
||
if let Some(b) = st.body(body) {
|
||
let m = b.mesh();
|
||
for e in m.face_edges(u32::try_from(*index).unwrap_or(u32::MAX)) {
|
||
if !m.seams.get(e).copied().unwrap_or(false)
|
||
&& let Some(p) = m.edges.get(e)
|
||
{
|
||
pts.push(polyline_mid(p));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
let mut uniq: Vec<Vec3> = Vec::new();
|
||
for p in pts {
|
||
if !uniq.iter().any(|q| q.dist(p) < 1e-9) {
|
||
uniq.push(p);
|
||
}
|
||
}
|
||
let edges: Vec<Value> = uniq.into_iter().map(pt).collect();
|
||
if *chamfer {
|
||
("FusionChamferCommand", json!({"edges": edges, "distance": radius}))
|
||
} else {
|
||
("FusionFilletEdgesCommand", json!({"edges": edges, "radius": radius}))
|
||
}
|
||
}
|
||
Kind::Shell { thickness } => {
|
||
need(0, "faces to remove")?;
|
||
("FusionShellBodyCommand", json!({"faces": face_points(0), "thickness": thickness}))
|
||
}
|
||
Kind::Draft { angle } => {
|
||
need(0, "faces")?;
|
||
need(1, "the neutral plane")?;
|
||
let neutral = plane_value(s, sels(d, 1).first()).ok_or("the neutral plane must be a plane or a planar face")?;
|
||
("FusionDraftCommand", json!({"faces": face_points(0), "angle": angle, "neutral": neutral}))
|
||
}
|
||
Kind::Mirror => {
|
||
need(0, "bodies")?;
|
||
need(1, "the mirror plane")?;
|
||
let features = source_features(s, &body_names(0));
|
||
let plane = plane_value(s, sels(d, 1).first()).ok_or("the mirror plane must be a plane or a planar face")?;
|
||
("MirrorCommand", json!({"features": features, "plane": plane}))
|
||
}
|
||
Kind::PatternRect { count, spacing, count2, spacing2 } => {
|
||
need(0, "objects")?;
|
||
let kept = d.extra.get("dir1").and_then(|v| serde_json::from_value::<[f64; 3]>(v.clone()).ok()).map(|a| Vec3::new(a[0], a[1], a[2]));
|
||
let d1 = match (sels(d, 1).first(), kept) {
|
||
(Some(x), _) => axis_of(app, x).ok_or("the direction must be an axis or a sketch line")?.1,
|
||
(None, Some(k)) => k,
|
||
(None, None) => return Err("select a direction (an axis or a sketch line) first".into()),
|
||
};
|
||
let mut p = json!({"features": source_features(s, &body_names(0)), "dir1": pt(d1), "count1": count, "spacing1": spacing});
|
||
if let Some((_, d2)) = sels(d, 2).first().and_then(|x| axis_of(app, x)) {
|
||
p["dir2"] = pt(d2);
|
||
p["count2"] = json!(count2);
|
||
p["spacing2"] = json!(spacing2);
|
||
}
|
||
("PatternRectangular", p)
|
||
}
|
||
Kind::PatternCirc { count, angle } => {
|
||
need(0, "objects")?;
|
||
let axis = match sels(d, 1).first() {
|
||
None if d.extra.contains_key("axis") => d.extra.get("axis").cloned().unwrap_or(Value::Null),
|
||
Some(Sel::Axis { name }) => json!(name),
|
||
Some(x) => {
|
||
let (o, dir) = axis_of(app, x).ok_or("the axis must be an origin axis or a sketch line")?;
|
||
json!({"origin": pt(o), "dir": pt(dir)})
|
||
}
|
||
None => return Err("select an axis".into()),
|
||
};
|
||
("PatternCircular", json!({"features": source_features(s, &body_names(0)), "axis": axis, "count": count, "angle": angle}))
|
||
}
|
||
Kind::Loft { operation } => {
|
||
need(0, "profiles of two or more sketches")?;
|
||
// One section per sketch, in the order the sketches were first picked.
|
||
let mut sections: Vec<(u64, Vec<usize>)> = Vec::new();
|
||
for x in sels(d, 0) {
|
||
if let Sel::Profile { sketch, index } = x {
|
||
match sections.iter_mut().find(|(sk, _)| sk == sketch) {
|
||
Some((_, v)) => v.push(*index),
|
||
None => sections.push((*sketch, vec![*index])),
|
||
}
|
||
}
|
||
}
|
||
if sections.len() < 2 {
|
||
return Err("pick profiles in two or more sketches".into());
|
||
}
|
||
let sections: Vec<Value> = sections.into_iter().map(|(sk, v)| json!({"sketch": sk, "profiles": v})).collect();
|
||
("SolidLoft", json!({"sections": sections, "operation": OPS.get(*operation).copied().unwrap_or("new")}))
|
||
}
|
||
Kind::Sweep { operation } => {
|
||
need(0, "a profile")?;
|
||
need(1, "the path")?;
|
||
let (sketch, idx) = profiles();
|
||
let path: Vec<String> = sels(d, 1).iter().filter_map(|x| if let Sel::SketchCurve { id } = x { Some(id.clone()) } else { None }).collect();
|
||
let path_sketch = curves_sketch(app, &path, sketch.as_u64()).ok_or("the path curves must be in one sketch")?;
|
||
(
|
||
"Sweep",
|
||
json!({"sketch": sketch, "profiles": idx, "path_sketch": path_sketch, "path": path, "operation": OPS.get(*operation).copied().unwrap_or("new")}),
|
||
)
|
||
}
|
||
Kind::OffsetPlane { offset } => {
|
||
need(0, "a plane")?;
|
||
let base = match sels(d, 0).first() {
|
||
Some(Sel::Plane { name }) => name.clone(),
|
||
_ => return Err("pick an origin or construction plane".into()),
|
||
};
|
||
("ConstructionPlaneOffsetFromPlaneCommand", json!({"base": base, "offset": offset}))
|
||
}
|
||
Kind::AnglePlane { angle } => {
|
||
need(0, "a plane")?;
|
||
need(1, "an axis")?;
|
||
let base = match sels(d, 0).first() {
|
||
Some(Sel::Plane { name }) => name.clone(),
|
||
_ => return Err("pick an origin or construction plane".into()),
|
||
};
|
||
let axis = match sels(d, 1).first() {
|
||
Some(Sel::Axis { name }) => json!(name),
|
||
Some(x) => {
|
||
let (o, dir) = axis_of(app, x).ok_or("the axis must be an origin axis or a sketch line")?;
|
||
json!({"origin": pt(o), "dir": pt(dir)})
|
||
}
|
||
None => return Err("pick an axis".into()),
|
||
};
|
||
("ConstructionPlaneAtAngleCommand", json!({"base": base, "axis": axis, "angle": angle}))
|
||
}
|
||
Kind::Rib { web, thickness, depth, flip } => {
|
||
need(0, "open sketch curves")?;
|
||
let curves = curve_ids(d, 0);
|
||
let sketch = curves_sketch(app, &curves, None).ok_or("the curves must be in one sketch")?;
|
||
let mut p = json!({"sketch": sketch, "curves": curves, "thickness": thickness, "flip": flip});
|
||
if !depth.trim().is_empty() {
|
||
p["depth"] = json!(depth);
|
||
}
|
||
(if *web { "FusionWebCommand" } else { "FusionRibCommand" }, p)
|
||
}
|
||
Kind::Emboss { depth, deboss } => {
|
||
need(0, "profiles")?;
|
||
let (sketch, idx) = profiles();
|
||
("EmbossCmd", json!({"sketch": sketch, "profiles": idx, "depth": depth, "mode": if *deboss { "deboss" } else { "emboss" }}))
|
||
}
|
||
Kind::ReplaceFace => {
|
||
need(0, "faces to move")?;
|
||
need(1, "the target")?;
|
||
let mut p = json!({"faces": face_points(0)});
|
||
match sels(d, 1).first() {
|
||
Some(Sel::Plane { name }) => p["target"] = json!(name),
|
||
Some(Sel::Face { point, .. }) => p["target_face"] = pt(*point),
|
||
_ => return Err("pick a plane or planar face".into()),
|
||
}
|
||
("FusionReplaceFaceCommand", p)
|
||
}
|
||
Kind::Align { flip } => {
|
||
need(0, "bodies")?;
|
||
need(1, "what to move from")?;
|
||
need(2, "where to move it to")?;
|
||
let mut p = json!({"bodies": body_names(0), "flip": flip});
|
||
for (i, key) in [(1, "from"), (2, "to")] {
|
||
match sels(d, i).first() {
|
||
Some(Sel::Face { point, .. }) => p[format!("{key}_face")] = pt(*point),
|
||
Some(Sel::Vertex { point, .. }) => p[key] = pt(*point),
|
||
_ => return Err("pick faces or vertices".into()),
|
||
}
|
||
}
|
||
("AlignCmd", p)
|
||
}
|
||
Kind::Remove => {
|
||
need(0, "bodies")?;
|
||
("SoftDeleteCommand", json!({"bodies": body_names(0)}))
|
||
}
|
||
Kind::PathPattern { count, spacing } => {
|
||
need(0, "objects")?;
|
||
need(1, "the path")?;
|
||
let path = curve_ids(d, 1);
|
||
let sketch = curves_sketch(app, &path, None).ok_or("the path curves must be in one sketch")?;
|
||
("PatternOnPath", json!({"bodies": body_names(0), "path_sketch": sketch, "path": path, "count": count, "spacing": spacing}))
|
||
}
|
||
Kind::Pipe { diameter, wall } => {
|
||
need(0, "the path")?;
|
||
let path = curve_ids(d, 0);
|
||
let sketch = curves_sketch(app, &path, None).ok_or("the path curves must be in one sketch")?;
|
||
let mut p = json!({"path_sketch": sketch, "path": path, "diameter": diameter});
|
||
if !wall.trim().is_empty() {
|
||
p["wall"] = json!(wall);
|
||
}
|
||
("PrimitivePipe", p)
|
||
}
|
||
Kind::Stock { margin } => {
|
||
let mut p = json!({"margin": margin});
|
||
if !sels(d, 0).is_empty() {
|
||
p["bodies"] = json!(body_names(0));
|
||
}
|
||
("StockModelCommand", p)
|
||
}
|
||
Kind::Split => {
|
||
need(0, "the body to split")?;
|
||
need(1, "the splitting plane")?;
|
||
let body = body_names(0).into_iter().next().unwrap_or_default();
|
||
let plane = match sels(d, 1).first() {
|
||
Some(Sel::Plane { name }) => json!(name),
|
||
Some(Sel::Face { body, index, point }) => {
|
||
let (_, n) = planar_face(s, body, *index).ok_or("the face must be planar")?;
|
||
json!({"origin": pt(*point), "normal": pt(n)})
|
||
}
|
||
_ => return Err("pick a plane or a planar face".into()),
|
||
};
|
||
("FusionSplitBodyCommand", json!({"body": body, "plane": plane}))
|
||
}
|
||
Kind::Scale { factor } => {
|
||
need(0, "bodies")?;
|
||
("ModifyScale", json!({"bodies": body_names(0), "factor": factor}))
|
||
}
|
||
Kind::OffsetFaces { distance } => {
|
||
need(0, "planar faces")?;
|
||
("FusionOffsetFacesCommand", json!({"faces": face_points(0), "distance": distance}))
|
||
}
|
||
Kind::Thread { designation, length } => {
|
||
need(0, "a cylindrical face")?;
|
||
let mut p = json!({"face": face_points(0).into_iter().next().unwrap_or(Value::Null)});
|
||
if !designation.trim().is_empty() {
|
||
p["designation"] = json!(designation.trim());
|
||
}
|
||
if !length.trim().is_empty() {
|
||
p["length"] = json!(length);
|
||
}
|
||
("FusionThreadCommand", p)
|
||
}
|
||
Kind::Material { index } => {
|
||
need(0, "bodies")?;
|
||
let name = solvecraft_engine::doc::MATERIALS.get(*index).map(|(n, _)| *n).unwrap_or("Default");
|
||
("PhysicalMaterialCommand", json!({"bodies": body_names(0), "material": name}))
|
||
}
|
||
Kind::Section { offset, flip } => {
|
||
need(0, "a plane or planar face")?;
|
||
let plane = match sels(d, 0).first() {
|
||
Some(Sel::Plane { name }) if matches!(name.as_str(), "XY" | "XZ" | "YZ") => json!(name),
|
||
Some(Sel::Face { body, index, point }) => {
|
||
let (_, n) = planar_face(s, body, *index).ok_or("the face must be planar")?;
|
||
json!({"origin": pt(*point), "normal": pt(n)})
|
||
}
|
||
Some(Sel::Plane { name }) => {
|
||
let pl = solvecraft_engine::view::construction_planes(s)
|
||
.into_iter()
|
||
.find(|(_, n, _)| n == name)
|
||
.map(|(_, _, pl)| pl)
|
||
.ok_or("unknown plane")?;
|
||
json!({"origin": pt(pl.origin), "normal": pt(pl.normal())})
|
||
}
|
||
_ => return Err("pick a plane or a planar face".into()),
|
||
};
|
||
("FusionHalfSectionViewCommand", json!({"plane": plane, "offset": offset, "flip": flip}))
|
||
}
|
||
Kind::Move { x, y, z } => {
|
||
need(0, "bodies")?;
|
||
("FusionMoveCommand", json!({"bodies": body_names(0), "translate": [x, y, z]}))
|
||
}
|
||
Kind::Hole { diameter, depth, kind, cb_diameter, cb_depth, cs_diameter, cs_angle } => {
|
||
need(0, "a face position")?;
|
||
let ty = HOLE_TYPES.get(*kind).copied().unwrap_or("simple");
|
||
// One hole per picked position.
|
||
let mut out = Vec::new();
|
||
for p in face_points(0) {
|
||
let mut params = json!({"position": p, "diameter": diameter, "type": ty});
|
||
if !depth.trim().is_empty() {
|
||
params["depth"] = json!(depth);
|
||
}
|
||
match *kind {
|
||
1 => {
|
||
params["cb_diameter"] = json!(cb_diameter);
|
||
params["cb_depth"] = json!(cb_depth);
|
||
}
|
||
2 => {
|
||
params["cs_diameter"] = json!(cs_diameter);
|
||
params["cs_angle"] = json!(cs_angle);
|
||
}
|
||
_ => {}
|
||
}
|
||
out.push(("FusionHoleCommand".to_string(), params));
|
||
}
|
||
return Ok(out);
|
||
}
|
||
Kind::Primitive { cmd, fields, operation } => {
|
||
let mut p = serde_json::Map::new();
|
||
for (k, v) in fields {
|
||
p.insert((*k).into(), json!(v));
|
||
}
|
||
p.insert("operation".into(), json!(OPS.get(*operation).copied().unwrap_or("new")));
|
||
(cmd, Value::Object(p))
|
||
}
|
||
Kind::Combine { operation, keep_tools } => {
|
||
need(0, "the target body")?;
|
||
need(1, "tool bodies")?;
|
||
let target = body_names(0).into_iter().next().unwrap_or_default();
|
||
let tools: Vec<String> = body_names(1).into_iter().filter(|n| *n != target).collect();
|
||
(
|
||
"FusionCombineCommand",
|
||
json!({"target": target, "tools": tools, "operation": OPS.get(*operation).copied().unwrap_or("join"), "keep_tools": keep_tools}),
|
||
)
|
||
}
|
||
Kind::EditParam { name, expr } => ("ChangeParameterCommand", json!({"name": name, "expression": expr})),
|
||
Kind::Rename { feature, name } => ("FusionRenameTimelineEntryCommand", json!({"feature": feature, "name": name})),
|
||
Kind::ConfirmDelete { feature, .. } => ("FusionDeleteCommand", json!({ "features": [feature.to_string()] })),
|
||
Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. } | Kind::Preferences => return Ok(Vec::new()),
|
||
};
|
||
let mut params = params;
|
||
if let Value::Object(m) = &mut params {
|
||
for (k, v) in &d.extra {
|
||
m.entry(k.clone()).or_insert_with(|| v.clone());
|
||
}
|
||
}
|
||
Ok(vec![(cmd.to_string(), params)])
|
||
}
|
||
|
||
/// Sketch curve ids picked in an input.
|
||
fn curve_ids(d: &Dialog, i: usize) -> Vec<String> {
|
||
sels(d, i).iter().filter_map(|x| if let Sel::SketchCurve { id } = x { Some(id.clone()) } else { None }).collect()
|
||
}
|
||
|
||
/// The features that made these bodies (what patterns and mirrors copy).
|
||
fn source_features(s: &Session, bodies: &[String]) -> Vec<String> {
|
||
let st = s.model.state();
|
||
let mut features: Vec<String> = Vec::new();
|
||
for n in bodies {
|
||
if let Some(f) = st.body(n).and_then(|b| s.doc.feature(b.feature)).map(|f| f.name.clone())
|
||
&& !features.contains(&f)
|
||
{
|
||
features.push(f);
|
||
}
|
||
}
|
||
features
|
||
}
|
||
|
||
/// The sketch holding all these curve ids (the active sketch first, never `not`).
|
||
fn curves_sketch(app: &SolveApp, ids: &[String], not: Option<u64>) -> Option<u64> {
|
||
let st = app.session.model.state();
|
||
let has = |sid: u64| st.sketch(sid).is_some_and(|ss| ids.iter().all(|id| ss.sketch.curve_index(id).is_some()));
|
||
if let Some(a) = app.session.active_sketch
|
||
&& Some(a) != not
|
||
&& has(a)
|
||
{
|
||
return Some(a);
|
||
}
|
||
st.sketches.iter().rev().map(|ss| ss.feature).find(|sid| Some(*sid) != not && has(*sid))
|
||
}
|
||
|
||
/// An axis selection as a line: a point on it and its unit direction (origin axes, or a straight
|
||
/// sketch line).
|
||
pub fn axis_of(app: &SolveApp, sel: &Sel) -> Option<(Vec3, Vec3)> {
|
||
match sel {
|
||
Sel::Axis { name } => Some((
|
||
Vec3::ZERO,
|
||
match name.as_str() {
|
||
"X" => Vec3::X,
|
||
"Y" => Vec3::Y,
|
||
_ => Vec3::Z,
|
||
},
|
||
)),
|
||
Sel::SketchCurve { id } => {
|
||
let st = app.session.model.state();
|
||
let sid = curves_sketch(app, std::slice::from_ref(id), None)?;
|
||
let ss = st.sketch(sid)?;
|
||
let c = ss.sketch.curves.get(ss.sketch.curve_index(id)?)?;
|
||
let solvecraft_engine::sketch::CurveKind::Line { a, b } = c.kind else { return None };
|
||
let (pa, pb) = (ss.plane.to_world(ss.sketch.point(a)?), ss.plane.to_world(ss.sketch.point(b)?));
|
||
Some((pa, (pb - pa).normalized()?))
|
||
}
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Which profiles of a sketch a profile selection means.
|
||
fn profile_indices(ss: &solvecraft_engine::doc::SolvedSketch, sel: &ProfileSel) -> Vec<usize> {
|
||
let ps = &ss.profiles;
|
||
match sel {
|
||
ProfileSel::All => (0..ps.len()).collect(),
|
||
ProfileSel::Indices { indices } => indices.iter().copied().filter(|i| *i < ps.len()).collect(),
|
||
ProfileSel::Curves { loops } => loops
|
||
.iter()
|
||
.filter_map(|l| {
|
||
let mut want: Vec<&String> = l.iter().collect();
|
||
want.sort();
|
||
ps.iter().position(|p| {
|
||
let mut have: Vec<&String> = p.outer_curves.iter().collect();
|
||
have.sort();
|
||
have == want
|
||
})
|
||
})
|
||
.collect(),
|
||
ProfileSel::Points { points } => points.iter().filter_map(|q| ps.iter().position(|p| p.region.contains(*q))).collect(),
|
||
}
|
||
}
|
||
|
||
/// The edge of a visible body through (or nearest) a point.
|
||
fn edge_sel(s: &Session, p: Vec3) -> Option<Sel> {
|
||
let st = s.model.state();
|
||
let mut best: Option<(f64, Sel)> = None;
|
||
for b in &st.bodies {
|
||
let m = b.mesh();
|
||
for (i, e) in m.edges.iter().enumerate() {
|
||
let d = e.windows(2).map(|w| p.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min);
|
||
if best.as_ref().is_none_or(|(bd, _)| d < *bd) {
|
||
best = Some((d, Sel::Edge { body: b.name.clone(), index: i, point: p }));
|
||
}
|
||
}
|
||
}
|
||
best.map(|x| x.1)
|
||
}
|
||
|
||
/// The body face containing a point (nearest triangle).
|
||
fn face_sel(s: &Session, p: Vec3) -> Option<Sel> {
|
||
let st = s.model.state();
|
||
let mut best: Option<(f64, Sel)> = None;
|
||
for b in &st.bodies {
|
||
let m = b.mesh();
|
||
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
|
||
let Some([a, bb, c]) = m.tri(t) else { continue };
|
||
let d = crate::preview::point_triangle_dist(p, a, bb, c);
|
||
if best.as_ref().is_none_or(|(bd, _)| d < *bd) {
|
||
best = Some((d, Sel::Face { body: b.name.clone(), index: *f as usize, point: p }));
|
||
}
|
||
}
|
||
}
|
||
best.filter(|(d, _)| *d < 1e-3 + 1e-6 * p.len()).map(|x| x.1)
|
||
}
|
||
|
||
fn plane_sel(s: &Session, pl: &PlaneRef) -> Option<Sel> {
|
||
match pl {
|
||
PlaneRef::Origin { name } | PlaneRef::Construction { name } => Some(Sel::Plane { name: name.clone() }),
|
||
PlaneRef::Custom { plane } => face_sel(s, plane.origin),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
fn op_index(o: &Operation) -> usize {
|
||
match o {
|
||
Operation::NewBody => 0,
|
||
Operation::Join => 1,
|
||
Operation::Cut => 2,
|
||
Operation::Intersect => 3,
|
||
}
|
||
}
|
||
|
||
/// The origin axis a unit direction runs along (either way), if any.
|
||
fn world_axis(d: Vec3) -> Option<String> {
|
||
[("X", Vec3::X), ("Y", Vec3::Y), ("Z", Vec3::Z)]
|
||
.into_iter()
|
||
.find(|(_, a)| d.cross(*a).len() < 1e-9 && d.dot(*a) > 0.0)
|
||
.map(|(n, _)| n.to_string())
|
||
}
|
||
|
||
fn pt3(v: Vec3) -> Value {
|
||
json!([v.x, v.y, v.z])
|
||
}
|
||
|
||
/// A dialog that edits an existing feature, filled from it. The timeline is rolled back to just
|
||
/// before the feature, so its references show on the geometry they refer to.
|
||
pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dialog> {
|
||
let s = &app.session;
|
||
let f = s.doc.feature(id)?.clone();
|
||
let st = s.model.state();
|
||
let start = |cmd: &str| Dialog::for_command(app, cmd);
|
||
let mut d = match &f.kind {
|
||
FeatureKind::Extrude { sketch, profiles, extent, operation, targets } => {
|
||
let mut d = start("Extrude")?;
|
||
d.kind = Kind::Extrude {
|
||
distance: extent.distance.clone(),
|
||
direction: match (extent.direction, &extent.distance2) {
|
||
(_, Some(_)) => TWO_SIDES,
|
||
(Direction::Positive, None) => 0,
|
||
(Direction::Negative, None) => 1,
|
||
(Direction::Symmetric, None) => 2,
|
||
},
|
||
operation: op_index(operation),
|
||
auto_op: false,
|
||
distance2: extent.distance2.clone().unwrap_or_else(|| "10 mm".into()),
|
||
taper: extent.taper.clone().unwrap_or_default(),
|
||
};
|
||
let items = st.sketch(*sketch).map(|ss| profile_indices(ss, profiles)).unwrap_or_default();
|
||
if let Some(inp) = d.inputs.first_mut() {
|
||
inp.items = items.into_iter().map(|index| Sel::Profile { sketch: *sketch, index }).collect();
|
||
}
|
||
for (k, v) in [
|
||
("start_offset", extent.start_offset.as_ref().map(|x| json!(x))),
|
||
("through_all", extent.through_all.then_some(json!(true))),
|
||
("targets", (!targets.is_empty()).then(|| json!(targets))),
|
||
] {
|
||
if let Some(v) = v {
|
||
d.extra.insert(k.into(), v);
|
||
}
|
||
}
|
||
d
|
||
}
|
||
FeatureKind::Revolve { sketch, profiles, axis, angle, operation, targets } => {
|
||
let mut d = start("Revolve")?;
|
||
d.kind = Kind::Revolve { angle: angle.clone(), operation: op_index(operation) };
|
||
let items = st.sketch(*sketch).map(|ss| profile_indices(ss, profiles)).unwrap_or_default();
|
||
if let Some(inp) = d.inputs.get_mut(0) {
|
||
inp.items = items.into_iter().map(|index| Sel::Profile { sketch: *sketch, index }).collect();
|
||
}
|
||
let ax = match axis {
|
||
AxisRef::World { axis } => Some(Sel::Axis { name: axis.to_ascii_uppercase() }),
|
||
AxisRef::SketchLine { curve } => Some(Sel::SketchCurve { id: curve.clone() }),
|
||
_ => None,
|
||
};
|
||
if let Some(inp) = d.inputs.get_mut(1) {
|
||
inp.items = ax.into_iter().collect();
|
||
}
|
||
if !targets.is_empty() {
|
||
d.extra.insert("targets".into(), json!(targets));
|
||
}
|
||
d
|
||
}
|
||
FeatureKind::Fillet { edges, radius, .. } | FeatureKind::Chamfer { edges, distance: radius, .. } => {
|
||
let chamfer = matches!(f.kind, FeatureKind::Chamfer { .. });
|
||
let mut d = start(if chamfer { "FusionChamferCommand" } else { "FusionFilletEdgesCommand" })?;
|
||
d.kind = Kind::Fillet { radius: radius.clone(), chamfer, chain: false };
|
||
if let Some(inp) = d.inputs.first_mut() {
|
||
inp.items = edges.iter().filter_map(|p| edge_sel(s, *p)).collect();
|
||
}
|
||
d
|
||
}
|
||
FeatureKind::Shell { faces, thickness, .. } => {
|
||
let mut d = start("FusionShellBodyCommand")?;
|
||
d.kind = Kind::Shell { thickness: thickness.clone() };
|
||
if let Some(inp) = d.inputs.first_mut() {
|
||
inp.items = faces.iter().filter_map(|p| face_sel(s, *p)).collect();
|
||
}
|
||
d
|
||
}
|
||
FeatureKind::Draft { faces, angle, neutral, pull, .. } => {
|
||
let mut d = start("FusionDraftCommand")?;
|
||
d.kind = Kind::Draft { angle: angle.clone() };
|
||
if let Some(inp) = d.inputs.get_mut(0) {
|
||
inp.items = faces.iter().filter_map(|p| face_sel(s, *p)).collect();
|
||
}
|
||
if let Some(inp) = d.inputs.get_mut(1) {
|
||
inp.items = plane_sel(s, neutral).into_iter().collect();
|
||
}
|
||
d.extra.insert("pull".into(), pt3(*pull));
|
||
d
|
||
}
|
||
FeatureKind::Hole { position, direction, diameter, depth, hole, .. } => {
|
||
let mut d = start("FusionHoleCommand")?;
|
||
let mut k = hole_defaults();
|
||
if let Kind::Hole { diameter: dia, depth: dep, kind, cb_diameter, cb_depth, cs_diameter, cs_angle } = &mut k {
|
||
*dia = diameter.clone();
|
||
*dep = depth.clone().unwrap_or_default();
|
||
match hole {
|
||
HoleKind::Counterbore { cb_diameter: a, cb_depth: b } => {
|
||
*kind = 1;
|
||
*cb_diameter = a.clone();
|
||
*cb_depth = b.clone();
|
||
}
|
||
HoleKind::Countersink { cs_diameter: a, cs_angle: b } => {
|
||
*kind = 2;
|
||
*cs_diameter = a.clone();
|
||
*cs_angle = b.clone();
|
||
}
|
||
HoleKind::Drilled { tip_angle } => {
|
||
d.extra.insert("tip_angle".into(), json!(tip_angle));
|
||
}
|
||
HoleKind::Simple => {}
|
||
}
|
||
}
|
||
d.kind = k;
|
||
if let Some(inp) = d.inputs.first_mut() {
|
||
inp.items = vec![face_sel(s, *position).unwrap_or(Sel::Face { body: String::new(), index: 0, point: *position })];
|
||
}
|
||
d.extra.insert("direction".into(), pt3(*direction));
|
||
d
|
||
}
|
||
FeatureKind::Box { corner, length, width, height, operation } => {
|
||
let mut d = start("PrimitiveBox")?;
|
||
d.kind = Kind::Primitive {
|
||
cmd: "PrimitiveBox",
|
||
fields: vec![("length", length.clone()), ("width", width.clone()), ("height", height.clone())],
|
||
operation: op_index(operation),
|
||
};
|
||
d.extra.insert("corner".into(), pt3(*corner));
|
||
d
|
||
}
|
||
FeatureKind::Cylinder { base, axis, radius, height, operation } => {
|
||
let mut d = start("PrimitiveCylinder")?;
|
||
d.kind = Kind::Primitive {
|
||
cmd: "PrimitiveCylinder",
|
||
fields: vec![("radius", radius.clone()), ("height", height.clone())],
|
||
operation: op_index(operation),
|
||
};
|
||
d.extra.insert("base".into(), pt3(*base));
|
||
d.extra.insert("axis".into(), pt3(*axis));
|
||
d
|
||
}
|
||
FeatureKind::Sphere { center, radius, operation } => {
|
||
let mut d = start("PrimitiveSphere")?;
|
||
d.kind = Kind::Primitive { cmd: "PrimitiveSphere", fields: vec![("radius", radius.clone())], operation: op_index(operation) };
|
||
d.extra.insert("center".into(), pt3(*center));
|
||
d
|
||
}
|
||
FeatureKind::Torus { center, major, minor, operation } => {
|
||
let mut d = start("PrimitiveTorus")?;
|
||
d.kind = Kind::Primitive {
|
||
cmd: "PrimitiveTorus",
|
||
fields: vec![("major", major.clone()), ("minor", minor.clone())],
|
||
operation: op_index(operation),
|
||
};
|
||
d.extra.insert("center".into(), pt3(*center));
|
||
d
|
||
}
|
||
FeatureKind::Combine { target, tools, operation, keep_tools } => {
|
||
let mut d = start("FusionCombineCommand")?;
|
||
d.kind = Kind::Combine { operation: op_index(operation).max(1), keep_tools: *keep_tools };
|
||
if let Some(inp) = d.inputs.get_mut(0) {
|
||
inp.items = vec![Sel::Body { name: target.clone() }];
|
||
}
|
||
if let Some(inp) = d.inputs.get_mut(1) {
|
||
inp.items = tools.iter().map(|n| Sel::Body { name: n.clone() }).collect();
|
||
}
|
||
d
|
||
}
|
||
FeatureKind::Mirror { features, plane, .. } => {
|
||
let mut d = start("MirrorCommand")?;
|
||
let ids: Vec<u64> = features.iter().filter_map(|n| s.doc.find_feature(n).map(|f| f.id)).collect();
|
||
if let Some(inp) = d.inputs.get_mut(0) {
|
||
inp.items = st.bodies.iter().filter(|b| ids.contains(&b.feature)).map(|b| Sel::Body { name: b.name.clone() }).collect();
|
||
}
|
||
if let Some(inp) = d.inputs.get_mut(1) {
|
||
inp.items = plane_sel(s, plane).into_iter().collect();
|
||
}
|
||
d
|
||
}
|
||
FeatureKind::Pattern { features, pattern, .. } => {
|
||
let ids: Vec<u64> = features.iter().filter_map(|n| s.doc.find_feature(n).map(|f| f.id)).collect();
|
||
let bodies: Vec<Sel> = st.bodies.iter().filter(|b| ids.contains(&b.feature)).map(|b| Sel::Body { name: b.name.clone() }).collect();
|
||
let mut d = match pattern {
|
||
PatternKind::Rectangular { dir1, count1, spacing1, dir2, count2, spacing2 } => {
|
||
let mut d = start("PatternRectangular")?;
|
||
d.kind = Kind::PatternRect {
|
||
count: count1.clone(),
|
||
spacing: spacing1.clone(),
|
||
count2: count2.clone().unwrap_or_else(|| "1".into()),
|
||
spacing2: spacing2.clone().unwrap_or_else(|| "20 mm".into()),
|
||
};
|
||
// Directions along the origin axes show as those axes; others are kept as is.
|
||
for (i, (key, dir)) in [("dir1", Some(*dir1)), ("dir2", *dir2)].into_iter().enumerate() {
|
||
let Some(dir) = dir else { continue };
|
||
match world_axis(dir) {
|
||
Some(name) => {
|
||
if let Some(inp) = d.inputs.get_mut(i + 1) {
|
||
inp.items = vec![Sel::Axis { name }];
|
||
}
|
||
}
|
||
None => {
|
||
d.extra.insert(key.into(), pt3(dir));
|
||
}
|
||
}
|
||
}
|
||
d
|
||
}
|
||
PatternKind::Circular { origin, axis, count, angle } => {
|
||
let mut d = start("PatternCircular")?;
|
||
d.kind = Kind::PatternCirc { count: count.clone(), angle: angle.clone() };
|
||
match world_axis(*axis).filter(|_| origin.len() < 1e-9) {
|
||
Some(name) => {
|
||
if let Some(inp) = d.inputs.get_mut(1) {
|
||
inp.items = vec![Sel::Axis { name }];
|
||
}
|
||
}
|
||
None => {
|
||
d.extra.insert("axis".into(), json!({"origin": pt3(*origin), "dir": pt3(*axis)}));
|
||
}
|
||
}
|
||
d
|
||
}
|
||
// No dialog for a path pattern yet: edit it with timeline.redefine.
|
||
PatternKind::Path { .. } => return None,
|
||
};
|
||
if let Some(inp) = d.inputs.first_mut() {
|
||
inp.items = bodies;
|
||
}
|
||
d
|
||
}
|
||
FeatureKind::Loft { sections, operation, targets } => {
|
||
let mut d = start("SolidLoft")?;
|
||
d.kind = Kind::Loft { operation: op_index(operation) };
|
||
if let Some(inp) = d.inputs.first_mut() {
|
||
inp.items = sections
|
||
.iter()
|
||
.flat_map(|sec| {
|
||
let idx = st.sketch(sec.sketch).map(|ss| profile_indices(ss, &sec.profiles)).unwrap_or_default();
|
||
idx.into_iter().map(move |index| Sel::Profile { sketch: sec.sketch, index })
|
||
})
|
||
.collect();
|
||
}
|
||
if !targets.is_empty() {
|
||
d.extra.insert("targets".into(), json!(targets));
|
||
}
|
||
d
|
||
}
|
||
FeatureKind::Sweep { sketch, profiles, path, operation, targets, .. } => {
|
||
let mut d = start("Sweep")?;
|
||
d.kind = Kind::Sweep { operation: op_index(operation) };
|
||
let items = st.sketch(*sketch).map(|ss| profile_indices(ss, profiles)).unwrap_or_default();
|
||
if let Some(inp) = d.inputs.get_mut(0) {
|
||
inp.items = items.into_iter().map(|index| Sel::Profile { sketch: *sketch, index }).collect();
|
||
}
|
||
if let Some(inp) = d.inputs.get_mut(1) {
|
||
inp.items = path.iter().map(|id| Sel::SketchCurve { id: id.clone() }).collect();
|
||
}
|
||
if !targets.is_empty() {
|
||
d.extra.insert("targets".into(), json!(targets));
|
||
}
|
||
d
|
||
}
|
||
FeatureKind::Move { bodies, translate, rotate_axis, angle } => {
|
||
let mut d = start("FusionMoveCommand")?;
|
||
let [x, y, z] = translate.clone();
|
||
d.kind = Kind::Move { x, y, z };
|
||
if let Some(inp) = d.inputs.first_mut() {
|
||
inp.items = bodies.iter().map(|n| Sel::Body { name: n.clone() }).collect();
|
||
}
|
||
if let Some(a) = rotate_axis {
|
||
d.extra.insert("axis".into(), pt3(*a));
|
||
}
|
||
if let Some(a) = angle {
|
||
d.extra.insert("angle".into(), json!(a));
|
||
}
|
||
d
|
||
}
|
||
_ => return None,
|
||
};
|
||
d.editing = Some((id, marker));
|
||
d.active = 0;
|
||
d.error = None;
|
||
Some(d)
|
||
}
|