With Object Type = Bodies, the Mirror, Rectangular Pattern and Circular
Pattern dialogs sent the feature that created each picked body
({"features": ["Extrude1"]}), so the copies replayed only that feature and
lost everything done to the body afterwards (a fillet, a chamfer, a cut).
They now send the bodies themselves ({"bodies": [...]}), which the commands
already support and the Path Pattern dialog already did. Picking Features
is unchanged.
Editing a body Mirror or Pattern from the timeline now reopens the dialog
with its bodies selected.
Copies made this way take the body-copy names ("Body1 (2)"), as the
commands already give; pattern_direction_edge now looks up "Body1 (3)".
Tests: UI scenarios mirror_body_keeps_fillets and pattern_body_keeps_fillets
(filleted cube, copy and edit through the dialogs); both fail without the
fix with the copies' sharp 8000 mm3 volume.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3674 lines
168 KiB
Rust
3674 lines
168 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, Stroke, 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, FilletStyle, 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;
|
||
|
||
pub(crate) const OPS: [&str; 4] = ["new", "join", "cut", "intersect"];
|
||
pub(crate) const OP_LABELS: [&str; 4] = ["New Body", "Join", "Cut", "Intersect"];
|
||
const DIRS: [&str; 4] = ["positive", "negative", "symmetric", "positive"];
|
||
/// 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,
|
||
/// Into a body: cut; additions: join when a solid exists, otherwise new.
|
||
/// until the user picks one.
|
||
auto_op: bool,
|
||
/// The other side's distance (two sides).
|
||
distance2: String,
|
||
/// Taper angle (empty or zero: none).
|
||
taper: String,
|
||
/// An explicitly authored zero taper is still a model parameter.
|
||
taper_present: bool,
|
||
/// Optional distance beyond the selected end object.
|
||
to_offset: String,
|
||
/// Start offset from the profile plane (empty: start on the profile plane).
|
||
start: String,
|
||
/// Extent type All (through everything) instead of a distance.
|
||
all: bool,
|
||
},
|
||
Revolve {
|
||
angle: String,
|
||
operation: usize,
|
||
/// An arbitrary stored line until the user deliberately picks a replacement.
|
||
axis: Option<Value>,
|
||
},
|
||
Fillet {
|
||
radius: String,
|
||
chamfer: bool,
|
||
chain: bool,
|
||
/// Chamfer type: 0 equal distance, 1 two distances, 2 distance and angle; with the
|
||
/// second distance, the angle and which face the first distance is on.
|
||
ctype: usize,
|
||
distance2: String,
|
||
angle: String,
|
||
flip: 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,
|
||
/// Boundary Fill: the cells the tool bodies make (a point inside, a description, filled
|
||
/// or not), found again when the tools change.
|
||
BoundaryFill {
|
||
operation: usize,
|
||
cells: Vec<(Vec3, String, bool)>,
|
||
of: Vec<Sel>,
|
||
},
|
||
/// 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,
|
||
},
|
||
/// 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/Copy (see `dialogs_move`): the move type, X/Y/Z distances (Point to Position: the
|
||
/// target), X/Y/Z angles (Free Move) and the angle (Rotate).
|
||
Move {
|
||
mode: usize,
|
||
x: String,
|
||
y: String,
|
||
z: String,
|
||
rx: String,
|
||
ry: String,
|
||
rz: String,
|
||
angle: String,
|
||
},
|
||
Hole {
|
||
diameter: String,
|
||
depth: String,
|
||
kind: usize,
|
||
cb_diameter: String,
|
||
cb_depth: String,
|
||
cs_diameter: String,
|
||
cs_angle: String,
|
||
opts: HoleOpts,
|
||
},
|
||
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>,
|
||
},
|
||
/// Joints and components (`dialogs_assembly`).
|
||
Assembly(crate::dialogs_assembly::Asm),
|
||
/// Sheet metal (`dialogs_sheet`).
|
||
Sheet(crate::dialogs_sheet::Sm),
|
||
/// Surface tools and Split Face (`dialogs_surface`).
|
||
Surface(crate::dialogs_surface::Surf),
|
||
/// Plastic features (`dialogs_plastic`).
|
||
Plastic(crate::dialogs_plastic::Pl),
|
||
/// Appearance (`dialogs_appearance`).
|
||
Appearance(crate::dialogs_appearance::Ap),
|
||
/// Contact sets, motion studies, exploded views, configurations (`dialogs_motion`).
|
||
Motion(crate::dialogs_motion::Mo),
|
||
/// Insert Part from the standard parts library (`dialogs_parts`).
|
||
Part(crate::dialogs_parts::Pt),
|
||
}
|
||
|
||
/// The rest of the Hole dialog: placement, extents, tap type and drill point.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct HoleOpts {
|
||
/// Placement at several sketch points instead of one face point.
|
||
pub multiple: bool,
|
||
/// Extents: through all instead of a distance.
|
||
pub all: bool,
|
||
/// Drill point: an angled tip instead of a flat bottom.
|
||
pub angled: bool,
|
||
pub tip_angle: String,
|
||
/// Tap type: tapped (a cosmetic thread) instead of simple.
|
||
pub tapped: bool,
|
||
pub thread: String,
|
||
}
|
||
|
||
impl Default for HoleOpts {
|
||
fn default() -> Self {
|
||
HoleOpts { multiple: false, all: false, angled: true, tip_angle: "118 deg".into(), tapped: false, thread: "M5".into() }
|
||
}
|
||
}
|
||
|
||
#[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>)>,
|
||
/// A user pick or clear makes the profile selector explicit, even if all are reselected.
|
||
pub profile_selection_changed: bool,
|
||
/// Give the on-canvas value box the keyboard (and select its text) on the next frame.
|
||
pub focus: bool,
|
||
/// Folded to its header (the "−" glyph).
|
||
pub collapsed: 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 { .. } => a & selection::FEATURES != 0,
|
||
Sel::SketchPoint { .. }
|
||
| Sel::SketchConstraint { .. }
|
||
| Sel::Occurrence { .. }
|
||
| Sel::ExternalSketchCurve { .. }
|
||
| Sel::ExternalSketchPoint { .. } => false,
|
||
}
|
||
}
|
||
|
||
impl Dialog {
|
||
pub(crate) fn new(kind: Kind, inputs: Vec<SelInput>) -> Dialog {
|
||
Dialog {
|
||
kind,
|
||
inputs,
|
||
active: 0,
|
||
error: None,
|
||
editing: None,
|
||
profile_selection_changed: false,
|
||
focus: true,
|
||
collapsed: false,
|
||
extra: serde_json::Map::new(),
|
||
}
|
||
}
|
||
|
||
pub fn for_command(app: &SolveApp, id: &str) -> Option<Dialog> {
|
||
let s = &app.session;
|
||
let has_bodies = !s.world_state().bodies.is_empty();
|
||
let mut d = match id {
|
||
"sketch.create" => Dialog::new(Kind::Sketch, vec![SelInput::new("Plane", PLANES | PLANAR_FACES, false)]),
|
||
"solid.extrude" => Dialog::new(
|
||
Kind::Extrude {
|
||
distance: "10 mm".into(),
|
||
direction: 0,
|
||
operation: usize::from(has_bodies),
|
||
auto_op: true,
|
||
distance2: "10 mm".into(),
|
||
taper: "0 deg".into(),
|
||
taper_present: false,
|
||
to_offset: String::new(),
|
||
start: String::new(),
|
||
all: false,
|
||
},
|
||
vec![SelInput::new("Profiles", PROFILES | PLANAR_FACES, true)],
|
||
),
|
||
"solid.revolve" => Dialog::new(
|
||
Kind::Revolve { angle: "360 deg".into(), operation: 0, axis: None },
|
||
vec![SelInput::new("Profile", PROFILES, true), SelInput::new("Axis", AXES, false)],
|
||
),
|
||
"solid.fillet" => Dialog::new(fillet_kind("2 mm", false), vec![SelInput::new("Edges", EDGES | FACES, true)]),
|
||
"solid.chamfer" => Dialog::new(fillet_kind("1 mm", true), vec![SelInput::new("Edges", EDGES | FACES, true)]),
|
||
"solid.shell" => {
|
||
let mut d = Dialog::new(Kind::Shell { thickness: "2 mm".into() }, vec![SelInput::new("Faces/Body", FACES, true)]);
|
||
// Fusion opens faces joined smoothly to the picked ones too.
|
||
d.extra.insert("tangent_chain".into(), json!(true));
|
||
d
|
||
}
|
||
"solid.draft" => Dialog::new(
|
||
Kind::Draft { angle: "5 deg".into() },
|
||
vec![SelInput::new("Faces", FACES, true), SelInput::new("Neutral plane", PLANES | PLANAR_FACES, false)],
|
||
),
|
||
"solid.mirror" => {
|
||
Dialog::new(Kind::Mirror, vec![SelInput::new("Objects", BODIES, true), SelInput::new("Mirror Plane", PLANES | PLANAR_FACES, false)])
|
||
}
|
||
"solid.rib" | "solid.web" => Dialog::new(
|
||
Kind::Rib { web: id == "solid.web", thickness: "2 mm".into(), depth: String::new(), flip: false },
|
||
vec![SelInput::new("Curves", CURVES, true)],
|
||
),
|
||
"solid.emboss" => Dialog::new(
|
||
Kind::Emboss { depth: "1 mm".into(), deboss: false },
|
||
vec![SelInput::new("Profiles", PROFILES, true), SelInput::new("Target Face (optional)", FACES, false)],
|
||
),
|
||
"solid.replace_face" => Dialog::new(
|
||
Kind::ReplaceFace,
|
||
vec![SelInput::new("Faces", PLANAR_FACES, true), SelInput::new("Target", PLANES | PLANAR_FACES, false)],
|
||
),
|
||
"SurfaceSculpt" => {
|
||
Dialog::new(Kind::BoundaryFill { operation: 0, cells: Vec::new(), of: Vec::new() }, vec![SelInput::new("Tools", BODIES, true)])
|
||
}
|
||
"solid.align" => Dialog::new(
|
||
Kind::Align { flip: false },
|
||
vec![
|
||
SelInput::new("Bodies", BODIES, true),
|
||
SelInput::new("From", FACES | EDGES | selection::VERTICES, false),
|
||
SelInput::new("To", FACES | EDGES | selection::VERTICES, false),
|
||
],
|
||
),
|
||
"solid.remove" => Dialog::new(Kind::Remove, vec![SelInput::new("Bodies", BODIES, true)]),
|
||
"solid.pattern.path" => Dialog::new(
|
||
Kind::PathPattern { count: "4".into(), spacing: "20 mm".into() },
|
||
vec![SelInput::new("Objects", BODIES, true), SelInput::new("Path", CURVES, true)],
|
||
),
|
||
"solid.pipe" => Dialog::new(Kind::Pipe { diameter: "5 mm".into(), wall: String::new() }, vec![SelInput::new("Path", CURVES, true)]),
|
||
"solid.bounding_solid" => Dialog::new(Kind::Stock { margin: "2 mm".into() }, vec![SelInput::new("Bodies (all if none)", BODIES, true)]),
|
||
"construct.plane.offset" => {
|
||
Dialog::new(Kind::OffsetPlane { offset: "10 mm".into() }, vec![SelInput::new("Plane", PLANES | PLANAR_FACES, false)])
|
||
}
|
||
"construct.plane.at_angle" => Dialog::new(
|
||
Kind::AnglePlane { angle: "45 deg".into() },
|
||
vec![SelInput::new("Plane", PLANES, false), SelInput::new("Axis", AXES, false)],
|
||
),
|
||
"solid.split_body" => {
|
||
Dialog::new(Kind::Split, vec![SelInput::new("Body to split", BODIES, false), SelInput::new("Splitting tool", PLANES | FACES, false)])
|
||
}
|
||
"solid.scale" => Dialog::new(Kind::Scale { factor: "2".into() }, vec![SelInput::new("Bodies", BODIES, true)]),
|
||
"solid.offset_face" => Dialog::new(Kind::OffsetFaces { distance: "2 mm".into() }, vec![SelInput::new("Faces", PLANAR_FACES, true)]),
|
||
"solid.thread" => {
|
||
Dialog::new(Kind::Thread { designation: String::new(), length: String::new() }, vec![SelInput::new("Cylindrical face", FACES, false)])
|
||
}
|
||
"material.assign" => Dialog::new(Kind::Material { index: 1 }, vec![SelInput::new("Bodies", BODIES, true)]),
|
||
"inspect.section" => {
|
||
Dialog::new(Kind::Section { offset: "0 mm".into(), flip: false }, vec![SelInput::new("Plane", PLANES | PLANAR_FACES, false)])
|
||
}
|
||
"inspect.measure" => {
|
||
Dialog::new(Kind::Measure { result: None, of: Vec::new() }, vec![SelInput::new("Items", FACES | EDGES | selection::VERTICES, true)])
|
||
}
|
||
"solid.pattern.rectangular" => 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 | EDGES, false),
|
||
SelInput::new("Direction 2", AXES | EDGES, false),
|
||
],
|
||
),
|
||
"solid.pattern.circular" => Dialog::new(
|
||
Kind::PatternCirc { count: "6".into(), angle: "360 deg".into() },
|
||
vec![SelInput::new("Objects", BODIES, true), SelInput::new("Axis", AXES, false)],
|
||
),
|
||
"solid.loft" => Dialog::new(Kind::Loft { operation: usize::from(has_bodies) }, vec![SelInput::new("Profiles", PROFILES, true)]),
|
||
"solid.sweep" => Dialog::new(
|
||
Kind::Sweep { operation: usize::from(has_bodies) },
|
||
vec![SelInput::new("Profile", PROFILES, true), SelInput::new("Path", CURVES, true)],
|
||
),
|
||
"solid.move" => Dialog::new(crate::dialogs_move::kind(0), crate::dialogs_move::inputs(0)),
|
||
"solid.hole" => Dialog::new(hole_defaults(), vec![SelInput::new("Face", PLANAR_FACES, true)]),
|
||
"solid.box" => Dialog::new(
|
||
Kind::Primitive {
|
||
cmd: "solid.box",
|
||
fields: vec![("length", "20".into()), ("width", "20".into()), ("height", "20".into())],
|
||
operation: 0,
|
||
},
|
||
vec![],
|
||
),
|
||
"solid.cylinder" => Dialog::new(
|
||
Kind::Primitive { cmd: "solid.cylinder", fields: vec![("diameter", "20".into()), ("height", "20".into())], operation: 0 },
|
||
vec![],
|
||
),
|
||
"solid.sphere" => Dialog::new(Kind::Primitive { cmd: "solid.sphere", fields: vec![("diameter", "20".into())], operation: 0 }, vec![]),
|
||
"solid.torus" => {
|
||
Dialog::new(Kind::Primitive { cmd: "solid.torus", fields: vec![("major", "20".into()), ("minor", "5".into())], operation: 0 }, vec![])
|
||
}
|
||
"solid.coil" => Dialog::new(
|
||
Kind::Primitive {
|
||
cmd: "solid.coil",
|
||
fields: vec![("diameter", "40".into()), ("revolutions", "4".into()), ("pitch", "8".into()), ("section_size", "3".into())],
|
||
operation: 0,
|
||
},
|
||
vec![],
|
||
),
|
||
"solid.combine" => Dialog::new(
|
||
Kind::Combine { operation: 1, keep_tools: false },
|
||
vec![SelInput::new("Target Body", BODIES, false), SelInput::new("Tool Bodies", BODIES, true)],
|
||
),
|
||
"parameters.change" => Dialog::new(Kind::Params { new_name: String::new(), new_expr: String::new() }, vec![]),
|
||
_ => {
|
||
let (kind, inputs) = crate::dialogs_assembly::start(app, id)
|
||
.or_else(|| crate::dialogs_sheet::start(app, id))
|
||
.or_else(|| crate::dialogs_surface::start(app, id))
|
||
.or_else(|| crate::dialogs_plastic::start(app, id))
|
||
.or_else(|| crate::dialogs_appearance::start(app, id))
|
||
.or_else(|| crate::dialogs_motion::start(app, id))
|
||
.or_else(|| crate::dialogs_parts::start(app, id))?;
|
||
Dialog::new(kind, inputs)
|
||
}
|
||
};
|
||
// Patterns of features picked in the timeline: the objects are features.
|
||
if is_pattern(&d.kind)
|
||
&& s.selection.iter().any(|x| matches!(x, Sel::Feature { .. }))
|
||
&& let Some(inp) = d.inputs.first_mut()
|
||
{
|
||
inp.accept = selection::FEATURES;
|
||
inp.label = objects_label(inp.accept);
|
||
}
|
||
// 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;
|
||
}
|
||
// Planar-only inputs take planar faces only.
|
||
let planar_ok = match sel {
|
||
Sel::Face { body, index, .. } => planar_face(s, body, *index).is_some(),
|
||
_ => true,
|
||
};
|
||
let takes = |i: &SelInput| {
|
||
fits(i.accept, sel) && (i.multi || i.items.is_empty()) && (planar_ok || i.accept & FACES != 0 || !matches!(sel, Sel::Face { .. }))
|
||
};
|
||
if let Some(inp) = d.inputs.iter_mut().find(|i| takes(i)) {
|
||
// Fillets and chamfers take an edge with its tangent chain, as a pick does.
|
||
let picked = match (&d.kind, sel) {
|
||
(Kind::Fillet { chain: true, .. }, Sel::Edge { body, index, .. }) => s
|
||
.model
|
||
.state()
|
||
.body(body)
|
||
.map(|b| {
|
||
let m = b.mesh();
|
||
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::<Vec<_>>()
|
||
})
|
||
.unwrap_or_else(|| vec![sel.clone()]),
|
||
(Kind::Measure { .. }, _) => vec![crate::viewport::measurement_selection(s, sel)],
|
||
_ if inp.multi => crate::viewport::grouped_selection(s, sel),
|
||
_ => vec![sel.clone()],
|
||
};
|
||
for x in picked {
|
||
if !inp.items.iter().any(|y| selection::same_item(y, &x)) {
|
||
inp.items.push(x);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// 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 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 {
|
||
match &self.kind {
|
||
Kind::Assembly(k) => return k.previews(),
|
||
Kind::Sheet(k) => return k.previews(),
|
||
Kind::Surface(_) => return true,
|
||
Kind::Plastic(k) => return k.previews(),
|
||
Kind::Part(_) => return true,
|
||
_ => {}
|
||
}
|
||
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 { .. }
|
||
| Kind::BoundaryFill { .. }
|
||
)
|
||
}
|
||
|
||
/// 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 { to_offset, .. } if to_object_on(&self.inputs) => ("To Offset", ValueKind::Length, to_offset),
|
||
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 { x, .. } => ("X", ValueKind::Length, x),
|
||
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),
|
||
Kind::Sheet(k) => return k.primary(&self.inputs),
|
||
Kind::Surface(k) => return k.primary(),
|
||
Kind::Plastic(k) => return k.primary(),
|
||
_ => return None,
|
||
})
|
||
}
|
||
|
||
/// Dialogs holding a table get the wide limit.
|
||
pub fn is_wide(&self) -> bool {
|
||
matches!(self.kind, Kind::Params { .. }) || matches!(&self.kind, Kind::Motion(k) if k.wide())
|
||
}
|
||
|
||
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> {
|
||
let inp = self.active_input()?;
|
||
// Features are also picked on the canvas: a face stands for the feature that made it.
|
||
if inp.accept == selection::FEATURES {
|
||
return match h {
|
||
Hit::Face { body, index, point } => feature_of_face(s, body, *index, *point).map(|id| Sel::Feature { id }),
|
||
_ => None,
|
||
};
|
||
}
|
||
inp.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) {
|
||
if self.active == 0 && matches!(self.kind, Kind::Extrude { .. } | Kind::Revolve { .. } | Kind::Emboss { .. }) {
|
||
self.profile_selection_changed = true;
|
||
}
|
||
let measure = matches!(self.kind, Kind::Measure { .. });
|
||
let sel = if measure { crate::viewport::measurement_selection(s, &sel) } else { sel };
|
||
let chain = matches!(self.kind, Kind::Fillet { chain: true, .. });
|
||
let mut picked =
|
||
if !measure && self.active_input().is_some_and(|i| i.multi) { crate::viewport::grouped_selection(s, &sel) } else { 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.world_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, s: &Session, sels: Vec<Sel>, add: bool) {
|
||
if self.active == 0 && matches!(self.kind, Kind::Extrude { .. } | Kind::Revolve { .. } | Kind::Emboss { .. }) {
|
||
self.profile_selection_changed = true;
|
||
}
|
||
let measure = matches!(self.kind, Kind::Measure { .. });
|
||
let Some(inp) = self.inputs.get_mut(self.active) else { return };
|
||
if !add {
|
||
inp.items.clear();
|
||
}
|
||
for x in sels {
|
||
let x = if measure { crate::viewport::measurement_selection(s, &x) } else { x };
|
||
if fits(inp.accept, &x) && !inp.items.iter().any(|y| selection::same_item(y, &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.world_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.world_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.
|
||
pub(crate) fn field(ui: &mut egui::Ui, s: &mut String) -> bool {
|
||
let r = ui.text_edit_singleline(s);
|
||
publish_row(&r);
|
||
crate::params_dialog::complete(ui, &r, s);
|
||
r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))
|
||
}
|
||
|
||
pub(crate) fn combo(ui: &mut egui::Ui, id: &str, labels: &[&str], sel: &mut usize) {
|
||
let row = ROW.with(|r| r.borrow().clone());
|
||
let selected = labels.get(*sel).copied().map(tr).unwrap_or("");
|
||
let r = egui::ComboBox::from_id_salt(id).selected_text(selected).width(FIELD_W).show_ui(ui, |ui| {
|
||
for (i, l) in labels.iter().enumerate() {
|
||
let item = ui.selectable_value(sel, i, tr(l));
|
||
crate::scenario::publish_handle(&format!("dialog:{row}:{l}"), item.rect.center());
|
||
}
|
||
});
|
||
publish_row(&r.response);
|
||
}
|
||
|
||
thread_local! {
|
||
/// The label of the dialog row being laid out (its widgets publish `dialog:<label>` handles).
|
||
static ROW: std::cell::RefCell<String> = const { std::cell::RefCell::new(String::new()) };
|
||
}
|
||
|
||
fn tr(text: &str) -> &str {
|
||
crate::i18n::tr_label(text)
|
||
}
|
||
|
||
/// A dialog row's widget, for scenarios and demos: the handle `dialog:<row label>`.
|
||
fn publish_row(r: &egui::Response) {
|
||
ROW.with(|row| crate::scenario::publish_handle(&format!("dialog:{}", row.borrow()), r.rect.center()));
|
||
}
|
||
|
||
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::OffsetPlane { .. } => "OFFSET PLANE",
|
||
Kind::Rib { web: false, .. } => "RIB",
|
||
Kind::Rib { web: true, .. } => "WEB",
|
||
Kind::Emboss { .. } => "EMBOSS",
|
||
Kind::ReplaceFace => "REPLACE FACE",
|
||
Kind::Align { .. } => "ALIGN",
|
||
Kind::BoundaryFill { .. } => "BOUNDARY FILL",
|
||
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 {
|
||
"solid.box" => "BOX",
|
||
"solid.cylinder" => "CYLINDER",
|
||
"solid.sphere" => "SPHERE",
|
||
"solid.coil" => "COIL",
|
||
_ => "TORUS",
|
||
},
|
||
Kind::Combine { .. } => "COMBINE",
|
||
Kind::Params { .. } => "PARAMETERS",
|
||
Kind::EditParam { .. } => "EDIT DIMENSION",
|
||
Kind::Rename { .. } => "RENAME",
|
||
Kind::ConfirmDelete { .. } => "DELETE FEATURE",
|
||
Kind::Assembly(k) => k.title(),
|
||
Kind::Sheet(k) => k.title(),
|
||
Kind::Surface(k) => k.title(),
|
||
Kind::Plastic(k) => k.title(),
|
||
Kind::Appearance(_) => "APPEARANCE",
|
||
Kind::Motion(k) => k.title(),
|
||
Kind::Part(_) => "INSERT PART",
|
||
}
|
||
}
|
||
|
||
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"
|
||
}
|
||
}
|
||
|
||
/// Width of the label column; longer labels are cut with "…".
|
||
const LABEL_W: f32 = 92.0;
|
||
/// The label column grows to fit its labels up to this, then long labels wrap.
|
||
const LABEL_MAX_W: f32 = 150.0;
|
||
|
||
/// A row label in the dialog's label column (the grid sizes the column to its widest label).
|
||
pub(crate) fn row_label(ui: &mut egui::Ui, text: &str) {
|
||
ROW.with(|r| *r.borrow_mut() = text.to_string());
|
||
let display_text = tr(text);
|
||
let full = egui::WidgetText::from(display_text).into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::TextStyle::Body).size().x;
|
||
let w = full.clamp(LABEL_W, LABEL_MAX_W);
|
||
ui.allocate_ui_with_layout(vec2(w, 22.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||
ui.set_width(w);
|
||
ui.add(egui::Label::new(display_text).wrap());
|
||
});
|
||
}
|
||
|
||
thread_local! {
|
||
/// Faces already traced to their feature, by (revision, body, face).
|
||
static FACE_FEATURE: std::cell::RefCell<(u64, std::collections::HashMap<(String, usize), Option<u64>>)> = Default::default();
|
||
}
|
||
|
||
/// The feature that made a face: the latest feature before which the face's point lay on no
|
||
/// face of the model.
|
||
fn feature_of_face(s: &Session, body: &str, index: usize, p: Vec3) -> Option<u64> {
|
||
let key = (body.to_string(), index);
|
||
if let Some(hit) = FACE_FEATURE.with(|c| {
|
||
let c = c.borrow();
|
||
if c.0 == s.revision { c.1.get(&key).copied() } else { None }
|
||
}) {
|
||
return hit;
|
||
}
|
||
let on_model = |st: &solvecraft_engine::doc::ModelState| {
|
||
st.bodies.iter().any(|b| {
|
||
let m = b.mesh();
|
||
let bb = m.bounds();
|
||
let tol = 1e-4 * (1.0 + bb.diagonal());
|
||
p.x >= bb.min.x - tol
|
||
&& p.y >= bb.min.y - tol
|
||
&& p.z >= bb.min.z - tol
|
||
&& p.x <= bb.max.x + tol
|
||
&& p.y <= bb.max.y + tol
|
||
&& p.z <= bb.max.z + tol
|
||
&& m.triangles.iter().filter_map(|t| m.tri(t)).any(|t| point_tri_dist(p, t) < tol)
|
||
})
|
||
};
|
||
let found = s
|
||
.doc
|
||
.features
|
||
.iter()
|
||
.rev()
|
||
.filter(|f| !matches!(f.kind, FeatureKind::Sketch { .. }) && s.model.result(f.id).is_some_and(|r| r.error.is_none()))
|
||
.find(|f| !on_model(&s.model.state_before(f.id)))
|
||
.map(|f| f.id);
|
||
FACE_FEATURE.with(|c| {
|
||
let mut c = c.borrow_mut();
|
||
if c.0 != s.revision {
|
||
*c = (s.revision, std::collections::HashMap::new());
|
||
}
|
||
c.1.insert(key, found);
|
||
});
|
||
found
|
||
}
|
||
|
||
/// Distance from a point to a triangle.
|
||
pub(crate) fn point_tri_dist(p: Vec3, [a, b, c]: [Vec3; 3]) -> f64 {
|
||
let n = (b - a).cross(c - a);
|
||
let nn = n.dot(n);
|
||
if nn > 1e-24 {
|
||
let q = p - n * ((p - a).dot(n) / nn);
|
||
let inside = [(a, b), (b, c), (c, a)].iter().all(|(u, v)| (*v - *u).cross(q - *u).dot(n) >= 0.0);
|
||
if inside {
|
||
return (p - q).len();
|
||
}
|
||
}
|
||
p.dist_to_segment(a, b).min(p.dist_to_segment(b, c)).min(p.dist_to_segment(c, a))
|
||
}
|
||
|
||
/// The pattern objects' label: features are picked in the timeline.
|
||
fn objects_label(accept: Accept) -> &'static str {
|
||
if accept == selection::FEATURES { "Features" } else { "Objects" }
|
||
}
|
||
|
||
fn is_pattern(k: &Kind) -> bool {
|
||
matches!(k, Kind::PatternRect { .. } | Kind::PatternCirc { .. } | Kind::PathPattern { .. } | Kind::Mirror)
|
||
}
|
||
|
||
/// The Object Type row of a pattern: bodies (picked in the view) or features (picked in the
|
||
/// timeline, e.g. a hole).
|
||
fn object_type_row(d: &mut Dialog, ui: &mut egui::Ui) {
|
||
let Some(inp) = d.inputs.first_mut() else { return };
|
||
row_label(ui, "Object Type");
|
||
let cur = usize::from(inp.accept == selection::FEATURES);
|
||
let mut ty = cur;
|
||
combo(ui, "pat_obj", &["Bodies", "Features"], &mut ty);
|
||
ui.end_row();
|
||
if ty != cur {
|
||
inp.accept = if ty == 1 { selection::FEATURES } else { BODIES };
|
||
inp.label = objects_label(inp.accept);
|
||
inp.items.clear();
|
||
d.active = 0;
|
||
}
|
||
}
|
||
|
||
/// Rows above the selection inputs: the pattern type and object type, the Hole placement (one
|
||
/// face point, or sketch points).
|
||
fn placement_row(d: &mut Dialog, ui: &mut egui::Ui) {
|
||
if matches!(d.kind, Kind::Mirror) {
|
||
object_type_row(d, ui);
|
||
return;
|
||
}
|
||
if matches!(d.kind, Kind::Move { .. }) {
|
||
crate::dialogs_move::type_row(d, ui);
|
||
return;
|
||
}
|
||
let current = match d.kind {
|
||
Kind::PatternRect { .. } => Some(0),
|
||
Kind::PatternCirc { .. } => Some(1),
|
||
Kind::PathPattern { .. } => Some(2),
|
||
_ => None,
|
||
};
|
||
if let Some(cur) = current {
|
||
row_label(ui, "Type");
|
||
let mut ty = cur;
|
||
combo(ui, "pat_type", &["Rectangular", "Circular", "Path"], &mut ty);
|
||
ui.end_row();
|
||
if ty != cur {
|
||
// Same objects, the new pattern's other inputs.
|
||
let objects = d.inputs.first().map(|i| i.items.clone()).unwrap_or_default();
|
||
let (kind, mut inputs) = match ty {
|
||
0 => (
|
||
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 | EDGES, false),
|
||
SelInput::new("Direction 2", AXES | EDGES, false),
|
||
],
|
||
),
|
||
1 => (
|
||
Kind::PatternCirc { count: "6".into(), angle: "360 deg".into() },
|
||
vec![SelInput::new("Objects", BODIES, true), SelInput::new("Axis", AXES, false)],
|
||
),
|
||
_ => (
|
||
Kind::PathPattern { count: "4".into(), spacing: "20 mm".into() },
|
||
vec![SelInput::new("Objects", BODIES, true), SelInput::new("Path", CURVES, true)],
|
||
),
|
||
};
|
||
let accept = d.inputs.first().map(|i| i.accept).unwrap_or(BODIES);
|
||
if let Some(i) = inputs.first_mut() {
|
||
i.items = objects;
|
||
i.accept = accept;
|
||
i.label = objects_label(accept);
|
||
}
|
||
d.kind = kind;
|
||
d.inputs = inputs;
|
||
d.extra.clear();
|
||
d.advance();
|
||
}
|
||
object_type_row(d, ui);
|
||
return;
|
||
}
|
||
let Kind::Hole { opts, .. } = &mut d.kind else { return };
|
||
row_label(ui, "Placement");
|
||
let mut m = usize::from(opts.multiple);
|
||
combo(ui, "hole_place", &["Single", "Multiple"], &mut m);
|
||
ui.end_row();
|
||
if (m == 1) != opts.multiple {
|
||
opts.multiple = m == 1;
|
||
d.inputs = vec![if opts.multiple { SelInput::new("Points", selection::POINTS, true) } else { SelInput::new("Face", PLANAR_FACES, true) }];
|
||
d.active = 0;
|
||
d.extra.remove("direction");
|
||
}
|
||
}
|
||
|
||
/// 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() {
|
||
row_label(ui, inp.label);
|
||
ui.horizontal(|ui| {
|
||
let active = i == d.active;
|
||
// A chip: "↖ Select" (outlined) while empty, "N selected" (filled) once picked.
|
||
let custom_axis = i == 1 && inp.items.is_empty() && matches!(d.kind, Kind::Revolve { axis: Some(_), .. });
|
||
let filled = !inp.items.is_empty() || custom_axis;
|
||
let text = if filled {
|
||
RichText::new(if custom_axis {
|
||
crate::i18n::tr("dialog.custom_axis").into()
|
||
} else {
|
||
crate::i18n::format("dialog.selected_count", &[("count", &inp.items.len().to_string())])
|
||
})
|
||
.color(Color32::WHITE)
|
||
} else {
|
||
RichText::new(crate::i18n::tr("dialog.select")).color(t.text)
|
||
};
|
||
let stroke = if active { Stroke::new(1.5, t.accent) } else { Stroke::new(1.0, t.dialog_border.gamma_multiply(1.6)) };
|
||
// Not in the Tab order: Tab moves between values.
|
||
let b = egui::Button::new(text)
|
||
.fill(if filled { t.chip } else { Color32::TRANSPARENT })
|
||
.stroke(stroke)
|
||
.min_size(vec2(0.0, 22.0))
|
||
.sense(egui::Sense::CLICK);
|
||
let chip = ui.add(b);
|
||
publish_row(&chip);
|
||
let tooltip = if custom_axis {
|
||
if let Kind::Revolve { axis: Some(axis), .. } = &d.kind {
|
||
format!(
|
||
"Custom axis\nOrigin: {}\nDirection: {}\nClick to select a replacement axis",
|
||
axis.get("origin").map(ToString::to_string).unwrap_or_default(),
|
||
axis.get("dir").map(ToString::to_string).unwrap_or_default()
|
||
)
|
||
} else {
|
||
hint(inp).to_string()
|
||
}
|
||
} else {
|
||
format!("{} (click to pick into this input)", hint(inp))
|
||
};
|
||
if chip.on_hover_text(tooltip).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();
|
||
if i == 0 {
|
||
d.profile_selection_changed = true;
|
||
}
|
||
d.active = i;
|
||
}
|
||
}
|
||
|
||
/// Width limits of a docked dialog: shrink-wrapped to its content within these.
|
||
use crate::frame::{DIALOG_MAX_W, DIALOG_MIN_W, DIALOG_WIDE_MAX_W};
|
||
/// Width of value fields and choice boxes in a dialog.
|
||
const FIELD_W: f32 = 124.0;
|
||
|
||
/// Find the Boundary Fill cells again when the tool bodies changed (keeping the choices of
|
||
/// cells that are still there).
|
||
fn update_fill_cells(app: &SolveApp, d: &mut Dialog) {
|
||
let tools: Vec<Sel> = sels(d, 0).to_vec();
|
||
let Kind::BoundaryFill { cells, of, .. } = &mut d.kind else { return };
|
||
if *of == tools {
|
||
return;
|
||
}
|
||
let names: Vec<String> = tools.iter().filter_map(|x| if let Sel::Body { name } = x { Some(name.clone()) } else { None }).collect();
|
||
// Editing: the tools as they were before this feature (it may have removed them).
|
||
let st = match d.editing {
|
||
Some((id, _)) => app.session.model.state_before(id),
|
||
None => app.session.world_state(),
|
||
};
|
||
let found = fill_cells(&st, &names);
|
||
let kept: Vec<String> = cells.iter().filter(|c| c.2).map(|c| c.1.clone()).collect();
|
||
*cells = found.into_iter().map(|(p, label)| (p, label.clone(), kept.is_empty() || kept.contains(&label))).collect();
|
||
*of = tools;
|
||
}
|
||
|
||
/// The cells a set of tool bodies makes: each region inside some of them and outside the rest,
|
||
/// as a point inside it and which tools hold it. Found by sampling a grid over the tools.
|
||
pub(crate) fn fill_cells(st: &solvecraft_engine::doc::ModelState, tools: &[String]) -> Vec<(Vec3, String)> {
|
||
let bodies: Vec<_> = tools.iter().filter_map(|n| st.body(n).map(|b| (n.clone(), b.mesh()))).take(16).collect();
|
||
if bodies.is_empty() {
|
||
return Vec::new();
|
||
}
|
||
let indexes: Vec<_> = bodies.iter().map(|(_, m)| m.inside_index()).collect();
|
||
let bb = bodies.iter().fold(solvecraft_engine::geom::Aabb3::EMPTY, |b, (_, m)| b.union(&m.bounds()));
|
||
let n = 20;
|
||
let at = |i: usize, lo: f64, hi: f64| lo + (hi - lo) * (i as f64 + 0.5) / n as f64;
|
||
// Signature (which tools hold the point) → the points with it.
|
||
let mut groups: Vec<(u32, Vec<Vec3>)> = Vec::new();
|
||
for i in 0..n {
|
||
for j in 0..n {
|
||
for k in 0..n {
|
||
let p = Vec3::new(at(i, bb.min.x, bb.max.x), at(j, bb.min.y, bb.max.y), at(k, bb.min.z, bb.max.z));
|
||
let sig = indexes.iter().enumerate().fold(0u32, |acc, (t, ix)| if ix.contains(p) { acc | (1 << t) } else { acc });
|
||
if sig == 0 {
|
||
continue;
|
||
}
|
||
match groups.iter_mut().find(|(g, _)| *g == sig) {
|
||
Some((_, v)) => v.push(p),
|
||
None => groups.push((sig, vec![p])),
|
||
}
|
||
}
|
||
}
|
||
}
|
||
groups.sort_by_key(|(sig, _)| *sig);
|
||
groups
|
||
.into_iter()
|
||
.filter_map(|(sig, pts)| {
|
||
let c = pts.iter().fold(Vec3::ZERO, |a, p| a + *p) * (1.0 / pts.len() as f64);
|
||
// The sample nearest the middle (the middle itself may be outside a curved cell).
|
||
let rep = pts.iter().min_by(|a, b| a.dist(c).total_cmp(&b.dist(c))).copied()?;
|
||
let inside: Vec<&str> = bodies.iter().enumerate().filter(|(t, _)| sig & (1 << t) != 0).map(|(_, (name, _))| name.as_str()).collect();
|
||
Some((rep, format!("Inside {}", inside.join(", "))))
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// 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;
|
||
}
|
||
}
|
||
|
||
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 axis view, sized to its content.
|
||
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||
app.viewport.dialog_rect = None;
|
||
let Some(mut d) = app.dialog.take() else { return };
|
||
let _language = crate::i18n::scope(&app.preferences.language);
|
||
auto_operation(app, &mut d);
|
||
update_measure(app, &mut d);
|
||
update_fill_cells(app, &mut d);
|
||
let t = Tokens::get();
|
||
let vp = app.viewport.rect.unwrap_or_else(|| ctx.content_rect());
|
||
let top = (vp.top() + crate::viewport::AXIS_VIEW_CLEARANCE).min((vp.bottom() - 360.0).max(vp.top()));
|
||
let anchor = egui::pos2(vp.right(), top);
|
||
let mut keep = true;
|
||
let mut ok = false;
|
||
let mut applied = false;
|
||
let mut cancel = false;
|
||
let mut enter = false;
|
||
let wide = d.is_wide();
|
||
let raw_title = title(&d.kind);
|
||
let heading = if d.editing.is_some() { crate::i18n::format("dialog.edit_title", &[("title", tr(raw_title))]) } else { tr(raw_title).to_string() };
|
||
let frame = egui::Frame::window(&ctx.global_style())
|
||
.fill(t.dialog_bg)
|
||
.stroke(Stroke::new(1.0, t.dialog_border))
|
||
.corner_radius(egui::CornerRadius { nw: 4, sw: 4, ne: 0, se: 0 })
|
||
.shadow(egui::Shadow { offset: [-2, 2], blur: 8, spread: 0, color: Color32::from_black_alpha(40) });
|
||
// The area includes the border as well as the inner margins; reserve all of it so a
|
||
// horizontally scrolling table cannot push a wide dialog two pixels past its limit.
|
||
let horizontal_frame = frame.inner_margin.leftf() + frame.inner_margin.rightf() + 2.0 * frame.stroke.width;
|
||
// OK is offered once the inputs make a feature.
|
||
let has_ok = !matches!(d.kind, Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. });
|
||
let valid = !has_ok || !d.previews() || (apply_commands(app, &d).is_ok() && app.preview.error.is_none());
|
||
let width = if wide { crate::frame::Width::Wide } else { crate::frame::Width::Normal };
|
||
let wkey = egui::Id::new(("sc_dialog_w", wide));
|
||
let response = crate::frame::docked(
|
||
RichText::new(heading.clone()).strong().size(13.0),
|
||
egui::Id::new(("sc_dialog", title(&d.kind))),
|
||
anchor,
|
||
vp.bottom() - anchor.y - 8.0,
|
||
width,
|
||
)
|
||
.frame(frame)
|
||
.open(&mut keep)
|
||
.show(ctx, |ui| {
|
||
ui.spacing_mut().text_edit_width = FIELD_W;
|
||
// Long texts (errors, notes) wrap instead of widening the dialog.
|
||
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap);
|
||
let margin = horizontal_frame;
|
||
let min_w = if wide { 480.0 } else { DIALOG_MIN_W } - margin;
|
||
ui.set_min_width(min_w);
|
||
ui.set_max_width(if wide { DIALOG_WIDE_MAX_W } else { DIALOG_MAX_W } - margin);
|
||
// Rows that end at the right edge line up with last frame's content width.
|
||
let total = crate::frame::content_width(ui, wkey, min_w);
|
||
// Header: "−" folds the dialog, the command name, "»" at the right.
|
||
ui.horizontal(|ui| {
|
||
let fold = if d.collapsed { "+" } else { "−" };
|
||
if ui
|
||
.add(egui::Button::new(RichText::new(fold).size(14.0)).frame(false))
|
||
.on_hover_text(if d.collapsed { "Expand" } else { "Collapse" })
|
||
.clicked()
|
||
{
|
||
d.collapsed = !d.collapsed;
|
||
}
|
||
ui.label(RichText::new(heading.as_str()).strong().size(12.5));
|
||
let chevron_width = ui.spacing().interact_size.x;
|
||
crate::frame::right_aligned(ui, total, chevron_width, |ui| {
|
||
let chevron = if d.collapsed { "«" } else { "»" };
|
||
if ui
|
||
.add(egui::Button::new(RichText::new(chevron).color(t.text_dim)).frame(false).min_size(vec2(chevron_width, 0.0)))
|
||
.on_hover_text(if d.collapsed { "Expand" } else { "Collapse" })
|
||
.clicked()
|
||
{
|
||
d.collapsed = !d.collapsed;
|
||
}
|
||
});
|
||
});
|
||
if d.collapsed {
|
||
return;
|
||
}
|
||
ui.add_space(2.0);
|
||
// A sizing pass initially offers almost no height to an auto-sized window.
|
||
// Give only the fields a viewport-derived scroll budget; header and actions
|
||
// stay visible, while short forms still shrink to their natural height.
|
||
let vertical_frame = frame.inner_margin.topf() + frame.inner_margin.bottomf() + 2.0 * frame.stroke.width;
|
||
let footer_height = ui.spacing().interact_size.y.max(24.0) + 6.0 + ui.spacing().item_spacing.y;
|
||
let fields_height = (vp.bottom() - anchor.y - 8.0 - vertical_frame - ui.min_rect().height() - footer_height).max(1.0);
|
||
egui::ScrollArea::vertical().id_salt("feature_fields").min_scrolled_height(fields_height).max_height(fields_height).show(ui, |ui| {
|
||
// Configuration tables own their column widths and scrolling; the two-column form
|
||
// grid would squeeze their row names and action buttons into a label-sized cell.
|
||
if let Kind::Motion(k @ crate::dialogs_motion::Mo::Configurations { .. }) = &mut d.kind {
|
||
enter |= crate::dialogs_motion::rows(app, ui, k, &mut d.inputs);
|
||
}
|
||
egui::Grid::new("sc_dialog_grid").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| {
|
||
if matches!(&d.kind, Kind::Motion(crate::dialogs_motion::Mo::Configurations { .. })) {
|
||
return;
|
||
}
|
||
placement_row(&mut d, ui);
|
||
input_rows(&mut d, ui);
|
||
match &mut d.kind {
|
||
Kind::Sketch => {
|
||
if d.error.is_some()
|
||
&& let Some(selection @ Sel::Face { .. }) = d.inputs.first().and_then(|i| i.items.first())
|
||
{
|
||
row_label(ui, "Attachment");
|
||
ui.label("This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.");
|
||
ui.end_row();
|
||
row_label(ui, "");
|
||
if ui.button("Use fixed plane").clicked()
|
||
&& let Some(mut plane) = crate::sketch_attachment_ui::face_plane(app, selection)
|
||
{
|
||
plane["fixed"] = json!(true);
|
||
match app.run("sketch.create", json!({"plane":plane})) {
|
||
Ok(_) => {
|
||
app.pre_sketch_cam = Some(app.cam);
|
||
look_at_sketch(app);
|
||
cancel = true;
|
||
}
|
||
Err(error) => d.error = Some(error),
|
||
}
|
||
}
|
||
ui.end_row();
|
||
}
|
||
}
|
||
Kind::Extrude { distance, direction, operation, auto_op, distance2, taper, taper_present, to_offset, start, all } => {
|
||
row_label(ui, "Start");
|
||
let old = if object_on(&d.inputs, FROM_OBJECT) { 2 } else { usize::from(!start.is_empty()) };
|
||
let mut st = old;
|
||
combo(ui, "ex_start", &["Profile Plane", "Offset", "From Object"], &mut st);
|
||
if st != old {
|
||
set_object(&mut d.inputs, &mut d.active, FROM_OBJECT, st == 2);
|
||
if st == 1 && start.is_empty() {
|
||
*start = "5 mm".into();
|
||
}
|
||
if st == 0 {
|
||
start.clear();
|
||
}
|
||
}
|
||
ui.end_row();
|
||
if st != 0 {
|
||
row_label(ui, "Offset");
|
||
enter |= field(ui, start);
|
||
ui.end_row();
|
||
}
|
||
row_label(ui, "Direction");
|
||
// One Side, Two Sides, Symmetric (a flip is a negative distance).
|
||
let order = [0usize, TWO_SIDES, 2];
|
||
let mut pos = order.iter().position(|o| o == direction).unwrap_or(0);
|
||
combo(ui, "ex_dir", &["One Side", "Two Sides", "Symmetric"], &mut pos);
|
||
if pos != order.iter().position(|o| o == direction).unwrap_or(0) {
|
||
*direction = order.get(pos).copied().unwrap_or(0);
|
||
}
|
||
ui.end_row();
|
||
if *direction < 2 {
|
||
row_label(ui, "Reverse");
|
||
let mut reverse = *direction == 1;
|
||
if ui.checkbox(&mut reverse, "Reverse Direction").changed() {
|
||
*direction = usize::from(reverse);
|
||
}
|
||
ui.end_row();
|
||
}
|
||
row_label(ui, "Extent Type");
|
||
let cur = if to_object_on(&d.inputs) { 1 } else { usize::from(*all) * 2 };
|
||
let mut ext = cur;
|
||
combo(ui, "ex_ext", &["Distance", "To Object", "All"], &mut ext);
|
||
*all = ext == 2;
|
||
set_to_object(&mut d.inputs, &mut d.active, ext == 1);
|
||
ui.end_row();
|
||
if ext == 0 {
|
||
row_label(ui, "Distance");
|
||
enter |= field(ui, distance);
|
||
ui.end_row();
|
||
if *direction == TWO_SIDES {
|
||
row_label(ui, "Distance 2");
|
||
enter |= field(ui, distance2);
|
||
ui.end_row();
|
||
}
|
||
}
|
||
if ext == 1 {
|
||
row_label(ui, "To Offset");
|
||
enter |= field(ui, to_offset);
|
||
ui.end_row();
|
||
}
|
||
if *direction < 2 {
|
||
row_label(ui, "Taper Angle");
|
||
let before = taper.clone();
|
||
enter |= field(ui, taper);
|
||
if *taper != before {
|
||
*taper_present = !taper.trim().is_empty();
|
||
}
|
||
ui.end_row();
|
||
}
|
||
row_label(ui, "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, .. } => {
|
||
row_label(ui, "Type");
|
||
let cur = usize::from(to_object_on(&d.inputs));
|
||
let mut ty = cur;
|
||
combo(ui, "rv_type", &["Angle", "To Object"], &mut ty);
|
||
set_to_object(&mut d.inputs, &mut d.active, ty == 1);
|
||
ui.end_row();
|
||
if ty == 0 {
|
||
// One Side, Two Sides (a second angle), Symmetric (the angle split).
|
||
row_label(ui, "Direction");
|
||
let cur = if d.extra.contains_key("angle2") {
|
||
1
|
||
} else if d.extra.get("direction").is_some() {
|
||
2
|
||
} else {
|
||
0
|
||
};
|
||
let mut dir = cur;
|
||
combo(ui, "rv_dir", &["One Side", "Two Sides", "Symmetric"], &mut dir);
|
||
if dir != cur {
|
||
d.extra.remove("angle2");
|
||
d.extra.remove("direction");
|
||
match dir {
|
||
1 => {
|
||
d.extra.insert("angle2".into(), json!("90 deg"));
|
||
}
|
||
2 => {
|
||
d.extra.insert("direction".into(), json!("symmetric"));
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
ui.end_row();
|
||
row_label(ui, "Angle");
|
||
enter |= field(ui, angle);
|
||
ui.end_row();
|
||
if dir == 1 {
|
||
row_label(ui, "Angle 2");
|
||
enter |= extra_field(ui, &mut d.extra, "angle2", "90 deg");
|
||
ui.end_row();
|
||
}
|
||
}
|
||
row_label(ui, "Operation");
|
||
combo(ui, "rv_op", &OP_LABELS, operation);
|
||
ui.end_row();
|
||
}
|
||
Kind::Fillet { radius, chamfer, chain, ctype, distance2, angle, flip } => {
|
||
if *chamfer {
|
||
row_label(ui, "Chamfer Type");
|
||
combo(ui, "ch_type", &CHAMFER_TYPES, ctype);
|
||
ui.end_row();
|
||
}
|
||
let mut rtype = 0;
|
||
if !*chamfer {
|
||
row_label(ui, "Radius Type");
|
||
rtype = extra_choice(
|
||
ui,
|
||
&mut d.extra,
|
||
"fi_type",
|
||
"type",
|
||
&[("Constant", "constant"), ("Chord Length", "chord"), ("Variable", "variable")],
|
||
);
|
||
ui.end_row();
|
||
}
|
||
row_label(
|
||
ui,
|
||
if !*chamfer {
|
||
match rtype {
|
||
1 => "Chord Length",
|
||
2 => "Start Radius",
|
||
_ => "Radius",
|
||
}
|
||
} else if *ctype == 0 {
|
||
"Distance"
|
||
} else {
|
||
"Distance 1"
|
||
},
|
||
);
|
||
enter |= field(ui, radius);
|
||
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();
|
||
}
|
||
if *chamfer && *ctype > 0 {
|
||
row_label(ui, "Flip");
|
||
ui.checkbox(flip, "");
|
||
ui.end_row();
|
||
}
|
||
if !*chamfer && rtype == 2 {
|
||
row_label(ui, "End Radius");
|
||
enter |= extra_field(ui, &mut d.extra, "radius2", "1 mm");
|
||
ui.end_row();
|
||
// Which end of the first edge takes the start radius.
|
||
row_label(ui, "Swap Ends");
|
||
extra_check(ui, &mut d.extra, "swap_ends");
|
||
ui.end_row();
|
||
// Radii between the ends: position along the edge (from the start end)
|
||
// and radius.
|
||
let mut mids = mid_points(&d.extra);
|
||
let mut remove = None;
|
||
for (k, (t, r)) in mids.iter_mut().enumerate() {
|
||
row_label(ui, "Mid Radius");
|
||
ui.horizontal(|ui| {
|
||
ui.add(egui::DragValue::new(t).speed(0.01).range(0.01..=0.99).fixed_decimals(2))
|
||
.on_hover_text("Position along the edge, 0 at the start end, 1 at the other");
|
||
enter |= field(ui, r);
|
||
if ui.small_button("×").on_hover_text("Remove this point").clicked() {
|
||
remove = Some(k);
|
||
}
|
||
});
|
||
ui.end_row();
|
||
}
|
||
if let Some(k) = remove {
|
||
mids.remove(k);
|
||
}
|
||
row_label(ui, "");
|
||
if ui.button("Add Mid Radius").clicked() && mids.len() < 20 {
|
||
let t = if mids.is_empty() { 0.5 } else { (mids.iter().map(|m| m.0).fold(0.0, f64::max) + 1.0) * 0.5 };
|
||
mids.push((t.min(0.99), radius.clone()));
|
||
}
|
||
ui.end_row();
|
||
d.extra.insert("mid".into(), json!(mids.iter().map(|(t, r)| json!([t, r])).collect::<Vec<_>>()));
|
||
if !mids.is_empty() {
|
||
row_label(ui, "Smooth");
|
||
extra_check(ui, &mut d.extra, "smooth");
|
||
ui.end_row();
|
||
}
|
||
}
|
||
row_label(ui, "Tangent Chain");
|
||
ui.checkbox(chain, "");
|
||
ui.end_row();
|
||
}
|
||
Kind::Shell { thickness } => {
|
||
row_label(ui, "Tangent Chain");
|
||
extra_check(ui, &mut d.extra, "tangent_chain");
|
||
ui.end_row();
|
||
let dir = d.extra.get("direction").and_then(Value::as_str).unwrap_or("inside").to_string();
|
||
row_label(
|
||
ui,
|
||
match dir.as_str() {
|
||
"outside" => "Outside Thickness",
|
||
"both" => "Thickness",
|
||
_ => "Inside Thickness",
|
||
},
|
||
);
|
||
enter |= field(ui, thickness);
|
||
ui.end_row();
|
||
row_label(ui, "Direction");
|
||
extra_choice(ui, &mut d.extra, "sh_dir", "direction", &[("Inside", "inside"), ("Outside", "outside"), ("Both", "both")]);
|
||
ui.end_row();
|
||
}
|
||
Kind::Draft { angle } => {
|
||
row_label(ui, "Angle");
|
||
enter |= field(ui, angle);
|
||
ui.end_row();
|
||
}
|
||
Kind::Mirror => {
|
||
row_label(ui, "Operation");
|
||
let mut op = usize::from(d.extra.get("combine").and_then(Value::as_bool).unwrap_or(false));
|
||
combo(ui, "mi_op", &["New Body", "Join"], &mut op);
|
||
d.extra.insert("combine".into(), json!(op == 1));
|
||
ui.end_row();
|
||
}
|
||
Kind::Measure { result, .. } => measure_rows(ui, result.as_ref()),
|
||
Kind::OffsetPlane { offset } => {
|
||
row_label(ui, "Distance");
|
||
enter |= field(ui, offset);
|
||
ui.end_row();
|
||
}
|
||
Kind::AnglePlane { angle } => {
|
||
row_label(ui, "Angle");
|
||
enter |= field(ui, angle);
|
||
ui.end_row();
|
||
}
|
||
Kind::Split | Kind::ReplaceFace | Kind::Remove => {}
|
||
Kind::Rib { thickness, depth, flip, .. } => {
|
||
row_label(ui, "Thickness");
|
||
enter |= field(ui, thickness);
|
||
ui.end_row();
|
||
row_label(ui, "Depth");
|
||
let r = ui.add(egui::TextEdit::singleline(depth).hint_text("to the next face"));
|
||
crate::params_dialog::complete(ui, &r, depth);
|
||
enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||
ui.end_row();
|
||
row_label(ui, "Flip");
|
||
ui.checkbox(flip, "");
|
||
ui.end_row();
|
||
}
|
||
Kind::Emboss { depth, deboss } => {
|
||
row_label(ui, "Depth");
|
||
enter |= field(ui, depth);
|
||
ui.end_row();
|
||
row_label(ui, "Type");
|
||
let mut i = usize::from(*deboss);
|
||
combo(ui, "emb_mode", &["Emboss", "Deboss"], &mut i);
|
||
*deboss = i == 1;
|
||
ui.end_row();
|
||
ui.label("");
|
||
ui.add(
|
||
egui::Label::new(
|
||
"Orthogonal line profiles tangent to one supported cylinder wrap automatically. Pick a wall to choose a target.",
|
||
)
|
||
.wrap(),
|
||
);
|
||
ui.end_row();
|
||
}
|
||
Kind::Align { flip } => {
|
||
// Where on the picked face or edge to snap: as picked (a face turns to meet
|
||
// the other, an edge at its middle), its centre, or a round one's centre.
|
||
for (label, key) in [("From Snap", "from_snap"), ("To Snap", "to_snap")] {
|
||
row_label(ui, label);
|
||
extra_choice(ui, &mut d.extra, key, key, &[("As Picked", "picked"), ("Centre", "center"), ("Circle Centre", "circle")]);
|
||
ui.end_row();
|
||
}
|
||
row_label(ui, "Flip");
|
||
ui.checkbox(flip, "");
|
||
ui.end_row();
|
||
}
|
||
Kind::BoundaryFill { operation, cells, .. } => {
|
||
row_label(ui, "Cells");
|
||
if cells.is_empty() {
|
||
ui.label(RichText::new("Pick the tool bodies").color(Tokens::get().text_dim));
|
||
}
|
||
ui.end_row();
|
||
for (i, (_, label, on)) in cells.iter_mut().enumerate() {
|
||
row_label(ui, &format!("Cell {}", i + 1));
|
||
ui.checkbox(on, label.as_str());
|
||
ui.end_row();
|
||
}
|
||
row_label(ui, "Operation");
|
||
combo(ui, "bf_op", &OP_LABELS, operation);
|
||
ui.end_row();
|
||
row_label(ui, "Remove Tools");
|
||
extra_check(ui, &mut d.extra, "remove_tools");
|
||
ui.end_row();
|
||
}
|
||
Kind::PathPattern { count, spacing } => {
|
||
for (l, v) in [("Quantity", count), ("Spacing", spacing)] {
|
||
row_label(ui, l);
|
||
enter |= field(ui, v);
|
||
ui.end_row();
|
||
}
|
||
}
|
||
Kind::Pipe { diameter, wall } => {
|
||
row_label(ui, "Diameter");
|
||
enter |= field(ui, diameter);
|
||
ui.end_row();
|
||
row_label(ui, "Wall");
|
||
let r = ui.add(egui::TextEdit::singleline(wall).hint_text("solid"));
|
||
crate::params_dialog::complete(ui, &r, wall);
|
||
enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||
ui.end_row();
|
||
}
|
||
Kind::Stock { margin } => {
|
||
row_label(ui, "Margin");
|
||
enter |= field(ui, margin);
|
||
ui.end_row();
|
||
}
|
||
Kind::Scale { factor } => {
|
||
row_label(ui, "Scale factor");
|
||
enter |= field(ui, factor);
|
||
ui.end_row();
|
||
}
|
||
Kind::OffsetFaces { distance } => {
|
||
row_label(ui, "Distance");
|
||
enter |= field(ui, distance);
|
||
ui.end_row();
|
||
}
|
||
Kind::Thread { designation, length } => {
|
||
// Fusion's order: Modeled, Full Length, then the size; a length only when not
|
||
// full.
|
||
row_label(ui, "Modeled");
|
||
extra_check(ui, &mut d.extra, "modeled");
|
||
ui.end_row();
|
||
row_label(ui, "Full Length");
|
||
let mut full = length.trim().is_empty();
|
||
if ui.checkbox(&mut full, "").changed() {
|
||
*length = if full { String::new() } else { "10 mm".into() };
|
||
}
|
||
ui.end_row();
|
||
row_label(ui, "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();
|
||
if !full {
|
||
row_label(ui, "Length");
|
||
enter |= field(ui, length);
|
||
ui.end_row();
|
||
}
|
||
}
|
||
Kind::Material { index } => {
|
||
let names: Vec<&str> = solvecraft_engine::doc::MATERIALS.iter().map(|(n, _)| *n).collect();
|
||
row_label(ui, "Material");
|
||
combo(ui, "mat", &names, index);
|
||
ui.end_row();
|
||
}
|
||
Kind::Section { offset, flip } => {
|
||
row_label(ui, "Distance");
|
||
enter |= field(ui, offset);
|
||
ui.end_row();
|
||
row_label(ui, "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)] {
|
||
row_label(ui, l);
|
||
enter |= field(ui, v);
|
||
ui.end_row();
|
||
}
|
||
}
|
||
Kind::PatternCirc { count, angle } => {
|
||
for (l, v) in [("Quantity", count), ("Total angle", angle)] {
|
||
row_label(ui, l);
|
||
enter |= field(ui, v);
|
||
ui.end_row();
|
||
}
|
||
}
|
||
Kind::Loft { operation } => {
|
||
row_label(ui, "Operation");
|
||
combo(ui, "lf_op", &OP_LABELS, operation);
|
||
ui.end_row();
|
||
}
|
||
Kind::Sweep { operation } => {
|
||
row_label(ui, "Operation");
|
||
combo(ui, "sw_op", &OP_LABELS, operation);
|
||
ui.end_row();
|
||
}
|
||
k @ Kind::Move { .. } => enter |= crate::dialogs_move::rows(ui, k),
|
||
Kind::Hole { diameter, depth, kind, cb_diameter, cb_depth, cs_diameter, cs_angle, opts } => {
|
||
row_label(ui, "Extents");
|
||
let cur = if to_object_on(&d.inputs) { 1 } else { usize::from(opts.all) * 2 };
|
||
let mut ext = cur;
|
||
combo(ui, "hole_ext", &["Distance", "To Object", "All"], &mut ext);
|
||
opts.all = ext == 2;
|
||
set_to_object(&mut d.inputs, &mut d.active, ext == 1);
|
||
ui.end_row();
|
||
if opts.multiple {
|
||
// Every point of the picked points' sketches (standalone points and
|
||
// circle centres), not just the picked ones.
|
||
row_label(ui, "All Sketch Points");
|
||
extra_check(ui, &mut d.extra, "all_points");
|
||
ui.end_row();
|
||
}
|
||
row_label(ui, "Hole Type");
|
||
combo(ui, "hole_kind", &HOLE_LABELS, kind);
|
||
ui.end_row();
|
||
row_label(ui, "Hole Tap Type");
|
||
let mut tap = usize::from(opts.tapped);
|
||
combo(ui, "hole_tap", &["Simple", "Tapped"], &mut tap);
|
||
opts.tapped = tap == 1;
|
||
ui.end_row();
|
||
if opts.tapped {
|
||
row_label(ui, "Size");
|
||
let r = ui.add(egui::TextEdit::singleline(&mut opts.thread).hint_text("M5"));
|
||
enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||
ui.end_row();
|
||
}
|
||
if *kind == 0 {
|
||
row_label(ui, "Drill Point");
|
||
let mut tip = usize::from(opts.angled);
|
||
combo(ui, "hole_tip", &["Flat", "Angle"], &mut tip);
|
||
opts.angled = tip == 1;
|
||
ui.end_row();
|
||
}
|
||
if !opts.all {
|
||
row_label(ui, "Depth");
|
||
enter |= field(ui, depth);
|
||
ui.end_row();
|
||
}
|
||
if *kind == 0 && opts.angled {
|
||
row_label(ui, "Tip Angle");
|
||
enter |= field(ui, &mut opts.tip_angle);
|
||
ui.end_row();
|
||
}
|
||
row_label(ui, "Diameter");
|
||
enter |= field(ui, diameter);
|
||
ui.end_row();
|
||
if *kind == 1 {
|
||
row_label(ui, "Counterbore Ø");
|
||
enter |= field(ui, cb_diameter);
|
||
ui.end_row();
|
||
row_label(ui, "Counterbore depth");
|
||
enter |= field(ui, cb_depth);
|
||
ui.end_row();
|
||
}
|
||
if *kind == 2 {
|
||
row_label(ui, "Countersink Ø");
|
||
enter |= field(ui, cs_diameter);
|
||
ui.end_row();
|
||
row_label(ui, "Countersink angle");
|
||
enter |= field(ui, cs_angle);
|
||
ui.end_row();
|
||
}
|
||
}
|
||
Kind::Primitive { fields, operation, .. } => {
|
||
for (k, v) in fields.iter_mut() {
|
||
row_label(ui, k);
|
||
enter |= field(ui, v);
|
||
ui.end_row();
|
||
}
|
||
row_label(ui, "Operation");
|
||
combo(ui, "pr_op", &OP_LABELS, operation);
|
||
ui.end_row();
|
||
}
|
||
Kind::Combine { operation, keep_tools } => {
|
||
row_label(ui, "Operation");
|
||
let mut op = operation.saturating_sub(1);
|
||
combo(ui, "cb_op", &OP_LABELS[1..], &mut op);
|
||
*operation = op + 1;
|
||
ui.end_row();
|
||
row_label(ui, "New Component");
|
||
extra_check(ui, &mut d.extra, "new_component");
|
||
ui.end_row();
|
||
row_label(ui, "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);
|
||
crate::params_dialog::complete(ui, &r, 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, .. } => {
|
||
row_label(ui, "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::Assembly(k) => enter |= crate::dialogs_assembly::rows(app, ui, k, &mut d.inputs, &mut d.active),
|
||
Kind::Sheet(k) => enter |= crate::dialogs_sheet::rows(app, ui, k, &mut d.inputs),
|
||
Kind::Surface(k) => enter |= crate::dialogs_surface::rows(ui, k),
|
||
Kind::Plastic(k) => enter |= crate::dialogs_plastic::rows(app, ui, k, &d.inputs),
|
||
Kind::Appearance(k) => enter |= crate::dialogs_appearance::rows(app, ui, k, &mut d.inputs),
|
||
Kind::Motion(k) => enter |= crate::dialogs_motion::rows(app, ui, k, &mut d.inputs),
|
||
Kind::Part(k) => enter |= crate::dialogs_parts::rows(app, ui, k, &d.inputs),
|
||
Kind::ConfirmDelete { with, fail, .. } => {
|
||
if !with.is_empty() {
|
||
row_label(ui, "Also deletes");
|
||
ui.label(with.join(", "));
|
||
ui.end_row();
|
||
}
|
||
if !fail.is_empty() {
|
||
row_label(ui, "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()).cloned() {
|
||
row_label(ui, "");
|
||
ui.vertical(|ui| {
|
||
ui.add(egui::Label::new(RichText::new(e.as_str()).color(t.error)).wrap());
|
||
let response = ui.button("Create Issue…");
|
||
crate::scenario::publish_handle("dialog:create_issue", response.rect.center());
|
||
if response.clicked() {
|
||
crate::issue_report::begin(app, ctx, Some(&d), Some(&e));
|
||
}
|
||
});
|
||
ui.end_row();
|
||
}
|
||
});
|
||
});
|
||
ui.add_space(6.0);
|
||
// Footer: an info glyph at the left, OK and Cancel at the right.
|
||
ui.horizontal(|ui| {
|
||
let (r, resp) = ui.allocate_exact_size(vec2(16.0, 16.0), egui::Sense::hover());
|
||
ui.painter().circle_stroke(r.center(), 7.0, Stroke::new(1.2, t.text_dim));
|
||
ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, "i", egui::FontId::proportional(11.0), t.text_dim);
|
||
resp.on_hover_text(heading.as_str());
|
||
// The spinner's place is always taken: a widget that comes and goes would shift
|
||
// the automatic ids of OK and Cancel, and a click pressed in one frame and
|
||
// released in the next would be lost (an animating preview toggles it often).
|
||
let (sr, sresp) = ui.allocate_exact_size(vec2(14.0, 14.0), egui::Sense::hover());
|
||
if app.preview.busy {
|
||
egui::Spinner::new().size(14.0).paint_at(ui, sr);
|
||
sresp.on_hover_text("Updating the preview");
|
||
}
|
||
let raw_label = if matches!(d.kind, Kind::ConfirmDelete { .. }) { "Delete" } else { "OK" };
|
||
let label = tr(raw_label);
|
||
let raw_close = if matches!(d.kind, Kind::Params { .. } | Kind::Measure { .. }) { "Close" } else { "Cancel" };
|
||
let close = tr(raw_close);
|
||
let button_width = |text: &str| {
|
||
let galley = egui::WidgetText::from(text).into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::TextStyle::Button);
|
||
(galley.size().x + 2.0 * ui.spacing().button_padding.x).max(64.0)
|
||
};
|
||
let (ok_width, close_width) = (button_width(label), button_width(close));
|
||
let buttons = close_width + if has_ok { ok_width + ui.spacing().item_spacing.x } else { 0.0 };
|
||
crate::frame::right_aligned(ui, total, buttons, |ui| {
|
||
if has_ok {
|
||
let b = egui::Button::new(RichText::new(label).color(if valid { Color32::WHITE } else { t.text_dim }))
|
||
.fill(if valid { t.accent } else { Color32::TRANSPARENT })
|
||
.wrap_mode(egui::TextWrapMode::Extend)
|
||
.min_size(vec2(ok_width, 24.0));
|
||
let r = ui.add_enabled(valid, b);
|
||
crate::scenario::publish_handle(&format!("dialog:{raw_label}"), r.rect.center());
|
||
if r.clicked() {
|
||
ok = true;
|
||
}
|
||
}
|
||
if ui.add(egui::Button::new(close).wrap_mode(egui::TextWrapMode::Extend).min_size(vec2(close_width, 24.0))).clicked() {
|
||
cancel = true;
|
||
}
|
||
});
|
||
});
|
||
crate::frame::remember_width(ui, wkey);
|
||
});
|
||
app.viewport.dialog_rect = response.map(|r| r.response.rect);
|
||
// 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")));
|
||
// Tables (configurations, a study's keys) take Enter for their cells.
|
||
let enter_applies = !matches!(d.kind, Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. } | Kind::ConfirmDelete { .. })
|
||
&& !matches!(&d.kind, Kind::Motion(k) if k.wide());
|
||
let report_active = app.issue_report.draft.is_some();
|
||
if !report_active && enter_applies && (enter || canvas_enter || (enter_free && nothing_focused)) {
|
||
ok = true;
|
||
}
|
||
if !report_active && esc && !app.esc_handled {
|
||
app.esc_handled = true;
|
||
cancel = true;
|
||
}
|
||
// A plane picked for a new sketch starts it right away.
|
||
if matches!(d.kind, Kind::Sketch)
|
||
&& d.error.is_none()
|
||
&& let Some(sel) = d.inputs.first().and_then(|i| i.items.first()).cloned()
|
||
{
|
||
match start_sketch(app, &sel) {
|
||
Ok(()) => cancel = true,
|
||
Err(error) => d.error = Some(error),
|
||
}
|
||
}
|
||
if ok {
|
||
match run_dialog(app, &d) {
|
||
Ok(()) => {
|
||
cancel = true;
|
||
applied = true;
|
||
}
|
||
Err(e) => d.error = Some(e),
|
||
}
|
||
}
|
||
if !keep || cancel {
|
||
crate::dialogs_motion::closed(app, &mut d, applied);
|
||
}
|
||
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 _ = roll_marker(app, marker);
|
||
}
|
||
}
|
||
|
||
/// Close the dialog without applying it (Esc, Cancel). An edit puts the timeline marker back.
|
||
pub fn cancel(app: &mut SolveApp) {
|
||
if let Some(mut d) = app.dialog.take() {
|
||
crate::dialogs_motion::closed(app, &mut d, false);
|
||
app.dialog = Some(d);
|
||
}
|
||
if let Some(d) = app.dialog.take()
|
||
&& let Some((_, marker)) = d.editing
|
||
{
|
||
let _ = roll_marker(app, marker);
|
||
}
|
||
}
|
||
|
||
/// Temporary rollback used by an editor is view state. Preserve both history stacks,
|
||
/// including redo and a history already at the undo limit.
|
||
pub(crate) fn roll_marker(app: &mut SolveApp, marker: Option<usize>) -> Result<(), String> {
|
||
let undo = std::mem::take(&mut app.session.undo);
|
||
let redo = std::mem::take(&mut app.session.redo);
|
||
let selection = std::mem::take(&mut app.session.selection);
|
||
let result = app.run("timeline.roll_to", marker.map(|m| json!({"position":m})).unwrap_or_else(|| json!({})));
|
||
app.session.undo = undo;
|
||
app.session.redo = redo;
|
||
app.session.selection = selection;
|
||
result.map(|_| ())
|
||
}
|
||
|
||
/// The pick input an extent of To Object adds (a face, vertex or point to go to).
|
||
const TO_OBJECT: &str = "To Object";
|
||
const FROM_OBJECT: &str = "From Object";
|
||
|
||
fn object_on(inputs: &[SelInput], label: &str) -> bool {
|
||
inputs.iter().any(|i| i.label == label)
|
||
}
|
||
|
||
fn to_object_on(inputs: &[SelInput]) -> bool {
|
||
inputs.iter().any(|i| i.label == TO_OBJECT)
|
||
}
|
||
|
||
/// Add or remove the To Object input; the active input moves to it when it is added.
|
||
fn set_to_object(inputs: &mut Vec<SelInput>, active: &mut usize, on: bool) {
|
||
set_object(inputs, active, TO_OBJECT, on);
|
||
}
|
||
|
||
fn set_object(inputs: &mut Vec<SelInput>, active: &mut usize, label: &'static str, on: bool) {
|
||
if on && !object_on(inputs, label) {
|
||
inputs.push(SelInput::new(label, FACES | selection::VERTICES, false));
|
||
*active = inputs.len() - 1;
|
||
} else if !on && object_on(inputs, label) {
|
||
inputs.retain(|i| i.label != label);
|
||
*active = (*active).min(inputs.len().saturating_sub(1));
|
||
}
|
||
}
|
||
|
||
/// The picked To Object place.
|
||
fn to_object_point(inputs: &[SelInput]) -> Option<Value> {
|
||
object_point(inputs, TO_OBJECT)
|
||
}
|
||
|
||
pub(crate) fn object_point(inputs: &[SelInput], label: &str) -> Option<Value> {
|
||
inputs.iter().find(|i| i.label == label)?.items.first().and_then(|x| match x {
|
||
Sel::Face { point, .. } | Sel::Vertex { point, .. } | Sel::Edge { point, .. } => Some(pt(*point)),
|
||
_ => None,
|
||
})
|
||
}
|
||
|
||
/// A To Object input filled from a stored place (editing).
|
||
fn with_to_object(d: &mut Dialog, s: &Session, to: Option<Vec3>) {
|
||
with_object(d, s, TO_OBJECT, to);
|
||
}
|
||
|
||
fn with_object(d: &mut Dialog, s: &Session, label: &'static str, to: Option<Vec3>) {
|
||
if let Some(p) = to {
|
||
set_object(&mut d.inputs, &mut d.active, label, true);
|
||
if let Some(inp) = d.inputs.iter_mut().find(|i| i.label == label) {
|
||
inp.items = vec![face_sel(s, p).unwrap_or(Sel::Vertex { body: String::new(), point: p })];
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A choice row stored in the dialog's extra parameters as `key: value` (the first choice, the
|
||
/// command's default, is stored as no key at all).
|
||
fn extra_choice(ui: &mut egui::Ui, extra: &mut serde_json::Map<String, Value>, id: &str, key: &str, choices: &[(&str, &str)]) -> usize {
|
||
let cur = extra.get(key).and_then(Value::as_str).and_then(|v| choices.iter().position(|(_, x)| *x == v)).unwrap_or(0);
|
||
let mut i = cur;
|
||
let labels: Vec<&str> = choices.iter().map(|(l, _)| *l).collect();
|
||
combo(ui, id, &labels, &mut i);
|
||
if i != cur {
|
||
match choices.get(i) {
|
||
Some((_, v)) if i > 0 => {
|
||
extra.insert(key.into(), json!(v));
|
||
}
|
||
_ => {
|
||
extra.remove(key);
|
||
}
|
||
}
|
||
}
|
||
i
|
||
}
|
||
|
||
/// A checkbox stored in the dialog's extra parameters.
|
||
fn extra_check(ui: &mut egui::Ui, extra: &mut serde_json::Map<String, Value>, key: &str) {
|
||
let mut on = extra.get(key).and_then(Value::as_bool).unwrap_or(false);
|
||
if ui.checkbox(&mut on, "").changed() {
|
||
extra.insert(key.into(), json!(on));
|
||
}
|
||
}
|
||
|
||
/// A value field stored in the dialog's extra parameters (`default` until edited).
|
||
fn extra_field(ui: &mut egui::Ui, extra: &mut serde_json::Map<String, Value>, key: &str, default: &str) -> bool {
|
||
let mut v = extra.get(key).and_then(Value::as_str).unwrap_or(default).to_string();
|
||
let enter = field(ui, &mut v);
|
||
extra.insert(key.into(), json!(v));
|
||
enter
|
||
}
|
||
|
||
/// A variable fillet's middle radii as (position, radius expression).
|
||
fn mid_points(extra: &serde_json::Map<String, Value>) -> Vec<(f64, String)> {
|
||
extra
|
||
.get("mid")
|
||
.and_then(Value::as_array)
|
||
.into_iter()
|
||
.flatten()
|
||
.filter_map(|m| {
|
||
let t = m.get(0)?.as_f64()?;
|
||
let r = match m.get(1)? {
|
||
Value::String(x) => x.clone(),
|
||
Value::Number(n) => n.to_string(),
|
||
_ => return None,
|
||
};
|
||
Some((t, r))
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
const CHAMFER_TYPES: [&str; 3] = ["Equal Distance", "Two Distances", "Distance and Angle"];
|
||
|
||
/// A new Fillet or Chamfer dialog's values.
|
||
fn fillet_kind(size: &str, chamfer: bool) -> Kind {
|
||
Kind::Fillet { radius: size.into(), chamfer, chain: true, ctype: 0, distance2: size.into(), angle: "45 deg".into(), flip: false }
|
||
}
|
||
|
||
fn hole_defaults() -> Kind {
|
||
Kind::Hole {
|
||
diameter: "5 mm".into(),
|
||
depth: "10 mm".into(),
|
||
kind: 0,
|
||
cb_diameter: "9 mm".into(),
|
||
cb_depth: "3 mm".into(),
|
||
cs_diameter: "10 mm".into(),
|
||
cs_angle: "90 deg".into(),
|
||
opts: HoleOpts::default(),
|
||
}
|
||
}
|
||
|
||
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("parameters.change", 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("parameters.change", 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) -> Result<(), String> {
|
||
let plane = match sel {
|
||
Sel::Plane { name } => json!(name),
|
||
Sel::Face { .. } => crate::sketch_attachment_ui::face_plane(app, sel).ok_or("Selected face placement no longer exists")?,
|
||
_ => return Err("Select an origin plane, construction plane or planar body face".into()),
|
||
};
|
||
let before = app.cam;
|
||
app.run("sketch.create", json!({ "plane": plane }))?;
|
||
app.pre_sketch_cam = Some(before);
|
||
look_at_sketch(app);
|
||
// On a face: look at the picked point, not the plane's origin (a corner of the face),
|
||
// which left the face filling the view off-centre like a big square.
|
||
if let Sel::Face { point, .. } = sel
|
||
&& let Some(mut to) = app.cam_anim.as_ref().map(|a| a.to)
|
||
{
|
||
to.target = *point;
|
||
app.animate_to(to);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Animate the camera to look straight at the active sketch plane.
|
||
pub fn look_at_sketch(app: &mut SolveApp) {
|
||
let st = app.session.world_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> {
|
||
let commands = apply_commands(app, d)?;
|
||
if let Some((_, marker)) = d.editing {
|
||
// Record the actual pre-edit timeline state in the command's undo snapshot.
|
||
roll_marker(app, marker)?;
|
||
}
|
||
for (cmd, params) in commands {
|
||
if let Err(error) = app.run(&cmd, params) {
|
||
if let Some((id, _)) = d.editing {
|
||
let position = app.session.doc.feature_index(id);
|
||
let _ = roll_marker(app, position);
|
||
}
|
||
return Err(error);
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Placement used to edit a definition in the selected authoring occurrence.
|
||
pub(crate) fn feature_frame(s: &Session, component: u64) -> solvecraft_engine::doc::Mat {
|
||
if component == s.active_component { s.active_component_frame() } else { s.placed_document().component_transform(component) }
|
||
}
|
||
|
||
/// The feature dialog's current operation name for a reviewed issue draft.
|
||
pub(crate) fn report_title(d: &Dialog) -> String {
|
||
title(&d.kind).to_string()
|
||
}
|
||
|
||
/// 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, mut params) = cmds.into_iter().next().ok_or("nothing to apply")?;
|
||
if let Some(feature) = app.session.doc.feature(id) {
|
||
let original_all = match &feature.kind {
|
||
FeatureKind::Extrude { sketch, profiles: ProfileSel::All, .. }
|
||
| FeatureKind::Revolve { sketch, profiles: ProfileSel::All, .. }
|
||
| FeatureKind::Emboss { sketch, profiles: ProfileSel::All, .. } => Some(*sketch),
|
||
_ => None,
|
||
};
|
||
if let Some(sketch) = original_all
|
||
&& !d.profile_selection_changed
|
||
&& let Some(ss) = app.session.world_state().sketch(sketch)
|
||
{
|
||
let mut selected: Vec<usize> = sels(d, 0)
|
||
.iter()
|
||
.filter_map(|item| match item {
|
||
Sel::Profile { sketch: id, index } if *id == sketch => Some(*index),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
selected.sort_unstable();
|
||
selected.dedup();
|
||
if selected == (0..ss.profiles.len()).collect::<Vec<_>>() && sels(d, 0).len() == selected.len() {
|
||
params["profiles"] = json!("all");
|
||
}
|
||
}
|
||
}
|
||
return Ok(vec![("timeline.redefine".into(), json!({"feature": id, "command": cmd, "params": params}))]);
|
||
}
|
||
Ok(cmds)
|
||
}
|
||
|
||
/// The commands the live preview runs: OK's, with a joint's motion while it animates.
|
||
pub fn preview_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, String> {
|
||
let mut cmds = apply_commands(app, d)?;
|
||
if let Kind::Assembly(k) = &d.kind {
|
||
crate::dialogs_assembly::animate(app, k, &mut cmds);
|
||
}
|
||
Ok(cmds)
|
||
}
|
||
|
||
/// A wrapped Emboss edit keeps its profile guide in the saved author occurrence.
|
||
/// An invalid saved context must not draw the guide at a different occurrence.
|
||
pub(crate) fn emboss_profile_plane(app: &SolveApp, sketch: u64) -> Result<Option<solvecraft_engine::geom::Plane>, String> {
|
||
let Some(dialog) = app.dialog.as_ref().filter(|d| matches!(d.kind, Kind::Emboss { .. })) else { return Ok(None) };
|
||
let Some((feature, _)) = dialog.editing else { return Ok(None) };
|
||
let Some(feature) = app.session.doc.feature(feature) else { return Err("the edited feature is missing".into()) };
|
||
let FeatureKind::Emboss { sketch: source, face: Some(face), .. } = &feature.kind else { return Ok(None) };
|
||
if *source != sketch {
|
||
return Ok(None);
|
||
}
|
||
let model = app.session.model.state();
|
||
let sketch = model.sketch(sketch).ok_or("the Emboss sketch is missing")?;
|
||
let body = model.body(&face.body).ok_or("the Emboss target body is missing")?;
|
||
let owner = app.session.doc.feature(sketch.feature).map(|feature| feature.component).unwrap_or(0);
|
||
let plane = sketch.plane;
|
||
let frame = face.sketch_frame(&app.session.doc, feature.component, owner, body).map_err(|error| error.to_string())?;
|
||
solvecraft_engine::geom::Plane::new(
|
||
solvecraft_engine::doc::apply_point(&frame, plane.origin),
|
||
solvecraft_engine::doc::apply_vector(&frame, plane.x),
|
||
solvecraft_engine::doc::apply_vector(&frame, plane.y),
|
||
)
|
||
.map(Some)
|
||
.ok_or_else(|| "the Emboss author frame is degenerate".into())
|
||
}
|
||
|
||
/// 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, taper_present, to_offset, start, all, .. } => {
|
||
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")});
|
||
let tapered = s.doc.eval(taper, ValueKind::Angle).map(|v| v.abs() > 1e-12).unwrap_or(!taper.trim().is_empty());
|
||
if *direction == TWO_SIDES {
|
||
common["distance2"] = json!(distance2);
|
||
} else if *direction < 2 && (*taper_present || tapered) {
|
||
common["taper"] = json!(taper);
|
||
}
|
||
if !start.trim().is_empty() {
|
||
common["start_offset"] = json!(start);
|
||
}
|
||
if *all {
|
||
common["through_all"] = json!(true);
|
||
}
|
||
if to_object_on(&d.inputs) {
|
||
common["to"] = to_object_point(&d.inputs).ok_or("select the object to extrude to")?;
|
||
if !to_offset.trim().is_empty() {
|
||
common["to_offset"] = json!(to_offset);
|
||
}
|
||
}
|
||
if object_on(&d.inputs, FROM_OBJECT) {
|
||
common["from"] = object_point(&d.inputs, FROM_OBJECT).ok_or("select the object to extrude from")?;
|
||
}
|
||
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(("solid.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(("solid.extrude".to_string(), with(json!({ "face": p }))));
|
||
}
|
||
return Ok(out);
|
||
}
|
||
Kind::Revolve { angle, operation, axis: stored_axis } => {
|
||
need(0, "profiles")?;
|
||
let axis = match sels(d, 1).first() {
|
||
Some(Sel::Axis { name }) => json!(name),
|
||
Some(Sel::SketchCurve { id }) => json!(id),
|
||
_ => stored_axis.clone().ok_or("select an axis")?,
|
||
};
|
||
let (sketch, idx) = profiles();
|
||
let mut p =
|
||
json!({"sketch": sketch, "profiles": idx, "axis": axis, "angle": angle, "operation": OPS.get(*operation).copied().unwrap_or("new")});
|
||
if to_object_on(&d.inputs) {
|
||
p["to"] = to_object_point(&d.inputs).ok_or("select the object to turn to")?;
|
||
}
|
||
("solid.revolve", p)
|
||
}
|
||
Kind::Fillet { radius, chamfer, ctype, distance2, angle, flip, .. } => {
|
||
need(0, "edges")?;
|
||
// Faces stand for all their edges.
|
||
let st = s.world_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 {
|
||
let mut p = json!({"edges": edges, "distance": radius});
|
||
match *ctype {
|
||
1 => p["distance2"] = json!(distance2),
|
||
2 => p["angle"] = json!(angle),
|
||
_ => {}
|
||
}
|
||
if *ctype > 0 && *flip {
|
||
p["flip"] = json!(true);
|
||
}
|
||
("solid.chamfer", p)
|
||
} else {
|
||
let mut p = json!({"edges": edges, "radius": radius});
|
||
if d.extra.get("type").and_then(Value::as_str) == Some("variable") {
|
||
// The start radius goes at one end of the first edge (Swap Ends: the other).
|
||
let ends = sels(d, 0).iter().find_map(|x| match x {
|
||
Sel::Edge { body, index, .. } => {
|
||
st.body(body).and_then(|b| b.mesh().edges.get(*index).and_then(|e| Some((*e.first()?, *e.last()?))))
|
||
}
|
||
_ => None,
|
||
});
|
||
let swap = d.extra.get("swap_ends").and_then(Value::as_bool).unwrap_or(false);
|
||
if let Some((a, b)) = ends {
|
||
p["start"] = pt(if swap { b } else { a });
|
||
}
|
||
let mids = mid_points(&d.extra);
|
||
if !mids.is_empty() {
|
||
p["mid"] = json!(mids.iter().map(|(t, r)| json!([t, r])).collect::<Vec<_>>());
|
||
if d.extra.get("smooth").and_then(Value::as_bool).unwrap_or(false) {
|
||
p["smooth"] = json!(true);
|
||
}
|
||
}
|
||
}
|
||
("solid.fillet", p)
|
||
}
|
||
}
|
||
Kind::Shell { thickness } => {
|
||
need(0, "faces to remove")?;
|
||
("solid.shell", 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")?;
|
||
("solid.draft", json!({"faces": face_points(0), "angle": angle, "neutral": neutral}))
|
||
}
|
||
Kind::Mirror => {
|
||
need(0, "objects")?;
|
||
need(1, "the mirror plane")?;
|
||
let plane = plane_value(s, sels(d, 1).first()).ok_or("the mirror plane must be a plane or a planar face")?;
|
||
let mut p = json!({"plane": plane});
|
||
pattern_objects(s, sels(d, 0), &mut p);
|
||
("solid.mirror", p)
|
||
}
|
||
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, a sketch line or a straight edge")?.1,
|
||
(None, Some(k)) => k,
|
||
(None, None) => return Err("select a direction (an axis, a sketch line or a straight edge) first".into()),
|
||
};
|
||
let mut p = json!({"dir1": pt(d1), "count1": count, "spacing1": spacing});
|
||
pattern_objects(s, sels(d, 0), &mut p);
|
||
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);
|
||
}
|
||
("solid.pattern.rectangular", 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()),
|
||
};
|
||
let mut p = json!({"axis": axis, "count": count, "angle": angle});
|
||
pattern_objects(s, sels(d, 0), &mut p);
|
||
("solid.pattern.circular", p)
|
||
}
|
||
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();
|
||
("solid.loft", 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")?;
|
||
(
|
||
"solid.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 }) => json!(name),
|
||
Some(face @ Sel::Face { .. }) => {
|
||
crate::sketch_attachment_ui::face_plane(app, face).ok_or("the picked face is no longer in the model")?
|
||
}
|
||
_ => return Err("pick an origin or construction plane, or a planar face".into()),
|
||
};
|
||
("construct.plane.offset", json!({"base": base, "offset": offset}))
|
||
}
|
||
Kind::AnglePlane { angle } => {
|
||
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()),
|
||
};
|
||
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 => d.extra.get("axis").cloned().ok_or("pick an axis")?,
|
||
};
|
||
("construct.plane.at_angle", 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 { "solid.web" } else { "solid.rib" }, p)
|
||
}
|
||
Kind::Emboss { depth, deboss } => {
|
||
need(0, "profiles")?;
|
||
let (sketch, idx) = profiles();
|
||
let mut p = json!({"sketch": sketch, "profiles": idx, "depth": depth, "mode": if *deboss { "deboss" } else { "emboss" }});
|
||
if let Some(Sel::Face { body, point, .. }) = sels(d, 1).first() {
|
||
p["face"] = pt(*point);
|
||
p["targets"] = json!([body]);
|
||
}
|
||
("solid.emboss", p)
|
||
}
|
||
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()),
|
||
}
|
||
("solid.replace_face", 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")] {
|
||
let snap = d.extra.get(&format!("{key}_snap")).and_then(Value::as_str).unwrap_or("picked");
|
||
match (sels(d, i).first(), snap) {
|
||
(Some(Sel::Face { point, .. }), "center") => p[key] = json!({"snap": "face_center", "at": pt(*point)}),
|
||
(Some(Sel::Face { point, .. } | Sel::Edge { point, .. }), "circle") => {
|
||
p[key] = json!({"snap": "circle_center", "at": pt(*point)})
|
||
}
|
||
(Some(Sel::Face { point, .. }), _) => p[format!("{key}_face")] = pt(*point),
|
||
(Some(Sel::Edge { point, .. }), _) => p[key] = json!({"snap": "edge_mid", "at": pt(*point)}),
|
||
(Some(Sel::Vertex { point, .. }), _) => p[key] = pt(*point),
|
||
_ => return Err("pick faces, edges or vertices".into()),
|
||
}
|
||
}
|
||
("solid.align", p)
|
||
}
|
||
Kind::Remove => {
|
||
need(0, "bodies")?;
|
||
("solid.remove", 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")?;
|
||
let mut p = json!({"path_sketch": sketch, "path": path, "count": count, "spacing": spacing});
|
||
pattern_objects(s, sels(d, 0), &mut p);
|
||
("solid.pattern.path", p)
|
||
}
|
||
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);
|
||
}
|
||
("solid.pipe", p)
|
||
}
|
||
Kind::Stock { margin } => {
|
||
let mut p = json!({"margin": margin});
|
||
if !sels(d, 0).is_empty() {
|
||
p["bodies"] = json!(body_names(0));
|
||
}
|
||
("solid.bounding_solid", 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();
|
||
// A plane, a planar face's plane, or any other face (its surface extended).
|
||
let p = match sels(d, 1).first() {
|
||
Some(Sel::Plane { name }) => json!({"body": body, "plane": name}),
|
||
Some(Sel::Face { body: b, index, point }) => match planar_face(s, b, *index) {
|
||
Some((_, n)) => json!({"body": body, "plane": {"origin": pt(*point), "normal": pt(n)}}),
|
||
None => json!({"body": body, "tool": {"body": b, "point": pt(*point)}}),
|
||
},
|
||
_ => return Err("pick a plane or a face".into()),
|
||
};
|
||
("solid.split_body", p)
|
||
}
|
||
Kind::Scale { factor } => {
|
||
need(0, "bodies")?;
|
||
("solid.scale", json!({"bodies": body_names(0), "factor": factor}))
|
||
}
|
||
Kind::OffsetFaces { distance } => {
|
||
need(0, "planar faces")?;
|
||
("solid.offset_face", 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);
|
||
}
|
||
("solid.thread", p)
|
||
}
|
||
Kind::Material { index } => {
|
||
need(0, "bodies")?;
|
||
let name = solvecraft_engine::doc::MATERIALS.get(*index).map(|(n, _)| *n).unwrap_or("Default");
|
||
("material.assign", 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()),
|
||
};
|
||
("inspect.section", json!({"plane": plane, "offset": offset, "flip": flip}))
|
||
}
|
||
Kind::Move { .. } => return Ok(vec![("solid.move".into(), crate::dialogs_move::params(app, d)?)]),
|
||
Kind::Hole { diameter, depth, kind, cb_diameter, cb_depth, cs_diameter, cs_angle, opts } => {
|
||
need(0, if opts.multiple { "sketch points" } else { "a face position" })?;
|
||
let ty = match *kind {
|
||
0 if opts.angled => "drilled",
|
||
k => HOLE_TYPES.get(k).copied().unwrap_or("simple"),
|
||
};
|
||
// One hole per picked face position, or one Hole feature per sketch for sketch points.
|
||
let mut places: Vec<Value> = Vec::new();
|
||
if opts.multiple {
|
||
let mut by_sketch: Vec<(u64, Vec<String>)> = Vec::new();
|
||
for x in sels(d, 0) {
|
||
if let Sel::SketchPoint { id } = x
|
||
&& let Some((sk, pid)) = id.split_once(':')
|
||
&& let Ok(sk) = sk.parse::<u64>()
|
||
{
|
||
match by_sketch.iter_mut().find(|(s, _)| *s == sk) {
|
||
Some((_, v)) => v.push(pid.to_string()),
|
||
None => by_sketch.push((sk, vec![pid.to_string()])),
|
||
}
|
||
}
|
||
}
|
||
let all_points = d.extra.get("all_points").and_then(Value::as_bool).unwrap_or(false);
|
||
places.extend(
|
||
by_sketch.into_iter().map(|(sk, ids)| if all_points { json!({ "sketch": sk }) } else { json!({"sketch": sk, "points": ids}) }),
|
||
);
|
||
} else {
|
||
places.extend(face_points(0).into_iter().map(|p| json!({ "position": p })));
|
||
}
|
||
let mut out = Vec::new();
|
||
for place in places {
|
||
let mut params = place;
|
||
params["diameter"] = json!(diameter);
|
||
params["type"] = json!(ty);
|
||
if to_object_on(&d.inputs) {
|
||
params["to"] = to_object_point(&d.inputs).ok_or("select the object to drill to")?;
|
||
} else if !opts.all {
|
||
params["depth"] = json!(depth);
|
||
}
|
||
if ty == "drilled" {
|
||
params["tip_angle"] = json!(opts.tip_angle);
|
||
}
|
||
if opts.tapped && !opts.thread.trim().is_empty() {
|
||
params["thread"] = json!(opts.thread.trim());
|
||
}
|
||
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(("solid.hole".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::BoundaryFill { operation, cells, .. } => {
|
||
need(0, "the tool bodies")?;
|
||
let picked: Vec<Value> = cells.iter().filter(|c| c.2).map(|c| pt(c.0)).collect();
|
||
if picked.is_empty() {
|
||
return Err("tick the cells to fill".into());
|
||
}
|
||
("SurfaceSculpt", json!({"tools": body_names(0), "cells": picked, "operation": OPS.get(*operation).copied().unwrap_or("new")}))
|
||
}
|
||
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();
|
||
(
|
||
"solid.combine",
|
||
json!({"target": target, "tools": tools, "operation": OPS.get(*operation).copied().unwrap_or("join"), "keep_tools": keep_tools}),
|
||
)
|
||
}
|
||
Kind::EditParam { name, expr } => ("parameters.change", json!({"name": name, "expression": expr})),
|
||
Kind::Rename { feature, name } => ("timeline.rename", json!({"feature": feature, "name": name})),
|
||
Kind::ConfirmDelete { feature, .. } => ("timeline.delete", json!({ "features": [feature.to_string()] })),
|
||
Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. } => return Ok(Vec::new()),
|
||
Kind::Assembly(k) => return crate::dialogs_assembly::commands(app, k, &d.inputs),
|
||
Kind::Sheet(k) => return crate::dialogs_sheet::commands(app, k, &d.inputs, &d.extra),
|
||
Kind::Surface(k) => return crate::dialogs_surface::commands(app, k, &d.inputs, &d.extra),
|
||
Kind::Plastic(k) => return crate::dialogs_plastic::commands(app, k, &d.inputs, &d.extra),
|
||
Kind::Appearance(k) => return Ok(crate::dialogs_appearance::commands(k, &d.inputs)),
|
||
Kind::Motion(k) => return crate::dialogs_motion::commands(app, k, &d.inputs),
|
||
Kind::Part(k) => return crate::dialogs_parts::commands(app, k, &d.inputs),
|
||
};
|
||
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).
|
||
/// The features a pattern repeats: picked features as they are, picked bodies by the features
|
||
/// that made them.
|
||
/// What a pattern or mirror copies: picked features are replayed; picked bodies are copied as
|
||
/// they are now, with every feature that shaped them (a fillet after the extrude included).
|
||
fn pattern_objects(s: &Session, objects: &[Sel], p: &mut Value) {
|
||
if objects.iter().any(|x| matches!(x, Sel::Feature { .. })) {
|
||
p["features"] = json!(pattern_features(s, objects));
|
||
} else {
|
||
let bodies: Vec<&str> = objects.iter().filter_map(|x| if let Sel::Body { name } = x { Some(name.as_str()) } else { None }).collect();
|
||
p["bodies"] = json!(bodies);
|
||
}
|
||
}
|
||
|
||
fn pattern_features(s: &Session, objects: &[Sel]) -> Vec<String> {
|
||
let mut out: Vec<String> = Vec::new();
|
||
let bodies: Vec<String> = objects.iter().filter_map(|x| if let Sel::Body { name } = x { Some(name.clone()) } else { None }).collect();
|
||
for x in objects {
|
||
if let Sel::Feature { id } = x
|
||
&& let Some(f) = s.doc.feature(*id)
|
||
&& !out.contains(&f.name)
|
||
{
|
||
out.push(f.name.clone());
|
||
}
|
||
}
|
||
for f in source_features(s, &bodies) {
|
||
if !out.contains(&f) {
|
||
out.push(f);
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
fn source_features(s: &Session, bodies: &[String]) -> Vec<String> {
|
||
let st = s.world_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.world_state();
|
||
let has =
|
||
|sid: u64| st.sketch(sid).is_some_and(|ss| ids.iter().all(|id| ss.sketch.curve_index(id).is_some() || ss.sketch.wire_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 } => match name.as_str() {
|
||
"X" => Some((Vec3::ZERO, Vec3::X)),
|
||
"Y" => Some((Vec3::ZERO, Vec3::Y)),
|
||
"Z" => Some((Vec3::ZERO, Vec3::Z)),
|
||
// A construction axis.
|
||
n => solvecraft_engine::view::construction_axes(&app.session).into_iter().find(|(_, an, _, _)| an == n).map(|(_, _, o, d)| (o, d)),
|
||
},
|
||
Sel::SketchCurve { id } => {
|
||
let st = app.session.world_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()?))
|
||
}
|
||
// A straight model edge, along it.
|
||
Sel::Edge { body, index, .. } => {
|
||
let b = app.session.world_state().body(body).cloned()?;
|
||
let m = b.mesh();
|
||
let e = m.edges.get(*index)?;
|
||
let (a, z) = (*e.first()?, *e.last()?);
|
||
let dir = (z - a).normalized()?;
|
||
let tol = 1e-6 * a.dist(z).max(1.0);
|
||
if !e.iter().all(|p| (*p - a).cross(dir).len() < tol) {
|
||
return None;
|
||
}
|
||
// An edge along an origin axis points the way the axis does (as picking the axis).
|
||
let axis = [Vec3::X, Vec3::Y, Vec3::Z].into_iter().find(|x| dir.dot(*x).abs() > 1.0 - 1e-9);
|
||
Some((a, axis.unwrap_or(dir)))
|
||
}
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Which profiles of a sketch a profile selection means.
|
||
pub(crate) 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::OffsetBoundaries { .. } | ProfileSel::WholeBoundaries { .. } => {
|
||
solvecraft_engine::doc::profile_selection_indices(ss, sel).unwrap_or_default()
|
||
}
|
||
ProfileSel::Curves { loops } => loops
|
||
.iter()
|
||
.filter_map(|l| {
|
||
solvecraft_engine::doc::profile_selection_indices(ss, &ProfileSel::Curves { loops: vec![l.clone()] }).ok()?.first().copied()
|
||
})
|
||
.collect(),
|
||
ProfileSel::Points { points } => points.iter().filter_map(|q| solvecraft_engine::doc::sketch::profile_at_point(ps, *q)).collect(),
|
||
}
|
||
}
|
||
|
||
thread_local! {
|
||
/// A dialog is being filled from a feature: its points are in component frames.
|
||
static REFILL: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||
}
|
||
|
||
/// While filling a dialog from a feature (dropped at the end).
|
||
struct Refill;
|
||
|
||
impl Refill {
|
||
fn start() -> Refill {
|
||
REFILL.with(|r| r.set(true));
|
||
Refill
|
||
}
|
||
}
|
||
|
||
impl Drop for Refill {
|
||
fn drop(&mut self) {
|
||
REFILL.with(|r| r.set(false));
|
||
}
|
||
}
|
||
|
||
/// A point stored in a feature, as it shows on world body `body`: features keep their picks in
|
||
/// the frame of the body they were on, so each body's placement takes the point to the world
|
||
/// (a moved component, a pasted instance). Other points are the world already.
|
||
fn stored_on(frames: &Option<Vec<(String, solvecraft_engine::doc::Mat)>>, body: &str, p: Vec3) -> Vec3 {
|
||
frames.as_ref().and_then(|f| f.iter().find(|(n, _)| n == body)).map(|(_, m)| solvecraft_engine::doc::apply_point(m, p)).unwrap_or(p)
|
||
}
|
||
|
||
/// Body placements while filling a dialog from a feature (when anything is placed).
|
||
fn stored_frames(s: &Session) -> Option<Vec<(String, solvecraft_engine::doc::Mat)>> {
|
||
if !REFILL.with(std::cell::Cell::get) || !solvecraft_engine::frames::placed(s) {
|
||
return None;
|
||
}
|
||
let st = s.model.state();
|
||
Some(s.doc.body_frames(&st).into_iter().map(|(w, _, _, m)| (w, m)).collect())
|
||
}
|
||
|
||
/// The edge of a visible body through (or nearest) a point.
|
||
pub(crate) fn edge_sel(s: &Session, p0: Vec3) -> Option<Sel> {
|
||
let st = s.world_state();
|
||
let frames = stored_frames(s);
|
||
let mut best: Option<(f64, Sel)> = None;
|
||
for b in &st.bodies {
|
||
let p = stored_on(&frames, &b.name, p0);
|
||
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).
|
||
pub(crate) fn face_sel(s: &Session, p0: Vec3) -> Option<Sel> {
|
||
let st = s.world_state();
|
||
let frames = stored_frames(s);
|
||
let mut best: Option<(f64, Sel)> = None;
|
||
for b in &st.bodies {
|
||
let p = stored_on(&frames, &b.name, p0);
|
||
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 * p0.len()).map(|x| x.1)
|
||
}
|
||
|
||
fn emboss_face_sel(s: &Session, component: u64, face: &solvecraft_engine::doc::EmbossFace, persistent: &str) -> Option<Sel> {
|
||
let source = s.model.state();
|
||
let body = source.body(&face.body)?;
|
||
let index = solvecraft_engine::doc::naming::face_names(body).iter().position(|name| name == persistent)?;
|
||
let owner = s.doc.model_body_component(body);
|
||
let world_name = s.doc.world_body_name(&source, &face.body, owner, &face.path);
|
||
let author = solvecraft_engine::doc::EmbossFace::component_frame(&s.doc, component, &face.author_path).ok()?;
|
||
let point = solvecraft_engine::doc::apply_point(&author, face.at);
|
||
s.world_state().body(&world_name)?;
|
||
Some(Sel::Face { body: world_name, index, point })
|
||
}
|
||
|
||
pub(crate) 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 } | PlaneRef::Face { plane, .. } => face_sel(s, plane.origin),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
pub(crate) 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 point of expressions: numbers as numbers, expressions as strings.
|
||
fn pt_expr(p: &solvecraft_engine::doc::PointExpr) -> Value {
|
||
Value::Array(p.iter().map(|e| e.parse::<f64>().ok().filter(|x| x.is_finite()).map_or_else(|| json!(e), |x| json!(x))).collect())
|
||
}
|
||
|
||
/// 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 _refill = Refill::start();
|
||
let s = &app.session;
|
||
let f = s.doc.feature(id)?.clone();
|
||
let st = s.world_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("solid.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_else(|| "0 deg".into()),
|
||
taper_present: extent.taper.is_some(),
|
||
to_offset: extent.to_offset.clone().unwrap_or_default(),
|
||
start: extent.start_offset.clone().unwrap_or_default(),
|
||
all: extent.through_all,
|
||
};
|
||
// Extent references belong to the extrude's authoring frame, including free
|
||
// points. They are not body-local picks (a reference may be on another body).
|
||
let frame = feature_frame(s, f.component);
|
||
for (label, point) in [(TO_OBJECT, extent.to), (FROM_OBJECT, extent.from)] {
|
||
if let Some(point) = point {
|
||
set_object(&mut d.inputs, &mut d.active, label, true);
|
||
if let Some(input) = d.inputs.iter_mut().find(|i| i.label == label) {
|
||
input.items = vec![Sel::Vertex { body: String::new(), point: solvecraft_engine::doc::apply_point(&frame, point) }];
|
||
}
|
||
}
|
||
}
|
||
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();
|
||
}
|
||
if !targets.is_empty() {
|
||
d.extra.insert("targets".into(), json!(targets));
|
||
}
|
||
d
|
||
}
|
||
FeatureKind::Revolve { sketch, profiles, axis, angle, operation, targets, angle2, symmetric, convention, to } => {
|
||
let mut d = start("solid.revolve")?;
|
||
let frame = feature_frame(s, f.component);
|
||
let stored_axis = match axis {
|
||
AxisRef::Line { origin, dir } => Some(json!({
|
||
"origin": pt(solvecraft_engine::doc::apply_point(&frame, *origin)),
|
||
"dir": pt(solvecraft_engine::doc::apply_vector(&frame, *dir))
|
||
})),
|
||
_ => None,
|
||
};
|
||
d.kind = Kind::Revolve { angle: angle.clone(), operation: op_index(operation), axis: stored_axis };
|
||
d.extra.insert("convention".into(), json!(convention));
|
||
if let Some(a2) = angle2 {
|
||
d.extra.insert("angle2".into(), json!(a2));
|
||
}
|
||
if *symmetric {
|
||
d.extra.insert("direction".into(), json!("symmetric"));
|
||
}
|
||
if let Some(point) = to {
|
||
set_to_object(&mut d.inputs, &mut d.active, true);
|
||
if let Some(input) = d.inputs.iter_mut().find(|i| i.label == TO_OBJECT) {
|
||
input.items = vec![Sel::Vertex { body: String::new(), point: solvecraft_engine::doc::apply_point(&frame, *point) }];
|
||
}
|
||
}
|
||
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::SketchAxis { axis } => Some(Sel::Axis { name: axis.to_ascii_lowercase() }),
|
||
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 { "solid.chamfer" } else { "solid.fillet" })?;
|
||
d.kind = fillet_kind(radius, chamfer);
|
||
if let Kind::Fillet { chain, ctype, distance2, angle, flip, .. } = &mut d.kind {
|
||
*chain = false;
|
||
// An unequal chamfer: two distances, or a distance and an angle.
|
||
if let FeatureKind::Chamfer { distance2: d2, angle: a, flip: f, .. } = &f.kind {
|
||
if let Some(v) = d2 {
|
||
*ctype = 1;
|
||
*distance2 = v.clone();
|
||
} else if let Some(v) = a {
|
||
*ctype = 2;
|
||
*angle = v.clone();
|
||
}
|
||
*flip = *f;
|
||
}
|
||
}
|
||
// A chord-length or variable fillet.
|
||
if let FeatureKind::Fillet { style, .. } = &f.kind {
|
||
match style {
|
||
FilletStyle::Constant => {}
|
||
FilletStyle::Chord => {
|
||
d.extra.insert("type".into(), json!("chord"));
|
||
}
|
||
FilletStyle::Variable { radius2, start, mid, smooth } => {
|
||
d.extra.insert("type".into(), json!("variable"));
|
||
d.extra.insert("radius2".into(), json!(radius2));
|
||
d.extra.insert("mid".into(), json!(mid.iter().map(|(t, r)| json!([t, r])).collect::<Vec<_>>()));
|
||
d.extra.insert("smooth".into(), json!(smooth));
|
||
// The start was the first edge's last point: ends swapped.
|
||
let first_end = edges.first().and_then(|p| edge_sel(s, *p)).and_then(|x| match x {
|
||
Sel::Edge { body, index, .. } => {
|
||
s.world_state().body(&body).and_then(|b| b.mesh().edges.get(index).and_then(|e| e.last().copied()))
|
||
}
|
||
_ => None,
|
||
});
|
||
let swapped = first_end.is_some_and(|e| e.dist(*start) < 1e-6);
|
||
d.extra.insert("swap_ends".into(), json!(swapped));
|
||
}
|
||
}
|
||
}
|
||
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, direction, tangent_chain, .. } => {
|
||
let mut d = start("solid.shell")?;
|
||
d.kind = Kind::Shell { thickness: thickness.clone() };
|
||
d.extra.insert("tangent_chain".into(), json!(tangent_chain));
|
||
if !direction.is_empty() && direction != "inside" {
|
||
d.extra.insert("direction".into(), json!(direction));
|
||
}
|
||
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("solid.draft")?;
|
||
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, points, thread, to } => {
|
||
let mut d = start("solid.hole")?;
|
||
let mut k = hole_defaults();
|
||
if let Kind::Hole { diameter: dia, depth: dep, kind, cb_diameter, cb_depth, cs_diameter, cs_angle, opts } = &mut k {
|
||
*dia = diameter.clone();
|
||
opts.all = depth.is_none();
|
||
opts.angled = matches!(hole, HoleKind::Drilled { .. });
|
||
opts.multiple = points.is_some();
|
||
if let Some(th) = thread {
|
||
opts.tapped = true;
|
||
opts.thread = th.clone();
|
||
}
|
||
if let Some(dd) = depth {
|
||
*dep = dd.clone();
|
||
}
|
||
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 } => {
|
||
opts.tip_angle = tip_angle.clone();
|
||
}
|
||
HoleKind::Simple => {}
|
||
}
|
||
}
|
||
d.kind = k;
|
||
match points {
|
||
Some(sp) => {
|
||
d.inputs = vec![SelInput::new("Points", selection::POINTS, true)];
|
||
if let Some(inp) = d.inputs.first_mut() {
|
||
inp.items = sp.ids.iter().map(|id| Sel::SketchPoint { id: format!("{}:{id}", sp.sketch) }).collect();
|
||
}
|
||
}
|
||
None => {
|
||
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));
|
||
}
|
||
}
|
||
if to.is_some() {
|
||
if let Kind::Hole { opts, .. } = &mut d.kind {
|
||
opts.all = false;
|
||
}
|
||
with_to_object(&mut d, s, *to);
|
||
}
|
||
// A hole at every point of a sketch.
|
||
if points.as_ref().is_some_and(|sp| sp.ids.is_empty()) {
|
||
d.extra.insert("all_points".into(), json!(true));
|
||
}
|
||
d
|
||
}
|
||
FeatureKind::Box { corner, length, width, height, operation } => {
|
||
let mut d = start("solid.box")?;
|
||
d.kind = Kind::Primitive {
|
||
cmd: "solid.box",
|
||
fields: vec![("length", length.clone()), ("width", width.clone()), ("height", height.clone())],
|
||
operation: op_index(operation),
|
||
};
|
||
d.extra.insert("corner".into(), pt_expr(corner));
|
||
d
|
||
}
|
||
FeatureKind::Cylinder { base, axis, radius, height, operation } => {
|
||
let mut d = start("solid.cylinder")?;
|
||
d.kind = Kind::Primitive {
|
||
cmd: "solid.cylinder",
|
||
fields: vec![("radius", radius.clone()), ("height", height.clone())],
|
||
operation: op_index(operation),
|
||
};
|
||
d.extra.insert("base".into(), pt_expr(base));
|
||
d.extra.insert("axis".into(), pt3(*axis));
|
||
d
|
||
}
|
||
FeatureKind::Sphere { center, radius, operation } => {
|
||
let mut d = start("solid.sphere")?;
|
||
d.kind = Kind::Primitive { cmd: "solid.sphere", fields: vec![("radius", radius.clone())], operation: op_index(operation) };
|
||
d.extra.insert("center".into(), pt_expr(center));
|
||
d
|
||
}
|
||
FeatureKind::Torus { center, major, minor, operation } => {
|
||
let mut d = start("solid.torus")?;
|
||
d.kind = Kind::Primitive {
|
||
cmd: "solid.torus",
|
||
fields: vec![("major", major.clone()), ("minor", minor.clone())],
|
||
operation: op_index(operation),
|
||
};
|
||
d.extra.insert("center".into(), pt_expr(center));
|
||
d
|
||
}
|
||
FeatureKind::BoundaryFill { tools, cells, operation, remove_tools } => {
|
||
let mut d = start("SurfaceSculpt")?;
|
||
// The cells again, ticked where a stored cell point lies in the same cell.
|
||
let before = s.model.state_before(f.id);
|
||
let found = fill_cells(&before, tools);
|
||
let filled: Vec<String> =
|
||
cells.iter().filter_map(|c| found.iter().min_by(|a, b| a.0.dist(*c).total_cmp(&b.0.dist(*c))).map(|x| x.1.clone())).collect();
|
||
let items: Vec<Sel> = tools.iter().map(|n| Sel::Body { name: n.clone() }).collect();
|
||
d.kind = Kind::BoundaryFill {
|
||
operation: op_index(operation),
|
||
cells: found.into_iter().map(|(p, l)| (p, l.clone(), filled.contains(&l))).collect(),
|
||
of: items.clone(),
|
||
};
|
||
if let Some(inp) = d.inputs.first_mut() {
|
||
inp.items = items;
|
||
}
|
||
if *remove_tools {
|
||
d.extra.insert("remove_tools".into(), json!(true));
|
||
}
|
||
d
|
||
}
|
||
FeatureKind::Combine { target, tools, operation, keep_tools } => {
|
||
let mut d = start("solid.combine")?;
|
||
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, bodies, .. } => {
|
||
let mut d = start("solid.mirror")?;
|
||
let ids: Vec<u64> = features.iter().filter_map(|n| s.doc.find_feature(n).map(|f| f.id)).collect();
|
||
// Features that made no body of their own (a hole) were picked as features.
|
||
let as_features = ids.iter().any(|id| !st.bodies.iter().any(|b| b.feature == *id));
|
||
if let Some(inp) = d.inputs.get_mut(0) {
|
||
if !bodies.is_empty() {
|
||
inp.items = bodies.iter().map(|name| Sel::Body { name: name.clone() }).collect();
|
||
} else if as_features {
|
||
inp.accept = selection::FEATURES;
|
||
inp.label = objects_label(inp.accept);
|
||
inp.items = ids.iter().map(|id| Sel::Feature { id: *id }).collect();
|
||
} else {
|
||
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, bodies: copied } => {
|
||
let ids: Vec<u64> = features.iter().filter_map(|n| s.doc.find_feature(n).map(|f| f.id)).collect();
|
||
let bodies: Vec<Sel> = if copied.is_empty() {
|
||
st.bodies.iter().filter(|b| ids.contains(&b.feature)).map(|b| Sel::Body { name: b.name.clone() }).collect()
|
||
} else {
|
||
copied.iter().map(|name| Sel::Body { name: name.clone() }).collect()
|
||
};
|
||
let mut d = match pattern {
|
||
PatternKind::Rectangular { dir1, count1, spacing1, dir2, count2, spacing2 } => {
|
||
let mut d = start("solid.pattern.rectangular")?;
|
||
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("solid.pattern.circular")?;
|
||
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,
|
||
};
|
||
// Features that made no body of their own (a hole, a fillet) were picked as features.
|
||
let as_features = copied.is_empty() && ids.iter().any(|id| !st.bodies.iter().any(|b| b.feature == *id));
|
||
if let Some(inp) = d.inputs.first_mut() {
|
||
if as_features {
|
||
inp.accept = selection::FEATURES;
|
||
inp.label = objects_label(inp.accept);
|
||
inp.items = ids.iter().map(|id| Sel::Feature { id: *id }).collect();
|
||
} else {
|
||
inp.items = bodies;
|
||
}
|
||
}
|
||
d
|
||
}
|
||
FeatureKind::Loft { sections, operation, targets } => {
|
||
let mut d = start("solid.loft")?;
|
||
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("solid.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::ConstructionPlane { plane: PlaneRef::Offset { base, distance } } => {
|
||
let mut d = start("construct.plane.offset")?;
|
||
d.kind = Kind::OffsetPlane { offset: distance.clone() };
|
||
if let Some(inp) = d.inputs.first_mut() {
|
||
inp.items = plane_sel(s, base).into_iter().collect();
|
||
}
|
||
d
|
||
}
|
||
FeatureKind::ConstructionPlane { plane: PlaneRef::AtAngle { base, axis_origin, axis_dir, angle } } => {
|
||
let mut d = start("construct.plane.at_angle")?;
|
||
d.kind = Kind::AnglePlane { angle: angle.clone() };
|
||
if let Some(inp) = d.inputs.first_mut() {
|
||
inp.items = plane_sel(s, base).into_iter().collect();
|
||
}
|
||
match world_axis(*axis_dir).filter(|_| axis_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(*axis_origin), "dir": pt3(*axis_dir)}));
|
||
}
|
||
}
|
||
d
|
||
}
|
||
FeatureKind::Split { body, plane, tool } => {
|
||
let mut d = start("solid.split_body")?;
|
||
if let Some(inp) = d.inputs.first_mut() {
|
||
inp.items = vec![Sel::Body { name: body.clone() }];
|
||
}
|
||
if let Some(inp) = d.inputs.get_mut(1) {
|
||
inp.items = match tool {
|
||
Some(t) => face_sel(s, t.point).into_iter().collect(),
|
||
None => plane_sel(s, plane).into_iter().collect(),
|
||
};
|
||
}
|
||
d
|
||
}
|
||
FeatureKind::Scale { bodies, origin, factor, factors } => {
|
||
let mut d = start("solid.scale")?;
|
||
d.kind = Kind::Scale { factor: factor.clone() };
|
||
if let Some(inp) = d.inputs.first_mut() {
|
||
inp.items = bodies.iter().map(|n| Sel::Body { name: n.clone() }).collect();
|
||
}
|
||
d.extra.insert("origin".into(), pt3(*origin));
|
||
if let Some(f) = factors {
|
||
d.extra.insert("factors".into(), json!(f));
|
||
}
|
||
d
|
||
}
|
||
FeatureKind::OffsetFace { faces, distance, body } => {
|
||
let mut d = start("solid.offset_face")?;
|
||
d.kind = Kind::OffsetFaces { distance: distance.clone() };
|
||
if let Some(inp) = d.inputs.first_mut() {
|
||
inp.items = faces.iter().filter_map(|p| face_sel(s, *p)).collect();
|
||
}
|
||
if let Some(b) = body {
|
||
d.extra.insert("body".into(), json!(b));
|
||
}
|
||
d
|
||
}
|
||
FeatureKind::Thread { face, designation, length, modeled } => {
|
||
let mut d = start("solid.thread")?;
|
||
if *modeled {
|
||
d.extra.insert("modeled".into(), json!(true));
|
||
}
|
||
d.kind = Kind::Thread { designation: designation.clone().unwrap_or_default(), length: length.clone().unwrap_or_default() };
|
||
if let Some(inp) = d.inputs.first_mut() {
|
||
inp.items = face_sel(s, *face).into_iter().collect();
|
||
}
|
||
d
|
||
}
|
||
FeatureKind::BoundingSolid { bodies, margin } => {
|
||
let mut d = start("solid.bounding_solid")?;
|
||
d.kind = Kind::Stock { margin: margin.clone() };
|
||
if let Some(inp) = d.inputs.first_mut() {
|
||
inp.items = bodies.iter().map(|n| Sel::Body { name: n.clone() }).collect();
|
||
}
|
||
d
|
||
}
|
||
FeatureKind::Pipe { path, diameter, wall, operation, targets, .. } => {
|
||
let mut d = start("solid.pipe")?;
|
||
d.kind = Kind::Pipe { diameter: diameter.clone(), wall: wall.clone().unwrap_or_default() };
|
||
if let Some(inp) = d.inputs.first_mut() {
|
||
inp.items = path.iter().map(|id| Sel::SketchCurve { id: id.clone() }).collect();
|
||
}
|
||
d.extra.insert("operation".into(), json!(OPS.get(op_index(operation)).copied().unwrap_or("new")));
|
||
if !targets.is_empty() {
|
||
d.extra.insert("targets".into(), json!(targets));
|
||
}
|
||
d
|
||
}
|
||
FeatureKind::Emboss { sketch, profiles, depth, deboss, targets, face } => {
|
||
let mut d = start("solid.emboss")?;
|
||
d.kind = Kind::Emboss { depth: depth.clone(), deboss: *deboss };
|
||
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();
|
||
}
|
||
if !targets.is_empty() {
|
||
d.extra.insert("targets".into(), json!(targets));
|
||
}
|
||
if let Some(face) = face
|
||
&& let Some(input) = d.inputs.get_mut(1)
|
||
{
|
||
input.items = vec![emboss_face_sel(s, f.component, face, f.face_names.first()?)?];
|
||
}
|
||
d
|
||
}
|
||
FeatureKind::Rib { curves, thickness, depth, flip, web, .. } => {
|
||
let mut d = start(if *web { "solid.web" } else { "solid.rib" })?;
|
||
d.kind = Kind::Rib { web: *web, thickness: thickness.clone(), depth: depth.clone().unwrap_or_default(), flip: *flip };
|
||
if let Some(inp) = d.inputs.first_mut() {
|
||
inp.items = curves.iter().map(|id| Sel::SketchCurve { id: id.clone() }).collect();
|
||
}
|
||
d
|
||
}
|
||
FeatureKind::ReplaceFace { faces, target, body } => {
|
||
let mut d = start("solid.replace_face")?;
|
||
if let Some(inp) = d.inputs.first_mut() {
|
||
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, target).into_iter().collect();
|
||
}
|
||
if let Some(b) = body {
|
||
d.extra.insert("body".into(), json!(b));
|
||
}
|
||
d
|
||
}
|
||
FeatureKind::Remove { bodies } => {
|
||
let mut d = start("solid.remove")?;
|
||
if let Some(inp) = d.inputs.first_mut() {
|
||
inp.items = bodies.iter().map(|n| Sel::Body { name: n.clone() }).collect();
|
||
}
|
||
d
|
||
}
|
||
FeatureKind::Move { bodies, translate, rotate_axis, angle } => {
|
||
let mut d = start("solid.move")?;
|
||
crate::dialogs_move::for_feature(&mut d, bodies, translate, *rotate_axis, angle.as_ref());
|
||
d
|
||
}
|
||
other => {
|
||
let (kind, inputs, extra) = crate::dialogs_sheet::for_feature(app, other)
|
||
.or_else(|| crate::dialogs_plastic::for_feature(app, other))
|
||
.or_else(|| crate::dialogs_surface::for_feature(app, other))?;
|
||
let mut d = Dialog::new(kind, inputs);
|
||
d.extra = extra;
|
||
d
|
||
}
|
||
};
|
||
d.editing = Some((id, marker));
|
||
d.active = 0;
|
||
d.error = None;
|
||
Some(d)
|
||
}
|