Merge origin/main into feat/profiles-by-curve-ids
This commit is contained in:
commit
4c466466a2
62 files changed
+3591
-347
No files matched your search
@@ -35,8 +35,13 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.params("feature: id|name → features deleted with it, and features that would fail without it"),
|
||||
CommandSpec::new("timeline.suppress", "Suppress Feature", suppress).params("feature: id|name, suppressed?: bool (default toggles)"),
|
||||
CommandSpec::new("timeline.edit", "Edit Feature", edit_feature).params(
|
||||
"feature: id|name, set: {fields to change, e.g. {\"extent\": {\"distance\": \"30\"}}} → recomputed: features rebuilt, \
|
||||
restored: features whose earlier result for the same inputs was reused",
|
||||
"feature: id|name, set: {fields to change, merged into the feature's definition as timeline.inspect returns it, \
|
||||
e.g. {\"extent\": {\"distance\": \"30\"}} (extrude), {\"angle\": \"90 deg\"} (revolve); \
|
||||
expressions are text} → recomputed: features rebuilt, restored: features whose earlier result for the same inputs was reused",
|
||||
),
|
||||
CommandSpec::new("timeline.inspect", "Inspect Feature", inspect_feature).noundo().params(
|
||||
"feature: id|name → type, definition (the fields timeline.edit's set changes, with their current values), \
|
||||
inputs: [{label, param, expression, unit}] (named inputs parameters.change can also set), error, warning",
|
||||
),
|
||||
CommandSpec::new("select.set", "Select", select_set)
|
||||
.noundo()
|
||||
@@ -213,6 +218,52 @@ fn command_profiles(set: &Value, cmd: &str) -> Result<Value> {
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
/// Fields of a stored feature left out of its definition: bookkeeping timeline.edit recomputes, and
|
||||
/// the name and suppression, which timeline.rename and timeline.suppress change.
|
||||
const NOT_EDITABLE: &[&str] = &["id", "type", "name", "suppressed", "param_names", "edge_refs", "edge_names", "face_names"];
|
||||
|
||||
fn inspect_feature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.inspect";
|
||||
let id = feature_id(s, p.get("feature"), cmd)?;
|
||||
let f = s.doc.feature(id).ok_or_else(|| bad(cmd, "feature"))?;
|
||||
let mut def = serde_json::to_value(f).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let mut omitted = Vec::new();
|
||||
if let Some(o) = def.as_object_mut() {
|
||||
for k in NOT_EDITABLE {
|
||||
o.remove(*k);
|
||||
}
|
||||
// Large payloads that timeline.edit cannot change.
|
||||
let heavy: &[(&str, &str)] = match &f.kind {
|
||||
FeatureKind::Sketch { .. } => &[("sketch", "the sketch geometry (sketch.inspect; edit it with the sketch commands)")],
|
||||
FeatureKind::Import { .. } => &[("step", "the imported STEP text"), ("components", "the imported product tree")],
|
||||
FeatureKind::MeshImport { .. } => &[("meshes", "the imported meshes")],
|
||||
_ => &[],
|
||||
};
|
||||
for (k, why) in heavy {
|
||||
if o.remove(*k).is_some() {
|
||||
omitted.push(json!({"field": k, "what": why}));
|
||||
}
|
||||
}
|
||||
}
|
||||
let inputs: Vec<Value> = f
|
||||
.kind
|
||||
.inputs()
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, (label, e, k))| {
|
||||
json!({"label": label, "param": f.param_names.get(i).filter(|n| !n.is_empty()), "expression": e, "unit": k.default_unit()})
|
||||
})
|
||||
.collect();
|
||||
let marker = s.doc.marker.unwrap_or(usize::MAX);
|
||||
let rolled_back = s.doc.features.iter().position(|x| x.id == id).is_some_and(|i| i >= marker);
|
||||
let r = s.model.result(id);
|
||||
Ok(json!({
|
||||
"id": f.id, "name": f.name, "type": f.kind.type_name(), "suppressed": f.suppressed, "rolled_back": rolled_back,
|
||||
"definition": def, "omitted": omitted, "inputs": inputs,
|
||||
"error": r.and_then(|r| r.error.clone()), "warning": r.and_then(|r| r.warning.clone()),
|
||||
}))
|
||||
}
|
||||
|
||||
fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.edit";
|
||||
let id = feature_id(s, p.get("feature"), cmd)?;
|
||||
|
||||
@@ -104,7 +104,10 @@ fn generated_profiles_and_press_pull_convert_rotated_occurrence_picks_once() {
|
||||
// Curved coincident Cut is an existing kernel limit; it must be atomic.
|
||||
let before = s.doc.to_json();
|
||||
let error = s.execute("solid.press_pull", &json!({"face":[35,-15,15],"distance":-5})).unwrap_err();
|
||||
assert!(error.to_string().contains("coincident faces on curved bodies"), "{error}");
|
||||
let message = error.to_string();
|
||||
assert!(message.contains("takes planar faces only"), "{error}");
|
||||
assert!(message.contains("the target has 2 curved faces (cylinder)"), "{error}");
|
||||
assert!(message.contains("the tool has 2 curved faces (cylinder)"), "{error}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
check(&mut s, area * 35.0, hole, 35.0);
|
||||
35.0
|
||||
|
||||
@@ -436,6 +436,54 @@ fn coil_and_loft_to_a_point() {
|
||||
assert!(s.execute("solid.loft", &json!({"sections": [{"sketch": "Base"}, {"sketch": "Top", "point": "nope"}]})).is_err());
|
||||
}
|
||||
|
||||
/// A 3 mm shelled basin (100 x 60 x 30, open top) with a fall lofted from its whole outline
|
||||
/// 10 up down to a drain pad (`pad`: corners, 1 up), in either section order. The loft's top
|
||||
/// edges lie on the outer walls; the cut used to fail for one order of the sections (an
|
||||
/// `Option::unwrap()` panic inside the kernel, or "shell not oriented and closed").
|
||||
fn basin_fall(pad: [f64; 4], reverse: bool, op: &str) -> (Session, std::result::Result<Value, String>) {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 100, "width": 60, "height": 30, "corner": [0, 0, 0]}));
|
||||
run(&mut s, "solid.shell", json!({"faces": [[50, 30, 30]], "thickness": 3}));
|
||||
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 10], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "name": "Rim"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 1], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "name": "Pad"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [pad[0], pad[1]], "p1": [pad[2], pad[3]]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let mut secs = vec![json!({"sketch": "Rim"}), json!({"sketch": "Pad"})];
|
||||
if reverse {
|
||||
secs.reverse();
|
||||
}
|
||||
let r = s.execute("solid.loft", &json!({"sections": secs, "operation": op})).map_err(|e| e.to_string());
|
||||
(s, r)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loft_cut_through_a_shelled_basin_in_either_order() {
|
||||
// What is left after the cut: the basin less the loft's part of the floor and walls
|
||||
// (integrated over the height), the same for a pad in any corner.
|
||||
let want = 41582.028;
|
||||
for pad in [[80.0, 40.0, 90.0, 50.0], [10.0, 40.0, 20.0, 50.0], [10.0, 10.0, 20.0, 20.0], [80.0, 10.0, 90.0, 20.0]] {
|
||||
for reverse in [false, true] {
|
||||
let (mut s, r) = basin_fall(pad, reverse, "cut");
|
||||
assert!(r.is_ok(), "{pad:?} reverse {reverse}: {r:?}");
|
||||
let v = volume(&mut s);
|
||||
assert!((v - want).abs() < 1.0, "{pad:?} reverse {reverse}: {v} vs {want}");
|
||||
assert_eq!(measure(&mut s)["body_count"], 1);
|
||||
}
|
||||
}
|
||||
// As a new body and joined: a valid solid of positive volume either way.
|
||||
for op in ["new", "join"] {
|
||||
let mut vols = Vec::new();
|
||||
for reverse in [false, true] {
|
||||
let (mut s, r) = basin_fall([80.0, 40.0, 90.0, 50.0], reverse, op);
|
||||
assert!(r.is_ok(), "{op} reverse {reverse}: {r:?}");
|
||||
vols.push(volume(&mut s));
|
||||
}
|
||||
assert!(vols[0] > 42948.0 && rel(vols[0], vols[1]) < 1e-6, "{op}: {vols:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_auto_inference_saves_a_profile_anchor_and_replays_both_depth_signs() {
|
||||
use solvecraft_geom::Vec3;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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::<Vec<_>>();
|
||||
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<String>, Vec<String>), 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;
|
||||
@@ -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"),
|
||||
|
||||
@@ -96,3 +96,90 @@ fn fillet_and_chamfer_corners() {
|
||||
assert!(solved(&s));
|
||||
assert!((area(&s) - (a0 - 8.0)).abs() < 1e-6, "{}", area(&s));
|
||||
}
|
||||
|
||||
/// The ids of the constraints driven by the model parameter `name`.
|
||||
fn driven_by(s: &Session, name: &str) -> Vec<String> {
|
||||
sk(s).constraints.iter().filter(|c| c.param.as_deref() == Some(name)).map(|c| c.id.clone()).collect()
|
||||
}
|
||||
|
||||
/// The spout of a basin: a vertical riser and a horizontal top, the top's length dimensioned
|
||||
/// to the corner and the corner's height dimensioned from the origin. Filleting the corner used
|
||||
/// to drop the height and leave the length on the shortened straight part, so the top came out
|
||||
/// a fillet radius too long. Like Fusion, the corner now stays as a virtual sharp corner (a point
|
||||
/// on both lines' extensions) and the dimensions measure to it.
|
||||
#[test]
|
||||
fn a_fillet_keeps_the_dimensions_of_its_corner_on_a_virtual_sharp() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], [0, 100], [-80, 100]], "ids": ["riser", "top"]}));
|
||||
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "riser", "mode": "vertical"}));
|
||||
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "top", "mode": "horizontal"}));
|
||||
let height = run(&mut s, "sketch.dimension", json!({"entities": ["origin", "riser.end"], "type": "vertical", "value": 100}));
|
||||
let height = height["param"].as_str().unwrap().to_string();
|
||||
let len = run(&mut s, "sketch.dimension", json!({"entities": ["top"], "value": 80}));
|
||||
let len = len["param"].as_str().unwrap().to_string();
|
||||
let (height_ids, len_ids) = (driven_by(&s, &height), driven_by(&s, &len));
|
||||
let f = run(&mut s, "sketch.fillet", json!({"point": "riser.end", "radius": 20}));
|
||||
assert!(solved(&s), "{f}");
|
||||
// Both dimensions are kept (same ids) and reported as moved to the virtual corner.
|
||||
assert_eq!(driven_by(&s, &height), height_ids);
|
||||
assert_eq!(driven_by(&s, &len), len_ids);
|
||||
let corner = f["virtual_corners"][0].as_str().unwrap_or_default().to_string();
|
||||
assert!(!corner.is_empty(), "{f}");
|
||||
let moved: Vec<String> = f["moved_to_virtual_corner"].as_array().unwrap().iter().filter_map(|v| v.as_str().map(str::to_string)).collect();
|
||||
for id in height_ids.iter().chain(&len_ids) {
|
||||
assert!(moved.contains(id), "{id}: {f}");
|
||||
}
|
||||
// The virtual corner sits where the corner was, and the part keeps its size.
|
||||
let k = sk(&s);
|
||||
let vc = k.point(k.point_index(&corner).unwrap()).unwrap();
|
||||
assert!(vc.dist(solvecraft_geom::Vec2::new(0.0, 100.0)) < 1e-6, "{vc:?}");
|
||||
let top_end = k.point(k.resolve_point("top.end").unwrap()).unwrap();
|
||||
assert!(top_end.dist(solvecraft_geom::Vec2::new(-80.0, 100.0)) < 1e-6, "{top_end:?}");
|
||||
let (lo, hi) = k.bounds().unwrap();
|
||||
assert!((hi.x - lo.x - 80.0).abs() < 1e-6 && (hi.y - lo.y - 100.0).abs() < 1e-6, "{lo:?} {hi:?}");
|
||||
// It is drawn with extension lines from both lines' ends to it.
|
||||
let vcs = k.virtual_corners();
|
||||
assert_eq!(vcs.len(), 1, "{vcs:?}");
|
||||
assert_eq!(vcs[0].1.len(), 2, "{vcs:?}");
|
||||
assert!(vcs[0].1.iter().all(|(a, b)| a.dist(vc) < 1e-9 && (b.dist(vc) - 20.0).abs() < 1e-6), "{vcs:?}");
|
||||
// The dimensions still drive the part, measured to the virtual corner.
|
||||
run(&mut s, "parameters.change", json!({"name": len, "expression": "90 mm"}));
|
||||
run(&mut s, "parameters.change", json!({"name": height, "expression": "120 mm"}));
|
||||
assert!(solved(&s));
|
||||
let (lo, hi) = sk(&s).bounds().unwrap();
|
||||
assert!((hi.x - lo.x - 90.0).abs() < 1e-6 && (hi.y - lo.y - 120.0).abs() < 1e-6, "{lo:?} {hi:?}");
|
||||
// It is kept in the design file, and undo and redo take it away and bring it back.
|
||||
let id = s.active_sketch.unwrap();
|
||||
let doc = solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||
assert_eq!(doc.sketch(id).unwrap(), s.doc.sketch(id).unwrap());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(sk(&s).point_index(&corner).is_none());
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert!(sk(&s).point_index(&corner).is_some());
|
||||
// A fillet without anything measuring to its corner leaves no virtual corner behind.
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points": [[10, 10], [10, 100], [-80, 100]], "ids": ["riser", "top"]}));
|
||||
let n = sk(&s).points.len();
|
||||
let f = run(&mut s, "sketch.fillet", json!({"point": "riser.end", "radius": 20}));
|
||||
assert_eq!(f["virtual_corners"], json!([]), "{f}");
|
||||
// Corner gone, two tangent points and the arc's centre added.
|
||||
assert_eq!(sk(&s).points.len(), n + 2);
|
||||
}
|
||||
|
||||
/// Lines meeting almost straight or almost folded back: a fillet or chamfer with dimensions on
|
||||
/// the corner either works or refuses with an error, never a panic.
|
||||
#[test]
|
||||
fn a_fillet_of_a_nearly_straight_or_folded_corner_with_dimensions_does_not_panic() {
|
||||
for (x, y) in [(100.0, 0.05), (100.0, -0.05), (100.0, 1e-9), (-100.0, 0.05), (-100.0, 1e-9)] {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], [100, 0], [100.0 + x, y]], "ids": ["a", "b"]}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["a"], "value": 100}));
|
||||
for r in [0.01, 1.0, 50.0] {
|
||||
let _ = s.execute("sketch.fillet", &json!({"point": "a.end", "radius": r}));
|
||||
let _ = s.execute("sketch.chamfer.equal_distance", &json!({"point": "a.end", "distance": r}));
|
||||
}
|
||||
assert!(sk(&s).points.iter().all(|p| p.pos.is_finite()));
|
||||
}
|
||||
}
|
||||
@@ -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<LinkSource> {
|
||||
let (t0, at0, h0, a0) = old.unwrap_or((String::new(), Vec2::ZERO, 5.0, 0.0));
|
||||
|
||||
@@ -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<String> {
|
||||
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#"<svg width="40mm" height="40mm" viewBox="0 0 40 40"><path d="M0 0 H40 V40 H0 Z"/></svg>"#;
|
||||
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<String> = ["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::<Vec<_>>()}));
|
||||
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<String> = ["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}");
|
||||
}
|
||||
@@ -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)"),
|
||||
|
||||
@@ -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"),
|
||||
.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;
|
||||
@@ -505,8 +505,71 @@ fn reattach(sk: &mut Sketch, l: usize, corner: usize, np: usize) {
|
||||
}
|
||||
}
|
||||
|
||||
/// What became of a rounded or cut corner's point: kept as a virtual sharp corner (its id),
|
||||
/// and the constraints and dimensions that now measure to it.
|
||||
#[derive(Default)]
|
||||
struct CornerKept {
|
||||
virtual_corner: Option<String>,
|
||||
moved: Vec<String>,
|
||||
}
|
||||
|
||||
/// Keep corner point `c` of curves `ca`, `cb` (whose corner ends were just moved to `na`, `nb`)
|
||||
/// when anything measures to it: a constraint or dimension on the point, the length of either
|
||||
/// line, or it is the origin or still ends another curve. It stays as a virtual sharp corner,
|
||||
/// held on both curves' extensions (their intersection), and the line lengths become distances
|
||||
/// from the far end to it, so the part keeps its size, as in Fusion. Otherwise None: the
|
||||
/// caller removes the point.
|
||||
fn keep_corner(sk: &mut Sketch, c: usize, [(ca, na), (cb, nb)]: [(usize, usize); 2]) -> Result<Option<CornerKept>> {
|
||||
let used = sk.curves.iter().any(|x| uses(x, c));
|
||||
let mut moved: Vec<String> = sk.constraints.iter().filter(|k| k.kind.refers_to_point(c)).map(|k| k.id.clone()).collect();
|
||||
for k in sk.constraints.iter_mut() {
|
||||
let ConstraintKind::Length { l, value } = k.kind else { continue };
|
||||
let new_end = if l == ca {
|
||||
na
|
||||
} else if l == cb {
|
||||
nb
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
let Some(CurveKind::Line { a, b }) = sk.curves.get(l).map(|x| &x.kind) else { continue };
|
||||
// Same direction as the line, so the dimension keeps its side and text place.
|
||||
k.kind = if *a == new_end { ConstraintKind::Distance { p: c, q: *b, value } } else { ConstraintKind::Distance { p: *a, q: c, value } };
|
||||
moved.push(k.id.clone());
|
||||
}
|
||||
if moved.is_empty() && !used && c != 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
for cu in [ca, cb] {
|
||||
let k = ConstraintKind::PointOnCurve { p: c, c: cu };
|
||||
if !sk.constraints.iter().any(|x| x.kind == k) {
|
||||
add_c(sk, k)?;
|
||||
}
|
||||
}
|
||||
// A point of its own now: `l1.end` names the line's new end, not the virtual corner.
|
||||
let virtual_corner = if used || c == 0 {
|
||||
None
|
||||
} else {
|
||||
let id = sk.fresh("corner");
|
||||
if let Some(pt) = sk.points.get_mut(c) {
|
||||
pt.id = id.clone();
|
||||
}
|
||||
Some(id)
|
||||
};
|
||||
Ok(Some(CornerKept { virtual_corner, moved }))
|
||||
}
|
||||
|
||||
/// Points at distances `da` along a and `db` along b from the corner; lines shortened to them.
|
||||
fn cut_corner(sk: &mut Sketch, la: usize, lb: usize, c: usize, fa: usize, fb: usize, da: f64, db: f64, cmd: &str) -> Result<(usize, usize, Vec2)> {
|
||||
fn cut_corner(
|
||||
sk: &mut Sketch,
|
||||
la: usize,
|
||||
lb: usize,
|
||||
c: usize,
|
||||
fa: usize,
|
||||
fb: usize,
|
||||
da: f64,
|
||||
db: f64,
|
||||
cmd: &str,
|
||||
) -> Result<(usize, usize, Vec2, CornerKept)> {
|
||||
let (pc, pa, pb) = (sk.point(c).unwrap_or_default(), sk.point(fa).unwrap_or_default(), sk.point(fb).unwrap_or_default());
|
||||
if !(da > 1e-9 && db > 1e-9 && da < pc.dist(pa) - 1e-9 && db < pc.dist(pb) - 1e-9) {
|
||||
return Err(bad(cmd, "too large for these lines"));
|
||||
@@ -517,13 +580,14 @@ fn cut_corner(sk: &mut Sketch, la: usize, lb: usize, c: usize, fa: usize, fb: us
|
||||
let tb = sk.add_point(pc + v * db, None)?;
|
||||
reattach(sk, la, c, ta);
|
||||
reattach(sk, lb, c, tb);
|
||||
let used = sk.curves.iter().any(|x| uses(x, c));
|
||||
if !used && c != 0 {
|
||||
let kept = keep_corner(sk, c, [(la, ta), (lb, tb)])?;
|
||||
let removed = kept.is_none();
|
||||
if removed {
|
||||
sk.remove_points(&[c]);
|
||||
}
|
||||
// Indices may have shifted when the corner point was removed.
|
||||
let fix = |i: usize| if !used && c != 0 && i > c { i - 1 } else { i };
|
||||
Ok((fix(ta), fix(tb), pc))
|
||||
// Indices shift when the corner point was removed.
|
||||
let fix = |i: usize| if removed && i > c { i - 1 } else { i };
|
||||
Ok((fix(ta), fix(tb), pc, kept.unwrap_or_default()))
|
||||
}
|
||||
|
||||
fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -541,9 +605,10 @@ fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, "radius must be positive"));
|
||||
}
|
||||
let pname = doc.new_model_param(&r_expr, "mm");
|
||||
let (mut arcs, mut cons) = (Vec::new(), Vec::new());
|
||||
let (mut arcs, mut cons, mut kept) = (Vec::new(), Vec::new(), Vec::new());
|
||||
for q in &corners {
|
||||
let (arc, mut c) = fillet_corner(sk, q, r, cmd)?;
|
||||
let (arc, mut c, k) = fillet_corner(sk, q, r, cmd)?;
|
||||
kept.push(k);
|
||||
// One radius for them all: the first carries the dimension, the rest are equal to it.
|
||||
match arcs.first() {
|
||||
None => c.push(sk.add_constraint(ConstraintKind::Radius { c: arc, value: r }, Some(pname.clone()))?),
|
||||
@@ -552,14 +617,27 @@ fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
cons.extend(c);
|
||||
arcs.push(arc);
|
||||
}
|
||||
Ok((ids_of(sk, &arcs), cons, pname))
|
||||
Ok((ids_of(sk, &arcs), cons, pname, kept_json(kept)))
|
||||
})?;
|
||||
Ok(json!({"curves": out.0, "constraints": out.1, "param": out.2, "sketch": info}))
|
||||
let (vc, moved) = out.3;
|
||||
Ok(json!({"curves": out.0, "constraints": out.1, "param": out.2, "virtual_corners": vc, "moved_to_virtual_corner": moved, "sketch": info}))
|
||||
}
|
||||
|
||||
/// The virtual corners kept and the constraints moved to them, for a response.
|
||||
fn kept_json(kept: Vec<CornerKept>) -> (Vec<String>, Vec<String>) {
|
||||
let vcs = kept.iter().filter_map(|k| k.virtual_corner.clone()).collect();
|
||||
let mut moved: Vec<String> = Vec::new();
|
||||
for id in kept.into_iter().flat_map(|k| k.moved) {
|
||||
if !moved.contains(&id) {
|
||||
moved.push(id);
|
||||
}
|
||||
}
|
||||
(vcs, moved)
|
||||
}
|
||||
|
||||
/// Round one corner (`point` or `a`, `b`) with an arc of radius `r` tangent to both curves:
|
||||
/// the arc and the tangent constraints.
|
||||
fn fillet_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<(usize, Vec<String>)> {
|
||||
/// the arc, the tangent constraints and what became of the corner point.
|
||||
fn fillet_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<(usize, Vec<String>, CornerKept)> {
|
||||
let (la, lb, c, fa, fb) = match corner(sk, p, cmd) {
|
||||
Ok(x) => x,
|
||||
// Not two lines: a corner with an arc.
|
||||
@@ -578,7 +656,7 @@ fn fillet_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<(usize
|
||||
}
|
||||
let bis = (u + v).normalized().ok_or_else(|| bad(cmd, "the lines are parallel"))?;
|
||||
let center = pc + bis * (r / half.sin());
|
||||
let (ta, tb, _) = cut_corner(sk, la, lb, c, fa, fb, d, d, cmd)?;
|
||||
let (ta, tb, _, kept) = cut_corner(sk, la, lb, c, fa, fb, d, d, cmd)?;
|
||||
let (qa, qb) = (sk.point(ta).unwrap_or_default(), sk.point(tb).unwrap_or_default());
|
||||
let ccw = (qa - center).cross(qb - center) > 0.0;
|
||||
let arc = if ccw {
|
||||
@@ -587,13 +665,13 @@ fn fillet_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<(usize
|
||||
sk.add_arc(center, qb, qa, [None, Some(tb), Some(ta)], None)?
|
||||
};
|
||||
let cons = vec![add_c(sk, ConstraintKind::Tangent { a: la, b: arc })?, add_c(sk, ConstraintKind::Tangent { a: lb, b: arc })?];
|
||||
Ok((arc, cons))
|
||||
Ok((arc, cons, kept))
|
||||
}
|
||||
|
||||
/// Fillet a corner where a line meets an arc, or two arcs meet: the arc of radius `r` touching
|
||||
/// both, nearest the corner; both curves are cut back to it. None when the corner is not of
|
||||
/// that kind.
|
||||
fn round_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<Option<(usize, Vec<String>)>> {
|
||||
fn round_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<Option<(usize, Vec<String>, CornerKept)>> {
|
||||
let ends = |c: usize| match sk.curves.get(c).map(|c| &c.kind) {
|
||||
Some(CurveKind::Line { a, b }) | Some(CurveKind::Arc { a, b, .. }) => Some((*a, *b)),
|
||||
_ => None,
|
||||
@@ -655,14 +733,15 @@ fn round_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<Option<
|
||||
let arc =
|
||||
if ccw { sk.add_arc(cen, ta, tb, [None, Some(na), Some(nb)], None)? } else { sk.add_arc(cen, tb, ta, [None, Some(nb), Some(na)], None)? };
|
||||
let cons = vec![add_c(sk, ConstraintKind::Tangent { a: ca, b: arc })?, add_c(sk, ConstraintKind::Tangent { a: cb, b: arc })?];
|
||||
let used = sk.curves.iter().any(|x| x.kind.uses(q));
|
||||
if !used && q != 0 {
|
||||
let arc_id = sk.curves.get(arc).map(|c| c.id.clone()).unwrap_or_default();
|
||||
sk.remove_points(&[q]);
|
||||
let arc = sk.curve_index(&arc_id).unwrap_or(arc);
|
||||
return Ok(Some((arc, cons)));
|
||||
match keep_corner(sk, q, [(ca, na), (cb, nb)])? {
|
||||
Some(kept) => Ok(Some((arc, cons, kept))),
|
||||
None => {
|
||||
let arc_id = sk.curves.get(arc).map(|c| c.id.clone()).unwrap_or_default();
|
||||
sk.remove_points(&[q]);
|
||||
let arc = sk.curve_index(&arc_id).unwrap_or(arc);
|
||||
Ok(Some((arc, cons, CornerKept::default())))
|
||||
}
|
||||
}
|
||||
Ok(Some((arc, cons)))
|
||||
}
|
||||
|
||||
fn chamfer(s: &mut Session, p: &Value, cmd: &str, dists: impl FnOnce(f64) -> Result<(f64, f64)>) -> Result<Value> {
|
||||
@@ -672,11 +751,12 @@ fn chamfer(s: &mut Session, p: &Value, cmd: &str, dists: impl FnOnce(f64) -> Res
|
||||
let (u, v) = ((pa - pc).normalized().unwrap_or(Vec2::X), (pb - pc).normalized().unwrap_or(Vec2::Y));
|
||||
let corner_angle = u.cross(v).atan2(u.dot(v)).abs();
|
||||
let (da, db) = dists(corner_angle)?;
|
||||
let (ta, tb, _) = cut_corner(sk, la, lb, c, fa, fb, da, db, cmd)?;
|
||||
let (ta, tb, _, kept) = cut_corner(sk, la, lb, c, fa, fb, da, db, cmd)?;
|
||||
let l = sk.add_line_pts(ta, tb, None)?;
|
||||
Ok(ids_of(sk, &[l]))
|
||||
Ok((ids_of(sk, &[l]), kept_json(vec![kept])))
|
||||
})?;
|
||||
Ok(json!({"curves": out, "sketch": info}))
|
||||
let (curves, (vc, moved)) = out;
|
||||
Ok(json!({"curves": curves, "virtual_corners": vc, "moved_to_virtual_corner": moved, "sketch": info}))
|
||||
}
|
||||
|
||||
fn chamfer_equal(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -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<LinkSource> {
|
||||
if let Some(name) = v.as_str() {
|
||||
|
||||
Reference in new issue
Block a user