Sketch inline dimensions while drawing (line length/angle, rectangle width/height, circle diameter, arc radius/sweep, polygon radius/sides, slot length/width): boxes sit on what they measure, typing goes into the first box and locks it, Tab moves on, Enter places the shape, typed values become dimension constraints (expressions and units); Press Pull (Q: faces extrude/cut by sign, edges fillet), X toggles construction on selected curves, Move dialog (M) with preview

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-07 21:40:14 +00:00
1 parent ea3dd0ed6e
commit eb22a149b1
11 files changed
+411 -25

No files matched your search

+22
View File
@@ -65,6 +65,11 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("plane")
.params("base: XY|XZ|YZ|plane name; axis: X|Y|Z|{origin, dir}; angle: expr; name?"),
CommandSpec::new("FusionSplitBodyCommand", "Split Body", split_body).at("SOLID", "MODIFY").icon("split").params("body: name; plane: XY|XZ|YZ|plane name|{origin, normal}"),
CommandSpec::new("FusionPressPullCommand", "Press Pull", press_pull)
.at("SOLID", "MODIFY")
.icon("presspull")
.key("Q")
.params("face: [x,y,z] and distance: expr (positive adds material, negative cuts), or edges: [[x,y,z]…] and distance (a fillet)"),
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"),
];
@@ -318,6 +323,23 @@ fn edge_points(s: &Session, p: &Value, cmd: &str) -> Result<Vec<Vec3>> {
Ok(out)
}
/// Press Pull: a face moves along its normal (an extrude that joins outward or cuts inward); an
/// edge is rounded (a fillet).
fn press_pull(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionPressPullCommand";
let distance = req_expr(cmd, p, "distance")?;
if p.get("edges").is_some() {
return fillet(s, &json!({"edges": p.get("edges"), "radius": distance}));
}
let face = p.get("face").ok_or_else(|| bad(cmd, "needs `face` or `edges`"))?;
let d = s.doc.eval(&distance, Kind::Length).map_err(|e| bad(cmd, format!("distance: {e}")))?;
if d.abs() < 1e-9 {
return Err(bad(cmd, "the distance must not be zero"));
}
let (dist, dir, op) = if d > 0.0 { (distance, "positive", "join") } else { (format!("-({distance})"), "negative", "cut") };
extrude(s, &json!({"face": face, "distance": dist, "direction": dir, "operation": op}))
}
fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionFilletEdgesCommand";
let edges = edge_points(s, p, cmd)?;
+1
View File
@@ -147,6 +147,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.construction", "Normal/Construction", construction)
.at("SKETCH", "CREATE")
.icon("construction")
.key("X")
.enabled(in_sketch)
.params("curves: [ids], value?: bool (default toggles)"),
CommandSpec::new("sketch.delete", "Delete Sketch Entities", sketch_delete)
+16
View File
@@ -526,6 +526,22 @@ fn preview_of_an_edit() {
assert!(Arc::ptr_eq(&doc, &s.doc));
}
/// Press Pull: positive pulls a face out, negative pushes it in (a cut), edges get a fillet.
#[test]
fn press_pull() {
let mut s = Session::default();
run(&mut s, "PrimitiveBox", json!({"length": 40, "width": 30, "height": 20}));
run(&mut s, "FusionPressPullCommand", json!({"face": [20, 15, 20], "distance": 5}));
assert!(rel(volume(&mut s), 40.0 * 30.0 * 25.0) < 1e-6);
run(&mut s, "FusionPressPullCommand", json!({"face": [20, 15, 25], "distance": "-10"}));
assert!(rel(volume(&mut s), 40.0 * 30.0 * 15.0) < 1e-6);
assert!(s.execute("FusionPressPullCommand", &json!({"face": [20, 15, 15], "distance": 0})).is_err());
let mut s = Session::default();
run(&mut s, "PrimitiveBox", json!({"length": 40, "width": 30, "height": 20}));
run(&mut s, "FusionPressPullCommand", json!({"edges": [[0, 0, 7]], "distance": 3}));
assert!(rel(volume(&mut s), 24000.0 - (9.0 - PI * 9.0 / 4.0) * 20.0) < 2e-4);
}
/// Extruding a planar body face (press-pull style): the face boundary becomes a sketch profile.
#[test]
fn extrude_a_body_face() {
+1
View File
@@ -26,6 +26,7 @@ pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option<Vec3>)> {
}
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::Body { name } => (st.body(name)?.mesh().bounds().center(), None),
_ => return None,
};
// Only extrudes point along the normal.
+37 -1
View File
@@ -49,6 +49,12 @@ pub enum Kind {
angle: String,
},
Mirror,
/// Move bodies by a distance along X, Y and Z.
Move {
x: String,
y: String,
z: String,
},
Hole {
diameter: String,
depth: String,
@@ -148,6 +154,9 @@ impl Dialog {
"MirrorCommand" => {
Dialog::new(Kind::Mirror, vec![SelInput::new("Bodies", BODIES, true), SelInput::new("Mirror plane", PLANES | PLANAR_FACES, false)])
}
"FusionMoveCommand" => {
Dialog::new(Kind::Move { x: "0 mm".into(), y: "0 mm".into(), z: "10 mm".into() }, vec![SelInput::new("Bodies", BODIES, true)])
}
"FusionHoleCommand" => Dialog::new(hole_defaults(), vec![SelInput::new("Position", FACES, true)]),
"PrimitiveBox" => Dialog::new(
Kind::Primitive {
@@ -175,8 +184,21 @@ impl Dialog {
"ChangeParameterCommand" => Dialog::new(Kind::Params { new_name: String::new(), new_expr: String::new() }, vec![]),
_ => return None,
};
// Pre-selection: what is selected now becomes the input (first input that takes it).
// Pre-selection: what is selected now becomes the input (first input that takes it); a
// face or edge stands for its body where bodies are wanted.
for sel in &s.selection {
let as_body = match sel {
Sel::Face { body, .. } | Sel::Edge { body, .. } | Sel::Vertex { body, .. } => Some(Sel::Body { name: body.clone() }),
_ => None,
};
if let Some(b) = as_body
&& let Some(inp) = d.inputs.iter_mut().find(|i| i.accept == BODIES && (i.multi || i.items.is_empty()))
{
if !inp.items.contains(&b) {
inp.items.push(b);
}
continue;
}
if let Some(inp) = d.inputs.iter_mut().find(|i| fits(i.accept, sel) && (i.multi || i.items.is_empty())) {
inp.items.push(sel.clone());
}
@@ -223,6 +245,7 @@ impl Dialog {
| Kind::Shell { .. }
| Kind::Draft { .. }
| Kind::Mirror
| Kind::Move { .. }
| Kind::Hole { .. }
| Kind::Primitive { .. }
| Kind::Combine { .. }
@@ -238,6 +261,7 @@ impl Dialog {
Kind::Shell { thickness } => ("Thickness", ValueKind::Length, thickness),
Kind::Draft { angle } => ("Angle", ValueKind::Angle, angle),
Kind::Hole { diameter, .. } => ("Diameter", ValueKind::Length, diameter),
Kind::Move { z, .. } => ("Z", ValueKind::Length, z),
_ => return None,
})
}
@@ -384,6 +408,7 @@ fn title(k: &Kind) -> &'static str {
Kind::Shell { .. } => "SHELL",
Kind::Draft { .. } => "DRAFT",
Kind::Mirror => "MIRROR",
Kind::Move { .. } => "MOVE",
Kind::Hole { .. } => "HOLE",
Kind::Primitive { cmd, .. } => match *cmd {
"PrimitiveBox" => "BOX",
@@ -539,6 +564,13 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
ui.end_row();
}
Kind::Mirror => {}
Kind::Move { x, y, z } => {
for (l, v) in [("X distance", x), ("Y distance", y), ("Z distance", z)] {
ui.label(l);
enter |= field(ui, v);
ui.end_row();
}
}
Kind::Hole { diameter, depth, kind, cb_diameter, cb_depth, cs_diameter, cs_angle } => {
ui.label("Type");
combo(ui, "hole_kind", &HOLE_LABELS, kind);
@@ -905,6 +937,10 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
let plane = plane_value(s, sels(d, 1).first()).ok_or("the mirror plane must be a plane or a planar face")?;
("MirrorCommand", json!({"features": features, "plane": plane}))
}
Kind::Move { x, y, z } => {
need(0, "bodies")?;
("FusionMoveCommand", json!({"bodies": body_names(0), "translate": [x, y, z]}))
}
Kind::Hole { diameter, depth, kind, cb_diameter, cb_depth, cs_diameter, cs_angle } => {
need(0, "a face position")?;
let ty = HOLE_TYPES.get(*kind).copied().unwrap_or("simple");
+4
View File
@@ -183,6 +183,10 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
pen.poly(&[(3.0, 7.0), (9.0, 10.0), (9.0, 17.0), (3.0, 17.0)], pen.fill, ink);
pen.poly(&[(21.0, 7.0), (15.0, 10.0), (15.0, 17.0), (21.0, 17.0)], Color32::TRANSPARENT, a);
}
"presspull" => {
pen.poly(&[(3.0, 15.0), (12.0, 11.0), (21.0, 15.0), (12.0, 19.0)], pen.fill, ink);
pen.arrow((12.0, 15.0), (12.0, 3.0), a);
}
"move" => {
for (dx, dy) in [(0.0, -1.0), (0.0, 1.0), (-1.0, 0.0), (1.0, 0.0)] {
pen.arrow((12.0, 12.0), (12.0 + 9.0 * dx, 12.0 + 9.0 * dy), a);
+19
View File
@@ -17,6 +17,7 @@ pub mod icons;
pub mod palette;
pub mod preview;
pub mod selection;
pub mod sketch_dims;
pub mod theme;
pub mod timeline;
pub mod toolbar;
@@ -185,6 +186,24 @@ impl SolveApp {
}
return;
}
// Press Pull: edges get a fillet, faces and profiles an extrude.
if id == "FusionPressPullCommand" {
let edges = self.session.selection.iter().any(|s| matches!(s, solvecraft_engine::Sel::Edge { .. }));
return self.start(if edges { "FusionFilletEdgesCommand" } else { "Extrude" });
}
// Construction toggle: selected sketch curves switch at once, otherwise pick them.
if id == "sketch.construction" {
let curves: Vec<String> = self
.session
.selection
.iter()
.filter_map(|s| if let solvecraft_engine::Sel::SketchCurve { id } = s { Some(id.clone()) } else { None })
.collect();
if !curves.is_empty() {
let _ = self.run(id, json!({ "curves": curves }));
return;
}
}
if let Some(t) = tools::Tool::for_command(id) {
self.tool = Some(t);
self.set_status(tools::hint(id), false);
+248
View File
@@ -0,0 +1,248 @@
//! Inline dimensions while drawing in a sketch. Once a shape's first point is down, value
//! boxes (line length and angle, rectangle width and height, circle diameter…) sit next to the
//! segment they measure and follow the cursor. Typing goes straight into the first box and
//! locks that value; Tab moves to the next box; Enter places the shape with the typed values,
//! which become dimension constraints. Values are expressions ("25", "1 in", "d1 * 2").
use egui::{Pos2, vec2};
use serde_json::{Value, json};
use solvecraft_engine::doc::expr::Kind as ValueKind;
use solvecraft_engine::geom::Vec2;
use crate::SolveApp;
use crate::theme::Tokens;
use crate::tools::Tool;
use crate::viewport::Proj;
/// One value box.
#[derive(Clone, Debug)]
pub struct DimBox {
pub label: &'static str,
pub kind: ValueKind,
/// The expression shown (the live value while not locked).
pub value: String,
/// Typed by the user: the value no longer follows the cursor.
pub locked: bool,
}
impl DimBox {
fn new(label: &'static str, kind: ValueKind) -> DimBox {
DimBox { label, kind, value: String::new(), locked: false }
}
}
/// The boxes a tool shows after `n` points.
pub fn boxes_for(cmd: &str, n: usize) -> Vec<DimBox> {
use ValueKind::*;
let b = DimBox::new;
match (cmd, n) {
("DrawPolyline", n) if n >= 1 => vec![b("Length", Length), b("Angle", Angle)],
("ShapeRectangleTwoPoint" | "ShapeRectangleCenter", 1) => vec![b("Width", Length), b("Height", Length)],
("CircleCenterRadius", 1) => vec![b("Diameter", Length)],
("ArcCenterTwoPoint", 1) => vec![b("Radius", Length)],
("ArcCenterTwoPoint", 2) => vec![b("Sweep", Angle)],
("ShapePolygonInscribed" | "ShapePolygonCircumscribed", 1) => vec![b("Radius", Length), b("Sides", Unitless)],
("ShapeSlotCenterToCenter" | "ShapeSlotOverall", 1) => vec![b("Length", Length)],
("ShapeSlotCenterToCenter" | "ShapeSlotOverall", 2) => vec![b("Width", Length)],
_ => Vec::new(),
}
}
/// A locked box's value (mm or radians).
fn locked(app: &SolveApp, t: &Tool, label: &str) -> Option<f64> {
let b = t.dims.iter().find(|b| b.label == label && b.locked)?;
app.session.doc.eval(&b.value, b.kind).ok().filter(|v| v.is_finite())
}
fn dir_or_x(v: Vec2) -> Vec2 {
v.normalized().unwrap_or(Vec2::X)
}
fn sign(x: f64) -> f64 {
if x < 0.0 { -1.0 } else { 1.0 }
}
/// The next point the tool would place for the cursor at `h`, honouring locked values.
pub fn effective(app: &SolveApp, t: &Tool, h: Vec2) -> Vec2 {
let Some(a) = t.pts.last().map(|p| p.0) else { return h };
let lk = |l: &str| locked(app, t, l);
match (t.cmd, t.pts.len()) {
("DrawPolyline", _) | ("ShapeSlotCenterToCenter" | "ShapeSlotOverall", 1) => {
let v = h - a;
let len = lk("Length").unwrap_or(v.len());
let ang = lk("Angle").unwrap_or_else(|| v.angle());
a + Vec2::from_angle(ang) * len
}
("ShapeRectangleTwoPoint", 1) => {
let d = h - a;
Vec2::new(a.x + lk("Width").map_or(d.x, |w| w * sign(d.x)), a.y + lk("Height").map_or(d.y, |hh| hh * sign(d.y)))
}
("ShapeRectangleCenter", 1) => {
let d = h - a;
Vec2::new(a.x + lk("Width").map_or(d.x, |w| w * 0.5 * sign(d.x)), a.y + lk("Height").map_or(d.y, |hh| hh * 0.5 * sign(d.y)))
}
("CircleCenterRadius", 1) => a + dir_or_x(h - a) * lk("Diameter").map_or((h - a).len(), |d| d * 0.5),
("ArcCenterTwoPoint", 1) => a + dir_or_x(h - a) * lk("Radius").unwrap_or((h - a).len()),
("ArcCenterTwoPoint", 2) => {
let c = t.pts.first().map(|p| p.0).unwrap_or(a);
let r = (a - c).len();
let a0 = (a - c).angle();
let sweep = lk("Sweep").unwrap_or_else(|| ((h - c).angle() - a0).rem_euclid(std::f64::consts::TAU));
c + Vec2::from_angle(a0 + sweep) * r
}
("ShapePolygonInscribed" | "ShapePolygonCircumscribed", 1) => a + dir_or_x(h - a) * lk("Radius").unwrap_or((h - a).len()),
("ShapeSlotCenterToCenter" | "ShapeSlotOverall", 2) => match lk("Width") {
Some(w) => {
let c0 = t.pts.first().map(|p| p.0).unwrap_or(a);
let dir = dir_or_x(a - c0);
let n = dir.perp();
let side = sign(n.dot(h - c0));
c0 + (a - c0) * 0.5 + n * (w * 0.5 * side)
}
None => h,
},
_ => h,
}
}
/// The value each box shows while it follows the cursor, and where it sits (sketch coordinates).
fn live(t: &Tool, e: Vec2) -> Vec<(f64, Vec2)> {
let Some(a) = t.pts.last().map(|p| p.0) else { return Vec::new() };
let first = t.pts.first().map(|p| p.0).unwrap_or(a);
match (t.cmd, t.pts.len()) {
("DrawPolyline", _) | ("ShapeSlotCenterToCenter" | "ShapeSlotOverall", 1) => {
let v = e - a;
vec![(v.len(), (a + e) * 0.5), (v.angle(), a + dir_or_x(v) * (v.len() * 0.2).min(12.0))]
}
("ShapeRectangleTwoPoint", 1) => {
vec![((e.x - a.x).abs(), Vec2::new((a.x + e.x) * 0.5, a.y)), ((e.y - a.y).abs(), Vec2::new(e.x, (a.y + e.y) * 0.5))]
}
("ShapeRectangleCenter", 1) => {
let d = e - a;
vec![(2.0 * d.x.abs(), Vec2::new(a.x, a.y - d.y)), (2.0 * d.y.abs(), Vec2::new(a.x + d.x, a.y))]
}
("CircleCenterRadius", 1) => vec![(2.0 * (e - a).len(), (a + e) * 0.5)],
("ArcCenterTwoPoint", 1) | ("ShapePolygonInscribed" | "ShapePolygonCircumscribed", 1) => {
vec![((e - a).len(), (a + e) * 0.5), (6.0, a + (e - a) * 0.5 + (e - a).perp() * 0.3)]
}
("ArcCenterTwoPoint", 2) => {
let a0 = (a - first).angle();
vec![(((e - first).angle() - a0).rem_euclid(std::f64::consts::TAU), e)]
}
("ShapeSlotCenterToCenter" | "ShapeSlotOverall", 2) => {
let dir = dir_or_x(a - first);
vec![(2.0 * dir.cross(e - first).abs(), e)]
}
_ => Vec::new(),
}
}
fn fmt(kind: ValueKind, v: f64) -> String {
match kind {
ValueKind::Angle => format!("{:.1} deg", v.to_degrees()),
ValueKind::Unitless => format!("{}", v.round()),
ValueKind::Length => format!("{v:.2}"),
}
}
/// Make the tool's boxes match its stage (new boxes when a point was placed).
pub fn sync(t: &mut Tool) {
if t.dims_stage != t.pts.len() {
t.dims = boxes_for(t.cmd, t.pts.len());
t.dims_stage = t.pts.len();
t.dims_focus = !t.dims.is_empty();
}
}
/// The dimension constraints the locked values ask for after the shape made `curves`:
/// (entities, type, value).
pub fn constraints(t: &Tool, curves: &[String]) -> Vec<(Vec<String>, &'static str, String)> {
let mut out = Vec::new();
for b in t.dims.iter().filter(|b| b.locked) {
let target = match (t.cmd, b.label) {
("DrawPolyline", "Length") | ("ShapeRectangleTwoPoint" | "ShapeRectangleCenter", "Width") => curves.first().map(|c| (c, "length")),
("ShapeRectangleTwoPoint" | "ShapeRectangleCenter", "Height") => curves.get(1).map(|c| (c, "length")),
("CircleCenterRadius", "Diameter") => curves.first().map(|c| (c, "diameter")),
("ArcCenterTwoPoint", "Radius") => curves.first().map(|c| (c, "radius")),
_ => None,
};
if let Some((c, ty)) = target {
out.push((vec![c.clone()], ty, b.value.clone()));
}
}
out
}
/// A locked box value by label (for the polygon side count).
pub fn locked_value(app: &SolveApp, t: &Tool, label: &str) -> Option<f64> {
locked(app, t, label)
}
/// Draw the boxes of the active tool next to what they measure. Enter places the point.
pub fn show(app: &mut SolveApp, ui: &mut egui::Ui, proj: &Proj) {
// Esc in a box ends the shape (the box had the keyboard, so the shortcut didn't see it).
let in_box =
app.tool.as_ref().is_some_and(|t| (0..t.dims.len()).any(|i| ui.ctx().memory(|m| m.had_focus_last_frame(egui::Id::new(("sc_dim", i))))));
if in_box && ui.input(|i| i.key_pressed(egui::Key::Escape)) {
crate::tools::finish(app);
return;
}
let Some(mut t) = app.tool.take() else { return };
sync(&mut t);
let place = if t.dims.is_empty() { None } else { boxes(app, ui, proj, &mut t) };
app.tool = Some(t);
if let Some(p) = place {
crate::tools::place(app, (p, None));
}
}
fn boxes(app: &SolveApp, ui: &mut egui::Ui, proj: &Proj, t: &mut Tool) -> Option<Vec2> {
let tk = Tokens::get();
let sid = app.session.active_sketch?;
let st = app.session.model.state();
let plane = st.sketch(sid)?.plane;
let h = t.hover.as_ref().map(|x| x.0)?;
let e = effective(app, t, h);
let lv = live(t, e);
let focus = std::mem::take(&mut t.dims_focus);
let mut any_focused = false;
for (i, b) in t.dims.iter_mut().enumerate() {
let Some((v, at)) = lv.get(i).copied() else { continue };
if !b.locked {
b.value = fmt(b.kind, v);
}
let Some(sp) = proj.to_screen(plane.to_world(at)) else { continue };
let id = egui::Id::new(("sc_dim", i));
let has_focus = ui.ctx().memory(|m| m.has_focus(id));
// While a box follows the cursor its whole text is selected, so typing replaces it.
if (has_focus || (focus && i == 0)) && !b.locked {
let mut s = egui::text_edit::TextEditState::load(ui.ctx(), id).unwrap_or_default();
let n = b.value.chars().count();
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 pos: Pos2 = sp + vec2(-34.0, -11.0);
egui::Area::new(egui::Id::new(("sc_dim_area", i))).fixed_pos(pos).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
let fill = if b.locked { tk.accent_soft } else { tk.panel };
egui::Frame::popup(ui.style()).fill(fill).inner_margin(egui::Margin::symmetric(3, 1)).show(ui, |ui| {
let r =
ui.add(egui::TextEdit::singleline(&mut b.value).id(id).desired_width(62.0).font(egui::TextStyle::Small)).on_hover_text(b.label);
if r.changed() {
b.locked = true;
}
if focus && i == 0 {
r.request_focus();
}
any_focused |= r.has_focus() || r.lost_focus();
});
});
}
let enter = ui.input(|i| i.key_pressed(egui::Key::Enter));
let nothing = ui.ctx().memory(|m| m.focused().is_none());
(enter && (any_focused || nothing)).then_some(e)
}
/// JSON for a typed dimension.
pub fn dimension_params(entities: &[String], ty: &str, value: &str) -> Value {
json!({"entities": entities, "type": ty, "value": value})
}
+1 -1
View File
@@ -375,7 +375,7 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
match k {
Key::Escape => {
if app.tool.is_some() {
app.tool = None;
crate::tools::finish(app);
} else if app.dialog.is_some() {
crate::dialogs::cancel(app);
} else {
+61 -23
View File
@@ -26,6 +26,11 @@ pub struct Tool {
pub hover: Option<(Vec2, Option<String>)>,
/// Picked entity ids.
pub picks: Vec<String>,
/// Inline dimension boxes for the shape being drawn, the point count they were made for,
/// and whether the first box should take the keyboard.
pub dims: Vec<crate::sketch_dims::DimBox>,
pub dims_stage: usize,
pub dims_focus: bool,
}
impl Tool {
@@ -61,7 +66,7 @@ impl Tool {
"sketch.construction" => ("sketch.construction", Kind::Pick(1)),
_ => return None,
};
Some(Tool { cmd, kind, pts: Vec::new(), hover: None, picks: Vec::new() })
Some(Tool { cmd, kind, pts: Vec::new(), hover: None, picks: Vec::new(), dims: Vec::new(), dims_stage: usize::MAX, dims_focus: false })
}
}
@@ -102,24 +107,15 @@ pub fn on_hover(app: &mut SolveApp, proj: &Proj, pos: Pos2) {
pub fn on_click(app: &mut SolveApp, proj: &Proj, pos: Pos2) {
let Some(mut tool) = app.tool.take() else { return };
match tool.kind {
Kind::Draw(n) => {
if let Some(p) = sketch_point_at(app, proj, pos) {
tool.pts.push(p);
}
if tool.cmd == "DrawPolyline" {
if tool.pts.len() >= 2 {
let (a, b) = (tool.pts[tool.pts.len() - 2].clone(), tool.pts[tool.pts.len() - 1].clone());
if let Ok(v) = app.run("DrawPolyline", json!({"points": [arg(&a), arg(&b)], "infer": true})) {
// Chain: the next segment starts at this one's end point.
if let Some(id) = v["curves"].get(0).and_then(Value::as_str) {
let last = tool.pts.len() - 1;
tool.pts[last].1 = Some(format!("{id}.end"));
}
}
Kind::Draw(_) => {
if let Some(mut p) = sketch_point_at(app, proj, pos) {
// Typed values win over the cursor.
if tool.dims.iter().any(|b| b.locked) {
p = (crate::sketch_dims::effective(app, &tool, p.0), None);
}
} else if n > 0 && tool.pts.len() >= n {
run_shape(app, &tool);
tool.pts.clear();
app.tool = Some(tool);
place(app, p);
return;
}
}
Kind::Pick(n) => {
@@ -168,9 +164,47 @@ pub fn on_click(app: &mut SolveApp, proj: &Proj, pos: Pos2) {
app.tool = Some(tool);
}
fn run_shape(app: &mut SolveApp, t: &Tool) {
/// Put down the next point of the shape being drawn; the last point runs the command (with
/// the typed values as dimension constraints).
pub fn place(app: &mut SolveApp, p: (Vec2, Option<String>)) {
let Some(mut tool) = app.tool.take() else { return };
let Kind::Draw(n) = tool.kind else {
app.tool = Some(tool);
return;
};
tool.pts.push(p);
if tool.cmd == "DrawPolyline" {
if tool.pts.len() >= 2 {
let (a, b) = (tool.pts[tool.pts.len() - 2].clone(), tool.pts[tool.pts.len() - 1].clone());
if let Ok(v) = app.run("DrawPolyline", json!({"points": [arg(&a), arg(&b)], "infer": true})) {
let curves: Vec<String> = v["curves"].as_array().into_iter().flatten().filter_map(|c| c.as_str().map(str::to_string)).collect();
dimension(app, &tool, &curves);
// Chain: the next segment starts at this one's end point.
if let Some(id) = curves.first() {
let last = tool.pts.len() - 1;
tool.pts[last].1 = Some(format!("{id}.end"));
}
}
}
} else if n > 0 && tool.pts.len() >= n {
let curves = run_shape(app, &tool);
dimension(app, &tool, &curves);
tool.pts.clear();
}
app.tool = Some(tool);
}
/// Typed values become dimension constraints on the new curves.
fn dimension(app: &mut SolveApp, t: &Tool, curves: &[String]) {
for (ents, ty, value) in crate::sketch_dims::constraints(t, curves) {
let _ = app.run("SketchDimension", crate::sketch_dims::dimension_params(&ents, ty, &value));
}
}
fn run_shape(app: &mut SolveApp, t: &Tool) -> Vec<String> {
let p = &t.pts;
let d = |a: usize, b: usize| p[a].0.dist(p[b].0);
let sides = crate::sketch_dims::locked_value(app, t, "Sides").map_or(6, |v| v.round().clamp(3.0, 200.0) as usize);
let params = match t.cmd {
"ShapeRectangleTwoPoint" => json!({"p0": xy(&p[0]), "p1": xy(&p[1])}),
"ShapeRectangleCenter" => json!({"center": xy(&p[0]), "corner": xy(&p[1])}),
@@ -182,18 +216,21 @@ fn run_shape(app: &mut SolveApp, t: &Tool) {
"ArcCenterTwoPoint" => json!({"center": arg(&p[0]), "start": arg(&p[1]), "end": xy(&p[2])}),
"ShapePolygonInscribed" | "ShapePolygonCircumscribed" => {
let a = (p[1].0 - p[0].0).angle().to_degrees();
json!({"center": xy(&p[0]), "radius": d(0, 1), "sides": 6, "angle": a})
json!({"center": xy(&p[0]), "radius": d(0, 1), "sides": sides, "angle": a})
}
"ShapePolygonEdge" => json!({"p0": xy(&p[0]), "p1": xy(&p[1]), "sides": 6}),
"ShapePolygonEdge" => json!({"p0": xy(&p[0]), "p1": xy(&p[1]), "sides": sides}),
"ShapeSlotCenterToCenter" | "ShapeSlotOverall" => {
let dir = (p[1].0 - p[0].0).normalized().unwrap_or(Vec2::X);
let w = 2.0 * dir.cross(p[2].0 - p[0].0).abs();
json!({"p0": xy(&p[0]), "p1": xy(&p[1]), "width": w.max(1e-3)})
}
"DrawPoint" => json!({"point": xy(&p[0])}),
_ => return,
_ => return Vec::new(),
};
let _ = app.run(t.cmd, params);
app.run(t.cmd, params)
.ok()
.map(|v| v["curves"].as_array().into_iter().flatten().filter_map(|c| c.as_str().map(str::to_string)).collect())
.unwrap_or_default()
}
fn run_pick(app: &mut SolveApp, t: &Tool) {
@@ -242,6 +279,7 @@ pub fn preview(app: &SolveApp, t: &Tool, painter: &egui::Painter, proj: &Proj) {
);
}
let Some((h, _)) = t.hover.clone() else { return };
let h = crate::sketch_dims::effective(app, t, h);
let pts: Vec<Vec2> = t.pts.iter().map(|p| p.0).collect();
let line = |a: Vec2, b: Vec2| {
if let (Some(x), Some(y)) = (to(a), to(b)) {
+1
View File
@@ -902,6 +902,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
crate::tools::preview(app, tl, &painter, &proj);
}
crate::canvas::show(app, ui, &painter, &proj);
crate::sketch_dims::show(app, ui, &proj);
view_cube(app, ui, rect);
nav_bar(app, ui, rect);
// Status chip.