Files
OneCAD/crates/ui-egui/src/canvas.rs
T
William Dunn facff1050e Put Move/Copy Sketch Entities on the toolbar, behind a window with distances, an angle and a copy flag (#735)
Sketch Move gets a toolbar placement in Sketch > Modify and a Move/Copy tool window with X and Y distances, an angle, an optional centre point and a Create Copy flag, replacing the context-menu route that ran the command with no parameters.
2026-10-13 13:48:13 -04:00

1019 lines
46 KiB
Rust

//! 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, and an arrow
//! (extrude, fillet, shell, move) that can be dragged: values snap to round steps (hold Alt or
//! 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 solidcraft_engine::Sel;
use solidcraft_engine::doc::expr::Kind as ValueKind;
use solidcraft_engine::geom::{Mesh, Vec2, Vec3};
use crate::SolveApp;
use crate::dialogs::{Dialog, Kind};
use crate::theme::Tokens;
use crate::viewport::{Proj, projected_polygon};
/// Where the dialog's geometry is: a base point and, for values that can be dragged, the
/// direction a growing value points.
pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option<Vec3>)> {
let st = app.session.world_state();
let first = d.inputs.first()?.items.first()?;
let (at, normal) = match first {
Sel::Profile { sketch, index } => {
let ss = st.sketch(*sketch)?;
let plane = profile_plane(app, *sketch)?;
let p = ss.profiles.get(*index)?;
let centre = if matches!(d.kind, Kind::Extrude { .. }) {
let regions: Vec<_> = d
.inputs
.first()?
.items
.iter()
.filter_map(|item| {
if let Sel::Profile { sketch: id, index } = item { (*id == *sketch).then(|| ss.profiles.get(*index)).flatten() } else { None }
})
.map(|p| p.region.clone())
.collect();
let regions = solidcraft_engine::sketch::merge_regions(&regions);
let points: Vec<_> = regions.iter().flat_map(|r| r.outer.polyline(1e-2)).collect();
points.iter().fold(solidcraft_engine::geom::Vec2::default(), |sum, p| sum + *p) * (1.0 / points.len().max(1) as f64)
} else {
p.region.interior_point()
};
(plane.to_world(centre), Some(plane.normal()))
}
Sel::Face { body, index, point } => (*point, crate::dialogs::planar_face(&app.session, body, *index).map(|(_, n)| n)),
Sel::Edge { body, index, point } => (*point, st.body(body).and_then(|b| edge_bisector(&b.mesh(), *index, *point))),
Sel::Vertex { point, .. } => (*point, None),
Sel::SketchCurve { id } => {
let ss = st.sketches.iter().rev().find(|ss| ss.sketch.curve_index(id).is_some())?;
let pts = ss.sketch.polyline(ss.sketch.curve_index(id)?);
let (mid, tangent) = poly_mid(&pts)?;
// An offset's drag arrow: the in-plane direction the distance grows along. The
// command offsets a picked curve to its left, and the window's Flip row reverses it.
let dir = match &d.kind {
Kind::SketchModify(crate::sketch_modify_ui::Mod::Offset { flip, .. }) => {
let n = if *flip { -tangent.perp() } else { tangent.perp() };
Some(ss.plane.to_world(mid + n) - ss.plane.to_world(mid))
}
_ => None,
};
(ss.plane.to_world(mid), dir)
}
Sel::Body { name } => (st.body(name)?.mesh().bounds().center(), None),
Sel::Plane { name } => {
let pl = solidcraft_engine::geom::Plane::named(name).or_else(|| {
solidcraft_engine::view::construction_planes(&app.session).into_iter().find(|(_, n, _)| n == name).map(|(_, _, pl)| pl)
})?;
(pl.origin, Some(pl.normal()))
}
_ => return None,
};
Some(match &d.kind {
Kind::Extrude { start, .. } => {
let n = normal?;
let plane = solidcraft_engine::geom::Plane::from_normal(at, n)?;
let from = reference_height(app, d, "From Object", &plane, at).unwrap_or(0.0);
let offset = if start.trim().is_empty() { 0.0 } else { app.session.doc.eval(start, ValueKind::Length).ok()? };
(at + n * (from + offset), Some(n))
}
Kind::Section { .. } | Kind::Hole { .. } | Kind::OffsetPlane { .. } | Kind::OffsetFaces { .. } => (at, normal),
// Fillet radii, chamfer distances and shell thickness grow into the body.
Kind::Fillet { .. } | Kind::Shell { .. } => (at, normal.map(|n| -n)),
Kind::Sheet(_) => (at, crate::dialogs_sheet::arrow(app, d).or(normal)),
Kind::Plastic(_) => (at, normal),
Kind::PatternRect { .. } => (at, d.inputs.get(1).and_then(|i| i.items.first()).and_then(|x| crate::dialogs::axis_of(app, x)).map(|a| a.1)),
// A sketch move's X Distance grows along the sketch's own x axis, so its drag arrow
// lies in the sketch plane. Scale has no arrow: a factor is not a length, and the
// square handle it wants is its own change.
Kind::SketchModify(crate::sketch_modify_ui::Mod::Move { .. }) => {
(at, app.session.active_sketch.and_then(|id| st.sketch(id)).and_then(|ss| ss.plane.x.normalized()))
}
// Offset's arrow is already computed above, per picked curve; keep it rather than the
// default below.
Kind::SketchModify(_) => (at, normal),
_ => (at, None),
})
}
/// The point halfway along a polyline, with the unit tangent there. Indexing the middle sample
/// instead would land on a two-point line's far end, putting the value box and its arrow on a
/// corner. None for a polyline with no length.
fn poly_mid(poly: &[Vec2]) -> Option<(Vec2, Vec2)> {
let total: f64 = poly.windows(2).map(|w| w[0].dist(w[1])).sum();
if !total.is_finite() || total <= 0.0 {
return None;
}
let half = total / 2.0;
let mut walked = 0.0;
let mut last = None;
for w in poly.windows(2) {
let (a, b) = (w[0], w[1]);
let Some(dir) = (b - a).normalized() else { continue };
let len = a.dist(b);
if walked + len >= half {
return Some((a + dir * (half - walked), dir));
}
walked += len;
last = Some((b, dir));
}
last
}
fn profile_plane(app: &SolveApp, sketch: u64) -> Option<solidcraft_engine::geom::Plane> {
let component = app.session.doc.feature(sketch)?.component;
let state = app.session.model.state();
let plane = state.sketch(sketch)?.plane;
let frame = crate::dialogs::feature_frame(&app.session, component);
solidcraft_engine::geom::Plane::new(
solidcraft_engine::doc::apply_point(&frame, plane.origin),
solidcraft_engine::doc::apply_vector(&frame, plane.x),
solidcraft_engine::doc::apply_vector(&frame, plane.y),
)
}
fn reference_height(app: &SolveApp, d: &Dialog, label: &str, plane: &solidcraft_engine::geom::Plane, centre: Vec3) -> Option<f64> {
let point = crate::dialogs::object_point(&d.inputs, label)?;
let p = point.as_array()?;
let p = Vec3::new(p.first()?.as_f64()?, p.get(1)?.as_f64()?, p.get(2)?.as_f64()?);
Some(solidcraft_engine::doc::object_height(&app.session.world_state(), plane, centre, p))
}
/// The outward direction between the two faces at an edge (a fillet's radius arrow points the
/// other way, into the body).
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) {
let Some(mut d) = app.dialog.take() else { return };
if matches!(d.kind, Kind::Assembly(_)) {
crate::dialogs_assembly::overlay(app, painter, proj, &mut d);
app.dialog = Some(d);
return;
}
if crate::dialogs_sheet::overlay(app, painter, proj.rect, &d) {
app.dialog = Some(d);
return;
}
if let Kind::Measure { result: Some(r), .. } = &d.kind {
measure_line(app, painter, proj, r);
app.dialog = Some(d);
return;
}
// Move: the triad at the pivot; dragging it sets the distances and angles. Its value box
// sits beside it and reads the move's main value, so a drag shows its number and typing
// one sets it. The triad is the manipulator here, so the box comes without the drag arrow
// every other dialog's value gets (`dir` is None below).
if matches!(d.kind, Kind::Move { .. }) {
if let Some(t) = crate::dialogs_move::triad(app, &d)
&& let Some(g) = crate::gizmo::show(ui, painter, proj, egui::Id::new("sc_move_triad"), &t, snap_step(app.cam.half_height()))
{
crate::dialogs_move::drag(app, &mut d, g);
}
// Read the triad again after the drag, so the box follows it in the same frame. Move
// types with no triad (Rotate, Point to Point, Point to Position) put the box on the
// picked bodies instead.
let triad = crate::dialogs_move::triad(app, &d);
let at = triad.map(|t| t.center).or_else(|| anchor(app, &d).map(|(base, _)| base));
let focus = std::mem::take(&mut d.focus);
if let Some(bs) = at.and_then(|base| proj.to_screen(base))
&& let Some((label, _, value)) = d.primary()
{
// Clear of the triad: its arrows reach LEN_PX and its rings RING of that, so a box
// beside the centre would cover the handles the user is reaching for.
let off = if triad.is_some() { vec2(crate::gizmo::LEN_PX as f32 + 16.0, -12.0) } else { vec2(16.0, -30.0) };
value_box(ui, label, value, bs + off, focus);
}
app.dialog = Some(d);
return;
}
if let Some((base, dir)) = anchor(app, &d)
&& let Some(bs) = proj.to_screen(base)
{
draw(app, ui, painter, proj, &mut d, base, bs, dir);
}
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 })
}
/// The offset plane preview: a square of half side `h` around `c`, square to `n`.
fn offset_plane_quad(proj: &Proj, c: Vec3, n: Vec3, h: f64) -> Option<Vec<Pos2>> {
let u = n.cross(if n.z.abs() < 0.9 { Vec3::Z } else { Vec3::X }).normalized()?;
let v = n.cross(u);
[(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)].iter().map(|(a, b)| proj.to_screen(c + u * (a * h) + v * (b * h))).collect()
}
#[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>) {
let t = Tokens::get();
let to_span = dir.and_then(|n| {
let plane = solidcraft_engine::geom::Plane::from_normal(base, n)?;
reference_height(app, d, "To Object", &plane, base)
});
let all_span = if matches!(d.kind, Kind::Extrude { all: true, .. }) {
dir.and_then(|n| {
let state = app.session.world_state();
let plane = match d.inputs.first()?.items.first()? {
Sel::Profile { sketch, .. } => profile_plane(app, *sketch)?,
_ => solidcraft_engine::geom::Plane::from_normal(base, n)?,
};
let targets: Vec<String> = d
.extra
.get("targets")
.and_then(serde_json::Value::as_array)
.map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_owned)).collect())
.unwrap_or_default();
solidcraft_engine::doc::through_all_distance(&state, &plane, &targets).ok()
})
} else {
None
};
let sign = match (to_span, &d.kind) {
(Some(span), _) => {
if span < 0.0 {
-1.0
} else {
1.0
}
}
(_, Kind::Extrude { direction: 1, .. }) => -1.0,
_ => 1.0,
};
let symmetric = to_span.is_none() && 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 { .. }
| Kind::Section { .. }
| Kind::OffsetPlane { .. }
| Kind::OffsetFaces { .. }
| Kind::SketchModify(crate::sketch_modify_ui::Mod::Move { .. })
);
let focus = std::mem::take(&mut d.focus);
let half_height = app.cam.half_height();
let axis = revolve_axis(app, d);
if matches!(d.kind, Kind::Revolve { .. }) {
if let Some(axis) = axis {
draw_revolve(app, ui, painter, proj, d, base, bs, axis, focus);
}
return;
}
// Two sides: the second distance gets its own arrow the other way.
if let (Kind::Extrude { direction: crate::dialogs::TWO_SIDES, distance2, .. }, Some(n)) = (&mut d.kind, dir)
&& let Some(l2) = app.session.doc.eval(distance2, ValueKind::Length).ok().filter(|v| v.is_finite())
{
drag_arrow(ui, painter, proj, base, bs, -n, l2, true, egui::Id::new("sc_manipulator2"), half_height, distance2);
}
// An offset plane shows where it will be.
if let (Kind::OffsetPlane { offset }, Some(n)) = (&d.kind, dir)
&& let Ok(off) = app.session.doc.eval(offset, ValueKind::Length)
&& let Some(q) = offset_plane_quad(proj, base + n * off, n, half_height * 0.25)
{
painter.extend(projected_polygon(&q, t.construction_plane, Stroke::new(1.2, t.origin_plane_edge)));
}
// Holes: the depth arrow points into the material (empty depth: through all); the
// diameter is typed in the box.
let mut dir = dir;
// Extrude taper: a handle beside the arrow tip; dragging it sideways tilts the walls.
let cam_right = app.cam.basis().0;
if let (Kind::Extrude { direction: 0 | 1, distance, taper, taper_present, to_offset, .. }, Some(n)) = (&mut d.kind, dir) {
let n = n * sign;
let span = to_span.map(|span| span.abs() + app.session.doc.eval(to_offset, ValueKind::Length).unwrap_or(0.0)).or(all_span);
if let Ok(l) = span.map(Ok).unwrap_or_else(|| app.session.doc.eval(distance, ValueKind::Length))
&& l.is_finite()
&& l.abs() > 1e-9
&& let Some(e) = (cam_right - n * cam_right.dot(n)).normalized()
{
let px = 2.0 * half_height / f64::from(proj.rect.height().max(1.0));
let ang = app.session.doc.eval(taper, ValueKind::Angle).ok().filter(|v| v.is_finite()).unwrap_or(0.0);
let before = taper.clone();
taper_handle(ui, painter, proj, base - e * (40.0 * px), n * l, -e, ang, taper);
if *taper != before {
*taper_present = true;
}
}
}
if let (Kind::Hole { depth, opts, .. }, Some(n)) = (&mut d.kind, dir) {
let l = if opts.all { 0.0 } else { app.session.doc.eval(depth, ValueKind::Length).ok().filter(|v| v.is_finite()).unwrap_or(0.0) };
let before = depth.clone();
drag_arrow(ui, painter, proj, base, bs, -n, l, false, egui::Id::new("sc_manipulator_depth"), half_height, depth);
// Dragging the depth turns "through all" into a distance.
if *depth != before {
opts.all = false;
}
dir = None;
}
// A fillet with a radius spline is shaped by its handles along the edge; the arrow would sit
// on one of them.
if radius_spline_handles(app, ui, painter, proj, d) {
dir = None;
}
let Some((label, kind, value)) = d.primary() else { return };
let len = all_span.or_else(|| {
if to_span.is_some() && value.trim().is_empty() { Some(0.0) } else { app.session.doc.eval(value, kind).ok().filter(|v| v.is_finite()) }
});
let before = value.clone();
let mut box_at = bs + vec2(16.0, -30.0);
if let (Some(n), Some(l)) = (dir, len.filter(|_| kind == ValueKind::Length)) {
let n = n * sign;
let baseline = to_span.map(f64::abs).unwrap_or(0.0);
let mut shown_value = format_len(l + baseline);
let original_shown = shown_value.clone();
let dragged = if to_span.is_some() { &mut shown_value } else { &mut *value };
if let Some(ts) = drag_arrow(ui, painter, proj, base, bs, n, l + baseline, signed, egui::Id::new("sc_manipulator"), half_height, dragged) {
if to_span.is_some()
&& shown_value != original_shown
&& let Ok(v) = app.session.doc.eval(&shown_value, ValueKind::Length)
{
*value = format_len(v - baseline);
}
if symmetric && let Some(bt) = proj.to_screen(base - n * l) {
arrow(painter, bs, bt, t.manipulator);
}
painter.circle(bs, 3.5, t.manipulator, Stroke::new(1.0, Color32::WHITE));
box_at = ts + vec2(14.0, -12.0);
}
}
value_box(ui, label, value, box_at, focus);
if *value != before
&& let Kind::Extrude { all, .. } = &mut d.kind
{
*all = false;
}
}
/// The command's main value in the viewport, next to the geometry: the keyboard goes here while
/// the window is open, so a number typed straight after a pick sets the value. `at` is its
/// top-left corner.
fn value_box(ui: &mut egui::Ui, label: &str, value: &mut String, at: Pos2, focus: bool) {
let t = Tokens::get();
let id = egui::Id::new("sc_canvas_value");
crate::frame::area(egui::Id::new("sc_canvas_area")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).inner_margin(egui::Margin::symmetric(6, 3)).show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(egui::RichText::new(label).color(t.text_dim).size(11.5));
let r = ui.add(egui::TextEdit::singleline(value).id(id).desired_width(72.0));
crate::params_dialog::complete(ui, &r, value);
if focus {
r.request_focus();
let n = value.chars().count();
let mut st = egui::text_edit::TextEditState::load(ui.ctx(), id).unwrap_or_default();
st.cursor.set_char_range(Some(egui::text_selection::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(n))));
st.store(ui.ctx(), id);
}
});
});
});
}
/// A draggable arrow from `base` along `n` showing `l` (at least a short stub); dragging writes
/// the snapped length into `value`. Returns the tip's screen position.
#[allow(clippy::too_many_arguments)]
fn drag_arrow(
ui: &mut egui::Ui,
painter: &egui::Painter,
proj: &Proj,
base: Vec3,
bs: Pos2,
n: Vec3,
l: f64,
signed: bool,
id: egui::Id,
half_height: f64,
value: &mut String,
) -> Option<Pos2> {
let t = Tokens::get();
let min = 40.0 * 2.0 * half_height / f64::from(proj.rect.height().max(1.0));
let shown = if l.abs() < min { min * if l < 0.0 { -1.0 } else { 1.0 } } else { l };
let ts = proj.to_screen(base + n * shown)?;
crate::scenario::publish_handle(if id == egui::Id::new("sc_manipulator") { "arrow" } else { "arrow2" }, ts);
let r = ui.interact(egui::Rect::from_center_size(ts, vec2(20.0, 20.0)), id, egui::Sense::drag());
let hot = r.hovered() || r.dragged();
let col = if hot { t.accent } else { t.manipulator };
arrow(painter, bs, ts, col);
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);
}
Some(ts)
}
/// A fillet's radius spline on its first edge (a spline fillet, or the spline a taper gave it):
/// the radius curve drawn into the body from the edge, and a handle per control radius at its
/// Greville position, that far from the edge. Dragging a handle sets its control radius (snapped
/// unless Alt/Ctrl); the first drag on a taper makes the fillet a spline fillet starting from the
/// taper's spline. Whether the handles are shown.
fn radius_spline_handles(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, d: &mut Dialog) -> bool {
use serde_json::{Value, json};
let t = Tokens::get();
let Kind::Fillet { chamfer: false, .. } = d.kind else { return false };
let st = app.session.world_state();
let Some((mesh, index)) = d.inputs.first().and_then(|i| {
i.items.iter().find_map(|x| match x {
Sel::Edge { body, index, .. } => Some((st.body(body)?.mesh(), *index)),
_ => None,
})
}) else {
return false;
};
let Some((a, b)) = mesh.edges.get(index).and_then(|e| Some((*e.first()?, *e.last()?))) else { return false };
let eval = |e: &str| app.session.doc.eval(e, ValueKind::Length).ok().filter(|v| v.is_finite());
let style = d.extra.get("type").and_then(Value::as_str).unwrap_or("constant").to_string();
// The spline, the end its positions start from and whether it is still the taper's.
let (spline, start, taper) = if style == "spline" {
let Kind::Fillet { radius, .. } = &d.kind else { return false };
let knots: Vec<f64> = d.extra.get("knots").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_f64).collect();
let exprs: Vec<String> =
d.extra.get("radii").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(str::to_owned).collect();
let Some(radii) = std::iter::once(radius.as_str()).chain(exprs.iter().map(String::as_str)).map(eval).collect::<Option<Vec<f64>>>() else {
return false;
};
let swap = d.extra.get("swap_ends").and_then(Value::as_bool).unwrap_or(false);
(solidcraft_engine::doc::FilletStyle::spline(&knots, radii), if swap { b } else { a }, false)
} else if style == "constant" && d.extra.get("fit").and_then(Value::as_str) == Some("taper") {
// The taper of this edge: its point lies on the edge's chord.
let on_edge = |p: Vec3| {
let ab = b - a;
let s = ((p - a).dot(ab) / ab.dot(ab).max(1e-300)).clamp(0.0, 1.0);
(a + ab * s).dist(p) <= 1e-6 * (1.0 + ab.len())
};
let Some((_, tp)) = app.preview.tapered.iter().find(|(p, _)| on_edge(*p)) else { return false };
(tp.spline.clone(), tp.start, true)
} else {
return false;
};
let other = if start.dist(a) <= start.dist(b) { b } else { a };
let at = |u: f64| start + (other - start) * u;
// Into the body, between the two faces (the edge is straight between planar faces).
let Some(n) = edge_bisector(&mesh, index, at(0.5)).map(|v| -v) else { return false };
let samples: Vec<Pos2> = (0..=48)
.filter_map(|k| {
let u = f64::from(k) / 48.0;
proj.to_screen(at(u) + n * spline.eval(u))
})
.collect();
painter.add(egui::Shape::line(samples, Stroke::new(1.5, t.manipulator)));
let g = spline.greville();
let tips: Vec<Option<Pos2>> = g.iter().zip(&spline.radii).map(|(u, r)| proj.to_screen(at(*u) + n * *r)).collect();
painter.add(egui::Shape::dashed_line(&tips.iter().flatten().copied().collect::<Vec<_>>(), Stroke::new(1.0, t.manipulator), 4.0, 3.0));
let half_height = app.cam.half_height();
let last = g.len().saturating_sub(1);
let mut changed = None;
for (k, (u, ts)) in g.iter().zip(&tips).enumerate() {
let Some(ts) = *ts else { continue };
crate::scenario::publish_handle(&format!("fillet_r{k}"), ts);
let r = ui.interact(egui::Rect::from_center_size(ts, vec2(16.0, 16.0)), egui::Id::new(("sc_fillet_r", k)), egui::Sense::drag());
let hot = r.hovered() || r.dragged();
let col = if hot { t.accent } else { t.manipulator };
if let Some(fs) = proj.to_screen(at(*u)) {
painter.line_segment([fs, ts], Stroke::new(1.0, col));
}
painter.circle(ts, if hot { 6.5 } else { 5.0 }, t.overlay, Stroke::new(2.0, col));
if hot {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
}
if r.dragged()
&& let Some(p) = r.interact_pointer_pos()
&& let Some(v) = along(proj, p, at(*u), n)
{
let free = ui.input(|i| i.modifiers.alt || i.modifiers.ctrl || i.modifiers.command);
let step = snap_step(half_height);
let v = if free { v } else { (v / step).round() * step };
// Zero only at an end, where the round closes to a point.
let floor = if k == 0 || k == last {
0.0
} else if free {
1e-3
} else {
step
};
changed = Some((k, v.max(floor)));
}
}
if let Some((k, v)) = changed {
let mut radii = spline.radii.clone();
if let Some(r) = radii.get_mut(k) {
*r = v;
}
if taper {
// The taper's spline becomes the fillet's own.
d.extra.insert("type".into(), json!("spline"));
d.extra.remove("fit");
d.extra.insert("knots".into(), json!(spline.knots));
d.extra.insert("swap_ends".into(), json!(start.dist(b) < start.dist(a)));
d.extra.insert("radii".into(), json!(radii.iter().skip(1).map(|r| format_len(*r)).collect::<Vec<_>>()));
if let Kind::Fillet { radius, .. } = &mut d.kind {
*radius = format_len(radii.first().copied().unwrap_or(0.0));
}
} else if k == 0 {
if let Kind::Fillet { radius, .. } = &mut d.kind {
*radius = format_len(v);
}
} else if let Some(Value::Array(rs)) = d.extra.get_mut("radii")
&& let Some(slot) = rs.get_mut(k - 1)
{
*slot = json!(format_len(v));
}
}
true
}
/// The measured distance: a line between the closest points with its length.
fn measure_line(app: &SolveApp, painter: &egui::Painter, proj: &Proj, r: &serde_json::Value) {
let t = Tokens::get();
let p = |k: &str| -> Option<Vec3> {
let a = r.get(k)?.as_array()?;
Some(Vec3::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2)?.as_f64()?))
};
let (Some(a), Some(b), Some(d)) = (p("from"), p("to"), r.get("distance_mm").and_then(|v| v.as_f64())) else { return };
let (Some(sa), Some(sb)) = (proj.to_screen(a), proj.to_screen(b)) else { return };
painter.line_segment([sa, sb], Stroke::new(2.0, t.manipulator));
for s in [sa, sb] {
painter.circle(s, 3.5, t.manipulator, Stroke::new(1.0, Color32::WHITE));
}
let mid = sa + (sb - sa) * 0.5 + vec2(10.0, -14.0);
let galley = painter.layout_no_wrap(crate::prefs::show_mm(app, d, 1), egui::FontId::proportional(12.5), t.text);
let rect = egui::Rect::from_min_size(mid, galley.size() + vec2(10.0, 6.0));
painter.rect(rect, 3.0, t.overlay, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
painter.galley(rect.min + vec2(5.0, 3.0), galley, t.text);
}
/// The taper handle: a line from `foot` along the extrude (`span`) leaning by `angle` towards
/// `e`, with a ring at its end; dragging the ring sideways sets the angle (1° steps, Alt/Ctrl:
/// free).
#[allow(clippy::too_many_arguments)]
fn taper_handle(ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, foot: Vec3, span: Vec3, e: Vec3, angle: f64, value: &mut String) {
let t = Tokens::get();
let l = span.len();
let end = foot + span + e * (l * angle.tan().clamp(-10.0, 10.0));
let (Some(fs), Some(es)) = (proj.to_screen(foot), proj.to_screen(end)) else { return };
let r = ui.interact(egui::Rect::from_center_size(es, vec2(16.0, 16.0)), egui::Id::new("sc_taper"), egui::Sense::drag());
let hot = r.hovered() || r.dragged();
let col = if hot { t.accent } else { t.manipulator };
painter.add(egui::Shape::dashed_line(&[fs, es], Stroke::new(1.2, col), 5.0, 3.0));
painter.circle(es, if hot { 6.5 } else { 5.0 }, t.overlay, Stroke::new(2.0, col));
if hot {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
}
if r.dragged()
&& let Some(p) = r.interact_pointer_pos()
&& let Some(s) = along(proj, p, foot + span, e)
{
let mut deg = s.atan2(l).to_degrees();
if !ui.input(|i| i.modifiers.alt || i.modifiers.ctrl || i.modifiers.command) {
deg = deg.round();
}
let txt = format!("{deg:.1}");
*value = format!("{} deg", txt.trim_end_matches('0').trim_end_matches('.'));
}
}
/// The revolve axis (a point on it and its unit direction).
pub(crate) fn revolve_axis(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Vec3)> {
if !matches!(d.kind, Kind::Revolve { .. }) {
return None;
}
if let Some(selected) = d.inputs.get(1)?.items.first() {
if let Sel::Axis { name } = selected
&& matches!(name.as_str(), "X" | "Y" | "Z")
&& let Sel::Profile { sketch, .. } = d.inputs.first()?.items.first()?
{
let component = app.session.doc.feature(*sketch)?.component;
let frame = crate::dialogs::feature_frame(&app.session, component);
let dir = match name.as_str() {
"X" => Vec3::X,
"Y" => Vec3::Y,
_ => Vec3::Z,
};
return Some((solidcraft_engine::doc::apply_point(&frame, Vec3::ZERO), solidcraft_engine::doc::apply_vector(&frame, dir)));
}
if let Sel::Axis { name } = selected
&& matches!(name.as_str(), "x" | "y")
&& let Sel::Profile { sketch, .. } = d.inputs.first()?.items.first()?
{
let plane = profile_plane(app, *sketch)?;
return Some((plane.origin, if name == "x" { plane.x } else { plane.y }));
}
return crate::dialogs::axis_of(app, selected);
}
let Kind::Revolve { axis: Some(axis), .. } = &d.kind else { return None };
let vector = |key: &str| -> Option<Vec3> {
let p = axis.get(key)?.as_array()?;
Some(Vec3::new(p.first()?.as_f64()?, p.get(1)?.as_f64()?, p.get(2)?.as_f64()?))
};
Some((vector("origin")?, vector("dir")?.normalized()?))
}
/// Draw angular extents in the same clockwise sense as the modeled solid.
#[allow(clippy::too_many_arguments)]
fn draw_revolve(
app: &SolveApp,
ui: &mut egui::Ui,
painter: &egui::Painter,
proj: &Proj,
d: &mut Dialog,
base: Vec3,
bs: Pos2,
axis: (Vec3, Vec3),
focus: bool,
) {
let to_mode = d.inputs.iter().any(|input| input.label == "To Object");
let extent = revolve_display_extent(app, d, axis).unwrap_or(solidcraft_engine::doc::RevolveExtent {
start: 0.0,
sweep: f64::NAN,
rotation_axis: axis.1,
display_start: 0.0,
curved_to: false,
});
let mut box_at = bs + vec2(16.0, -30.0);
if !extent.curved_to && extent.sweep.is_finite() {
let (origin, direction) = axis;
let centre = origin + direction * (base - origin).dot(direction);
let radius = (base - centre).len();
if radius > 1e-9 {
let e1 = (base - centre) / radius;
let e2 = extent.rotation_axis.cross(e1);
let handed = match revolve_convention(d) {
solidcraft_engine::doc::RevolveConvention::LegacyAxisStart => -direction.dot(extent.rotation_axis),
solidcraft_engine::doc::RevolveConvention::SketchNormal => 1.0,
};
let at = |angle: f64| centre + (e1 * angle.cos() + e2 * angle.sin()) * radius;
let points: Vec<_> = (0..=48).filter_map(|i| proj.to_screen(at(extent.display_start + extent.sweep * i as f64 / 48.0))).collect();
painter.add(egui::Shape::line(points, Stroke::new(2.0, Tokens::get().manipulator)));
if let Some(end) = proj.to_screen(at(extent.display_start + extent.sweep)) {
box_at = end + vec2(14.0, -12.0);
}
if !to_mode {
let symmetric = !d.extra.contains_key("angle2") && d.extra.get("direction").and_then(serde_json::Value::as_str) == Some("symmetric");
let other_angle = d
.extra
.get("angle2")
.and_then(|value| value.as_str())
.and_then(|value| app.session.doc.eval(value, ValueKind::Angle).ok())
.unwrap_or(0.0);
let range = if extent.sweep < 0.0 { (-std::f64::consts::TAU, 0.0) } else { (0.0, std::f64::consts::TAU) };
if let Kind::Revolve { angle, .. } = &mut d.kind {
let boundary = extent.display_start + extent.sweep;
if let Some(end) = rotation_handle(
ui,
painter,
proj,
centre,
e1,
e2,
direction,
radius,
boundary,
if symmetric { 1.0 / (1.0 - handed * 0.5) } else { 1.0 },
if symmetric { 0.0 } else { -other_angle * (1.0 - handed) },
(range.0 - other_angle, range.1 - other_angle),
"revolve",
angle,
) {
box_at = end + vec2(14.0, -12.0);
}
if symmetric {
rotation_handle(
ui,
painter,
proj,
centre,
e1,
e2,
direction,
radius,
extent.display_start,
-2.0 * handed,
0.0,
range,
"revolve2",
angle,
);
}
}
if let Some(other) = d.extra.get_mut("angle2") {
let mut value = other.as_str().map(str::to_owned).unwrap_or_else(|| other.to_string());
let before = value.clone();
let first_angle = extent.sweep - other_angle;
rotation_handle(
ui,
painter,
proj,
centre,
e1,
e2,
direction,
radius,
extent.display_start,
-handed,
0.0,
(range.0 - first_angle, range.1 - first_angle),
"revolve2",
&mut value,
);
if value != before {
*other = serde_json::Value::String(value);
}
}
}
}
}
crate::frame::area(egui::Id::new("sc_canvas_area")).fixed_pos(box_at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).inner_margin(egui::Margin::symmetric(6, 3)).show(ui, |ui| {
if to_mode {
let text = if extent.curved_to {
"To surface".into()
} else if extent.sweep.is_finite() {
format!("Sweep {:.1}°", extent.sweep.to_degrees())
} else {
"Choose target".into()
};
ui.label(text).on_hover_text(crate::i18n::tr_label("Determined by To Object. Choose Angle to edit the angular extent."));
} else if let Kind::Revolve { angle, .. } = &mut d.kind {
ui.horizontal(|ui| {
ui.label(egui::RichText::new(crate::i18n::tr_label("Angle")).color(Tokens::get().text_dim).size(11.5));
let id = egui::Id::new("sc_canvas_value");
let response = ui.add(egui::TextEdit::singleline(angle).id(id).desired_width(72.0));
crate::params_dialog::complete(ui, &response, angle);
if focus {
response.request_focus();
let n = angle.chars().count();
let mut state = egui::text_edit::TextEditState::load(ui.ctx(), id).unwrap_or_default();
state
.cursor
.set_char_range(Some(egui::text_selection::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(n))));
state.store(ui.ctx(), id);
}
});
}
});
});
}
fn revolve_convention(d: &Dialog) -> solidcraft_engine::doc::RevolveConvention {
d.extra
.get("convention")
.and_then(|value| serde_json::from_value(value.clone()).ok())
.unwrap_or(solidcraft_engine::doc::RevolveConvention::SketchNormal)
}
fn revolve_display_extent(app: &SolveApp, d: &Dialog, axis: (Vec3, Vec3)) -> Option<solidcraft_engine::doc::RevolveExtent> {
let Sel::Profile { sketch, .. } = d.inputs.first()?.items.first()? else { return None };
let state = app.session.world_state();
let ss = state.sketch(*sketch)?;
let regions: Vec<_> = d
.inputs
.first()?
.items
.iter()
.filter_map(|item| match item {
Sel::Profile { sketch: id, index } if id == sketch => ss.profiles.get(*index).map(|profile| profile.region.clone()),
_ => None,
})
.collect();
let regions = solidcraft_engine::sketch::merge_regions(&regions);
let plane = profile_plane(app, *sketch)?;
let to_mode = d.inputs.iter().any(|input| input.label == "To Object");
let to = if to_mode {
let point = crate::dialogs::object_point(&d.inputs, "To Object")?;
Some(Vec3::new(point.get(0)?.as_f64()?, point.get(1)?.as_f64()?, point.get(2)?.as_f64()?))
} else {
None
};
let Kind::Revolve { angle, .. } = &d.kind else { return None };
let angles = if to_mode {
(0.0, None)
} else {
let angle = app.session.doc.eval(angle, ValueKind::Angle).ok()?;
let other = d.extra.get("angle2").map(|value| app.session.doc.eval(value.as_str().unwrap_or_default(), ValueKind::Angle)).transpose().ok()?;
(angle, other)
};
solidcraft_engine::doc::revolve_extent_with_convention(
&state,
&plane,
&regions,
axis,
angles,
d.extra.get("direction").and_then(serde_json::Value::as_str) == Some("symmetric"),
to,
revolve_convention(d),
)
.ok()
}
#[allow(clippy::too_many_arguments)]
fn rotation_handle(
ui: &mut egui::Ui,
painter: &egui::Painter,
proj: &Proj,
centre: Vec3,
e1: Vec3,
e2: Vec3,
axis: Vec3,
radius: f64,
boundary: f64,
scale: f64,
offset: f64,
range: (f64, f64),
name: &str,
value: &mut String,
) -> Option<Pos2> {
let end = proj.to_screen(centre + (e1 * boundary.cos() + e2 * boundary.sin()) * radius)?;
crate::scenario::publish_handle(name, end);
let response = ui.interact(egui::Rect::from_center_size(end, vec2(20.0, 20.0)), egui::Id::new(("sc_rotator", name)), egui::Sense::drag());
let hot = response.hovered() || response.dragged();
let color = if hot { Tokens::get().accent } else { Tokens::get().manipulator };
painter.circle(end, if hot { 7.0 } else { 5.5 }, color, Stroke::new(1.5, Color32::WHITE));
if hot {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
}
if response.dragged()
&& let Some(pointer) = response.interact_pointer_pos()
{
let (origin, direction) = proj.ray(pointer);
let den = direction.dot(axis);
let (a, b) = ((range.0 - offset) / scale, (range.1 - offset) / scale);
let bounds = (a.min(b), a.max(b));
// Near edge-on, a plane intersection magnifies subpixel pointer error.
let raw = if den.abs() > 0.05 {
let point = origin + direction * ((centre - origin).dot(axis) / den) - centre;
Some(point.dot(e2).atan2(point.dot(e1)))
} else {
projected_rotation_angle(proj, pointer, centre, e1, e2, radius, boundary, bounds)
};
if let Some(raw) = raw {
let boundary_angle = (-4..=4)
.map(|k| raw + k as f64 * std::f64::consts::TAU)
.filter(|angle| *angle >= bounds.0 - 1e-4 && *angle <= bounds.1 + 1e-4)
.min_by(|a, b| (a - boundary).abs().total_cmp(&(b - boundary).abs()))
.unwrap_or_else(|| raw.clamp(bounds.0, bounds.1));
let mut angle = (boundary_angle.clamp(bounds.0, bounds.1) * scale + offset).to_degrees();
if !ui.input(|input| input.modifiers.alt || input.modifiers.ctrl || input.modifiers.command) {
angle = (angle / 5.0).round() * 5.0;
}
if angle.abs() < 0.05 {
angle = 0.0;
}
let text = format!("{angle:.1}");
*value = format!("{} deg", text.trim_end_matches('0').trim_end_matches('.'));
}
}
Some(end)
}
/// An edge-on ring has two equally valid depths; retain the branch nearest the current handle.
#[allow(clippy::too_many_arguments)]
fn projected_rotation_angle(
proj: &Proj,
pointer: Pos2,
centre: Vec3,
e1: Vec3,
e2: Vec3,
radius: f64,
current: f64,
(lo, hi): (f64, f64),
) -> Option<f64> {
let distance = |angle: f64| {
proj.to_screen(centre + (e1 * angle.cos() + e2 * angle.sin()) * radius)
.map(|point| f64::from(point.distance_sq(pointer)))
.unwrap_or(f64::INFINITY)
};
let step = (hi - lo) / 720.0;
let mut best = None;
for i in 0..=720 {
let angle = lo + step * i as f64;
let score = distance(angle);
if !score.is_finite() {
continue;
}
if best.is_none_or(|(old_angle, old_score): (f64, f64)| {
score < old_score - 1e-4 || (score - old_score).abs() <= 1e-4 && (angle - current).abs() < (old_angle - current).abs()
}) {
best = Some((angle, score));
}
}
let (angle, _) = best?;
let (mut a, mut b) = ((angle - step).max(lo), (angle + step).min(hi));
for _ in 0..24 {
let (left, right) = (a + (b - a) / 3.0, b - (b - a) / 3.0);
if distance(left) <= distance(right) {
b = right;
} else {
a = left;
}
}
Some((a + b) / 2.0)
}
fn arrow(painter: &egui::Painter, a: Pos2, b: Pos2, col: Color32) {
let v = b - a;
let l = v.length();
if l < 1.0 {
return;
}
let u = v / l;
let n = vec2(-u.y, u.x);
painter.line_segment([a, b], Stroke::new(2.0, col));
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));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn offset_plane_preview_stays_within_its_square() {
let rect = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
let mut worst = f32::NEG_INFINITY;
for n in [Vec3::X, Vec3::Y, Vec3::Z] {
for dir in crate::viewport::orbit_directions() {
let cam = solidcraft_engine::render::Camera::default().looking_from(dir);
let proj = Proj { cam, vp: cam.view_proj(4.0 / 3.0, 100.0), rect };
let h = cam.half_height() * 0.25;
let (Some(q), Some(c), Some(side)) =
(offset_plane_quad(&proj, Vec3::ZERO, n, h), proj.to_screen(Vec3::ZERO), proj.to_screen(cam.basis().0 * h))
else {
continue;
};
// The square's half diagonal on screen (an orthographic view), plus stroke and feathering.
let limit = c.distance(side) * 2f32.sqrt() + 3.0;
for p in crate::viewport::drawn_points(projected_polygon(&q, Color32::GRAY, Stroke::new(1.2, Color32::BLACK))) {
worst = worst.max(p.distance(c) - limit);
}
}
}
assert!(worst <= 0.0, "a vertex {worst} px past the square");
}
#[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");
}
}