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:
1 parent
ea3dd0ed6e
commit
eb22a149b1
11 files changed
+411
-25
No files matched your search
@@ -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)?;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
@@ -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)) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user