What a component holds moves with its occurrence, and picks in the world land in the
component's frame:
- Sketches and construction planes show where their component is placed (free move, joint
drive, Capture Position); sketch.inspect reports sketch points in the world.
- frames.rs maps a command's world picks into the frame of the body they lie on (through the
inverse of its placement: a moved component or a pasted instance), points that make new
geometry into the active component's frame, and directions with them; instance body names
("Body1 (Pin:2)") become the component's body. Fillet/chamfer edges, shell/offset/draft
faces, face extrude/revolve, sketch on face, holes, sheet metal and plastic picks, primitives,
appearance faces, analyses. Once per command (not again for commands run by commands); the
active-frame fallback stays for commands without a table. Nothing changes while every
placement is the identity.
- component.move_bodies keeps a body where it is in the world (Document.body_offsets: from the
new component's frame to the one it was made in; renames and deleted components follow).
- The UI reads the placed model (Session::world_state) for display, picking, sketch editing and
dialogs; live previews use it once anything is placed.
Tests (engine): sketches and construction planes move (and turn) with the component; world
picks fillet, sketch, hole and make boxes in a moved component; a pick on a pasted instance;
move_bodies keeps the world place there and back; a sketch on a face follows a Move feature.
QA's s34 now runs.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
360 lines
15 KiB
Rust
360 lines
15 KiB
Rust
//! 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::Sel;
|
|
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,
|
|
/// Where it sits (None until the dimension has been drawn).
|
|
at: Option<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,
|
|
/// Frames left to wait for the dimension to be drawn.
|
|
wait: u8,
|
|
}
|
|
|
|
thread_local! {
|
|
static DRAWN: RefCell<Vec<Drawn>> = const { RefCell::new(Vec::new()) };
|
|
/// 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 whose value text is under a screen position (its id).
|
|
pub fn hit_text(pos: Pos2) -> Option<String> {
|
|
DRAWN.with(|d| d.borrow().iter().find(|x| x.text.expand(2.0).contains(pos)).map(|x| x.id.clone()))
|
|
}
|
|
|
|
/// 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 dimensions (constraint ids; they are in the session's selection).
|
|
pub fn selected(app: &SolveApp) -> Vec<String> {
|
|
let dims: Vec<String> = DRAWN.with(|d| d.borrow().iter().map(|x| x.id.clone()).collect());
|
|
app.session
|
|
.selection
|
|
.iter()
|
|
.filter_map(|s| if let Sel::SketchConstraint { id } = s { Some(id.clone()) } else { None })
|
|
.filter(|id| dims.contains(id))
|
|
.collect()
|
|
}
|
|
|
|
/// Where the dimension driven by parameter `param` (or with constraint id `param`) shows its
|
|
/// value (screen), as drawn last frame.
|
|
pub fn text_at(param: &str) -> Option<Pos2> {
|
|
DRAWN.with(|d| d.borrow().iter().find(|x| x.param.as_deref() == Some(param) || x.id == param).map(|x| x.text.center()))
|
|
}
|
|
|
|
/// Dimension text boxes drawn last frame (for box selection).
|
|
pub fn text_rects() -> Vec<(String, Rect)> {
|
|
DRAWN.with(|d| d.borrow().iter().map(|x| (x.id.clone(), x.text)).collect())
|
|
}
|
|
|
|
/// 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;
|
|
};
|
|
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: Some(d.text.center()), fresh: FRESH, wait: 0 }));
|
|
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;
|
|
};
|
|
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.world_state();
|
|
let Some(ss) = app.session.active_sketch.and_then(|s| st.sketch(s)) else {
|
|
DRAWN.with(|d| d.borrow_mut().clear());
|
|
EDIT.with(|e| *e.borrow_mut() = None);
|
|
return;
|
|
};
|
|
let sk = &ss.sketch;
|
|
if sk.view.hide_dimensions {
|
|
DRAWN.with(|d| d.borrow_mut().clear());
|
|
return;
|
|
}
|
|
let tk = Tokens::get();
|
|
let is_sel = |id: &str| app.session.selection.iter().any(|s| matches!(s, Sel::SketchConstraint { id: x } if x == id));
|
|
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 is_sel(&c.id) || 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.
|
|
/// Edit a dimension's value in place by its parameter (a new dimension from the Dimension tool:
|
|
/// its value is selected, so typing replaces it).
|
|
pub fn edit_param(app: &SolveApp, param: &str) {
|
|
let expr = app.session.doc.param(param).map(|p| p.expr.clone()).unwrap_or_default();
|
|
EDIT.with(|e| *e.borrow_mut() = Some(Edit { param: param.to_string(), expr, at: None, fresh: FRESH, wait: 10 }));
|
|
}
|
|
|
|
fn edit_box(app: &mut SolveApp, ctx: &egui::Context) {
|
|
let Some(mut e) = EDIT.with(|x| x.borrow_mut().take()) else { return };
|
|
if e.at.is_none() {
|
|
e.at = DRAWN.with(|d| d.borrow().iter().find(|x| x.param.as_deref() == Some(e.param.as_str())).map(|x| x.text.center()));
|
|
}
|
|
let Some(at) = e.at else {
|
|
// Not drawn yet (this frame's solve): try again next frame, for a few frames.
|
|
if e.wait > 0 {
|
|
e.wait -= 1;
|
|
EDIT.with(|x| *x.borrow_mut() = Some(e));
|
|
}
|
|
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(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°");
|
|
}
|
|
}
|