Merge origin/main (with #86): sketch.dimension reports the solved value in the dimension's unit; the spout test expects the solved 30 and 60 degrees

This commit is contained in:
CypherNaugh_0x committed 2026-10-11 00:13:51 +02:00
commit ade30e9c4e
107 files changed
+4098 -378

No files matched your search

+3 -1
View File
@@ -495,7 +495,9 @@ mod tests {
assert!((zmax(&mut s) - 5.0).abs() < 1e-6);
run(&mut s, "sketch.redefine", json!({"sketch": sk, "plane": "XZ"}));
let z = zmax(&mut s);
assert!(z > 9.0, "the profile stands up on XZ: {z}");
assert!(z.abs() < 1e-6, "positive sketch Y on XZ extends toward -Z: {z}");
let ymin = run(&mut s, "inspect.measure", json!({}))["bodies"][0]["bbox"]["min"][2].as_f64().unwrap();
assert!((ymin + 20.0).abs() < 1e-6);
assert!(s.execute("sketch.redefine", &json!({"sketch": sk, "plane": "nope"})).is_err());
assert!(s.execute("sketch.redefine", &json!({"sketch": 999, "plane": "XY"})).is_err());
}
+27 -4
View File
@@ -10,6 +10,12 @@ use crate::{EngineError, Result, Sel, Session, Snapshot};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("edit.undo", "Undo", undo).icon("undo").key("Ctrl+Z").noundo(),
CommandSpec::new("edit.redo", "Redo", redo).icon("redo").key("Ctrl+Y").noundo(),
CommandSpec::new("edit.checkpoint", "Checkpoint", checkpoint)
.noundo()
.params("→ {checkpoint: id}: remember the design, active sketch and undo history (restore with edit.restore_checkpoint)"),
CommandSpec::new("edit.restore_checkpoint", "Restore Checkpoint", restore_checkpoint)
.noundo()
.params("checkpoint: id; forget?: bool (true = drop it without going back). Goes back to it, undo history included"),
CommandSpec::new("timeline.compute_all", "Compute All", compute_all).at("SOLID", "MODIFY").icon("compute").noundo(),
CommandSpec::new("timeline.delete", "Delete", delete)
.at("SOLID", "MODIFY")
@@ -28,8 +34,10 @@ pub static COMMANDS: &[CommandSpec] = &[
.noundo()
.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\"}}}"),
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",
),
CommandSpec::new("select.set", "Select", select_set)
.noundo()
.params("items: [{type: body|edge|face|feature|sketch_curve|sketch_point|profile, …}], add?: bool"),
@@ -58,6 +66,21 @@ fn redo(s: &mut Session, _p: &Value) -> Result<Value> {
Ok(json!({"redone": label}))
}
fn checkpoint(s: &mut Session, _p: &Value) -> Result<Value> {
Ok(json!({"checkpoint": s.checkpoint()}))
}
fn restore_checkpoint(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "edit.restore_checkpoint";
let id = p.get("checkpoint").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "`checkpoint` must be an id from edit.checkpoint"))?;
if bool_(p, "forget") == Some(true) {
s.drop_checkpoint(id);
return Ok(json!({"checkpoint": id, "restored": false}));
}
s.restore_checkpoint(id)?;
Ok(json!({"checkpoint": id, "restored": true}))
}
fn compute_all(s: &mut Session, _p: &Value) -> Result<Value> {
s.model = solvecraft_doc::Model::new();
s.refresh();
@@ -204,7 +227,7 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) {
return Err(EngineError::Other(e));
}
Ok(json!({"feature": id, "recomputed": s.model.last_recomputed}))
Ok(json!({"feature": id, "recomputed": s.model.last_recomputed, "restored": s.model.last_restored}))
}
fn select_set(s: &mut Session, p: &Value) -> Result<Value> {
@@ -307,7 +330,7 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) {
return Err(EngineError::Other(e));
}
Ok(json!({"feature": id, "recomputed": s.model.last_recomputed}))
Ok(json!({"feature": id, "recomputed": s.model.last_recomputed, "restored": s.model.last_restored}))
}
fn dependents(s: &mut Session, p: &Value) -> Result<Value> {
+2 -2
View File
@@ -13,7 +13,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "CREATE")
.icon("extrude")
.key("E")
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [[curve ids]] | [{point:[x,y]}]; face?: [x,y,z] (extrude a planar body face instead of a sketch profile); direction?: positive|negative|symmetric; distance2?; start_offset?; to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, join out of one, else new); targets?: [body]; name?; body_name?"),
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [[curve ids]] | [{point:[x,y]}]; face?: [x,y,z] (extrude a planar body face instead of a sketch profile); direction?: positive|negative|symmetric; distance2?; start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, join out of one, else new); targets?: [body]; name?; body_name?"),
CommandSpec::new("solid.revolve", "Revolve", revolve)
.at("SOLID", "CREATE")
.icon("revolve")
@@ -581,7 +581,7 @@ fn press_pull(s: &mut Session, p: &Value) -> Result<Value> {
let new = format!("({old}) + ({distance})");
let r = super::find_command("timeline.edit").ok_or_else(|| bad(cmd, "timeline.edit"))?;
let out = (r.run)(s, &json!({"feature": f.id, "set": {key: new}}))?;
return Ok(json!({"feature": f.id, "edited": f.name, key: new, "recomputed": out.get("recomputed")}));
return Ok(json!({"feature": f.id, "edited": f.name, key: new, "recomputed": out.get("recomputed"), "restored": out.get("restored")}));
}
// Another curved face moves along its normal.
let r = super::find_command("solid.offset_face").ok_or_else(|| bad(cmd, "solid.offset_face"))?;
+49 -10
View File
@@ -55,13 +55,13 @@ fn densify(pts: &[Vec3]) -> Vec<Vec3> {
/// The mesh edge a selection means: by index if it still passes through the point, otherwise
/// the nearest.
fn edge_points(m: &Mesh, index: usize, point: Vec3) -> Option<Vec<Vec3>> {
fn edge_index(m: &Mesh, index: usize, point: Vec3) -> Option<usize> {
let near = |e: &Vec<Vec3>| e.windows(2).map(|w| point.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min);
let tol = m.bounds().diagonal().max(1.0) * 1e-6;
if let Some(e) = m.edges.get(index).filter(|e| near(e) < tol) {
return Some(e.clone());
if m.edges.get(index).is_some_and(|e| near(e) < tol) {
return Some(index);
}
m.edges.iter().min_by(|a, b| near(a).total_cmp(&near(b))).cloned()
m.edges.iter().enumerate().min_by(|(_, a), (_, b)| near(a).total_cmp(&near(b))).map(|(i, _)| i)
}
fn face_tris(m: &Mesh, f: usize) -> Vec<[Vec3; 3]> {
@@ -75,15 +75,23 @@ fn describe(s: &Session, sel: &Sel) -> Option<(Geo, Option<Vec3>, Value)> {
Sel::Vertex { point, .. } => (Geo::Points(vec![*point]), None, json!({"type": "vertex", "position": point})),
Sel::Edge { body, index, point } => {
let m = st.body(body)?.mesh();
let pts = edge_points(&m, *index, *point)?;
let index = edge_index(&m, *index, *point)?;
let pts = m.edges.get(index)?;
let rim = m.circular_rim(index);
if rim.len() > 1 {
let pieces: Vec<&Vec<Vec3>> = rim.iter().filter_map(|&i| m.edges.get(i)).collect();
let len: f64 = pieces.iter().map(|p| polyline_len(p)).sum();
let points = pieces.iter().flat_map(|p| densify(p)).collect();
return Some((Geo::Points(points), None, json!({"type": "edge", "length_mm": len})));
}
let (a, b) = (*pts.first()?, *pts.last()?);
let straight = (b - a).normalized().filter(|d| pts.iter().all(|p| (*p - a).cross(*d).len() < 1e-6 * (1.0 + a.dist(b))));
let len = polyline_len(&pts);
(Geo::Points(densify(&pts)), straight, json!({"type": "edge", "length_mm": len}))
let len = polyline_len(pts);
(Geo::Points(densify(pts)), straight, json!({"type": "edge", "length_mm": len}))
}
Sel::Face { body, index, .. } => {
let m = st.body(body)?.mesh();
let tris = face_tris(&m, *index);
let tris = m.surface_patch(*index).into_iter().flat_map(|f| face_tris(&m, f)).collect::<Vec<_>>();
let area: f64 = tris.iter().map(|[a, b, c]| (*b - *a).cross(*c - *a).len() * 0.5).sum();
let normals: Vec<Vec3> = tris.iter().filter_map(|[a, b, c]| (*b - *a).cross(*c - *a).normalized()).collect();
let planar = normals.first().copied().filter(|n0| normals.iter().all(|n| n.dot(*n0) > 1.0 - 1e-6));
@@ -150,10 +158,41 @@ fn closest(a: &Geo, b: &Geo) -> Option<(Vec3, Vec3)> {
pub(crate) fn measure_items(s: &Session, items: &Value) -> Result<Value> {
let cmd = "inspect.measure";
let list = items.as_array().ok_or_else(|| bad(cmd, "`items` must be a list of selections"))?;
if list.is_empty() || list.len() > 2 {
if list.is_empty() || list.len() > 1000 {
return Err(bad(cmd, "measure one or two items"));
}
let sels: Vec<Sel> = list.iter().map(|v| serde_json::from_value::<Sel>(v.clone()).map_err(|e| bad(cmd, e.to_string()))).collect::<Result<_>>()?;
let st = s.world_state();
let mut sels = Vec::new();
for value in list {
let mut sel = serde_json::from_value::<Sel>(value.clone()).map_err(|e| bad(cmd, e.to_string()))?;
// Two kernel halves of one visible item are one measurement target.
match &mut sel {
Sel::Edge { body, index, point } => {
if let Some(b) = st.body(body) {
let m = b.mesh();
let resolved = edge_index(&m, *index, *point).unwrap_or(*index);
*index = m.circular_rim(resolved).first().copied().unwrap_or(resolved);
}
}
Sel::Face { body, index, .. } => {
if let Some(b) = st.body(body) {
*index = b.mesh().surface_patch(*index).first().copied().unwrap_or(*index);
}
}
_ => {}
}
let duplicate = sels.iter().any(|x| match (x, &sel) {
(Sel::Edge { body: a, index: i, .. }, Sel::Edge { body: b, index: j, .. })
| (Sel::Face { body: a, index: i, .. }, Sel::Face { body: b, index: j, .. }) => a == b && i == j,
_ => false,
});
if !duplicate {
sels.push(sel);
}
if sels.len() > 2 {
return Err(bad(cmd, "measure one or two items"));
}
}
let mut described = Vec::new();
for x in &sels {
described.push(describe(s, x).ok_or_else(|| bad(cmd, "an item can't be measured (pick vertices, edges, faces or bodies)"))?);
+3 -1
View File
@@ -12,7 +12,8 @@ use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("parameters.change", "Change Parameters", change_param).at("SOLID", "MODIFY").icon("params").params(
"name, expression (number or text), unit?: mm|cm|m|in|ft|deg|rad|\"\" (new parameters default to their value's unit), comment?, \
favorite?: bool, new_name?: rename (references follow); or delete: name",
favorite?: bool, new_name?: rename (references follow); or delete: name → recomputed: features rebuilt, \
restored: features whose earlier result for the same inputs was reused (e.g. a value set back)",
),
CommandSpec::new("parameters.list", "List Parameters", list).noundo().params(
"favorites?: bool (only favourites), filter?: all|user|sketch|feature|favorites → parameters (name, expression, unit, value in its unit, \
@@ -107,6 +108,7 @@ fn change_param(s: &mut Session, p: &Value) -> Result<Value> {
out["value"] = json!(vals.get(&name).map(|v| v.v));
out["display_value"] = json!(s.doc.param_display_value(&vals, &name));
out["recomputed"] = json!(s.model.last_recomputed);
out["restored"] = json!(s.model.last_restored);
out["errors"] = json!(failing(s));
Ok(out)
}
+19
View File
@@ -67,6 +67,25 @@ fn width_follows_an_expression() {
assert!(e.contains("expected a length, got an angle"), "{e}");
}
/// Setting a parameter back rebuilds nothing (the earlier results are reused) and says so: the
/// features it restored are reported next to `recomputed`, so `recomputed: 0` is not mistaken
/// for "nothing changed". timeline.edit reports the same.
#[test]
fn setting_a_parameter_back_reports_restored_features() {
let mut s = Session::default();
block(&mut s);
let r = run(&mut s, "parameters.change", json!({"name": "len", "expression": "100 mm"}));
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(2), Some(0)), "{r}");
let r = run(&mut s, "parameters.change", json!({"name": "len", "expression": "60 mm"}));
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(0), Some(2)), "{r}");
assert!(rel(volume(&mut s), 40.0 * 30.0 * 10.0) < 1e-9);
let r = run(&mut s, "timeline.edit", json!({"feature": "Extrude1", "set": {"extent": {"distance": "20"}}}));
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(1), Some(0)), "{r}");
let r = run(&mut s, "timeline.edit", json!({"feature": "Extrude1", "set": {"extent": {"distance": "10"}}}));
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(0), Some(1)), "{r}");
assert!(rel(volume(&mut s), 40.0 * 30.0 * 10.0) < 1e-9);
}
#[test]
fn feature_inputs_are_named_and_editable() {
let mut s = Session::default();
+1 -1
View File
@@ -231,7 +231,7 @@ fn cyl(s: &mut Session, d: f64, z0: f64, z1: f64, op: &str) -> Result<()> {
/// A solid of revolution about z from a closed (r, z) outline, as one body or joined.
fn revolve(s: &mut Session, pts: &[(f64, f64)], op: &str) -> Result<()> {
run(s, "sketch.create", json!({"plane": "XZ"}))?;
let points: Vec<[f64; 2]> = pts.iter().map(|(r, z)| [*r, *z]).collect();
let points: Vec<[f64; 2]> = pts.iter().map(|(r, z)| [*r, -*z]).collect();
run(s, "sketch.line", json!({"points": points, "closed": true}))?;
run(s, "sketch.finish", json!({}))?;
run(s, "solid.revolve", json!({"axis": "y", "operation": op}))?;
+1 -1
View File
@@ -10,7 +10,7 @@ use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] =
&[CommandSpec::new("inspect.section", "Section Analysis", section).at("SOLID", "INSPECT").icon("section").noundo().params(
"plane: XY|XZ|YZ | {origin, normal}; offset?: expr along the plane's normal (XY: +Z, XZ: -Y, YZ: +X); \
"plane: XY|XZ|YZ | {origin, normal}; offset?: expr along the plane's normal (XY: +Z, XZ: +Y, YZ: +X); \
at?: expr, the cut's world coordinate on the named plane's axis (XY: Z, XZ: Y, YZ: X), instead of offset; \
flip?: bool; or clear: true. Returns the cut's origin and normal, and for a named plane its axis and at",
)];
+39 -14
View File
@@ -1,7 +1,7 @@
//! Sketch commands: create/edit/finish sketches, draw curves, constraints and dimensions.
use serde_json::{Value, json};
use solvecraft_doc::{FeatureKind, PlaneRef};
use solvecraft_doc::{FeatureKind, PlaneRef, expr::Kind};
use solvecraft_geom::{Plane, Vec2, Vec3};
use solvecraft_sketch::{ConstraintKind, CurveKind, Hold, Sketch, SolveReport, solve, solve_holding};
@@ -94,7 +94,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.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!())),
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)",
"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)",
),
CommandSpec::new("sketch.constraint.horizontal_vertical", "Horizontal/Vertical", c_horizontal_vertical)
.at("SKETCH", "CONSTRAINTS")
@@ -359,8 +359,10 @@ pub(super) fn plane_ref(s: &Session, p: &Value, cmd: &str) -> Result<PlaneRef> {
}
Some(v @ Value::Object(o)) => {
if let Some(fp) = o.get("face").and_then(vec3) {
// A planar face of a body at this point.
let st = s.model.state();
// A planar face of a body at this point, as the active component sees the model
// (another component's face where it is placed).
let model = s.model.state();
let st = solvecraft_doc::state_in_frame(&s.doc, &model, s.active_component);
let mut found = None;
let mut face_name = None;
for b in &st.bodies {
@@ -406,7 +408,10 @@ fn face_contains(m: &solvecraft_geom::Mesh, fi: usize, p: Vec3, tol: f64) -> boo
let s1 = (b - a).cross(p - a).dot(n);
let s2 = (c - b).cross(p - b).dot(n);
let s3 = (a - c).cross(p - c).dot(n);
(s1 >= 0.0 && s2 >= 0.0 && s3 >= 0.0) || (s1 <= 0.0 && s2 <= 0.0 && s3 <= 0.0)
(s1 >= 0.0 && s2 >= 0.0 && s3 >= 0.0)
|| (s1 <= 0.0 && s2 <= 0.0 && s3 <= 0.0)
// Exact curved boundaries can sit just outside their tessellated chords.
|| p.dist_to_segment(a, b).min(p.dist_to_segment(b, c)).min(p.dist_to_segment(c, a)) <= tol * 0.1
})
}
@@ -1332,7 +1337,9 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
};
let is_line = |c: usize| matches!(sk.curves.get(c).map(|c| &c.kind), Some(CurveKind::Line { .. }));
let is_circle = |c: usize| matches!(sk.curves.get(c).map(|c| &c.kind), Some(CurveKind::Circle { .. }));
let (k, cur): (ConstraintKind, f64) = match (e0, e1) {
// The other reading of an angle between two lines (see below).
let mut alt: Option<(ConstraintKind, f64)> = None;
let (mut k, mut cur): (ConstraintKind, f64) = match (e0, e1) {
((None, Some(c)), None) if is_line(c) => {
let (a, b) = line_pts(sk, c).unwrap_or_default();
match ty.as_str() {
@@ -1409,7 +1416,14 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
ang = -ang;
std::mem::swap(&mut ka, &mut kb);
}
(ConstraintKind::Angle { a: ka, b: kb, value: ang, flip: false }, ang)
// Two lines make two angles, θ and 180° − θ, whichever way they were
// drawn: the acute one unless the value asks for the other.
let direct = (ConstraintKind::Angle { a: ka, b: kb, value: ang, flip: false }, ang);
let supplement =
(ConstraintKind::Angle { a: kb, b: ka, value: std::f64::consts::PI - ang, flip: true }, std::f64::consts::PI - ang);
let (acute, obtuse) = if ang <= std::f64::consts::FRAC_PI_2 { (direct, supplement) } else { (supplement, direct) };
alt = Some(obtuse);
acute
}
}
}
@@ -1426,7 +1440,7 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
c.driven = true;
}
before = None;
return Ok((String::new(), name, cur));
return Ok((String::new(), name, measured(is_angle, cur)));
}
let (unit, default_expr) =
if is_angle { ("deg", format!("{} deg", round6(cur.to_degrees()))) } else { ("mm", format!("{} mm", round6(cur))) };
@@ -1437,28 +1451,39 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(err) = errs.get(&pname) {
return Err(bad(cmd, format!("value: {err}")));
}
let _ = vals;
// An angle between two lines dimensions whichever of its two readings is nearer the value.
if let (Some((ka, ca)), Some(want)) = (alt, vals.get(&pname).and_then(|v| v.to_kind(Kind::Angle).ok()))
&& (want - ca).abs() < (want - cur).abs()
{
(k, cur) = (ka, ca);
}
let name = k.name();
let id = sk.add_constraint(k, Some(pname.clone()))?;
place_text(sk, &id, text_at);
Ok((pname, name, cur))
Ok((pname, name, measured(is_angle, cur)))
})?;
// Report what the dimension measures once the sketch is solved, not the geometry before.
let current = solved_measure(s, p, cmd).unwrap_or(current);
if driven {
return Ok(json!({"param": Value::Null, "driven": true, "type": kind_name, "measured": current, "sketch": info}));
return Ok(json!({"param": Value::Null, "driven": true, "type": kind_name, "measured": current.0, "unit": current.1, "sketch": info}));
}
if reject_redundant(before, &info, cmd).is_err() {
return Err(bad(cmd, "that dimension would over-constrain the sketch; add it as a driven (reference) dimension with driven: true"));
}
let v = s.doc.param(&param).map(|p| p.expr.clone()).unwrap_or_default();
Ok(json!({"param": param, "type": kind_name, "expression": v, "measured": current, "sketch": info}))
Ok(json!({"param": param, "type": kind_name, "expression": v, "measured": current.0, "unit": current.1, "sketch": info}))
}
/// A dimension's measured value as reported, in the unit its expression is written in.
fn measured(is_angle: bool, v: f64) -> (f64, &'static str) {
if is_angle { (round6(v.to_degrees()), "deg") } else { (round6(v), "mm") }
}
/// What the dimension just added (the sketch's last constraint) measures on the solved sketch.
fn solved_measure(s: &Session, p: &Value, cmd: &str) -> Option<f64> {
fn solved_measure(s: &Session, p: &Value, cmd: &str) -> Option<(f64, &'static str)> {
let sk = s.doc.sketch(target_sketch(s, p, cmd).ok()?).ok()?;
solvecraft_sketch::measure_dimension(sk, &sk.constraints.last()?.kind)
let k = &sk.constraints.last()?.kind;
Some(measured(k.is_angle(), solvecraft_sketch::measure_dimension(sk, k)?))
}
/// Keep a dimension's text where it was placed (`at`, sketch coordinates), relative to the
+3
View File
@@ -57,6 +57,8 @@ fn make(s: &mut Session, p: &Value, cmd: &str, decal: bool) -> Result<Value> {
return Err(bad(cmd, "`width` must be positive"));
}
let opacity = num(p, "opacity").unwrap_or(if decal { 1.0 } else { 0.5 }).clamp(0.0, 1.0);
// It belongs to the active component (its plane is in that frame) and moves with it.
let component = s.active_component;
let doc = s.doc_mut();
let id = doc.canvases.iter().map(|c| c.id).max().unwrap_or(0) + 1;
let base = if decal { "Decal" } else { "Canvas" };
@@ -74,6 +76,7 @@ fn make(s: &mut Session, p: &Value, cmd: &str, decal: bool) -> Result<Value> {
opacity,
flip: bool_(p, "flip").unwrap_or(false),
visible: true,
component,
});
Ok(json!({"canvas": id, "name": name, "pixels": pixels}))
}
@@ -221,3 +221,65 @@ fn drawing_onto_a_curve_keeps_the_point_on_it() {
let cc = sk.center(sk.curve_index(&c).unwrap()).unwrap();
assert!((st.dist(cc) - 8.0).abs() < 1e-7, "{st:?} {cc:?}");
}
/// A faucet spout path: a vertical riser, a horizontal top and an outlet sloping down, joined
/// by fillets. The riser points up and the outlet down-left, so their directions are 150° apart
/// while the lines meet at 30°.
fn spout() -> Session {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[795, 955], [795, 1068], [689, 1068], [661, 1020]], "ids": ["l1", "l2", "l3"]}));
run(&mut s, "sketch.fillet", json!({"point": "l1.end", "radius": 50}));
run(&mut s, "sketch.fillet", json!({"point": "l2.end", "radius": 35}));
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l1", "mode": "vertical"}));
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l2", "mode": "horizontal"}));
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "l1.start"], "type": "horizontal", "value": 795}));
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "l1.start"], "type": "vertical", "value": 955}));
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "l2.start"], "type": "vertical", "value": 1068}));
run(&mut s, "sketch.dimension", json!({"entities": ["l3.end", "l1.start"], "type": "horizontal", "value": 134}));
let d = run(&mut s, "sketch.dimension", json!({"entities": ["origin", "l3.end"], "type": "vertical", "value": 1020}));
assert_eq!(d["sketch"]["dof"], 1, "{d}");
s
}
/// The angle (degrees, 0..=90) between two lines of the active sketch.
fn line_angle(s: &Session, a: &str, b: &str) -> f64 {
let sk = sketch(s);
let dir = |l: &str| {
let c = sk.curve_index(l).unwrap();
let (p, q) = crate::cmd::sketch::line_pts(&sk, c).unwrap();
q - p
};
let (u, v) = (dir(a), dir(b));
let t = u.cross(v).abs().atan2(u.dot(v)).to_degrees();
t.min(180.0 - t)
}
#[test]
fn an_angle_between_lines_takes_the_reading_nearest_its_value_and_reports_degrees() {
// The riser and the outlet are not adjacent and meet at 30°; their directions are 150°
// apart. Asking for 30° used to swing the outlet round and was refused as over-constraining.
for ents in [["l1", "l3"], ["l3", "l1"]] {
let mut s = spout();
let d = run(&mut s, "sketch.dimension", json!({"entities": ents, "type": "angle", "value": "30 deg"}));
assert_eq!(d["sketch"]["dof"], 0, "{d}");
assert_eq!(d["unit"], "deg", "{d}");
assert!((d["measured"].as_f64().unwrap() - 30.0).abs() < 1e-6, "{d}");
assert!((line_angle(&s, "l1", "l3") - 30.0).abs() < 1e-6);
}
// The other reading still works, and the adjacent top line measures 60° the same way.
let mut s = spout();
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l1", "l3"], "value": "150 deg"}));
assert_eq!(d["sketch"]["dof"], 0, "{d}");
assert!((line_angle(&s, "l1", "l3") - 30.0).abs() < 1e-6);
let mut s = spout();
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l2", "l3"], "value": "60 deg"}));
assert!((d["measured"].as_f64().unwrap() - 60.0).abs() < 1e-6, "{d}");
// Without a value the acute angle is kept as it is.
let mut s = spout();
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l1", "l3"]}));
let e = d["expression"].as_str().unwrap();
assert!(e.ends_with(" deg") && (e.trim_end_matches(" deg").parse::<f64>().unwrap() - 30.3).abs() < 0.1, "{d}");
// Lengths report millimetres.
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "driven": true}));
assert_eq!(d["unit"], "mm", "{d}");
}
+14 -2
View File
@@ -520,7 +520,7 @@ fn fillet_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<(usize
let u = (pa - pc).normalized().ok_or_else(|| bad(cmd, "degenerate line"))?;
let v = (pb - pc).normalized().ok_or_else(|| bad(cmd, "degenerate line"))?;
let half = 0.5 * u.cross(v).atan2(u.dot(v)).abs();
if half < 1e-6 || half > std::f64::consts::FRAC_PI_2 - 1e-9 {
if !(1e-6..=std::f64::consts::FRAC_PI_2 - 1e-9).contains(&half) {
return Err(bad(cmd, "the lines are parallel"));
}
let d = r / half.tan();
@@ -813,6 +813,12 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
}
let ci = sk.add_circle(cc, nr, Some(c), None)?;
made.push(ci);
sk.offsets.push(solvecraft_sketch::OffsetChain {
sources: vec![(ids[0].clone(), true)],
outputs: ids_of(sk, &made),
distance: r - nr,
closed: true,
});
return Ok((ids_of(sk, &made), cons));
}
let pool: Vec<usize> = if use_chain {
@@ -916,7 +922,13 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
}
}
}
Ok((ids_of(sk, &made), cons))
let outputs = ids_of(sk, &made);
if pieces.iter().all(|p| !matches!(p, Piece::Free(_))) {
let sources = order.iter().filter_map(|(i, forward)| sk.curves.get(*i).map(|c| (c.id.clone(), *forward))).collect();
sk.offsets.push(solvecraft_sketch::OffsetChain { sources, outputs: outputs.clone(), distance: d, closed });
sk.refresh_offsets()?;
}
Ok((outputs, cons))
})?;
Ok(json!({"curves": out.0, "constraints": out.1, "sketch": info}))
}
+13 -1
View File
@@ -140,8 +140,20 @@ pub(super) fn add_link(s: &Session, doc: &Document, id: u64, sk: &mut Sketch, ki
let plane = sketch_plane(doc, id)?;
let (vals, _) = doc.param_values();
let st = s.model.state_before(id);
// The model as the sketch's component sees it.
let comp = doc.feature(id).map(|f| f.component).unwrap_or(0);
let st = solvecraft_doc::state_in_frame(doc, &st, comp);
let r = resolve(doc, &vals, &st, &plane, kind, &src)?;
Ok(sk.add_link_with_wires(kind, r.source, &r.geom, &r.wires)?)
let name = if let LinkSource::Face { at, .. } = &r.source {
solvecraft_doc::naming::face_names_at(&st, &[*at]).into_iter().next().filter(|n| !n.is_empty())
} else {
None
};
let link = sk.add_link_with_wires(kind, r.source, &r.geom, &r.wires)?;
if let Some(l) = sk.links.iter_mut().find(|l| l.id == link) {
l.face_name = name;
}
Ok(link)
}
fn link_json(sk: &Sketch, link: &str) -> Value {