Drag manipulators and automatic operation: extrude (two arrows when symmetric), fillet/chamfer, shell and move arrows drag their value with round-step snapping (Alt/Ctrl: free) and the dialog follows; Extrude operation "auto" (engine) and the dialog follow the geometry: into a body cuts (red), out of one joins, free space makes a new body; a manual choice sticks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
f5c2c725c8
commit
b2b9331415
7 files changed
+250
-25
No files matched your search
@@ -17,6 +17,21 @@ fn point_triangle_dist(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> f64 {
|
|||||||
if inside { h.abs() } else { [(a, b), (b, c), (c, a)].iter().map(|(u, v)| p.dist_to_segment(*u, *v)).fold(f64::INFINITY, f64::min) }
|
if inside { h.abs() } else { [(a, b), (b, c), (c, a)].iter().map(|(u, v)| p.dist_to_segment(*u, *v)).fold(f64::INFINITY, f64::min) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The outward normal of the planar body face at `p`.
|
||||||
|
pub(super) fn face_normal(s: &Session, p: Vec3) -> Option<Vec3> {
|
||||||
|
let (m, f, d) = face_at(s, p)?;
|
||||||
|
let tol = (m.bounds().diagonal() * 1e-3).max(1e-3);
|
||||||
|
if d > tol * 10.0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
m.triangles
|
||||||
|
.iter()
|
||||||
|
.zip(&m.tri_face)
|
||||||
|
.filter(|(_, tf)| **tf == f)
|
||||||
|
.filter_map(|(t, _)| m.tri(t))
|
||||||
|
.find_map(|[a, b, c]| (b - a).cross(c - a).normalized())
|
||||||
|
}
|
||||||
|
|
||||||
/// The body face under a point: (mesh, face index, distance).
|
/// The body face under a point: (mesh, face index, distance).
|
||||||
fn face_at(s: &Session, p: Vec3) -> Option<(std::sync::Arc<Mesh>, u32, f64)> {
|
fn face_at(s: &Session, p: Vec3) -> Option<(std::sync::Arc<Mesh>, u32, f64)> {
|
||||||
let st = s.model.state();
|
let st = s.model.state();
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
|||||||
.at("SOLID", "CREATE")
|
.at("SOLID", "CREATE")
|
||||||
.icon("extrude")
|
.icon("extrude")
|
||||||
.key("E")
|
.key("E")
|
||||||
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [[curve ids]] | [{point:[x,y]}]; face?: [x,y,z] (extrude a planar body face instead of a sketch profile); direction?: positive|negative|symmetric; distance2?; start_offset?; operation?: new|join|cut|intersect; targets?: [body]; name?; body_name?"),
|
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [[curve ids]] | [{point:[x,y]}]; face?: [x,y,z] (extrude a planar body face instead of a sketch profile); direction?: positive|negative|symmetric; distance2?; start_offset?; operation?: new|join|cut|intersect|auto (cut into a body, join out of one, else new); targets?: [body]; name?; body_name?"),
|
||||||
CommandSpec::new("Revolve", "Revolve", revolve)
|
CommandSpec::new("Revolve", "Revolve", revolve)
|
||||||
.at("SOLID", "CREATE")
|
.at("SOLID", "CREATE")
|
||||||
.icon("revolve")
|
.icon("revolve")
|
||||||
@@ -73,6 +73,63 @@ pub static COMMANDS: &[CommandSpec] = &[
|
|||||||
CommandSpec::new("FusionMoveCommand", "Move/Copy", move_bodies).at("SOLID", "MODIFY").icon("move").key("M").params("bodies: [names]; translate?: [x,y,z] (exprs or numbers); axis?: [x,y,z]; angle?: expr"),
|
CommandSpec::new("FusionMoveCommand", "Move/Copy", move_bodies).at("SOLID", "MODIFY").icon("move").key("M").params("bodies: [names]; translate?: [x,y,z] (exprs or numbers); axis?: [x,y,z]; angle?: expr"),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// Where an extrude starts and which way is positive: a point inside the (first) profile or
|
||||||
|
/// face, and the unit normal.
|
||||||
|
fn extrude_base(s: &Session, p: &Value) -> Option<(Vec3, Vec3)> {
|
||||||
|
if let Some(fp) = p.get("face").and_then(vec3) {
|
||||||
|
return Some((fp, super::face::face_normal(s, fp)?));
|
||||||
|
}
|
||||||
|
let sid = feature_sketch(s, p, "Extrude").ok()?;
|
||||||
|
let st = s.model.state();
|
||||||
|
let ss = st.sketch(sid)?;
|
||||||
|
let ps = &ss.profiles;
|
||||||
|
let q = match profiles(p, "Extrude").ok()? {
|
||||||
|
ProfileSel::All => ps.first()?.region.interior_point(),
|
||||||
|
ProfileSel::Indices { indices } => ps.get(*indices.first()?)?.region.interior_point(),
|
||||||
|
ProfileSel::Points { points } => *points.first()?,
|
||||||
|
ProfileSel::Curves { loops } => {
|
||||||
|
let mut want: Vec<&String> = loops.first()?.iter().collect();
|
||||||
|
want.sort();
|
||||||
|
ps.iter()
|
||||||
|
.find(|x| {
|
||||||
|
let mut have: Vec<&String> = x.outer_curves.iter().collect();
|
||||||
|
have.sort();
|
||||||
|
have == want
|
||||||
|
})?
|
||||||
|
.region
|
||||||
|
.interior_point()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Some((ss.plane.to_world(q), ss.plane.normal()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What `operation: "auto"` means for an extrude with these parameters: `cut` when it goes
|
||||||
|
/// into a body, `join` when it grows out of one, `new` otherwise.
|
||||||
|
pub fn auto_operation(s: &Session, p: &Value) -> Option<&'static str> {
|
||||||
|
let d = s.doc.eval(&expr(p, "distance")?, Kind::Length).ok()?;
|
||||||
|
let dir = str_(p, "direction").map(str::to_ascii_lowercase);
|
||||||
|
let symmetric = dir.as_deref() == Some("symmetric");
|
||||||
|
let d = if matches!(dir.as_deref(), Some("negative" | "reverse" | "flip")) { -d } else { d };
|
||||||
|
let (base, n) = extrude_base(s, p)?;
|
||||||
|
let st = s.model.state();
|
||||||
|
if st.bodies.is_empty() || !d.is_finite() {
|
||||||
|
return Some("new");
|
||||||
|
}
|
||||||
|
let inside = |q: Vec3| st.bodies.iter().any(|b| b.mesh().contains(q));
|
||||||
|
let eps = (d.abs() * 1e-3).max(1e-3);
|
||||||
|
let sign = if d < 0.0 { -1.0 } else { 1.0 };
|
||||||
|
if symmetric {
|
||||||
|
return Some(if inside(base + n * (d.abs() * 0.5)) || inside(base - n * (d.abs() * 0.5)) { "cut" } else { "new" });
|
||||||
|
}
|
||||||
|
if inside(base + n * (d * 0.5)) {
|
||||||
|
Some("cut")
|
||||||
|
} else if inside(base - n * (eps * sign)) {
|
||||||
|
Some("join")
|
||||||
|
} else {
|
||||||
|
Some("new")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn operation(p: &Value, cmd: &str) -> Result<Operation> {
|
fn operation(p: &Value, cmd: &str) -> Result<Operation> {
|
||||||
match str_(p, "operation") {
|
match str_(p, "operation") {
|
||||||
None => Ok(Operation::NewBody),
|
None => Ok(Operation::NewBody),
|
||||||
@@ -219,7 +276,10 @@ fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
None => profiles(p, cmd)?,
|
None => profiles(p, cmd)?,
|
||||||
},
|
},
|
||||||
extent: Extent { distance, direction, distance2, start_offset, through_all, taper },
|
extent: Extent { distance, direction, distance2, start_offset, through_all, taper },
|
||||||
operation: operation(p, cmd)?,
|
operation: match str_(p, "operation") {
|
||||||
|
Some(o) if o.eq_ignore_ascii_case("auto") => Operation::parse(auto_operation(s, p).unwrap_or("new")).unwrap_or(Operation::NewBody),
|
||||||
|
_ => operation(p, cmd)?,
|
||||||
|
},
|
||||||
targets: string_list(p, "targets"),
|
targets: string_list(p, "targets"),
|
||||||
};
|
};
|
||||||
add_feature(s, p, kind)
|
add_feature(s, p, kind)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
mod edit;
|
mod edit;
|
||||||
mod face;
|
mod face;
|
||||||
mod features;
|
mod features;
|
||||||
|
pub use features::auto_operation;
|
||||||
mod file;
|
mod file;
|
||||||
mod inspect;
|
mod inspect;
|
||||||
mod sketch;
|
mod sketch;
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ use serde_json::Value;
|
|||||||
use solvecraft_doc::{Document, Model, ModelState};
|
use solvecraft_doc::{Document, Model, ModelState};
|
||||||
use solvecraft_geom::Vec3;
|
use solvecraft_geom::Vec3;
|
||||||
|
|
||||||
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
|
pub use cmd::{CommandInfo, CommandSpec, auto_operation, command_specs, find_command};
|
||||||
pub use solvecraft_doc as doc;
|
pub use solvecraft_doc as doc;
|
||||||
pub use solvecraft_geom as geom;
|
pub use solvecraft_geom as geom;
|
||||||
pub use solvecraft_io as io;
|
pub use solvecraft_io as io;
|
||||||
|
|||||||
@@ -529,6 +529,32 @@ fn preview_of_an_edit() {
|
|||||||
assert!(Arc::ptr_eq(&doc, &s.doc));
|
assert!(Arc::ptr_eq(&doc, &s.doc));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `operation: "auto"`: into a body cuts, out of a body joins, in free space makes a new body.
|
||||||
|
#[test]
|
||||||
|
fn extrude_auto_operation() {
|
||||||
|
let mut s = Session::default();
|
||||||
|
run(&mut s, "PrimitiveBox", json!({"length": 40, "width": 30, "height": 20}));
|
||||||
|
// A face pushed in (negative distance) cuts.
|
||||||
|
let p = json!({"face": [20, 15, 20], "distance": -5, "operation": "auto"});
|
||||||
|
assert_eq!(auto_operation(&s, &p), Some("cut"));
|
||||||
|
run(&mut s, "Extrude", p);
|
||||||
|
assert!(rel(volume(&mut s), 40.0 * 30.0 * 15.0) < 1e-6);
|
||||||
|
// Pulled out, it joins.
|
||||||
|
assert_eq!(auto_operation(&s, &json!({"face": [20, 15, 15], "distance": 5})), Some("join"));
|
||||||
|
// A sketch on XY under the box: up goes into it (cut), down grows out of it (join), and a
|
||||||
|
// profile beside the box makes a new body.
|
||||||
|
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
|
||||||
|
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [5, 5], "p1": [15, 15]}));
|
||||||
|
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [60, 5], "p1": [70, 15]}));
|
||||||
|
run(&mut s, "SketchStop", json!({}));
|
||||||
|
let at = |x: f64, d: f64| json!({"profiles": [{"point": [x, 10]}], "distance": d});
|
||||||
|
assert_eq!(auto_operation(&s, &at(10.0, 8.0)), Some("cut"));
|
||||||
|
assert_eq!(auto_operation(&s, &at(10.0, -8.0)), Some("join"));
|
||||||
|
assert_eq!(auto_operation(&s, &at(65.0, 8.0)), Some("new"));
|
||||||
|
run(&mut s, "Extrude", json!({"profiles": [{"point": [10, 10]}], "distance": 8, "operation": "auto"}));
|
||||||
|
assert!(rel(volume(&mut s), 40.0 * 30.0 * 15.0 - 100.0 * 8.0) < 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
/// Press Pull: positive pulls a face out, negative pushes it in (a cut), edges get a fillet.
|
/// Press Pull: positive pulls a face out, negative pushes it in (a cut), edges get a fillet.
|
||||||
#[test]
|
#[test]
|
||||||
fn press_pull() {
|
fn press_pull() {
|
||||||
|
|||||||
+115
-19
@@ -1,42 +1,66 @@
|
|||||||
//! On-canvas command input: while a feature dialog is open, its main value (extrude distance,
|
//! On-canvas command input: while a feature dialog is open, its main value (extrude distance,
|
||||||
//! fillet radius…) gets a value box in the viewport next to the geometry, with a direction
|
//! fillet radius…) gets a value box in the viewport next to the geometry, and an arrow
|
||||||
//! arrow for extrudes. The box takes the keyboard as soon as the command has its geometry, so
|
//! (extrude, fillet, shell, move) that can be dragged: values snap to round steps (hold Alt or
|
||||||
//! typing a number sets the value at once; Enter applies, Tab moves on, Esc cancels.
|
//! Ctrl for a free drag) and the dialog follows. The box takes the keyboard as soon as the
|
||||||
|
//! command has its geometry, so typing a number sets the value at once; Enter applies, Tab
|
||||||
|
//! moves on, Esc cancels.
|
||||||
|
|
||||||
use egui::{Color32, Pos2, Stroke, vec2};
|
use egui::{Color32, Pos2, Stroke, vec2};
|
||||||
use solvecraft_engine::Sel;
|
use solvecraft_engine::Sel;
|
||||||
use solvecraft_engine::doc::expr::Kind as ValueKind;
|
use solvecraft_engine::doc::expr::Kind as ValueKind;
|
||||||
use solvecraft_engine::geom::Vec3;
|
use solvecraft_engine::geom::{Mesh, Vec3};
|
||||||
|
|
||||||
use crate::SolveApp;
|
use crate::SolveApp;
|
||||||
use crate::dialogs::{Dialog, Kind};
|
use crate::dialogs::{Dialog, Kind};
|
||||||
use crate::theme::Tokens;
|
use crate::theme::Tokens;
|
||||||
use crate::viewport::Proj;
|
use crate::viewport::Proj;
|
||||||
|
|
||||||
/// Where the dialog's geometry is: a base point and, for directional values, the direction a
|
/// Where the dialog's geometry is: a base point and, for values that can be dragged, the
|
||||||
/// positive value points.
|
/// direction a growing value points.
|
||||||
pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option<Vec3>)> {
|
pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option<Vec3>)> {
|
||||||
let st = app.session.model.state();
|
let st = app.session.model.state();
|
||||||
let first = d.inputs.first()?.items.first()?;
|
let first = d.inputs.first()?.items.first()?;
|
||||||
let at = match first {
|
let (at, normal) = match first {
|
||||||
Sel::Profile { sketch, index } => {
|
Sel::Profile { sketch, index } => {
|
||||||
let ss = st.sketch(*sketch)?;
|
let ss = st.sketch(*sketch)?;
|
||||||
let p = ss.profiles.get(*index)?;
|
let p = ss.profiles.get(*index)?;
|
||||||
(ss.plane.to_world(p.region.interior_point()), Some(ss.plane.normal()))
|
(ss.plane.to_world(p.region.interior_point()), Some(ss.plane.normal()))
|
||||||
}
|
}
|
||||||
Sel::Face { body, index, point } => (*point, crate::dialogs::planar_face(&app.session, body, *index).map(|(_, n)| n)),
|
Sel::Face { body, index, point } => (*point, crate::dialogs::planar_face(&app.session, body, *index).map(|(_, n)| n)),
|
||||||
Sel::Edge { point, .. } | Sel::Vertex { point, .. } => (*point, None),
|
Sel::Edge { body, index, point } => (*point, st.body(body).and_then(|b| edge_bisector(&b.mesh(), *index, *point))),
|
||||||
|
Sel::Vertex { point, .. } => (*point, None),
|
||||||
Sel::Body { name } => (st.body(name)?.mesh().bounds().center(), None),
|
Sel::Body { name } => (st.body(name)?.mesh().bounds().center(), None),
|
||||||
_ => return None,
|
_ => return None,
|
||||||
};
|
};
|
||||||
// Only extrudes point along the normal.
|
|
||||||
Some(match d.kind {
|
Some(match d.kind {
|
||||||
Kind::Extrude { .. } => at,
|
Kind::Extrude { .. } | Kind::Fillet { .. } => (at, normal),
|
||||||
_ => (at.0, None),
|
// Shell thickness grows into the body.
|
||||||
|
Kind::Shell { .. } => (at, normal.map(|n| -n)),
|
||||||
|
Kind::Move { .. } => (at, Some(Vec3::Z)),
|
||||||
|
_ => (at, None),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Draw the arrow and the value box (called by the viewport after the model is drawn).
|
/// The outward direction between the two faces at an edge (where a fillet's radius arrow
|
||||||
|
/// points).
|
||||||
|
fn edge_bisector(m: &Mesh, index: usize, p: Vec3) -> Option<Vec3> {
|
||||||
|
let faces = m.edge_faces.get(index)?;
|
||||||
|
let mut sum = Vec3::ZERO;
|
||||||
|
for f in faces {
|
||||||
|
let n = m
|
||||||
|
.triangles
|
||||||
|
.iter()
|
||||||
|
.zip(&m.tri_face)
|
||||||
|
.filter(|(_, tf)| *tf == f)
|
||||||
|
.filter_map(|(t, _)| m.tri(t))
|
||||||
|
.min_by(|a, b| ((a[0] + a[1] + a[2]) / 3.0).dist(p).total_cmp(&((b[0] + b[1] + b[2]) / 3.0).dist(p)))
|
||||||
|
.and_then(|[a, b, c]| (b - a).cross(c - a).normalized())?;
|
||||||
|
sum += n;
|
||||||
|
}
|
||||||
|
sum.normalized()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw the manipulator and the value box (called by the viewport after the model is drawn).
|
||||||
pub fn show(app: &mut SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj) {
|
pub fn show(app: &mut SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj) {
|
||||||
let Some(mut d) = app.dialog.take() else { return };
|
let Some(mut d) = app.dialog.take() else { return };
|
||||||
if let Some((base, dir)) = anchor(app, &d)
|
if let Some((base, dir)) = anchor(app, &d)
|
||||||
@@ -47,6 +71,38 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj
|
|||||||
app.dialog = Some(d);
|
app.dialog = Some(d);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A round step for dragged values at this zoom (1, 2 or 5 times a power of ten).
|
||||||
|
pub fn snap_step(half_height: f64) -> f64 {
|
||||||
|
let raw = (half_height / 40.0).max(1e-4);
|
||||||
|
let p = 10f64.powf(raw.log10().floor());
|
||||||
|
if raw / p < 2.0 {
|
||||||
|
p
|
||||||
|
} else if raw / p < 5.0 {
|
||||||
|
2.0 * p
|
||||||
|
} else {
|
||||||
|
5.0 * p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parameter along the line `base + dir * t` closest to the view ray through `pos`.
|
||||||
|
fn along(proj: &Proj, pos: Pos2, base: Vec3, dir: Vec3) -> Option<f64> {
|
||||||
|
let (o, r) = proj.ray(pos);
|
||||||
|
let w0 = base - o;
|
||||||
|
let (b, d, e) = (dir.dot(r), dir.dot(w0), r.dot(w0));
|
||||||
|
let den = 1.0 - b * b;
|
||||||
|
// Looking straight along the line: nothing to drag.
|
||||||
|
if den.abs() < 1e-6 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((b * e - d) / den)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_len(v: f64) -> String {
|
||||||
|
let s = format!("{v:.3}");
|
||||||
|
let s = s.trim_end_matches('0').trim_end_matches('.');
|
||||||
|
format!("{} mm", if s == "-0" { "0" } else { s })
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, d: &mut Dialog, base: Vec3, bs: Pos2, dir: Option<Vec3>) {
|
fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, d: &mut Dialog, base: Vec3, bs: Pos2, dir: Option<Vec3>) {
|
||||||
let t = Tokens::get();
|
let t = Tokens::get();
|
||||||
@@ -55,20 +111,45 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj,
|
|||||||
_ => 1.0,
|
_ => 1.0,
|
||||||
};
|
};
|
||||||
let symmetric = matches!(d.kind, Kind::Extrude { direction: 2, .. });
|
let symmetric = matches!(d.kind, Kind::Extrude { direction: 2, .. });
|
||||||
|
// Extrudes and moves go either way; radii and thicknesses stay positive.
|
||||||
|
let signed = matches!(d.kind, Kind::Extrude { .. } | Kind::Move { .. });
|
||||||
let focus = std::mem::take(&mut d.focus);
|
let focus = std::mem::take(&mut d.focus);
|
||||||
|
let half_height = app.cam.half_height();
|
||||||
let Some((label, kind, value)) = d.primary() else { return };
|
let Some((label, kind, value)) = d.primary() else { return };
|
||||||
let len = app.session.doc.eval(value, kind).ok().filter(|v| v.is_finite());
|
let len = app.session.doc.eval(value, kind).ok().filter(|v| v.is_finite());
|
||||||
let mut box_at = bs + vec2(16.0, -30.0);
|
let mut box_at = bs + vec2(16.0, -30.0);
|
||||||
if let (Some(n), Some(l)) = (dir, len.filter(|_| kind == ValueKind::Length)) {
|
if let (Some(n), Some(l)) = (dir, len.filter(|_| kind == ValueKind::Length)) {
|
||||||
let n = n * sign;
|
let n = n * sign;
|
||||||
let tip = base + n * l;
|
// A short arrow still shows (and can be grabbed) when the value is near zero.
|
||||||
let back = if symmetric { base - n * l } else { base };
|
let min = 40.0 * 2.0 * half_height / f64::from(proj.rect.height().max(1.0));
|
||||||
if let (Some(ts), Some(b0)) = (proj.to_screen(tip), proj.to_screen(back)) {
|
let shown = if l.abs() < min { min * if l < 0.0 { -1.0 } else { 1.0 } } else { l };
|
||||||
arrow(painter, b0, ts, t.manipulator);
|
let tip = base + n * shown;
|
||||||
if symmetric && let Some(bt) = proj.to_screen(base - n * l) {
|
if let Some(ts) = proj.to_screen(tip) {
|
||||||
arrow(painter, bs, bt, t.manipulator);
|
let handle = egui::Rect::from_center_size(ts, vec2(20.0, 20.0));
|
||||||
|
let r = ui.interact(handle, egui::Id::new("sc_manipulator"), egui::Sense::drag());
|
||||||
|
let hot = r.hovered() || r.dragged();
|
||||||
|
let col = if hot { t.accent } else { t.manipulator };
|
||||||
|
arrow(painter, bs, ts, col);
|
||||||
|
if symmetric && let Some(bt) = proj.to_screen(base - n * shown) {
|
||||||
|
arrow(painter, bs, bt, col);
|
||||||
|
}
|
||||||
|
painter.circle(bs, 3.5, col, Stroke::new(1.0, Color32::WHITE));
|
||||||
|
if hot {
|
||||||
|
painter.circle_stroke(ts, 8.0, Stroke::new(1.5, col));
|
||||||
|
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
|
||||||
|
}
|
||||||
|
if r.dragged()
|
||||||
|
&& let Some(p) = r.interact_pointer_pos()
|
||||||
|
&& let Some(v) = along(proj, p, base, n)
|
||||||
|
{
|
||||||
|
let free = ui.input(|i| i.modifiers.alt || i.modifiers.ctrl || i.modifiers.command);
|
||||||
|
let step = snap_step(half_height);
|
||||||
|
let mut v = if free { v } else { (v / step).round() * step };
|
||||||
|
if !signed {
|
||||||
|
v = v.max(if free { 1e-3 } else { step });
|
||||||
|
}
|
||||||
|
*value = format_len(v);
|
||||||
}
|
}
|
||||||
painter.circle(bs, 3.5, t.manipulator, Stroke::new(1.0, Color32::WHITE));
|
|
||||||
box_at = ts + vec2(14.0, -12.0);
|
box_at = ts + vec2(14.0, -12.0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,3 +183,18 @@ fn arrow(painter: &egui::Painter, a: Pos2, b: Pos2, col: Color32) {
|
|||||||
let head = 11.0f32.min(l * 0.6);
|
let head = 11.0f32.min(l * 0.6);
|
||||||
painter.add(egui::Shape::convex_polygon(vec![b, b - u * head + n * head * 0.45, b - u * head - n * head * 0.45], col, Stroke::NONE));
|
painter.add(egui::Shape::convex_polygon(vec![b, b - u * head + n * head * 0.45, b - u * head - n * head * 0.45], col, Stroke::NONE));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snapping_steps_and_formatting() {
|
||||||
|
assert_eq!(snap_step(40.0), 1.0);
|
||||||
|
assert_eq!(snap_step(100.0), 2.0);
|
||||||
|
assert_eq!(snap_step(4.0), 0.1);
|
||||||
|
assert_eq!(format_len(12.0), "12 mm");
|
||||||
|
assert_eq!(format_len(-2.5), "-2.5 mm");
|
||||||
|
assert_eq!(format_len(-0.0001), "0 mm");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,9 @@ pub enum Kind {
|
|||||||
distance: String,
|
distance: String,
|
||||||
direction: usize,
|
direction: usize,
|
||||||
operation: usize,
|
operation: usize,
|
||||||
|
/// The operation follows the geometry (into a body: cut, out of one: join, else new)
|
||||||
|
/// until the user picks one.
|
||||||
|
auto_op: bool,
|
||||||
},
|
},
|
||||||
Revolve {
|
Revolve {
|
||||||
angle: String,
|
angle: String,
|
||||||
@@ -133,7 +136,7 @@ impl Dialog {
|
|||||||
let mut d = match id {
|
let mut d = match id {
|
||||||
"SketchCreate" => Dialog::new(Kind::Sketch, vec![SelInput::new("Plane", PLANES | PLANAR_FACES, false)]),
|
"SketchCreate" => Dialog::new(Kind::Sketch, vec![SelInput::new("Plane", PLANES | PLANAR_FACES, false)]),
|
||||||
"Extrude" => Dialog::new(
|
"Extrude" => Dialog::new(
|
||||||
Kind::Extrude { distance: "10 mm".into(), direction: 0, operation: usize::from(has_bodies) },
|
Kind::Extrude { distance: "10 mm".into(), direction: 0, operation: usize::from(has_bodies), auto_op: true },
|
||||||
vec![SelInput::new("Profiles", PROFILES | PLANAR_FACES, true)],
|
vec![SelInput::new("Profiles", PROFILES | PLANAR_FACES, true)],
|
||||||
),
|
),
|
||||||
"Revolve" => Dialog::new(
|
"Revolve" => Dialog::new(
|
||||||
@@ -487,10 +490,28 @@ const DIALOG_MAX_W: f32 = 380.0;
|
|||||||
/// Width of value fields and choice boxes in a dialog.
|
/// Width of value fields and choice boxes in a dialog.
|
||||||
const FIELD_W: f32 = 140.0;
|
const FIELD_W: f32 = 140.0;
|
||||||
|
|
||||||
|
/// 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
|
/// Show the active dialog (if any), docked flush to the right edge of the viewport just below
|
||||||
/// the view cube, sized to its content.
|
/// the view cube, sized to its content.
|
||||||
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||||
let Some(mut d) = app.dialog.take() else { return };
|
let Some(mut d) = app.dialog.take() else { return };
|
||||||
|
auto_operation(app, &mut d);
|
||||||
let t = Tokens::get();
|
let t = Tokens::get();
|
||||||
let vp = app.viewport.rect.unwrap_or_else(|| ctx.content_rect());
|
let vp = app.viewport.rect.unwrap_or_else(|| ctx.content_rect());
|
||||||
let anchor = egui::pos2(vp.right(), vp.top() + crate::viewport::VIEW_CUBE_CLEARANCE);
|
let anchor = egui::pos2(vp.right(), vp.top() + crate::viewport::VIEW_CUBE_CLEARANCE);
|
||||||
@@ -526,7 +547,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
|||||||
input_rows(&mut d, ui);
|
input_rows(&mut d, ui);
|
||||||
match &mut d.kind {
|
match &mut d.kind {
|
||||||
Kind::Sketch => {}
|
Kind::Sketch => {}
|
||||||
Kind::Extrude { distance, direction, operation } => {
|
Kind::Extrude { distance, direction, operation, auto_op } => {
|
||||||
ui.label("Direction");
|
ui.label("Direction");
|
||||||
combo(ui, "ex_dir", &DIR_LABELS, direction);
|
combo(ui, "ex_dir", &DIR_LABELS, direction);
|
||||||
ui.end_row();
|
ui.end_row();
|
||||||
@@ -534,7 +555,12 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
|||||||
enter |= field(ui, distance);
|
enter |= field(ui, distance);
|
||||||
ui.end_row();
|
ui.end_row();
|
||||||
ui.label("Operation");
|
ui.label("Operation");
|
||||||
|
let before = *operation;
|
||||||
combo(ui, "ex_op", &OP_LABELS, operation);
|
combo(ui, "ex_op", &OP_LABELS, operation);
|
||||||
|
// A choice made by hand sticks.
|
||||||
|
if *operation != before {
|
||||||
|
*auto_op = false;
|
||||||
|
}
|
||||||
ui.end_row();
|
ui.end_row();
|
||||||
}
|
}
|
||||||
Kind::Revolve { angle, operation } => {
|
Kind::Revolve { angle, operation } => {
|
||||||
@@ -838,7 +864,7 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
|||||||
sels(d, i).iter().filter_map(|x| if let Sel::Face { point, .. } = x { Some(pt(*point)) } else { None }).collect()
|
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 {
|
let (cmd, params): (&str, Value) = match &d.kind {
|
||||||
Kind::Extrude { distance, direction, operation } => {
|
Kind::Extrude { distance, direction, operation, .. } => {
|
||||||
need(0, "profiles or a planar face")?;
|
need(0, "profiles or a planar face")?;
|
||||||
let common = json!({"distance": distance, "direction": DIRS.get(*direction).copied().unwrap_or("positive"), "operation": OPS.get(*operation).copied().unwrap_or("new")});
|
let common = json!({"distance": distance, "direction": DIRS.get(*direction).copied().unwrap_or("positive"), "operation": OPS.get(*operation).copied().unwrap_or("new")});
|
||||||
let with = |extra: Value| -> Value {
|
let with = |extra: Value| -> Value {
|
||||||
@@ -1092,6 +1118,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
|||||||
Direction::Symmetric => 2,
|
Direction::Symmetric => 2,
|
||||||
},
|
},
|
||||||
operation: op_index(operation),
|
operation: op_index(operation),
|
||||||
|
auto_op: false,
|
||||||
};
|
};
|
||||||
let items = st.sketch(*sketch).map(|ss| profile_indices(ss, profiles)).unwrap_or_default();
|
let items = st.sketch(*sketch).map(|ss| profile_indices(ss, profiles)).unwrap_or_default();
|
||||||
if let Some(inp) = d.inputs.first_mut() {
|
if let Some(inp) = d.inputs.first_mut() {
|
||||||
|
|||||||
Reference in new issue
Block a user