Sketch dimensions drawn as dimensions: extension lines, dimension lines and arcs with arrowheads (flipped outside when tight), the value centred on the line (fx: for expressions), R/Ø leaders, angle arcs, arc-length mark, driven ones in parentheses in a muted colour; click selects, Delete removes, drag moves the text (sketch.dimension_text, kept relative to the dimension), double-click edits the value in place with parameter completion
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
4adfd69d6a
commit
187ffd0a03
12 files changed
+946
-57
No files matched your search
@@ -8,7 +8,8 @@
|
||||
//! - `ui.inspect`: UI state, viewport rect, camera, tool and dialog
|
||||
//! - `ui.drag {x0, y0, x1, y1, button?, shift?, ctrl?, steps?, hold?}` (box selection, navigation;
|
||||
//! `hold` keeps the button down that many frames before moving);
|
||||
//! `ui.selection` (selection, dialog inputs, hover); `ui.editFeature {feature}` (edit dialog)
|
||||
//! `ui.selection` (selection, dialog inputs, hover, drawn sketch dimensions);
|
||||
//! `ui.sketchToScreen {points: [[x,y]…]}` (screen points of active-sketch coordinates); `ui.editFeature {feature}` (edit dialog)
|
||||
//! - `ui.set {...UiState fields}`; `ui.view {view: front|back|top|bottom|left|right|iso|home|fit, animate?: bool}` (snaps unless animate)
|
||||
//! - `ui.start {command}`: like clicking the toolbar button (starts tools/dialogs)
|
||||
//! - `ui.click {x, y, button?, shift?}`, `ui.move {x, y}`, `ui.scroll {x, y, delta}`: real
|
||||
@@ -201,7 +202,31 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
"selection": app.session.selection,
|
||||
"dialog": app.dialog.as_ref().map(|d| d.inputs.iter().map(|i| json!({"label": i.label, "items": i.items})).collect::<Vec<_>>()),
|
||||
"hover": app.viewport.hover.as_ref().map(|h| format!("{h:?}")),
|
||||
"dimensions": crate::dim_view::drawn().into_iter().map(|(id, p)| json!({"id": id, "x": p.x, "y": p.y})).collect::<Vec<_>>(),
|
||||
"dimension_selected": crate::dim_view::selected(),
|
||||
"dimension_editing": crate::dim_view::editing(),
|
||||
})),
|
||||
"ui.sketchToScreen" => {
|
||||
// Screen points of active-sketch coordinates (for driving sketch interaction).
|
||||
let st = app.session.model.state();
|
||||
let (Some(rect), Some(ss)) = (app.viewport.rect, app.session.active_sketch.and_then(|s| st.sketch(s))) else {
|
||||
return err("no active sketch in a drawn viewport");
|
||||
};
|
||||
let proj = crate::viewport::projection(app, rect);
|
||||
let pts: Vec<Value> = p
|
||||
.get("points")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.take(1000)
|
||||
.map(|v| {
|
||||
let c = |i: usize| v.get(i).and_then(Value::as_f64).unwrap_or(0.0);
|
||||
let q = solvecraft_engine::geom::Vec2::new(c(0), c(1));
|
||||
proj.to_screen(ss.plane.to_world(q)).map_or(Value::Null, |p| json!([p.x, p.y]))
|
||||
})
|
||||
.collect();
|
||||
ok(json!(pts))
|
||||
}
|
||||
"ui.scroll" => {
|
||||
let (Some(x), Some(y)) = (f("x"), f("y")) else { return err("missing x/y") };
|
||||
app.synthetic.push(egui::Event::PointerMoved(egui::pos2(x as f32, y as f32)));
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
//! Sketch dimensions drawn as dimensions: extension lines from the measured geometry, a
|
||||
//! dimension line (or arc) with arrowheads, and the value centred on it; radius and diameter
|
||||
//! leaders with R / Ø, angles as an arc between the two lines, arc lengths with the arc mark,
|
||||
//! driven (reference) dimensions in parentheses in a muted colour. The geometry comes from
|
||||
//! `solvecraft_sketch::dim_layout`.
|
||||
//!
|
||||
//! A click selects a dimension (Delete removes it), a drag moves its text (and so the offset of
|
||||
//! its dimension line), a double-click edits its value in place with parameter completion.
|
||||
//! Everything acts through commands: `sketch.dimension_text`, `sketch.delete`,
|
||||
//! `ChangeParameterCommand`.
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
||||
use egui::{FontId, Pos2, Rect, Shape, Stroke, vec2};
|
||||
use serde_json::json;
|
||||
use solvecraft_engine::geom::Vec2;
|
||||
use solvecraft_engine::sketch::{ConstraintKind, DimFrame, chain_centres, default_text, dim_frame, dim_layout};
|
||||
|
||||
use crate::SolveApp;
|
||||
use crate::theme::Tokens;
|
||||
use crate::viewport::Proj;
|
||||
|
||||
/// A dimension as drawn this frame (screen space), for hit testing.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Drawn {
|
||||
id: String,
|
||||
param: Option<String>,
|
||||
text: Rect,
|
||||
segs: Vec<[Pos2; 2]>,
|
||||
/// Text centre in sketch coordinates.
|
||||
at: Vec2,
|
||||
}
|
||||
|
||||
/// The inline value editor: parameter, expression, where it sits, first frame.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Edit {
|
||||
param: String,
|
||||
expr: String,
|
||||
at: Pos2,
|
||||
/// Frames left in which the box (re)takes the keyboard: the rest of the double-click
|
||||
/// that opened it must not close it.
|
||||
fresh: u8,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static DRAWN: RefCell<Vec<Drawn>> = const { RefCell::new(Vec::new()) };
|
||||
static SELECTED: RefCell<Option<String>> = const { RefCell::new(None) };
|
||||
/// A text being dragged: dimension id, undo depth at the start, grab offset (sketch).
|
||||
static DRAG: RefCell<Option<(String, usize, Vec2)>> = const { RefCell::new(None) };
|
||||
static EDIT: RefCell<Option<Edit>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Frames the inline editor holds on to the keyboard after opening.
|
||||
const FRESH: u8 = 3;
|
||||
|
||||
/// Pixels from a dimension line or the text box that still count as on it.
|
||||
const HIT_PX: f32 = 4.0;
|
||||
|
||||
fn seg_dist(p: Pos2, a: Pos2, b: Pos2) -> f32 {
|
||||
let ab = b - a;
|
||||
let t = if ab.length_sq() > 0.0 { ((p - a).dot(ab) / ab.length_sq()).clamp(0.0, 1.0) } else { 0.0 };
|
||||
p.distance(a + ab * t)
|
||||
}
|
||||
|
||||
/// The dimension under a screen position (its id).
|
||||
pub fn hit(pos: Pos2) -> Option<String> {
|
||||
DRAWN.with(|d| {
|
||||
let d = d.borrow();
|
||||
// Text boxes first (they sit on top of the lines), then the lines.
|
||||
d.iter()
|
||||
.find(|x| x.text.expand(2.0).contains(pos))
|
||||
.or_else(|| d.iter().find(|x| x.segs.iter().any(|s| seg_dist(pos, s[0], s[1]) <= HIT_PX)))
|
||||
.map(|x| x.id.clone())
|
||||
})
|
||||
}
|
||||
|
||||
/// The selected dimension (constraint id).
|
||||
pub fn selected() -> Option<String> {
|
||||
SELECTED.with(|s| s.borrow().clone())
|
||||
}
|
||||
|
||||
/// A click without a tool: select the dimension under it (true: the click was used). A click
|
||||
/// elsewhere drops the dimension selection.
|
||||
pub fn click(app: &mut SolveApp, pos: Pos2) -> bool {
|
||||
if app.session.active_sketch.is_none() {
|
||||
return false;
|
||||
}
|
||||
let h = hit(pos);
|
||||
SELECTED.with(|s| *s.borrow_mut() = h.clone());
|
||||
if let Some(id) = &h {
|
||||
let _ = app.run("select.clear", json!({}));
|
||||
app.set_status(format!("Dimension {id} selected: drag to move, double-click to edit, Delete removes it"), false);
|
||||
}
|
||||
h.is_some()
|
||||
}
|
||||
|
||||
/// Drop the dimension selection (true when there was one).
|
||||
pub fn deselect() -> bool {
|
||||
SELECTED.with(|s| s.borrow_mut().take()).is_some()
|
||||
}
|
||||
|
||||
/// Delete the selected dimension (true when one was selected).
|
||||
pub fn delete_selected(app: &mut SolveApp) -> bool {
|
||||
let Some(id) = SELECTED.with(|s| s.borrow_mut().take()) else { return false };
|
||||
let _ = app.run("sketch.delete", json!({"entities": [id]}));
|
||||
true
|
||||
}
|
||||
|
||||
/// A double-click on a dimension opens its value for editing (true: it was on one).
|
||||
pub fn double_click(app: &mut SolveApp, pos: Pos2) -> bool {
|
||||
let Some(d) = DRAWN.with(|d| {
|
||||
let id = hit(pos)?;
|
||||
d.borrow().iter().find(|x| x.id == id).cloned()
|
||||
}) else {
|
||||
return false;
|
||||
};
|
||||
SELECTED.with(|s| *s.borrow_mut() = Some(d.id.clone()));
|
||||
let Some(param) = d.param else {
|
||||
app.set_status("A driven dimension measures the sketch; it has no value to edit", false);
|
||||
return true;
|
||||
};
|
||||
let expr = app.session.doc.param(¶m).map(|p| p.expr.clone()).unwrap_or_default();
|
||||
EDIT.with(|e| *e.borrow_mut() = Some(Edit { param, expr, at: d.text.center(), fresh: FRESH }));
|
||||
true
|
||||
}
|
||||
|
||||
/// Start dragging the text of the dimension under `press` (true: a dimension took the drag).
|
||||
pub fn drag_start(app: &SolveApp, press: Pos2, press_sketch: Vec2) -> bool {
|
||||
let Some(d) = DRAWN.with(|d| {
|
||||
let id = hit(press)?;
|
||||
d.borrow().iter().find(|x| x.id == id).cloned()
|
||||
}) else {
|
||||
return false;
|
||||
};
|
||||
SELECTED.with(|s| *s.borrow_mut() = Some(d.id.clone()));
|
||||
DRAG.with(|g| *g.borrow_mut() = Some((d.id, app.session.undo.len(), d.at - press_sketch)));
|
||||
true
|
||||
}
|
||||
|
||||
/// Is a dimension text being dragged?
|
||||
pub fn dragging() -> bool {
|
||||
DRAG.with(|g| g.borrow().is_some())
|
||||
}
|
||||
|
||||
/// Move the dragged text so it follows the pointer (at `to`, sketch coordinates); one undo
|
||||
/// step for the whole drag.
|
||||
pub fn drag_to(app: &mut SolveApp, to: Vec2) {
|
||||
let Some((id, depth, off)) = DRAG.with(|g| g.borrow().clone()) else { return };
|
||||
let at = to + off;
|
||||
if app.session.execute("sketch.dimension_text", &json!({"dimension": id, "at": [at.x, at.y]})).is_ok() {
|
||||
app.session.undo.truncate(depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn drag_end() {
|
||||
DRAG.with(|g| *g.borrow_mut() = None);
|
||||
}
|
||||
|
||||
/// The text shown for a dimension: the value (R / Ø for radius and diameter, degrees for
|
||||
/// angles), "fx: value" when an expression drives it, in parentheses when driven.
|
||||
pub fn label(k: &ConstraintKind, expr: Option<&str>, driven: bool) -> String {
|
||||
let v = k.value().unwrap_or(0.0);
|
||||
let num = if k.is_angle() { format!("{:.1}°", v.to_degrees()) } else { format!("{v:.2}") };
|
||||
let num = match k {
|
||||
ConstraintKind::Radius { .. } => format!("R{num}"),
|
||||
ConstraintKind::Diameter { .. } | ConstraintKind::LinearDiameter { .. } => format!("Ø{num}"),
|
||||
_ => num,
|
||||
};
|
||||
let plain = expr.is_none_or(|e| {
|
||||
let e = e.trim();
|
||||
let e = e.strip_suffix("mm").or_else(|| e.strip_suffix("deg")).or_else(|| e.strip_suffix('°')).unwrap_or(e);
|
||||
e.trim().parse::<f64>().is_ok()
|
||||
});
|
||||
let text = if plain { num } else { format!("fx: {num}") };
|
||||
if driven { format!("({text})") } else { text }
|
||||
}
|
||||
|
||||
/// Draw the active sketch's dimensions and remember where they are for picking.
|
||||
pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &Proj) {
|
||||
let mut drawn = Vec::new();
|
||||
let st = app.session.model.state();
|
||||
let Some(ss) = app.session.active_sketch.and_then(|s| st.sketch(s)) else {
|
||||
DRAWN.with(|d| d.borrow_mut().clear());
|
||||
SELECTED.with(|s| *s.borrow_mut() = None);
|
||||
EDIT.with(|e| *e.borrow_mut() = None);
|
||||
return;
|
||||
};
|
||||
let sk = &ss.sketch;
|
||||
let tk = Tokens::get();
|
||||
let sel = selected();
|
||||
// A selected dimension that was deleted (undo, another command) is forgotten.
|
||||
if sel.as_ref().is_some_and(|id| !sk.constraints.iter().any(|c| &c.id == id)) {
|
||||
SELECTED.with(|s| *s.borrow_mut() = None);
|
||||
}
|
||||
let hover = ui.input(|i| i.pointer.hover_pos()).filter(|p| proj.rect.contains(*p)).and_then(hit);
|
||||
let to = |p: Vec2| proj.to_screen(ss.plane.to_world(p));
|
||||
let font = FontId::proportional(12.0);
|
||||
let centres = chain_centres(sk);
|
||||
for c in &sk.constraints {
|
||||
let Some(frame) = dim_frame(sk, &c.kind) else { continue };
|
||||
let anchor = match frame {
|
||||
DimFrame::Linear { p0, p1, .. } => (p0 + p1) * 0.5,
|
||||
DimFrame::Radial { center, .. } | DimFrame::ArcLength { center, .. } => center,
|
||||
DimFrame::Angular { vertex, .. } => vertex,
|
||||
};
|
||||
// Millimetres per pixel at the dimension.
|
||||
let (Some(a), Some(b)) = (to(anchor), to(anchor + Vec2::X)) else { continue };
|
||||
let px = 1.0 / f64::from(a.distance(b)).max(1e-9);
|
||||
if !px.is_finite() {
|
||||
continue;
|
||||
}
|
||||
let expr = c.param.as_ref().and_then(|p| app.session.doc.param(p)).map(|p| p.expr.as_str());
|
||||
let failing = ss.report.failing.contains(&c.id);
|
||||
let col = if failing {
|
||||
tk.error
|
||||
} else if sel.as_deref() == Some(c.id.as_str()) || hover.as_deref() == Some(c.id.as_str()) {
|
||||
tk.accent
|
||||
} else if c.driven {
|
||||
tk.dimension_driven
|
||||
} else {
|
||||
tk.dimension
|
||||
};
|
||||
let galley = painter.layout_no_wrap(label(&c.kind, expr, c.driven), font.clone(), col);
|
||||
let size = galley.size() + vec2(6.0, 2.0);
|
||||
let half = Vec2::new(f64::from(size.x) * 0.5 * px, f64::from(size.y) * 0.5 * px);
|
||||
// Unplaced linear dimensions sit outside the shape they measure.
|
||||
let away = first_point(sk, &c.kind).and_then(|p| centres.get(p).copied()).unwrap_or(anchor);
|
||||
let lay = dim_layout(&frame, c.text.or_else(|| default_text(&frame, away, px)), px, half);
|
||||
let stroke = Stroke::new(1.0, col);
|
||||
let mut segs = Vec::new();
|
||||
for line in &lay.lines {
|
||||
let pts: Vec<Pos2> = line.iter().filter_map(|q| to(*q)).collect();
|
||||
for w in pts.windows(2) {
|
||||
segs.push([w[0], w[1]]);
|
||||
}
|
||||
painter.add(Shape::line(pts, stroke));
|
||||
}
|
||||
for (tip, dir) in &lay.arrows {
|
||||
if let (Some(t), Some(back)) = (to(*tip), to(*tip - *dir * px)) {
|
||||
let d = (t - back).normalized();
|
||||
let n = vec2(-d.y, d.x);
|
||||
painter.add(Shape::convex_polygon(vec![t, t - d * 9.0 + n * 3.0, t - d * 9.0 - n * 3.0], col, Stroke::NONE));
|
||||
}
|
||||
}
|
||||
let Some(tc) = to(lay.text) else { continue };
|
||||
let r = Rect::from_center_size(tc, size);
|
||||
painter.rect_filled(r, 2.0, tk.viewport_top.gamma_multiply(0.85));
|
||||
painter.galley(r.min + vec2(3.0, 1.0), galley, col);
|
||||
if matches!(frame, DimFrame::ArcLength { .. }) {
|
||||
// The arc-length mark over the value.
|
||||
let c0 = pos2_mid_top(r);
|
||||
let pts: Vec<Pos2> = (0..=12)
|
||||
.map(|i| c0 + vec2(5.0 * (std::f32::consts::PI * i as f32 / 12.0).cos(), -3.0 * (std::f32::consts::PI * i as f32 / 12.0).sin()))
|
||||
.collect();
|
||||
painter.add(Shape::line(pts, stroke));
|
||||
}
|
||||
drawn.push(Drawn { id: c.id.clone(), param: c.param.clone(), text: r, segs, at: lay.text });
|
||||
}
|
||||
DRAWN.with(|d| *d.borrow_mut() = drawn);
|
||||
edit_box(app, ui.ctx());
|
||||
}
|
||||
|
||||
/// A point a dimension measures (for the shape it belongs to).
|
||||
fn first_point(sk: &solvecraft_engine::sketch::Sketch, k: &ConstraintKind) -> Option<usize> {
|
||||
use ConstraintKind::*;
|
||||
match *k {
|
||||
Length { l, .. } => sk.curves.get(l).and_then(|c| c.kind.point_ids().first().copied()),
|
||||
Distance { p, .. } | DistanceX { p, .. } | DistanceY { p, .. } | PointLineDistance { p, .. } | LinearDiameter { p, .. } => Some(p),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn pos2_mid_top(r: Rect) -> Pos2 {
|
||||
Pos2::new(r.center().x, r.top() - 1.0)
|
||||
}
|
||||
|
||||
/// The inline value editor: Enter applies, Esc (or a click elsewhere) cancels.
|
||||
fn edit_box(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let Some(mut e) = EDIT.with(|x| x.borrow_mut().take()) else { return };
|
||||
let id = egui::Id::new("sc_dim_edit");
|
||||
let mut done: Option<bool> = None;
|
||||
egui::Area::new(egui::Id::new("sc_dim_edit_area")).fixed_pos(e.at - vec2(45.0, 11.0)).order(egui::Order::Foreground).show(ctx, |ui| {
|
||||
egui::Frame::popup(ui.style()).inner_margin(egui::Margin::symmetric(3, 1)).show(ui, |ui| {
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut e.expr).id(id).desired_width(90.0));
|
||||
crate::params_dialog::complete(ui, &r, &mut e.expr);
|
||||
if e.fresh == FRESH {
|
||||
// The whole value is selected, so typing replaces it.
|
||||
let n = e.expr.chars().count();
|
||||
let mut s = egui::text_edit::TextEditState::load(ui.ctx(), id).unwrap_or_default();
|
||||
s.cursor.set_char_range(Some(egui::text_selection::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(n))));
|
||||
s.store(ui.ctx(), id);
|
||||
}
|
||||
let enter = ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
if r.lost_focus() && enter {
|
||||
done = Some(true);
|
||||
} else if e.fresh > 0 {
|
||||
if !r.has_focus() {
|
||||
r.request_focus();
|
||||
}
|
||||
} else if r.lost_focus() {
|
||||
done = Some(false);
|
||||
}
|
||||
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
done = Some(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
e.fresh = e.fresh.saturating_sub(1);
|
||||
match done {
|
||||
Some(true) => {
|
||||
if let Err(err) = app.run("ChangeParameterCommand", json!({"name": e.param, "expression": e.expr})) {
|
||||
app.set_status(err, true);
|
||||
EDIT.with(|x| *x.borrow_mut() = Some(Edit { fresh: FRESH, ..e }));
|
||||
}
|
||||
}
|
||||
Some(false) => {}
|
||||
None => EDIT.with(|x| *x.borrow_mut() = Some(e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Is the inline editor open (it has the keyboard)?
|
||||
pub fn editing() -> bool {
|
||||
EDIT.with(|e| e.borrow().is_some())
|
||||
}
|
||||
|
||||
/// Dimensions drawn last frame: (id, text centre on screen), for tests and the control channel.
|
||||
pub fn drawn() -> Vec<(String, Pos2)> {
|
||||
DRAWN.with(|d| d.borrow().iter().map(|x| (x.id.clone(), x.text.center())).collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn labels_show_the_value_or_fx() {
|
||||
let k = ConstraintKind::Length { l: 0, value: 25.0 };
|
||||
assert_eq!(label(&k, Some("25 mm"), false), "25.00");
|
||||
assert_eq!(label(&k, Some("width * 2"), false), "fx: 25.00");
|
||||
assert_eq!(label(&k, None, true), "(25.00)");
|
||||
assert_eq!(label(&ConstraintKind::Radius { c: 0, value: 5.0 }, Some("5 mm"), false), "R5.00");
|
||||
assert_eq!(label(&ConstraintKind::Diameter { c: 0, value: 10.0 }, Some("10"), false), "Ø10.00");
|
||||
let a = ConstraintKind::Angle { a: 0, b: 1, value: std::f64::consts::FRAC_PI_4, flip: false };
|
||||
assert_eq!(label(&a, Some("45 deg"), false), "45.0°");
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ pub mod canvas;
|
||||
pub mod context_menu;
|
||||
pub mod control;
|
||||
pub mod dialogs;
|
||||
pub mod dim_view;
|
||||
pub mod gpu;
|
||||
pub mod icons;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -80,6 +80,11 @@ pub struct Tokens {
|
||||
/// Sketch curves: under-constrained, fully constrained (and finished sketches).
|
||||
pub sketch: Color32,
|
||||
pub sketch_fixed: Color32,
|
||||
/// Fixed (locked) sketch geometry.
|
||||
pub sketch_locked: Color32,
|
||||
/// Sketch dimensions: driving, driven (reference).
|
||||
pub dimension: Color32,
|
||||
pub dimension_driven: Color32,
|
||||
/// Sketch points: fully constrained, free.
|
||||
pub sketch_point: Color32,
|
||||
pub sketch_point_free: Color32,
|
||||
@@ -168,6 +173,9 @@ impl Tokens {
|
||||
sketch: Color32::from_rgb(30, 90, 200),
|
||||
sketch_fixed: Color32::from_rgb(20, 20, 20),
|
||||
sketch_point: Color32::BLACK,
|
||||
sketch_locked: Color32::from_rgb(0, 140, 60),
|
||||
dimension: Color32::from_rgb(36, 40, 48),
|
||||
dimension_driven: Color32::from_rgb(120, 126, 138),
|
||||
sketch_point_free: Color32::from_rgb(30, 90, 200),
|
||||
section_cap: Color32::from_rgb(214, 128, 72),
|
||||
profile_fill: Color32::from_rgba_unmultiplied_const(255, 196, 120, 70),
|
||||
@@ -220,6 +228,9 @@ impl Tokens {
|
||||
t.sketch = Color32::from_rgb(92, 150, 255);
|
||||
t.sketch_fixed = Color32::from_rgb(232, 234, 238);
|
||||
t.sketch_point = Color32::from_rgb(232, 234, 238);
|
||||
t.sketch_locked = Color32::from_rgb(96, 204, 116);
|
||||
t.dimension = Color32::from_rgb(224, 228, 234);
|
||||
t.dimension_driven = Color32::from_rgb(142, 150, 164);
|
||||
t.sketch_point_free = Color32::from_rgb(92, 150, 255);
|
||||
t.profile_fill = Color32::from_rgba_unmultiplied_const(120, 150, 200, 40);
|
||||
t.glyph_bg = Color32::from_rgb(44, 50, 60);
|
||||
|
||||
@@ -410,6 +410,7 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
crate::tools::finish(app);
|
||||
} else if app.dialog.is_some() {
|
||||
crate::dialogs::cancel(app);
|
||||
} else if crate::dim_view::deselect() {
|
||||
} else if !app.session.selection.is_empty() || app.session.active_sketch.is_none() {
|
||||
let _ = app.run("select.clear", json!({}));
|
||||
} else {
|
||||
|
||||
@@ -806,7 +806,7 @@ pub fn sketch_point_at(app: &SolveApp, proj: &Proj, pos: Pos2) -> Option<(Vec2,
|
||||
}
|
||||
|
||||
pub fn delete_selection(app: &mut SolveApp) {
|
||||
if crate::sketch_tools::delete_glyph(app) {
|
||||
if crate::dim_view::delete_selected(app) || crate::sketch_tools::delete_glyph(app) {
|
||||
return;
|
||||
}
|
||||
let sel = app.session.selection.clone();
|
||||
@@ -973,13 +973,16 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
app.dialog = Some(d);
|
||||
}
|
||||
} else {
|
||||
if !crate::sketch_tools::click_glyph(app, &proj, p) {
|
||||
if !crate::dim_view::click(app, p) && !crate::sketch_tools::click_glyph(app, &proj, p) {
|
||||
select(app, cand.and_then(|c| c.1), add);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Double-click a dimension: edit its value in place.
|
||||
let dim_edit = resp.double_clicked() && app.tool.is_none() && app.dialog.is_none() && crate::dim_view::double_click(app, p);
|
||||
// Double-click an edge: select the edges that continue it smoothly (the loop).
|
||||
if resp.double_clicked()
|
||||
&& !dim_edit
|
||||
&& app.tool.is_none()
|
||||
&& app.dialog.is_none()
|
||||
&& let Some(Hit::Edge { body, index, .. }) = app.viewport.hover.clone()
|
||||
@@ -1003,7 +1006,26 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
// Dragging a sketch point of the active sketch moves it; the solver keeps the constraints.
|
||||
if app.tool.is_none() && app.dialog.is_none() && app.viewport.nav.is_none() && app.session.active_sketch.is_some() {
|
||||
let on_plane = |app: &SolveApp, p: Pos2| {
|
||||
let ss = app.session.active_sketch.and_then(|sid| app.session.model.state().sketch(sid).cloned())?;
|
||||
let (o, d) = proj.ray(p);
|
||||
ss.plane.intersect_ray(o, d).map(|w| ss.plane.to_local(w))
|
||||
};
|
||||
// A dimension's text follows a drag that starts on it.
|
||||
if resp.drag_started_by(egui::PointerButton::Primary)
|
||||
&& let Some(a) = ui.input(|i| i.pointer.press_origin())
|
||||
&& let Some(q) = on_plane(app, a)
|
||||
{
|
||||
crate::dim_view::drag_start(app, a, q);
|
||||
}
|
||||
if crate::dim_view::dragging()
|
||||
&& (resp.dragged_by(egui::PointerButton::Primary) || resp.drag_stopped())
|
||||
&& let Some(q) = hover.and_then(|p| on_plane(app, p))
|
||||
{
|
||||
crate::dim_view::drag_to(app, q);
|
||||
}
|
||||
if resp.drag_started_by(egui::PointerButton::Primary)
|
||||
&& !crate::dim_view::dragging()
|
||||
&& let Some(a) = ui.input(|i| i.pointer.press_origin())
|
||||
&& let Some(Hit::SketchPoint { id, .. }) = pick(app, &proj, a).into_iter().next()
|
||||
{
|
||||
@@ -1025,10 +1047,11 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
if resp.drag_stopped() {
|
||||
app.viewport.point_drag = None;
|
||||
crate::dim_view::drag_end();
|
||||
}
|
||||
}
|
||||
// Box selection: a primary drag on the model when no navigation mode is on.
|
||||
if app.tool.is_none() && app.viewport.nav.is_none() && app.viewport.point_drag.is_none() {
|
||||
if app.tool.is_none() && app.viewport.nav.is_none() && app.viewport.point_drag.is_none() && !crate::dim_view::dragging() {
|
||||
let origin = ui.input(|i| i.pointer.press_origin());
|
||||
if resp.dragged_by(egui::PointerButton::Primary)
|
||||
&& let (Some(a), Some(b)) = (origin, hover)
|
||||
@@ -1045,7 +1068,8 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
} else {
|
||||
app.viewport.boxsel = None;
|
||||
}
|
||||
overlays(app, ui, &painter, &proj);
|
||||
overlays(app, &painter, &proj);
|
||||
crate::dim_view::show(app, ui, &painter, &proj);
|
||||
hover_highlight(app, &painter, &proj);
|
||||
points_2d(app, &painter, &proj);
|
||||
if let Some(bx) = app.viewport.boxsel {
|
||||
@@ -1235,7 +1259,7 @@ fn hover_highlight(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
||||
}
|
||||
|
||||
/// Sketch points, dimension labels and constraint glyphs of the active sketch.
|
||||
fn overlays(app: &mut SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj) {
|
||||
fn overlays(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
||||
let t = Tokens::get();
|
||||
let st = app.session.model.state();
|
||||
let Some(sid) = app.session.active_sketch else { return };
|
||||
@@ -1254,31 +1278,8 @@ fn overlays(app: &mut SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj
|
||||
painter.rect_filled(Rect::from_center_size(sp, vec2(5.0, 5.0)), 0.0, c);
|
||||
}
|
||||
}
|
||||
let mut edit: Option<String> = None;
|
||||
for c in &sk.constraints {
|
||||
let Some(param) = &c.param else {
|
||||
glyph(painter, proj, ss, &c.kind);
|
||||
continue;
|
||||
};
|
||||
let expr = app.session.doc.param(param).map(|p| p.expr.clone()).unwrap_or_default();
|
||||
let anchor = dim_anchor(sk, &c.kind);
|
||||
let Some(a) = anchor.and_then(|a| proj.to_screen(ss.plane.to_world(a))) else { continue };
|
||||
let text = if expr.chars().all(|ch| ch.is_ascii_digit() || ch == '.' || ch == ' ' || ch == 'm' || ch == 'd' || ch == 'e' || ch == 'g') {
|
||||
expr.replace(" mm", "").replace(" deg", "°")
|
||||
} else {
|
||||
format!("{param}: {}", expr)
|
||||
};
|
||||
let galley = painter.layout_no_wrap(text, FontId::proportional(12.0), t.text);
|
||||
let r = Rect::from_center_size(a, galley.size() + vec2(8.0, 4.0));
|
||||
painter.rect(r, 2.0, t.overlay, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
painter.galley(r.min + vec2(4.0, 2.0), galley, t.text);
|
||||
let resp = ui.interact(r, ui.id().with(("dim", param.as_str())), Sense::click());
|
||||
if resp.double_clicked() || resp.clicked() && app.tool.is_none() {
|
||||
edit = Some(param.clone());
|
||||
}
|
||||
}
|
||||
if let Some(p) = edit {
|
||||
app.dialog = Some(crate::dialogs::Dialog::edit_param(&app.session, &p));
|
||||
for c in sk.constraints.iter().filter(|c| !c.kind.is_dimension()) {
|
||||
glyph(painter, proj, ss, &c.kind);
|
||||
}
|
||||
// Sketch status.
|
||||
if let Some(r) = app.viewport.rect {
|
||||
@@ -1301,29 +1302,6 @@ fn line_mid(sk: &solvecraft_engine::sketch::Sketch, l: usize) -> Option<(Vec2, V
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a dimension label goes (sketch coordinates).
|
||||
fn dim_anchor(sk: &solvecraft_engine::sketch::Sketch, k: &ConstraintKind) -> Option<Vec2> {
|
||||
use ConstraintKind::*;
|
||||
match *k {
|
||||
Length { l, .. } | PointLineDistance { l, .. } => {
|
||||
let (a, b) = line_mid(sk, l)?;
|
||||
let n = (b - a).perp().normalized().unwrap_or(Vec2::Y);
|
||||
Some((a + b) * 0.5 + n * ((b - a).len() * 0.08 + 1.0))
|
||||
}
|
||||
Distance { p, q, .. } | DistanceX { p, q, .. } | DistanceY { p, q, .. } => Some((sk.point(p)? + sk.point(q)?) * 0.5),
|
||||
Radius { c, .. } | Diameter { c, .. } => {
|
||||
let r = sk.radius(c)?;
|
||||
Some(sk.center(c)? + Vec2::new(std::f64::consts::FRAC_1_SQRT_2, std::f64::consts::FRAC_1_SQRT_2) * r)
|
||||
}
|
||||
Angle { a, b, .. } => {
|
||||
let (a0, a1) = line_mid(sk, a)?;
|
||||
let (b0, b1) = line_mid(sk, b)?;
|
||||
Some((a0 + a1 + b0 + b1) * 0.25)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Small constraint markers next to the constrained geometry.
|
||||
fn glyph(painter: &egui::Painter, proj: &Proj, ss: &solvecraft_engine::doc::SolvedSketch, k: &ConstraintKind) {
|
||||
use ConstraintKind::*;
|
||||
|
||||
Reference in new issue
Block a user