diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 8eec64ee..426389bb 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -2,6 +2,13 @@ //! `sketch.rectangle.two_point`); the toolbar placement (`tab`, `panel`) follows the familiar //! Design workspace layout. Ids used before the rename still resolve (`legacy_ids`). +/// The params-doc note of every command that adds sketch geometry (see `sketch::created`). +macro_rules! created_doc { + () => { + ". Returns created: {points, curves, constraints, dimensions}: the ids of what it added (stable and never reused: usable right away in sketch.dimension, sketch.constraint.*, sketch.delete…)" + }; +} + mod browser; mod clipboard; mod component; @@ -30,6 +37,7 @@ mod plastic; mod sheet; mod sketch; pub(crate) use sketch::author_path_for_rebind; +pub(crate) use sketch::created as sketch_created; mod sketch_3d; mod sketch_canvas; mod sketch_constraints; diff --git a/crates/engine/src/cmd/sketch.rs b/crates/engine/src/cmd/sketch.rs index a3921e3b..ffdfd478 100644 --- a/crates/engine/src/cmd/sketch.rs +++ b/crates/engine/src/cmd/sketch.rs @@ -34,74 +34,74 @@ pub static COMMANDS: &[CommandSpec] = &[ .icon("line") .key("L") .enabled(in_sketch) - .params(concat!("points: [[x,y] | \"l1.end\" | {at, ref}…], closed?: bool, construction?: bool, ids?: [..], infer?: bool (add horizontal/vertical)", connect_doc!())), + .params(concat!("points: [[x,y] | \"l1.end\" | {at, ref}…], closed?: bool, construction?: bool, ids?: [..], infer?: bool (add horizontal/vertical)", connect_doc!(), created_doc!())), CommandSpec::new("sketch.draw.dimensioned", "Draw Dimensioned Sketch Geometry", draw_dimensioned) .enabled(in_sketch) - .params("command: sketch creation command, params: object, dimensions: object of role to numeric or expression value; creates geometry and all driving dimensions atomically in one undo step"), + .params(concat!("command: sketch creation command, params: object, dimensions: object of role to numeric or expression value; creates geometry and all driving dimensions atomically in one undo step", created_doc!())), CommandSpec::new("sketch.rectangle.two_point", "2-Point Rectangle", rect_two_point) .at("SKETCH", "CREATE") .icon("rect") .key("R") .enabled(in_sketch) - .params(concat!("p0, p1: opposite corners [x,y]; construction?. Returns sides {bottom, right, top, left} (line ids) and corners {bottom_left, bottom_right, top_right, top_left} (point ids)", connect_doc!())), + .params(concat!("p0, p1: opposite corners [x,y]; construction?. Returns sides {bottom, right, top, left} (line ids) and corners {bottom_left, bottom_right, top_right, top_left} (point ids)", connect_doc!(), created_doc!())), CommandSpec::new("sketch.rectangle.three_point", "3-Point Rectangle", rect_three_point) .at("SKETCH", "CREATE") .icon("rect3") .enabled(in_sketch) - .params(concat!("p0, p1: first edge; p2: point on the opposite edge", connect_doc!())), + .params(concat!("p0, p1: first edge; p2: point on the opposite edge", connect_doc!(), created_doc!())), CommandSpec::new("sketch.rectangle.center", "Center Rectangle", rect_center) .at("SKETCH", "CREATE") .icon("rect_center") .enabled(in_sketch) - .params(concat!("center, corner. Returns sides {bottom, right, top, left} (line ids) and corners {bottom_left, bottom_right, top_right, top_left} (point ids)", connect_doc!())), + .params(concat!("center, corner. Returns sides {bottom, right, top, left} (line ids) and corners {bottom_left, bottom_right, top_right, top_left} (point ids)", connect_doc!(), created_doc!())), CommandSpec::new("sketch.circle.center", "Center Diameter Circle", circle_center) .at("SKETCH", "CREATE") .icon("circle") .key("C") .enabled(in_sketch) - .params(concat!("center: [x,y] or point ref, radius | diameter: number", connect_doc!())), + .params(concat!("center: [x,y] or point ref, radius | diameter: number", connect_doc!(), created_doc!())), CommandSpec::new("sketch.circle.two_point", "2-Point Circle", circle_two) .at("SKETCH", "CREATE") .icon("circle2") .enabled(in_sketch) - .params(concat!("p0, p1: diameter ends", connect_doc!())), + .params(concat!("p0, p1: diameter ends", connect_doc!(), created_doc!())), CommandSpec::new("sketch.circle.three_point", "3-Point Circle", circle_three) .at("SKETCH", "CREATE") .icon("circle3") .enabled(in_sketch) - .params(concat!("p0, p1, p2", connect_doc!())), - CommandSpec::new("sketch.arc.three_point", "3-Point Arc", arc_three).at("SKETCH", "CREATE").icon("arc3").enabled(in_sketch).params(concat!("start, end, through", connect_doc!())), + .params(concat!("p0, p1, p2", connect_doc!(), created_doc!())), + CommandSpec::new("sketch.arc.three_point", "3-Point Arc", arc_three).at("SKETCH", "CREATE").icon("arc3").enabled(in_sketch).params(concat!("start, end, through", connect_doc!(), created_doc!())), CommandSpec::new("sketch.arc.center_point", "Center Point Arc", arc_center) .at("SKETCH", "CREATE") .icon("arc_center") .enabled(in_sketch) - .params(concat!("center, start, end (counter-clockwise from start) | center, start, sweep: signed degrees", connect_doc!())), + .params(concat!("center, start, end (counter-clockwise from start) | center, start, sweep: signed degrees", connect_doc!(), created_doc!())), CommandSpec::new("sketch.polygon.inscribed", "Inscribed Polygon", polygon_inscribed) .at("SKETCH", "CREATE") .icon("polygon") .enabled(in_sketch) - .params(concat!("center, radius (to vertices), sides, angle?: deg", connect_doc!())), + .params(concat!("center, radius (to vertices), sides, angle?: deg", connect_doc!(), created_doc!())), CommandSpec::new("sketch.polygon.circumscribed", "Circumscribed Polygon", polygon_circumscribed) .at("SKETCH", "CREATE") .icon("polygon") .enabled(in_sketch) - .params(concat!("center, radius (to edge midpoints), sides, angle?: deg", connect_doc!())), + .params(concat!("center, radius (to edge midpoints), sides, angle?: deg", connect_doc!(), created_doc!())), CommandSpec::new("sketch.polygon.edge", "Edge Polygon", polygon_edge) .at("SKETCH", "CREATE") .icon("polygon") .enabled(in_sketch) - .params(concat!("p0, p1: one edge, sides", connect_doc!())), + .params(concat!("p0, p1: one edge, sides", connect_doc!(), created_doc!())), CommandSpec::new("sketch.slot.center_to_center", "Center to Center Slot", slot_c2c) .at("SKETCH", "CREATE") .icon("slot") .enabled(in_sketch) - .params(concat!("p0, p1: arc centres, width", connect_doc!())), + .params(concat!("p0, p1: arc centres, width", connect_doc!(), created_doc!())), CommandSpec::new("sketch.slot.overall", "Overall Slot", slot_overall) .at("SKETCH", "CREATE") .icon("slot") .enabled(in_sketch) - .params(concat!("p0, p1: overall ends, width", connect_doc!())), - CommandSpec::new("sketch.point", "Point", draw_point).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).params(concat!("point: [x,y], id?", connect_doc!())), + .params(concat!("p0, p1: overall ends, width", connect_doc!(), created_doc!())), + CommandSpec::new("sketch.point", "Point", draw_point).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).params(concat!("point: [x,y], id?", connect_doc!(), created_doc!())), CommandSpec::new("sketch.dimension", "Sketch Dimension", dimension).at("SKETCH", "CREATE").icon("dimension").key("D").enabled(in_sketch).params( "entities: [refs], type?: auto|distance|horizontal|vertical|length|radius|diameter|angle, value?: number or expression (default: current); text_at?: [x, y]. Two lines: the angle between them (acute, or 180° − it when the value is nearer that) unless text_at picks a sector. Returns measured in unit (mm or deg)", ), @@ -353,6 +353,37 @@ pub(super) fn mark_construction(sk: &mut Sketch, curves: &[usize], p: &Value) { } } +/// What a command added to a sketch: the ids of the points, curves, constraints and dimensions +/// in `after` that `before` does not have (`None`: a new sketch), in creation order, plus the +/// 3D curves (`wires`) when there are any. Ids are stable and never reused in a sketch, so they +/// can be used in later commands (`sketch.dimension`, `sketch.constraint.*`, `sketch.delete`…). +pub(crate) fn created(before: Option<&Sketch>, after: &Sketch) -> Value { + use std::collections::HashSet; + let empty = Sketch::new(); + let before = before.unwrap_or(&empty); + let old: HashSet<&str> = before + .points + .iter() + .map(|x| x.id.as_str()) + .chain(before.curves.iter().map(|x| x.id.as_str())) + .chain(before.constraints.iter().map(|x| x.id.as_str())) + .chain(before.wires.iter().map(|x| x.id.as_str())) + .collect(); + let new = |id: &&str| !old.contains(*id); + let points: Vec<&str> = after.points.iter().map(|x| x.id.as_str()).filter(new).collect(); + let curves: Vec<&str> = after.curves.iter().map(|x| x.id.as_str()).filter(new).collect(); + let (dims, cons): (Vec<_>, Vec<_>) = after.constraints.iter().filter(|c| new(&c.id.as_str())).partition(|c| c.kind.is_dimension()); + let ids = |v: Vec<&solvecraft_sketch::Constraint>| v.into_iter().map(|c| c.id.clone()).collect::>(); + let mut out = json!({"points": points, "curves": curves, "constraints": ids(cons), "dimensions": ids(dims)}); + let wires: Vec<&str> = after.wires.iter().map(|x| x.id.as_str()).filter(new).collect(); + if !wires.is_empty() + && let Some(o) = out.as_object_mut() + { + o.insert("wires".into(), json!(wires)); + } + out +} + pub(super) fn result(sk_out: (Vec, Vec), info: Value) -> Value { json!({"curves": sk_out.0, "constraints": sk_out.1, "sketch": info}) } @@ -1909,3 +1940,7 @@ mod rect_tests; #[cfg(test)] #[path = "sketch_ux_tests.rs"] mod ux_tests; + +#[cfg(test)] +#[path = "sketch_ids_tests.rs"] +mod ids_tests; diff --git a/crates/engine/src/cmd/sketch_3d.rs b/crates/engine/src/cmd/sketch_3d.rs index d2a243c0..bdd65665 100644 --- a/crates/engine/src/cmd/sketch_3d.rs +++ b/crates/engine/src/cmd/sketch_3d.rs @@ -12,13 +12,16 @@ use crate::params::{bad, req_vec3, vec3}; use crate::{Result, Session}; pub static COMMANDS: &[CommandSpec] = &[ - CommandSpec::new("sketch.line3d", "3D Line", line3d) - .enabled(in_sketch) - .params("points: [[x,y,z]…] world points (2 or more): one 3D line per segment, joined end to end (needs 3D Sketch on)"), + CommandSpec::new("sketch.line3d", "3D Line", line3d).enabled(in_sketch).params(concat!( + "points: [[x,y,z]…] world points (2 or more): one 3D line per segment, joined end to end (needs 3D Sketch on)", + created_doc!() + )), CommandSpec::new("sketch.spline3d", "3D Spline", spline3d) .enabled(in_sketch) - .params("points: [[x,y,z]…] world fit points (2 or more): a smooth 3D curve through them (needs 3D Sketch on)"), - CommandSpec::new("sketch.point3d", "3D Point", point3d).enabled(in_sketch).params("point: [x,y,z] world point (needs 3D Sketch on)"), + .params(concat!("points: [[x,y,z]…] world fit points (2 or more): a smooth 3D curve through them (needs 3D Sketch on)", created_doc!())), + CommandSpec::new("sketch.point3d", "3D Point", point3d) + .enabled(in_sketch) + .params(concat!("point: [x,y,z] world point (needs 3D Sketch on)", created_doc!())), CommandSpec::new("sketch.move3d", "Move 3D Point", move3d) .enabled(in_sketch) .params("wire: drawn 3D curve or point id, index?: fit point (default 0), to: [x,y,z] | by: [dx,dy,dz]; points joined to it move too"), diff --git a/crates/engine/src/cmd/sketch_create.rs b/crates/engine/src/cmd/sketch_create.rs index 6b8948d2..92d61a99 100644 --- a/crates/engine/src/cmd/sketch_create.rs +++ b/crates/engine/src/cmd/sketch_create.rs @@ -10,83 +10,81 @@ use super::{CommandSpec, in_sketch}; use crate::params::{bad, num, req_vec2, str_, string_list}; use crate::{Result, Session}; -pub static COMMANDS: &[CommandSpec] = - &[ - CommandSpec::new("sketch.line.midpoint", "Midpoint Line", midpoint_line) - .at("SKETCH", "CREATE") - .icon("line_mid") - .enabled(in_sketch) - .params("mid: [x,y] or point ref, end: [x,y]; construction?"), - CommandSpec::new("sketch.arc.tangent", "Tangent Arc", arc_tangent).at("SKETCH", "CREATE").icon("arc_tangent").enabled(in_sketch).params( - "start: end point of a line or arc (\"l1.end\"), end: [x,y]; curve?: the curve to be tangent to (default: the one ending at start)", - ), - CommandSpec::new("sketch.circle.two_tangent", "2-Tangent Circle", circle_tan_tan) - .at("SKETCH", "CREATE") - .icon("circle_tt") - .enabled(in_sketch) - .params("curves: [two lines/circles/arcs], radius | diameter: expr, near: [x,y] (which of the solutions)"), - CommandSpec::new("sketch.circle.three_tangent", "3-Tangent Circle", circle_three_tan) - .at("SKETCH", "CREATE") - .icon("circle_ttt") - .enabled(in_sketch) - .params("curves: [three lines/circles/arcs], near: [x,y] (which of the solutions)"), - CommandSpec::new("sketch.slot.center_point", "Center Point Slot", slot_center_point) - .at("SKETCH", "CREATE") - .icon("slot") - .enabled(in_sketch) - .params("center: slot centre, end: one arc centre, width"), - CommandSpec::new("sketch.slot.arc_three_point", "Three Point Arc Slot", arc_slot_three) - .at("SKETCH", "CREATE") - .icon("arc_slot") - .enabled(in_sketch) - .params("start, through, end: points on the centre arc, width"), - CommandSpec::new("sketch.slot.arc_center", "Center Point Arc Slot", arc_slot_center) - .at("SKETCH", "CREATE") - .icon("arc_slot") - .enabled(in_sketch) - .params("center, start: centre arc start, end: direction of the centre arc end (counter-clockwise), width"), - CommandSpec::new("sketch.ellipse", "Ellipse", ellipse) - .at("SKETCH", "CREATE") - .icon("ellipse") - .enabled(in_sketch) - .params("center, major: end of the major axis [x,y], minor: a point the ellipse passes through on the minor side [x,y] | minor_radius"), - CommandSpec::new("sketch.spline.fit_point", "Fit Point Spline", spline_fit) - .at("SKETCH", "CREATE") - .icon("spline") - .enabled(in_sketch) - .params("points: [[x,y] or point refs…] (2…500), periodic?: boolean (default false); periodic cubic fit closes smoothly through at least 3 distinct samples"), - CommandSpec::new("sketch.spline.control_point", "Control Point Spline", spline_cv3) - .at("SKETCH", "CREATE") - .icon("spline_cv") - .enabled(in_sketch) - .params("points: control points (2…500), cubic"), - CommandSpec::new("sketch.spline.control_point_5", "Control Point Spline (Degree 5)", spline_cv5) - .at("SKETCH", "CREATE") - .icon("spline_cv") - .enabled(in_sketch) - .params("points: control points (2…500), degree 5"), - CommandSpec::new("sketch.conic", "Conic Curve", conic) - .at("SKETCH", "CREATE") - .icon("conic") - .enabled(in_sketch) - .params("start, end, apex: [x,y] or point refs; rho?: 0…1 (default 0.5: parabola)"), - CommandSpec::new("sketch.blend_curve", "Blend Curve", blend) - .at("SKETCH", "MODIFY") - .icon("blend") - .enabled(in_sketch) - .params("a, b: end points of two curves (\"l1.end\", \"a2.start\"): a smooth (tangent) spline joining them"), - CommandSpec::new("sketch.centerline", "Centerline", centerline) - .enabled(in_sketch) - .params("curves: [line ids], value?: bool (default toggles): centerline line type (not in profiles; an axis)"), - CommandSpec::new("sketch.text", "Text", text) - .at("SKETCH", "CREATE") - .icon("text") - .enabled(in_sketch) - .params("text: string (\\n breaks lines), at: baseline start [x,y], height?: capital height mm (default 5), angle?: deg"), - CommandSpec::new("sketch.edit_text", "Edit Text", edit_text) - .enabled(in_sketch) - .params("link: the text's link id (or entity: one of its curves), text?, at?, height?, angle?: deg"), - ]; +pub static COMMANDS: &[CommandSpec] = &[ + CommandSpec::new("sketch.line.midpoint", "Midpoint Line", midpoint_line) + .at("SKETCH", "CREATE") + .icon("line_mid") + .enabled(in_sketch) + .params(concat!("mid: [x,y] or point ref, end: [x,y]; construction?", created_doc!())), + CommandSpec::new("sketch.arc.tangent", "Tangent Arc", arc_tangent).at("SKETCH", "CREATE").icon("arc_tangent").enabled(in_sketch).params(concat!( + "start: end point of a line or arc (\"l1.end\"), end: [x,y]; curve?: the curve to be tangent to (default: the one ending at start)", + created_doc!() + )), + CommandSpec::new("sketch.circle.two_tangent", "2-Tangent Circle", circle_tan_tan) + .at("SKETCH", "CREATE") + .icon("circle_tt") + .enabled(in_sketch) + .params(concat!("curves: [two lines/circles/arcs], radius | diameter: expr, near: [x,y] (which of the solutions)", created_doc!())), + CommandSpec::new("sketch.circle.three_tangent", "3-Tangent Circle", circle_three_tan) + .at("SKETCH", "CREATE") + .icon("circle_ttt") + .enabled(in_sketch) + .params(concat!("curves: [three lines/circles/arcs], near: [x,y] (which of the solutions)", created_doc!())), + CommandSpec::new("sketch.slot.center_point", "Center Point Slot", slot_center_point) + .at("SKETCH", "CREATE") + .icon("slot") + .enabled(in_sketch) + .params(concat!("center: slot centre, end: one arc centre, width", created_doc!())), + CommandSpec::new("sketch.slot.arc_three_point", "Three Point Arc Slot", arc_slot_three) + .at("SKETCH", "CREATE") + .icon("arc_slot") + .enabled(in_sketch) + .params(concat!("start, through, end: points on the centre arc, width", created_doc!())), + CommandSpec::new("sketch.slot.arc_center", "Center Point Arc Slot", arc_slot_center) + .at("SKETCH", "CREATE") + .icon("arc_slot") + .enabled(in_sketch) + .params(concat!("center, start: centre arc start, end: direction of the centre arc end (counter-clockwise), width", created_doc!())), + CommandSpec::new("sketch.ellipse", "Ellipse", ellipse).at("SKETCH", "CREATE").icon("ellipse").enabled(in_sketch).params(concat!( + "center, major: end of the major axis [x,y], minor: a point the ellipse passes through on the minor side [x,y] | minor_radius", + created_doc!() + )), + CommandSpec::new("sketch.spline.fit_point", "Fit Point Spline", spline_fit) + .at("SKETCH", "CREATE") + .icon("spline") + .enabled(in_sketch) + .params(concat!("points: [[x,y] or point refs…] (2…500): the spline passes through them, periodic?: boolean (default false); periodic cubic fit closes smoothly through at least 3 distinct samples", created_doc!())), + CommandSpec::new("sketch.spline.control_point", "Control Point Spline", spline_cv3) + .at("SKETCH", "CREATE") + .icon("spline_cv") + .enabled(in_sketch) + .params(concat!("points: control points (2…500), cubic", created_doc!())), + CommandSpec::new("sketch.spline.control_point_5", "Control Point Spline (Degree 5)", spline_cv5) + .at("SKETCH", "CREATE") + .icon("spline_cv") + .enabled(in_sketch) + .params(concat!("points: control points (2…500), degree 5", created_doc!())), + CommandSpec::new("sketch.conic", "Conic Curve", conic) + .at("SKETCH", "CREATE") + .icon("conic") + .enabled(in_sketch) + .params(concat!("start, end, apex: [x,y] or point refs; rho?: 0…1 (default 0.5: parabola)", created_doc!())), + CommandSpec::new("sketch.blend_curve", "Blend Curve", blend) + .at("SKETCH", "MODIFY") + .icon("blend") + .enabled(in_sketch) + .params(concat!("a, b: end points of two curves (\"l1.end\", \"a2.start\"): a smooth (tangent) spline joining them", created_doc!())), + CommandSpec::new("sketch.centerline", "Centerline", centerline) + .enabled(in_sketch) + .params("curves: [line ids], value?: bool (default toggles): centerline line type (not in profiles; an axis)"), + CommandSpec::new("sketch.text", "Text", text).at("SKETCH", "CREATE").icon("text").enabled(in_sketch).params(concat!( + "text: string (\\n breaks lines), at: baseline start [x,y], height?: capital height mm (default 5), angle?: deg", + created_doc!() + )), + CommandSpec::new("sketch.edit_text", "Edit Text", edit_text) + .enabled(in_sketch) + .params(concat!("link: the text's link id (or entity: one of its curves), text?, at?, height?, angle?: deg", created_doc!())), +]; fn text_source(p: &Value, cmd: &str, old: Option<(String, Vec2, f64, f64)>) -> Result { let (t0, at0, h0, a0) = old.unwrap_or((String::new(), Vec2::ZERO, 5.0, 0.0)); diff --git a/crates/engine/src/cmd/sketch_ids_tests.rs b/crates/engine/src/cmd/sketch_ids_tests.rs new file mode 100644 index 00000000..65063ba0 --- /dev/null +++ b/crates/engine/src/cmd/sketch_ids_tests.rs @@ -0,0 +1,330 @@ +//! Every command that adds sketch geometry returns `created: {points, curves, constraints, +//! dimensions}` with the real ids of what it added, so a script can dimension and constrain the +//! new geometry right away; ids are never reused after a delete. + +use serde_json::{Value, json}; +use solvecraft_sketch::{CurveKind, Sketch}; + +use crate::Session; + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + match s.execute(id, &p) { + Ok(v) => v, + Err(e) => panic!("{id} {p}: {e}"), + } +} + +fn strs(v: &Value) -> Vec { + v.as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default() +} + +fn sketch(s: &Session) -> Sketch { + s.doc.sketch(s.active_sketch.unwrap()).unwrap().clone() +} + +fn new_sketch() -> Session { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY"})); + s +} + +/// The ids in `r.created` exist in the active sketch, and each curve and point takes a +/// dimension (driven, so it never conflicts) and a fix right away. `before`: the sketch before +/// the command. Returns `created`. +fn check(s: &mut Session, cmd: &str, r: &Value, before: &Sketch) -> Value { + let c = r.get("created").cloned().unwrap_or_else(|| panic!("{cmd}: no `created` in {r}")); + for k in ["points", "curves", "constraints", "dimensions"] { + assert!(c[k].is_array(), "{cmd}: created.{k} in {c}"); + } + let sk = sketch(s); + let curves = strs(&c["curves"]); + let points = strs(&c["points"]); + assert!(!curves.is_empty() || !points.is_empty() || c.get("wires").is_some(), "{cmd}: nothing created: {c}"); + for id in &points { + assert!(sk.point_index(id).is_some(), "{cmd}: point {id} not in the sketch"); + assert_ne!(id, "origin", "{cmd}"); + } + for id in strs(&c["constraints"]).iter().chain(&strs(&c["dimensions"])) { + assert!(sk.constraints.iter().any(|k| &k.id == id), "{cmd}: constraint {id} not in the sketch"); + } + for id in strs(&c["dimensions"]) { + assert!(sk.constraints.iter().any(|k| k.id == id && k.kind.is_dimension()), "{cmd}: {id} is not a dimension"); + } + for id in strs(&c.get("wires").cloned().unwrap_or_default()) { + assert!(sk.wire_index(&id).is_some(), "{cmd}: wire {id} not in the sketch"); + } + // The new curves in the command's own list (where it has one) are in `created`. + let wires = strs(&c.get("wires").cloned().unwrap_or_default()); + for id in strs(&r["curves"]) { + let old = before.curve_index(&id).is_some() || before.wire_index(&id).is_some(); + assert!(old || curves.contains(&id) || wires.contains(&id), "{cmd}: returned curve {id} missing from created {c}"); + } + for id in curves.iter().chain(&points) { + assert!(before.curve_index(id).is_none() && before.point_index(id).is_none(), "{cmd}: {id} is not new"); + } + for id in &curves { + let i = sk.curve_index(id).unwrap_or_else(|| panic!("{cmd}: curve {id} not in the sketch")); + let measurable = matches!(sk.curves[i].kind, CurveKind::Line { .. } | CurveKind::Circle { .. } | CurveKind::Arc { .. }); + if measurable { + run(s, "sketch.dimension", json!({"entities": [id], "driven": true})); + } + run(s, "sketch.constraint.fix", json!({"entity": id, "fixed": true})); + run(s, "edit.undo", json!({})); + if measurable { + run(s, "edit.undo", json!({})); + } + } + for id in &points { + run(s, "sketch.dimension", json!({"entities": [id, "origin"], "driven": true})); + run(s, "edit.undo", json!({})); + } + c +} + +/// Run `cmd` in `s` and check what it reports. +fn made(s: &mut Session, cmd: &str, p: Value) -> Value { + let before = sketch(s); + let r = run(s, cmd, p); + check(s, cmd, &r, &before) +} + +#[test] +fn drawing_commands_return_the_ids_they_create() { + let cases: Vec<(&str, Value)> = vec![ + ("sketch.line", json!({"points": [[0, 0], [10, 0], [10, 5]]})), + ("sketch.rectangle.two_point", json!({"p0": [1, 1], "p1": [21, 15]})), + ("sketch.rectangle.three_point", json!({"p0": [0, 0], "p1": [20, 5], "p2": [18, 15]})), + ("sketch.rectangle.center", json!({"center": [3, 2], "corner": [13, 9]})), + ("sketch.circle.center", json!({"center": [5, 5], "diameter": 8})), + ("sketch.circle.two_point", json!({"p0": [0, 0], "p1": [10, 0]})), + ("sketch.circle.three_point", json!({"p0": [0, 0], "p1": [10, 0], "p2": [5, 6]})), + ("sketch.arc.three_point", json!({"start": [0, 0], "end": [10, 0], "through": [5, 4]})), + ("sketch.arc.center_point", json!({"center": [0, 0], "start": [10, 0], "end": [0, 10]})), + ("sketch.polygon.inscribed", json!({"center": [0, 0], "radius": 10, "sides": 6})), + ("sketch.polygon.circumscribed", json!({"center": [0, 0], "radius": 10, "sides": 5})), + ("sketch.polygon.edge", json!({"p0": [0, 0], "p1": [10, 0], "sides": 4})), + ("sketch.slot.center_to_center", json!({"p0": [0, 0], "p1": [20, 0], "width": 6})), + ("sketch.slot.overall", json!({"p0": [0, 0], "p1": [20, 0], "width": 6})), + ("sketch.slot.center_point", json!({"center": [0, 0], "end": [10, 0], "width": 4})), + ("sketch.slot.arc_three_point", json!({"start": [0, 0], "through": [10, 5], "end": [20, 0], "width": 3})), + ("sketch.slot.arc_center", json!({"center": [0, 0], "start": [10, 0], "end": [0, 10], "width": 3})), + ("sketch.point", json!({"point": [3, 4]})), + ("sketch.line.midpoint", json!({"mid": [0, 0], "end": [10, 5]})), + ("sketch.ellipse", json!({"center": [0, 0], "major": [10, 0], "minor_radius": 4})), + ("sketch.spline.fit_point", json!({"points": [[0, 0], [5, 5], [10, 0]]})), + ("sketch.spline.fit_point", json!({"points": [[0, 0], [5, 5], [10, 0]], "periodic": true})), + ("sketch.spline.control_point", json!({"points": [[0, 0], [5, 5], [10, 0], [15, 5]]})), + ("sketch.spline.control_point_5", json!({"points": [[0, 0], [3, 5], [6, 0], [9, 5], [12, 0], [15, 5]]})), + ("sketch.conic", json!({"start": [0, 0], "end": [10, 0], "apex": [5, 6]})), + ("sketch.text", json!({"text": "IT", "at": [0, 0], "height": 10})), + ]; + for (cmd, p) in cases { + let mut s = new_sketch(); + made(&mut s, cmd, p); + } +} + +#[test] +fn drawn_ids_take_driving_dimensions_and_constraints() { + let mut s = new_sketch(); + let c = made(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]], "connect": false})); + let l = strs(&c["curves"]); + let pts = strs(&c["points"]); + assert_eq!((l.len(), pts.len()), (1, 2), "{c}"); + run(&mut s, "sketch.constraint.coincident", json!({"a": pts[0], "b": "origin"})); + run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": l[0]})); + let d = run(&mut s, "sketch.dimension", json!({"entities": [l[0]], "value": 30})); + assert_eq!(strs(&d["created"]["dimensions"]).len(), 1, "{d}"); + let sk = sketch(&s); + let end = sk.point(sk.point_index(&pts[1]).unwrap()).unwrap(); + assert!((end.x - 30.0).abs() < 1e-6 && end.y.abs() < 1e-6, "{end:?}"); + + let c = made(&mut s, "sketch.circle.center", json!({"center": [50, 0], "radius": 4})); + let circle = strs(&c["curves"]); + run(&mut s, "sketch.dimension", json!({"entities": [circle[0]], "type": "diameter", "value": 12})); + let sk = sketch(&s); + let i = sk.curve_index(&circle[0]).unwrap(); + assert!(matches!(sk.curves[i].kind, CurveKind::Circle { r, .. } if (r - 6.0).abs() < 1e-6)); +} + +#[test] +fn modify_commands_return_the_ids_they_create() { + // Corner tools: an open L of two lines. + for (cmd, extra) in [ + ("sketch.fillet", json!({"radius": 2})), + ("sketch.chamfer.equal_distance", json!({"distance": 2})), + ("sketch.chamfer.distance_angle", json!({"distance": 2, "angle": 40})), + ("sketch.chamfer.two_distance", json!({"distance": 2, "distance2": 3})), + ] { + let mut s = new_sketch(); + let c = made(&mut s, "sketch.line", json!({"points": [[0, 10], [0, 0], [10, 0]]})); + let l = strs(&c["curves"]); + let mut p = extra; + p["point"] = json!(format!("{}.end", l[0])); + let c = made(&mut s, cmd, p); + assert!(!strs(&c["curves"]).is_empty(), "{cmd}: {c}"); + } + + let mut s = new_sketch(); + let rect = strs(&made(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]}))["curves"]); + let c = made(&mut s, "sketch.offset", json!({"curves": [rect[0]], "distance": 2, "side": [10, 5]})); + assert_eq!(strs(&c["curves"]).len(), 4, "{c}"); + made(&mut s, "sketch.move", json!({"entities": [rect[0]], "translate": [0, -20], "copy": true})); + made(&mut s, "sketch.pattern.rectangular", json!({"entities": [rect[2]], "count": 3, "spacing": 30})); + let circle = strs(&made(&mut s, "sketch.circle.center", json!({"center": [100, 0], "radius": 2}))["curves"]); + made(&mut s, "sketch.pattern.circular", json!({"entities": [circle[0]], "center": [90, 0], "count": 4})); + let axis = strs(&made(&mut s, "sketch.line", json!({"points": [[200, -10], [200, 10]]}))["curves"]); + let sm = strs(&made(&mut s, "sketch.circle.center", json!({"center": [190, 0], "radius": 2}))["curves"]); + made(&mut s, "sketch.mirror", json!({"entities": [sm[0]], "line": axis[0]})); + + // Break and trim split a curve where another crosses it. + let mut s = new_sketch(); + let h = strs(&made(&mut s, "sketch.line", json!({"points": [[0, 0], [20, 0]]}))["curves"]); + made(&mut s, "sketch.line", json!({"points": [[10, -5], [10, 5]]})); + made(&mut s, "sketch.break", json!({"curve": h[0], "at": [5, 0]})); + let mut s = new_sketch(); + let h = strs(&made(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 0]]}))["curves"]); + made(&mut s, "sketch.line", json!({"points": [[10, -5], [10, 5]]})); + made(&mut s, "sketch.line", json!({"points": [[20, -5], [20, 5]]})); + made(&mut s, "sketch.trim", json!({"curve": h[0], "at": [15, 0]})); + + // Tangent tools and blends start from existing curves. + let mut s = new_sketch(); + let l = strs(&made(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]]}))["curves"]); + made(&mut s, "sketch.arc.tangent", json!({"start": format!("{}.end", l[0]), "end": [20, 10]})); + let a = strs(&made(&mut s, "sketch.line", json!({"points": [[0, 30], [10, 30]]}))["curves"]); + let b = strs(&made(&mut s, "sketch.line", json!({"points": [[20, 40], [30, 40]]}))["curves"]); + made(&mut s, "sketch.blend_curve", json!({"a": format!("{}.end", a[0]), "b": format!("{}.start", b[0])})); + let x = strs(&made(&mut s, "sketch.line", json!({"points": [[100, 0], [140, 0]], "connect": false}))["curves"]); + let y = strs(&made(&mut s, "sketch.line", json!({"points": [[100, 0], [100, 40]], "connect": false}))["curves"]); + made(&mut s, "sketch.circle.two_tangent", json!({"curves": [x[0], y[0]], "radius": 5, "near": [106, 4]})); + let z = strs(&made(&mut s, "sketch.line", json!({"points": [[100, 40], [140, 40]], "connect": false}))["curves"]); + made(&mut s, "sketch.circle.three_tangent", json!({"curves": [x[0], y[0], z[0]], "near": [120, 20]})); +} + +#[test] +fn dimensioned_drawing_reports_its_geometry_and_driving_dimensions() { + // The creator runs nested inside sketch.draw.dimensioned; `created` must still cover the + // dimensions the wrapper adds afterwards, not just the nested creator's geometry. + for (cmd, p, dims) in [ + ("sketch.line", json!({"points": [[0, 0], [5, 5]]}), json!({"length": 10, "angle": 45})), + ("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]}), json!({"width": 30, "height": 15})), + ("sketch.circle.center", json!({"center": [0, 0], "radius": 3}), json!({"diameter": 12})), + ("sketch.polygon.inscribed", json!({"center": [0, 0], "radius": 10, "sides": 6}), json!({"radius": 8})), + ] { + let mut s = new_sketch(); + let c = made(&mut s, "sketch.draw.dimensioned", json!({"command": cmd, "params": p, "dimensions": dims})); + let n = dims.as_object().map_or(0, |d| d.len()); + assert_eq!(strs(&c["dimensions"]).len(), n, "{cmd}: {c}"); + assert!(!strs(&c["curves"]).is_empty(), "{cmd}: {c}"); + } +} + +#[test] +fn breaking_an_offset_association_creates_nothing() { + let mut s = new_sketch(); + let e = strs(&made(&mut s, "sketch.ellipse", json!({"center": [0, 0], "major": [10, 0], "minor_radius": 5, "connect": false}))["curves"]); + let c = made(&mut s, "sketch.offset", json!({"curves": [e[0]], "distance": -1, "chain": false})); + let out = strs(&c["curves"]); + let r = run(&mut s, "sketch.offset.break", json!({"curve": out[0]})); + let c = &r["created"]; + for k in ["points", "curves", "constraints", "dimensions"] { + assert_eq!(c[k], json!([]), "{r}"); + } +} + +#[test] +fn text_edits_and_imports_return_the_ids_they_create() { + let mut s = new_sketch(); + let r = run(&mut s, "sketch.text", json!({"text": "I", "at": [0, 0], "height": 10})); + check(&mut s, "sketch.text", &r, &Sketch::new()); + let before = sketch(&s); + let link = r["link"].as_str().map(str::to_string).unwrap_or_else(|| panic!("{r}")); + let r = run(&mut s, "sketch.edit_text", json!({"link": link, "text": "II"})); + check(&mut s, "sketch.edit_text", &r, &before); + + let svg = r#""#; + let mut s = new_sketch(); + let c = made(&mut s, "sketch.insert_svg", json!({"text": svg})); + assert_eq!(strs(&c["curves"]).len(), 4, "{c}"); + let dxf = run(&mut s, "sketch.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string(); + let mut s = new_sketch(); + let c = made(&mut s, "sketch.insert_dxf", json!({"text": dxf})); + assert_eq!(strs(&c["curves"]).len(), 4, "{c}"); +} + +#[test] +fn projections_and_3d_curves_return_the_ids_they_create() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"corner": [0, 0, 0], "length": 20, "width": 20, "height": 10})); + let body = s.model.state().bodies.first().map(|b| b.name.clone()).unwrap_or_default(); + run(&mut s, "sketch.create", json!({"plane": "XY", "offset": 5})); + made(&mut s, "sketch.project", json!({"refs": [{"edge": [10, 0, 10]}]})); + made(&mut s, "sketch.intersect", json!({"refs": [{"body": body}]})); + made(&mut s, "sketch.include_3d", json!({"refs": [{"edge": [20, 10, 10]}]})); + made(&mut s, "sketch.fit_curves_to_section", json!({"body": body})); + made(&mut s, "sketch.isoparametric_curve", json!({"face": [10, 10, 10]})); + + let mut s = new_sketch(); + run(&mut s, "sketch.options", json!({"sketch_3d": true})); + let c = made(&mut s, "sketch.line3d", json!({"points": [[0, 0, 0], [0, 0, 10], [10, 0, 10]]})); + assert_eq!(strs(&c["wires"]).len(), 2, "{c}"); + made(&mut s, "sketch.spline3d", json!({"points": [[0, 0, 0], [5, 5, 5], [10, 0, 10]]})); + made(&mut s, "sketch.point3d", json!({"point": [5, 5, 5]})); +} + +#[test] +fn a_new_sketch_reports_its_projected_face_edges() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"corner": [0, 0, 0], "length": 20, "width": 20, "height": 10})); + let r = run(&mut s, "sketch.create", json!({"plane": {"face": [10, 10, 10]}})); + let c = check(&mut s, "sketch.create", &r, &Sketch::new()); + assert_eq!(strs(&c["curves"]).len(), 4, "{c}"); +} + +#[test] +fn ids_are_never_reused_after_a_delete() { + let mut s = new_sketch(); + let first = made(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]]})); + let circle = made(&mut s, "sketch.circle.center", json!({"center": [30, 0], "radius": 3})); + let dim = run(&mut s, "sketch.dimension", json!({"entities": [strs(&circle["curves"])[0]], "value": 8})); + let mut gone: Vec = ["points", "curves"].iter().flat_map(|k| strs(&first[k]).into_iter().chain(strs(&circle[k]))).collect(); + gone.extend(strs(&dim["created"]["dimensions"])); + // Deleting the curves takes their own end and centre points (and the dimension) with them. + run(&mut s, "sketch.delete", json!({"entities": strs(&first["curves"]).into_iter().chain(strs(&circle["curves"])).collect::>()})); + let sk = sketch(&s); + assert!(sk.curves.is_empty() && sk.points.len() == 1 && sk.constraints.is_empty(), "{:?}", sk.curves); + // The same commands again: every id is new. + let again = made(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]]})); + let circle2 = made(&mut s, "sketch.circle.center", json!({"center": [30, 0], "radius": 3})); + let dim2 = run(&mut s, "sketch.dimension", json!({"entities": [strs(&circle2["curves"])[0]], "value": 8})); + let mut fresh: Vec = ["points", "curves"].iter().flat_map(|k| strs(&again[k]).into_iter().chain(strs(&circle2[k]))).collect(); + fresh.extend(strs(&dim2["created"]["dimensions"])); + assert_eq!(fresh.len(), gone.len(), "{fresh:?} {gone:?}"); + for id in &fresh { + assert!(!gone.contains(id), "id {id} reused: {gone:?} -> {fresh:?}"); + } + // Undo of a delete brings the old ids back unchanged (they still name the same geometry). + let line = strs(&again["curves"])[0].clone(); + run(&mut s, "sketch.delete", json!({"entities": [line]})); + run(&mut s, "edit.undo", json!({})); + assert!(sketch(&s).curve_index(&line).is_some()); +} + +/// The example in docs/control-protocol.md. +#[test] +fn documented_example_holds() { + let mut s = new_sketch(); + let r = run(&mut s, "sketch.line", json!({"points": [[0, 0], [40, 0]]})); + assert_eq!(r["curves"], json!(["l1"])); + assert_eq!(r["created"], json!({"points": ["l1.start", "l1.end"], "curves": ["l1"], "constraints": ["k1"], "dimensions": []})); + run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 50})); +} + +#[test] +fn commands_that_add_nothing_report_empty_lists() { + let mut s = new_sketch(); + let c = made(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 5]]})); + let r = run(&mut s, "sketch.move_point", json!({"point": strs(&c["points"])[1], "to": [12, 6]})); + assert_eq!(r["created"], json!({"points": [], "curves": [], "constraints": [], "dimensions": []}), "{r}"); +} diff --git a/crates/engine/src/cmd/sketch_import.rs b/crates/engine/src/cmd/sketch_import.rs index 801683bf..e776852a 100644 --- a/crates/engine/src/cmd/sketch_import.rs +++ b/crates/engine/src/cmd/sketch_import.rs @@ -16,11 +16,11 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("sketch.insert_dxf", "Insert DXF", insert_dxf) .at("SKETCH", "INSERT") .icon("dxf") - .params("path | text: ASCII DXF (blocks expanded); at?: [x,y] offset, scale?, tolerance?: mm within which ends join (default 0.0001); plane?: (when no sketch is active, a new sketch on it; default XY)"), + .params(concat!("path | text: ASCII DXF (blocks expanded); at?: [x,y] offset, scale?, tolerance?: mm within which ends join (default 0.0001); plane?: (when no sketch is active, a new sketch on it; default XY)", created_doc!())), CommandSpec::new("sketch.insert_svg", "Insert SVG", insert_svg) .at("SKETCH", "INSERT") .icon("svg") - .params("path | text: SVG; at?: [x,y] offset, scale?, tolerance?: mm within which ends join (default 0.0001); plane?: (when no sketch is active, a new sketch on it; default XY)"), + .params(concat!("path | text: SVG; at?: [x,y] offset, scale?, tolerance?: mm within which ends join (default 0.0001); plane?: (when no sketch is active, a new sketch on it; default XY)", created_doc!())), CommandSpec::new("sketch.export_dxf", "Save As DXF", export_dxf) .noundo() .params("sketch?: id|name (default active); path?: file to write (else the DXF text is returned)"), diff --git a/crates/engine/src/cmd/sketch_modify.rs b/crates/engine/src/cmd/sketch_modify.rs index c9fc562d..21582431 100644 --- a/crates/engine/src/cmd/sketch_modify.rs +++ b/crates/engine/src/cmd/sketch_modify.rs @@ -20,28 +20,28 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SKETCH", "MODIFY") .icon("sketch_fillet") .enabled(in_sketch) - .params("point: corner shared by two curves (\"l1.end\") | points: [corners] (all rounded alike: one radius, the rest equal) | a, b: two lines or arcs meeting at a corner; radius: expr. A corner that dimensions or constraints measure to stays as a virtual sharp corner on both curves' extensions (`virtual_corners`; the moved ones in `moved_to_virtual_corner`)"), + .params(concat!("point: corner shared by two curves (\"l1.end\") | points: [corners] (all rounded alike: one radius, the rest equal) | a, b: two lines or arcs meeting at a corner; radius: expr. A corner that dimensions or constraints measure to stays as a virtual sharp corner on both curves' extensions (`virtual_corners`; the moved ones in `moved_to_virtual_corner`)", created_doc!())), CommandSpec::new("sketch.chamfer.equal_distance", "Equal Distance Chamfer", chamfer_equal) .at("SKETCH", "MODIFY") .icon("sketch_chamfer") .enabled(in_sketch) - .params("point | a, b: a corner of two lines; distance"), + .params(concat!("point | a, b: a corner of two lines; distance", created_doc!())), CommandSpec::new("sketch.chamfer.distance_angle", "Distance and Angle Chamfer", chamfer_dist_angle) .at("SKETCH", "MODIFY") .icon("sketch_chamfer") .enabled(in_sketch) - .params("point | a, b: a corner of two lines; distance: along a, angle: deg from a"), + .params(concat!("point | a, b: a corner of two lines; distance: along a, angle: deg from a", created_doc!())), CommandSpec::new("sketch.chamfer.two_distance", "Two Distance Chamfer", chamfer_two) .at("SKETCH", "MODIFY") .icon("sketch_chamfer") .enabled(in_sketch) - .params("point | a, b: a corner of two lines; distance: along a, distance2: along b"), + .params(concat!("point | a, b: a corner of two lines; distance: along a, distance2: along b", created_doc!())), CommandSpec::new("sketch.offset", "Offset", offset) .at("SKETCH", "MODIFY") .icon("offset") .key("O") .enabled(in_sketch) - .params("curves: [ids], distance: number (positive = left of the chain, or toward `side`), side?: [x,y], chain?: bool (default true: the connected chain)"), + .params(concat!("curves: [ids], distance: number (positive = left of the chain, or toward `side`), side?: [x,y], chain?: bool (default true: the connected chain)", created_doc!())), CommandSpec::new("sketch.offset.break", "Break Offset Association", break_offset) .enabled(in_sketch) .params("curve: derived offset curve id (retains geometry and makes its points editable)"), @@ -50,7 +50,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .icon("trim") .key("T") .enabled(in_sketch) - .params("curve: id, at: [x,y] (the piece to remove: between the nearest intersections around it)"), + .params(concat!("curve: id, at: [x,y] (the piece to remove: between the nearest intersections around it)", created_doc!())), CommandSpec::new("sketch.extend", "Extend", extend) .at("SKETCH", "MODIFY") .icon("extend") @@ -60,7 +60,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SKETCH", "MODIFY") .icon("break") .enabled(in_sketch) - .params("curve: id, at: [x,y] (split at intersections) | points: [[x,y]…] (1…1000 positions projected onto the curve; duplicates are merged)"), + .params(concat!("curve: id, at: [x,y] (split at intersections) | points: [[x,y]…] (1…1000 positions projected onto the curve; duplicates are merged)", created_doc!())), CommandSpec::new("sketch.scale", "Sketch Scale", scale) .at("SKETCH", "MODIFY") .icon("scale") @@ -68,22 +68,22 @@ pub static COMMANDS: &[CommandSpec] = &[ .params("entities: [curve/point ids], base: [x,y] or point ref, factor: number"), CommandSpec::new("sketch.move", "Move/Copy Sketch Entities", move_copy) .enabled(in_sketch) - .params("entities: [ids], translate?: [dx,dy], angle?: deg, center?: [x,y], copy?: bool"), + .params(concat!("entities: [ids], translate?: [dx,dy], angle?: deg, center?: [x,y], copy?: bool", created_doc!())), CommandSpec::new("sketch.mirror", "Mirror", mirror) .at("SKETCH", "CREATE") .icon("sketch_mirror") .enabled(in_sketch) - .params("entities: [curve/point ids], line: mirror line id (symmetry constraints are added)"), + .params(concat!("entities: [curve/point ids], line: mirror line id (symmetry constraints are added)", created_doc!())), CommandSpec::new("sketch.pattern.circular", "Circular Pattern", pattern_circular) .at("SKETCH", "CREATE") .icon("sketch_circ_pattern") .enabled(in_sketch) - .params("entities: [ids], center: [x,y] or point ref, count: 2…500, angle?: total deg (default 360)"), + .params(concat!("entities: [ids], center: [x,y] or point ref, count: 2…500, angle?: total deg (default 360)", created_doc!())), CommandSpec::new("sketch.pattern.rectangular", "Rectangular Pattern", pattern_rect) .at("SKETCH", "CREATE") .icon("sketch_rect_pattern") .enabled(in_sketch) - .params("entities: [ids], dir?: [x,y] (default x), count, spacing; count2?, spacing2? (perpendicular direction)"), + .params(concat!("entities: [ids], dir?: [x,y] (default x), count, spacing; count2?, spacing2? (perpendicular direction)", created_doc!())), ]; const TOL: f64 = 1e-7; diff --git a/crates/engine/src/cmd/sketch_project.rs b/crates/engine/src/cmd/sketch_project.rs index 05daef5f..4605b24e 100644 --- a/crates/engine/src/cmd/sketch_project.rs +++ b/crates/engine/src/cmd/sketch_project.rs @@ -13,41 +13,47 @@ use super::{CommandSpec, in_sketch}; use crate::params::{bad, bool_, str_, vec3}; use crate::{EngineError, Result, Session}; +macro_rules! refs_doc { + () => { + "refs: [{edge: [x,y,z], body?} | {face: [x,y,z], body?} (its edge loops) | {body: name} (silhouette) | {vertex: [x,y,z], body?} | {sketch, curve} | {sketch, point} | \"origin\" | {axis: X|Y|Z} | {plane: XY|XZ|YZ|name}]" + }; +} + pub static COMMANDS: &[CommandSpec] = &[ - CommandSpec::new("sketch.project", "Project", project_cmd).at("SKETCH", "CREATE").icon("project").key("P").enabled(in_sketch).params(REFS_DOC), + CommandSpec::new("sketch.project", "Project", project_cmd).at("SKETCH", "CREATE").icon("project").key("P").enabled(in_sketch).params(concat!(refs_doc!(), created_doc!())), CommandSpec::new("sketch.intersect", "Intersect", intersect_cmd).at("SKETCH", "CREATE").icon("intersect").enabled(in_sketch).params( - "refs: [{body}, {face: [x,y,z], body?}, {edge: [x,y,z], body?}, {sketch, curve}]: section curves (or points) with the sketch plane, linked", + concat!("refs: [{body}, {face: [x,y,z], body?}, {edge: [x,y,z], body?}, {sketch, curve}]: section curves (or points) with the sketch plane, linked", created_doc!()), ), CommandSpec::new("sketch.include_3d", "Include 3D Geometry", include_cmd) .at("SKETCH", "CREATE") .icon("include") .enabled(in_sketch) - .params("refs: like Project (edges, vertices, sketch curves and points); curves out of the sketch plane are flattened onto it"), + .params(concat!("refs: like Project (edges, vertices, sketch curves and points); curves out of the sketch plane are flattened onto it", created_doc!())), CommandSpec::new("sketch.project_to_surface", "Project To Surface", project_to_surface) .at("SKETCH", "CREATE") .icon("project_surface") .enabled(in_sketch) - .params("curves: [{sketch, curve}…] (curves of other sketches), face: [x,y,z] point on the target face, body?: projected along the active sketch's normal onto the face, as linked 3D curves"), + .params(concat!("curves: [{sketch, curve}…] (curves of other sketches), face: [x,y,z] point on the target face, body?: projected along the active sketch's normal onto the face, as linked 3D curves", created_doc!())), CommandSpec::new("sketch.intersection_curve", "Intersection Curve", intersection_curve) .at("SKETCH", "CREATE") .icon("intersection_curve") .enabled(in_sketch) - .params("a, b: {body} | {face: [x,y,z], body?}: where they meet, as linked 3D curves"), + .params(concat!("a, b: {body} | {face: [x,y,z], body?}: where they meet, as linked 3D curves", created_doc!())), CommandSpec::new("sketch.spun_profile", "Spun Profile", spun_profile) .at("SKETCH", "CREATE") .icon("spun") .enabled(in_sketch) - .params("body: name, axis: a line of the active sketch (curve id) | X|Y|Z: the body's outline revolved about the axis, in the sketch plane"), + .params(concat!("body: name, axis: a line of the active sketch (curve id) | X|Y|Z: the body's outline revolved about the axis, in the sketch plane", created_doc!())), CommandSpec::new("sketch.isoparametric_curve", "Isoparametric Curve", iso_curve) .at("SKETCH", "CREATE") .icon("iso_curve") .enabled(in_sketch) - .params("face: [x,y,z] point on the face (the curve passes through it), body?, direction?: u|v|[x,y,z] (default u): linked 3D curve"), + .params(concat!("face: [x,y,z] point on the face (the curve passes through it), body?, direction?: u|v|[x,y,z] (default u): linked 3D curve", created_doc!())), CommandSpec::new("sketch.fit_curves_to_section", "Fit Curves to Mesh Section", fit_section) .at("SKETCH", "CREATE") .icon("fit_section") .enabled(in_sketch) - .params("body: name (a mesh or solid body): its section with the sketch plane, fitted with lines and arcs, as normal (unlinked) geometry"), + .params(concat!("body: name (a mesh or solid body): its section with the sketch plane, fitted with lines and arcs, as normal (unlinked) geometry", created_doc!())), CommandSpec::new("sketch.break_link", "Break Link", break_link) .enabled(in_sketch) .params("link?: link id (j1) | entities?: [curve or point ids]: linked geometry becomes normal sketch geometry"), @@ -56,8 +62,6 @@ pub static COMMANDS: &[CommandSpec] = &[ .params("value?: bool (default toggles): project a face's edges when a sketch is created on it"), ]; -const REFS_DOC: &str = "refs: [{edge: [x,y,z], body?} | {face: [x,y,z], body?} (its edge loops) | {body: name} (silhouette) | {vertex: [x,y,z], body?} | {sketch, curve} | {sketch, point} | \"origin\" | {axis: X|Y|Z} | {plane: XY|XZ|YZ|name}]"; - /// Parse one reference. fn source(s: &Session, v: &Value, cmd: &str) -> Result { if let Some(name) = v.as_str() { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e371eb15..1d157477 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -395,6 +395,7 @@ impl Session { let changed = !Arc::ptr_eq(&self.doc, &before.doc); match r { Ok(v) => { + let v = if changed { self.with_created(spec.id, params, &before, v) } else { v }; if changed { if spec.undoable { self.undo.push(before); @@ -419,6 +420,27 @@ impl Session { } } + /// A sketch command's result gets `created`: the ids of the points, curves, constraints and + /// dimensions it added to its sketch (the `sketch` param, else the active sketch), so every + /// drawing and modify command reports them the same way (see `cmd::sketch_created`). A command + /// that runs another one (`sketch.draw.dimensioned`) gets the whole diff, replacing the nested + /// command's narrower `created`. + fn with_created(&self, id: &str, params: &Value, before: &Snapshot, mut v: Value) -> Value { + if !id.starts_with("sketch.") { + return v; + } + let Some(out) = v.as_object_mut() else { return v }; + let named = match params.get("sketch") { + Some(Value::Number(n)) => self.doc.find_feature(&n.to_string()).map(|f| f.id), + Some(Value::String(x)) => self.doc.find_feature(x).map(|f| f.id), + _ => None, + }; + let Some(sk) = named.or(self.active_sketch).or(before.active_sketch) else { return v }; + let Ok(after) = self.doc.sketch(sk) else { return v }; + out.insert("created".into(), cmd::sketch_created(before.doc.sketch(sk).ok(), after)); + v + } + /// Remember the design, active sketch, pending positions and undo history; returns the checkpoint's id. pub fn checkpoint(&mut self) -> u64 { let id = self.next_checkpoint; diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 938e0de7..f458ed38 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -58,7 +58,10 @@ pub fn tool_definitions() -> Vec { {center: [20,15], radius: 5}; sketch.dimension {entities: [curve id], value: \"width\"}; sketch.finish {}; solid.extrude \ {distance: 20, operation: \"cut\"}; solid.fillet {edges: [[0,0,10]], radius: 3}; solid.box {length, \ width, height}. Lengths are mm; expressions may use parameters and units (\"width / 2\", \"30 deg\"). Returns \ - the command's result; failures leave the design unchanged.", + the command's result; failures leave the design unchanged. Sketch commands that add geometry return \ + created: {points, curves, constraints, dimensions}: the ids of what they added, stable and not reused \ + after a delete (undo rewinds the counters with the design), usable right away \ + in sketch.dimension, sketch.constraint.* and sketch.delete.", obj( json!({ "command": string("Command id (see list_commands)"), diff --git a/docs/book/src/guide/sketching.md b/docs/book/src/guide/sketching.md index 99fb02b7..7b4e0ce7 100644 --- a/docs/book/src/guide/sketching.md +++ b/docs/book/src/guide/sketching.md @@ -15,6 +15,11 @@ fillet, chamfer, offset (O), mirror, patterns, move/copy and scale. **Project** (P) and **Intersect** bring model edges into the sketch as linked geometry that follows later changes. Sketching on a face projects its edges automatically. +Run from a script or the control channel, every command that adds sketch geometry returns the +ids of what it added (`created: {points, curves, constraints, dimensions}`). Ids are stable and +never reused within a sketch, even after a delete, so a script can dimension or constrain the new +geometry right away. + Extend finds intersections with exact ellipses, rational conics and spline spans. It keeps the original line or circular support and extends only beyond the selected endpoint; conflicting constraints or an ambiguous overlap leave the sketch unchanged. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index fd57f2d6..a1211295 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -17,6 +17,18 @@ events, one pointer event per frame. Commands never open dialogs when run this way. `solvecraft-cli commands` prints the same registry. +Every `sketch.*` command that changes a sketch adds `created: {points, curves, constraints, +dimensions}` to its result (and `wires` for 3D curves): the ids of what it added, in creation +order, next to whatever else the command returns (`curves`, `sides`, `corners`…). Sketch ids are +stable and never reused, even after a delete (`l1`, `l2`, then `l3` once `l2` is gone; only undo +rolls the numbering back with the design), so they can go straight into later commands: + +```text +{"id":7,"method":"engine.execute","params":{"command":"sketch.line","params":{"points":[[0,0],[40,0]]}}} +→ {"curves":["l1"],"created":{"points":["l1.start","l1.end"],"curves":["l1"],"constraints":["k1"],"dimensions":[]},…} +{"id":8,"method":"engine.execute","params":{"command":"sketch.dimension","params":{"entities":["l1"],"value":50}}} +``` + ## UI | Method | Params | Notes |