Command ids are SolveCraft's own: 173 ids that were another product's internal command ids become dotted, lower-case ids (<area>.<thing>[.<variant>]: solid.extrude, sketch.rectangle.two_point, sheet.flange, joint.drive, ...), in the registry, toolbar, dialogs, shortcuts, palette, control channel, MCP, examples, scenarios and docs

The old ids live only in crates/engine/src/legacy_ids.rs (the legacy migration table): find_command maps them, so old scripts, recipes and control calls keep working, and saved shortcuts, recent and pinned commands are migrated on load (designs store no command ids). Parity maps the observed catalog ids to ours through xtask/data/fusion-ids.tsv. The agent rule now reads: our own dotted id, never another product's. Cargo.toml's repository points at github.com/bherbruck/solvecraft.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-09 12:17:23 +00:00
1 parent 740360d7c3
commit d10312b7fd
158 files changed
+3611 -3205

No files matched your search

+18 -18
View File
@@ -385,10 +385,10 @@ mod tests {
#[test]
fn renamed_body_keeps_its_users() {
let mut s = Session::default();
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0]}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0]}));
let [a, b] = [names(&s)[0].clone(), names(&s)[1].clone()];
run(&mut s, "FusionMoveCommand", json!({"bodies": [b.clone()], "translate": [0, 0, 5]}));
run(&mut s, "solid.move", json!({"bodies": [b.clone()], "translate": [0, 0, 5]}));
run(&mut s, "body.rename", json!({"body": b, "name": "Lid"}));
assert_eq!(names(&s), vec![a.clone(), "Lid".to_string()]);
assert!(s.model.results.iter().all(|r| r.error.is_none()), "the move still finds its body");
@@ -396,18 +396,18 @@ mod tests {
assert!(s.execute("body.rename", &json!({"body": "Lid", "name": a})).is_err());
assert!(s.execute("body.rename", &json!({"body": "Nope", "name": "X"})).is_err());
assert!(s.execute("body.rename", &json!({"body": "Lid", "name": " "})).is_err());
run(&mut s, "UndoCommand", json!({}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(names(&s)[1], b);
}
#[test]
fn redefined_sketch_moves_its_extrude() {
let mut s = Session::default();
let sk = run(&mut s, "SketchCreate", json!({"plane": "XY"}))["sketch"].as_u64().unwrap();
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [10, 20]}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"distance": 5}));
let zmax = |s: &mut Session| run(s, "MeasureCommand", json!({}))["bodies"][0]["bbox"]["max"][2].as_f64().unwrap();
let sk = run(&mut s, "sketch.create", json!({"plane": "XY"}))["sketch"].as_u64().unwrap();
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 20]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 5}));
let zmax = |s: &mut Session| run(s, "inspect.measure", json!({}))["bodies"][0]["bbox"]["max"][2].as_f64().unwrap();
assert!((zmax(&mut s) - 5.0).abs() < 1e-6);
run(&mut s, "sketch.redefine", json!({"sketch": sk, "plane": "XZ"}));
let z = zmax(&mut s);
@@ -419,8 +419,8 @@ mod tests {
#[test]
fn design_units_change_without_moving_geometry() {
let mut s = Session::default();
run(&mut s, "PrimitiveBox", json!({"length": 20, "width": 10, "height": 5}));
let vol = |s: &mut Session| run(s, "MeasureCommand", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
run(&mut s, "solid.box", json!({"length": 20, "width": 10, "height": 5}));
let vol = |s: &mut Session| run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
let v0 = vol(&mut s);
run(&mut s, "document.units", json!({"units": "in"}));
assert_eq!(s.doc.units, "in");
@@ -430,14 +430,14 @@ mod tests {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "w", "expression": "5", "unit": ""}));
run(&mut s, "parameters.add", json!({"name": "n", "expression": "3", "unit": ""}));
run(&mut s, "PrimitiveBox", json!({"length": "w", "width": 10, "height": 5}));
run(&mut s, "solid.box", json!({"length": "w", "width": 10, "height": 5}));
let b = s.model.state().bodies[0].name.clone();
run(&mut s, "PatternRectangular", json!({"bodies": [b], "dir1": [1, 0, 0], "count1": "n", "spacing1": 20}));
run(&mut s, "solid.pattern.rectangular", json!({"bodies": [b], "dir1": [1, 0, 0], "count1": "n", "spacing1": 20}));
let v0 = vol(&mut s);
let r = run(&mut s, "document.units", json!({"units": "cm"}));
assert!((vol(&mut s) - v0).abs() < 1e-6, "{r}");
assert!(s.doc.param("n").is_some_and(|p| p.expr == "3"), "the count stays bare");
run(&mut s, "UndoCommand", json!({}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.units, "mm");
}
@@ -457,15 +457,15 @@ mod tests {
for p in [json!({}), json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": -1}}), json!({"camera": "x"})] {
assert!(s.execute("view.save", &p).is_err(), "{p}");
}
run(&mut s, "UndoCommand", json!({}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.named_views.len(), 2);
}
#[test]
fn groups_hold_items_and_follow_renames() {
let mut s = Session::default();
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0]}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0]}));
let [a, b] = [names(&s)[0].clone(), names(&s)[1].clone()];
let g = run(&mut s, "browser.group", json!({"folder": "bodies", "items": [a.clone()]}))["group"].as_u64().unwrap();
assert_eq!(s.doc.browser_groups[0].name, "Group1");
@@ -495,7 +495,7 @@ mod tests {
] {
assert!(s.execute(c, &p).is_err(), "{c} {p}");
}
run(&mut s, "UndoCommand", json!({}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.browser_groups.len(), 1, "ungroup undoes");
}
}
+12 -12
View File
@@ -122,32 +122,32 @@ mod tests {
}
fn volume(s: &mut Session) -> f64 {
run(s, "MeasureCommand", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
}
#[test]
fn copy_paste_sketch_and_extrude() {
let mut s = Session::default();
run(&mut s, "ChangeParameterCommand", json!({"name": "w", "expression": "10 mm"}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "S"}));
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [10, 10]}));
run(&mut s, "ConstraintCoincident", json!({"a": "p1", "b": "origin"}));
run(&mut s, "SketchDimension", json!({"entities": ["l1"], "value": "w"}));
run(&mut s, "SketchDimension", json!({"entities": ["l2"], "value": 10}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"sketch": "S", "distance": 5}));
run(&mut s, "parameters.change", json!({"name": "w", "expression": "10 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]}));
run(&mut s, "sketch.constraint.coincident", json!({"a": "p1", "b": "origin"}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": "w"}));
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 10}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "S", "distance": 5}));
assert!((volume(&mut s) - 500.0).abs() < 1e-6);
run(&mut s, "timeline.copy", json!({"features": ["S", "Extrude1"]}));
let r = run(&mut s, "timeline.paste", json!({"translate": [30, 0, 0]}));
assert_eq!(r["errors"].as_array().map(Vec::len), Some(0), "{r}");
assert_eq!(run(&mut s, "MeasureCommand", json!({}))["body_count"], 2);
assert_eq!(run(&mut s, "inspect.measure", json!({}))["body_count"], 2);
assert!((volume(&mut s) - 1000.0).abs() < 1e-6);
// The copy's dimensions are its own, still driven by the user parameter.
run(&mut s, "ChangeParameterCommand", json!({"name": "w", "expression": "20 mm"}));
run(&mut s, "parameters.change", json!({"name": "w", "expression": "20 mm"}));
assert!((volume(&mut s) - 2000.0).abs() < 1e-6);
let pasted_extrude = r["pasted"][1]["name"].as_str().unwrap_or_default().to_string();
let d = s.doc.find_feature(&pasted_extrude).and_then(|f| f.param_names.first().cloned()).unwrap_or_default();
run(&mut s, "ChangeParameterCommand", json!({"name": d, "expression": "10"}));
run(&mut s, "parameters.change", json!({"name": d, "expression": "10"}));
assert!((volume(&mut s) - 3000.0).abs() < 1e-6, "only the copy got thicker");
assert!(s.execute("timeline.paste", &json!({"translate": "x"})).is_err());
let mut t = Session::default();
+6 -6
View File
@@ -12,11 +12,11 @@ use crate::params::{bad, bool_, num, str_, string_list, vec3};
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("FusionCreateNewComponentCommand", "New Component", new_component)
CommandSpec::new("component.create", "New Component", new_component)
.at("SOLID", "ASSEMBLE")
.icon("component")
.params("name?, parent?: component id or name (default: the active one); activate?: bool (default true)"),
CommandSpec::new("FusionCreateComponentsFromBodiesCommand", "Create Components from Bodies", from_bodies)
CommandSpec::new("component.from_bodies", "Create Components from Bodies", from_bodies)
.icon("component")
.params("bodies: [names] → one new component per body, named after it"),
CommandSpec::new("component.activate", "Activate Component", activate).noundo().params("component: id or name (0 or \"root\" for the design)"),
@@ -31,8 +31,8 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("occurrence.move", "Move Occurrence", move_occurrence)
.params("occurrence; translate?: [x,y,z]; axis?: [x,y,z], angle?: radians or \"30 deg\", origin?: [x,y,z]; capture?: bool (default true; false keeps it pending until Capture Position)"),
CommandSpec::new("occurrence.ground", "Ground", ground).params("occurrence; grounded?: bool (default toggles)"),
CommandSpec::new("SnapshotCmd", "Capture Position", capture).at("SOLID", "POSITION").icon("capture").params("keeps pending occurrence moves"),
CommandSpec::new("AsBuiltPositionsCmd", "Revert Position", revert).at("SOLID", "POSITION").icon("revert").noundo().params("drops pending occurrence moves"),
CommandSpec::new("component.capture_position", "Capture Position", capture).at("SOLID", "POSITION").icon("capture").params("keeps pending occurrence moves"),
CommandSpec::new("component.revert_position", "Revert Position", revert).at("SOLID", "POSITION").icon("revert").noundo().params("drops pending occurrence moves"),
];
fn key(v: Option<&Value>) -> Option<String> {
@@ -58,7 +58,7 @@ fn translation(t: Vec3) -> Mat {
}
fn new_component(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionCreateNewComponentCommand";
let cmd = "component.create";
let parent = if p.get("parent").is_some() { component(s, p, "parent", cmd)? } else { s.active_component };
let id = s.doc_mut().add_component(str_(p, "name"), parent)?;
if p.get("activate").and_then(Value::as_bool).unwrap_or(true) {
@@ -70,7 +70,7 @@ fn new_component(s: &mut Session, p: &Value) -> Result<Value> {
}
fn from_bodies(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionCreateComponentsFromBodiesCommand";
let cmd = "component.from_bodies";
let bodies = string_list(p, "bodies");
if bodies.is_empty() || bodies.len() > 1000 {
return Err(bad(cmd, "`bodies` must list 1…1000 body names"));
+14 -14
View File
@@ -9,10 +9,10 @@ use crate::params::{bad, bool_, str_, string_list};
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("FusionStartDesignConfigModeCmd", "Configure", configure).at("SOLID", "CONFIGURE").icon("params").params(
CommandSpec::new("config.configure", "Configure", configure).at("SOLID", "CONFIGURE").icon("params").params(
"params?: [parameter names]; features?: [feature names] (columns to add; with no rows yet, a \"Default\" row takes the current values)",
),
CommandSpec::new("FusionShowDesignConfigPanelCmd", "Display Configuration Table", show)
CommandSpec::new("config.table", "Display Configuration Table", show)
.at("SOLID", "CONFIGURE")
.icon("params")
.noundo()
@@ -54,7 +54,7 @@ fn add_column(s: &mut Session, c: Column, cmd: &str) -> Result<()> {
}
fn configure(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionStartDesignConfigModeCmd";
let cmd = "config.configure";
for n in string_list(p, "params") {
let c = column_of(s, &json!({ "param": n }), cmd)?;
add_column(s, c, cmd)?;
@@ -196,17 +196,17 @@ mod tests {
}
fn volume(s: &mut Session) -> f64 {
run(s, "MeasureCommand", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
}
#[test]
fn configurations_switch_sizes_and_suppressions() {
let mut s = Session::default();
run(&mut s, "ChangeParameterCommand", json!({"name": "len", "expression": "40 mm"}));
run(&mut s, "PrimitiveBox", json!({"length": "len", "width": 20, "height": 10}));
run(&mut s, "FusionFilletEdgesCommand", json!({"edges": [[0, 0, 5]], "radius": 3}));
run(&mut s, "parameters.change", json!({"name": "len", "expression": "40 mm"}));
run(&mut s, "solid.box", json!({"length": "len", "width": 20, "height": 10}));
run(&mut s, "solid.fillet", json!({"edges": [[0, 0, 5]], "radius": 3}));
let fillet = s.doc.features.last().map(|f| f.name.clone()).unwrap_or_default();
let t = run(&mut s, "FusionStartDesignConfigModeCmd", json!({"params": ["len"], "features": [fillet]}));
let t = run(&mut s, "config.configure", json!({"params": ["len"], "features": [fillet]}));
assert_eq!(t["rows"][0]["name"], "Default");
run(&mut s, "config.row", json!({"name": "Long", "values": {"len": "80 mm"}}));
run(&mut s, "config.row", json!({"name": "Sharp", "from": "Long", "values": {fillet.clone(): true}}));
@@ -216,17 +216,17 @@ mod tests {
assert!((volume(&mut s) - (16000.0 - (9.0 - std::f64::consts::PI * 9.0 / 4.0) * 10.0)).abs() < 0.5, "{}", volume(&mut s));
run(&mut s, "config.activate", json!({"row": "Sharp"}));
assert!((volume(&mut s) - 16000.0).abs() < 1e-6);
let t = run(&mut s, "FusionShowDesignConfigPanelCmd", json!({}));
let t = run(&mut s, "config.table", json!({}));
assert_eq!((t["active"].as_str(), t["matches"].as_bool()), (Some("Sharp"), Some(true)), "{t}");
// Undo goes back to the previous configuration's values.
run(&mut s, "UndoCommand", json!({}));
run(&mut s, "edit.undo", json!({}));
assert!(volume(&mut s) > 15000.0 && volume(&mut s) < 16000.0);
// Renaming the parameter keeps the column; editing it by hand breaks the match.
run(&mut s, "parameters.rename", json!({"name": "len", "new_name": "length"}));
let t = run(&mut s, "FusionShowDesignConfigPanelCmd", json!({}));
let t = run(&mut s, "config.table", json!({}));
assert_eq!(t["columns"][0], "param:length", "{t}");
run(&mut s, "ChangeParameterCommand", json!({"name": "length", "expression": "50 mm"}));
assert_eq!(run(&mut s, "FusionShowDesignConfigPanelCmd", json!({}))["matches"], false);
run(&mut s, "parameters.change", json!({"name": "length", "expression": "50 mm"}));
assert_eq!(run(&mut s, "config.table", json!({}))["matches"], false);
// Bad input.
assert!(s.execute("config.row", &json!({"name": "X", "values": {"nope": 1}})).is_err());
assert!(s.execute("config.activate", &json!({"row": "Missing"})).is_err());
@@ -234,6 +234,6 @@ mod tests {
assert!(s.execute("config.activate", &json!({"row": "Broken"})).is_err());
run(&mut s, "config.row", json!({"name": "Broken", "delete": true}));
run(&mut s, "config.column", json!({"feature": fillet, "delete": true}));
assert_eq!(run(&mut s, "FusionShowDesignConfigPanelCmd", json!({}))["columns"].as_array().map(Vec::len), Some(1));
assert_eq!(run(&mut s, "config.table", json!({}))["columns"].as_array().map(Vec::len), Some(1));
}
}
+13 -13
View File
@@ -95,7 +95,7 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
}
if !features.is_empty() {
let ids: Vec<String> = features.iter().map(u64::to_string).collect();
sub(s, "FusionDeleteCommand", json!({ "features": ids }))?;
sub(s, "timeline.delete", json!({ "features": ids }))?;
done = true;
}
// Bodies still there (not made by a feature deleted above) get a Remove feature.
@@ -103,7 +103,7 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
bodies.retain(|b| st.body(b).is_some());
bodies.dedup();
if !bodies.is_empty() {
sub(s, "SoftDeleteCommand", json!({ "bodies": bodies }))?;
sub(s, "solid.remove", json!({ "bodies": bodies }))?;
done = true;
}
// An occurrence goes alone while its component has others; the last one takes the component.
@@ -174,12 +174,12 @@ mod tests {
/// A sketch with a rectangle extruded into a body, a second box, and an offset plane.
fn part() -> (Session, u64) {
let mut s = Session::default();
let sk = run(&mut s, "SketchCreate", json!({"plane": "XY"}))["sketch"].as_u64().unwrap();
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [10, 20]}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"distance": 5}));
run(&mut s, "PrimitiveBox", json!({"length": 5, "width": 5, "height": 5, "corner": [50, 0, 0]}));
run(&mut s, "ConstructionPlaneOffsetFromPlaneCommand", json!({"base": "XY", "offset": 10}));
let sk = run(&mut s, "sketch.create", json!({"plane": "XY"}))["sketch"].as_u64().unwrap();
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 20]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 5}));
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [50, 0, 0]}));
run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": 10}));
(s, sk)
}
@@ -192,7 +192,7 @@ mod tests {
assert!(dry["deleted"].as_array().unwrap().len() >= 2, "the extrude goes with its sketch: {dry}");
run(&mut s, "selection.delete", json!({"items": [{"type": "feature", "id": sk}]}));
assert_eq!(bodies(&s), 1);
run(&mut s, "UndoCommand", json!({}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.features.len(), n);
assert_eq!(bodies(&s), 2);
}
@@ -212,7 +212,7 @@ mod tests {
assert!(s.doc.find_feature("Plane1").is_none());
assert_eq!(r["skipped"][0], "the origin planes can't be deleted");
assert_eq!(s.undo.len(), depth + 1);
run(&mut s, "UndoCommand", json!({}));
run(&mut s, "edit.undo", json!({}));
assert_eq!((bodies(&s), s.doc.features.len()), (2, n));
}
@@ -237,7 +237,7 @@ mod tests {
fn occurrences_components_and_sketch_entities() {
let (mut s, _) = part();
let b = s.model.state().bodies[1].name.clone();
run(&mut s, "FusionCreateComponentsFromBodiesCommand", json!({"bodies": [b]}));
run(&mut s, "component.from_bodies", json!({"bodies": [b]}));
let c = s.doc.components[0].id;
let o1 = s.doc.occurrence_of(c).unwrap().id;
run(&mut s, "occurrence.copy", json!({"component": c, "translate": [0, 30, 0]}));
@@ -249,8 +249,8 @@ mod tests {
run(&mut s, "selection.delete", json!({"occurrences": [o2]}));
assert!(s.doc.components.is_empty());
// Sketch entities while sketching.
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [10, 0]]}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]]}));
let sk = s.active_sketch.unwrap();
let id = s.model.state().sketch(sk).unwrap().sketch.curves[0].id.clone();
run(&mut s, "selection.delete", json!({"items": [{"type": "sketch_curve", "id": id}]}));
+10 -10
View File
@@ -8,17 +8,17 @@ use crate::params::{bad, bool_, num, str_, string_list};
use crate::{EngineError, Result, Sel, Session, Snapshot};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("UndoCommand", "Undo", undo).icon("undo").key("Ctrl+Z").noundo(),
CommandSpec::new("RedoCommand", "Redo", redo).icon("redo").key("Ctrl+Y").noundo(),
CommandSpec::new("FusionComputeAllCommand", "Compute All", compute_all).at("SOLID", "MODIFY").icon("compute").noundo(),
CommandSpec::new("FusionDeleteCommand", "Delete", delete)
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("timeline.compute_all", "Compute All", compute_all).at("SOLID", "MODIFY").icon("compute").noundo(),
CommandSpec::new("timeline.delete", "Delete", delete)
.at("SOLID", "MODIFY")
.icon("delete")
.key("Delete")
.params("features: [id or name] (a sketch takes its dependent features with it)"),
CommandSpec::new("FusionRenameTimelineEntryCommand", "Rename", rename).params("feature: id|name, name"),
CommandSpec::new("timeline.rename", "Rename", rename).params("feature: id|name, name"),
CommandSpec::new("timeline.rollback", "Move Timeline Marker", rollback).params("position: number of features to keep active (omit = end)"),
CommandSpec::new("timeline.rollTo", "Roll History Marker", rollback)
CommandSpec::new("timeline.roll_to", "Roll History Marker", rollback)
.params("position: number of features to keep active (omit = end) | feature: id|name (the marker goes right after it)"),
CommandSpec::new("timeline.reorder", "Reorder Feature", reorder)
.params("feature: id|name, position: its new index; refused when a feature that worked would fail"),
@@ -75,7 +75,7 @@ fn feature_id(s: &Session, v: Option<&Value>, cmd: &str) -> Result<u64> {
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionDeleteCommand";
let cmd = "timeline.delete";
let mut keys = string_list(p, "features");
if let Some(Value::Array(a)) = p.get("features") {
keys.extend(a.iter().filter_map(Value::as_u64).map(|n| n.to_string()));
@@ -108,7 +108,7 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
}
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionRenameTimelineEntryCommand";
let cmd = "timeline.rename";
let id = feature_id(s, p.get("feature"), cmd)?;
let name =
str_(p, "name").map(str::trim).filter(|n| !n.is_empty() && n.len() <= 128).ok_or_else(|| bad(cmd, "`name` must be 1…128 characters"))?;
@@ -125,7 +125,7 @@ fn rollback(s: &mut Session, p: &Value) -> Result<Value> {
let n = s.doc.features.len();
let pos = match p.get("feature") {
Some(f) => {
let id = feature_id(s, Some(f), "timeline.rollTo")?;
let id = feature_id(s, Some(f), "timeline.roll_to")?;
s.doc.feature_index(id).map(|i| i + 1)
}
None => num(p, "position").map(|x| x.max(0.0) as usize),
@@ -278,7 +278,7 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
let id = feature_id(s, p.get("feature"), cmd)?;
let cid = str_(p, "command").ok_or_else(|| bad(cmd, "`command` must name the feature's command"))?;
let spec = super::find_command(cid).ok_or_else(|| bad(cmd, format!("no command `{cid}`")))?;
if cid.starts_with("timeline.") || cid.starts_with("select.") || cid.starts_with("Sketch") {
if spec.id.starts_with("timeline.") || spec.id.starts_with("select.") || spec.id.starts_with("sketch.") {
return Err(bad(cmd, format!("`{cid}` does not make a feature")));
}
let params = p.get("params").cloned().unwrap_or_else(|| json!({}));
+4 -4
View File
@@ -118,7 +118,7 @@ pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str) -> Result<
let spec = crate::find_command(id).ok_or_else(|| bad(cmd, id))?;
(spec.run)(s, &p)
};
let r = run(s, "SketchCreate", json!({"plane": {"face": [inside.x, inside.y, inside.z]}, "project_edges": true}))?;
let r = run(s, "sketch.create", json!({"plane": {"face": [inside.x, inside.y, inside.z]}, "project_edges": true}))?;
let sid = r.get("sketch").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "could not start a sketch on the face"))?;
s.refresh();
let plane = s.model.state().sketch(sid).map(|ss| ss.plane).ok_or_else(|| bad(cmd, "could not start a sketch on the face"))?;
@@ -142,10 +142,10 @@ pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str) -> Result<
None
};
match circle {
Some((o, r)) if closed => run(s, "CircleCenterRadius", json!({"center": xy(o), "radius": r}))?,
Some((o, r)) if closed => run(s, "sketch.circle.center", json!({"center": xy(o), "radius": r}))?,
Some(_) => {
let mid = pts.get(pts.len() / 2).copied().unwrap_or(first);
run(s, "ArcThreePoint", json!({"start": xy(first), "end": xy(last), "through": xy(mid)}))?
run(s, "sketch.arc.three_point", json!({"start": xy(first), "end": xy(last), "through": xy(mid)}))?
}
None => {
let mut list: Vec<Value> = Vec::new();
@@ -159,7 +159,7 @@ pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str) -> Result<
if closed_poly {
list.pop();
}
run(s, "DrawPolyline", json!({"points": list, "closed": closed_poly}))?
run(s, "sketch.line", json!({"points": list, "closed": closed_poly}))?
}
};
}
+57 -57
View File
@@ -9,95 +9,95 @@ use crate::params::{bad, bool_, expr, req_expr, str_, string_list, vec2, vec3};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("Extrude", "Extrude", extrude)
CommandSpec::new("solid.extrude", "Extrude", extrude)
.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?; operation?: new|join|cut|intersect|auto (cut into a body, join out of one, else new); targets?: [body]; name?; body_name?"),
CommandSpec::new("Revolve", "Revolve", revolve)
CommandSpec::new("solid.revolve", "Revolve", revolve)
.at("SOLID", "CREATE")
.icon("revolve")
.params("axis: sketch line id | x | y (sketch axes) | X|Y|Z (world); angle?: expr (default 360 deg); sketch?, profiles? | face: [x,y,z] (a planar body face as the profile), operation?, targets?, name?, body_name?"),
CommandSpec::new("Sweep", "Sweep", sweep)
CommandSpec::new("solid.sweep", "Sweep", sweep)
.at("SOLID", "CREATE")
.icon("sweep")
.params("sketch: profile sketch; profiles?; path_sketch: sketch; path: [curve ids in order]; operation?"),
CommandSpec::new("SolidLoft", "Loft", loft).at("SOLID", "CREATE").icon("loft").params("sections: [{sketch, profiles?} | {sketch, point: id} (an apex)] (2 or more, in order); operation?"),
CommandSpec::new("PrimitiveBox", "Box", prim_box).at("SOLID", "CREATE").icon("box").params("length, width, height: expr; corner?: [x,y,z] | center?: [x,y,z]; operation?"),
CommandSpec::new("PrimitiveCylinder", "Cylinder", prim_cylinder).at("SOLID", "CREATE").icon("cylinder").params("radius | diameter, height: expr; base?: [x,y,z]; axis?: [x,y,z]; operation?"),
CommandSpec::new("PrimitiveSphere", "Sphere", prim_sphere).at("SOLID", "CREATE").icon("sphere").params("radius | diameter: expr; center?: [x,y,z]; operation?"),
CommandSpec::new("PrimitiveTorus", "Torus", prim_torus).at("SOLID", "CREATE").icon("torus").params("major, minor: expr (radii); center?; operation?"),
CommandSpec::new("FusionFilletEdgesCommand", "Fillet", fillet)
CommandSpec::new("solid.loft", "Loft", loft).at("SOLID", "CREATE").icon("loft").params("sections: [{sketch, profiles?} | {sketch, point: id} (an apex)] (2 or more, in order); operation?"),
CommandSpec::new("solid.box", "Box", prim_box).at("SOLID", "CREATE").icon("box").params("length, width, height: expr; corner?: [x,y,z] | center?: [x,y,z]; operation?"),
CommandSpec::new("solid.cylinder", "Cylinder", prim_cylinder).at("SOLID", "CREATE").icon("cylinder").params("radius | diameter, height: expr; base?: [x,y,z]; axis?: [x,y,z]; operation?"),
CommandSpec::new("solid.sphere", "Sphere", prim_sphere).at("SOLID", "CREATE").icon("sphere").params("radius | diameter: expr; center?: [x,y,z]; operation?"),
CommandSpec::new("solid.torus", "Torus", prim_torus).at("SOLID", "CREATE").icon("torus").params("major, minor: expr (radii); center?; operation?"),
CommandSpec::new("solid.fillet", "Fillet", fillet)
.at("SOLID", "MODIFY")
.icon("fillet")
.key("F")
.params("edges: [[x,y,z] point on edge | {body, index}]; radius: expr; body?; type?: constant|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other); radius2?; start?: [x,y,z]"),
CommandSpec::new("FusionChamferCommand", "Chamfer", chamfer).at("SOLID", "MODIFY").icon("chamfer").params("edges: [[x,y,z] | {body, index}]; distance: expr; distance2?: expr (along the second face) | angle?: expr (from the first face); flip?: bool (which face is first: by default the one facing up most); body?"),
CommandSpec::new("FusionCombineCommand", "Combine", combine).at("SOLID", "MODIFY").icon("combine").params("target: body; tools: [body]; operation?: join|cut|intersect; keep_tools?: bool"),
CommandSpec::new("PatternRectangular", "Rectangular Pattern", pattern_rect)
CommandSpec::new("solid.chamfer", "Chamfer", chamfer).at("SOLID", "MODIFY").icon("chamfer").params("edges: [[x,y,z] | {body, index}]; distance: expr; distance2?: expr (along the second face) | angle?: expr (from the first face); flip?: bool (which face is first: by default the one facing up most); body?"),
CommandSpec::new("solid.combine", "Combine", combine).at("SOLID", "MODIFY").icon("combine").params("target: body; tools: [body]; operation?: join|cut|intersect; keep_tools?: bool"),
CommandSpec::new("solid.pattern.rectangular", "Rectangular Pattern", pattern_rect)
.at("SOLID", "CREATE")
.icon("pattern_rect")
.params("features: [names] | bodies: [names] | components: [names]; dir1: [x,y,z]; count1; spacing1; dir2?, count2?, spacing2?"),
CommandSpec::new("PatternCircular", "Circular Pattern", pattern_circ)
CommandSpec::new("solid.pattern.circular", "Circular Pattern", pattern_circ)
.at("SOLID", "CREATE")
.icon("pattern_circ")
.params("features: [names] | bodies: [names] | components: [names]; axis: X|Y|Z | {origin, dir}; count; angle? (default 360 deg)"),
CommandSpec::new("PatternOnPath", "Pattern on Path", pattern_path)
CommandSpec::new("solid.pattern.path", "Pattern on Path", pattern_path)
.at("SOLID", "CREATE")
.icon("pattern_rect")
.params("features: [names] | bodies: [names]; path_sketch: sketch; path: [curve ids in order]; count; spacing (between instances) | distance (first to last); orientation?: identical|path; flip?: bool"),
CommandSpec::new("MirrorCommand", "Mirror", mirror).at("SOLID", "CREATE").icon("mirror").params("features: [names] | bodies: [names] (combine?: join with the original); plane: XY|XZ|YZ | {origin, x_dir, y_dir}"),
CommandSpec::new("FusionHoleCommand", "Hole", hole)
CommandSpec::new("solid.mirror", "Mirror", mirror).at("SOLID", "CREATE").icon("mirror").params("features: [names] | bodies: [names] (combine?: join with the original); plane: XY|XZ|YZ | {origin, x_dir, y_dir}"),
CommandSpec::new("solid.hole", "Hole", hole)
.at("SOLID", "CREATE")
.icon("hole")
.key("H")
.params("position: [x,y,z] on a face | sketch + points: [sketch point ids] (one hole each, perpendicular to the sketch); direction?: [x,y,z] (default: into the face); diameter | thread (a tapped hole: the tap drill, major − pitch) | clearance: \"M6\" with fit?: close|normal|loose (ISO 273); depth? (default through all); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?; cs_diameter?, cs_angle?; thread?: \"M6\" (cosmetic)"),
CommandSpec::new("PrimitivePipe", "Pipe", pipe)
CommandSpec::new("solid.pipe", "Pipe", pipe)
.at("SOLID", "CREATE")
.icon("pipe")
.params("path_sketch: sketch; path: [curve ids in order]; diameter: expr; wall?: expr (hollow); operation?, targets?, name?, body_name?"),
CommandSpec::new("StockModelCommand", "Bounding Solid", bounding_solid)
CommandSpec::new("solid.bounding_solid", "Bounding Solid", bounding_solid)
.at("SOLID", "CREATE")
.icon("box")
.params("bodies?: [names] (default: all); margin?: expr; name?, body_name?"),
CommandSpec::new("ModifyScale", "Scale", scale)
CommandSpec::new("solid.scale", "Scale", scale)
.at("SOLID", "MODIFY")
.icon("scale")
.params("bodies: [names]; factor: expr | factors: [x, y, z] exprs; origin?: [x,y,z]"),
CommandSpec::new("FusionOffsetFacesCommand", "Offset Face", offset_face)
CommandSpec::new("solid.offset_face", "Offset Face", offset_face)
.at("SOLID", "MODIFY")
.icon("offset_face")
.params("faces: [[x,y,z] points on planar faces]; distance: expr (positive: outward); body?"),
CommandSpec::new("FusionThreadCommand", "Thread", thread)
CommandSpec::new("solid.thread", "Thread", thread)
.at("SOLID", "CREATE")
.icon("thread")
.params("face: [x,y,z] on a cylindrical face; designation?: ISO metric (\"M8\", \"M8x1\"; default: the size that fits); length?: expr (default: the whole face). Cosmetic: the model is unchanged, model.threads lists it"),
CommandSpec::new("FusionShellBodyCommand", "Shell", shell)
CommandSpec::new("solid.shell", "Shell", shell)
.at("SOLID", "MODIFY")
.icon("shell")
.params("faces: [[x,y,z] points on the faces to remove]; thickness: expr (inside); body?"),
CommandSpec::new("FusionDraftCommand", "Draft", draft)
CommandSpec::new("solid.draft", "Draft", draft)
.at("SOLID", "MODIFY")
.icon("draft")
.params("faces: [[x,y,z]]; angle: expr; neutral: XY|XZ|YZ|plane name|{origin, normal}; pull?: [x,y,z] (default: neutral plane normal); body?"),
CommandSpec::new("ConstructionPlaneOffsetFromPlaneCommand", "Offset Plane", plane_offset)
CommandSpec::new("construct.plane.offset", "Offset Plane", plane_offset)
.at("SOLID", "CONSTRUCT")
.icon("plane")
.params("base: XY|XZ|YZ|plane name; offset: expr; name?"),
CommandSpec::new("ConstructionPlaneAtAngleCommand", "Plane at Angle", plane_angle)
CommandSpec::new("construct.plane.at_angle", "Plane at Angle", plane_angle)
.at("SOLID", "CONSTRUCT")
.icon("plane")
.params("base: XY|XZ|YZ|plane name; axis: X|Y|Z|{origin, dir}; angle: expr; name?"),
CommandSpec::new("FusionSplitBodyCommand", "Split Body", split_body)
CommandSpec::new("solid.split_body", "Split Body", split_body)
.at("SOLID", "MODIFY")
.icon("split")
.params("body: name; plane: XY|XZ|YZ|plane name|{origin, normal} — or tool: {body, point} (a face of a body, its surface extended)"),
CommandSpec::new("FusionPressPullCommand", "Press Pull", press_pull)
CommandSpec::new("solid.press_pull", "Press Pull", press_pull)
.at("SOLID", "MODIFY")
.icon("presspull")
.key("Q")
.params("face: [x,y,z] and distance: expr (positive adds material, negative cuts), or edges: [[x,y,z]…] and distance (a fillet)"),
CommandSpec::new("FusionMoveCommand", "Move/Copy", move_bodies).at("SOLID", "MODIFY").icon("move").key("M").params("bodies: [names]; translate?: [x,y,z] (exprs or numbers); axis?: [x,y,z]; angle?: expr"),
CommandSpec::new("solid.move", "Move/Copy", move_bodies).at("SOLID", "MODIFY").icon("move").key("M").params("bodies: [names]; translate?: [x,y,z] (exprs or numbers); axis?: [x,y,z]; angle?: expr"),
];
/// Where an extrude starts and which way is positive: a point inside the (first) profile or
@@ -316,7 +316,7 @@ pub(super) fn add_feature(s: &mut Session, p: &Value, kind: FeatureKind) -> Resu
}
fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "Extrude";
let cmd = "solid.extrude";
// A planar body face extrudes like a profile: its boundary is copied into a sketch on it.
let (sketch, face_profile) = match p.get("face").and_then(vec3) {
Some(fp) => {
@@ -363,7 +363,7 @@ fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
}
fn revolve(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "Revolve";
let cmd = "solid.revolve";
// A planar body face revolves like a profile (its edges projected into a sketch on it).
let (sketch, face_profile) = match p.get("face").and_then(vec3) {
Some(fp) => {
@@ -404,7 +404,7 @@ fn radius_expr(p: &Value, cmd: &str) -> Result<String> {
}
fn prim_box(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "PrimitiveBox";
let cmd = "solid.box";
let (l, w, h) = (req_expr(cmd, p, "length")?, req_expr(cmd, p, "width")?, req_expr(cmd, p, "height")?);
for (e, n) in [(&l, "length"), (&w, "width"), (&h, "height")] {
check_expr(s, e, Kind::Length, cmd, n)?;
@@ -421,7 +421,7 @@ fn prim_box(s: &mut Session, p: &Value) -> Result<Value> {
}
fn prim_cylinder(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "PrimitiveCylinder";
let cmd = "solid.cylinder";
let radius = radius_expr(p, cmd)?;
let height = req_expr(cmd, p, "height")?;
check_expr(s, &radius, Kind::Length, cmd, "radius")?;
@@ -432,7 +432,7 @@ fn prim_cylinder(s: &mut Session, p: &Value) -> Result<Value> {
}
fn prim_sphere(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "PrimitiveSphere";
let cmd = "solid.sphere";
let radius = radius_expr(p, cmd)?;
check_expr(s, &radius, Kind::Length, cmd, "radius")?;
let center = p.get("center").and_then(vec3).unwrap_or(Vec3::ZERO);
@@ -440,7 +440,7 @@ fn prim_sphere(s: &mut Session, p: &Value) -> Result<Value> {
}
fn prim_torus(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "PrimitiveTorus";
let cmd = "solid.torus";
let (major, minor) = (req_expr(cmd, p, "major")?, req_expr(cmd, p, "minor")?);
check_expr(s, &major, Kind::Length, cmd, "major")?;
check_expr(s, &minor, Kind::Length, cmd, "minor")?;
@@ -474,7 +474,7 @@ fn edge_points(s: &Session, p: &Value, cmd: &str) -> Result<Vec<Vec3>> {
/// Press Pull: a face moves along its normal (an extrude that joins outward or cuts inward); an
/// edge is rounded (a fillet).
fn press_pull(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionPressPullCommand";
let cmd = "solid.press_pull";
let distance = req_expr(cmd, p, "distance")?;
if p.get("edges").is_some() {
return fillet(s, &json!({"edges": p.get("edges"), "radius": distance}));
@@ -503,7 +503,7 @@ fn press_pull(s: &mut Session, p: &Value) -> Result<Value> {
return Ok(json!({"feature": f.id, "edited": f.name, key: new, "recomputed": out.get("recomputed")}));
}
// Another curved face moves along its normal.
let r = super::find_command("FusionOffsetFacesCommand").ok_or_else(|| bad(cmd, "FusionOffsetFacesCommand"))?;
let r = super::find_command("solid.offset_face").ok_or_else(|| bad(cmd, "solid.offset_face"))?;
return (r.run)(s, &json!({"faces": [face], "distance": distance}));
}
let (dist, dir, op) = if d > 0.0 { (distance, "positive", "join") } else { (format!("-({distance})"), "negative", "cut") };
@@ -511,7 +511,7 @@ fn press_pull(s: &mut Session, p: &Value) -> Result<Value> {
}
fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionFilletEdgesCommand";
let cmd = "solid.fillet";
let edges = edge_points(s, p, cmd)?;
let radius = req_expr(cmd, p, "radius")?;
check_expr(s, &radius, Kind::Length, cmd, "radius")?;
@@ -531,7 +531,7 @@ fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
}
fn chamfer(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionChamferCommand";
let cmd = "solid.chamfer";
let edges = edge_points(s, p, cmd)?;
let distance = req_expr(cmd, p, "distance")?;
check_expr(s, &distance, Kind::Length, cmd, "distance")?;
@@ -550,7 +550,7 @@ fn chamfer(s: &mut Session, p: &Value) -> Result<Value> {
}
fn combine(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionCombineCommand";
let cmd = "solid.combine";
let target = str_(p, "target").ok_or_else(|| bad(cmd, "`target` must be a body name"))?.to_string();
let tools = string_list(p, "tools");
if tools.is_empty() {
@@ -564,7 +564,7 @@ fn combine(s: &mut Session, p: &Value) -> Result<Value> {
}
fn move_bodies(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionMoveCommand";
let cmd = "solid.move";
let bodies = string_list(p, "bodies");
if bodies.is_empty() {
return Err(bad(cmd, "`bodies` must list bodies"));
@@ -658,7 +658,7 @@ fn pattern_sources(s: &Session, p: &Value, cmd: &str) -> Result<(Vec<String>, Ve
}
fn pattern_rect(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "PatternRectangular";
let cmd = "solid.pattern.rectangular";
let (features, bodies) = pattern_sources(s, p, cmd)?;
let dir1 = p.get("dir1").and_then(vec3).ok_or_else(|| bad(cmd, "`dir1` must be [x, y, z]"))?;
let count1 = req_expr(cmd, p, "count1")?;
@@ -678,7 +678,7 @@ fn pattern_rect(s: &mut Session, p: &Value) -> Result<Value> {
}
fn pattern_circ(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "PatternCircular";
let cmd = "solid.pattern.circular";
let (features, bodies) = pattern_sources(s, p, cmd)?;
let (origin, axis) = axis_line(p, cmd)?;
let count = req_expr(cmd, p, "count")?;
@@ -690,7 +690,7 @@ fn pattern_circ(s: &mut Session, p: &Value) -> Result<Value> {
}
fn pattern_path(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "PatternOnPath";
let cmd = "solid.pattern.path";
let (features, bodies) = pattern_sources(s, p, cmd)?;
let path_sketch = sketch_id(s, p.get("path_sketch").or_else(|| p.get("sketch")), cmd, "path_sketch")?;
let path = string_list(p, "path");
@@ -716,7 +716,7 @@ fn pattern_path(s: &mut Session, p: &Value) -> Result<Value> {
}
fn mirror(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "MirrorCommand";
let cmd = "solid.mirror";
let bodies = string_list(p, "bodies");
let features = if bodies.is_empty() { source_features(s, p, cmd)? } else { Vec::new() };
let plane = match p.get("plane") {
@@ -760,7 +760,7 @@ pub fn plane_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<solvecra
}
fn plane_offset(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ConstructionPlaneOffsetFromPlaneCommand";
let cmd = "construct.plane.offset";
let base = plane_param(s, p.get("base"), cmd)?;
let d = req_expr(cmd, p, "offset")?;
check_expr(s, &d, Kind::Length, cmd, "offset")?;
@@ -768,7 +768,7 @@ fn plane_offset(s: &mut Session, p: &Value) -> Result<Value> {
}
fn plane_angle(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ConstructionPlaneAtAngleCommand";
let cmd = "construct.plane.at_angle";
let base = plane_param(s, p.get("base"), cmd)?;
let (axis_origin, axis_dir) = axis_line(p, cmd)?;
let angle = req_expr(cmd, p, "angle")?;
@@ -781,7 +781,7 @@ fn plane_angle(s: &mut Session, p: &Value) -> Result<Value> {
}
fn split_body(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionSplitBodyCommand";
let cmd = "solid.split_body";
let body = str_(p, "body").ok_or_else(|| bad(cmd, "`body` is required"))?.to_string();
if s.model.state().body(&body).is_none() {
return Err(bad(cmd, format!("no body `{body}`")));
@@ -846,7 +846,7 @@ fn clearance_diameter(size: &str, fit: &str) -> Option<f64> {
}
fn hole(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionHoleCommand";
let cmd = "solid.hole";
// At sketch points: the holes go perpendicular to the sketch and follow its points.
let points = match (p.get("sketch"), p.get("points")) {
(Some(_), Some(_)) => {
@@ -951,7 +951,7 @@ fn face_points(p: &Value, cmd: &str) -> Result<Vec<Vec3>> {
}
fn shell(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionShellBodyCommand";
let cmd = "solid.shell";
let faces = face_points(p, cmd)?;
let thickness = req_expr(cmd, p, "thickness")?;
check_expr(s, &thickness, Kind::Length, cmd, "thickness")?;
@@ -959,7 +959,7 @@ fn shell(s: &mut Session, p: &Value) -> Result<Value> {
}
fn draft(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionDraftCommand";
let cmd = "solid.draft";
let faces = face_points(p, cmd)?;
let angle = req_expr(cmd, p, "angle")?;
check_expr(s, &angle, Kind::Angle, cmd, "angle")?;
@@ -983,7 +983,7 @@ pub(super) fn sketch_id(s: &Session, v: Option<&Value>, cmd: &str, what: &str) -
}
fn sweep(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "Sweep";
let cmd = "solid.sweep";
let sketch = feature_sketch(s, p, cmd)?;
let path_sketch = sketch_id(s, p.get("path_sketch"), cmd, "path_sketch")?;
let path = string_list(p, "path");
@@ -1005,7 +1005,7 @@ fn sweep(s: &mut Session, p: &Value) -> Result<Value> {
}
fn loft(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "SolidLoft";
let cmd = "solid.loft";
let list = p.get("sections").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`sections` must be a list"))?;
if list.len() < 2 || list.len() > 50 {
return Err(bad(cmd, "a loft needs 2…50 sections"));
@@ -1028,7 +1028,7 @@ fn loft(s: &mut Session, p: &Value) -> Result<Value> {
}
fn thread(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionThreadCommand";
let cmd = "solid.thread";
let face = p
.get("face")
.and_then(|v| vec3(v).or_else(|| v.get("point").and_then(vec3)))
@@ -1050,7 +1050,7 @@ fn thread(s: &mut Session, p: &Value) -> Result<Value> {
}
fn pipe(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "PrimitivePipe";
let cmd = "solid.pipe";
let path_sketch = sketch_id(s, p.get("path_sketch"), cmd, "path_sketch")?;
let path = string_list(p, "path");
if path.is_empty() {
@@ -1066,14 +1066,14 @@ fn pipe(s: &mut Session, p: &Value) -> Result<Value> {
}
fn bounding_solid(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "StockModelCommand";
let cmd = "solid.bounding_solid";
let margin = expr(p, "margin").unwrap_or_else(|| "0".into());
check_expr(s, &margin, Kind::Length, cmd, "margin")?;
add_feature(s, p, FeatureKind::BoundingSolid { bodies: string_list(p, "bodies"), margin })
}
fn scale(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ModifyScale";
let cmd = "solid.scale";
let bodies = string_list(p, "bodies");
if bodies.is_empty() {
return Err(bad(cmd, "`bodies` must list bodies"));
@@ -1100,7 +1100,7 @@ fn scale(s: &mut Session, p: &Value) -> Result<Value> {
}
fn offset_face(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionOffsetFacesCommand";
let cmd = "solid.offset_face";
let faces = face_points(p, cmd)?;
let distance = req_expr(cmd, p, "distance")?;
check_expr(s, &distance, Kind::Length, cmd, "distance")?;
+14 -14
View File
@@ -11,35 +11,35 @@ use crate::params::{bad, bool_, expr, req_expr, str_, string_list, vec3};
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("PrimitiveCoil", "Coil", coil).at("SOLID", "CREATE").icon("coil").params(
CommandSpec::new("solid.coil", "Coil", coil).at("SOLID", "CREATE").icon("coil").params(
"diameter; two of revolutions (or turns), height, pitch; section_size; section?: circular|square; section_position?: inside|center|outside; \
base?: [x,y,z]; axis?: X|Y|Z|[x,y,z] (default Z); start_angle?; clockwise?: bool; operation?, targets?, name?, body_name?",
),
CommandSpec::new("FusionRibCommand", "Rib", rib)
CommandSpec::new("solid.rib", "Rib", rib)
.at("SOLID", "CREATE")
.icon("rib")
.params("sketch, curves: [open curve ids, in order]; thickness (across the sketch plane); depth? (default: to the next face); flip?: bool (fill to the other side)"),
CommandSpec::new("FusionWebCommand", "Web", web)
CommandSpec::new("solid.web", "Web", web)
.at("SOLID", "CREATE")
.icon("rib")
.params("sketch, curves: [open curve ids] (one wall each); thickness; depth? (default: to the next face); flip?: bool"),
CommandSpec::new("EmbossCmd", "Emboss", emboss)
CommandSpec::new("solid.emboss", "Emboss", emboss)
.at("SOLID", "CREATE")
.icon("emboss")
.params("sketch (on a planar face), profiles?; depth; mode?: emboss|deboss (default emboss); targets?"),
CommandSpec::new("FusionReplaceFaceCommand", "Replace Face", replace_face)
CommandSpec::new("solid.replace_face", "Replace Face", replace_face)
.at("SOLID", "MODIFY")
.icon("offset_face")
.params("faces: [[x,y,z] points on planar faces]; target: plane (XY|XZ|YZ|construction plane|{origin, normal}) | target_face: [x,y,z] on a parallel planar face; body?"),
CommandSpec::new("AlignCmd", "Align", align)
CommandSpec::new("solid.align", "Align", align)
.at("SOLID", "MODIFY")
.icon("move")
.params("bodies: [names]; from: [x,y,z] | from_face: [x,y,z]; to: [x,y,z] | to_face: [x,y,z] (faces also turn to meet); flip?: bool"),
CommandSpec::new("SoftDeleteCommand", "Remove", remove).at("SOLID", "MODIFY").icon("delete").params("bodies: [names] (removed from here on in the timeline)"),
CommandSpec::new("solid.remove", "Remove", remove).at("SOLID", "MODIFY").icon("delete").params("bodies: [names] (removed from here on in the timeline)"),
];
fn coil(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "PrimitiveCoil";
let cmd = "solid.coil";
let base = p.get("base").and_then(vec3).unwrap_or(Vec3::ZERO);
let axis = match p.get("axis") {
None => Vec3::Z,
@@ -131,15 +131,15 @@ fn rib_like(s: &mut Session, p: &Value, cmd: &str, web: bool) -> Result<Value> {
}
fn rib(s: &mut Session, p: &Value) -> Result<Value> {
rib_like(s, p, "FusionRibCommand", false)
rib_like(s, p, "solid.rib", false)
}
fn web(s: &mut Session, p: &Value) -> Result<Value> {
rib_like(s, p, "FusionWebCommand", true)
rib_like(s, p, "solid.web", true)
}
fn emboss(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "EmbossCmd";
let cmd = "solid.emboss";
let sketch = feature_sketch(s, p, cmd)?;
let profiles = profiles(p, cmd)?;
let depth = req_expr(cmd, p, "depth")?;
@@ -166,7 +166,7 @@ fn face_points(p: &Value, k: &str, cmd: &str) -> Result<Vec<Vec3>> {
}
fn replace_face(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionReplaceFaceCommand";
let cmd = "solid.replace_face";
let faces = face_points(p, "faces", cmd)?;
for f in &faces {
if super::face::face_normal(s, *f).is_none() {
@@ -186,7 +186,7 @@ fn replace_face(s: &mut Session, p: &Value) -> Result<Value> {
}
fn align(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "AlignCmd";
let cmd = "solid.align";
let bodies = string_list(p, "bodies");
if bodies.is_empty() {
return Err(bad(cmd, "`bodies` must list the bodies to move"));
@@ -215,7 +215,7 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
}
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "SoftDeleteCommand";
let cmd = "solid.remove";
let bodies = string_list(p, "bodies");
if bodies.is_empty() {
return Err(bad(cmd, "`bodies` must list bodies"));
+43 -43
View File
@@ -10,7 +10,7 @@ fn run(s: &mut Session, id: &str, p: Value) -> Value {
}
fn measure(s: &mut Session) -> Value {
run(s, "MeasureCommand", json!({}))
run(s, "inspect.measure", json!({}))
}
fn volume(s: &mut Session) -> f64 {
@@ -24,76 +24,76 @@ fn rel(a: f64, b: f64) -> f64 {
/// An L bracket: a 40 x 20 x 5 base and a 5 x 20 x 40 wall at x = 0..5.
fn bracket() -> Session {
let mut s = Session::default();
run(&mut s, "PrimitiveBox", json!({"length": 40, "width": 20, "height": 5, "corner": [0, 0, 0], "body_name": "L"}));
run(&mut s, "PrimitiveBox", json!({"length": 5, "width": 20, "height": 40, "corner": [0, 0, 0], "operation": "join"}));
run(&mut s, "solid.box", json!({"length": 40, "width": 20, "height": 5, "corner": [0, 0, 0], "body_name": "L"}));
run(&mut s, "solid.box", json!({"length": 5, "width": 20, "height": 40, "corner": [0, 0, 0], "operation": "join"}));
assert!(rel(volume(&mut s), 4000.0 + 3500.0) < 1e-9);
// Mid-plane of the bracket (y = 10): sketch x = X, sketch y = Z.
run(&mut s, "SketchCreate", json!({"plane": {"origin": [0, 10, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, 1]}, "name": "RibSketch"}));
run(&mut s, "DrawPolyline", json!({"points": [[5, 25], [25, 5]], "ids": ["l1"]}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 10, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, 1]}, "name": "RibSketch"}));
run(&mut s, "sketch.line", json!({"points": [[5, 25], [25, 5]], "ids": ["l1"]}));
run(&mut s, "sketch.finish", json!({}));
s
}
#[test]
fn rib_to_the_next_face_fills_the_corner() {
let mut s = bracket();
run(&mut s, "FusionRibCommand", json!({"sketch": "RibSketch", "curves": ["l1"], "thickness": 4}));
run(&mut s, "solid.rib", json!({"sketch": "RibSketch", "curves": ["l1"], "thickness": 4}));
// The triangle (5,5)-(5,25)-(25,5) of area 200, 4 thick.
assert!(rel(volume(&mut s), 7500.0 + 800.0) < 1e-6, "{}", volume(&mut s));
assert_eq!(measure(&mut s)["body_count"], 1);
// The thickness is a parameter.
let t = s.doc.features.last().and_then(|f| f.param_names.first().cloned()).unwrap_or_default();
run(&mut s, "ChangeParameterCommand", json!({"name": t, "expression": "2"}));
run(&mut s, "parameters.change", json!({"name": t, "expression": "2"}));
assert!(rel(volume(&mut s), 7500.0 + 400.0) < 1e-6);
}
#[test]
fn rib_with_a_depth_and_web() {
let mut s = bracket();
run(&mut s, "FusionRibCommand", json!({"sketch": "RibSketch", "curves": ["l1"], "thickness": 4, "depth": 2}));
run(&mut s, "solid.rib", json!({"sketch": "RibSketch", "curves": ["l1"], "thickness": 4, "depth": 2}));
let v = volume(&mut s);
assert!(v > 7500.0 + 100.0 && v < 7500.0 + 800.0, "{v}");
let mut s = bracket();
run(&mut s, "FusionWebCommand", json!({"sketch": "RibSketch", "curves": ["l1"], "thickness": 4}));
run(&mut s, "solid.web", json!({"sketch": "RibSketch", "curves": ["l1"], "thickness": 4}));
assert!(rel(volume(&mut s), 8300.0) < 1e-6);
// Curves that face no body.
let mut s = Session::default();
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "Far"}));
run(&mut s, "DrawPolyline", json!({"points": [[50, 50], [60, 60]], "ids": ["l1"]}));
run(&mut s, "SketchStop", json!({}));
let e = s.execute("FusionRibCommand", &json!({"sketch": "Far", "curves": ["l1"], "thickness": 1}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Far"}));
run(&mut s, "sketch.line", json!({"points": [[50, 50], [60, 60]], "ids": ["l1"]}));
run(&mut s, "sketch.finish", json!({}));
let e = s.execute("solid.rib", &json!({"sketch": "Far", "curves": ["l1"], "thickness": 1}));
assert!(e.is_err_and(|e| e.to_string().contains("do not face a body")));
}
#[test]
fn emboss_and_deboss_on_a_face() {
let mut s = Session::default();
run(&mut s, "PrimitiveBox", json!({"length": 40, "width": 30, "height": 10}));
run(&mut s, "SketchCreate", json!({"plane": {"face": [20, 15, 10]}, "name": "Logo"}));
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [5, 5], "p1": [15, 15]}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "EmbossCmd", json!({"sketch": "Logo", "depth": 2}));
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10}));
run(&mut s, "sketch.create", json!({"plane": {"face": [20, 15, 10]}, "name": "Logo"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [15, 15]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.emboss", json!({"sketch": "Logo", "depth": 2}));
assert!(rel(volume(&mut s), 12000.0 + 200.0) < 1e-6, "{}", volume(&mut s));
run(&mut s, "UndoCommand", json!({}));
run(&mut s, "EmbossCmd", json!({"sketch": "Logo", "depth": 3, "mode": "deboss"}));
run(&mut s, "edit.undo", json!({}));
run(&mut s, "solid.emboss", json!({"sketch": "Logo", "depth": 3, "mode": "deboss"}));
assert!(rel(volume(&mut s), 12000.0 - 300.0) < 1e-6);
assert!(s.execute("EmbossCmd", &json!({"sketch": "Logo", "depth": 1, "mode": "sideways"})).is_err());
assert!(s.execute("solid.emboss", &json!({"sketch": "Logo", "depth": 1, "mode": "sideways"})).is_err());
}
#[test]
fn replace_face_align_remove() {
let mut s = Session::default();
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "body_name": "A"}));
run(&mut s, "FusionReplaceFaceCommand", json!({"faces": [[5, 5, 10]], "target": {"origin": [0, 0, 15], "normal": [0, 0, 1]}}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "A"}));
run(&mut s, "solid.replace_face", json!({"faces": [[5, 5, 10]], "target": {"origin": [0, 0, 15], "normal": [0, 0, 1]}}));
assert!(rel(volume(&mut s), 1500.0) < 1e-6);
let e = s.execute("FusionReplaceFaceCommand", &json!({"faces": [[5, 5, 15]], "target": {"origin": [0, 0, 15], "normal": [1, 0, 1]}}));
let e = s.execute("solid.replace_face", &json!({"faces": [[5, 5, 15]], "target": {"origin": [0, 0, 15], "normal": [1, 0, 1]}}));
assert!(e.is_err() || s.model.results.last().is_some_and(|r| r.error.as_deref().unwrap_or("").contains("parallel")));
let mut s = Session::default();
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "body_name": "A"}));
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "corner": [50, 0, 0], "body_name": "B"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "A"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [50, 0, 0], "body_name": "B"}));
// B's top face onto A's top face: B turns over and sits on A.
run(&mut s, "AlignCmd", json!({"bodies": ["B"], "from_face": [55, 5, 10], "to_face": [5, 5, 10]}));
run(&mut s, "solid.align", json!({"bodies": ["B"], "from_face": [55, 5, 10], "to_face": [5, 5, 10]}));
let m = measure(&mut s);
let b = m["bodies"].as_array().unwrap().iter().find(|b| b["name"] == "B").unwrap().clone();
let (lo, hi) = (b["bbox"]["min"].clone(), b["bbox"]["max"].clone());
@@ -101,11 +101,11 @@ fn replace_face_align_remove() {
assert!((got.as_f64().unwrap_or(f64::NAN) - want).abs() < 1e-6, "{b}");
}
// Points only: a plain move.
run(&mut s, "AlignCmd", json!({"bodies": ["B"], "from": [0, 0, 10], "to": [0, 0, 30]}));
run(&mut s, "solid.align", json!({"bodies": ["B"], "from": [0, 0, 10], "to": [0, 0, 30]}));
let m = measure(&mut s);
let b = m["bodies"].as_array().unwrap().iter().find(|b| b["name"] == "B").unwrap().clone();
assert!((b["bbox"]["min"][2].as_f64().unwrap_or(0.0) - 30.0).abs() < 1e-6, "{b}");
run(&mut s, "SoftDeleteCommand", json!({"bodies": ["B"]}));
run(&mut s, "solid.remove", json!({"bodies": ["B"]}));
assert_eq!(measure(&mut s)["body_count"], 1);
}
@@ -113,27 +113,27 @@ fn replace_face_align_remove() {
fn coil_and_loft_to_a_point() {
use std::f64::consts::{PI, TAU};
let mut s = Session::default();
run(&mut s, "PrimitiveCoil", json!({"diameter": 40, "revolutions": 2, "pitch": 8, "section_size": 4}));
run(&mut s, "solid.coil", json!({"diameter": 40, "revolutions": 2, "pitch": 8, "section_size": 4}));
assert!(rel(volume(&mut s), PI * 4.0 * TAU * 20.0 * 2.0) < 1e-3, "{}", volume(&mut s));
// Height and revolutions give the pitch; a square section outside the diameter.
let mut s = Session::default();
run(
&mut s,
"PrimitiveCoil",
"solid.coil",
json!({"diameter": 40, "revolutions": 1.5, "height": 15, "section": "square", "section_size": 2, "section_position": "outside", "axis": "X"}),
);
assert!(rel(volume(&mut s), 4.0 * TAU * 21.0 * 1.5) < 1e-3, "{}", volume(&mut s));
assert!(s.execute("PrimitiveCoil", &json!({"diameter": 40, "pitch": 1, "revolutions": 3, "section_size": 4})).is_err(), "runs into itself");
assert!(s.execute("PrimitiveCoil", &json!({"diameter": 40, "section_size": 4, "pitch": 8})).is_err(), "two of three");
assert!(s.execute("solid.coil", &json!({"diameter": 40, "pitch": 1, "revolutions": 3, "section_size": 4})).is_err(), "runs into itself");
assert!(s.execute("solid.coil", &json!({"diameter": 40, "section_size": 4, "pitch": 8})).is_err(), "two of three");
// Pyramid: a square lofted to a point.
let mut s = Session::default();
run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "Base"}));
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [-10, -10], "p1": [10, 10]}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "SketchCreate", json!({"plane": {"origin": [0, 0, 30], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "name": "Top"}));
run(&mut s, "DrawPoint", json!({"point": [0, 0], "id": "apex"}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "SolidLoft", json!({"sections": [{"sketch": "Base"}, {"sketch": "Top", "point": "apex"}]}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-10, -10], "p1": [10, 10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 30], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "name": "Top"}));
run(&mut s, "sketch.point", json!({"point": [0, 0], "id": "apex"}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.loft", json!({"sections": [{"sketch": "Base"}, {"sketch": "Top", "point": "apex"}]}));
assert!(rel(volume(&mut s), 400.0 * 30.0 / 3.0) < 1e-6);
assert!(s.execute("SolidLoft", &json!({"sections": [{"sketch": "Base"}, {"sketch": "Top", "point": "nope"}]})).is_err());
assert!(s.execute("solid.loft", &json!({"sections": [{"sketch": "Base"}, {"sketch": "Top", "point": "nope"}]})).is_err());
}
+13 -13
View File
@@ -9,22 +9,22 @@ use crate::params::{bad, bool_, str_, string_list};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("NewDocumentCommand", "New Design", new_doc).icon("new").key("Ctrl+N").noundo().params("name?"),
CommandSpec::new("file.new", "New Design", new_doc).icon("new").key("Ctrl+N").noundo().params("name?"),
CommandSpec::new("doc.open", "Open", open)
.icon("open")
.key("Ctrl+O")
.noundo()
.params("path: .solvecraft design, or a .step/.stp file (opens as a new design)"),
CommandSpec::new("FusionImportCommandFromToolbar", "Insert STEP", insert_step)
CommandSpec::new("file.insert_step", "Insert STEP", insert_step)
.at("SOLID", "INSERT")
.icon("import")
.params("path: .step/.stp file (its bodies join the design as an Import base feature); name?"),
CommandSpec::new("ParaMeshInsertAlignCommand", "Insert Mesh", insert_mesh)
CommandSpec::new("file.insert_mesh", "Insert Mesh", insert_mesh)
.at("SOLID", "INSERT")
.icon("import")
.params("path: .3mf or .stl file (its meshes join the design as mesh bodies); name?"),
CommandSpec::new("SaveDocumentCommand", "Save", save).icon("save").key("Ctrl+S").noundo().params("path? (default: current file)"),
CommandSpec::new("SaveDocumentAsCommand", "Save As", save_as).icon("save").noundo().params("path"),
CommandSpec::new("file.save", "Save", save).icon("save").key("Ctrl+S").noundo().params("path? (default: current file)"),
CommandSpec::new("file.save_as", "Save As", save_as).icon("save").noundo().params("path"),
CommandSpec::new("doc.recovery_list", "Recoverable Designs", recovery_list)
.noundo()
.params("dir? (default: the recovery folder) → designs autosaved by apps that crashed or closed with unsaved changes"),
@@ -32,11 +32,11 @@ pub static COMMANDS: &[CommandSpec] = &[
.noundo()
.params("id (from doc.recovery_list); dir?; discard?: bool (delete the entry once open) — opens it with its file path, unsaved"),
CommandSpec::new("doc.recovery_discard", "Discard Recovered Design", recovery_discard).noundo().params("id, or all: true; dir?"),
CommandSpec::new("ExportCommand", "Export", export)
CommandSpec::new("file.export", "Export", export)
.icon("export")
.noundo()
.params("path; format?: stl|stla|obj|step|3mf (default from extension); bodies?: [names]"),
CommandSpec::new("FusionSaveAsSTLCommand", "Save As Mesh", save_stl).icon("export").noundo().params("path; bodies?: [names]; ascii?: bool"),
CommandSpec::new("file.save_mesh", "Save As Mesh", save_stl).icon("export").noundo().params("path; bodies?: [names]; ascii?: bool"),
];
fn new_doc(s: &mut Session, p: &Value) -> Result<Value> {
@@ -103,7 +103,7 @@ fn add_mesh(s: &mut Session, path: &str, name: Option<&str>) -> Result<Value> {
}
fn insert_mesh(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ParaMeshInsertAlignCommand";
let cmd = "file.insert_mesh";
let path = path_arg(p, cmd)?;
if !solvecraft_io::is_mesh_path(path) {
return Err(bad(cmd, "only 3MF and STL files (.3mf, .stl) can be inserted as meshes"));
@@ -112,7 +112,7 @@ fn insert_mesh(s: &mut Session, p: &Value) -> Result<Value> {
}
fn insert_step(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionImportCommandFromToolbar";
let cmd = "file.insert_step";
let path = path_arg(p, cmd)?;
if !solvecraft_io::is_step_path(path) {
return Err(bad(cmd, "only STEP files (.step, .stp) can be inserted"));
@@ -156,13 +156,13 @@ fn write(s: &mut Session, path: &str) -> Result<Value> {
fn save(s: &mut Session, p: &Value) -> Result<Value> {
let path = match str_(p, "path") {
Some(x) if !x.trim().is_empty() => x.to_string(),
_ => s.path.clone().ok_or_else(|| bad("SaveDocumentCommand", "the design has no file yet: give a `path`"))?,
_ => s.path.clone().ok_or_else(|| bad("file.save", "the design has no file yet: give a `path`"))?,
};
write(s, &path)
}
fn save_as(s: &mut Session, p: &Value) -> Result<Value> {
let path = path_arg(p, "SaveDocumentAsCommand")?.to_string();
let path = path_arg(p, "file.save_as")?.to_string();
write(s, &path)
}
@@ -180,13 +180,13 @@ fn export_to(s: &Session, path: &str, format: Format, bodies: &[String]) -> Resu
}
fn export(s: &mut Session, p: &Value) -> Result<Value> {
let path = path_arg(p, "ExportCommand")?;
let path = path_arg(p, "file.export")?;
let format = Format::from_name(str_(p, "format").unwrap_or(path))?;
export_to(s, path, format, &string_list(p, "bodies"))
}
fn save_stl(s: &mut Session, p: &Value) -> Result<Value> {
let path = path_arg(p, "FusionSaveAsSTLCommand")?;
let path = path_arg(p, "file.save_mesh")?;
let f = if bool_(p, "ascii").unwrap_or(false) { Format::StlAscii } else { Format::StlBinary };
export_to(s, path, f, &string_list(p, "bodies"))
}
+6 -6
View File
@@ -8,7 +8,7 @@ use crate::params::{bad, bool_, str_, string_list};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("MeasureCommand", "Measure", measure)
CommandSpec::new("inspect.measure", "Measure", measure)
.at("SOLID", "INSPECT")
.icon("measure")
.key("I")
@@ -19,11 +19,11 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("model.edges", "List Edges", model_edges).noundo().params("body: name"),
CommandSpec::new("model.faces", "List Faces", model_faces).noundo().params("body: name"),
CommandSpec::new("model.threads", "List Threads", model_threads).noundo(),
CommandSpec::new("PhysicalMaterialCommand", "Physical Material", physical_material)
CommandSpec::new("material.assign", "Physical Material", physical_material)
.at("SOLID", "MODIFY")
.icon("material")
.params("bodies: [names]; material: name (Steel, Aluminum, ABS Plastic, …; `material.list`)"),
CommandSpec::new("AppearanceCommand", "Appearance", appearance).at("SOLID", "MODIFY").icon("appearance").key("A").params(
CommandSpec::new("appearance.assign", "Appearance", appearance).at("SOLID", "MODIFY").icon("appearance").key("A").params(
"bodies?: [names]; faces?: [[x,y,z] on faces] (body?: which body, default the nearest); components?: [names or ids, or occurrence names]; \
appearance?: library name (appearance.library) | color: \"#rrggbb\" or [r, g, b] (0–255), opacity? (0–1, default 1); \
neither (or color: null): clear, so the face follows its body, the body its component, the component its material",
@@ -74,7 +74,7 @@ fn measure(s: &mut Session, p: &Value) -> Result<Value> {
}
}
if !want.is_empty() && bodies.len() != want.len() {
return Err(bad("MeasureCommand", "unknown body name"));
return Err(bad("inspect.measure", "unknown body name"));
}
// Sheet metal keeps the edges where bends meet flat faces (as Fusion does): count its
// B-rep faces as they are, without merging coplanar neighbours.
@@ -265,7 +265,7 @@ fn model_threads(s: &mut Session, _p: &Value) -> Result<Value> {
}
fn physical_material(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "PhysicalMaterialCommand";
let cmd = "material.assign";
let bodies = string_list(p, "bodies");
let material = str_(p, "material").ok_or_else(|| bad(cmd, "`material` must be a material name"))?;
let canonical = solvecraft_doc::MATERIALS
@@ -304,7 +304,7 @@ fn color_param(v: &Value) -> Option<[u8; 3]> {
fn appearance(s: &mut Session, p: &Value) -> Result<Value> {
use solvecraft_doc::appearance::{FaceLook, Look, library};
let cmd = "AppearanceCommand";
let cmd = "appearance.assign";
let look: Option<Look> = match (str_(p, "appearance"), p.get("color")) {
// A colour with a name: that colour, called that (a name not in the library is fine).
(Some(n), Some(v)) if !v.is_null() => {
+15 -15
View File
@@ -13,21 +13,21 @@ use crate::params::{bad, bool_, num, str_, string_list, vec3};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("JointOrigin", "Joint Origin", joint_origin).at("SOLID", "CREATE").icon("joint_origin").params(
CommandSpec::new("joint.origin", "Joint Origin", joint_origin).at("SOLID", "CREATE").icon("joint_origin").params(
"name?, occurrence?: id|name (default: from the pick), face: [x,y,z] (centre of a planar face) | circle: [x,y,z] (centre of a round end) | point: [x,y,z], z?: [x,y,z]",
),
CommandSpec::new("JointAssembleCmdNew", "Joint", joint).at("SOLID", "ASSEMBLE").icon("joint").key("J").params(
CommandSpec::new("joint.create", "Joint", joint).at("SOLID", "ASSEMBLE").icon("joint").key("J").params(
"type: rigid|revolute|slider|cylindrical|pin_slot|planar|ball; a, b: joint origins ({occurrence, face|circle|point: [x,y,z] in world} | origin name); \
values?: [angles in deg or expressions, distances]; limits?: [[min, max] | null…]; flip?: bool; offset?; angle?; name?",
),
CommandSpec::new("JointAsBuiltCmd", "As-Built Joint", as_built).at("SOLID", "ASSEMBLE").icon("as_built").params(
CommandSpec::new("joint.as_built", "As-Built Joint", as_built).at("SOLID", "ASSEMBLE").icon("as_built").params(
"type, a: occurrence, b: occurrence (stay where they are); at?: joint origin (default: b's origin) — the joint's frame",
),
CommandSpec::new("RigidGroupCmd", "Rigid Group", rigid_group).at("SOLID", "ASSEMBLE").icon("rigid_group").params("occurrences: [ids or names] (move together, as they are)"),
CommandSpec::new("FusionMoveJointsCommand", "Drive Joints", drive).at("SOLID", "ASSEMBLE").icon("drive").params(
CommandSpec::new("joint.rigid_group", "Rigid Group", rigid_group).at("SOLID", "ASSEMBLE").icon("rigid_group").params("occurrences: [ids or names] (move together, as they are)"),
CommandSpec::new("joint.drive", "Drive Joints", drive).at("SOLID", "ASSEMBLE").icon("drive").params(
"joint: id|name; value | values: [angles in deg or expressions, distances] (clamped to the limits)",
),
CommandSpec::new("FusionMotionRelationshipCommand", "Motion Link", motion_link)
CommandSpec::new("joint.motion_link", "Motion Link", motion_link)
.at("SOLID", "ASSEMBLE")
.icon("motion_link")
.params("a, b: joints; ratio (b = ratio · a + offset); offset?; ia?, ib?: value indices (default 0)"),
@@ -36,7 +36,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("joint.delete", "Delete Joint", delete).params("joint: id|name (or origin: name, link: index)"),
CommandSpec::new("joint.list", "List Joints", list).noundo().params("→ joints (type, occurrences, values, limits), origins, links, degrees of freedom, conflicts"),
CommandSpec::new("joint.solve", "Solve Joints", solve_cmd).params("re-place the occurrences from the joints → conflicts, errors, dof"),
CommandSpec::new("InterferenceCheckCommand", "Interference", interference).at("SOLID", "INSPECT").icon("interference").noundo().params(
CommandSpec::new("inspect.interference", "Interference", interference).at("SOLID", "INSPECT").icon("interference").noundo().params(
"bodies?: [names] (default: all, in world placement) → pairs that overlap with the overlap volume, area, bounding box and centre",
),
];
@@ -221,7 +221,7 @@ fn add_joint(s: &mut Session, j: Joint) -> Result<Value> {
return Err(EngineError::Other("too many joints".into()));
}
if j.a.occurrence == j.b.occurrence {
return Err(bad("JointAssembleCmdNew", "a joint needs two different occurrences"));
return Err(bad("joint.create", "a joint needs two different occurrences"));
}
let id = j.id;
let name = j.name.clone();
@@ -234,7 +234,7 @@ fn add_joint(s: &mut Session, j: Joint) -> Result<Value> {
}
fn joint_origin(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "JointOrigin";
let cmd = "joint.origin";
let origin = origin_param(s, Some(p), cmd)?;
let name = match str_(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
Some(n) => n.to_string(),
@@ -254,7 +254,7 @@ fn kind_param(p: &Value, cmd: &str) -> Result<JointKind> {
}
fn joint(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "JointAssembleCmdNew";
let cmd = "joint.create";
let kind = kind_param(p, cmd)?;
let a = origin_param(s, p.get("a"), cmd)?;
let b = origin_param(s, p.get("b"), cmd)?;
@@ -285,7 +285,7 @@ fn joint(s: &mut Session, p: &Value) -> Result<Value> {
}
fn as_built(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "JointAsBuiltCmd";
let cmd = "joint.as_built";
let kind = kind_param(p, cmd)?;
let (oa, ob) = (occurrence_id(s, p.get("a"), cmd)?, occurrence_id(s, p.get("b"), cmd)?);
// The joint's frame in the world: the given origin, or B's component origin.
@@ -318,7 +318,7 @@ fn as_built(s: &mut Session, p: &Value) -> Result<Value> {
}
fn rigid_group(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "RigidGroupCmd";
let cmd = "joint.rigid_group";
let list = p.get("occurrences").and_then(Value::as_array).cloned().unwrap_or_default();
if list.len() < 2 || list.len() > 1000 {
return Err(bad(cmd, "`occurrences` must list 2…1000 occurrences"));
@@ -334,7 +334,7 @@ fn rigid_group(s: &mut Session, p: &Value) -> Result<Value> {
}
fn drive(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionMoveJointsCommand";
let cmd = "joint.drive";
let id = joint_id(s, p.get("joint"), cmd)?;
let kind = s.doc.assembly.joints.iter().find(|j| j.id == id).map(|j| j.kind).ok_or_else(|| bad(cmd, "joint"))?;
if kind == JointKind::Rigid {
@@ -364,7 +364,7 @@ fn drive(s: &mut Session, p: &Value) -> Result<Value> {
}
fn motion_link(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionMotionRelationshipCommand";
let cmd = "joint.motion_link";
let (a, b) = (joint_id(s, p.get("a"), cmd)?, joint_id(s, p.get("b"), cmd)?);
if a == b {
return Err(bad(cmd, "link two different joints"));
@@ -523,7 +523,7 @@ fn solve_cmd(s: &mut Session, _p: &Value) -> Result<Value> {
}
fn interference(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "InterferenceCheckCommand";
let cmd = "inspect.interference";
let want = string_list(p, "bodies");
let st = s.world_state();
let bodies: Vec<&solvecraft_doc::ModelBody> =
+32 -32
View File
@@ -29,9 +29,9 @@ fn near(a: Vec3, b: Vec3) -> bool {
/// at y = 33, z = 4.
fn leaf(s: &mut Session, name: &str) {
run(s, "component.activate", json!({"component": "root"}));
run(s, "FusionCreateNewComponentCommand", json!({"name": name}));
run(s, "PrimitiveBox", json!({"length": 40, "width": 30, "height": 3, "corner": [0, 0, 0]}));
run(s, "PrimitiveCylinder", json!({"base": [0, 33, 4], "axis": [1, 0, 0], "radius": 4, "height": 40, "operation": "new"}));
run(s, "component.create", json!({"name": name}));
run(s, "solid.box", json!({"length": 40, "width": 30, "height": 3, "corner": [0, 0, 0]}));
run(s, "solid.cylinder", json!({"base": [0, 33, 4], "axis": [1, 0, 0], "radius": 4, "height": 40, "operation": "new"}));
}
#[test]
@@ -43,7 +43,7 @@ fn revolute_hinge_turns_about_the_knuckle() {
run(&mut s, "occurrence.ground", json!({"occurrence": o, "grounded": true}));
let r = run(
&mut s,
"JointAssembleCmdNew",
"joint.create",
json!({
"type": "revolute", "a": {"occurrence": "LeafA", "circle": [1, 37, 4]}, "b": {"occurrence": "LeafB", "circle": [1, 37, 4]}, "flip": true, "name": "Hinge"}),
);
@@ -54,7 +54,7 @@ fn revolute_hinge_turns_about_the_knuckle() {
let dof = l["dof"].as_array().unwrap().iter().find(|d| d["occurrence"] == occ(&s, "LeafB")).unwrap()["dof"].clone();
assert_eq!(dof, 1, "{l}");
// Drive 90°: B turns about the knuckle axis (through (0, 33, 4), along the frame's z = −X).
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Hinge", "value": 90}));
run(&mut s, "joint.drive", json!({"joint": "Hinge", "value": 90}));
let want = solvecraft_doc::rigid(Vec3::ZERO, Vec3::new(0.0, 33.0, 4.0), Vec3::new(-1.0, 0.0, 0.0), FRAC_PI_2);
let p = Vec3::new(20.0, 0.0, 0.0);
assert!(near(place(&s, "LeafB", p), solvecraft_doc::apply_point(&want, p)), "{:?}", place(&s, "LeafB", p));
@@ -63,9 +63,9 @@ fn revolute_hinge_turns_about_the_knuckle() {
assert!(near(place(&s, "LeafA", p), p));
assert!(near(place(&s, "LeafB", Vec3::new(10.0, 33.0, 4.0)), Vec3::new(10.0, 33.0, 4.0)));
// Expressions drive too, and undo puts it back.
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Hinge", "value": "pi rad"}));
run(&mut s, "joint.drive", json!({"joint": "Hinge", "value": "pi rad"}));
assert!(near(place(&s, "LeafB", p), Vec3::new(20.0, 66.0, 8.0)));
run(&mut s, "UndoCommand", json!({}));
run(&mut s, "edit.undo", json!({}));
assert!(near(place(&s, "LeafB", p), Vec3::new(20.0, 29.0, 37.0)));
}
@@ -74,8 +74,8 @@ fn two_cubes() -> Session {
let mut s = Session::default();
for (name, x) in [("A", 0), ("B", 20)] {
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "FusionCreateNewComponentCommand", json!({"name": name}));
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]}));
run(&mut s, "component.create", json!({"name": name}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]}));
}
let o = occ(&s, "A");
run(&mut s, "occurrence.ground", json!({"occurrence": o, "grounded": true}));
@@ -88,19 +88,19 @@ fn slider_limits_clamp() {
// B's bottom face onto A's top face (faces meet), sliding along their common normal.
let r = run(
&mut s,
"JointAssembleCmdNew",
"joint.create",
json!({"type": "slider", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}, "limits": [[0, 10]], "name": "Rail"}),
);
assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{r}");
// B now sits on A.
assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 0.0)), Vec3::new(5.0, 5.0, 10.0)), "{:?}", place(&s, "B", Vec3::new(25.0, 5.0, 0.0)));
let r = run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": 25}));
let r = run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 25}));
assert_eq!(r["values"], json!([10.0]), "{r}");
assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 0.0)), Vec3::new(5.0, 5.0, 20.0)));
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": -5}));
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": -5}));
assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 0.0)), Vec3::new(5.0, 5.0, 10.0)));
// Changing the limits clamps the current value.
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": 8}));
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 8}));
run(&mut s, "joint.limits", json!({"joint": "Rail", "min": 0, "max": 4}));
assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 0.0)), Vec3::new(5.0, 5.0, 14.0)));
// Joints follow edits: A gets taller, B rides on its new top.
@@ -111,7 +111,7 @@ fn slider_limits_clamp() {
.find(|f| matches!(f.kind, solvecraft_doc::FeatureKind::Box { .. }))
.and_then(|f| f.param_names.get(2).cloned())
.unwrap_or_default();
run(&mut s, "ChangeParameterCommand", json!({"name": h, "expression": "15"}));
run(&mut s, "parameters.change", json!({"name": h, "expression": "15"}));
assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 0.0)), Vec3::new(5.0, 5.0, 19.0)), "{:?}", place(&s, "B", Vec3::new(25.0, 5.0, 0.0)));
}
@@ -119,12 +119,12 @@ fn slider_limits_clamp() {
fn rigid_cycles_solve_and_conflicts_report() {
let mut s = two_cubes();
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "FusionCreateNewComponentCommand", json!({"name": "C"}));
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "corner": [40, 0, 0]}));
run(&mut s, "component.create", json!({"name": "C"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [40, 0, 0]}));
run(&mut s, "component.activate", json!({"component": "root"}));
// A rigid group and a closing as-built joint: a consistent loop.
run(&mut s, "RigidGroupCmd", json!({"occurrences": ["A", "B", "C"]}));
let r = run(&mut s, "JointAsBuiltCmd", json!({"type": "rigid", "a": "B", "b": "C"}));
run(&mut s, "joint.rigid_group", json!({"occurrences": ["A", "B", "C"]}));
let r = run(&mut s, "joint.as_built", json!({"type": "rigid", "a": "B", "b": "C"}));
assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{r}");
assert!(near(place(&s, "C", Vec3::new(40.0, 0.0, 0.0)), Vec3::new(40.0, 0.0, 0.0)));
let l = run(&mut s, "joint.list", json!({}));
@@ -132,7 +132,7 @@ fn rigid_cycles_solve_and_conflicts_report() {
// A joint that wants C elsewhere conflicts and says so.
let r = run(
&mut s,
"JointAssembleCmdNew",
"joint.create",
json!({"type": "rigid", "a": {"occurrence": "A", "point": [0, 0, 0]}, "b": {"occurrence": "C", "point": [40, 0, 0]}, "name": "Wrong"}),
);
let c = r["conflicts"].as_array().cloned().unwrap_or_default();
@@ -143,37 +143,37 @@ fn rigid_cycles_solve_and_conflicts_report() {
assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0));
assert!(
s.execute(
"JointAssembleCmdNew",
"joint.create",
&json!({"type": "rigid", "a": {"occurrence": "A", "point": [0, 0, 0]}, "b": {"occurrence": "A", "point": [1, 0, 0]}})
)
.is_err()
);
assert!(s.execute("JointAssembleCmdNew", &json!({"type": "hinge"})).is_err());
assert!(s.execute("joint.create", &json!({"type": "hinge"})).is_err());
}
#[test]
fn motion_links_and_interference() {
let mut s = two_cubes();
run(&mut s, "JointAssembleCmdNew", json!({"type": "cylindrical", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}, "name": "Screw"}));
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Screw", "values": [0, 0]}));
run(&mut s, "joint.create", json!({"type": "cylindrical", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}, "name": "Screw"}));
run(&mut s, "joint.drive", json!({"joint": "Screw", "values": [0, 0]}));
// No overlap while B sits on A; then push B 4 mm into A.
let i = run(&mut s, "InterferenceCheckCommand", json!({}));
let i = run(&mut s, "inspect.interference", json!({}));
assert_eq!(i["count"], 0, "{i}");
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Screw", "values": [0, -4]}));
let i = run(&mut s, "InterferenceCheckCommand", json!({}));
run(&mut s, "joint.drive", json!({"joint": "Screw", "values": [0, -4]}));
let i = run(&mut s, "inspect.interference", json!({}));
assert_eq!(i["count"], 1, "{i}");
assert!((i["interferences"][0]["volume_mm3"].as_f64().unwrap_or(0.0) - 400.0).abs() < 1e-3, "{i}");
// A motion link: a second joint's value follows.
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "FusionCreateNewComponentCommand", json!({"name": "D"}));
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "corner": [60, 0, 0]}));
run(&mut s, "component.create", json!({"name": "D"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [60, 0, 0]}));
run(
&mut s,
"JointAssembleCmdNew",
"joint.create",
json!({"type": "slider", "a": {"occurrence": "A", "face": [5, 5, 10]}, "b": {"occurrence": "D", "face": [65, 5, 0]}, "name": "Follower"}),
);
run(&mut s, "FusionMotionRelationshipCommand", json!({"a": "Screw", "ia": 1, "b": "Follower", "ratio": 2}));
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Screw", "values": [0, 3]}));
run(&mut s, "joint.motion_link", json!({"a": "Screw", "ia": 1, "b": "Follower", "ratio": 2}));
run(&mut s, "joint.drive", json!({"joint": "Screw", "values": [0, 3]}));
assert!(near(place(&s, "D", Vec3::new(65.0, 5.0, 0.0)), Vec3::new(5.0, 5.0, 16.0)), "{:?}", place(&s, "D", Vec3::new(65.0, 5.0, 0.0)));
// Saved and reopened, joints come back.
let back = solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
@@ -188,7 +188,7 @@ fn motion_links_and_interference() {
#[test]
fn faces_mate_by_default() {
let mut s = two_cubes();
run(&mut s, "JointAssembleCmdNew", json!({"type": "rigid", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}, "name": "Stack"}));
run(&mut s, "joint.create", json!({"type": "rigid", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}, "name": "Stack"}));
// B's bottom centre (25, 5, 0) at A's top centre, B above it.
assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 0.0)), Vec3::new(5.0, 5.0, 10.0)));
assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 10.0)), Vec3::new(5.0, 5.0, 20.0)), "{:?}", place(&s, "B", Vec3::new(25.0, 5.0, 10.0)));
+8 -8
View File
@@ -12,7 +12,7 @@ fn run(s: &mut Session, id: &str, p: Value) -> Value {
}
fn volume(s: &mut Session) -> f64 {
run(s, "MeasureCommand", json!({}))["total"]["volume_mm3"].as_f64().unwrap()
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap()
}
/// A box with a drilled hole (cone tip) shells: the cavity is the box shrunk by the wall and the
@@ -21,15 +21,15 @@ fn volume(s: &mut Session) -> f64 {
fn shell_a_box_with_a_drilled_hole() {
use std::f64::consts::PI;
let mut s = Session::default();
run(&mut s, "PrimitiveBox", json!({"length": 40, "width": 30, "height": 20}));
run(&mut s, "FusionHoleCommand", json!({"position": [20, 15, 20], "diameter": 8, "depth": 10, "type": "drilled", "tip_angle": 118}));
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20}));
run(&mut s, "solid.hole", json!({"position": [20, 15, 20], "diameter": 8, "depth": 10, "type": "drilled", "tip_angle": 118}));
let v_body = volume(&mut s);
let (r, t) = (4.0, 2.0);
let a = (59.0f64).to_radians();
// Cylinder depth from the measured body: 24000 − π r² h − π r² (r / tan α) / 3.
let h = (24000.0 - v_body - PI * r * r * (r / a.tan()) / 3.0) / (PI * r * r);
assert!(h > 9.0 && h < 11.0, "{h}");
run(&mut s, "FusionShellBodyCommand", json!({"faces": [[5, 5, 20]], "thickness": t}));
run(&mut s, "solid.shell", json!({"faces": [[5, 5, 20]], "thickness": t}));
let v_shell = volume(&mut s);
let apex = 20.0 - h - r / a.tan() - t / a.sin();
let big_r = r + t;
@@ -45,8 +45,8 @@ fn shell_a_box_with_a_drilled_hole() {
fn offset_cone_and_sphere_faces() {
use std::f64::consts::PI;
let mut s = Session::default();
run(&mut s, "PrimitiveSphere", json!({"center": [0, 0, 0], "radius": 10}));
let r = run(&mut s, "FusionOffsetFacesCommand", json!({"faces": [[10, 0, 0]], "distance": 2}));
run(&mut s, "solid.sphere", json!({"center": [0, 0, 0], "radius": 10}));
let r = run(&mut s, "solid.offset_face", json!({"faces": [[10, 0, 0]], "distance": 2}));
let v = volume(&mut s);
assert!((v - 4.0 / 3.0 * PI * 1728.0).abs() < 1e-3 * v, "{v} {r}");
}
@@ -56,10 +56,10 @@ fn offset_cone_and_sphere_faces() {
fn offset_torus_face() {
use std::f64::consts::PI;
let mut s = Session::default();
run(&mut s, "PrimitiveTorus", json!({"major": 20, "minor": 5}));
run(&mut s, "solid.torus", json!({"major": 20, "minor": 5}));
let v0 = volume(&mut s);
assert!((v0 - 2.0 * PI * PI * 20.0 * 25.0).abs() < 1e-3 * v0, "{v0}");
run(&mut s, "FusionOffsetFacesCommand", json!({"faces": [[25, 0, 0]], "distance": 1}));
run(&mut s, "solid.offset_face", json!({"faces": [[25, 0, 0]], "distance": 1}));
let v = volume(&mut s);
assert!((v - 2.0 * PI * PI * 20.0 * 36.0).abs() < 1e-3 * v, "{v}");
}
+3 -3
View File
@@ -1,4 +1,4 @@
//! Measuring picked geometry (`MeasureCommand` with `items`): one item gives its length, area,
//! Measuring picked geometry (`inspect.measure` with `items`): one item gives its length, area,
//! volume or position; two give the minimum distance between them (with the two closest
//! points) and, where both have a direction (planar faces, straight edges), the angle.
@@ -146,9 +146,9 @@ fn closest(a: &Geo, b: &Geo) -> Option<(Vec3, Vec3)> {
}
}
/// `MeasureCommand` with `items` (one or two selections).
/// `inspect.measure` with `items` (one or two selections).
pub(crate) fn measure_items(s: &Session, items: &Value) -> Result<Value> {
let cmd = "MeasureCommand";
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 {
return Err(bad(cmd, "measure one or two items"));
+5 -3
View File
@@ -1,6 +1,6 @@
//! The command registry. Ids follow Fusion's command ids where Fusion has the command; the
//! toolbar placement (`tab`, `panel`) follows Fusion's Design workspace so the registry doubles
//! as the parity metric (`cargo xtask parity`).
//! The command registry. Ids are SolveCraft's own dotted ids (`solid.extrude`,
//! `sketch.rectangle.two_point`); the toolbar placement (`tab`, `panel`) follows the familiar
//! Design workspace layout. Ids used before the rename still resolve (`legacy_ids`).
mod browser;
mod clipboard;
@@ -165,5 +165,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
let all = command_specs();
// Ids from before the rename still find their command.
let id = crate::legacy_ids::current_id(id);
all.iter().find(|c| c.id == id).or_else(|| all.iter().find(|c| c.id.eq_ignore_ascii_case(id))).copied()
}
+8 -8
View File
@@ -13,22 +13,22 @@ use crate::params::{bad, bool_, str_, vec3};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("EnableContactSetsCmd", "Enable Contact Sets", enable_sets).at("SOLID", "ASSEMBLE").icon("joint").params(
CommandSpec::new("contact.enable_sets", "Enable Contact Sets", enable_sets).at("SOLID", "ASSEMBLE").icon("joint").params(
"enabled?: bool (default true) — driving joints and motion studies stop where bodies in one contact set would pass through each other",
),
CommandSpec::new("EnableAllContactCmd", "Enable All Contact", enable_all)
CommandSpec::new("contact.enable_all", "Enable All Contact", enable_all)
.at("SOLID", "ASSEMBLE")
.icon("joint")
.params("every occurrence takes part in contact (one set of all)"),
CommandSpec::new("DisableAllContactCmd", "Disable Contact", disable_all).at("SOLID", "ASSEMBLE").icon("joint").params("contact off"),
CommandSpec::new("ContactSetCmd", "New Contact Set", new_set)
CommandSpec::new("contact.disable_all", "Disable Contact", disable_all).at("SOLID", "ASSEMBLE").icon("joint").params("contact off"),
CommandSpec::new("contact.create", "New Contact Set", new_set)
.at("SOLID", "ASSEMBLE")
.icon("joint")
.params("occurrences: [ids or names] (two or more); name?"),
CommandSpec::new("contact.edit", "Edit Contact Set", edit_set).params("set: id|name; occurrences?; name?; suppressed?: bool; delete?: bool"),
CommandSpec::new("contact.list", "List Contact Sets", list_sets).noundo().params("→ sets, enabled, all"),
CommandSpec::new("contact.check", "Check Contact", check).noundo().params("→ pairs in contact sets that pass through each other now"),
CommandSpec::new("FusionMotionStudyCommand", "Motion Study", study).at("SOLID", "ASSEMBLE").icon("joint").params(
CommandSpec::new("motion.study", "Motion Study", study).at("SOLID", "ASSEMBLE").icon("joint").params(
"name?; study? (edit an existing one); steps? (timeline length, default 100); keys: [{joint, index?, points: [[step, value], …]}] \
(values: degrees for angles, mm for distances, or expressions)",
),
@@ -285,7 +285,7 @@ fn drive_with_contact(s: &mut Session, want: &[(u64, Vec<f64>)]) -> Result<Value
Ok(json!({"values": values, "stopped_by_contact": stopped, "conflicts": sol.conflicts.len()}))
}
/// Drive one joint (FusionMoveJointsCommand), honouring contact sets.
/// Drive one joint (joint.drive), honouring contact sets.
pub fn drive_joint(s: &mut Session, id: u64, q: Vec<f64>) -> Result<Value> {
drive_with_contact(s, &[(id, q)])
}
@@ -311,7 +311,7 @@ fn disable_all(s: &mut Session, _p: &Value) -> Result<Value> {
}
fn new_set(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ContactSetCmd";
let cmd = "contact.create";
let occ = occurrences(s, p, cmd)?;
if occ.len() < 2 {
return Err(bad(cmd, "a contact set needs two or more occurrences"));
@@ -387,7 +387,7 @@ fn study_index(s: &Session, p: &Value, cmd: &str) -> Result<usize> {
}
fn study(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionMotionStudyCommand";
let cmd = "motion.study";
let existing = if p.get("study").is_some() { Some(study_index(s, p, cmd)?) } else { None };
let steps = match p.get("steps") {
Some(v) => {
+23 -24
View File
@@ -26,14 +26,14 @@ fn rail() -> Session {
let mut s = Session::default();
for (name, x) in [("A", 0), ("B", 20)] {
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "FusionCreateNewComponentCommand", json!({"name": name}));
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]}));
run(&mut s, "component.create", json!({"name": name}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]}));
}
run(&mut s, "component.activate", json!({"component": "root"}));
let a = occ(&s, "A");
run(&mut s, "occurrence.ground", json!({"occurrence": a, "grounded": true}));
run(&mut s, "JointAssembleCmdNew", json!({"type": "slider", "a": {"face": [10, 5, 5]}, "b": {"face": [20, 5, 5]}, "name": "Rail"}));
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": 10}));
run(&mut s, "joint.create", json!({"type": "slider", "a": {"face": [10, 5, 5]}, "b": {"face": [20, 5, 5]}, "name": "Rail"}));
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 10}));
assert!((b_x(&s) - 20.0).abs() < 1e-6, "{}", b_x(&s));
s
}
@@ -42,45 +42,44 @@ fn rail() -> Session {
fn contact_sets_stop_a_driven_joint_at_touching() {
let mut s = rail();
// No contact: B passes into A.
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": -5}));
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": -5}));
assert!((b_x(&s) - 5.0).abs() < 1e-6);
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": 10}));
run(&mut s, "ContactSetCmd", json!({"occurrences": ["A", "B"]}));
let r = run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": -5}));
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 10}));
run(&mut s, "contact.create", json!({"occurrences": ["A", "B"]}));
let r = run(&mut s, "joint.drive", json!({"joint": "Rail", "value": -5}));
assert!(r["stopped_by_contact"].is_object(), "{r}");
// Stopped where the faces touch (to the search's resolution, 15 mm / 2^14).
assert!((b_x(&s) - 10.0).abs() < 2e-3, "{}", b_x(&s));
assert!(b_x(&s) >= 10.0 - 2e-3, "a touch, not a pass");
assert_eq!(run(&mut s, "contact.check", json!({}))["collisions"].as_array().map(Vec::len), Some(0));
// Away is free; contact off lets it through again.
let r = run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": 3}));
let r = run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 3}));
assert!(r["stopped_by_contact"].is_null() && (b_x(&s) - 13.0).abs() < 1e-6, "{r}");
run(&mut s, "DisableAllContactCmd", json!({}));
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": -5}));
run(&mut s, "contact.disable_all", json!({}));
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": -5}));
assert!((b_x(&s) - 5.0).abs() < 1e-6);
// Suppressed sets don't count; Enable All Contact puts every occurrence in one.
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": 10}));
run(&mut s, "EnableContactSetsCmd", json!({}));
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 10}));
run(&mut s, "contact.enable_sets", json!({}));
run(&mut s, "contact.edit", json!({"set": "Contact Set1", "suppressed": true}));
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": -5}));
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": -5}));
assert!((b_x(&s) - 5.0).abs() < 1e-6);
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": 10}));
run(&mut s, "EnableAllContactCmd", json!({}));
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": -5}));
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 10}));
run(&mut s, "contact.enable_all", json!({}));
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": -5}));
assert!((b_x(&s) - 10.0).abs() < 2e-3);
assert!(s.execute("ContactSetCmd", &json!({"occurrences": ["A"]})).is_err());
assert!(s.execute("contact.create", &json!({"occurrences": ["A"]})).is_err());
}
#[test]
fn motion_study_plays_and_exports() {
let mut s = rail();
let r =
run(&mut s, "FusionMotionStudyCommand", json!({"name": "Push", "steps": 100, "keys": [{"joint": "Rail", "points": [[0, 10], [100, -5]]}]}));
let r = run(&mut s, "motion.study", json!({"name": "Push", "steps": 100, "keys": [{"joint": "Rail", "points": [[0, 10], [100, -5]]}]}));
assert_eq!(r["keys"], 1);
run(&mut s, "motion.play", json!({"study": "Push", "step": 50}));
assert!((b_x(&s) - 12.5).abs() < 1e-6, "{}", b_x(&s));
// With contact, the end of the study stops at touching.
run(&mut s, "ContactSetCmd", json!({"occurrences": ["A", "B"]}));
run(&mut s, "contact.create", json!({"occurrences": ["A", "B"]}));
let r = run(&mut s, "motion.play", json!({"study": "Push", "step": 100}));
assert!(r["stopped_by_contact"].is_object() && (b_x(&s) - 10.0).abs() < 2e-3, "{r}");
// Export: positions per sample; the design keeps its joint value.
@@ -104,7 +103,7 @@ fn motion_study_plays_and_exports() {
let text = std::fs::read_to_string(&csv).unwrap();
assert!(text.starts_with("step,occurrence,x,y,z") && text.lines().count() == 1 + 3 * 2, "{text}");
let _ = std::fs::remove_dir_all(&dir);
assert!(s.execute("FusionMotionStudyCommand", &json!({"keys": [{"joint": "Rail", "index": 3, "points": [[0, 1]]}]})).is_err());
assert!(s.execute("motion.study", &json!({"keys": [{"joint": "Rail", "index": 3, "points": [[0, 1]]}]})).is_err());
run(&mut s, "motion.delete", json!({"study": "Push"}));
assert_eq!(run(&mut s, "motion.list", json!({}))["studies"].as_array().map(Vec::len), Some(0));
}
@@ -117,7 +116,7 @@ fn exploded_views_show_without_changing_the_design() {
assert_eq!(r["moves"].as_array().map(Vec::len), Some(2), "{r}");
run(&mut s, "explode.show", json!({"view": "Apart"}));
// Centres at x 5 and 25 → middle 15: each moves 10 mm further out.
let bb = |s: &mut Session, b: &str| run(s, "MeasureCommand", json!({"bodies": [b]}))["bodies"][0]["bbox"]["min"][0].as_f64().unwrap_or(f64::NAN);
let bb = |s: &mut Session, b: &str| run(s, "inspect.measure", json!({"bodies": [b]}))["bodies"][0]["bbox"]["min"][0].as_f64().unwrap_or(f64::NAN);
let names: Vec<String> = s.world_state().bodies.iter().map(|b| b.name.clone()).collect();
assert!((bb(&mut s, &names[0]) + 10.0).abs() < 1e-6, "{names:?}");
assert!((bb(&mut s, &names[1]) - 30.0).abs() < 1e-6);
@@ -127,7 +126,7 @@ fn exploded_views_show_without_changing_the_design() {
run(&mut s, "explode.create", json!({"name": "Hand", "moves": [{"occurrence": "B", "translate": [0, 0, 50]}]}));
run(&mut s, "explode.show", json!({"view": "Hand"}));
assert!(
(run(&mut s, "MeasureCommand", json!({"bodies": [names[1]]}))["bodies"][0]["bbox"]["min"][2].as_f64().unwrap_or(0.0) - 50.0).abs() < 1e-6
(run(&mut s, "inspect.measure", json!({"bodies": [names[1]]}))["bodies"][0]["bbox"]["min"][2].as_f64().unwrap_or(0.0) - 50.0).abs() < 1e-6
);
assert_eq!(run(&mut s, "explode.list", json!({}))["views"].as_array().map(Vec::len), Some(2));
}
+2 -2
View File
@@ -10,7 +10,7 @@ use crate::params::{bad, bool_, str_};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("ChangeParameterCommand", "Change Parameters", change_param).at("SOLID", "MODIFY").icon("params").params(
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",
),
@@ -65,7 +65,7 @@ fn known(s: &Session, name: &str) -> bool {
}
fn change_param(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ChangeParameterCommand";
let cmd = "parameters.change";
if let Some(n) = str_(p, "delete") {
s.doc_mut().remove_param(n.trim())?;
return Ok(json!({"deleted": n.trim()}));
+21 -21
View File
@@ -17,7 +17,7 @@ fn err(s: &mut Session, id: &str, p: Value) -> String {
}
fn volume(s: &mut Session) -> f64 {
run(s, "MeasureCommand", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
}
fn rel(a: f64, b: f64) -> f64 {
@@ -36,13 +36,13 @@ fn expr_of(s: &mut Session, name: &str) -> String {
/// A sketched block 40 x (len / 2) x 10 whose width is an expression of a user parameter.
fn block(s: &mut Session) {
run(s, "parameters.add", json!({"name": "len", "expression": "60 mm", "comment": "overall length", "favorite": true}));
run(s, "SketchCreate", json!({"plane": "XY", "name": "Base"}));
run(s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [40, 30]}));
run(s, "ConstraintCoincident", json!({"a": "p1", "b": "origin"}));
run(s, "SketchDimension", json!({"entities": ["l1"], "value": 40}));
run(s, "SketchDimension", json!({"entities": ["l2"], "value": "len/2"}));
run(s, "SketchStop", json!({}));
run(s, "Extrude", json!({"distance": 10, "body_name": "Block"}));
run(s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
run(s, "sketch.constraint.coincident", json!({"a": "p1", "b": "origin"}));
run(s, "sketch.dimension", json!({"entities": ["l1"], "value": 40}));
run(s, "sketch.dimension", json!({"entities": ["l2"], "value": "len/2"}));
run(s, "sketch.finish", json!({}));
run(s, "solid.extrude", json!({"distance": 10, "body_name": "Block"}));
}
#[test]
@@ -50,20 +50,20 @@ fn width_follows_an_expression() {
let mut s = Session::default();
block(&mut s);
assert!(rel(volume(&mut s), 40.0 * 30.0 * 10.0) < 1e-9);
let r = run(&mut s, "ChangeParameterCommand", json!({"name": "len", "expression": "100 mm"}));
let r = run(&mut s, "parameters.change", json!({"name": "len", "expression": "100 mm"}));
assert_eq!(r["errors"].as_array().map(Vec::len), Some(0), "{r}");
assert!(rel(volume(&mut s), 40.0 * 50.0 * 10.0) < 1e-9);
// Units convert: 4 in = 101.6 mm.
run(&mut s, "ChangeParameterCommand", json!({"name": "len", "expression": "4 in"}));
run(&mut s, "parameters.change", json!({"name": "len", "expression": "4 in"}));
assert!(rel(volume(&mut s), 40.0 * 50.8 * 10.0) < 1e-9);
// A parameter in inches reads bare numbers as inches.
run(&mut s, "ChangeParameterCommand", json!({"name": "len", "expression": "5", "unit": "in"}));
run(&mut s, "parameters.change", json!({"name": "len", "expression": "5", "unit": "in"}));
assert!(rel(volume(&mut s), 40.0 * 63.5 * 10.0) < 1e-9);
let l = run(&mut s, "parameters.list", json!({}));
assert_eq!(row(&l, "len")["value"], 5.0, "{l}");
assert_eq!(row(&l, "len")["unit"], "in");
// A failing parameter fails the features using it, with its error.
let e = err(&mut s, "ChangeParameterCommand", json!({"name": "len", "expression": "5 deg"}));
let e = err(&mut s, "parameters.change", json!({"name": "len", "expression": "5 deg"}));
assert!(e.contains("expected a length, got an angle"), "{e}");
}
@@ -75,14 +75,14 @@ fn feature_inputs_are_named_and_editable() {
let dist = l["parameters"].as_array().unwrap().iter().find(|r| r["input"] == "Distance").unwrap().clone();
assert_eq!(dist["feature"], "Extrude1", "{l}");
let d = dist["name"].as_str().unwrap().to_string();
run(&mut s, "ChangeParameterCommand", json!({"name": d, "expression": "len / 3"}));
run(&mut s, "parameters.change", json!({"name": d, "expression": "len / 3"}));
assert!(rel(volume(&mut s), 40.0 * 30.0 * 20.0) < 1e-9);
// Editing the feature keeps its parameter name.
run(&mut s, "timeline.edit", json!({"feature": "Extrude1", "set": {"extent": {"distance": "12 mm"}}}));
assert!(rel(volume(&mut s), 40.0 * 30.0 * 12.0) < 1e-9);
assert_eq!(expr_of(&mut s, &d), "12 mm");
// Another feature can use it.
run(&mut s, "PrimitiveBox", json!({"length": d, "width": 5, "height": 5, "corner": [100, 0, 0]}));
run(&mut s, "solid.box", json!({"length": d, "width": 5, "height": 5, "corner": [100, 0, 0]}));
assert!(rel(volume(&mut s), 40.0 * 30.0 * 12.0 + 12.0 * 25.0) < 1e-9);
let u = run(&mut s, "parameters.users", json!({"name": d}));
assert_eq!(u["users"].as_array().map(Vec::len), Some(1), "{u}");
@@ -101,7 +101,7 @@ fn unit_errors_and_cycles_are_refused() {
assert!(e.contains("expected an angle, got a length"), "{e}");
assert!(err(&mut s, "parameters.add", json!({"name": "x", "expression": "1", "unit": "furlong"})).contains("unknown unit"));
run(&mut s, "parameters.add", json!({"name": "b", "expression": "a * 2"}));
let e = err(&mut s, "ChangeParameterCommand", json!({"name": "a", "expression": "b + 1"}));
let e = err(&mut s, "parameters.change", json!({"name": "a", "expression": "b + 1"}));
assert!(e.contains("circular reference: a → b → a"), "{e}");
assert!(err(&mut s, "parameters.add", json!({"name": "selfref", "expression": "selfref"})).contains("circular"));
assert!(err(&mut s, "parameters.add", json!({"name": "a", "expression": "1"})).contains("already"));
@@ -132,7 +132,7 @@ fn rename_updates_every_reference() {
let l = run(&mut s, "parameters.list", json!({"favorites": true}));
assert_eq!(l["count"], 1, "the favourite follows the rename");
assert_eq!(row(&l, "length")["comment"], "overall length");
run(&mut s, "ChangeParameterCommand", json!({"name": "length", "expression": "80"}));
run(&mut s, "parameters.change", json!({"name": "length", "expression": "80"}));
assert!(rel(volume(&mut s), 40.0 * 40.0 * 10.0) < 1e-9);
// Model and feature parameters can be renamed too: dimensions and features follow.
let dim = s.doc.features.first().and_then(|f| match &f.kind {
@@ -140,17 +140,17 @@ fn rename_updates_every_reference() {
_ => None,
});
let dim = dim.unwrap_or_default();
run(&mut s, "ChangeParameterCommand", json!({"name": dim, "new_name": "base_w", "expression": "50"}));
run(&mut s, "parameters.change", json!({"name": dim, "new_name": "base_w", "expression": "50"}));
assert!(rel(volume(&mut s), 50.0 * 40.0 * 10.0) < 1e-9);
let ext = s.doc.features.get(1).and_then(|f| f.param_names.first().cloned()).unwrap_or_default();
run(&mut s, "parameters.rename", json!({"name": ext, "new_name": "thick"}));
run(&mut s, "ChangeParameterCommand", json!({"name": "thick", "expression": "base_w / 10"}));
run(&mut s, "parameters.change", json!({"name": "thick", "expression": "base_w / 10"}));
assert!(rel(volume(&mut s), 50.0 * 40.0 * 5.0) < 1e-9);
assert!(err(&mut s, "parameters.rename", json!({"name": "twice", "new_name": "length"})).contains("already"));
assert!(err(&mut s, "parameters.rename", json!({"name": "nope", "new_name": "x"})).contains("unknown"));
// Undo puts the old name back.
run(&mut s, "UndoCommand", json!({}));
run(&mut s, "UndoCommand", json!({}));
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert!(s.doc.features.get(1).is_some_and(|f| f.param_names.first() == Some(&ext)));
}
@@ -165,7 +165,7 @@ fn deleting_a_used_parameter_lists_its_users() {
assert_eq!(u["users"].as_array().map(Vec::len), Some(2), "{u}");
assert_eq!(u["drives"][0]["name"], "Base", "{u}");
let dim = u["users"].as_array().unwrap().iter().find(|x| x["name"] != "spare").unwrap()["name"].as_str().unwrap().to_string();
let e = err(&mut s, "ChangeParameterCommand", json!({"delete": dim}));
let e = err(&mut s, "parameters.change", json!({"delete": dim}));
assert!(e.contains("Base dimension"), "{e}");
run(&mut s, "parameters.delete", json!({"name": "spare"}));
assert!(s.doc.param("spare").is_none());
+15 -15
View File
@@ -18,7 +18,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("parts.library", "Standard Parts", library)
.noundo()
.params("family? → families (name, standard, sizes, lengths) and, with family, each size's dimensions (mm)"),
CommandSpec::new("FusionFastenersCommand", "Insert Fastener", insert).at("SOLID", "INSERT").icon("fastener").params(
CommandSpec::new("parts.fastener", "Insert Fastener", insert).at("SOLID", "INSERT").icon("fastener").params(
"the Insert Part dialog's command (same as parts.insert): family, size, length?, at?|point?+direction?, name?",
),
CommandSpec::new("parts.insert", "Insert Part", insert).icon("fastener").params(
@@ -215,31 +215,31 @@ fn run(s: &mut Session, id: &str, p: Value) -> Result<Value> {
/// A hex prism (across flats `af`) from z0 to z1, joined or cut.
fn hex(s: &mut Session, af: f64, z0: f64, z1: f64, op: &str) -> Result<()> {
run(s, "SketchCreate", json!({"plane": "XY", "offset": z0}))?;
run(s, "ShapePolygonCircumscribed", json!({"center": [0, 0], "radius": af / 2.0, "sides": 6}))?;
run(s, "SketchStop", json!({}))?;
run(s, "sketch.create", json!({"plane": "XY", "offset": z0}))?;
run(s, "sketch.polygon.circumscribed", json!({"center": [0, 0], "radius": af / 2.0, "sides": 6}))?;
run(s, "sketch.finish", json!({}))?;
let (dist, dir) = if z1 >= z0 { (z1 - z0, "positive") } else { (z0 - z1, "negative") };
run(s, "Extrude", json!({"distance": dist, "direction": dir, "operation": op}))?;
run(s, "solid.extrude", json!({"distance": dist, "direction": dir, "operation": op}))?;
Ok(())
}
fn cyl(s: &mut Session, d: f64, z0: f64, z1: f64, op: &str) -> Result<()> {
run(s, "PrimitiveCylinder", json!({"diameter": d, "height": z1 - z0, "base": [0, 0, z0], "axis": [0, 0, 1], "operation": op}))?;
run(s, "solid.cylinder", json!({"diameter": d, "height": z1 - z0, "base": [0, 0, z0], "axis": [0, 0, 1], "operation": op}))?;
Ok(())
}
/// 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, "SketchCreate", json!({"plane": "XZ"}))?;
run(s, "sketch.create", json!({"plane": "XZ"}))?;
let points: Vec<[f64; 2]> = pts.iter().map(|(r, z)| [*r, *z]).collect();
run(s, "DrawPolyline", json!({"points": points, "closed": true}))?;
run(s, "SketchStop", json!({}))?;
run(s, "Revolve", json!({"axis": "y", "operation": op}))?;
run(s, "sketch.line", json!({"points": points, "closed": true}))?;
run(s, "sketch.finish", json!({}))?;
run(s, "solid.revolve", json!({"axis": "y", "operation": op}))?;
Ok(())
}
fn thread(s: &mut Session, d: f64, pitch: f64, at: Vec3) {
let _ = run(s, "FusionThreadCommand", json!({"face": at, "designation": format!("M{}x{}", d, pitch)}));
let _ = run(s, "solid.thread", json!({"face": at, "designation": format!("M{}x{}", d, pitch)}));
let _ = d;
}
@@ -352,7 +352,7 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
};
let name = str_(p, "name").map(str::to_string).unwrap_or_else(|| part_name(&family, &size, &sh));
let active = s.active_component;
let c = run(s, "FusionCreateNewComponentCommand", json!({"name": name}))?;
let c = run(s, "component.create", json!({"name": name}))?;
let (comp, occ) = (c["component"].as_u64().unwrap_or(0), c["occurrence"].as_u64().unwrap_or(0));
build(s, &sh)?;
s.doc_mut().parts.insert(comp, solvecraft_doc::StandardPart { family: family.clone(), size: size.clone(), length: shape_len(&sh) });
@@ -363,7 +363,7 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
// into the hole (the joint mates the part's −z with the face's outward z).
let j = run(
s,
"JointAssembleCmdNew",
"joint.create",
json!({"type": "rigid", "a": {"occurrence": h, "circle": a}, "b": {"occurrence": occ, "point": [0, 0, 0], "z": [0, 0, -1]}, "name": format!("{name} seat")}),
)?;
out["joint"] = j["joint"].clone();
@@ -500,8 +500,8 @@ mod tests {
#[test]
fn a_screw_seats_in_a_hole_and_resizes() {
let mut s = Session::default();
run(&mut s, "PrimitiveBox", json!({"length": 40, "width": 30, "height": 10, "body_name": "Plate"}));
run(&mut s, "FusionHoleCommand", json!({"position": [20, 15, 10], "diameter": 6.4}));
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "Plate"}));
run(&mut s, "solid.hole", json!({"position": [20, 15, 10], "diameter": 6.4}));
// Pick the hole's rim at the top face.
let r = run(&mut s, "parts.insert", json!({"family": "socket_head_cap_screw", "size": "M6", "length": 20, "at": [23.2, 15, 10]}));
assert!(r["joint"].is_number(), "{r}");
+37 -35
View File
@@ -12,7 +12,7 @@ fn run(s: &mut Session, id: &str, p: Value) -> Value {
}
fn volume(s: &mut Session) -> f64 {
run(s, "MeasureCommand", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
}
fn rel(a: f64, b: f64) -> f64 {
@@ -22,12 +22,12 @@ fn rel(a: f64, b: f64) -> f64 {
/// A 100 x 50 x 5 plate with a hole at the start of a straight path (10,10) → (90,10).
fn plate_with_hole() -> Session {
let mut s = Session::default();
run(&mut s, "PrimitiveBox", json!({"length": 100, "width": 50, "height": 5, "corner": [0, 0, 0]}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "Path"}));
run(&mut s, "DrawPolyline", json!({"points": [[10, 10], [90, 10]], "ids": ["l1"]}));
run(&mut s, "CircleCenterRadius", json!({"center": [10, 10], "radius": 2, "id": "c1"}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"sketch": "Path", "through_all": true, "operation": "cut", "name": "Hole1"}));
run(&mut s, "solid.box", json!({"length": 100, "width": 50, "height": 5, "corner": [0, 0, 0]}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Path"}));
run(&mut s, "sketch.line", json!({"points": [[10, 10], [90, 10]], "ids": ["l1"]}));
run(&mut s, "sketch.circle.center", json!({"center": [10, 10], "radius": 2, "id": "c1"}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Path", "through_all": true, "operation": "cut", "name": "Hole1"}));
s
}
@@ -38,25 +38,25 @@ const HOLE: f64 = PI * 4.0 * 5.0;
fn pattern_on_path_spacing_extent_and_flip() {
let mut s = plate_with_hole();
assert!(rel(volume(&mut s), PLATE - HOLE) < 1e-4);
run(&mut s, "PatternOnPath", json!({"features": ["Hole1"], "path_sketch": "Path", "path": ["l1"], "count": 5, "spacing": 20}));
run(&mut s, "solid.pattern.path", json!({"features": ["Hole1"], "path_sketch": "Path", "path": ["l1"], "count": 5, "spacing": 20}));
assert!(rel(volume(&mut s), PLATE - 5.0 * HOLE) < 1e-4);
// The count is a parameter like any other input.
let n = s.doc.features.last().and_then(|f| f.param_names.first().cloned()).unwrap_or_default();
run(&mut s, "ChangeParameterCommand", json!({"name": n, "expression": "3"}));
run(&mut s, "parameters.change", json!({"name": n, "expression": "3"}));
assert!(rel(volume(&mut s), PLATE - 3.0 * HOLE) < 1e-4);
// Instances past the end of the path are an error.
run(&mut s, "ChangeParameterCommand", json!({"name": n, "expression": "6"}));
run(&mut s, "parameters.change", json!({"name": n, "expression": "6"}));
let err = s.model.results.last().and_then(|r| r.error.clone()).unwrap_or_default();
assert!(err.contains("past the end of the path"), "{err}");
// Distance = first to last instance.
let mut s = plate_with_hole();
run(&mut s, "PatternOnPath", json!({"features": ["Hole1"], "path_sketch": "Path", "path": ["l1"], "count": 3, "distance": 80}));
run(&mut s, "solid.pattern.path", json!({"features": ["Hole1"], "path_sketch": "Path", "path": ["l1"], "count": 3, "distance": 80}));
assert!(rel(volume(&mut s), PLATE - 3.0 * HOLE) < 1e-4);
// Flipped, the path runs from (90,10) back toward (10,10).
let mut s = plate_with_hole();
run(&mut s, "PatternOnPath", json!({"features": ["Hole1"], "path_sketch": "Path", "path": ["l1"], "count": 2, "spacing": 30, "flip": true}));
run(&mut s, "solid.pattern.path", json!({"features": ["Hole1"], "path_sketch": "Path", "path": ["l1"], "count": 2, "spacing": 30, "flip": true}));
// The copy moves by path(30) − path(0) = (60,10) − (90,10) = −30: off the plate side → only 1 hole.
assert!(rel(volume(&mut s), PLATE - HOLE) < 1e-4);
}
@@ -64,47 +64,47 @@ fn pattern_on_path_spacing_extent_and_flip() {
#[test]
fn patterns_of_bodies() {
let mut s = Session::default();
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "body_name": "Cube"}));
run(&mut s, "PatternCircular", json!({"bodies": ["Cube"], "axis": "Z", "count": 4}));
let m = run(&mut s, "MeasureCommand", json!({}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "body_name": "Cube"}));
run(&mut s, "solid.pattern.circular", json!({"bodies": ["Cube"], "axis": "Z", "count": 4}));
let m = run(&mut s, "inspect.measure", json!({}));
assert_eq!(m["body_count"], 4, "{m}");
assert!(rel(volume(&mut s), 4000.0) < 1e-4);
run(&mut s, "PatternRectangular", json!({"bodies": ["Cube"], "dir1": [0, 0, 1], "count1": 3, "spacing1": 20}));
run(&mut s, "solid.pattern.rectangular", json!({"bodies": ["Cube"], "dir1": [0, 0, 1], "count1": 3, "spacing1": 20}));
assert!(rel(volume(&mut s), 6000.0) < 1e-4);
assert!(s.execute("PatternRectangular", &json!({"bodies": ["Nope"], "dir1": [1, 0, 0], "count1": 2, "spacing1": 5})).is_err());
assert!(s.execute("solid.pattern.rectangular", &json!({"bodies": ["Nope"], "dir1": [1, 0, 0], "count1": 2, "spacing1": 5})).is_err());
}
#[test]
fn patterns_of_components_place_occurrences() {
let mut s = Session::default();
run(&mut s, "FusionCreateNewComponentCommand", json!({"name": "Peg"}));
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0]}));
run(&mut s, "component.create", json!({"name": "Peg"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0]}));
run(&mut s, "component.activate", json!({"component": "root"}));
let r = run(&mut s, "PatternRectangular", json!({"components": ["Peg"], "dir1": [0, 0, 1], "count1": 3, "spacing1": 20}));
let r = run(&mut s, "solid.pattern.rectangular", json!({"components": ["Peg"], "dir1": [0, 0, 1], "count1": 3, "spacing1": 20}));
assert_eq!(r["occurrences"].as_array().map(Vec::len), Some(2), "{r}");
let m = run(&mut s, "MeasureCommand", json!({}));
let m = run(&mut s, "inspect.measure", json!({}));
assert_eq!(m["body_count"], 3, "{m}");
let zs: Vec<f64> = m["bodies"].as_array().unwrap().iter().filter_map(|b| b["bbox"]["min"][2].as_f64()).collect();
for z in [0.0, 20.0, 40.0] {
assert!(zs.iter().any(|x| (x - z).abs() < 1e-6), "{zs:?}");
}
assert!(s.execute("PatternCircular", &json!({"components": ["Nope"], "axis": "Z", "count": 2})).is_err());
assert!(s.execute("solid.pattern.circular", &json!({"components": ["Nope"], "axis": "Z", "count": 2})).is_err());
}
#[test]
fn path_pattern_orientation_follows_the_path() {
let width_of_copy = |orient: &str| {
let mut s = Session::default();
run(&mut s, "PrimitiveBox", json!({"length": 2, "width": 4, "height": 2, "corner": [19, -2, 0], "body_name": "Tab"}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "Arc"}));
run(&mut s, "ArcCenterTwoPoint", json!({"center": [0, 0], "start": [20, 0], "sweep": 90, "id": "a1"}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "solid.box", json!({"length": 2, "width": 4, "height": 2, "corner": [19, -2, 0], "body_name": "Tab"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Arc"}));
run(&mut s, "sketch.arc.center_point", json!({"center": [0, 0], "start": [20, 0], "sweep": 90, "id": "a1"}));
run(&mut s, "sketch.finish", json!({}));
run(
&mut s,
"PatternOnPath",
"solid.pattern.path",
json!({"bodies": ["Tab"], "path_sketch": "Arc", "path": ["a1"], "count": 2, "distance": "pi * 10", "orientation": orient}),
);
let m = run(&mut s, "MeasureCommand", json!({}));
let m = run(&mut s, "inspect.measure", json!({}));
let b = m["bodies"].as_array().unwrap().iter().find(|b| b["name"] != "Tab").unwrap().clone();
let (lo, hi) = (b["bbox"]["min"].clone(), b["bbox"]["max"].clone());
// The copy sits at the arc's end (0, 20).
@@ -121,22 +121,24 @@ fn path_pattern_orientation_follows_the_path() {
fn patterns_of_a_blind_hole_drill_every_copy() {
let one = {
let mut s = Session::default();
run(&mut s, "PrimitiveBox", json!({"length": 50, "width": 25, "height": 15}));
run(&mut s, "solid.box", json!({"length": 50, "width": 25, "height": 15}));
let v0 = volume(&mut s);
run(&mut s, "FusionHoleCommand", json!({"position": [10, 12, 15], "diameter": "5 mm", "depth": "10 mm"}));
run(&mut s, "solid.hole", json!({"position": [10, 12, 15], "diameter": "5 mm", "depth": "10 mm"}));
v0 - volume(&mut s)
};
let patterned = |p: (&str, Value)| {
let mut s = Session::default();
run(&mut s, "PrimitiveBox", json!({"length": 50, "width": 25, "height": 15}));
run(&mut s, "solid.box", json!({"length": 50, "width": 25, "height": 15}));
let v0 = volume(&mut s);
run(&mut s, "FusionHoleCommand", json!({"position": [10, 12, 15], "diameter": "5 mm", "depth": "10 mm"}));
run(&mut s, "solid.hole", json!({"position": [10, 12, 15], "diameter": "5 mm", "depth": "10 mm"}));
run(&mut s, p.0, p.1);
v0 - volume(&mut s)
};
let r = patterned(("PatternRectangular", json!({"features": ["Hole1"], "dir1": [1, 0, 0], "count1": 3, "spacing1": "15 mm"})));
let r = patterned(("solid.pattern.rectangular", json!({"features": ["Hole1"], "dir1": [1, 0, 0], "count1": 3, "spacing1": "15 mm"})));
assert!((r - 3.0 * one).abs() < 1e-3 * one, "{r} vs 3 × {one}");
let c =
patterned(("PatternCircular", json!({"features": ["Hole1"], "axis": {"origin": [25, 12, 0], "dir": [0, 0, 1]}, "count": 2, "angle": 360})));
let c = patterned((
"solid.pattern.circular",
json!({"features": ["Hole1"], "axis": {"origin": [25, 12, 0], "dir": [0, 0, 1]}, "count": 2, "angle": 360}),
));
assert!((c - 2.0 * one).abs() < 1e-3 * one, "{c} vs 2 × {one}");
}
+50 -50
View File
@@ -12,26 +12,26 @@ use crate::params::{bad, bool_, expr, req_expr, str_, string_list, vec3};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("FusionBossCommand", "Boss", boss).at("PLASTIC", "CREATE").icon("boss").params(
CommandSpec::new("plastic.boss", "Boss", boss).at("PLASTIC", "CREATE").icon("boss").params(
"position: [x,y,z] on a face; direction? (default: out of the face); diameter, height; hole_diameter?, hole_depth? (default: to the face); \
draft? (angle), fillet? (root radius); ribs? (count), rib_thickness?, rib_length? (out from the wall), rib_offset? (below the top); body?",
),
CommandSpec::new("FusionLipCommand", "Lip", lip).at("PLASTIC", "CREATE").icon("lip").params(
CommandSpec::new("plastic.lip", "Lip", lip).at("PLASTIC", "CREATE").icon("lip").params(
"face: [x,y,z] on the rim face of a shelled body; width, height; type?: lip|groove; gap? (groove clearance); side?: inside|outside (which rim edge); body?",
),
CommandSpec::new("FusionSnapFitCommand", "Snap Fit", snap_fit).at("PLASTIC", "CREATE").icon("snap").params(
CommandSpec::new("plastic.snap_fit", "Snap Fit", snap_fit).at("PLASTIC", "CREATE").icon("snap").params(
"position: [x,y,z] on a face; direction? (default: out of the face); hook: [x,y,z] (the catch's direction); length, thickness, width, catch_depth, catch_length; body?",
),
CommandSpec::new("FusionRestCommand", "Rest", rest).at("PLASTIC", "CREATE").icon("rest").params(
CommandSpec::new("plastic.rest", "Rest", rest).at("PLASTIC", "CREATE").icon("rest").params(
"position: [x,y,z] on a face (the rest's centre); direction? (default: out of the face); width (round: the diameter); length? (a rectangle, along `along`); along?: [x,y,z]; \
height; draft? (angle; default: the body's plastic rule); thickness? (wall of a hollow rest); body?",
),
CommandSpec::new("FusionManagePlasticRuleCommand", "Manage Plastic Rules", manage_rules).at("PLASTIC", "SETUP").icon("plastic_rule").params(
CommandSpec::new("plastic.manage_rules", "Manage Plastic Rules", manage_rules).at("PLASTIC", "SETUP").icon("plastic_rule").params(
"name (library or new rule; a new one starts from `from`, default ABS (1.5mm)); from?; material?; thickness?, nominal_radius?, clearance?, knife_edge?, \
reveal_height?, thickness_variation?, draft?, max_thickness?, min_thickness?, min_draft? (expressions; `Thickness` is the rule's); active?: bool (rule for bodies without one); \
delete?: bool (a design rule; a library rule goes back to the library values)",
),
CommandSpec::new("FusionAssignPlasticRuleCommand", "Assign Plastic Rule", assign_rule).at("PLASTIC", "SETUP").icon("plastic_assign").params(
CommandSpec::new("plastic.assign_rule", "Assign Plastic Rule", assign_rule).at("PLASTIC", "SETUP").icon("plastic_assign").params(
"bodies: [names]; rule: name (empty: no rule) — plastic features on these bodies take its draft and clearance as defaults",
),
CommandSpec::new("plastic.rules", "List Plastic Rules", list_rules).noundo().params("→ rules with their expressions and values, the active rule and the rules assigned to bodies"),
@@ -53,7 +53,7 @@ fn rule_json(s: &Session, r: &PlasticRule) -> Value {
}
fn manage_rules(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionManagePlasticRuleCommand";
let cmd = "plastic.manage_rules";
let name = str_(p, "name").map(str::trim).filter(|n| !n.is_empty() && n.len() <= 128).ok_or_else(|| bad(cmd, "`name` is required"))?.to_string();
if bool_(p, "delete").unwrap_or(false) {
let doc = s.doc_mut();
@@ -116,7 +116,7 @@ fn manage_rules(s: &mut Session, p: &Value) -> Result<Value> {
}
fn assign_rule(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionAssignPlasticRuleCommand";
let cmd = "plastic.assign_rule";
let bodies = string_list(p, "bodies");
if bodies.is_empty() || bodies.len() > 10_000 {
return Err(bad(cmd, "`bodies` must list bodies"));
@@ -147,7 +147,7 @@ fn list_rules(s: &mut Session, _p: &Value) -> Result<Value> {
}
fn rest(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionRestCommand";
let cmd = "plastic.rest";
let (position, direction) = placed(s, p, cmd)?;
let along = match p.get("along") {
Some(v) => Some(
@@ -199,7 +199,7 @@ fn placed(s: &Session, p: &Value, cmd: &str) -> Result<(Vec3, Vec3)> {
}
fn boss(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionBossCommand";
let cmd = "plastic.boss";
let (position, direction) = placed(s, p, cmd)?;
let kind = FeatureKind::Boss {
position,
@@ -220,7 +220,7 @@ fn boss(s: &mut Session, p: &Value) -> Result<Value> {
}
fn lip(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionLipCommand";
let cmd = "plastic.lip";
let face = p.get("face").and_then(vec3).ok_or_else(|| bad(cmd, "`face` must be a point [x, y, z] on the rim face"))?;
let groove = match str_(p, "type").map(str::to_ascii_lowercase).as_deref() {
None | Some("lip") => false,
@@ -245,7 +245,7 @@ fn lip(s: &mut Session, p: &Value) -> Result<Value> {
}
fn snap_fit(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionSnapFitCommand";
let cmd = "plastic.snap_fit";
let (position, direction) = placed(s, p, cmd)?;
let hook = p.get("hook").and_then(vec3).and_then(|h| h.normalized()).ok_or_else(|| bad(cmd, "`hook` must be the catch's direction [x, y, z]"))?;
if hook.cross(direction).len() < 1e-6 {
@@ -281,7 +281,7 @@ mod tests {
}
fn volume(s: &mut Session) -> f64 {
run(s, "MeasureCommand", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
}
fn rel(a: f64, b: f64) -> f64 {
@@ -291,17 +291,17 @@ mod tests {
#[test]
fn boss_with_hole_fillet_and_ribs() {
let mut s = Session::default();
run(&mut s, "PrimitiveBox", json!({"length": 60, "width": 60, "height": 3, "body_name": "Plate"}));
run(&mut s, "FusionBossCommand", json!({"position": [30, 30, 3], "diameter": 10, "height": 12, "hole_diameter": 4}));
run(&mut s, "solid.box", json!({"length": 60, "width": 60, "height": 3, "body_name": "Plate"}));
run(&mut s, "plastic.boss", json!({"position": [30, 30, 3], "diameter": 10, "height": 12, "hole_diameter": 4}));
let want = 10800.0 + PI * 25.0 * 12.0 - PI * 4.0 * 12.0;
assert!(rel(volume(&mut s), want) < 1e-3, "{} {want}", volume(&mut s));
assert_eq!(run(&mut s, "MeasureCommand", json!({}))["body_count"], 1);
assert_eq!(run(&mut s, "inspect.measure", json!({}))["body_count"], 1);
// Ribs add t·l·h each.
let mut t = Session::default();
run(&mut t, "PrimitiveBox", json!({"length": 60, "width": 60, "height": 3}));
run(&mut t, "solid.box", json!({"length": 60, "width": 60, "height": 3}));
run(
&mut t,
"FusionBossCommand",
"plastic.boss",
json!({"position": [30, 30, 3], "diameter": 10, "height": 12, "ribs": 4, "rib_thickness": 1.5, "rib_length": 6, "rib_offset": 2}),
);
let ribs = 4.0 * 1.5 * 6.0 * 10.0;
@@ -309,52 +309,52 @@ mod tests {
let v = volume(&mut t) - 10800.0 - PI * 25.0 * 12.0;
assert!(v > ribs && v < ribs * 1.05, "{v} {ribs}");
let mut u = Session::default();
run(&mut u, "PrimitiveBox", json!({"length": 60, "width": 60, "height": 3}));
run(&mut u, "FusionBossCommand", json!({"position": [30, 30, 3], "diameter": 10, "height": 12, "fillet": 2, "draft": 1}));
run(&mut u, "solid.box", json!({"length": 60, "width": 60, "height": 3}));
run(&mut u, "plastic.boss", json!({"position": [30, 30, 3], "diameter": 10, "height": 12, "fillet": 2, "draft": 1}));
assert!(volume(&mut u) > 10800.0 + PI * 25.0 * 10.0);
assert!(u.execute("FusionBossCommand", &json!({"position": [30, 30, 3], "diameter": 10, "height": 12, "hole_diameter": 20})).is_err());
assert!(u.execute("plastic.boss", &json!({"position": [30, 30, 3], "diameter": 10, "height": 12, "hole_diameter": 20})).is_err());
}
#[test]
fn lip_and_groove_on_a_shelled_box() {
let shelled = || {
let mut s = Session::default();
run(&mut s, "PrimitiveBox", json!({"length": 80, "width": 50, "height": 30}));
run(&mut s, "FusionShellBodyCommand", json!({"faces": [[40, 25, 30]], "thickness": 2}));
run(&mut s, "solid.box", json!({"length": 80, "width": 50, "height": 30}));
run(&mut s, "solid.shell", json!({"faces": [[40, 25, 30]], "thickness": 2}));
s
};
let mut s = shelled();
let v0 = volume(&mut s);
run(&mut s, "FusionLipCommand", json!({"face": [1, 25, 30], "width": 1, "height": 2}));
run(&mut s, "plastic.lip", json!({"face": [1, 25, 30], "width": 1, "height": 2}));
// Inside band 1 wide along the inner edge (76 x 46): (76+2)(46+2) − 76·46 = 248, 2 high.
assert!(rel(volume(&mut s) - v0, 248.0 * 2.0) < 1e-6, "{}", volume(&mut s) - v0);
let mut g = shelled();
run(&mut g, "FusionLipCommand", json!({"face": [1, 25, 30], "width": 1, "height": 2, "type": "groove", "side": "outside"}));
run(&mut g, "plastic.lip", json!({"face": [1, 25, 30], "width": 1, "height": 2, "type": "groove", "side": "outside"}));
// Outside band along the outer edge (80 x 50): 80·50 − 78·48 = 256, 2 deep.
assert!(rel(v0 - volume(&mut g), 256.0 * 2.0) < 1e-6, "{}", v0 - volume(&mut g));
assert!(g.execute("FusionLipCommand", &json!({"face": [40, 25, 0], "width": 1, "height": 2})).is_err(), "a face without an inner edge");
assert!(g.execute("plastic.lip", &json!({"face": [40, 25, 0], "width": 1, "height": 2})).is_err(), "a face without an inner edge");
}
#[test]
fn snap_fit_arm_and_catch() {
let mut s = Session::default();
run(&mut s, "PrimitiveBox", json!({"length": 40, "width": 40, "height": 4}));
run(&mut s, "solid.box", json!({"length": 40, "width": 40, "height": 4}));
let v0 = volume(&mut s);
run(
&mut s,
"FusionSnapFitCommand",
"plastic.snap_fit",
json!({"position": [20, 20, 4], "hook": [1, 0, 0], "length": 15, "thickness": 1.5, "width": 6, "catch_depth": 1, "catch_length": 3}),
);
let want = 1.5 * 15.0 * 6.0 + 0.5 * 1.0 * 3.0 * 6.0;
assert!(rel(volume(&mut s) - v0, want) < 1e-6, "{}", volume(&mut s) - v0);
let m = run(&mut s, "MeasureCommand", json!({}));
let m = run(&mut s, "inspect.measure", json!({}));
assert!((m["bodies"][0]["bbox"]["max"][0].as_f64().unwrap_or(0.0) - 40.0).abs() < 1e-9);
assert!(s.execute("FusionSnapFitCommand", &json!({"position": [5, 5, 4], "direction": [0, 0, 1], "hook": [0, 0, 1], "length": 15, "thickness": 1.5, "width": 6, "catch_depth": 1, "catch_length": 3})).is_err());
assert!(s.execute("plastic.snap_fit", &json!({"position": [5, 5, 4], "direction": [0, 0, 1], "hook": [0, 0, 1], "length": 15, "thickness": 1.5, "width": 6, "catch_depth": 1, "catch_length": 3})).is_err());
}
fn plate() -> (Session, f64) {
let mut s = Session::default();
run(&mut s, "PrimitiveBox", json!({"length": 60, "width": 60, "height": 3}));
run(&mut s, "solid.box", json!({"length": 60, "width": 60, "height": 3}));
let v0 = volume(&mut s);
(s, v0)
}
@@ -363,27 +363,27 @@ mod tests {
fn rest_pads() {
// Rectangle 20 x 10, 4 high: 800.
let (mut s, v0) = plate();
run(&mut s, "FusionRestCommand", json!({"position": [30, 30, 3], "width": 10, "length": 20, "along": [1, 0, 0], "height": 4}));
run(&mut s, "plastic.rest", json!({"position": [30, 30, 3], "width": 10, "length": 20, "along": [1, 0, 0], "height": 4}));
assert!(rel(volume(&mut s) - v0, 800.0) < 1e-6, "{}", volume(&mut s) - v0);
let m = run(&mut s, "MeasureCommand", json!({}));
let m = run(&mut s, "inspect.measure", json!({}));
assert!((m["bodies"][0]["bbox"]["max"][2].as_f64().unwrap_or(0.0) - 7.0).abs() < 1e-9);
// Round, diameter 10: π·25·4.
let (mut r, v0) = plate();
run(&mut r, "FusionRestCommand", json!({"position": [30, 30, 3], "width": 10, "height": 4}));
run(&mut r, "plastic.rest", json!({"position": [30, 30, 3], "width": 10, "height": 4}));
assert!(rel(volume(&mut r) - v0, PI * 25.0 * 4.0) < 1e-3, "{}", volume(&mut r) - v0);
// Hollow, wall 1: (20·10 − 18·8)·4 = 224.
let (mut h, v0) = plate();
run(&mut h, "FusionRestCommand", json!({"position": [30, 30, 3], "width": 10, "length": 20, "height": 4, "thickness": 1}));
run(&mut h, "plastic.rest", json!({"position": [30, 30, 3], "width": 10, "length": 20, "height": 4, "thickness": 1}));
assert!(rel(volume(&mut h) - v0, 224.0) < 1e-6, "{}", volume(&mut h) - v0);
// Drafted 2°: the section shrinks by 2·s·tan 2° each way at height s.
let (mut d, v0) = plate();
run(&mut d, "FusionRestCommand", json!({"position": [30, 30, 3], "width": 10, "length": 20, "height": 4, "draft": "2 deg"}));
run(&mut d, "plastic.rest", json!({"position": [30, 30, 3], "width": 10, "length": 20, "height": 4, "draft": "2 deg"}));
let a = 2f64.to_radians().tan();
let want = 800.0 - a * 30.0 * 16.0 + 4.0 / 3.0 * a * a * 64.0;
assert!(rel(volume(&mut d) - v0, want) < 1e-6, "{} {want}", volume(&mut d) - v0);
// A wall thicker than half the width, or a draft that closes the top, is refused.
assert!(d.execute("FusionRestCommand", &json!({"position": [10, 10, 3], "width": 4, "height": 4, "thickness": 2})).is_err());
assert!(d.execute("FusionRestCommand", &json!({"position": [10, 10, 3], "width": 4, "height": 40, "draft": 10})).is_err());
assert!(d.execute("plastic.rest", &json!({"position": [10, 10, 3], "width": 4, "height": 4, "thickness": 2})).is_err());
assert!(d.execute("plastic.rest", &json!({"position": [10, 10, 3], "width": 4, "height": 40, "draft": 10})).is_err());
}
#[test]
@@ -395,31 +395,31 @@ mod tests {
assert_eq!(list["rules"][0]["values"]["nominal_radius"], 0.75);
assert_eq!(list["rules"][3]["values"]["thickness"].as_f64().map(|t| (t - 2.54).abs() < 1e-9), Some(true));
// A new rule from ABS: Thickness-based values follow its thickness.
let r = run(&mut s, "FusionManagePlasticRuleCommand", json!({"name": "Thick ABS", "thickness": 3, "draft": "1.5 deg"}));
let r = run(&mut s, "plastic.manage_rules", json!({"name": "Thick ABS", "thickness": 3, "draft": "1.5 deg"}));
assert_eq!(r["rule"]["values"]["nominal_radius"], 1.5);
assert!(s.execute("FusionManagePlasticRuleCommand", &json!({"name": "Bad", "draft": "3 mm"})).is_err());
assert!(s.execute("plastic.manage_rules", &json!({"name": "Bad", "draft": "3 mm"})).is_err());
// Assigned to the plate: a boss without a draft takes the rule's 1.5°.
let body = run(&mut s, "MeasureCommand", json!({}))["bodies"][0]["name"].as_str().unwrap_or_default().to_string();
run(&mut s, "FusionAssignPlasticRuleCommand", json!({"bodies": [body], "rule": "Thick ABS"}));
run(&mut s, "FusionBossCommand", json!({"position": [30, 30, 3], "diameter": 10, "height": 12}));
let body = run(&mut s, "inspect.measure", json!({}))["bodies"][0]["name"].as_str().unwrap_or_default().to_string();
run(&mut s, "plastic.assign_rule", json!({"bodies": [body], "rule": "Thick ABS"}));
run(&mut s, "plastic.boss", json!({"position": [30, 30, 3], "diameter": 10, "height": 12}));
let rt = 5.0 - 12.0 * 1.5f64.to_radians().tan();
let want = PI * 12.0 / 3.0 * (25.0 + 5.0 * rt + rt * rt);
assert!(rel(volume(&mut s) - v0, want) < 1e-3, "{} {want}", volume(&mut s) - v0);
// Editing the rule rebuilds the boss.
run(&mut s, "FusionManagePlasticRuleCommand", json!({"name": "Thick ABS", "draft": "0 deg"}));
run(&mut s, "plastic.manage_rules", json!({"name": "Thick ABS", "draft": "0 deg"}));
assert!(rel(volume(&mut s) - v0, PI * 25.0 * 12.0) < 1e-3, "{}", volume(&mut s) - v0);
assert!(s.execute("FusionAssignPlasticRuleCommand", &json!({"bodies": [body], "rule": "Nope"})).is_err());
assert!(s.execute("FusionAssignPlasticRuleCommand", &json!({"bodies": ["Nope"], "rule": "Thick ABS"})).is_err());
assert!(s.execute("plastic.assign_rule", &json!({"bodies": [body], "rule": "Nope"})).is_err());
assert!(s.execute("plastic.assign_rule", &json!({"bodies": ["Nope"], "rule": "Thick ABS"})).is_err());
}
#[test]
fn groove_takes_the_rule_clearance() {
let mut g = Session::default();
run(&mut g, "PrimitiveBox", json!({"length": 80, "width": 50, "height": 30}));
run(&mut g, "FusionShellBodyCommand", json!({"faces": [[40, 25, 30]], "thickness": 2}));
run(&mut g, "solid.box", json!({"length": 80, "width": 50, "height": 30}));
run(&mut g, "solid.shell", json!({"faces": [[40, 25, 30]], "thickness": 2}));
let v0 = volume(&mut g);
run(&mut g, "FusionManagePlasticRuleCommand", json!({"name": "ABS (1.5mm)", "active": true}));
run(&mut g, "FusionLipCommand", json!({"face": [1, 25, 30], "width": 1, "height": 2, "type": "groove", "side": "outside"}));
run(&mut g, "plastic.manage_rules", json!({"name": "ABS (1.5mm)", "active": true}));
run(&mut g, "plastic.lip", json!({"face": [1, 25, 30], "width": 1, "height": 2, "type": "groove", "side": "outside"}));
// Width and depth grow by the 0.1 clearance: (80·50 − 77.8·47.8)·2.1.
let want = (4000.0 - 77.8 * 47.8) * 2.1;
assert!(rel(v0 - volume(&mut g), want) < 1e-6, "{} {want}", v0 - volume(&mut g));
+2 -2
View File
@@ -8,14 +8,14 @@ use super::CommandSpec;
use crate::params::{bad, bool_, expr, vec3};
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("FusionHalfSectionViewCommand", "Section Analysis", section)
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 normal; flip?: bool; or clear: true")];
fn section(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionHalfSectionViewCommand";
let cmd = "inspect.section";
if bool_(p, "clear").unwrap_or(false) {
s.section = None;
s.revision += 1;
+14 -14
View File
@@ -13,31 +13,31 @@ use crate::params::{bad, bool_, expr, req_expr, str_, string_list, vec3};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("FusionSheetMetalFlangeCommand", "Flange", flange).at("SHEET METAL", "CREATE").icon("flange").params(
CommandSpec::new("sheet.flange", "Flange", flange).at("SHEET METAL", "CREATE").icon("flange").params(
"base: sketch + profiles? (rule?, flip?) | contour: sketch + curves (open chain of lines) + distance (rule?, flip?, reverse?) | \
edge: edges: [[x,y,z] on top/bottom edges] + height (to the outer face), angle? (default 90 deg), radius? (default: rule), \
position?: inside|outside|middle, flip?, body?; type?: base|edge|contour (default from the inputs)",
),
CommandSpec::new("FusionSheetMetalHemFlangeCommand", "Hem", hem)
CommandSpec::new("sheet.hem", "Hem", hem)
.at("SHEET METAL", "CREATE")
.icon("hem")
.params("edges: [[x,y,z] on sheet edges]; length (to the outer face); gap? (inner radius, default: rule); flip?; body?"),
CommandSpec::new("SheetMetalFoldCmd", "Fold", fold).at("SHEET METAL", "CREATE").icon("fold").params(
CommandSpec::new("sheet.fold", "Fold", fold).at("SHEET METAL", "CREATE").icon("fold").params(
"line: sketch + curve (a sketch line on the sheet's base face; followed when the sketch changes) or points: [[x,y,z], [x,y,z]]; \
angle? (default 90 deg); radius? (default: rule); position?: centerline|start|end|mould (where the line sits in the bend, default centerline); \
fixed?: [x,y,z] on the side that stays (default: the larger side); flip? (fold the other way); body?",
),
CommandSpec::new("FusionSheetmetalUnfoldCommand", "Unfold", unfold).at("SHEET METAL", "MODIFY").icon("unfold").params("body? (default: the last sheet body) — all bends"),
CommandSpec::new("sheet.unfold", "Unfold", unfold).at("SHEET METAL", "MODIFY").icon("unfold").params("body? (default: the last sheet body) — all bends"),
CommandSpec::new("sheet.refold", "Refold", refold).at("SHEET METAL", "MODIFY").icon("refold").params("body? (default: the last unfolded sheet)"),
CommandSpec::new("ConvertToSheetMetalCmd", "Convert to Sheet Metal", convert)
CommandSpec::new("sheet.convert", "Convert to Sheet Metal", convert)
.at("SHEET METAL", "CREATE")
.icon("convert_sheet")
.params("body; face: [x,y,z] on its large flat face (a plate of even thickness); rule?"),
CommandSpec::new("FusionSheetMetalRulesCommand", "Sheet Metal Rules", rules).at("SHEET METAL", "MODIFY").icon("sheet_rules").params(
CommandSpec::new("sheet.manage_rules", "Sheet Metal Rules", rules).at("SHEET METAL", "MODIFY").icon("sheet_rules").params(
"name (new or existing); thickness?, k_factor?, bend_radius?, relief_width?, relief_depth?, corner_relief?, hem_gap?, gap? (expressions; `Thickness` is the rule's); active?: bool; delete?: bool",
),
CommandSpec::new("sheet.rules", "List Sheet Metal Rules", list_rules).noundo().params("→ rules with their expressions and values, and the active one"),
CommandSpec::new("FusionSheetMetalFlatPatternCmd", "Create Flat Pattern", flat_pattern)
CommandSpec::new("sheet.flat_pattern", "Create Flat Pattern", flat_pattern)
.at("SHEET METAL", "CREATE")
.icon("flat")
.noundo()
@@ -60,13 +60,13 @@ fn points(p: &Value, k: &str, cmd: &str) -> Result<Vec<Vec3>> {
fn rule_param(s: &Session, p: &Value, cmd: &str) -> Result<Option<String>> {
match str_(p, "rule") {
Some(r) if s.doc.sheet.rules.iter().any(|x| x.name == r) || r == SheetRule::steel().name => Ok(Some(r.to_string())),
Some(r) => Err(bad(cmd, format!("no sheet metal rule `{r}` (FusionSheetMetalRulesCommand makes one)"))),
Some(r) => Err(bad(cmd, format!("no sheet metal rule `{r}` (sheet.manage_rules makes one)"))),
None => Ok(None),
}
}
fn flange(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionSheetMetalFlangeCommand";
let cmd = "sheet.flange";
let ty = match str_(p, "type") {
Some(t) => t.to_ascii_lowercase(),
None if p.get("edges").is_some() => "edge".into(),
@@ -123,7 +123,7 @@ fn flange(s: &mut Session, p: &Value) -> Result<Value> {
}
fn hem(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionSheetMetalHemFlangeCommand";
let cmd = "sheet.hem";
let edges = points(p, "edges", cmd)?;
let length = req_expr(cmd, p, "length")?;
check_expr(s, &length, Kind::Length, cmd, "length")?;
@@ -139,7 +139,7 @@ fn hem(s: &mut Session, p: &Value) -> Result<Value> {
}
fn fold(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "SheetMetalFoldCmd";
let cmd = "sheet.fold";
let (sketch, curve, a, b) = match p.get("points") {
Some(v) => {
let pts = v.as_array().map(|a| a.iter().filter_map(vec3).collect::<Vec<Vec3>>()).unwrap_or_default();
@@ -189,7 +189,7 @@ fn refold(s: &mut Session, p: &Value) -> Result<Value> {
}
fn convert(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ConvertToSheetMetalCmd";
let cmd = "sheet.convert";
let body = str_(p, "body").ok_or_else(|| bad(cmd, "`body` is required"))?.to_string();
if s.model.state().body(&body).is_none() {
return Err(bad(cmd, format!("no body `{body}`")));
@@ -200,7 +200,7 @@ fn convert(s: &mut Session, p: &Value) -> Result<Value> {
}
fn rules(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionSheetMetalRulesCommand";
let cmd = "sheet.manage_rules";
let name = str_(p, "name").map(str::trim).filter(|n| !n.is_empty() && n.len() <= 128).ok_or_else(|| bad(cmd, "`name` is required"))?.to_string();
if bool_(p, "delete").unwrap_or(false) {
let doc = s.doc_mut();
@@ -289,7 +289,7 @@ fn flat_json(sh: &SheetBody) -> Result<Value> {
}
fn flat_pattern(s: &mut Session, p: &Value) -> Result<Value> {
let sh = sheet_of(s, p, "FusionSheetMetalFlatPatternCmd")?;
let sh = sheet_of(s, p, "sheet.flat_pattern")?;
flat_json(&sh)
}
+71 -71
View File
@@ -12,7 +12,7 @@ fn run(s: &mut Session, id: &str, p: Value) -> Value {
}
fn volume(s: &mut Session) -> f64 {
run(s, "MeasureCommand", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
}
fn rel(a: f64, b: f64) -> f64 {
@@ -20,15 +20,15 @@ fn rel(a: f64, b: f64) -> f64 {
}
fn flat(s: &mut Session) -> Value {
run(s, "FusionSheetMetalFlatPatternCmd", json!({}))
run(s, "sheet.flat_pattern", json!({}))
}
/// A w x h base flange on XY.
fn base(s: &mut Session, w: f64, h: f64) {
run(s, "SketchCreate", json!({"plane": "XY", "name": "Base"}));
run(s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [w, h]}));
run(s, "SketchStop", json!({}));
run(s, "FusionSheetMetalFlangeCommand", json!({"sketch": "Base"}));
run(s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [w, h]}));
run(s, "sketch.finish", json!({}));
run(s, "sheet.flange", json!({"sketch": "Base"}));
}
#[test]
@@ -36,70 +36,70 @@ fn base_and_edge_flange_follow_the_k_factor() {
let mut s = Session::default();
base(&mut s, 100.0, 60.0);
assert!(rel(volume(&mut s), 15000.0) < 1e-9);
run(&mut s, "FusionSheetMetalFlangeCommand", json!({"edges": [[50, 0, 2.5]], "height": 20}));
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20}));
// Oracle 68: flat = 60 − 5 + (π/2)(2.5 + 0.44·2.5) + 20 − 5.
let want = 60.0 - 5.0 + FRAC_PI_2 * (2.5 + 0.44 * 2.5) + 15.0;
let f = flat(&mut s);
assert!((f["flat_size_mm"][1].as_f64().unwrap_or(0.0) - want).abs() < 1e-6, "{f}");
assert_eq!(f["bends"][0]["direction"], "up");
assert!(rel(volume(&mut s), 18972.622) < 1e-4, "{}", volume(&mut s));
let m = run(&mut s, "MeasureCommand", json!({}));
let m = run(&mut s, "inspect.measure", json!({}));
assert_eq!(m["total"]["faces"], 14, "{m}");
// The angle is a parameter: 45° (oracle 70 uses height 15).
let names = s.doc.features.last().map(|f| f.param_names.clone()).unwrap_or_default();
run(&mut s, "ChangeParameterCommand", json!({"name": names[0], "expression": "15"}));
run(&mut s, "ChangeParameterCommand", json!({"name": names[1], "expression": "45 deg"}));
run(&mut s, "parameters.change", json!({"name": names[0], "expression": "15"}));
run(&mut s, "parameters.change", json!({"name": names[1], "expression": "45 deg"}));
assert!(rel(volume(&mut s), 18450.777) < 1e-4, "{}", volume(&mut s));
assert!((flat(&mut s)["flat_size_mm"][1].as_f64().unwrap_or(0.0) - 73.685298).abs() < 1e-5);
// DXF: outline and an up bend line.
let d = run(&mut s, "sheet.export_dxf", json!({}));
let text = d["dxf"].as_str().unwrap_or_default();
assert!(text.contains("BEND_UP") && text.contains("OUTLINE"), "{text}");
assert!(s.execute("FusionSheetMetalFlangeCommand", &json!({"edges": [[50, 30, 40]], "height": 20})).is_err(), "not on an edge");
assert!(s.execute("sheet.flange", &json!({"edges": [[50, 30, 40]], "height": 20})).is_err(), "not on an edge");
}
#[test]
fn rules_are_parameters() {
let mut s = Session::default();
run(&mut s, "ChangeParameterCommand", json!({"name": "Thick", "expression": "1.5 mm"}));
run(&mut s, "FusionSheetMetalRulesCommand", json!({"name": "Custom", "thickness": "Thick", "k_factor": 0.33, "bend_radius": "3 mm"}));
run(&mut s, "parameters.change", json!({"name": "Thick", "expression": "1.5 mm"}));
run(&mut s, "sheet.manage_rules", json!({"name": "Custom", "thickness": "Thick", "k_factor": 0.33, "bend_radius": "3 mm"}));
base(&mut s, 80.0, 50.0);
run(&mut s, "FusionSheetMetalFlangeCommand", json!({"edges": [[40, 50, 1.5], [40, 0, 1.5]], "height": 20}));
run(&mut s, "sheet.flange", json!({"edges": [[40, 50, 1.5], [40, 0, 1.5]], "height": 20}));
// Oracle 71: 50 − 9 + 2·(π/2)(3 + 0.495) + 2·15.5 = 82.980.
let want = 50.0 - 2.0 * 4.5 + 2.0 * FRAC_PI_2 * (3.0 + 0.33 * 1.5) + 2.0 * 15.5;
assert!((flat(&mut s)["flat_size_mm"][1].as_f64().unwrap_or(0.0) - want).abs() < 1e-6);
assert!(rel(volume(&mut s), 10053.717) < 1e-4, "{}", volume(&mut s));
// Thicker: everything follows.
run(&mut s, "ChangeParameterCommand", json!({"name": "Thick", "expression": "2 mm"}));
run(&mut s, "parameters.change", json!({"name": "Thick", "expression": "2 mm"}));
let want = 50.0 - 2.0 * 5.0 + 2.0 * FRAC_PI_2 * (3.0 + 0.33 * 2.0) + 2.0 * 15.0;
assert!((flat(&mut s)["flat_size_mm"][1].as_f64().unwrap_or(0.0) - want).abs() < 1e-6);
let l = run(&mut s, "sheet.rules", json!({}));
assert_eq!(l["active"], "Custom", "{l}");
assert!(s.execute("FusionSheetMetalRulesCommand", &json!({"name": "Bad", "k_factor": 3})).is_err());
assert!(s.execute("sheet.manage_rules", &json!({"name": "Bad", "k_factor": 3})).is_err());
}
#[test]
fn contour_flange_hem_and_tray() {
// Oracle 72.
let mut s = Session::default();
run(&mut s, "SketchCreate", json!({"plane": {"origin": [0, 0, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, -1]}, "name": "Contour"}));
run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [40, 0], [40, -30], [70, -30]], "ids": ["l1", "l2", "l3"]}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "FusionSheetMetalFlangeCommand", json!({"sketch": "Contour", "curves": ["l1", "l2", "l3"], "distance": 50}));
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, -1]}, "name": "Contour"}));
run(&mut s, "sketch.line", json!({"points": [[0, 0], [40, 0], [40, -30], [70, -30]], "ids": ["l1", "l2", "l3"]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sheet.flange", json!({"sketch": "Contour", "curves": ["l1", "l2", "l3"], "distance": 50}));
assert!(rel(volume(&mut s), 12097.622) < 1e-4, "{}", volume(&mut s));
assert!((flat(&mut s)["flat_size_mm"][0].as_f64().unwrap_or(0.0) - 96.309734).abs() < 1e-5);
// Oracle 73: flange, then a flat hem of 8.
let mut s = Session::default();
base(&mut s, 100.0, 60.0);
run(&mut s, "FusionSheetMetalFlangeCommand", json!({"edges": [[50, 0, 2.5]], "height": 20}));
run(&mut s, "FusionSheetMetalHemFlangeCommand", json!({"edges": [[50, 0, 20]], "length": 8}));
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20}));
run(&mut s, "sheet.hem", json!({"edges": [[50, 0, 20]], "length": 8}));
assert!(rel(volume(&mut s), 21334.723) < 1e-4, "{}", volume(&mut s));
let b = flat(&mut s)["bends"].clone();
assert!(b.as_array().is_some_and(|a| a.iter().any(|x| (x["angle_deg"].as_f64().unwrap_or(0.0) - 180.0).abs() < 1e-9)), "{b}");
// A tray: four flanges at once stop at each other's bends.
let mut s = Session::default();
base(&mut s, 100.0, 60.0);
run(&mut s, "FusionSheetMetalFlangeCommand", json!({"edges": [[50, 60, 2.5], [100, 30, 2.5], [50, 0, 2.5], [0, 30, 2.5]], "height": 25}));
run(&mut s, "sheet.flange", json!({"edges": [[50, 60, 2.5], [100, 30, 2.5], [50, 0, 2.5], [0, 30, 2.5]], "height": 25}));
let f = flat(&mut s);
// Oracle 69: 100 − 10 + 2·5.655 + 2·20 = 141.310 across.
assert!((f["flat_size_mm"][0].as_f64().unwrap_or(0.0) - (90.0 + 2.0 * FRAC_PI_2 * 3.6 + 40.0)).abs() < 1e-6, "{f}");
@@ -117,15 +117,15 @@ fn unfold_cut_refold_and_convert() {
// Oracle 74: two flanges, unfold, a slot across the bend, refold.
let mut s = Session::default();
base(&mut s, 80.0, 50.0);
run(&mut s, "FusionSheetMetalFlangeCommand", json!({"edges": [[40, 50, 2.5], [40, 0, 2.5]], "height": 20}));
run(&mut s, "sheet.flange", json!({"edges": [[40, 50, 2.5], [40, 0, 2.5]], "height": 20}));
let folded = volume(&mut s);
run(&mut s, "FusionSheetmetalUnfoldCommand", json!({}));
let m = run(&mut s, "MeasureCommand", json!({}));
run(&mut s, "sheet.unfold", json!({}));
let m = run(&mut s, "inspect.measure", json!({}));
assert!((m["bodies"][0]["bbox"]["max"][2].as_f64().unwrap_or(0.0) - 2.5).abs() < 1e-6, "{m}");
run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "Slot"}));
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [35, -12], "p1": [45, 10]}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"sketch": "Slot", "through_all": true, "operation": "cut", "direction": "symmetric"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Slot"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [35, -12], "p1": [45, 10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Slot", "through_all": true, "operation": "cut", "direction": "symmetric"}));
run(&mut s, "sheet.refold", json!({}));
let v = volume(&mut s);
assert!(v < folded - 300.0, "{v} {folded}");
@@ -133,24 +133,24 @@ fn unfold_cut_refold_and_convert() {
assert_eq!(flat(&mut s)["cutouts"].as_array().map(Vec::len), Some(1));
// Convert a plate.
let mut s = Session::default();
run(&mut s, "PrimitiveBox", json!({"length": 100, "width": 60, "height": 2.5, "body_name": "Plate"}));
run(&mut s, "ConvertToSheetMetalCmd", json!({"body": "Plate", "face": [50, 30, 2.5]}));
run(&mut s, "FusionSheetMetalFlangeCommand", json!({"edges": [[50, 0, 2.5]], "height": 20}));
run(&mut s, "solid.box", json!({"length": 100, "width": 60, "height": 2.5, "body_name": "Plate"}));
run(&mut s, "sheet.convert", json!({"body": "Plate", "face": [50, 30, 2.5]}));
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20}));
assert!(rel(volume(&mut s), 18972.622) < 1e-4, "{}", volume(&mut s));
let mut t = Session::default();
run(&mut t, "PrimitiveCylinder", json!({"radius": 10, "height": 30, "body_name": "Rod"}));
assert!(t.execute("ConvertToSheetMetalCmd", &json!({"body": "Rod", "face": [0, 0, 30]})).is_err());
run(&mut t, "solid.cylinder", json!({"radius": 10, "height": 30, "body_name": "Rod"}));
assert!(t.execute("sheet.convert", &json!({"body": "Rod", "face": [0, 0, 30]})).is_err());
}
/// A sketch line across a 100 x 60 plate's top face at x = 40.
fn fold_line(s: &mut Session) {
run(s, "SketchCreate", json!({"plane": "XY", "offset": 2.5, "name": "FoldLine"}));
run(s, "DrawPolyline", json!({"points": [[40, 0], [40, 60]], "ids": ["fl"]}));
run(s, "SketchStop", json!({}));
run(s, "sketch.create", json!({"plane": "XY", "offset": 2.5, "name": "FoldLine"}));
run(s, "sketch.line", json!({"points": [[40, 0], [40, 60]], "ids": ["fl"]}));
run(s, "sketch.finish", json!({}));
}
fn bbox(s: &mut Session) -> Value {
run(s, "MeasureCommand", json!({}))["bodies"][0]["bbox"].clone()
run(s, "inspect.measure", json!({}))["bodies"][0]["bbox"].clone()
}
#[test]
@@ -160,7 +160,7 @@ fn fold_takes_the_bend_out_of_the_flat_sheet() {
let mut s = Session::default();
base(&mut s, 100.0, 60.0);
fold_line(&mut s);
run(&mut s, "SheetMetalFoldCmd", json!({"sketch": "FoldLine", "curve": "fl"}));
run(&mut s, "sheet.fold", json!({"sketch": "FoldLine", "curve": "fl"}));
// The flat pattern keeps its size; the bend's centre line is the sketch line.
let f = flat(&mut s);
assert!((f["flat_size_mm"][0].as_f64().unwrap_or(0.0) - 100.0).abs() < 1e-6, "{f}");
@@ -189,7 +189,7 @@ fn fold_positions_fixed_side_and_flip() {
for (k, v) in p.as_object().into_iter().flatten() {
q[k] = v.clone();
}
run(&mut s, "SheetMetalFoldCmd", q);
run(&mut s, "sheet.fold", q);
bbox(&mut s)
};
let x0 = |b: &Value| b["min"][0].as_f64().unwrap_or(f64::NAN);
@@ -215,10 +215,10 @@ fn fold_carries_flanges_on_the_moving_side() {
let mut s = Session::default();
base(&mut s, 100.0, 60.0);
// A flange on the left edge, then a fold between it and the rest.
run(&mut s, "FusionSheetMetalFlangeCommand", json!({"edges": [[0, 30, 2.5]], "height": 20}));
run(&mut s, "sheet.flange", json!({"edges": [[0, 30, 2.5]], "height": 20}));
let v0 = volume(&mut s);
fold_line(&mut s);
run(&mut s, "SheetMetalFoldCmd", json!({"sketch": "FoldLine", "curve": "fl", "fixed": [90, 30, 2.5]}));
run(&mut s, "sheet.fold", json!({"sketch": "FoldLine", "curve": "fl", "fixed": [90, 30, 2.5]}));
// The flange rode along: it is above the plate now, nothing left at z < 0.
let b = bbox(&mut s);
assert!(b["min"][2].as_f64().unwrap_or(-1.0) > -1e-6, "{b}");
@@ -229,9 +229,9 @@ fn fold_carries_flanges_on_the_moving_side() {
// Errors: off the sheet, through the flange's bend.
let mut e = Session::default();
base(&mut e, 100.0, 60.0);
assert!(e.execute("SheetMetalFoldCmd", &json!({"points": [[40, 0, 30], [40, 60, 30]]})).is_err());
assert!(e.execute("SheetMetalFoldCmd", &json!({"points": [[40, 0, 2.5], [40, 60, 2.5]], "position": "sideways"})).is_err());
assert!(e.execute("SheetMetalFoldCmd", &json!({"points": [[0.5, 0, 2.5], [0.5, 60, 2.5]]})).is_err(), "no material past the bend");
assert!(e.execute("sheet.fold", &json!({"points": [[40, 0, 30], [40, 60, 30]]})).is_err());
assert!(e.execute("sheet.fold", &json!({"points": [[40, 0, 2.5], [40, 60, 2.5]], "position": "sideways"})).is_err());
assert!(e.execute("sheet.fold", &json!({"points": [[0.5, 0, 2.5], [0.5, 60, 2.5]]})).is_err(), "no material past the bend");
}
#[test]
@@ -240,12 +240,12 @@ fn fold_on_a_flange_panel() {
let ba = FRAC_PI_2 * (r + k * t);
let mut s = Session::default();
base(&mut s, 100.0, 60.0);
run(&mut s, "FusionSheetMetalFlangeCommand", json!({"edges": [[50, 0, 2.5]], "height": 30}));
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 30}));
let flat0 = flat(&mut s)["flat_size_mm"].clone();
let v0 = volume(&mut s);
// The flange stands up at the plate's y = 0 edge: its outer face is the body's min y.
let y = bbox(&mut s)["min"][1].as_f64().unwrap_or(f64::NAN);
run(&mut s, "SheetMetalFoldCmd", json!({"points": [[10, y, 18], [90, y, 18]]}));
run(&mut s, "sheet.fold", json!({"points": [[10, y, 18], [90, y, 18]]}));
let f = flat(&mut s);
for i in 0..2 {
assert!(
@@ -262,7 +262,7 @@ fn fold_on_a_flange_panel() {
let b = bbox(&mut s);
assert!(b["max"][2].as_f64().unwrap_or(99.0) < 25.0, "{b}");
// Folding across the flange's own bend is refused.
assert!(s.execute("SheetMetalFoldCmd", &json!({"points": [[50, y, 2], [50, y, 25]]})).is_err());
assert!(s.execute("sheet.fold", &json!({"points": [[50, y, 2], [50, y, 25]]})).is_err());
}
#[test]
@@ -270,51 +270,51 @@ fn fold_through_cut_outs() {
let mut s = Session::default();
base(&mut s, 100.0, 60.0);
// A slot across the fold line (edges along and across the bend).
run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "Slot"}));
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [30, 20], "p1": [50, 40]}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"sketch": "Slot", "through_all": true, "operation": "cut", "direction": "symmetric"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Slot"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [30, 20], "p1": [50, 40]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Slot", "through_all": true, "operation": "cut", "direction": "symmetric"}));
let mut plain = Session::default();
base(&mut plain, 100.0, 60.0);
fold_line(&mut plain);
run(&mut plain, "SheetMetalFoldCmd", json!({"sketch": "FoldLine", "curve": "fl"}));
run(&mut plain, "sheet.fold", json!({"sketch": "FoldLine", "curve": "fl"}));
fold_line(&mut s);
run(&mut s, "SheetMetalFoldCmd", json!({"sketch": "FoldLine", "curve": "fl"}));
run(&mut s, "sheet.fold", json!({"sketch": "FoldLine", "curve": "fl"}));
let f = flat(&mut s);
assert_eq!(f["cutouts"].as_array().map(Vec::len), Some(1), "{f}");
let (v, vp) = (volume(&mut s), volume(&mut plain));
// The slot takes 20 x 20 of the flat sheet; across the bend it removes the bend's share.
assert!(v < vp - 900.0 && v > vp - 1100.0, "{v} {vp}");
run(&mut s, "FusionSheetmetalUnfoldCommand", json!({}));
run(&mut s, "sheet.unfold", json!({}));
assert!((volume(&mut s) - (15000.0 - 1000.0)).abs() < 1e-3, "{}", volume(&mut s));
// A round hole across the bend is not supported yet, and says so.
let mut c = Session::default();
base(&mut c, 100.0, 60.0);
run(&mut c, "SketchCreate", json!({"plane": "XY", "name": "Hole"}));
run(&mut c, "CircleCenterRadius", json!({"center": [40, 30], "radius": 5}));
run(&mut c, "SketchStop", json!({}));
run(&mut c, "Extrude", json!({"sketch": "Hole", "through_all": true, "operation": "cut", "direction": "symmetric"}));
run(&mut c, "sketch.create", json!({"plane": "XY", "name": "Hole"}));
run(&mut c, "sketch.circle.center", json!({"center": [40, 30], "radius": 5}));
run(&mut c, "sketch.finish", json!({}));
run(&mut c, "solid.extrude", json!({"sketch": "Hole", "through_all": true, "operation": "cut", "direction": "symmetric"}));
fold_line(&mut c);
let e = c.execute("SheetMetalFoldCmd", &json!({"sketch": "FoldLine", "curve": "fl"})).map(|_| ()).unwrap_err().to_string();
let e = c.execute("sheet.fold", &json!({"sketch": "FoldLine", "curve": "fl"})).map(|_| ()).unwrap_err().to_string();
assert!(e.contains("not supported yet"), "{e}");
}
#[test]
fn flange_edges_are_named_and_follow_the_base_sketch() {
let mut s = Session::default();
run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "Base"}));
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [100, 60]}));
let d = run(&mut s, "SketchDimension", json!({"entities": ["l1"], "value": 100}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "FusionSheetMetalFlangeCommand", json!({"sketch": "Base"}));
run(&mut s, "FusionSheetMetalFlangeCommand", json!({"edges": [[100, 30, 2.5]], "height": 20, "name": "Side"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]}));
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 100}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sheet.flange", json!({"sketch": "Base"}));
run(&mut s, "sheet.flange", json!({"edges": [[100, 30, 2.5]], "height": 20, "name": "Side"}));
let side = s.doc.find_feature("Side").cloned().unwrap();
assert_eq!(side.edge_names, vec!["base:l2:top".to_string()]);
let v0 = volume(&mut s);
// Narrower base: the flange stays on the right edge (now at x = 40).
let name = d["param"].as_str().or_else(|| d["name"].as_str()).map(str::to_string).unwrap_or_else(|| "d1".into());
run(&mut s, "ChangeParameterCommand", json!({"name": name, "expression": "40"}));
let b = run(&mut s, "MeasureCommand", json!({}))["bodies"][0]["bbox"].clone();
run(&mut s, "parameters.change", json!({"name": name, "expression": "40"}));
let b = run(&mut s, "inspect.measure", json!({}))["bodies"][0]["bbox"].clone();
let w = b["max"][0].as_f64().unwrap_or(0.0) - b["min"][0].as_f64().unwrap_or(0.0);
assert!((w - 40.0).abs() < 1e-3 && b["max"][2].as_f64().unwrap_or(0.0) > 19.0, "{b}");
assert!(volume(&mut s) < v0);
@@ -323,7 +323,7 @@ fn flange_edges_are_named_and_follow_the_base_sketch() {
// A hem on the flange's far edge is named after the flange.
let top = b["max"][2].as_f64().unwrap_or(0.0);
let x = b["max"][0].as_f64().unwrap_or(0.0);
run(&mut s, "FusionSheetMetalHemFlangeCommand", json!({"edges": [[x, 30, top]], "length": 6, "name": "H"}));
run(&mut s, "sheet.hem", json!({"edges": [[x, 30, top]], "length": 6, "name": "H"}));
let h = s.doc.find_feature("H").cloned().unwrap();
assert!(h.edge_names[0].starts_with(&format!("F{id}.0:far")), "{:?}", h.edge_names);
}
@@ -334,7 +334,7 @@ fn fold_keeps_the_larger_side_either_way() {
for (w, line, moving_right) in [(60.0, 40.0, true), (100.0, 40.0, false)] {
let mut s = Session::default();
base(&mut s, w, 40.0);
run(&mut s, "SheetMetalFoldCmd", json!({"points": [[line, 0, 2.5], [line, 40, 2.5]]}));
run(&mut s, "sheet.fold", json!({"points": [[line, 0, 2.5], [line, 40, 2.5]]}));
let b = bbox(&mut s);
let (x0, x1) = (b["min"][0].as_f64().unwrap_or(0.0), b["max"][0].as_f64().unwrap_or(0.0));
if moving_right {
+69 -61
View File
@@ -10,136 +10,136 @@ use crate::params::{bad, bool_, expr, num, req_vec2, str_, string_list, vec2, ve
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("SketchCreate", "Create Sketch", create_sketch)
CommandSpec::new("sketch.create", "Create Sketch", create_sketch)
.at("SOLID", "CREATE")
.icon("sketch")
.params("plane: XY|XZ|YZ | {origin, x_dir, y_dir} | {face: [x,y,z]} (planar face at point), offset?: expr, name?"),
CommandSpec::new("SketchActivate", "Edit Sketch", edit_sketch).icon("sketch").params("sketch: id or name"),
CommandSpec::new("SketchStop", "Finish Sketch", finish_sketch).at("SKETCH", "FINISH SKETCH").icon("finish").enabled(in_sketch).noundo(),
CommandSpec::new("DrawPolyline", "Line", draw_line)
CommandSpec::new("sketch.edit", "Edit Sketch", edit_sketch).icon("sketch").params("sketch: id or name"),
CommandSpec::new("sketch.finish", "Finish Sketch", finish_sketch).at("SKETCH", "FINISH SKETCH").icon("finish").enabled(in_sketch).noundo(),
CommandSpec::new("sketch.line", "Line", draw_line)
.at("SKETCH", "CREATE")
.icon("line")
.key("L")
.enabled(in_sketch)
.params("points: [[x,y] | \"l1.end\" | {at, ref}…], closed?: bool, construction?: bool, ids?: [..], infer?: bool (add horizontal/vertical)"),
CommandSpec::new("ShapeRectangleTwoPoint", "2-Point Rectangle", rect_two_point)
CommandSpec::new("sketch.rectangle.two_point", "2-Point Rectangle", rect_two_point)
.at("SKETCH", "CREATE")
.icon("rect")
.key("R")
.enabled(in_sketch)
.params("p0, p1: opposite corners [x,y]; construction?"),
CommandSpec::new("ShapeRectangleThreePoint", "3-Point Rectangle", rect_three_point)
CommandSpec::new("sketch.rectangle.three_point", "3-Point Rectangle", rect_three_point)
.at("SKETCH", "CREATE")
.icon("rect3")
.enabled(in_sketch)
.params("p0, p1: first edge; p2: point on the opposite edge"),
CommandSpec::new("ShapeRectangleCenter", "Center Rectangle", rect_center)
CommandSpec::new("sketch.rectangle.center", "Center Rectangle", rect_center)
.at("SKETCH", "CREATE")
.icon("rect_center")
.enabled(in_sketch)
.params("center, corner"),
CommandSpec::new("CircleCenterRadius", "Center Diameter Circle", circle_center)
CommandSpec::new("sketch.circle.center", "Center Diameter Circle", circle_center)
.at("SKETCH", "CREATE")
.icon("circle")
.key("C")
.enabled(in_sketch)
.params("center: [x,y] or point ref, radius | diameter: number"),
CommandSpec::new("CircleTwoPoint", "2-Point Circle", circle_two)
CommandSpec::new("sketch.circle.two_point", "2-Point Circle", circle_two)
.at("SKETCH", "CREATE")
.icon("circle2")
.enabled(in_sketch)
.params("p0, p1: diameter ends"),
CommandSpec::new("CircleThreePoint", "3-Point Circle", circle_three)
CommandSpec::new("sketch.circle.three_point", "3-Point Circle", circle_three)
.at("SKETCH", "CREATE")
.icon("circle3")
.enabled(in_sketch)
.params("p0, p1, p2"),
CommandSpec::new("ArcThreePoint", "3-Point Arc", arc_three).at("SKETCH", "CREATE").icon("arc3").enabled(in_sketch).params("start, end, through"),
CommandSpec::new("ArcCenterTwoPoint", "Center Point Arc", arc_center)
CommandSpec::new("sketch.arc.three_point", "3-Point Arc", arc_three).at("SKETCH", "CREATE").icon("arc3").enabled(in_sketch).params("start, end, through"),
CommandSpec::new("sketch.arc.center_point", "Center Point Arc", arc_center)
.at("SKETCH", "CREATE")
.icon("arc_center")
.enabled(in_sketch)
.params("center, start, end (counter-clockwise from start) | center, start, sweep: signed degrees"),
CommandSpec::new("ShapePolygonInscribed", "Inscribed Polygon", polygon_inscribed)
CommandSpec::new("sketch.polygon.inscribed", "Inscribed Polygon", polygon_inscribed)
.at("SKETCH", "CREATE")
.icon("polygon")
.enabled(in_sketch)
.params("center, radius (to vertices), sides, angle?: deg"),
CommandSpec::new("ShapePolygonCircumscribed", "Circumscribed Polygon", polygon_circumscribed)
CommandSpec::new("sketch.polygon.circumscribed", "Circumscribed Polygon", polygon_circumscribed)
.at("SKETCH", "CREATE")
.icon("polygon")
.enabled(in_sketch)
.params("center, radius (to edge midpoints), sides, angle?: deg"),
CommandSpec::new("ShapePolygonEdge", "Edge Polygon", polygon_edge)
CommandSpec::new("sketch.polygon.edge", "Edge Polygon", polygon_edge)
.at("SKETCH", "CREATE")
.icon("polygon")
.enabled(in_sketch)
.params("p0, p1: one edge, sides"),
CommandSpec::new("ShapeSlotCenterToCenter", "Center to Center Slot", slot_c2c)
CommandSpec::new("sketch.slot.center_to_center", "Center to Center Slot", slot_c2c)
.at("SKETCH", "CREATE")
.icon("slot")
.enabled(in_sketch)
.params("p0, p1: arc centres, width"),
CommandSpec::new("ShapeSlotOverall", "Overall Slot", slot_overall)
CommandSpec::new("sketch.slot.overall", "Overall Slot", slot_overall)
.at("SKETCH", "CREATE")
.icon("slot")
.enabled(in_sketch)
.params("p0, p1: overall ends, width"),
CommandSpec::new("DrawPoint", "Point", draw_point).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).params("point: [x,y], id?"),
CommandSpec::new("SketchDimension", "Sketch Dimension", dimension).at("SKETCH", "CREATE").icon("dimension").key("D").enabled(in_sketch).params(
CommandSpec::new("sketch.point", "Point", draw_point).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).params("point: [x,y], id?"),
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)",
),
CommandSpec::new("ConstraintHorizontalVertical", "Horizontal/Vertical", c_horizontal_vertical)
CommandSpec::new("sketch.constraint.horizontal_vertical", "Horizontal/Vertical", c_horizontal_vertical)
.at("SKETCH", "CONSTRAINTS")
.icon("c_hv")
.enabled(in_sketch)
.params("line: id | points: [a, b]; mode?: horizontal|vertical"),
CommandSpec::new("ConstraintCoincident", "Coincident", c_coincident)
CommandSpec::new("sketch.constraint.coincident", "Coincident", c_coincident)
.at("SKETCH", "CONSTRAINTS")
.icon("c_coincident")
.enabled(in_sketch)
.params("a: point, b: point or curve"),
CommandSpec::new("ConstraintTangent", "Tangent", c_tangent)
CommandSpec::new("sketch.constraint.tangent", "Tangent", c_tangent)
.at("SKETCH", "CONSTRAINTS")
.icon("c_tangent")
.enabled(in_sketch)
.params("a, b: curves"),
CommandSpec::new("ConstraintEqual", "Equal", c_equal)
CommandSpec::new("sketch.constraint.equal", "Equal", c_equal)
.at("SKETCH", "CONSTRAINTS")
.icon("c_equal")
.enabled(in_sketch)
.params("a, b: lines or circles/arcs"),
CommandSpec::new("ConstraintParallel", "Parallel", c_parallel)
CommandSpec::new("sketch.constraint.parallel", "Parallel", c_parallel)
.at("SKETCH", "CONSTRAINTS")
.icon("c_parallel")
.enabled(in_sketch)
.params("a, b: lines"),
CommandSpec::new("ConstraintPerpendicular", "Perpendicular", c_perpendicular)
CommandSpec::new("sketch.constraint.perpendicular", "Perpendicular", c_perpendicular)
.at("SKETCH", "CONSTRAINTS")
.icon("c_perpendicular")
.enabled(in_sketch)
.params("a, b: lines"),
CommandSpec::new("ConstraintFix", "Fix/UnFix", c_fix)
CommandSpec::new("sketch.constraint.fix", "Fix/UnFix", c_fix)
.at("SKETCH", "CONSTRAINTS")
.icon("c_fix")
.enabled(in_sketch)
.params("entity: point or curve | entities: [points and curves]; fixed?: bool (default: fix them all unless all are fixed, then unfix)"),
CommandSpec::new("ConstraintMidPoint", "MidPoint", c_midpoint)
CommandSpec::new("sketch.constraint.midpoint", "MidPoint", c_midpoint)
.at("SKETCH", "CONSTRAINTS")
.icon("c_midpoint")
.enabled(in_sketch)
.params("point, line"),
CommandSpec::new("ConstraintConcentric", "Concentric", c_concentric)
CommandSpec::new("sketch.constraint.concentric", "Concentric", c_concentric)
.at("SKETCH", "CONSTRAINTS")
.icon("c_concentric")
.enabled(in_sketch)
.params("a, b: circles/arcs"),
CommandSpec::new("ConstraintCollinear", "Collinear", c_collinear)
CommandSpec::new("sketch.constraint.collinear", "Collinear", c_collinear)
.at("SKETCH", "CONSTRAINTS")
.icon("c_collinear")
.enabled(in_sketch)
.params("a, b: lines"),
CommandSpec::new("ConstraintSymmetry", "Symmetry", c_symmetry)
CommandSpec::new("sketch.constraint.symmetry", "Symmetry", c_symmetry)
.at("SKETCH", "CONSTRAINTS")
.icon("c_symmetry")
.enabled(in_sketch)
@@ -363,7 +363,7 @@ fn face_contains(m: &solvecraft_geom::Mesh, fi: usize, p: Vec3, tol: f64) -> boo
}
fn create_sketch(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "SketchCreate";
let cmd = "sketch.create";
let plane = plane_ref(s, p, cmd)?;
// Validate the plane resolves.
let (vals, _) = s.doc.param_values();
@@ -393,11 +393,11 @@ fn edit_sketch(s: &mut Session, p: &Value) -> Result<Value> {
let key = match p.get("sketch") {
Some(Value::Number(n)) => n.to_string(),
Some(Value::String(x)) => x.clone(),
_ => return Err(bad("SketchActivate", "`sketch` must be an id or a name")),
_ => return Err(bad("sketch.edit", "`sketch` must be an id or a name")),
};
let f = s.doc.find_feature(&key).ok_or_else(|| bad("SketchActivate", format!("no sketch `{key}`")))?;
let f = s.doc.find_feature(&key).ok_or_else(|| bad("sketch.edit", format!("no sketch `{key}`")))?;
if !matches!(f.kind, FeatureKind::Sketch { .. }) {
return Err(bad("SketchActivate", format!("`{key}` is not a sketch")));
return Err(bad("sketch.edit", format!("`{key}` is not a sketch")));
}
let (id, name) = (f.id, f.name.clone());
s.active_sketch = Some(id);
@@ -416,7 +416,7 @@ fn finish_sketch(s: &mut Session, _p: &Value) -> Result<Value> {
// Drawing
fn draw_line(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "DrawPolyline";
let cmd = "sketch.line";
let p = &super::sketch_project::snap_points(s, p, cmd)?;
let list = p.get("points").and_then(Value::as_array).cloned().ok_or_else(|| bad(cmd, "`points` must be a list"))?;
if list.len() < 2 || list.len() > 10_000 {
@@ -527,7 +527,7 @@ fn rect_constraints(sk: &mut Sketch, l: &[usize]) -> Result<Vec<String>> {
}
fn rect_two_point(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ShapeRectangleTwoPoint";
let cmd = "sketch.rectangle.two_point";
let (a, b) = (req_vec2(cmd, p, "p0")?, req_vec2(cmd, p, "p1")?);
if (a.x - b.x).abs() < 1e-9 || (a.y - b.y).abs() < 1e-9 {
return Err(bad(cmd, "the corners must differ in x and y"));
@@ -543,7 +543,7 @@ fn rect_two_point(s: &mut Session, p: &Value) -> Result<Value> {
}
fn rect_center(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ShapeRectangleCenter";
let cmd = "sketch.rectangle.center";
let (c, k) = (req_vec2(cmd, p, "center")?, req_vec2(cmd, p, "corner")?);
let h = Vec2::new((k.x - c.x).abs(), (k.y - c.y).abs());
if h.x < 1e-9 || h.y < 1e-9 {
@@ -573,7 +573,7 @@ fn rect_center(s: &mut Session, p: &Value) -> Result<Value> {
}
fn rect_three_point(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ShapeRectangleThreePoint";
let cmd = "sketch.rectangle.three_point";
let (a, b, c) = (req_vec2(cmd, p, "p0")?, req_vec2(cmd, p, "p1")?, req_vec2(cmd, p, "p2")?);
let d = (b - a).normalized().ok_or_else(|| bad(cmd, "p0 and p1 must differ"))?;
let n = d.perp();
@@ -605,7 +605,7 @@ pub(super) fn radius_arg(p: &Value, cmd: &str) -> Result<f64> {
}
fn circle_center(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "CircleCenterRadius";
let cmd = "sketch.circle.center";
let p = &super::sketch_project::snap_points(s, p, cmd)?;
let r = radius_arg(p, cmd)?;
let id = str_(p, "id").map(str::to_string);
@@ -619,7 +619,7 @@ fn circle_center(s: &mut Session, p: &Value) -> Result<Value> {
}
fn circle_two(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "CircleTwoPoint";
let cmd = "sketch.circle.two_point";
let (a, b) = (req_vec2(cmd, p, "p0")?, req_vec2(cmd, p, "p1")?);
let r = a.dist(b) / 2.0;
if r < 1e-9 {
@@ -647,7 +647,7 @@ pub(super) fn circumcircle(a: Vec2, b: Vec2, c: Vec2) -> Option<(Vec2, f64)> {
}
fn circle_three(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "CircleThreePoint";
let cmd = "sketch.circle.three_point";
let (a, b, c) = (req_vec2(cmd, p, "p0")?, req_vec2(cmd, p, "p1")?, req_vec2(cmd, p, "p2")?);
let (o, r) = circumcircle(a, b, c).ok_or_else(|| bad(cmd, "the points are collinear"))?;
let (out, info) = edit(s, p, cmd, false, |sk, _| {
@@ -658,7 +658,7 @@ fn circle_three(s: &mut Session, p: &Value) -> Result<Value> {
}
fn arc_three(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ArcThreePoint";
let cmd = "sketch.arc.three_point";
let p = &super::sketch_project::snap_points(s, p, cmd)?;
let id = str_(p, "id").map(str::to_string);
let (out, info) = edit(s, p, cmd, false, |sk, _| {
@@ -680,7 +680,7 @@ fn arc_three(s: &mut Session, p: &Value) -> Result<Value> {
}
fn arc_center(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ArcCenterTwoPoint";
let cmd = "sketch.arc.center_point";
let p = &super::sketch_project::snap_points(s, p, cmd)?;
let id = str_(p, "id").map(str::to_string);
let sweep = num(p, "sweep");
@@ -759,7 +759,7 @@ fn sides(p: &Value, cmd: &str) -> Result<usize> {
}
fn polygon_inscribed(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ShapePolygonInscribed";
let cmd = "sketch.polygon.inscribed";
let c = req_vec2(cmd, p, "center")?;
let r = radius_arg(p, cmd)?;
let n = sides(p, cmd)?;
@@ -768,7 +768,7 @@ fn polygon_inscribed(s: &mut Session, p: &Value) -> Result<Value> {
}
fn polygon_circumscribed(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ShapePolygonCircumscribed";
let cmd = "sketch.polygon.circumscribed";
let c = req_vec2(cmd, p, "center")?;
let r = radius_arg(p, cmd)?;
let n = sides(p, cmd)?;
@@ -777,7 +777,7 @@ fn polygon_circumscribed(s: &mut Session, p: &Value) -> Result<Value> {
}
fn polygon_edge(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ShapePolygonEdge";
let cmd = "sketch.polygon.edge";
let (a, b) = (req_vec2(cmd, p, "p0")?, req_vec2(cmd, p, "p1")?);
let n = sides(p, cmd)?;
let e = a.dist(b);
@@ -816,14 +816,14 @@ pub(super) fn slot(s: &mut Session, p: &Value, cmd: &str, c0: Vec2, c1: Vec2, w:
}
fn slot_c2c(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ShapeSlotCenterToCenter";
let cmd = "sketch.slot.center_to_center";
let (a, b) = (req_vec2(cmd, p, "p0")?, req_vec2(cmd, p, "p1")?);
let w = num(p, "width").filter(|w| *w > 1e-9).ok_or_else(|| bad(cmd, "`width` must be positive"))?;
slot(s, p, cmd, a, b, w)
}
fn slot_overall(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ShapeSlotOverall";
let cmd = "sketch.slot.overall";
let (a, b) = (req_vec2(cmd, p, "p0")?, req_vec2(cmd, p, "p1")?);
let w = num(p, "width").filter(|w| *w > 1e-9).ok_or_else(|| bad(cmd, "`width` must be positive"))?;
let d = (b - a).normalized().ok_or_else(|| bad(cmd, "the slot ends must differ"))?;
@@ -834,7 +834,7 @@ fn slot_overall(s: &mut Session, p: &Value) -> Result<Value> {
}
fn draw_point(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "DrawPoint";
let cmd = "sketch.point";
let at = req_vec2(cmd, p, "point")?;
let id = str_(p, "id").map(str::to_string);
let (pid, info) = edit(s, p, cmd, false, |sk, _| {
@@ -1018,7 +1018,7 @@ pub(super) fn reject_redundant(before: Option<usize>, info: &Value, cmd: &str) -
}
fn c_horizontal_vertical(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ConstraintHorizontalVertical";
let cmd = "sketch.constraint.horizontal_vertical";
let mode = str_(p, "mode").map(str::to_ascii_lowercase);
constrain(s, p, cmd, |sk| {
if let Some(pts) = p.get("points").and_then(Value::as_array) {
@@ -1048,7 +1048,7 @@ fn c_horizontal_vertical(s: &mut Session, p: &Value) -> Result<Value> {
}
fn c_coincident(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ConstraintCoincident";
let cmd = "sketch.constraint.coincident";
constrain(s, p, cmd, |sk| {
let aref = p.get("a").and_then(Value::as_str).unwrap_or_default();
let bref = p.get("b").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`b` must be a point or curve"))?;
@@ -1070,31 +1070,39 @@ fn two_curves(sk: &Sketch, p: &Value, cmd: &str) -> Result<(usize, usize)> {
}
fn c_tangent(s: &mut Session, p: &Value) -> Result<Value> {
constrain(s, p, "ConstraintTangent", |sk| two_curves(sk, p, "ConstraintTangent").map(|(a, b)| ConstraintKind::Tangent { a, b }))
constrain(s, p, "sketch.constraint.tangent", |sk| two_curves(sk, p, "sketch.constraint.tangent").map(|(a, b)| ConstraintKind::Tangent { a, b }))
}
fn c_equal(s: &mut Session, p: &Value) -> Result<Value> {
constrain(s, p, "ConstraintEqual", |sk| two_curves(sk, p, "ConstraintEqual").map(|(a, b)| ConstraintKind::Equal { a, b }))
constrain(s, p, "sketch.constraint.equal", |sk| two_curves(sk, p, "sketch.constraint.equal").map(|(a, b)| ConstraintKind::Equal { a, b }))
}
fn c_parallel(s: &mut Session, p: &Value) -> Result<Value> {
constrain(s, p, "ConstraintParallel", |sk| two_curves(sk, p, "ConstraintParallel").map(|(a, b)| ConstraintKind::Parallel { a, b }))
constrain(s, p, "sketch.constraint.parallel", |sk| {
two_curves(sk, p, "sketch.constraint.parallel").map(|(a, b)| ConstraintKind::Parallel { a, b })
})
}
fn c_perpendicular(s: &mut Session, p: &Value) -> Result<Value> {
constrain(s, p, "ConstraintPerpendicular", |sk| two_curves(sk, p, "ConstraintPerpendicular").map(|(a, b)| ConstraintKind::Perpendicular { a, b }))
constrain(s, p, "sketch.constraint.perpendicular", |sk| {
two_curves(sk, p, "sketch.constraint.perpendicular").map(|(a, b)| ConstraintKind::Perpendicular { a, b })
})
}
fn c_concentric(s: &mut Session, p: &Value) -> Result<Value> {
constrain(s, p, "ConstraintConcentric", |sk| two_curves(sk, p, "ConstraintConcentric").map(|(a, b)| ConstraintKind::Concentric { a, b }))
constrain(s, p, "sketch.constraint.concentric", |sk| {
two_curves(sk, p, "sketch.constraint.concentric").map(|(a, b)| ConstraintKind::Concentric { a, b })
})
}
fn c_collinear(s: &mut Session, p: &Value) -> Result<Value> {
constrain(s, p, "ConstraintCollinear", |sk| two_curves(sk, p, "ConstraintCollinear").map(|(a, b)| ConstraintKind::Collinear { a, b }))
constrain(s, p, "sketch.constraint.collinear", |sk| {
two_curves(sk, p, "sketch.constraint.collinear").map(|(a, b)| ConstraintKind::Collinear { a, b })
})
}
fn c_midpoint(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ConstraintMidPoint";
let cmd = "sketch.constraint.midpoint";
constrain(s, p, cmd, |sk| {
Ok(ConstraintKind::Midpoint { p: point_ref(sk, p.get("point"), cmd, "point")?, l: curve_ref(sk, p.get("line"), cmd, "line")? })
})
}
fn c_symmetry(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ConstraintSymmetry";
let cmd = "sketch.constraint.symmetry";
let (ids, info) = edit(s, p, cmd, true, |sk, _| {
let l = curve_ref(sk, p.get("line"), cmd, "line")?;
let ra = p.get("a").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`a` must be a point or curve"))?;
@@ -1141,7 +1149,7 @@ fn round_center(sk: &Sketch, c: usize) -> Option<usize> {
}
fn c_fix(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ConstraintFix";
let cmd = "sketch.constraint.fix";
let want = bool_(p, "fixed");
let mut refs = string_list(p, "entities");
if let Some(e) = str_(p, "entity") {
@@ -1207,7 +1215,7 @@ pub(super) fn line_pts(sk: &Sketch, l: usize) -> Option<(Vec2, Vec2)> {
}
fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "SketchDimension";
let cmd = "sketch.dimension";
let ents = string_list(p, "entities");
if ents.is_empty() || ents.len() > 2 {
return Err(bad(cmd, "`entities` must list one or two sketch entities"));
+9 -9
View File
@@ -9,11 +9,11 @@ use crate::params::{bad, bool_, num, str_, vec2};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("FusionAddCanvasCommand", "Canvas", add_canvas)
CommandSpec::new("canvas.insert", "Canvas", add_canvas)
.at("SKETCH", "INSERT")
.icon("canvas")
.params("path | data (base64 PNG or JPEG); plane?: XY|XZ|YZ|{face: [x,y,z]}|… (default XY); center?: [x,y] on the plane, or at?: [x,y,z]; width?: mm (default 100); angle?: deg; opacity?: 0…1 (default 0.5); flip?; name?"),
CommandSpec::new("FusionAddEditDecalCommand", "Decal", add_decal)
CommandSpec::new("canvas.decal", "Decal", add_decal)
.at("SKETCH", "INSERT")
.icon("decal")
.params("path | data; face: [x,y,z] on a planar face (the image is centred there); width?: mm; angle?: deg; opacity?: (default 1)"),
@@ -79,11 +79,11 @@ fn make(s: &mut Session, p: &Value, cmd: &str, decal: bool) -> Result<Value> {
}
fn add_canvas(s: &mut Session, p: &Value) -> Result<Value> {
make(s, p, "FusionAddCanvasCommand", false)
make(s, p, "canvas.insert", false)
}
fn add_decal(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionAddEditDecalCommand";
let cmd = "canvas.decal";
let face = p.get("face").cloned().ok_or_else(|| bad(cmd, "`face` must be a point [x,y,z] on a planar face"))?;
let mut q = p.clone();
if let Some(o) = q.as_object_mut() {
@@ -174,7 +174,7 @@ mod tests {
#[test]
fn canvas_insert_edit_calibrate_save() {
let mut s = Session::default();
let r = s.execute("FusionAddCanvasCommand", &json!({"data": png(400, 200), "plane": "XZ", "width": 80})).unwrap();
let r = s.execute("canvas.insert", &json!({"data": png(400, 200), "plane": "XZ", "width": 80})).unwrap();
let id = r["canvas"].as_u64().unwrap();
let c = s.doc.canvases[0].clone();
assert_eq!(c.pixels, [400, 200]);
@@ -188,12 +188,12 @@ mod tests {
assert_eq!(back.canvases, s.doc.canvases);
s.execute("canvas.delete", &json!({"canvas": id})).unwrap();
assert!(s.doc.canvases.is_empty());
s.execute("UndoCommand", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc.canvases.len(), 1);
assert!(s.execute("FusionAddCanvasCommand", &json!({"data": "bm9wZQ=="})).is_err());
assert!(s.execute("canvas.insert", &json!({"data": "bm9wZQ=="})).is_err());
// A decal on the top face of a box.
s.execute("PrimitiveBox", &json!({"length": 30, "width": 30, "height": 10})).unwrap();
let r = s.execute("FusionAddEditDecalCommand", &json!({"data": png(10, 10), "face": [15, 15, 10], "width": 20})).unwrap();
s.execute("solid.box", &json!({"length": 30, "width": 30, "height": 10})).unwrap();
let r = s.execute("canvas.decal", &json!({"data": png(10, 10), "face": [15, 15, 10], "width": 20})).unwrap();
assert!(r["canvas"].is_u64());
}
}
+12 -12
View File
@@ -11,32 +11,32 @@ use crate::params::{bad, num, req_vec2, str_, string_list, vec2};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("ConstraintSmooth", "Curvature", c_smooth)
CommandSpec::new("sketch.constraint.curvature", "Curvature", c_smooth)
.at("SKETCH", "CONSTRAINTS")
.icon("c_smooth")
.enabled(in_sketch)
.params("a, b: two curves sharing an end point (curvature continuous, G2)"),
CommandSpec::new("SketchPolygonConstraintCmd", "Polygon", c_polygon)
CommandSpec::new("sketch.constraint.polygon", "Polygon", c_polygon)
.at("SKETCH", "CONSTRAINTS")
.icon("c_polygon")
.enabled(in_sketch)
.params("lines: [line ids] of a closed chain: equal sides with every corner on one circle (a regular polygon)"),
CommandSpec::new("SketchAutoConstraintAndDimCmd", "AutoConstrain", auto_constrain)
CommandSpec::new("sketch.auto_constrain", "AutoConstrain", auto_constrain)
.at("SKETCH", "CONSTRAINTS")
.icon("auto_constrain")
.enabled(in_sketch)
.params("tolerance?: mm (default 0.01), angle_tolerance?: deg (default 1), dimensions?: bool (default true): infer constraints, then dimension until fully constrained"),
CommandSpec::new("SketchAutoConstraintAndDimFromDatumCmd", "AutoConstrain from datum", auto_constrain_datum)
CommandSpec::new("sketch.auto_constrain_from_datum", "AutoConstrain from datum", auto_constrain_datum)
.at("SKETCH", "CONSTRAINTS")
.icon("auto_constrain")
.enabled(in_sketch)
.params("datum: point ref (default origin); like AutoConstrain with positions measured from the datum"),
CommandSpec::new("SketchAutoConstrainAndFinish", "Finish with AutoConstrain", auto_constrain_finish)
CommandSpec::new("sketch.finish_auto_constrain", "Finish with AutoConstrain", auto_constrain_finish)
.at("SKETCH", "FINISH SKETCH")
.icon("finish")
.enabled(in_sketch)
.params("like AutoConstrain, then Finish Sketch"),
CommandSpec::new("SketchConstrainer", "SketchConstrainer", smart_constrain)
CommandSpec::new("sketch.constrainer", "Sketch Constrainer", smart_constrain)
.at("SKETCH", "CREATE")
.icon("constrainer")
.enabled(in_sketch)
@@ -55,7 +55,7 @@ pub static COMMANDS: &[CommandSpec] = &[
];
fn c_smooth(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ConstraintSmooth";
let cmd = "sketch.constraint.curvature";
let mut before = None;
let (id, info) = edit(s, p, cmd, true, |sk, _| {
let a = curve(sk, p.get("a"), cmd)?;
@@ -73,7 +73,7 @@ fn curve(sk: &Sketch, v: Option<&Value>, cmd: &str) -> Result<usize> {
}
fn c_polygon(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "SketchPolygonConstraintCmd";
let cmd = "sketch.constraint.polygon";
let ids = string_list(p, "lines");
if ids.len() < 3 || ids.len() > 200 {
return Err(bad(cmd, "`lines` must list 3 to 200 lines of a closed chain"));
@@ -258,13 +258,13 @@ fn auto(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
}
fn auto_constrain(s: &mut Session, p: &Value) -> Result<Value> {
auto(s, p, "SketchAutoConstraintAndDimCmd")
auto(s, p, "sketch.auto_constrain")
}
fn auto_constrain_datum(s: &mut Session, p: &Value) -> Result<Value> {
auto(s, p, "SketchAutoConstraintAndDimFromDatumCmd")
auto(s, p, "sketch.auto_constrain_from_datum")
}
fn auto_constrain_finish(s: &mut Session, p: &Value) -> Result<Value> {
let r = auto(s, p, "SketchAutoConstrainAndFinish")?;
let r = auto(s, p, "sketch.finish_auto_constrain")?;
s.active_sketch = None;
s.revision += 1;
Ok(r)
@@ -343,7 +343,7 @@ fn guess(sk: &Sketch, ents: &[String], cmd: &str) -> Result<ConstraintKind> {
}
fn smart_constrain(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "SketchConstrainer";
let cmd = "sketch.constrainer";
let ents = string_list(p, "entities");
let mut before = None;
let mut name = "";
@@ -16,7 +16,7 @@ fn ids(v: &Value) -> Vec<String> {
fn new_sketch() -> Session {
let mut s = Session::default();
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
s
}
@@ -27,21 +27,21 @@ fn sketch(s: &Session) -> solvecraft_sketch::Sketch {
#[test]
fn redundant_constraints_and_dimensions_are_refused_driven_ones_measure() {
let mut s = new_sketch();
let l = ids(&run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [10, 0]], "infer": true}))["curves"])[0].clone();
let l = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]], "infer": true}))["curves"])[0].clone();
// Already horizontal (inferred).
let e = s.execute("ConstraintHorizontalVertical", &json!({"line": l})).unwrap_err().to_string();
let e = s.execute("sketch.constraint.horizontal_vertical", &json!({"line": l})).unwrap_err().to_string();
assert!(e.contains("over-constrain"), "{e}");
run(&mut s, "ConstraintCoincident", json!({"a": format!("{l}.start"), "b": "origin"}));
run(&mut s, "SketchDimension", json!({"entities": [l], "value": 10}));
run(&mut s, "sketch.constraint.coincident", json!({"a": format!("{l}.start"), "b": "origin"}));
run(&mut s, "sketch.dimension", json!({"entities": [l], "value": 10}));
let si = run(&mut s, "sketch.inspect", json!({}));
assert_eq!(si["dof"], 0, "{si}");
// A second length on it over-constrains: refused, unless driven.
assert!(s.execute("SketchDimension", &json!({"entities": [format!("{l}.start"), format!("{l}.end")]})).is_err());
let d = run(&mut s, "SketchDimension", json!({"entities": [format!("{l}.start"), format!("{l}.end")], "driven": true}));
assert!(s.execute("sketch.dimension", &json!({"entities": [format!("{l}.start"), format!("{l}.end")]})).is_err());
let d = run(&mut s, "sketch.dimension", json!({"entities": [format!("{l}.start"), format!("{l}.end")], "driven": true}));
assert_eq!(d["driven"], true);
// The driven dimension follows the driving one.
let pname = si["constraints"].as_array().unwrap().iter().find_map(|c| c["param"].as_str().map(str::to_string)).unwrap();
run(&mut s, "ChangeParameterCommand", json!({"name": pname, "expression": "25 mm"}));
run(&mut s, "parameters.change", json!({"name": pname, "expression": "25 mm"}));
let g = run(&mut s, "sketch.glyphs", json!({}));
let driven = g["glyphs"].as_array().unwrap().iter().find(|x| x["driven"] == true).unwrap().clone();
assert!((driven["value"].as_f64().unwrap() - 25.0).abs() < 1e-7, "{driven}");
@@ -53,9 +53,10 @@ fn curvature_polygon_arc_length_linear_diameter() {
let mut s = new_sketch();
// A horizontal line ending where a control-point spline starts: G2 keeps the spline's
// start straight (zero curvature) and tangent.
let l = ids(&run(&mut s, "DrawPolyline", json!({"points": [[-10, 0], [0, 0]], "infer": true}))["curves"])[0].clone();
let sp = ids(&run(&mut s, "DrawCVMSpline3D", json!({"points": [format!("{l}.end"), [5, 2], [10, 6], [15, 15]]}))["curves"])[0].clone();
run(&mut s, "ConstraintSmooth", json!({"a": l, "b": sp}));
let l = ids(&run(&mut s, "sketch.line", json!({"points": [[-10, 0], [0, 0]], "infer": true}))["curves"])[0].clone();
let sp =
ids(&run(&mut s, "sketch.spline.control_point", json!({"points": [format!("{l}.end"), [5, 2], [10, 6], [15, 15]]}))["curves"])[0].clone();
run(&mut s, "sketch.constraint.curvature", json!({"a": l, "b": sp}));
let sk = sketch(&s);
let pts: Vec<Vec2> = sk.curves[sk.curve_index(&sp).unwrap()].kind.point_ids().iter().map(|i| sk.point(*i).unwrap()).collect();
// Tangent: first leg along +x; zero curvature: the second leg too (collinear first three).
@@ -63,16 +64,16 @@ fn curvature_polygon_arc_length_linear_diameter() {
assert!((pts[2] - pts[1]).cross(pts[1] - pts[0]).abs() < 1e-3, "{pts:?}");
// Polygon constraint on a rough quadrilateral: becomes a square-ish regular polygon.
let q = run(&mut s, "DrawPolyline", json!({"points": [[100, 0], [111, 1], [110, 9], [99, 10]], "closed": true}));
let q = run(&mut s, "sketch.line", json!({"points": [[100, 0], [111, 1], [110, 9], [99, 10]], "closed": true}));
let ql = ids(&q["curves"]);
run(&mut s, "SketchPolygonConstraintCmd", json!({"lines": ql}));
run(&mut s, "sketch.constraint.polygon", json!({"lines": ql}));
let sk = sketch(&s);
let lens: Vec<f64> = ql.iter().map(|i| sk.polyline(sk.curve_index(i).unwrap())).map(|p| p[0].dist(p[p.len() - 1])).collect();
assert!(lens.iter().all(|x| (x - lens[0]).abs() < 1e-6), "{lens:?}");
// Arc length.
let a = ids(&run(&mut s, "ArcCenterTwoPoint", json!({"center": [0, 50], "start": [10, 50], "end": [0, 60]}))["curves"])[0].clone();
run(&mut s, "SketchDimension", json!({"entities": [a], "type": "arc_length", "value": 20}));
let a = ids(&run(&mut s, "sketch.arc.center_point", json!({"center": [0, 50], "start": [10, 50], "end": [0, 60]}))["curves"])[0].clone();
run(&mut s, "sketch.dimension", json!({"entities": [a], "type": "arc_length", "value": 20}));
let sk = sketch(&s);
let ai = sk.curve_index(&a).unwrap();
let len = match sk.segs(ai)[0] {
@@ -82,10 +83,10 @@ fn curvature_polygon_arc_length_linear_diameter() {
assert!((len - 20.0).abs() < 1e-6, "{len}");
// Linear diameter about a centerline.
let cl = ids(&run(&mut s, "DrawPolyline", json!({"points": [[200, 0], [200, 50]]}))["curves"])[0].clone();
let cl = ids(&run(&mut s, "sketch.line", json!({"points": [[200, 0], [200, 50]]}))["curves"])[0].clone();
run(&mut s, "sketch.centerline", json!({"curves": [cl]}));
run(&mut s, "DrawPoint", json!({"point": [207, 10], "id": "rim"}));
let d = run(&mut s, "SketchDimension", json!({"entities": ["rim", cl], "value": 30}));
run(&mut s, "sketch.point", json!({"point": [207, 10], "id": "rim"}));
let d = run(&mut s, "sketch.dimension", json!({"entities": ["rim", cl], "value": 30}));
assert_eq!(d["type"], "LinearDiameterDimension", "{d}");
let sk = sketch(&s);
let rim = sk.point(sk.point_index("rim").unwrap()).unwrap();
@@ -98,7 +99,7 @@ fn curvature_polygon_arc_length_linear_diameter() {
#[test]
fn glyphs_and_snaps() {
let mut s = new_sketch();
let r = run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [20, 10]}));
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]}));
let l = ids(&r["curves"]);
let g = run(&mut s, "sketch.glyphs", json!({}));
assert_eq!(g["glyphs"].as_array().unwrap().len(), 4);
@@ -116,9 +117,9 @@ fn glyphs_and_snaps() {
fn auto_constrain_fully_constrains_a_rough_sketch() {
let mut s = new_sketch();
// A slightly crooked closed rectangle and a circle inside.
run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [40, 0.004], [40.003, 30], [0, 30.002]], "closed": true}));
run(&mut s, "CircleCenterRadius", json!({"center": [20, 15], "radius": 5}));
let r = run(&mut s, "SketchAutoConstraintAndDimCmd", json!({}));
run(&mut s, "sketch.line", json!({"points": [[0, 0], [40, 0.004], [40.003, 30], [0, 30.002]], "closed": true}));
run(&mut s, "sketch.circle.center", json!({"center": [20, 15], "radius": 5}));
let r = run(&mut s, "sketch.auto_constrain", json!({}));
assert_eq!(r["dof"], 0, "{r}");
let si = run(&mut s, "sketch.inspect", json!({}));
assert_eq!(si["fully_constrained"], true, "{si}");
@@ -127,9 +128,9 @@ fn auto_constrain_fully_constrains_a_rough_sketch() {
assert!(names.iter().any(|n| n == "DiameterDimension"), "{names:?}");
// From a datum, and finishing.
let mut s = new_sketch();
run(&mut s, "DrawPoint", json!({"point": [5, 5], "id": "d"}));
run(&mut s, "DrawPolyline", json!({"points": [[10, 10], [20, 10]]}));
let r = run(&mut s, "SketchAutoConstrainAndFinish", json!({"datum": "d"}));
run(&mut s, "sketch.point", json!({"point": [5, 5], "id": "d"}));
run(&mut s, "sketch.line", json!({"points": [[10, 10], [20, 10]]}));
let r = run(&mut s, "sketch.finish_auto_constrain", json!({"datum": "d"}));
assert!(s.active_sketch.is_none());
assert!(r["added"].as_array().unwrap().len() >= 3, "{r}");
}
@@ -137,11 +138,11 @@ fn auto_constrain_fully_constrains_a_rough_sketch() {
#[test]
fn drawing_infers_tangent_and_perpendicular() {
let mut s = new_sketch();
let l1 = ids(&run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [10, 10]], "infer": true}))["curves"])[0].clone();
let r = run(&mut s, "DrawPolyline", json!({"points": [format!("{l1}.end"), [20, 0]], "infer": true}));
let l1 = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 10]], "infer": true}))["curves"])[0].clone();
let r = run(&mut s, "sketch.line", json!({"points": [format!("{l1}.end"), [20, 0]], "infer": true}));
assert_eq!(r["constraints"].as_array().unwrap().len(), 1, "{r}");
let a = ids(&run(&mut s, "ArcCenterTwoPoint", json!({"center": [50, 0], "start": [60, 0], "end": [50, 10]}))["curves"])[0].clone();
let r = run(&mut s, "DrawPolyline", json!({"points": [format!("{a}.end"), [30, 10]], "infer": true}));
let a = ids(&run(&mut s, "sketch.arc.center_point", json!({"center": [50, 0], "start": [60, 0], "end": [50, 10]}))["curves"])[0].clone();
let r = run(&mut s, "sketch.line", json!({"points": [format!("{a}.end"), [30, 10]], "infer": true}));
let si = run(&mut s, "sketch.inspect", json!({}));
let names: Vec<&str> = si["constraints"].as_array().unwrap().iter().map(|c| c["name"].as_str().unwrap()).collect();
assert!(names.contains(&"Perpendicular") && names.contains(&"Tangent"), "{names:?} {r}");
@@ -150,18 +151,18 @@ fn drawing_infers_tangent_and_perpendicular() {
#[test]
fn smart_constrain_and_driven_toggle() {
let mut s = new_sketch();
let a = ids(&run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [10, 0.5]]}))["curves"])[0].clone();
let b = ids(&run(&mut s, "DrawPolyline", json!({"points": [[0, 5], [10, 6]]}))["curves"])[0].clone();
let r = run(&mut s, "SketchConstrainer", json!({"entities": [a]}));
let a = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0.5]]}))["curves"])[0].clone();
let b = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 5], [10, 6]]}))["curves"])[0].clone();
let r = run(&mut s, "sketch.constrainer", json!({"entities": [a]}));
assert_eq!(r["type"], "Horizontal");
let r = run(&mut s, "SketchConstrainer", json!({"entities": [a, b]}));
let r = run(&mut s, "sketch.constrainer", json!({"entities": [a, b]}));
assert_eq!(r["type"], "Parallel");
let c1 = ids(&run(&mut s, "CircleCenterRadius", json!({"center": [50, 0], "radius": 5}))["curves"])[0].clone();
let c2 = ids(&run(&mut s, "CircleCenterRadius", json!({"center": [50.3, 0.2], "radius": 3}))["curves"])[0].clone();
let r = run(&mut s, "SketchConstrainer", json!({"entities": [c1, c2]}));
let c1 = ids(&run(&mut s, "sketch.circle.center", json!({"center": [50, 0], "radius": 5}))["curves"])[0].clone();
let c2 = ids(&run(&mut s, "sketch.circle.center", json!({"center": [50.3, 0.2], "radius": 3}))["curves"])[0].clone();
let r = run(&mut s, "sketch.constrainer", json!({"entities": [c1, c2]}));
assert_eq!(r["type"], "Concentric");
// Driven toggle.
let d = run(&mut s, "SketchDimension", json!({"entities": [c1], "value": 10}));
let d = run(&mut s, "sketch.dimension", json!({"entities": [c1], "value": 10}));
let g = run(&mut s, "sketch.glyphs", json!({}));
let did = g["glyphs"].as_array().unwrap().iter().find(|x| x["param"] == d["param"]).unwrap()["id"].as_str().unwrap().to_string();
let r = run(&mut s, "sketch.toggle_driven", json!({"constraint": did}));
@@ -174,8 +175,8 @@ fn smart_constrain_and_driven_toggle() {
#[test]
fn drawing_from_a_line_midpoint_keeps_it_there() {
let mut s = new_sketch();
let l = ids(&run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [20, 0]]}))["curves"])[0].clone();
let r = run(&mut s, "DrawPolyline", json!({"points": [format!("mid:{l}"), [10, 15]]}));
let l = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [20, 0]]}))["curves"])[0].clone();
let r = run(&mut s, "sketch.line", json!({"points": [format!("mid:{l}"), [10, 15]]}));
let si = run(&mut s, "sketch.inspect", json!({}));
assert!(si["constraints"].as_array().unwrap().iter().any(|c| c["name"] == "MidPoint"), "{si}");
// Lengthen the first line: the second one's start follows the middle.
@@ -191,10 +192,10 @@ fn drawing_from_a_line_midpoint_keeps_it_there() {
#[test]
fn drawing_onto_a_curve_keeps_the_point_on_it() {
let mut s = new_sketch();
let c = ids(&run(&mut s, "CircleCenterRadius", json!({"center": [0, 0], "radius": 10}))["curves"])[0].clone();
let r = run(&mut s, "DrawPolyline", json!({"points": [format!("on:{c}:7.2,7.1"), [30, 30]]}));
let c = ids(&run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 10}))["curves"])[0].clone();
let r = run(&mut s, "sketch.line", json!({"points": [format!("on:{c}:7.2,7.1"), [30, 30]]}));
let n = ids(&r["curves"])[0].clone();
run(&mut s, "SketchDimension", json!({"entities": [c], "value": 16}));
run(&mut s, "sketch.dimension", json!({"entities": [c], "value": 16}));
let sk = sketch(&s);
let st = sk.resolve_point(&format!("{n}.start")).and_then(|i| sk.point(i)).unwrap();
let cc = sk.center(sk.curve_index(&c).unwrap()).unwrap();
+28 -28
View File
@@ -12,65 +12,65 @@ use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] =
&[
CommandSpec::new("SketchMidpointLine", "Midpoint Line", midpoint_line)
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("ArcTangent", "Tangent Arc", arc_tangent).at("SKETCH", "CREATE").icon("arc_tangent").enabled(in_sketch).params(
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("CircleTanTanRadius", "2-Tangent Circle", circle_tan_tan)
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("CircleThreeTangent", "3-Tangent Circle", circle_three_tan)
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("ShapeSlotCenterPoint", "Center Point Slot", slot_center_point)
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("ShapeArcSlotThreePoint", "Three Point Arc Slot", arc_slot_three)
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("ShapeArcSlotCenterTwoPoint", "Center Point Arc Slot", arc_slot_center)
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("CircleElipse", "Ellipse", ellipse)
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("DrawSpline", "Fit Point Spline", spline_fit)
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): the spline passes through them"),
CommandSpec::new("DrawCVMSpline3D", "Control Point Spline", spline_cv3)
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("DrawCVMSpline5D", "Control Point Spline (Degree 5)", spline_cv5)
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("ConicCurveCmd", "Conic Curve", conic)
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("BlendG1CurveSketchCmd", "Blend Curve", blend)
CommandSpec::new("sketch.blend_curve", "Blend Curve", blend)
.at("SKETCH", "MODIFY")
.icon("blend")
.enabled(in_sketch)
@@ -78,7 +78,7 @@ pub static COMMANDS: &[CommandSpec] =
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("MTextCmd", "Text", text)
CommandSpec::new("sketch.text", "Text", text)
.at("SKETCH", "CREATE")
.icon("text")
.enabled(in_sketch)
@@ -107,7 +107,7 @@ fn text_source(p: &Value, cmd: &str, old: Option<(String, Vec2, f64, f64)>) -> R
}
fn text(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "MTextCmd";
let cmd = "sketch.text";
if p.get("at").is_none() {
return Err(bad(cmd, "`at` must be the baseline start [x, y]"));
}
@@ -153,7 +153,7 @@ fn end_of(sk: &Sketch, pi: usize) -> Option<(usize, Vec2)> {
}
fn blend(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "BlendG1CurveSketchCmd";
let cmd = "sketch.blend_curve";
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let pa = req_parg(sk, p, "a", cmd)?.idx().ok_or_else(|| bad(cmd, "`a` must be the end point of a curve"))?;
let pb = req_parg(sk, p, "b", cmd)?.idx().ok_or_else(|| bad(cmd, "`b` must be the end point of a curve"))?;
@@ -200,7 +200,7 @@ fn centerline(s: &mut Session, p: &Value) -> Result<Value> {
}
fn ellipse(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "CircleElipse";
let cmd = "sketch.ellipse";
let (c, m) = (req_vec2(cmd, p, "center")?, req_vec2(cmd, p, "major")?);
let u = (m - c).normalized().ok_or_else(|| bad(cmd, "the major axis end must differ from the centre"))?;
let a = c.dist(m);
@@ -263,17 +263,17 @@ fn spline(s: &mut Session, p: &Value, cmd: &str, control: bool, degree: u8) -> R
}
fn spline_fit(s: &mut Session, p: &Value) -> Result<Value> {
spline(s, p, "DrawSpline", false, 3)
spline(s, p, "sketch.spline.fit_point", false, 3)
}
fn spline_cv3(s: &mut Session, p: &Value) -> Result<Value> {
spline(s, p, "DrawCVMSpline3D", true, 3)
spline(s, p, "sketch.spline.control_point", true, 3)
}
fn spline_cv5(s: &mut Session, p: &Value) -> Result<Value> {
spline(s, p, "DrawCVMSpline5D", true, 5)
spline(s, p, "sketch.spline.control_point_5", true, 5)
}
fn conic(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ConicCurveCmd";
let cmd = "sketch.conic";
let rho = num(p, "rho").unwrap_or(0.5);
if !(rho > 1e-6 && rho < 1.0 - 1e-6) {
return Err(bad(cmd, "`rho` must be between 0 and 1"));
@@ -300,7 +300,7 @@ fn conic(s: &mut Session, p: &Value) -> Result<Value> {
}
fn midpoint_line(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "SketchMidpointLine";
let cmd = "sketch.line.midpoint";
let end = req_vec2(cmd, p, "end")?;
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let m = req_parg(sk, p, "mid", cmd)?;
@@ -369,7 +369,7 @@ fn leaving_dir(sk: &Sketch, ci: usize, pi: usize) -> Option<Vec2> {
}
fn arc_tangent(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ArcTangent";
let cmd = "sketch.arc.tangent";
let end = req_vec2(cmd, p, "end")?;
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let st = req_parg(sk, p, "start", cmd)?;
@@ -458,7 +458,7 @@ fn tangent_circle(s: &mut Session, p: &Value, cmd: &str, n: usize, radius: Optio
}
fn circle_tan_tan(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "CircleTanTanRadius";
let cmd = "sketch.circle.two_tangent";
let r = match (crate::params::expr(p, "radius"), num(p, "diameter")) {
(Some(r), _) => r,
(None, Some(d)) => format!("{}", d / 2.0),
@@ -468,7 +468,7 @@ fn circle_tan_tan(s: &mut Session, p: &Value) -> Result<Value> {
}
fn circle_three_tan(s: &mut Session, p: &Value) -> Result<Value> {
tangent_circle(s, p, "CircleThreeTangent", 3, None)
tangent_circle(s, p, "sketch.circle.three_tangent", 3, None)
}
fn width(p: &Value, cmd: &str) -> Result<f64> {
@@ -476,7 +476,7 @@ fn width(p: &Value, cmd: &str) -> Result<f64> {
}
fn slot_center_point(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ShapeSlotCenterPoint";
let cmd = "sketch.slot.center_point";
let (c, e) = (req_vec2(cmd, p, "center")?, req_vec2(cmd, p, "end")?);
let w = width(p, cmd)?;
slot(s, p, cmd, c * 2.0 - e, e, w)
@@ -518,7 +518,7 @@ fn arc_slot(s: &mut Session, p: &Value, cmd: &str, o: Vec2, r: f64, t0: f64, swe
}
fn arc_slot_three(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ShapeArcSlotThreePoint";
let cmd = "sketch.slot.arc_three_point";
let (a, t, b) = (req_vec2(cmd, p, "start")?, req_vec2(cmd, p, "through")?, req_vec2(cmd, p, "end")?);
let w = width(p, cmd)?;
let (o, r) = circumcircle(a, t, b).ok_or_else(|| bad(cmd, "the points are collinear"))?;
@@ -530,7 +530,7 @@ fn arc_slot_three(s: &mut Session, p: &Value) -> Result<Value> {
}
fn arc_slot_center(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ShapeArcSlotCenterTwoPoint";
let cmd = "sketch.slot.arc_center";
let (o, a, b) = (req_vec2(cmd, p, "center")?, req_vec2(cmd, p, "start")?, req_vec2(cmd, p, "end")?);
let w = width(p, cmd)?;
let r = o.dist(a);
+33 -33
View File
@@ -44,53 +44,53 @@ fn random_sketch_editing_never_breaks() {
for seed in 1..=2u64 {
let mut r = Rng(0x9E37_79B9_7F4A_7C15 ^ seed);
let mut s = Session::default();
let _ = s.execute("PrimitiveBox", &json!({"length": 30, "width": 20, "height": 10}));
let _ = s.execute("SketchCreate", &json!({"plane": {"face": [15, 10, 10]}}));
let _ = s.execute("solid.box", &json!({"length": 30, "width": 20, "height": 10}));
let _ = s.execute("sketch.create", &json!({"plane": {"face": [15, 10, 10]}}));
for step in 0..120 {
let (curves, points, cons) = entities(&s);
let c = |r: &mut Rng| r.pick(&curves).cloned().unwrap_or_default();
let p = |r: &mut Rng| r.pick(&points).cloned().unwrap_or_default();
let (cmd, params) = match r.next() % 34 {
0 => ("DrawPolyline", json!({"points": [r.pt(), r.pt(), r.pt()], "infer": true})),
1 => ("CircleCenterRadius", json!({"center": r.pt(), "radius": r.f(0.5, 20.0)})),
2 => ("ArcCenterTwoPoint", json!({"center": r.pt(), "start": r.pt(), "end": r.pt()})),
3 => ("ShapeRectangleTwoPoint", json!({"p0": r.pt(), "p1": r.pt()})),
4 => ("DrawSpline", json!({"points": [r.pt(), r.pt(), r.pt(), r.pt()]})),
5 => ("CircleElipse", json!({"center": r.pt(), "major": r.pt(), "minor_radius": r.f(0.5, 10.0)})),
6 => ("ConicCurveCmd", json!({"start": r.pt(), "end": r.pt(), "apex": r.pt(), "rho": r.f(0.1, 0.9)})),
7 => ("TrimSketchCmd", json!({"curve": c(&mut r), "at": r.pt()})),
8 => ("ExtendSketchCmd", json!({"curve": c(&mut r), "at": r.pt()})),
9 => ("BreakSketchCmd", json!({"curve": c(&mut r), "at": r.pt()})),
10 => ("FilletSketchCmd", json!({"point": p(&mut r), "radius": r.f(0.1, 5.0)})),
11 => ("ChamferSketchEqualDistance", json!({"point": p(&mut r), "distance": r.f(0.1, 5.0)})),
12 => ("Offset", json!({"curves": [c(&mut r)], "distance": r.f(-5.0, 5.0), "side": r.pt()})),
13 => ("MirrorSketchCommand", json!({"entities": [c(&mut r)], "line": c(&mut r)})),
14 => ("CircularSketchPatternCommand", json!({"entities": [c(&mut r)], "center": r.pt(), "count": 3})),
15 => ("ConstraintCoincident", json!({"a": p(&mut r), "b": p(&mut r)})),
16 => ("ConstraintTangent", json!({"a": c(&mut r), "b": c(&mut r)})),
17 => ("ConstraintParallel", json!({"a": c(&mut r), "b": c(&mut r)})),
18 => ("ConstraintSmooth", json!({"a": c(&mut r), "b": c(&mut r)})),
19 => ("SketchDimension", json!({"entities": [c(&mut r)]})),
20 => ("SketchDimension", json!({"entities": [p(&mut r), p(&mut r)], "driven": r.next().is_multiple_of(2)})),
0 => ("sketch.line", json!({"points": [r.pt(), r.pt(), r.pt()], "infer": true})),
1 => ("sketch.circle.center", json!({"center": r.pt(), "radius": r.f(0.5, 20.0)})),
2 => ("sketch.arc.center_point", json!({"center": r.pt(), "start": r.pt(), "end": r.pt()})),
3 => ("sketch.rectangle.two_point", json!({"p0": r.pt(), "p1": r.pt()})),
4 => ("sketch.spline.fit_point", json!({"points": [r.pt(), r.pt(), r.pt(), r.pt()]})),
5 => ("sketch.ellipse", json!({"center": r.pt(), "major": r.pt(), "minor_radius": r.f(0.5, 10.0)})),
6 => ("sketch.conic", json!({"start": r.pt(), "end": r.pt(), "apex": r.pt(), "rho": r.f(0.1, 0.9)})),
7 => ("sketch.trim", json!({"curve": c(&mut r), "at": r.pt()})),
8 => ("sketch.extend", json!({"curve": c(&mut r), "at": r.pt()})),
9 => ("sketch.break", json!({"curve": c(&mut r), "at": r.pt()})),
10 => ("sketch.fillet", json!({"point": p(&mut r), "radius": r.f(0.1, 5.0)})),
11 => ("sketch.chamfer.equal_distance", json!({"point": p(&mut r), "distance": r.f(0.1, 5.0)})),
12 => ("sketch.offset", json!({"curves": [c(&mut r)], "distance": r.f(-5.0, 5.0), "side": r.pt()})),
13 => ("sketch.mirror", json!({"entities": [c(&mut r)], "line": c(&mut r)})),
14 => ("sketch.pattern.circular", json!({"entities": [c(&mut r)], "center": r.pt(), "count": 3})),
15 => ("sketch.constraint.coincident", json!({"a": p(&mut r), "b": p(&mut r)})),
16 => ("sketch.constraint.tangent", json!({"a": c(&mut r), "b": c(&mut r)})),
17 => ("sketch.constraint.parallel", json!({"a": c(&mut r), "b": c(&mut r)})),
18 => ("sketch.constraint.curvature", json!({"a": c(&mut r), "b": c(&mut r)})),
19 => ("sketch.dimension", json!({"entities": [c(&mut r)]})),
20 => ("sketch.dimension", json!({"entities": [p(&mut r), p(&mut r)], "driven": r.next().is_multiple_of(2)})),
21 => ("sketch.delete", json!({"entities": [r.pick(&cons).cloned().unwrap_or_else(|| c(&mut r))]})),
22 => ("sketch.move_point", json!({"point": p(&mut r), "to": r.pt()})),
23 => ("ProjectNewCmd", json!({"refs": [{"edge": [r.f(0.0, 30.0), 0.0, 10.0]}]})),
23 => ("sketch.project", json!({"refs": [{"edge": [r.f(0.0, 30.0), 0.0, 10.0]}]})),
24 => ("sketch.break_link", json!({"entities": [c(&mut r)]})),
25 => ("SketchConstrainer", json!({"entities": [c(&mut r), c(&mut r)]})),
26 => ("SketchAutoConstraintAndDimCmd", json!({})),
27 => ("MTextCmd", json!({"text": "Ab", "at": r.pt(), "height": r.f(1.0, 8.0)})),
25 => ("sketch.constrainer", json!({"entities": [c(&mut r), c(&mut r)]})),
26 => ("sketch.auto_constrain", json!({})),
27 => ("sketch.text", json!({"text": "Ab", "at": r.pt(), "height": r.f(1.0, 8.0)})),
28 => ("sketch.toggle_driven", json!({"constraint": r.pick(&cons).cloned().unwrap_or_default()})),
29 => ("ArcTangent", json!({"start": format!("{}.end", c(&mut r)), "end": r.pt()})),
30 => ("SketchScaleCmd", json!({"entities": [c(&mut r)], "base": r.pt(), "factor": r.f(0.5, 2.0)})),
31 => ("UndoCommand", json!({})),
32 => ("ShapeArcSlotThreePoint", json!({"start": r.pt(), "through": r.pt(), "end": r.pt(), "width": r.f(0.5, 4.0)})),
_ => ("RedoCommand", json!({})),
29 => ("sketch.arc.tangent", json!({"start": format!("{}.end", c(&mut r)), "end": r.pt()})),
30 => ("sketch.scale", json!({"entities": [c(&mut r)], "base": r.pt(), "factor": r.f(0.5, 2.0)})),
31 => ("edit.undo", json!({})),
32 => ("sketch.slot.arc_three_point", json!({"start": r.pt(), "through": r.pt(), "end": r.pt(), "width": r.f(0.5, 4.0)})),
_ => ("edit.redo", json!({})),
};
if let Err(EngineError::Internal(id, msg)) = s.execute(cmd, &params) {
panic!("seed {seed} step {step}: {id} {params}: {msg}");
}
if s.active_sketch.is_none() {
let _ = s.execute("SketchCreate", &json!({"plane": "XY"}));
let _ = s.execute("sketch.create", &json!({"plane": "XY"}));
}
}
// The design round-trips.
+16 -16
View File
@@ -13,11 +13,11 @@ use crate::params::{bad, num, str_, vec2};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("ImportDxfFileCommand", "Insert DXF", insert_dxf)
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)"),
CommandSpec::new("SketchImportSVG", "Insert SVG", insert_svg)
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)"),
@@ -123,7 +123,7 @@ fn insert(s: &mut Session, p: &Value, cmd: &str, svg: bool) -> Result<Value> {
if let Some(n) = str_(p, "name") {
cp["name"] = json!(n);
}
let r = (super::find_command("SketchCreate").ok_or_else(|| bad(cmd, "SketchCreate"))?.run)(s, &cp)?;
let r = (super::find_command("sketch.create").ok_or_else(|| bad(cmd, "sketch.create"))?.run)(s, &cp)?;
created = r.get("sketch").and_then(Value::as_u64);
}
let tol = num(p, "tolerance").filter(|t| t.is_finite() && *t > 0.0).unwrap_or(1e-4).clamp(1e-9, 10.0);
@@ -150,11 +150,11 @@ fn insert(s: &mut Session, p: &Value, cmd: &str, svg: bool) -> Result<Value> {
}
fn insert_dxf(s: &mut Session, p: &Value) -> Result<Value> {
insert(s, p, "ImportDxfFileCommand", false)
insert(s, p, "sketch.insert_dxf", false)
}
fn insert_svg(s: &mut Session, p: &Value) -> Result<Value> {
insert(s, p, "SketchImportSVG", true)
insert(s, p, "sketch.insert_svg", true)
}
fn export_dxf(s: &mut Session, p: &Value) -> Result<Value> {
@@ -201,14 +201,14 @@ mod tests {
#[test]
fn dxf_and_svg_round_trip_into_sketches() {
let mut s = Session::default();
s.execute("SketchCreate", &json!({"plane": "XY"})).unwrap();
s.execute("ShapeRectangleTwoPoint", &json!({"p0": [0, 0], "p1": [30, 20]})).unwrap();
s.execute("CircleCenterRadius", &json!({"center": [10, 10], "radius": 4})).unwrap();
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
s.execute("sketch.rectangle.two_point", &json!({"p0": [0, 0], "p1": [30, 20]})).unwrap();
s.execute("sketch.circle.center", &json!({"center": [10, 10], "radius": 4})).unwrap();
let out = s.execute("sketch.export_dxf", &json!({})).unwrap();
let dxf = out["dxf"].as_str().unwrap().to_string();
s.execute("SketchStop", &json!({})).unwrap();
s.execute("sketch.finish", &json!({})).unwrap();
// Insert it again as a new sketch, offset: same profiles.
let r = s.execute("ImportDxfFileCommand", &json!({"text": dxf, "at": [100, 0]})).unwrap();
let r = s.execute("sketch.insert_dxf", &json!({"text": dxf, "at": [100, 0]})).unwrap();
assert_eq!(r["curves"], 5, "{r}");
let id = r["sketch"].as_u64().unwrap();
let ss = s.model.state().sketch(id).unwrap().clone();
@@ -219,19 +219,19 @@ mod tests {
// SVG with a path and text, into the active sketch.
let svg =
r#"<svg width="40mm" height="40mm" viewBox="0 0 40 40"><path d="M0 0 H40 V40 H0 Z"/><text x="5" y="20" font-size="10">Hi</text></svg>"#;
let r = s.execute("SketchImportSVG", &json!({"text": svg})).unwrap();
let r = s.execute("sketch.insert_svg", &json!({"text": svg})).unwrap();
assert_eq!(r["curves"], 4, "{r}");
assert_eq!(r["texts"], 1, "{r}");
assert!(s.execute("SketchImportSVG", &json!({"text": "<svg></svg>"})).is_err());
assert!(s.execute("ImportDxfFileCommand", &json!({"path": "/nonexistent.dxf"})).is_err());
s.execute("SketchStop", &json!({})).unwrap();
assert!(s.execute("sketch.insert_svg", &json!({"text": "<svg></svg>"})).is_err());
assert!(s.execute("sketch.insert_dxf", &json!({"path": "/nonexistent.dxf"})).is_err());
s.execute("sketch.finish", &json!({})).unwrap();
// Ends that miss by 0.00005 still close the square; with a tighter tolerance they don't.
let gappy = "0\nSECTION\n2\nENTITIES\n0\nLINE\n10\n0\n20\n0\n11\n10\n21\n0\n0\nLINE\n10\n10.00005\n20\n0\n11\n10\n21\n10\n0\nLINE\n10\n10\n20\n10\n11\n0\n21\n10\n0\nLINE\n10\n0\n20\n10.00003\n11\n0\n21\n0.00004\n0\nENDSEC\n0\nEOF\n";
for (tol, n) in [(1e-4, 1), (1e-6, 0)] {
let r = s.execute("ImportDxfFileCommand", &json!({"text": gappy, "tolerance": tol})).unwrap();
let r = s.execute("sketch.insert_dxf", &json!({"text": gappy, "tolerance": tol})).unwrap();
let ss = s.model.state().sketch(r["sketch"].as_u64().unwrap()).unwrap().clone();
assert_eq!(ss.profiles.len(), n, "tolerance {tol}");
s.execute("SketchStop", &json!({})).unwrap();
s.execute("sketch.finish", &json!({})).unwrap();
}
}
}
+28 -28
View File
@@ -9,22 +9,22 @@ use crate::params::{bad, num, string_list, vec3};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("FusionCurvatureCombAnalysisCommand", "Curvature Comb Analysis", curvature_comb)
CommandSpec::new("inspect.curvature_comb", "Curvature Comb Analysis", curvature_comb)
.at("SKETCH", "INSPECT")
.icon("curvature_comb")
.noundo()
.params("curves?: [sketch curve ids] (sketch?: id|name, default active) | edges?: [[x,y,z] points on body edges]; density?: teeth per curve (default 40), scale?: comb length per unit curvature (default auto)"),
CommandSpec::new("FusionMinimumRadiusAnalysisCommand", "Minimum Radius Analysis", minimum_radius)
CommandSpec::new("inspect.minimum_radius", "Minimum Radius Analysis", minimum_radius)
.at("SKETCH", "INSPECT")
.icon("min_radius")
.noundo()
.params("curves?: [sketch curve ids] (sketch?) | edges?: [[x,y,z]…] | faces?: [[x,y,z]…] (default: every face of every body); the smallest radius of curvature of each and where it is"),
CommandSpec::new("FusionIsoCurveAnalysisCommand", "Isocurve Analysis", isocurves)
CommandSpec::new("inspect.isocurve", "Isocurve Analysis", isocurves)
.at("SKETCH", "INSPECT")
.icon("iso_analysis")
.noundo()
.params("faces: [[x,y,z]…] points on faces; count?: curves each way (default 8): the faces' isoparametric curves"),
CommandSpec::new("FusionCenterOfMassCommand", "Center of Mass", center_of_mass)
CommandSpec::new("inspect.center_of_mass", "Center of Mass", center_of_mass)
.at("SKETCH", "INSPECT")
.icon("center_of_mass")
.noundo()
@@ -73,7 +73,7 @@ fn teeth_3d(pts: &[Vec3]) -> Vec<(Vec3, f64, Vec3)> {
}
fn curvature_comb(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionCurvatureCombAnalysisCommand";
let cmd = "inspect.curvature_comb";
let density = num(p, "density").map(|d| d.clamp(2.0, 2000.0) as usize).unwrap_or(40);
let st = s.model.state();
let mut combs: Vec<(String, Vec<(Vec3, f64, Vec3)>)> = Vec::new();
@@ -210,7 +210,7 @@ fn face_min_radius(m: &solvecraft_geom::Mesh, face: u32) -> Option<(f64, Vec3)>
}
fn minimum_radius(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionMinimumRadiusAnalysisCommand";
let cmd = "inspect.minimum_radius";
let st = s.model.state();
let mut items: Vec<(String, Option<(f64, Vec3)>)> = Vec::new();
let curves = string_list(p, "curves");
@@ -282,7 +282,7 @@ fn minimum_radius(s: &mut Session, p: &Value) -> Result<Value> {
}
fn isocurves(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionIsoCurveAnalysisCommand";
let cmd = "inspect.isocurve";
let count = num(p, "count").map(|c| c.clamp(1.0, 100.0) as usize).unwrap_or(8);
let faces = p.get("faces").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`faces` must list points [x,y,z]"))?;
if faces.is_empty() {
@@ -302,7 +302,7 @@ fn isocurves(s: &mut Session, p: &Value) -> Result<Value> {
}
fn center_of_mass(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionCenterOfMassCommand";
let cmd = "inspect.center_of_mass";
let names = string_list(p, "bodies");
let st = s.model.state();
let mut out = Vec::new();
@@ -332,10 +332,10 @@ mod tests {
#[test]
fn curvature_combs() {
let mut s = Session::default();
s.execute("SketchCreate", &json!({"plane": "XY"})).unwrap();
let c = s.execute("CircleCenterRadius", &json!({"center": [0, 0], "radius": 5})).unwrap()["curves"][0].clone();
let l = s.execute("DrawPolyline", &json!({"points": [[0, 20], [10, 20]]})).unwrap()["curves"][0].clone();
let r = s.execute("FusionCurvatureCombAnalysisCommand", &json!({"curves": [c, l], "density": 20})).unwrap();
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
let c = s.execute("sketch.circle.center", &json!({"center": [0, 0], "radius": 5})).unwrap()["curves"][0].clone();
let l = s.execute("sketch.line", &json!({"points": [[0, 20], [10, 20]]})).unwrap()["curves"][0].clone();
let r = s.execute("inspect.curvature_comb", &json!({"curves": [c, l], "density": 20})).unwrap();
let circle = &r["combs"][0];
assert!((circle["min_radius"].as_f64().unwrap() - 5.0).abs() < 1e-3, "{circle}");
assert!(r["combs"][1]["min_radius"].is_null(), "a line is straight");
@@ -343,39 +343,39 @@ mod tests {
let t = &circle["teeth"][3];
let (a, b) = ((t[0][0].as_f64().unwrap(), t[0][1].as_f64().unwrap()), (t[1][0].as_f64().unwrap(), t[1][1].as_f64().unwrap()));
assert!(b.0 * b.0 + b.1 * b.1 > a.0 * a.0 + a.1 * a.1);
s.execute("SketchStop", &json!({})).unwrap();
s.execute("PrimitiveCylinder", &json!({"base": [50, 0, 0], "radius": 8, "height": 10})).unwrap();
let r = s.execute("FusionCurvatureCombAnalysisCommand", &json!({"edges": [[58, 0, 10]]})).unwrap();
s.execute("sketch.finish", &json!({})).unwrap();
s.execute("solid.cylinder", &json!({"base": [50, 0, 0], "radius": 8, "height": 10})).unwrap();
let r = s.execute("inspect.curvature_comb", &json!({"edges": [[58, 0, 10]]})).unwrap();
assert!((r["combs"][0]["min_radius"].as_f64().unwrap() - 8.0).abs() < 0.5, "{r}");
assert!(s.execute("FusionCurvatureCombAnalysisCommand", &json!({})).is_err());
assert!(s.execute("inspect.curvature_comb", &json!({})).is_err());
}
#[test]
fn minimum_radius_and_center_of_mass() {
let mut s = Session::default();
s.execute("SketchCreate", &json!({"plane": "XY"})).unwrap();
let e = s.execute("CircleElipse", &json!({"center": [0, 0], "major": [10, 0], "minor_radius": 4})).unwrap()["curves"][0].clone();
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
let e = s.execute("sketch.ellipse", &json!({"center": [0, 0], "major": [10, 0], "minor_radius": 4})).unwrap()["curves"][0].clone();
// An ellipse's tightest bend is at the major axis ends: b²/a.
let r = s.execute("FusionMinimumRadiusAnalysisCommand", &json!({"curves": [e]})).unwrap();
let r = s.execute("inspect.minimum_radius", &json!({"curves": [e]})).unwrap();
assert!((r["min_radius"].as_f64().unwrap() - 1.6).abs() < 1e-3, "{r}");
assert!((r["at"][0].as_f64().unwrap().abs() - 10.0).abs() < 0.1, "{r}");
s.execute("SketchStop", &json!({})).unwrap();
s.execute("PrimitiveCylinder", &json!({"base": [50, 0, 0], "radius": 8, "height": 10})).unwrap();
s.execute("sketch.finish", &json!({})).unwrap();
s.execute("solid.cylinder", &json!({"base": [50, 0, 0], "radius": 8, "height": 10})).unwrap();
// The cylinder's side face: radius 8 (the mesh estimate is close).
let r = s.execute("FusionMinimumRadiusAnalysisCommand", &json!({"faces": [[58, 0, 5]]})).unwrap();
let r = s.execute("inspect.minimum_radius", &json!({"faces": [[58, 0, 5]]})).unwrap();
assert!((r["min_radius"].as_f64().unwrap() - 8.0).abs() < 0.4, "{r}");
let r = s.execute("FusionMinimumRadiusAnalysisCommand", &json!({})).unwrap();
let r = s.execute("inspect.minimum_radius", &json!({})).unwrap();
assert!((r["min_radius"].as_f64().unwrap() - 8.0).abs() < 0.4, "{r}");
// Centre of mass: a box beside the cylinder.
s.execute("PrimitiveBox", &json!({"corner": [0, 0, 0], "length": 10, "width": 10, "height": 10})).unwrap();
let r = s.execute("FusionCenterOfMassCommand", &json!({})).unwrap();
s.execute("solid.box", &json!({"corner": [0, 0, 0], "length": 10, "width": 10, "height": 10})).unwrap();
let r = s.execute("inspect.center_of_mass", &json!({})).unwrap();
let (vb, vc) = (1000.0, std::f64::consts::PI * 640.0);
let want = (5.0 * vb + 50.0 * vc) / (vb + vc);
assert!((r["center"][0].as_f64().unwrap() - want).abs() < 0.1, "{r}");
assert!((r["center"][2].as_f64().unwrap() - 5.0).abs() < 0.05, "{r}");
assert!(s.execute("FusionCenterOfMassCommand", &json!({"bodies": ["nope"]})).is_err());
assert!(s.execute("inspect.center_of_mass", &json!({"bodies": ["nope"]})).is_err());
// Isocurves on the cylinder's side: rings around it and lines along it.
let r = s.execute("FusionIsoCurveAnalysisCommand", &json!({"faces": [[58, 0, 5]], "count": 4})).unwrap();
let r = s.execute("inspect.isocurve", &json!({"faces": [[58, 0, 5]], "count": 4})).unwrap();
let curves = r["curves"].as_array().unwrap();
let ring =
|c: &Value| c.as_array().unwrap().iter().all(|q| ((q[0].as_f64().unwrap() - 50.0).hypot(q[1].as_f64().unwrap()) - 8.0).abs() < 0.2);
@@ -386,6 +386,6 @@ mod tests {
z.iter().cloned().fold(f64::NEG_INFINITY, f64::max) - z.iter().cloned().fold(f64::INFINITY, f64::min) < 1e-6
};
assert!(curves.iter().filter(|c| flat(c)).count() >= 4, "rings at constant height");
assert!(s.execute("FusionIsoCurveAnalysisCommand", &json!({"faces": []})).is_err());
assert!(s.execute("inspect.isocurve", &json!({"faces": []})).is_err());
}
}
+24 -24
View File
@@ -13,49 +13,49 @@ use crate::params::{bad, bool_, expr, num, req_vec2, str_, string_list, vec2};
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("FilletSketchCmd", "Fillet", fillet)
CommandSpec::new("sketch.fillet", "Fillet", fillet)
.at("SKETCH", "MODIFY")
.icon("sketch_fillet")
.enabled(in_sketch)
.params("point: corner shared by two curves (\"l1.end\") | a, b: two lines or arcs meeting at a corner; radius: expr"),
CommandSpec::new("ChamferSketchEqualDistance", "Equal Distance Chamfer", chamfer_equal)
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"),
CommandSpec::new("ChamferSketchDistanceAngle", "Distance and Angle Chamfer", chamfer_dist_angle)
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"),
CommandSpec::new("ChamferSketchDistanceDistance", "Two Distance Chamfer", chamfer_two)
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"),
CommandSpec::new("Offset", "Offset", offset)
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)"),
CommandSpec::new("TrimSketchCmd", "Trim", trim)
CommandSpec::new("sketch.trim", "Trim", trim)
.at("SKETCH", "MODIFY")
.icon("trim")
.key("T")
.enabled(in_sketch)
.params("curve: id, at: [x,y] (the piece to remove: between the nearest intersections around it)"),
CommandSpec::new("ExtendSketchCmd", "Extend", extend)
CommandSpec::new("sketch.extend", "Extend", extend)
.at("SKETCH", "MODIFY")
.icon("extend")
.enabled(in_sketch)
.params("curve: id, at: [x,y] near the end to extend (to the nearest curve)"),
CommandSpec::new("BreakSketchCmd", "Break", break_cmd)
CommandSpec::new("sketch.break", "Break", break_cmd)
.at("SKETCH", "MODIFY")
.icon("break")
.enabled(in_sketch)
.params("curve: id, at: [x,y] (split at the nearest intersections around it) | params: split at points: [[x,y]…]"),
CommandSpec::new("SketchScaleCmd", "Sketch Scale", scale)
CommandSpec::new("sketch.scale", "Sketch Scale", scale)
.at("SKETCH", "MODIFY")
.icon("scale")
.enabled(in_sketch)
@@ -63,17 +63,17 @@ pub static COMMANDS: &[CommandSpec] = &[
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"),
CommandSpec::new("MirrorSketchCommand", "Mirror", mirror)
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)"),
CommandSpec::new("CircularSketchPatternCommand", "Circular Pattern", pattern_circular)
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)"),
CommandSpec::new("RectangularSketchPatternCommand", "Rectangular Pattern", pattern_rect)
CommandSpec::new("sketch.pattern.rectangular", "Rectangular Pattern", pattern_rect)
.at("SKETCH", "CREATE")
.icon("sketch_rect_pattern")
.enabled(in_sketch)
@@ -253,7 +253,7 @@ fn bracket(sk: &Sketch, ci: usize, tq: f64) -> (Vec<(f64, Vec2, usize)>, bool) {
}
fn trim(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "TrimSketchCmd";
let cmd = "sketch.trim";
let at = req_vec2(cmd, p, "at")?;
let (out, info) = edit(s, p, cmd, false, |sk, doc| {
let ci = curve_arg(sk, p, cmd)?;
@@ -313,7 +313,7 @@ fn trim(s: &mut Session, p: &Value) -> Result<Value> {
}
fn break_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "BreakSketchCmd";
let cmd = "sketch.break";
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let ci = curve_arg(sk, p, cmd)?;
if sk.curves.get(ci).is_some_and(|c| c.kind.is_freeform()) {
@@ -349,7 +349,7 @@ fn break_cmd(s: &mut Session, p: &Value) -> Result<Value> {
}
fn extend(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ExtendSketchCmd";
let cmd = "sketch.extend";
let at = req_vec2(cmd, p, "at")?;
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let ci = curve_arg(sk, p, cmd)?;
@@ -476,7 +476,7 @@ fn cut_corner(sk: &mut Sketch, la: usize, lb: usize, c: usize, fa: usize, fb: us
}
fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FilletSketchCmd";
let cmd = "sketch.fillet";
let r_expr = expr(p, "radius").ok_or_else(|| bad(cmd, "`radius` must be a number or an expression"))?;
let (out, info) = edit(s, p, cmd, false, |sk, doc| {
let r = doc.eval(&r_expr, solvecraft_doc::expr::Kind::Length).map_err(|e| bad(cmd, format!("radius: {e}")))?;
@@ -610,20 +610,20 @@ fn chamfer(s: &mut Session, p: &Value, cmd: &str, dists: impl FnOnce(f64) -> Res
}
fn chamfer_equal(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ChamferSketchEqualDistance";
let cmd = "sketch.chamfer.equal_distance";
let d = num(p, "distance").ok_or_else(|| bad(cmd, "`distance` must be a number"))?;
chamfer(s, p, cmd, |_| Ok((d, d)))
}
fn chamfer_two(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ChamferSketchDistanceDistance";
let cmd = "sketch.chamfer.two_distance";
let d = num(p, "distance").ok_or_else(|| bad(cmd, "`distance` must be a number"))?;
let d2 = num(p, "distance2").ok_or_else(|| bad(cmd, "`distance2` must be a number"))?;
chamfer(s, p, cmd, |_| Ok((d, d2)))
}
fn chamfer_dist_angle(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ChamferSketchDistanceAngle";
let cmd = "sketch.chamfer.distance_angle";
let d = num(p, "distance").ok_or_else(|| bad(cmd, "`distance` must be a number"))?;
let a = num(p, "angle").ok_or_else(|| bad(cmd, "`angle` must be a number (degrees)"))?.to_radians();
chamfer(s, p, cmd, |phi| {
@@ -766,7 +766,7 @@ fn piece_dist(p: &Piece, q: Vec2) -> f64 {
}
fn offset(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "Offset";
let cmd = "sketch.offset";
let ids = string_list(p, "curves");
if ids.is_empty() {
return Err(bad(cmd, "`curves` must list curves"));
@@ -988,7 +988,7 @@ fn copy_through(
}
fn mirror(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "MirrorSketchCommand";
let cmd = "sketch.mirror";
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let (cs, ps) = entities(sk, p, cmd)?;
let lid = str_(p, "line").ok_or_else(|| bad(cmd, "`line` must be the mirror line"))?;
@@ -1043,7 +1043,7 @@ fn rotate(c: Vec2, ang: f64) -> impl Fn(Vec2) -> Vec2 {
}
fn pattern_circular(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "CircularSketchPatternCommand";
let cmd = "sketch.pattern.circular";
let n = count(p, "count", cmd)?;
let total = num(p, "angle").unwrap_or(360.0);
if !(total.abs() > 1e-9 && total.abs() <= 360.0) {
@@ -1066,7 +1066,7 @@ fn pattern_circular(s: &mut Session, p: &Value) -> Result<Value> {
}
fn pattern_rect(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "RectangularSketchPatternCommand";
let cmd = "sketch.pattern.rectangular";
let n1 = count(p, "count", cmd)?;
let s1 = num(p, "spacing").ok_or_else(|| bad(cmd, "`spacing` must be a number"))?;
let n2 = if p.get("count2").is_some() { count(p, "count2", cmd)? } else { 1 };
@@ -1134,7 +1134,7 @@ fn move_copy(s: &mut Session, p: &Value) -> Result<Value> {
}
fn scale(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "SketchScaleCmd";
let cmd = "sketch.scale";
let k = num(p, "factor").filter(|k| *k > 1e-9 && *k < 1e6).ok_or_else(|| bad(cmd, "`factor` must be a positive number"))?;
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let (cs, ps) = entities(sk, p, cmd)?;
+108 -105
View File
@@ -22,7 +22,7 @@ fn sketch(s: &Session) -> solvecraft_sketch::Sketch {
fn new_sketch() -> Session {
let mut s = Session::default();
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
s
}
@@ -41,10 +41,10 @@ fn close(a: f64, b: f64) -> bool {
fn trim_extend_break() {
let mut s = new_sketch();
// A cross: horizontal line crossed by a vertical one.
let h = ids(&run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [20, 0]]}))["curves"])[0].clone();
let v = ids(&run(&mut s, "DrawPolyline", json!({"points": [[10, -5], [10, 5]]}))["curves"])[0].clone();
let h = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [20, 0]]}))["curves"])[0].clone();
let v = ids(&run(&mut s, "sketch.line", json!({"points": [[10, -5], [10, 5]]}))["curves"])[0].clone();
// Trim the right half of the horizontal line.
run(&mut s, "TrimSketchCmd", json!({"curve": h, "at": [15, 0]}));
run(&mut s, "sketch.trim", json!({"curve": h, "at": [15, 0]}));
let sk = sketch(&s);
let sh = sk.shape(sk.curve_index(&h).unwrap()).unwrap();
assert!(
@@ -52,32 +52,32 @@ fn trim_extend_break() {
"{sh:?}"
);
// Trim the lower half of the vertical line: the original id survives on the upper piece.
run(&mut s, "TrimSketchCmd", json!({"curve": v, "at": [10, -3]}));
run(&mut s, "sketch.trim", json!({"curve": v, "at": [10, -3]}));
let sk = sketch(&s);
assert_eq!(sk.curves.len(), 2);
// Extend: a short line reaching toward the vertical one.
let e = ids(&run(&mut s, "DrawPolyline", json!({"points": [[0, 3], [4, 3]]}))["curves"])[0].clone();
run(&mut s, "ExtendSketchCmd", json!({"curve": e, "at": [4, 3]}));
let e = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 3], [4, 3]]}))["curves"])[0].clone();
run(&mut s, "sketch.extend", json!({"curve": e, "at": [4, 3]}));
let sk = sketch(&s);
let end = sk.resolve_point(&format!("{e}.end")).and_then(|i| sk.point(i)).unwrap();
assert!(end.dist(Vec2::new(10.0, 3.0)) < 1e-7, "{end:?}");
// Break the horizontal line where... nothing crosses it inside: error.
assert!(s.execute("BreakSketchCmd", &json!({"curve": h, "at": [5, 0]})).is_err());
assert!(s.execute("sketch.break", &json!({"curve": h, "at": [5, 0]})).is_err());
// Break the vertical at the extended line: two pieces.
let r = run(&mut s, "BreakSketchCmd", json!({"curve": v, "at": [10, 1]}));
let r = run(&mut s, "sketch.break", json!({"curve": v, "at": [10, 1]}));
assert_eq!(ids(&r["curves"]).len(), 2, "{r}");
// A lone curve trims away entirely.
let lone = ids(&run(&mut s, "DrawPolyline", json!({"points": [[50, 50], [60, 50]]}))["curves"])[0].clone();
run(&mut s, "TrimSketchCmd", json!({"curve": lone, "at": [55, 50]}));
let lone = ids(&run(&mut s, "sketch.line", json!({"points": [[50, 50], [60, 50]]}))["curves"])[0].clone();
run(&mut s, "sketch.trim", json!({"curve": lone, "at": [55, 50]}));
assert!(sketch(&s).curve_index(&lone).is_none());
}
#[test]
fn trim_circle_makes_an_arc_and_a_profile() {
let mut s = new_sketch();
let c = ids(&run(&mut s, "CircleCenterRadius", json!({"center": [0, 0], "radius": 10}))["curves"])[0].clone();
run(&mut s, "DrawPolyline", json!({"points": [[-20, 0], [20, 0]]}));
run(&mut s, "TrimSketchCmd", json!({"curve": c, "at": [0, -10]}));
let c = ids(&run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 10}))["curves"])[0].clone();
run(&mut s, "sketch.line", json!({"points": [[-20, 0], [20, 0]]}));
run(&mut s, "sketch.trim", json!({"curve": c, "at": [0, -10]}));
let sk = sketch(&s);
let ci = sk.curve_index(&c).unwrap();
assert!(matches!(sk.curves[ci].kind, CurveKind::Arc { .. }));
@@ -90,65 +90,65 @@ fn trim_circle_makes_an_arc_and_a_profile() {
#[test]
fn fillet_and_chamfers() {
let mut s = new_sketch();
let r = run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [40, 30]}));
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
let l = ids(&r["curves"]);
// Pin the rectangle so a dimension change only moves the fillet.
run(&mut s, "ConstraintFix", json!({"entity": l[2]}));
run(&mut s, "ConstraintFix", json!({"entity": l[3]}));
let f = run(&mut s, "FilletSketchCmd", json!({"a": l[0], "b": l[1], "radius": 5}));
run(&mut s, "sketch.constraint.fix", json!({"entity": l[2]}));
run(&mut s, "sketch.constraint.fix", json!({"entity": l[3]}));
let f = run(&mut s, "sketch.fillet", json!({"a": l[0], "b": l[1], "radius": 5}));
assert!(f["param"].is_string());
let a = profiles(&s);
assert!(close(a[0], 1200.0 - (25.0 - std::f64::consts::PI * 25.0 / 4.0)), "{a:?}");
// The fillet radius is a dimension: change it.
let pname = f["param"].as_str().unwrap().to_string();
run(&mut s, "ChangeParameterCommand", json!({"name": pname, "expression": "8 mm"}));
run(&mut s, "parameters.change", json!({"name": pname, "expression": "8 mm"}));
let a = profiles(&s);
assert!(close(a[0], 1200.0 - (64.0 - std::f64::consts::PI * 64.0 / 4.0)), "{a:?}");
run(&mut s, "ChamferSketchEqualDistance", json!({"a": l[2], "b": l[3], "distance": 4}));
run(&mut s, "sketch.chamfer.equal_distance", json!({"a": l[2], "b": l[3], "distance": 4}));
let a2 = profiles(&s)[0];
assert!(close(a[0] - a2, 8.0), "{a2}");
run(&mut s, "ChamferSketchDistanceDistance", json!({"a": l[1], "b": l[2], "distance": 2, "distance2": 6}));
run(&mut s, "sketch.chamfer.two_distance", json!({"a": l[1], "b": l[2], "distance": 2, "distance2": 6}));
let a3 = profiles(&s)[0];
assert!(close(a2 - a3, 6.0), "{a3}");
// Distance-angle at the last corner (origin, 90 degrees): 45 deg gives an isosceles cut.
run(&mut s, "ChamferSketchDistanceAngle", json!({"a": l[3], "b": l[0], "distance": 3, "angle": 45}));
run(&mut s, "sketch.chamfer.distance_angle", json!({"a": l[3], "b": l[0], "distance": 3, "angle": 45}));
let a4 = profiles(&s)[0];
assert!(close(a3 - a4, 4.5), "{a4}");
assert!(s.execute("FilletSketchCmd", &json!({"a": l[0], "b": l[2], "radius": 1})).is_err(), "no shared corner");
assert!(s.execute("sketch.fillet", &json!({"a": l[0], "b": l[2], "radius": 1})).is_err(), "no shared corner");
}
#[test]
fn offset_chain_mirror_patterns_move_scale() {
let mut s = new_sketch();
let r = run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [40, 30]}));
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
let l = ids(&r["curves"]);
// Outward offset by 2 (toward a point outside).
let o = run(&mut s, "Offset", json!({"curves": [l[0]], "distance": 2, "side": [20, -10]}));
let o = run(&mut s, "sketch.offset", json!({"curves": [l[0]], "distance": 2, "side": [20, -10]}));
assert_eq!(ids(&o["curves"]).len(), 4, "{o}");
let a = profiles(&s);
assert!(close(a[1], 44.0 * 34.0 - 1200.0) || close(a.iter().sum::<f64>(), 44.0 * 34.0), "{a:?}");
// Circle offset inward.
let c = ids(&run(&mut s, "CircleCenterRadius", json!({"center": [100, 0], "radius": 10}))["curves"])[0].clone();
run(&mut s, "Offset", json!({"curves": [c], "distance": 3, "side": [100, 0]}));
let c = ids(&run(&mut s, "sketch.circle.center", json!({"center": [100, 0], "radius": 10}))["curves"])[0].clone();
run(&mut s, "sketch.offset", json!({"curves": [c], "distance": 3, "side": [100, 0]}));
// Mirror a circle about a vertical line.
let m = ids(&run(&mut s, "DrawPolyline", json!({"points": [[120, -50], [120, 50]]}))["curves"])[0].clone();
let r = run(&mut s, "MirrorSketchCommand", json!({"entities": [c], "line": m}));
let m = ids(&run(&mut s, "sketch.line", json!({"points": [[120, -50], [120, 50]]}))["curves"])[0].clone();
let r = run(&mut s, "sketch.mirror", json!({"entities": [c], "line": m}));
let mc = ids(&r["curves"])[0].clone();
let sk = sketch(&s);
assert!(sk.center(sk.curve_index(&mc).unwrap()).unwrap().dist(Vec2::new(140.0, 0.0)) < 1e-7);
// Circular pattern of the mirrored circle: 4 around (120, 0).
let r = run(&mut s, "CircularSketchPatternCommand", json!({"entities": [mc], "center": [120, 0], "count": 4}));
let r = run(&mut s, "sketch.pattern.circular", json!({"entities": [mc], "center": [120, 0], "count": 4}));
assert_eq!(ids(&r["curves"]).len(), 3);
let sk = sketch(&s);
let c2 = sk.center(sk.curve_index(&ids(&r["curves"])[0]).unwrap()).unwrap();
assert!(c2.dist(Vec2::new(120.0, 20.0)) < 1e-7, "{c2:?}");
// Rectangular pattern 3 x 2.
let small = ids(&run(&mut s, "CircleCenterRadius", json!({"center": [0, 100], "radius": 1}))["curves"])[0].clone();
let r = run(&mut s, "RectangularSketchPatternCommand", json!({"entities": [small], "count": 3, "spacing": 5, "count2": 2, "spacing2": 4}));
let small = ids(&run(&mut s, "sketch.circle.center", json!({"center": [0, 100], "radius": 1}))["curves"])[0].clone();
let r = run(&mut s, "sketch.pattern.rectangular", json!({"entities": [small], "count": 3, "spacing": 5, "count2": 2, "spacing2": 4}));
assert_eq!(ids(&r["curves"]).len(), 5);
// Move and scale.
run(&mut s, "sketch.move", json!({"entities": [small], "translate": [0, 10]}));
run(&mut s, "SketchScaleCmd", json!({"entities": [small], "base": [0, 110], "factor": 3}));
run(&mut s, "sketch.scale", json!({"entities": [small], "base": [0, 110], "factor": 3}));
let sk = sketch(&s);
let ci = sk.curve_index(&small).unwrap();
assert!(sk.center(ci).unwrap().dist(Vec2::new(0.0, 110.0)) < 1e-7);
@@ -162,31 +162,31 @@ fn offset_chain_mirror_patterns_move_scale() {
#[test]
fn new_creation_tools() {
let mut s = new_sketch();
let r = run(&mut s, "SketchMidpointLine", json!({"mid": [0, 0], "end": [10, 5]}));
let r = run(&mut s, "sketch.line.midpoint", json!({"mid": [0, 0], "end": [10, 5]}));
let l = ids(&r["curves"])[0].clone();
let sk = sketch(&s);
let start = sk.resolve_point(&format!("{l}.start")).and_then(|i| sk.point(i)).unwrap();
assert!(start.dist(Vec2::new(-10.0, -5.0)) < 1e-9);
// Tangent arc off the end of a horizontal line.
let h = ids(&run(&mut s, "DrawPolyline", json!({"points": [[0, 20], [10, 20]]}))["curves"])[0].clone();
let r = run(&mut s, "ArcTangent", json!({"start": format!("{h}.end"), "end": [20, 30]}));
let h = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 20], [10, 20]]}))["curves"])[0].clone();
let r = run(&mut s, "sketch.arc.tangent", json!({"start": format!("{h}.end"), "end": [20, 30]}));
let a = ids(&r["curves"])[0].clone();
let sk = sketch(&s);
let ai = sk.curve_index(&a).unwrap();
assert!(sk.center(ai).unwrap().dist(Vec2::new(10.0, 30.0)) < 1e-7, "{:?}", sk.center(ai));
assert!(close(sk.radius(ai).unwrap(), 10.0));
// 2-tangent circle in the corner of two lines.
let x = ids(&run(&mut s, "DrawPolyline", json!({"points": [[100, 0], [150, 0]]}))["curves"])[0].clone();
let y = ids(&run(&mut s, "DrawPolyline", json!({"points": [[100, 0], [100, 50]]}))["curves"])[0].clone();
let r = run(&mut s, "CircleTanTanRadius", json!({"curves": [x, y], "radius": 5, "near": [106, 104 - 100]}));
let x = ids(&run(&mut s, "sketch.line", json!({"points": [[100, 0], [150, 0]]}))["curves"])[0].clone();
let y = ids(&run(&mut s, "sketch.line", json!({"points": [[100, 0], [100, 50]]}))["curves"])[0].clone();
let r = run(&mut s, "sketch.circle.two_tangent", json!({"curves": [x, y], "radius": 5, "near": [106, 104 - 100]}));
let c = ids(&r["curves"])[0].clone();
let sk = sketch(&s);
let ci = sk.curve_index(&c).unwrap();
assert!(sk.center(ci).unwrap().dist(Vec2::new(105.0, 5.0)) < 1e-6, "{:?}", sk.center(ci));
// 3-tangent circle inside a triangle: the incircle of the 3-4-5 triangle (r = 1).
let t = run(&mut s, "DrawPolyline", json!({"points": [[200, 0], [203, 0], [200, 4]], "closed": true}));
let t = run(&mut s, "sketch.line", json!({"points": [[200, 0], [203, 0], [200, 4]], "closed": true}));
let tl = ids(&t["curves"]);
let r = run(&mut s, "CircleThreeTangent", json!({"curves": tl, "near": [201, 1.2]}));
let r = run(&mut s, "sketch.circle.three_tangent", json!({"curves": tl, "near": [201, 1.2]}));
let c = ids(&r["curves"])[0].clone();
let sk = sketch(&s);
let ci = sk.curve_index(&c).unwrap();
@@ -194,9 +194,9 @@ fn new_creation_tools() {
assert!(sk.center(ci).unwrap().dist(Vec2::new(201.0, 1.0)) < 1e-6);
// Slots.
let before = profiles(&s).len();
run(&mut s, "ShapeSlotCenterPoint", json!({"center": [0, -100], "end": [10, -100], "width": 4}));
run(&mut s, "ShapeArcSlotThreePoint", json!({"start": [100, -100], "through": [110, -90], "end": [120, -100], "width": 2}));
run(&mut s, "ShapeArcSlotCenterTwoPoint", json!({"center": [200, -100], "start": [210, -100], "end": [200, -90], "width": 2}));
run(&mut s, "sketch.slot.center_point", json!({"center": [0, -100], "end": [10, -100], "width": 4}));
run(&mut s, "sketch.slot.arc_three_point", json!({"start": [100, -100], "through": [110, -90], "end": [120, -100], "width": 2}));
run(&mut s, "sketch.slot.arc_center", json!({"center": [200, -100], "start": [210, -100], "end": [200, -90], "width": 2}));
let a = profiles(&s);
assert_eq!(a.len(), before + 3, "{a:?}");
let pi = std::f64::consts::PI;
@@ -210,13 +210,13 @@ fn new_creation_tools() {
fn ellipse_splines_conic() {
let pi = std::f64::consts::PI;
let mut s = new_sketch();
let e = run(&mut s, "CircleElipse", json!({"center": [0, 0], "major": [10, 0], "minor": [0, 4]}));
let e = run(&mut s, "sketch.ellipse", json!({"center": [0, 0], "major": [10, 0], "minor": [0, 4]}));
let eid = ids(&e["curves"])[0].clone();
let a = profiles(&s);
assert!((a[0] - pi * 40.0).abs() / (pi * 40.0) < 2e-3, "{a:?}");
// A point constrained onto the ellipse lands on it.
run(&mut s, "DrawPoint", json!({"point": [3, 5], "id": "q"}));
run(&mut s, "ConstraintCoincident", json!({"a": "q", "b": eid}));
run(&mut s, "sketch.point", json!({"point": [3, 5], "id": "q"}));
run(&mut s, "sketch.constraint.coincident", json!({"a": "q", "b": eid}));
let sk = sketch(&s);
let q = sk.point(sk.point_index("q").unwrap()).unwrap();
let poly = sk.polyline(sk.curve_index(&eid).unwrap());
@@ -227,9 +227,9 @@ fn ellipse_splines_conic() {
});
assert!(d.fold(f64::INFINITY, f64::min) < 1e-6, "{q:?}");
// Fit spline closed by a line: one profile.
let sp = run(&mut s, "DrawSpline", json!({"points": [[100, 0], [110, 8], [120, 3], [130, 0]]}));
let sp = run(&mut s, "sketch.spline.fit_point", json!({"points": [[100, 0], [110, 8], [120, 3], [130, 0]]}));
let sid = ids(&sp["curves"])[0].clone();
run(&mut s, "DrawPolyline", json!({"points": [format!("{sid}.end"), format!("{sid}.start")]}));
run(&mut s, "sketch.line", json!({"points": [format!("{sid}.end"), format!("{sid}.start")]}));
assert_eq!(profiles(&s).len(), 2);
// Moving a fit point reshapes the spline (it is a sketch point).
let sk = sketch(&s);
@@ -240,31 +240,31 @@ fn ellipse_splines_conic() {
let sk = sketch(&s);
assert!(sk.polyline(si).iter().any(|q| q.dist(Vec2::new(110.0, 12.0)) < 1e-9));
// Control point splines (degree 3 and 5) and a conic.
run(&mut s, "DrawCVMSpline3D", json!({"points": [[0, 50], [0, 60], [10, 60], [10, 50]]}));
run(&mut s, "DrawCVMSpline5D", json!({"points": [[20, 50], [20, 60], [25, 65], [30, 60], [35, 62], [40, 50]]}));
run(&mut s, "sketch.spline.control_point", json!({"points": [[0, 50], [0, 60], [10, 60], [10, 50]]}));
run(&mut s, "sketch.spline.control_point_5", json!({"points": [[20, 50], [20, 60], [25, 65], [30, 60], [35, 62], [40, 50]]}));
let w = std::f64::consts::FRAC_1_SQRT_2;
let k = run(&mut s, "ConicCurveCmd", json!({"start": [210, 0], "end": [200, 10], "apex": [210, 10], "rho": w / (1.0 + w)}));
let k = run(&mut s, "sketch.conic", json!({"start": [210, 0], "end": [200, 10], "apex": [210, 10], "rho": w / (1.0 + w)}));
let kid = ids(&k["curves"])[0].clone();
// A line tangent to the conic at its end: the tangent arc tool continues it.
let r = run(&mut s, "ArcTangent", json!({"start": format!("{kid}.end"), "end": [190, 0]}));
let r = run(&mut s, "sketch.arc.tangent", json!({"start": format!("{kid}.end"), "end": [190, 0]}));
let aid = ids(&r["curves"])[0].clone();
let sk = sketch(&s);
// The conic is a quarter circle about (200, 0) leaving (200, 10) heading -x: the tangent
// arc from there to (190, 0) continues the same circle.
let ai = sk.curve_index(&aid).unwrap();
assert!(sk.center(ai).unwrap().dist(Vec2::new(200.0, 0.0)) < 1e-6, "{:?}", sk.center(ai));
assert!(s.execute("DrawSpline", &json!({"points": [[0, 0]]})).is_err());
assert!(s.execute("ConicCurveCmd", &json!({"start": [0, 0], "end": [1, 0], "apex": [0, 1], "rho": 1.5})).is_err());
assert!(s.execute("sketch.spline.fit_point", &json!({"points": [[0, 0]]})).is_err());
assert!(s.execute("sketch.conic", &json!({"start": [0, 0], "end": [1, 0], "apex": [0, 1], "rho": 1.5})).is_err());
// Mirror and pattern copy free-form curves too.
let m = ids(&run(&mut s, "DrawPolyline", json!({"points": [[-50, -50], [-50, 50]]}))["curves"])[0].clone();
let r = run(&mut s, "MirrorSketchCommand", json!({"entities": [eid, sid], "line": m}));
let m = ids(&run(&mut s, "sketch.line", json!({"points": [[-50, -50], [-50, 50]]}))["curves"])[0].clone();
let r = run(&mut s, "sketch.mirror", json!({"entities": [eid, sid], "line": m}));
assert_eq!(ids(&r["curves"]).len(), 2);
}
#[test]
fn text_makes_profiles_that_extrude_and_edit() {
let mut s = new_sketch();
let r = run(&mut s, "MTextCmd", json!({"text": "SO", "at": [0, 0], "height": 10}));
let r = run(&mut s, "sketch.text", json!({"text": "SO", "at": [0, 0], "height": 10}));
let link = r["link"].as_str().unwrap().to_string();
let a = profiles(&s);
// S (one region) + O (ring = outer with a hole, plus the counter as its own region).
@@ -276,21 +276,21 @@ fn text_makes_profiles_that_extrude_and_edit() {
assert_eq!(r2["link"], json!(link));
let a = profiles(&s);
assert_eq!(a.len(), 1, "{a:?}");
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"distance": 2}));
let m = run(&mut s, "MeasureCommand", json!({}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 2}));
let m = run(&mut s, "inspect.measure", json!({}));
let v = m["total"]["volume_mm3"].as_f64().unwrap();
// An "I" 20 mm tall is a bar a couple of mm wide.
assert!(v > 40.0 && v < 200.0, "{v}");
assert!(s.execute("MTextCmd", &json!({"text": "", "at": [0, 0]})).is_err());
assert!(s.execute("sketch.text", &json!({"text": "", "at": [0, 0]})).is_err());
}
#[test]
fn blend_curve_joins_tangentially() {
let mut s = new_sketch();
let a = ids(&run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [10, 0]]}))["curves"])[0].clone();
let b = ids(&run(&mut s, "DrawPolyline", json!({"points": [[20, 10], [20, 20]]}))["curves"])[0].clone();
let r = run(&mut s, "BlendG1CurveSketchCmd", json!({"a": format!("{a}.end"), "b": format!("{b}.start")}));
let a = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]]}))["curves"])[0].clone();
let b = ids(&run(&mut s, "sketch.line", json!({"points": [[20, 10], [20, 20]]}))["curves"])[0].clone();
let r = run(&mut s, "sketch.blend_curve", json!({"a": format!("{a}.end"), "b": format!("{b}.start")}));
assert_eq!(r["sketch"]["solved"], true, "{r}");
let sp = ids(&r["curves"])[0].clone();
let sk = sketch(&s);
@@ -305,9 +305,9 @@ fn blend_curve_joins_tangentially() {
#[test]
fn centerline_is_left_out_of_profiles() {
let mut s = new_sketch();
let r = run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [10, 10]}));
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]}));
let l = ids(&r["curves"]);
let m = ids(&run(&mut s, "DrawPolyline", json!({"points": [[5, -5], [5, 15]]}))["curves"])[0].clone();
let m = ids(&run(&mut s, "sketch.line", json!({"points": [[5, -5], [5, 15]]}))["curves"])[0].clone();
assert_eq!(profiles(&s).len(), 2);
run(&mut s, "sketch.centerline", json!({"curves": [m]}));
assert_eq!(profiles(&s).len(), 1);
@@ -318,57 +318,59 @@ fn centerline_is_left_out_of_profiles() {
#[test]
fn free_form_profiles_extrude_exactly() {
let pi = std::f64::consts::PI;
let vol = |s: &mut Session| run(s, "MeasureCommand", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
let vol = |s: &mut Session| run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
// Ellipse a = 10, b = 4: π a b h.
let mut s = new_sketch();
run(&mut s, "CircleElipse", json!({"center": [0, 0], "major": [10, 0], "minor_radius": 4}));
run(&mut s, "sketch.ellipse", json!({"center": [0, 0], "major": [10, 0], "minor_radius": 4}));
let a = profiles(&s)[0];
assert!((a - pi * 40.0).abs() < 1e-9, "{a}");
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"distance": 5}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 5}));
let v = vol(&mut s);
assert!((v - pi * 40.0 * 5.0).abs() / (pi * 200.0) < 2e-4, "{v}");
let m = run(&mut s, "MeasureCommand", json!({}));
let m = run(&mut s, "inspect.measure", json!({}));
assert_eq!(m["bodies"][0]["faces"], 3, "an exact ellipse: top, bottom and one side ({m})");
// A region closed by a fit spline and a line.
let mut s = new_sketch();
let sp = ids(&run(&mut s, "DrawSpline", json!({"points": [[0, 0], [10, 8], [20, 0]]}))["curves"])[0].clone();
run(&mut s, "DrawPolyline", json!({"points": [format!("{sp}.end"), format!("{sp}.start")]}));
let sp = ids(&run(&mut s, "sketch.spline.fit_point", json!({"points": [[0, 0], [10, 8], [20, 0]]}))["curves"])[0].clone();
run(&mut s, "sketch.line", json!({"points": [format!("{sp}.end"), format!("{sp}.start")]}));
let a = profiles(&s)[0];
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"distance": 2}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 2}));
let v = vol(&mut s);
assert!((v - a * 2.0).abs() / (a * 2.0) < 2e-4, "{v} vs {}", a * 2.0);
let m = run(&mut s, "MeasureCommand", json!({}));
let m = run(&mut s, "inspect.measure", json!({}));
assert!(m["bodies"][0]["faces"].as_u64().unwrap() <= 5, "{m}");
}
#[test]
fn degree_five_and_cut_free_form_profiles_extrude_exactly() {
let vol = |s: &mut Session| run(s, "MeasureCommand", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
let vol = |s: &mut Session| run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
// A degree-5 control spline closed by a line: one B-spline side face.
let mut s = new_sketch();
let sp = ids(&run(&mut s, "DrawCVMSpline5D", json!({"points": [[0, 0], [4, 10], [10, 12], [16, 12], [22, 10], [26, 0]]}))["curves"])[0].clone();
run(&mut s, "DrawPolyline", json!({"points": [format!("{sp}.end"), format!("{sp}.start")]}));
let sp =
ids(&run(&mut s, "sketch.spline.control_point_5", json!({"points": [[0, 0], [4, 10], [10, 12], [16, 12], [22, 10], [26, 0]]}))["curves"])[0]
.clone();
run(&mut s, "sketch.line", json!({"points": [format!("{sp}.end"), format!("{sp}.start")]}));
let a = profiles(&s)[0];
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"distance": 3}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 3}));
let v = vol(&mut s);
assert!((v - a * 3.0).abs() / (a * 3.0) < 2e-4, "{v} vs {}", a * 3.0);
let m = run(&mut s, "MeasureCommand", json!({}));
let m = run(&mut s, "inspect.measure", json!({}));
assert_eq!(m["bodies"][0]["faces"], 4, "{m}");
// A fit spline arch cut by a line: both sides extrude with exact curved faces.
let mut s = new_sketch();
let sp = ids(&run(&mut s, "DrawSpline", json!({"points": [[0, 0], [6, 9], [14, 9], [20, 0]]}))["curves"])[0].clone();
run(&mut s, "DrawPolyline", json!({"points": [format!("{sp}.end"), format!("{sp}.start")]}));
run(&mut s, "DrawPolyline", json!({"points": [[10, -5], [10, 20]]}));
let sp = ids(&run(&mut s, "sketch.spline.fit_point", json!({"points": [[0, 0], [6, 9], [14, 9], [20, 0]]}))["curves"])[0].clone();
run(&mut s, "sketch.line", json!({"points": [format!("{sp}.end"), format!("{sp}.start")]}));
run(&mut s, "sketch.line", json!({"points": [[10, -5], [10, 20]]}));
let areas = profiles(&s);
assert_eq!(areas.len(), 2, "{areas:?}");
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"distance": 2, "profiles": [0]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 2, "profiles": [0]}));
let v = vol(&mut s);
assert!((v - areas[0] * 2.0).abs() / (areas[0] * 2.0) < 2e-4, "{v} vs {}", areas[0] * 2.0);
let m = run(&mut s, "MeasureCommand", json!({}));
let m = run(&mut s, "inspect.measure", json!({}));
assert!(m["bodies"][0]["faces"].as_u64().unwrap() <= 7, "no polyline facets: {m}");
}
@@ -378,9 +380,9 @@ fn trim_and_break_free_form_curves_exactly() {
let mut s = new_sketch();
// An ellipse cut by a line through its centre: trimming the lower half leaves the upper
// half, which closes with the line into half the ellipse.
let e = ids(&run(&mut s, "CircleElipse", json!({"center": [0, 0], "major": [10, 0], "minor_radius": 4}))["curves"])[0].clone();
let l = ids(&run(&mut s, "DrawPolyline", json!({"points": [[-15, 0], [15, 0]]}))["curves"])[0].clone();
let r = run(&mut s, "TrimSketchCmd", json!({"curve": e, "at": [0, -4]}));
let e = ids(&run(&mut s, "sketch.ellipse", json!({"center": [0, 0], "major": [10, 0], "minor_radius": 4}))["curves"])[0].clone();
let l = ids(&run(&mut s, "sketch.line", json!({"points": [[-15, 0], [15, 0]]}))["curves"])[0].clone();
let r = run(&mut s, "sketch.trim", json!({"curve": e, "at": [0, -4]}));
assert!(!r["result"]["pieces"].as_array().unwrap().is_empty(), "{r}");
let a = profiles(&s);
assert_eq!(a.len(), 1, "{a:?}");
@@ -388,10 +390,10 @@ fn trim_and_break_free_form_curves_exactly() {
let _ = l;
// A fit spline broken where a line crosses it: two pieces that still trace the curve.
let mut s = new_sketch();
let sp = ids(&run(&mut s, "DrawSpline", json!({"points": [[0, 0], [10, 10], [20, 0], [30, 5]]}))["curves"])[0].clone();
let sp = ids(&run(&mut s, "sketch.spline.fit_point", json!({"points": [[0, 0], [10, 10], [20, 0], [30, 5]]}))["curves"])[0].clone();
let before = sketch(&s).polyline(sketch(&s).curve_index(&sp).unwrap());
run(&mut s, "DrawPolyline", json!({"points": [[15, -10], [15, 20]]}));
let r = run(&mut s, "BreakSketchCmd", json!({"curve": sp, "at": [15, 5]}));
run(&mut s, "sketch.line", json!({"points": [[15, -10], [15, 20]]}));
let r = run(&mut s, "sketch.break", json!({"curve": sp, "at": [15, 5]}));
let pieces = ids(&r["curves"]);
assert_eq!(pieces.len(), 4, "three Béziers, the middle one split: {r}");
let sk = sketch(&s);
@@ -415,9 +417,9 @@ fn trim_and_break_free_form_curves_exactly() {
#[test]
fn ellipse_radii_dimensions() {
let mut s = new_sketch();
let e = ids(&run(&mut s, "CircleElipse", json!({"center": [0, 0], "major": [10, 0], "minor_radius": 4}))["curves"])[0].clone();
run(&mut s, "SketchDimension", json!({"entities": [e], "type": "major", "value": 12}));
run(&mut s, "SketchDimension", json!({"entities": [e], "value": 5}));
let e = ids(&run(&mut s, "sketch.ellipse", json!({"center": [0, 0], "major": [10, 0], "minor_radius": 4}))["curves"])[0].clone();
run(&mut s, "sketch.dimension", json!({"entities": [e], "type": "major", "value": 12}));
run(&mut s, "sketch.dimension", json!({"entities": [e], "value": 5}));
let a = profiles(&s)[0];
assert!((a - std::f64::consts::PI * 60.0).abs() < 1e-6, "{a}");
}
@@ -426,10 +428,11 @@ fn ellipse_radii_dimensions() {
fn fillet_a_line_and_an_arc() {
let mut s = new_sketch();
// A line into a quarter arc meeting at a sharp corner.
let l = ids(&run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [20, 0]]}))["curves"])[0].clone();
let a = ids(&run(&mut s, "ArcCenterTwoPoint", json!({"center": [30, 0], "start": format!("{l}.end"), "end": [30, -10]}))["curves"])[0].clone();
let l = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [20, 0]]}))["curves"])[0].clone();
let a =
ids(&run(&mut s, "sketch.arc.center_point", json!({"center": [30, 0], "start": format!("{l}.end"), "end": [30, -10]}))["curves"])[0].clone();
let _ = a;
let f = run(&mut s, "FilletSketchCmd", json!({"point": format!("{l}.end"), "radius": 2}));
let f = run(&mut s, "sketch.fillet", json!({"point": format!("{l}.end"), "radius": 2}));
let arc = ids(&f["curves"])[0].clone();
let sk = sketch(&s);
let ai = sk.curve_index(&arc).unwrap();
@@ -444,8 +447,8 @@ fn fillet_a_line_and_an_arc() {
#[test]
fn offset_free_form_curves() {
let mut s = new_sketch();
let sp = ids(&run(&mut s, "DrawSpline", json!({"points": [[0, 0], [10, 8], [20, 0], [30, 6]]}))["curves"])[0].clone();
let r = run(&mut s, "Offset", json!({"curves": [sp], "distance": 2, "side": [10, 20]}));
let sp = ids(&run(&mut s, "sketch.spline.fit_point", json!({"points": [[0, 0], [10, 8], [20, 0], [30, 6]]}))["curves"])[0].clone();
let r = run(&mut s, "sketch.offset", json!({"curves": [sp], "distance": 2, "side": [10, 20]}));
let made = ids(&r["curves"]);
assert_eq!(made.len(), 1, "{r}");
let sk = sketch(&s);
@@ -464,7 +467,7 @@ fn offset_free_form_curves() {
assert!((dist(*q) - 2.0).abs() < 0.08, "{q:?} {}", dist(*q));
}
// An ellipse grows by the offset all round.
let e = ids(&run(&mut s, "CircleElipse", json!({"center": [100, 0], "major": [110, 0], "minor_radius": 5}))["curves"])[0].clone();
let r = run(&mut s, "Offset", json!({"curves": [e], "distance": 1, "side": [100, 20]}));
let e = ids(&run(&mut s, "sketch.ellipse", json!({"center": [100, 0], "major": [110, 0], "minor_radius": 5}))["curves"])[0].clone();
let r = run(&mut s, "sketch.offset", json!({"curves": [e], "distance": 1, "side": [100, 20]}));
assert_eq!(ids(&r["curves"]).len(), 1, "{r}");
}
+2 -2
View File
@@ -15,7 +15,7 @@ fn ms(t: Instant) -> f64 {
/// them, built straight into the document.
fn big(n: usize) -> Session {
let mut s = Session::default();
s.execute("SketchCreate", &json!({"plane": "XY"})).unwrap();
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
let id = s.active_sketch.unwrap();
let mut doc = (*s.doc).clone();
let sk = doc.sketch_mut(id).unwrap();
@@ -67,7 +67,7 @@ fn sketch_perf() {
s.execute("sketch.snap", &json!({"at": [10.0, 0.2], "from": [0, 0], "radius": 1})).unwrap();
let snap = ms(t);
let t = Instant::now();
s.execute("DrawPolyline", &json!({"points": [[-50, -50], [-40, -45]]})).unwrap();
s.execute("sketch.line", &json!({"points": [[-50, -50], [-40, -45]]})).unwrap();
let add = ms(t);
let t = Instant::now();
s.execute("sketch.move_point", &json!({"point": "p1", "to": [1, 1]})).unwrap();
+16 -16
View File
@@ -14,36 +14,36 @@ use crate::params::{bad, bool_, str_, vec3};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("ProjectNewCmd", "Project", project_cmd).at("SKETCH", "CREATE").icon("project").key("P").enabled(in_sketch).params(REFS_DOC),
CommandSpec::new("IntersectCmd", "Intersect", intersect_cmd).at("SKETCH", "CREATE").icon("intersect").enabled(in_sketch).params(
CommandSpec::new("sketch.project", "Project", project_cmd).at("SKETCH", "CREATE").icon("project").key("P").enabled(in_sketch).params(REFS_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",
),
CommandSpec::new("Include3DGeometry", "Include 3D Geometry", include_cmd)
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"),
CommandSpec::new("ProjectToSurface", "Project To Surface", project_to_surface)
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"),
CommandSpec::new("IntersectionCurve", "Intersection Curve", intersection_curve)
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"),
CommandSpec::new("SpunProfileCmd", "Spun Profile", spun_profile)
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"),
CommandSpec::new("SketchIsoparametricCurve", "Isoparametric Curve", iso_curve)
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"),
CommandSpec::new("FitCurvesToSectionCommand", "Fit Curves to Mesh Section", fit_section)
CommandSpec::new("sketch.fit_curves_to_section", "Fit Curves to Mesh Section", fit_section)
.at("SKETCH", "CREATE")
.icon("fit_section")
.enabled(in_sketch)
@@ -176,15 +176,15 @@ fn run_links(s: &mut Session, p: &Value, cmd: &str, kind: LinkKind) -> Result<Va
}
fn project_cmd(s: &mut Session, p: &Value) -> Result<Value> {
run_links(s, p, "ProjectNewCmd", LinkKind::Project)
run_links(s, p, "sketch.project", LinkKind::Project)
}
fn intersect_cmd(s: &mut Session, p: &Value) -> Result<Value> {
run_links(s, p, "IntersectCmd", LinkKind::Intersect)
run_links(s, p, "sketch.intersect", LinkKind::Intersect)
}
fn include_cmd(s: &mut Session, p: &Value) -> Result<Value> {
run_links(s, p, "Include3DGeometry", LinkKind::Include)
run_links(s, p, "sketch.include_3d", LinkKind::Include)
}
/// Add one link from `src` to the active sketch and report it.
@@ -204,7 +204,7 @@ fn one_link(s: &mut Session, cmd: &str, kind: LinkKind, src: LinkSource) -> Resu
}
fn project_to_surface(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ProjectToSurface";
let cmd = "sketch.project_to_surface";
let at = p.get("face").and_then(vec3).ok_or_else(|| bad(cmd, "`face` must be a point [x,y,z] on the target face"))?;
let body = str_(p, "body").unwrap_or("").to_string();
let list = p.get("curves").and_then(Value::as_array).cloned().ok_or_else(|| bad(cmd, "`curves` must list {sketch, curve}"))?;
@@ -222,7 +222,7 @@ fn project_to_surface(s: &mut Session, p: &Value) -> Result<Value> {
}
fn intersection_curve(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "IntersectionCurve";
let cmd = "sketch.intersection_curve";
let side = |k: &str| -> Result<LinkSource> {
let v = p.get(k).ok_or_else(|| bad(cmd, format!("missing `{k}`")))?;
match source(s, v, cmd)? {
@@ -235,7 +235,7 @@ fn intersection_curve(s: &mut Session, p: &Value) -> Result<Value> {
}
fn spun_profile(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "SpunProfileCmd";
let cmd = "sketch.spun_profile";
let body = str_(p, "body").ok_or_else(|| bad(cmd, "`body` must name a body"))?.to_string();
let axis = str_(p, "axis").ok_or_else(|| bad(cmd, "`axis` must be a sketch line or X|Y|Z"))?;
let (origin, dir) = match axis.to_ascii_uppercase().as_str() {
@@ -256,7 +256,7 @@ fn spun_profile(s: &mut Session, p: &Value) -> Result<Value> {
}
fn iso_curve(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "SketchIsoparametricCurve";
let cmd = "sketch.isoparametric_curve";
let at = p.get("face").and_then(vec3).ok_or_else(|| bad(cmd, "`face` must be a point [x,y,z] on the face"))?;
let body = str_(p, "body").unwrap_or("").to_string();
let dir = match p.get("direction") {
@@ -271,7 +271,7 @@ fn iso_curve(s: &mut Session, p: &Value) -> Result<Value> {
}
fn fit_section(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FitCurvesToSectionCommand";
let cmd = "sketch.fit_curves_to_section";
let body = str_(p, "body").ok_or_else(|| bad(cmd, "`body` must name a body"))?.to_string();
if s.model.state().body(&body).is_none() {
return Err(bad(cmd, format!("no body `{body}`")));
+88 -88
View File
@@ -15,20 +15,20 @@ fn ids(v: &Value) -> Vec<String> {
/// A 40 x 30 x 20 box from a sketch whose width is the user parameter `width`.
fn box_part(s: &mut Session) {
run(s, "ChangeParameterCommand", json!({"name": "width", "expression": "40 mm"}));
run(s, "SketchCreate", json!({"plane": "XY"}));
let r = run(s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [40, 30]}));
run(s, "parameters.change", json!({"name": "width", "expression": "40 mm"}));
run(s, "sketch.create", json!({"plane": "XY"}));
let r = run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
let l = ids(&r["curves"]);
let sk = s.doc.sketch(s.active_sketch.unwrap()).unwrap();
let start = match sk.curves[0].kind {
solvecraft_sketch::CurveKind::Line { a, .. } => sk.points[a].id.clone(),
_ => panic!(),
};
run(s, "ConstraintCoincident", json!({"a": start, "b": "origin"}));
run(s, "SketchDimension", json!({"entities": [l[0]], "value": "width"}));
run(s, "SketchDimension", json!({"entities": [l[1]], "value": 30}));
run(s, "SketchStop", json!({}));
run(s, "Extrude", json!({"distance": 20}));
run(s, "sketch.constraint.coincident", json!({"a": start, "b": "origin"}));
run(s, "sketch.dimension", json!({"entities": [l[0]], "value": "width"}));
run(s, "sketch.dimension", json!({"entities": [l[1]], "value": 30}));
run(s, "sketch.finish", json!({}));
run(s, "solid.extrude", json!({"distance": 20}));
}
fn inspect(s: &mut Session) -> Value {
@@ -43,7 +43,7 @@ fn linked_curves(si: &Value) -> Vec<Value> {
fn sketch_on_face_projects_its_edges_and_follows_edits() {
let mut s = Session::default();
box_part(&mut s);
let r = run(&mut s, "SketchCreate", json!({"plane": {"face": [20, 15, 20]}}));
let r = run(&mut s, "sketch.create", json!({"plane": {"face": [20, 15, 20]}}));
assert!(r["projected"].is_string(), "{r}");
let si = inspect(&mut s);
let lc = linked_curves(&si);
@@ -55,16 +55,16 @@ fn sketch_on_face_projects_its_edges_and_follows_edits() {
// Projected geometry is fixed: the sketch has no degrees of freedom.
assert_eq!(si["dof"], 0, "{si}");
// A circle on the face, dimensioned to a projected edge.
let c = run(&mut s, "CircleCenterRadius", json!({"center": [10, 10], "radius": 3}));
let c = run(&mut s, "sketch.circle.center", json!({"center": [10, 10], "radius": 3}));
let cid = ids(&c["curves"])[0].clone();
let e = lc.iter().find(|c| c["start_at"][1].as_f64().unwrap().abs() < 1e-9 && c["end_at"][1].as_f64().unwrap().abs() < 1e-9).unwrap();
run(&mut s, "SketchDimension", json!({"entities": [format!("{cid}.center"), e["id"]], "value": 8}));
run(&mut s, "sketch.dimension", json!({"entities": [format!("{cid}.center"), e["id"]], "value": 8}));
let si = inspect(&mut s);
let center = si["points"].as_array().unwrap().iter().find(|p| p["id"] == format!("{cid}.center")).unwrap();
assert!((center["at"][1].as_f64().unwrap() - 8.0).abs() < 1e-7, "{center}");
run(&mut s, "SketchStop", json!({}));
run(&mut s, "sketch.finish", json!({}));
// Widen the box: the projected loop follows.
run(&mut s, "ChangeParameterCommand", json!({"name": "width", "expression": "50 mm"}));
run(&mut s, "parameters.change", json!({"name": "width", "expression": "50 mm"}));
let st = s.model.state();
let ss = st.sketches.last().unwrap();
assert_eq!(ss.profiles.len(), 2);
@@ -78,11 +78,11 @@ fn sketch_on_face_projects_its_edges_and_follows_edits() {
fn auto_project_can_be_turned_off() {
let mut s = Session::default();
box_part(&mut s);
let r = run(&mut s, "SketchCreate", json!({"plane": {"face": [20, 15, 20]}, "project_edges": false}));
let r = run(&mut s, "sketch.create", json!({"plane": {"face": [20, 15, 20]}, "project_edges": false}));
assert!(r["projected"].is_null());
run(&mut s, "SketchStop", json!({}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sketch.auto_project", json!({"value": false}));
let r = run(&mut s, "SketchCreate", json!({"plane": {"face": [20, 15, 20]}}));
let r = run(&mut s, "sketch.create", json!({"plane": {"face": [20, 15, 20]}}));
assert!(r["projected"].is_null());
}
@@ -90,10 +90,10 @@ fn auto_project_can_be_turned_off() {
fn project_edges_bodies_sketches_and_origin() {
let mut s = Session::default();
box_part(&mut s);
run(&mut s, "PrimitiveCylinder", json!({"base": [80, 0, 5], "radius": 6, "height": 10}));
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
run(&mut s, "solid.cylinder", json!({"base": [80, 0, 5], "radius": 6, "height": 10}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
// An edge on the top face projects to a line on XY.
let r = run(&mut s, "ProjectNewCmd", json!({"refs": [{"edge": [20, 30, 20]}]}));
let r = run(&mut s, "sketch.project", json!({"refs": [{"edge": [20, 30, 20]}]}));
let si = inspect(&mut s);
let c = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == r["links"][0]["curves"][0]).unwrap().clone();
assert_eq!(c["type"], "line");
@@ -101,7 +101,7 @@ fn project_edges_bodies_sketches_and_origin() {
assert!(ys.iter().all(|y| (y - 30.0).abs() < 1e-9), "{c}");
// The cylinder's silhouette seen from above is its circle, exactly.
let name = s.model.state().bodies[1].name.clone();
let r = run(&mut s, "ProjectNewCmd", json!({"refs": [{"body": name}]}));
let r = run(&mut s, "sketch.project", json!({"refs": [{"body": name}]}));
let cid = r["links"][0]["curves"][0].clone();
let si = inspect(&mut s);
let c = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == cid).unwrap().clone();
@@ -109,34 +109,34 @@ fn project_edges_bodies_sketches_and_origin() {
assert!((c["radius"].as_f64().unwrap() - 6.0).abs() < 1e-6, "{c}");
assert!((c["center_at"][0].as_f64().unwrap() - 80.0).abs() < 1e-6, "{c}");
// Origin, the Y axis and the YZ plane.
run(&mut s, "ProjectNewCmd", json!({"refs": ["origin", {"axis": "Y"}, {"plane": "YZ"}]}));
run(&mut s, "sketch.project", json!({"refs": ["origin", {"axis": "Y"}, {"plane": "YZ"}]}));
// The box silhouette from above is its 40 x 30 outline.
let b0 = s.model.state().bodies[0].name.clone();
let r = run(&mut s, "ProjectNewCmd", json!({"refs": [{"body": b0}]}));
let r = run(&mut s, "sketch.project", json!({"refs": [{"body": b0}]}));
assert_eq!(r["links"][0]["curves"].as_array().unwrap().len(), 4, "{r}");
run(&mut s, "SketchStop", json!({}));
run(&mut s, "sketch.finish", json!({}));
let first = s.active_sketch;
assert!(first.is_none());
let sk1 = s.doc.features.last().unwrap().id;
// Another sketch projects a curve of the first one.
run(&mut s, "SketchCreate", json!({"plane": "XY", "offset": 50}));
let r = run(&mut s, "ProjectNewCmd", json!({"refs": [{"sketch": sk1, "curve": cid}]}));
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": 50}));
let r = run(&mut s, "sketch.project", json!({"refs": [{"sketch": sk1, "curve": cid}]}));
let c2 = r["links"][0]["curves"][0].clone();
let si = inspect(&mut s);
let c = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == c2).unwrap().clone();
assert_eq!(c["type"], "circle");
// Bad references fail cleanly.
assert!(s.execute("ProjectNewCmd", &json!({"refs": [{"edge": [1000, 1000, 1000]}]})).is_err());
assert!(s.execute("ProjectNewCmd", &json!({"refs": [{"plane": "XY"}]})).is_err(), "parallel plane projects to nothing");
assert!(s.execute("sketch.project", &json!({"refs": [{"edge": [1000, 1000, 1000]}]})).is_err());
assert!(s.execute("sketch.project", &json!({"refs": [{"plane": "XY"}]})).is_err(), "parallel plane projects to nothing");
}
#[test]
fn intersect_cuts_a_body_with_the_sketch_plane() {
let mut s = Session::default();
run(&mut s, "PrimitiveCylinder", json!({"base": [0, 0, 0], "radius": 7, "height": 30}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "offset": 12}));
run(&mut s, "solid.cylinder", json!({"base": [0, 0, 0], "radius": 7, "height": 30}));
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": 12}));
let name = s.model.state().bodies[0].name.clone();
let r = run(&mut s, "IntersectCmd", json!({"refs": [{"body": name}]}));
let r = run(&mut s, "sketch.intersect", json!({"refs": [{"body": name}]}));
let si = inspect(&mut s);
let c = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == r["links"][0]["curves"][0]).unwrap().clone();
assert_eq!(c["type"], "circle", "{c}");
@@ -148,7 +148,7 @@ fn intersect_cuts_a_body_with_the_sketch_plane() {
fn break_link_makes_normal_geometry() {
let mut s = Session::default();
box_part(&mut s);
let r = run(&mut s, "SketchCreate", json!({"plane": {"face": [20, 15, 20]}}));
let r = run(&mut s, "sketch.create", json!({"plane": {"face": [20, 15, 20]}}));
let link = r["projected"].as_str().unwrap().to_string();
run(&mut s, "sketch.break_link", json!({"link": link}));
let si = inspect(&mut s);
@@ -162,11 +162,11 @@ fn break_link_makes_normal_geometry() {
#[test]
fn lost_reference_keeps_geometry_with_a_warning() {
let mut s = Session::default();
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
let boxf = s.doc.features.last().unwrap().id;
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
run(&mut s, "ProjectNewCmd", json!({"refs": [{"edge": [5, 0, 10]}]}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.project", json!({"refs": [{"edge": [5, 0, 10]}]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "timeline.suppress", json!({"feature": boxf, "suppressed": true}));
let st = s.model.state();
let ss = st.sketches.last().unwrap();
@@ -180,8 +180,8 @@ fn lost_reference_keeps_geometry_with_a_warning() {
fn drawing_on_a_model_vertex_projects_it() {
let mut s = Session::default();
box_part(&mut s);
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
let r = run(&mut s, "DrawPolyline", json!({"points": ["vertex:40,30,20", [60, 45]]}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let r = run(&mut s, "sketch.line", json!({"points": ["vertex:40,30,20", [60, 45]]}));
let l = ids(&r["curves"])[0].clone();
let si = inspect(&mut s);
let start = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == l).unwrap()["start"].clone();
@@ -190,7 +190,7 @@ fn drawing_on_a_model_vertex_projects_it() {
assert_eq!(p["fixed"], true);
assert!((p["at"][0].as_f64().unwrap() - 40.0).abs() < 1e-9);
// Snapping onto an edge projects it and keeps the point on it.
let r = run(&mut s, "DrawPolyline", json!({"points": [{"on_edge": [20, 0, 20]}, [20, -15]]}));
let r = run(&mut s, "sketch.line", json!({"points": [{"on_edge": [20, 0, 20]}, [20, -15]]}));
assert_eq!(ids(&r["curves"]).len(), 1);
let si = inspect(&mut s);
assert!(si["constraints"].as_array().unwrap().iter().any(|c| c["type"] == "point_on_curve"), "{si}");
@@ -199,41 +199,41 @@ fn drawing_on_a_model_vertex_projects_it() {
#[test]
fn fit_curves_to_a_mesh_section() {
let mut s = Session::default();
run(&mut s, "PrimitiveCylinder", json!({"base": [0, 0, 0], "radius": 7, "height": 30}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "offset": 10}));
run(&mut s, "solid.cylinder", json!({"base": [0, 0, 0], "radius": 7, "height": 30}));
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": 10}));
let name = s.model.state().bodies[0].name.clone();
let r = run(&mut s, "FitCurvesToSectionCommand", json!({"body": name}));
let r = run(&mut s, "sketch.fit_curves_to_section", json!({"body": name}));
assert_eq!(r["curves"].as_array().unwrap().len(), 1, "{r}");
let si = inspect(&mut s);
assert!(si["links"].as_array().unwrap().is_empty());
let c = &si["curves"][0];
assert_eq!(c["type"], "circle");
assert!(c.get("link").is_none());
assert!(s.execute("FitCurvesToSectionCommand", &json!({"body": "nope"})).is_err());
assert!(s.execute("sketch.fit_curves_to_section", &json!({"body": "nope"})).is_err());
}
#[test]
fn three_d_sketch_curves() {
let mut s = Session::default();
// Include keeps an out-of-plane edge 3D.
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
let r = run(&mut s, "Include3DGeometry", json!({"refs": [{"edge": [10, 10, 5]}]}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let r = run(&mut s, "sketch.include_3d", json!({"refs": [{"edge": [10, 10, 5]}]}));
let st = s.model.state();
let sk = &st.sketch(s.active_sketch.unwrap()).unwrap().sketch;
assert_eq!(sk.wires.len(), 1, "{r}");
assert!((sk.wires[0].pts.iter().map(|p| p.z).fold(0.0, f64::max) - 10.0).abs() < 1e-9);
run(&mut s, "SketchStop", json!({}));
run(&mut s, "sketch.finish", json!({}));
// Project a line onto a sphere along the sketch normal.
let mut s = Session::default();
run(&mut s, "PrimitiveSphere", json!({"center": [0, 0, 0], "radius": 20}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "offset": 50}));
let l = ids(&run(&mut s, "DrawPolyline", json!({"points": [[-10, 0], [10, 0]]}))["curves"])[0].clone();
run(&mut s, "SketchStop", json!({}));
run(&mut s, "solid.sphere", json!({"center": [0, 0, 0], "radius": 20}));
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": 50}));
let l = ids(&run(&mut s, "sketch.line", json!({"points": [[-10, 0], [10, 0]]}))["curves"])[0].clone();
run(&mut s, "sketch.finish", json!({}));
let src = s.doc.features.last().unwrap().id;
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
let r = run(&mut s, "ProjectToSurface", json!({"curves": [{"sketch": src, "curve": l}], "face": [0, 0, 20]}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let r = run(&mut s, "sketch.project_to_surface", json!({"curves": [{"sketch": src, "curve": l}], "face": [0, 0, 20]}));
let wid = r["links"][0]["wires"][0].as_str().unwrap().to_string();
let st = s.model.state();
let sk = &st.sketch(s.active_sketch.unwrap()).unwrap().sketch;
@@ -246,16 +246,16 @@ fn three_d_sketch_curves() {
// Delete the wire.
run(&mut s, "sketch.delete", json!({"entities": [wid]}));
assert!(s.model.state().sketch(s.active_sketch.unwrap()).unwrap().sketch.wires.is_empty());
run(&mut s, "SketchStop", json!({}));
run(&mut s, "sketch.finish", json!({}));
// Two crossing cylinders meet in 3D curves.
let mut s = Session::default();
run(&mut s, "PrimitiveCylinder", json!({"base": [0, 0, -20], "radius": 5, "height": 40}));
run(&mut s, "PrimitiveCylinder", json!({"base": [-20, 0, 0], "axis": [1, 0, 0], "radius": 4, "height": 40, "operation": "new"}));
run(&mut s, "solid.cylinder", json!({"base": [0, 0, -20], "radius": 5, "height": 40}));
run(&mut s, "solid.cylinder", json!({"base": [-20, 0, 0], "axis": [1, 0, 0], "radius": 4, "height": 40, "operation": "new"}));
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
assert_eq!(names.len(), 2, "{names:?}");
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
let r = run(&mut s, "IntersectionCurve", json!({"a": {"body": names[0]}, "b": {"body": names[1]}}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let r = run(&mut s, "sketch.intersection_curve", json!({"a": {"body": names[0]}, "b": {"body": names[1]}}));
assert!(!r["wires"].as_array().unwrap().is_empty(), "{r}");
let st = s.model.state();
let sk = &st.sketch(s.active_sketch.unwrap()).unwrap().sketch;
@@ -265,14 +265,14 @@ fn three_d_sketch_curves() {
assert!(((p.y * p.y + p.z * p.z).sqrt() - 4.0).abs() < 0.1, "{p:?}");
}
}
run(&mut s, "SketchStop", json!({}));
run(&mut s, "sketch.finish", json!({}));
// Spun profile of a cylinder about Z, on the XZ plane: a straight outline 6 from the axis.
let mut s = Session::default();
run(&mut s, "PrimitiveCylinder", json!({"base": [0, 0, 0], "radius": 6, "height": 20}));
run(&mut s, "solid.cylinder", json!({"base": [0, 0, 0], "radius": 6, "height": 20}));
let name = s.model.state().bodies[0].name.clone();
run(&mut s, "SketchCreate", json!({"plane": "XZ"}));
let r = run(&mut s, "SpunProfileCmd", json!({"body": name, "axis": "Z"}));
run(&mut s, "sketch.create", json!({"plane": "XZ"}));
let r = run(&mut s, "sketch.spun_profile", json!({"body": name, "axis": "Z"}));
let si = inspect(&mut s);
let c = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == r["curves"][0]).unwrap().clone();
assert_eq!(c["type"], "line", "{c}");
@@ -282,15 +282,15 @@ fn three_d_sketch_curves() {
#[test]
fn isoparametric_curves_of_a_cylinder() {
let mut s = Session::default();
run(&mut s, "PrimitiveCylinder", json!({"base": [0, 0, 0], "radius": 6, "height": 20}));
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
run(&mut s, "solid.cylinder", json!({"base": [0, 0, 0], "radius": 6, "height": 20}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
// u: around (a circle at z = 10); v: along (a line from z 0 to 20).
let r = run(&mut s, "SketchIsoparametricCurve", json!({"face": [6, 0, 10]}));
let r = run(&mut s, "sketch.isoparametric_curve", json!({"face": [6, 0, 10]}));
let st = s.model.state();
let sk = &st.sketch(s.active_sketch.unwrap()).unwrap().sketch;
let w = &sk.wires[sk.wire_index(r["wires"][0].as_str().unwrap()).unwrap()];
assert!(w.pts.iter().all(|p| (p.z - 10.0).abs() < 1e-6 && ((p.x * p.x + p.y * p.y).sqrt() - 6.0).abs() < 1e-3), "{:?}", &w.pts[..3]);
let r = run(&mut s, "SketchIsoparametricCurve", json!({"face": [6, 0, 10], "direction": "v"}));
let r = run(&mut s, "sketch.isoparametric_curve", json!({"face": [6, 0, 10], "direction": "v"}));
let st = s.model.state();
let sk = &st.sketch(s.active_sketch.unwrap()).unwrap().sketch;
let w = &sk.wires[sk.wire_index(r["wires"][0].as_str().unwrap()).unwrap()];
@@ -302,10 +302,10 @@ fn isoparametric_curves_of_a_cylinder() {
#[test]
fn tilted_circles_project_to_exact_ellipses_and_splines_stay_splines() {
let mut s = Session::default();
run(&mut s, "PrimitiveCylinder", json!({"base": [0, 0, 0], "axis": [1, 0, 1], "radius": 5, "height": 20}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "offset": -10}));
run(&mut s, "solid.cylinder", json!({"base": [0, 0, 0], "axis": [1, 0, 1], "radius": 5, "height": 20}));
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": -10}));
// The base circle (centred at the origin) seen from above.
let r = run(&mut s, "ProjectNewCmd", json!({"refs": [{"edge": [0, 5, 0]}]}));
let r = run(&mut s, "sketch.project", json!({"refs": [{"edge": [0, 5, 0]}]}));
// One seam half: exact conics (rational quadratic arcs).
let si = inspect(&mut s);
let c = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == r["links"][0]["curves"][0]).unwrap().clone();
@@ -320,7 +320,7 @@ fn tilted_circles_project_to_exact_ellipses_and_splines_stay_splines() {
}
}
// The whole face (both halves of its rim, plus its seam) gives the full ellipse.
let r = run(&mut s, "ProjectNewCmd", json!({"refs": [{"face": [0, 0, 0]}]}));
let r = run(&mut s, "sketch.project", json!({"refs": [{"face": [0, 0, 0]}]}));
let si = inspect(&mut s);
let types: Vec<String> = r["links"][0]["curves"]
.as_array()
@@ -329,7 +329,7 @@ fn tilted_circles_project_to_exact_ellipses_and_splines_stay_splines() {
.map(|id| si["curves"].as_array().unwrap().iter().find(|c| c["id"] == *id).unwrap()["type"].as_str().unwrap().to_string())
.collect();
assert!(types.iter().all(|t| t == "conic" || t == "ellipse"), "{types:?}");
let r = run(&mut s, "ProjectNewCmd", json!({"refs": [{"edge": [0, -5, 0]}]}));
let r = run(&mut s, "sketch.project", json!({"refs": [{"edge": [0, -5, 0]}]}));
let si = inspect(&mut s);
let c = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == r["links"][0]["curves"][0]).unwrap().clone();
if c["type"] == "ellipse" {
@@ -337,14 +337,14 @@ fn tilted_circles_project_to_exact_ellipses_and_splines_stay_splines() {
let major = &c["major_at"];
assert!(((major[0].as_f64().unwrap().powi(2) + major[1].as_f64().unwrap().powi(2)).sqrt() - 5.0).abs() < 1e-6, "{c}");
}
run(&mut s, "SketchStop", json!({}));
run(&mut s, "sketch.finish", json!({}));
// A spline of a parallel sketch projects as the same spline.
run(&mut s, "SketchCreate", json!({"plane": "XY", "offset": 30}));
let sp = run(&mut s, "DrawSpline", json!({"points": [[0, 0], [10, 5], [20, 0]]}))["curves"][0].clone();
run(&mut s, "SketchStop", json!({}));
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": 30}));
let sp = run(&mut s, "sketch.spline.fit_point", json!({"points": [[0, 0], [10, 5], [20, 0]]}))["curves"][0].clone();
run(&mut s, "sketch.finish", json!({}));
let sk1 = s.doc.features.last().unwrap().id;
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
let r = run(&mut s, "ProjectNewCmd", json!({"refs": [{"sketch": sk1, "curve": sp}]}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let r = run(&mut s, "sketch.project", json!({"refs": [{"sketch": sk1, "curve": sp}]}));
let si = inspect(&mut s);
let c = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == r["links"][0]["curves"][0]).unwrap().clone();
assert_eq!(c["type"], "spline", "{c}");
@@ -355,13 +355,13 @@ fn tilted_circles_project_to_exact_ellipses_and_splines_stay_splines() {
fn sweeps_and_pipes_follow_3d_sketch_curves() {
// A line projected onto a sphere: a 3D arc on its top.
let mut s = Session::default();
run(&mut s, "PrimitiveSphere", json!({"center": [0, 0, 0], "radius": 20}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "offset": 50}));
let l = ids(&run(&mut s, "DrawPolyline", json!({"points": [[-12, 0], [12, 0]]}))["curves"])[0].clone();
run(&mut s, "SketchStop", json!({}));
run(&mut s, "solid.sphere", json!({"center": [0, 0, 0], "radius": 20}));
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": 50}));
let l = ids(&run(&mut s, "sketch.line", json!({"points": [[-12, 0], [12, 0]]}))["curves"])[0].clone();
run(&mut s, "sketch.finish", json!({}));
let src = s.doc.features.last().unwrap().id;
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
let r = run(&mut s, "ProjectToSurface", json!({"curves": [{"sketch": src, "curve": l}], "face": [0, 0, 20]}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let r = run(&mut s, "sketch.project_to_surface", json!({"curves": [{"sketch": src, "curve": l}], "face": [0, 0, 20]}));
let wid = r["links"][0]["wires"][0].as_str().unwrap().to_string();
let path_sketch = s.active_sketch.unwrap();
let pts = {
@@ -369,11 +369,11 @@ fn sweeps_and_pipes_follow_3d_sketch_curves() {
let sk = &st.sketch(path_sketch).unwrap().sketch;
sk.wires[sk.wire_index(&wid).unwrap()].pts.clone()
};
run(&mut s, "SketchStop", json!({}));
run(&mut s, "sketch.finish", json!({}));
let len: f64 = pts.windows(2).map(|w| w[0].dist(w[1])).sum();
// A pipe along it: volume ≈ π r² × length.
let before = s.model.state().bodies.len();
run(&mut s, "PrimitivePipe", json!({"path_sketch": path_sketch, "path": [wid.clone()], "diameter": "2 mm", "operation": "new"}));
run(&mut s, "solid.pipe", json!({"path_sketch": path_sketch, "path": [wid.clone()], "diameter": "2 mm", "operation": "new"}));
let st = s.model.state();
assert_eq!(st.bodies.len(), before + 1);
let v = st.bodies.last().unwrap().mesh().measure().volume;
@@ -385,11 +385,11 @@ fn sweeps_and_pipes_follow_3d_sketch_curves() {
let t = (b - a).normalized().unwrap();
let x = t.cross(solvecraft_geom::Vec3::Z).normalized().unwrap();
let y = t.cross(x);
run(&mut s, "SketchCreate", json!({"plane": {"origin": [a.x, a.y, a.z], "x_dir": [x.x, x.y, x.z], "y_dir": [y.x, y.y, y.z]}}));
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [-1, -1], "p1": [1, 1]}));
run(&mut s, "sketch.create", json!({"plane": {"origin": [a.x, a.y, a.z], "x_dir": [x.x, x.y, x.z], "y_dir": [y.x, y.y, y.z]}}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-1, -1], "p1": [1, 1]}));
let prof = s.active_sketch.unwrap();
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Sweep", json!({"sketch": prof, "path_sketch": path_sketch, "path": [wid], "operation": "new"}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.sweep", json!({"sketch": prof, "path_sketch": path_sketch, "path": [wid], "operation": "new"}));
let st = s.model.state();
assert_eq!(st.bodies.len(), before + 2);
let v = st.bodies.last().unwrap().mesh().measure().volume;
+9 -9
View File
@@ -19,11 +19,11 @@ fn ids(v: &Value) -> Vec<String> {
/// A new sketch with one rectangle of the given kind; returns its four side ids.
fn rect(kind: &str) -> (Session, Vec<String>) {
let mut s = Session::default();
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let r = match kind {
"center" => run(&mut s, "ShapeRectangleCenter", json!({"center": [3, 2], "corner": [13, 9]})),
"two" => run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [1, 1], "p1": [21, 15]})),
_ => run(&mut s, "ShapeRectangleThreePoint", json!({"p0": [0, 0], "p1": [20, 5], "p2": [18, 15]})),
"center" => run(&mut s, "sketch.rectangle.center", json!({"center": [3, 2], "corner": [13, 9]})),
"two" => run(&mut s, "sketch.rectangle.two_point", json!({"p0": [1, 1], "p1": [21, 15]})),
_ => run(&mut s, "sketch.rectangle.three_point", json!({"p0": [0, 0], "p1": [20, 5], "p2": [18, 15]})),
};
let sides = ids(&r["curves"]);
assert_eq!(sides.len(), 4, "{r}");
@@ -54,9 +54,9 @@ fn fresh_rectangles_have_the_expected_freedom() {
fn rectangles_take_dimensions_without_conflict() {
for k in KINDS {
let (mut s, l) = rect(k);
run(&mut s, "SketchDimension", json!({"entities": [l[0]], "value": 30}));
run(&mut s, "sketch.dimension", json!({"entities": [l[0]], "value": 30}));
healthy(&mut s, &format!("{k} width"));
run(&mut s, "SketchDimension", json!({"entities": [l[1]], "value": 12}));
run(&mut s, "sketch.dimension", json!({"entities": [l[1]], "value": 12}));
let dof = healthy(&mut s, &format!("{k} width+height"));
assert_eq!(dof, if k == "three" { 3 } else { 2 }, "{k}");
}
@@ -72,12 +72,12 @@ fn rectangles_can_be_dragged_pinned_and_made_square() {
run(&mut s, "sketch.move_point", json!({"point": format!("{}.end", l[2]), "to": [25, 22]}));
healthy(&mut s, &format!("{k} drag other corner"));
// Pin a corner to the origin.
run(&mut s, "ConstraintCoincident", json!({"a": format!("{}.start", l[0]), "b": "origin"}));
run(&mut s, "sketch.constraint.coincident", json!({"a": format!("{}.start", l[0]), "b": "origin"}));
healthy(&mut s, &format!("{k} corner on origin"));
// Square: equal sides.
run(&mut s, "ConstraintEqual", json!({"a": l[0], "b": l[1]}));
run(&mut s, "sketch.constraint.equal", json!({"a": l[0], "b": l[1]}));
healthy(&mut s, &format!("{k} square"));
run(&mut s, "SketchDimension", json!({"entities": [l[0]], "value": 10}));
run(&mut s, "sketch.dimension", json!({"entities": [l[0]], "value": 10}));
let dof = healthy(&mut s, &format!("{k} square sized"));
assert_eq!(dof, if k == "three" { 1 } else { 0 }, "{k}");
}
+102 -102
View File
@@ -20,7 +20,7 @@ fn sketch(s: &Session) -> Sketch {
fn new_sketch() -> Session {
let mut s = Session::default();
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
s
}
@@ -30,11 +30,11 @@ fn new_sketch() -> Session {
#[test]
fn every_dimension_kind_has_a_drawable_frame() {
let mut s = new_sketch();
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [60, 40]}));
run(&mut s, "CircleCenterRadius", json!({"center": [30, 20], "radius": 8}));
run(&mut s, "ArcCenterTwoPoint", json!({"center": [100, 0], "start": [110, 0], "end": [100, 10]}));
run(&mut s, "DrawPolyline", json!({"points": [[-40, 0], [-10, 0]]}));
run(&mut s, "DrawPolyline", json!({"points": [[-40, 0], [-20, 25]]}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [60, 40]}));
run(&mut s, "sketch.circle.center", json!({"center": [30, 20], "radius": 8}));
run(&mut s, "sketch.arc.center_point", json!({"center": [100, 0], "start": [110, 0], "end": [100, 10]}));
run(&mut s, "sketch.line", json!({"points": [[-40, 0], [-10, 0]]}));
run(&mut s, "sketch.line", json!({"points": [[-40, 0], [-20, 25]]}));
for (ents, ty) in [
(json!(["l1"]), "auto"),
(json!(["l2"]), "vertical"),
@@ -44,9 +44,9 @@ fn every_dimension_kind_has_a_drawable_frame() {
(json!(["a1"]), "arc_length"),
(json!(["l5", "l6"]), "angle"),
] {
run(&mut s, "SketchDimension", json!({"entities": ents, "type": ty}));
run(&mut s, "sketch.dimension", json!({"entities": ents, "type": ty}));
}
run(&mut s, "SketchDimension", json!({"entities": ["c1.center", "l2"], "driven": true}));
run(&mut s, "sketch.dimension", json!({"entities": ["c1.center", "l2"], "driven": true}));
let sk = sketch(&s);
let dims: Vec<_> = sk.constraints.iter().filter(|c| c.kind.is_dimension()).collect();
assert_eq!(dims.len(), 8);
@@ -71,8 +71,8 @@ fn every_dimension_kind_has_a_drawable_frame() {
#[test]
fn dimension_text_is_stored_relative_to_the_dimension_and_follows_it() {
let mut s = new_sketch();
run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [40, 0]]}));
let d = run(&mut s, "SketchDimension", json!({"entities": ["l1"], "text_at": [20, -8]}));
run(&mut s, "sketch.line", json!({"points": [[0, 0], [40, 0]]}));
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "text_at": [20, -8]}));
let param = d["param"].as_str().unwrap_or_default().to_string();
let text = |s: &Session| {
let sk = sketch(s);
@@ -102,8 +102,8 @@ fn dimension_text_is_stored_relative_to_the_dimension_and_follows_it() {
#[test]
fn deleting_a_dimension_frees_its_parameter() {
let mut s = new_sketch();
run(&mut s, "CircleCenterRadius", json!({"center": [0, 0], "radius": 5}));
let d = run(&mut s, "SketchDimension", json!({"entities": ["c1"]}));
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 5}));
let d = run(&mut s, "sketch.dimension", json!({"entities": ["c1"]}));
let param = d["param"].as_str().unwrap_or_default().to_string();
let id = sketch(&s).constraints.iter().find(|c| c.param.as_deref() == Some(param.as_str())).map(|c| c.id.clone()).expect("dim");
run(&mut s, "sketch.delete", json!({"entities": [id]}));
@@ -126,22 +126,22 @@ fn dof(s: &mut Session) -> i64 {
#[test]
fn fixed_points_survive_drags_and_solves_and_unfix_frees_them() {
let mut s = new_sketch();
run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [30, 0], [30, 20]]}));
run(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 0], [30, 20]]}));
let free = dof(&mut s);
let r = run(&mut s, "ConstraintFix", json!({"entity": "l1.end"}));
let r = run(&mut s, "sketch.constraint.fix", json!({"entity": "l1.end"}));
assert_eq!(r["result"]["fixed"], true);
assert_eq!(dof(&mut s), free - 2);
let corner = pt(&s, "l1.end");
// Dragging the fixed point does nothing; dragging its neighbours never moves it.
run(&mut s, "sketch.move_point", json!({"point": "l1.end", "to": [50, 50]}));
assert_eq!(pt(&s, "l1.end"), corner);
run(&mut s, "ConstraintHorizontalVertical", json!({"line": "l2", "mode": "vertical"}));
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l2", "mode": "vertical"}));
run(&mut s, "sketch.move_point", json!({"point": "l2.end", "to": [40, 25]}));
run(&mut s, "SketchDimension", json!({"entities": ["l1"], "value": 12}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 12}));
assert!(pt(&s, "l1.end").dist(corner) < 1e-12, "{:?}", pt(&s, "l1.end"));
assert!((pt(&s, "l1.start").dist(corner) - 12.0).abs() < 1e-6);
// Unfix: the point moves again.
let r = run(&mut s, "ConstraintFix", json!({"entity": "l1.end"}));
let r = run(&mut s, "sketch.constraint.fix", json!({"entity": "l1.end"}));
assert_eq!(r["result"]["fixed"], false);
run(&mut s, "sketch.move_point", json!({"point": "l1.end", "to": [30, -5]}));
assert!(pt(&s, "l1.end").dist(corner) > 1.0);
@@ -150,13 +150,13 @@ fn fixed_points_survive_drags_and_solves_and_unfix_frees_them() {
#[test]
fn fixing_curves_holds_their_points_and_radius() {
let mut s = new_sketch();
run(&mut s, "CircleCenterRadius", json!({"center": [10, 10], "radius": 5}));
run(&mut s, "DrawPolyline", json!({"points": [[30, 0], [50, 0]]}));
run(&mut s, "ArcCenterTwoPoint", json!({"center": [0, 40], "start": [10, 40], "end": [0, 50]}));
run(&mut s, "CircleElipse", json!({"center": [60, 40], "major": [70, 40], "minor_radius": 4}));
run(&mut s, "sketch.circle.center", json!({"center": [10, 10], "radius": 5}));
run(&mut s, "sketch.line", json!({"points": [[30, 0], [50, 0]]}));
run(&mut s, "sketch.arc.center_point", json!({"center": [0, 40], "start": [10, 40], "end": [0, 50]}));
run(&mut s, "sketch.ellipse", json!({"center": [60, 40], "major": [70, 40], "minor_radius": 4}));
let free = dof(&mut s);
// Multi-select fixes them all (circle 3, line 4, arc 5, ellipse 5 degrees of freedom).
let r = run(&mut s, "ConstraintFix", json!({"entities": ["c1", "l1", "a1", "e1"]}));
let r = run(&mut s, "sketch.constraint.fix", json!({"entities": ["c1", "l1", "a1", "e1"]}));
assert_eq!(r["result"]["changed"], 4);
assert_eq!(dof(&mut s), free - 17);
let si = run(&mut s, "sketch.inspect", json!({}));
@@ -164,18 +164,18 @@ fn fixing_curves_holds_their_points_and_radius() {
// A fixed circle keeps centre and radius; a dimension on it conflicts.
run(&mut s, "sketch.move_point", json!({"point": "c1.center", "to": [0, 0]}));
assert_eq!(pt(&s, "c1.center"), solvecraft_geom::Vec2::new(10.0, 10.0));
assert!(s.execute("SketchDimension", &json!({"entities": ["c1"], "value": 20})).is_err());
assert!(s.execute("sketch.dimension", &json!({"entities": ["c1"], "value": 20})).is_err());
let r0 = sketch(&s).radius(0);
assert_eq!(r0, Some(5.0));
// Toggle without `fixed`: all fixed, so all are unfixed.
let r = run(&mut s, "ConstraintFix", json!({"entities": ["c1", "l1", "a1", "e1"]}));
let r = run(&mut s, "sketch.constraint.fix", json!({"entities": ["c1", "l1", "a1", "e1"]}));
assert_eq!(r["result"]["fixed"], false);
assert_eq!(dof(&mut s), free);
run(&mut s, "SketchDimension", json!({"entities": ["c1"], "type": "radius", "value": 7}));
run(&mut s, "sketch.dimension", json!({"entities": ["c1"], "type": "radius", "value": 7}));
assert!((sketch(&s).radius(0).unwrap_or(0.0) - 7.0).abs() < 1e-9);
// Mixed selection: one fixed, one free → both end up fixed.
run(&mut s, "ConstraintFix", json!({"entity": "l1"}));
let r = run(&mut s, "ConstraintFix", json!({"entities": ["l1", "l1.start", "a1"]}));
run(&mut s, "sketch.constraint.fix", json!({"entity": "l1"}));
let r = run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1", "l1.start", "a1"]}));
assert_eq!(r["result"]["fixed"], true);
assert!(sketch(&s).curves.iter().filter(|c| c.id == "l1" || c.id == "a1").all(|c| c.fixed));
}
@@ -183,17 +183,17 @@ fn fixing_curves_holds_their_points_and_radius() {
#[test]
fn projected_geometry_counts_as_fixed() {
let mut s = Session::default();
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [20, 10]}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"distance": 5}));
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
let r = run(&mut s, "ProjectNewCmd", json!({"refs": [{"edge": [10, 0, 5]}]}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 5}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let r = run(&mut s, "sketch.project", json!({"refs": [{"edge": [10, 0, 5]}]}));
let sk = sketch(&s);
let ci = sk.curves.iter().position(|c| c.link.is_some()).unwrap_or_else(|| panic!("projected: {r}"));
let id = sk.curves[ci].id.clone();
assert!(sk.curve_locked(ci));
let r = run(&mut s, "ConstraintFix", json!({"entity": id}));
let r = run(&mut s, "sketch.constraint.fix", json!({"entity": id}));
// Already fixed: the toggle unfixes, which projected geometry ignores.
assert_eq!(r["result"]["changed"], 0);
assert!(sketch(&s).curve_locked(ci));
@@ -213,11 +213,11 @@ fn curve_ids(v: &Value) -> Vec<String> {
#[test]
fn construction_toggle_takes_shapes_out_of_profiles_and_back() {
let mut s = new_sketch();
let rect = curve_ids(&run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [20, 10]})));
let slot = curve_ids(&run(&mut s, "ShapeSlotCenterToCenter", json!({"p0": [40, 5], "p1": [60, 5], "width": 6})));
let poly = curve_ids(&run(&mut s, "ShapePolygonInscribed", json!({"center": [0, 40], "radius": 8, "sides": 6})));
let circ = curve_ids(&run(&mut s, "CircleCenterRadius", json!({"center": [40, 40], "radius": 5})));
let ell = curve_ids(&run(&mut s, "CircleElipse", json!({"center": [70, 40], "major": [80, 40], "minor_radius": 4})));
let rect = curve_ids(&run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]})));
let slot = curve_ids(&run(&mut s, "sketch.slot.center_to_center", json!({"p0": [40, 5], "p1": [60, 5], "width": 6})));
let poly = curve_ids(&run(&mut s, "sketch.polygon.inscribed", json!({"center": [0, 40], "radius": 8, "sides": 6})));
let circ = curve_ids(&run(&mut s, "sketch.circle.center", json!({"center": [40, 40], "radius": 5})));
let ell = curve_ids(&run(&mut s, "sketch.ellipse", json!({"center": [70, 40], "major": [80, 40], "minor_radius": 4})));
assert_eq!(profiles(&mut s), 5);
for (shape, left) in [(&rect, 4), (&slot, 3), (&poly, 2), (&circ, 1), (&ell, 0)] {
let r = run(&mut s, "sketch.construction", json!({ "curves": shape }));
@@ -240,12 +240,12 @@ fn construction_toggle_takes_shapes_out_of_profiles_and_back() {
#[test]
fn projected_geometry_can_become_construction() {
let mut s = Session::default();
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [20, 10]}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"distance": 5}));
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
run(&mut s, "ProjectNewCmd", json!({"refs": [{"edge": [10, 0, 5]}]}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 5}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.project", json!({"refs": [{"edge": [10, 0, 5]}]}));
let id = sketch(&s).curves.iter().find(|c| c.link.is_some()).map(|c| c.id.clone()).expect("projected");
let r = run(&mut s, "sketch.construction", json!({ "curves": [id.clone()] }));
assert_eq!(r["changed"], 1);
@@ -273,8 +273,8 @@ fn same(a: &(Vec<solvecraft_geom::Vec2>, Option<f64>), b: &(Vec<solvecraft_geom:
/// A free line l1 (5,3)–(45,3) and a circle c1 at (20,18) with radius 5.
fn line_and_circle() -> Session {
let mut s = new_sketch();
run(&mut s, "DrawPolyline", json!({"points": [[5, 3], [45, 3]]}));
run(&mut s, "CircleCenterRadius", json!({"center": [20, 18], "radius": 5}));
run(&mut s, "sketch.line", json!({"points": [[5, 3], [45, 3]]}));
run(&mut s, "sketch.circle.center", json!({"center": [20, 18], "radius": 5}));
s
}
@@ -291,40 +291,40 @@ fn tangent_moves_only_the_second_pick() {
// Line then circle: only the circle moves (and keeps its size).
let mut s = line_and_circle();
let l0 = snap(&s, "l1");
run(&mut s, "ConstraintTangent", json!({"a": "l1", "b": "c1"}));
run(&mut s, "sketch.constraint.tangent", json!({"a": "l1", "b": "c1"}));
assert!(same(&snap(&s, "l1"), &l0), "line moved: {:?}", snap(&s, "l1"));
let c = snap(&s, "c1");
assert!((c.1.unwrap_or(0.0) - 5.0).abs() < 1e-9 && (c.0[0].y - 8.0).abs() < 1e-6, "{c:?}");
// Circle then line: only the line moves.
check_order(line_and_circle(), "ConstraintTangent", "c1", "l1");
check_order(line_and_circle(), "sketch.constraint.tangent", "c1", "l1");
}
#[test]
fn a_fixed_second_pick_makes_the_first_move() {
let mut s = line_and_circle();
run(&mut s, "ConstraintFix", json!({"entity": "l1"}));
run(&mut s, "sketch.constraint.fix", json!({"entity": "l1"}));
let l0 = snap(&s, "l1");
let c0 = snap(&s, "c1");
run(&mut s, "ConstraintTangent", json!({"a": "c1", "b": "l1"}));
run(&mut s, "sketch.constraint.tangent", json!({"a": "c1", "b": "l1"}));
assert!(same(&snap(&s, "l1"), &l0));
assert!(!same(&snap(&s, "c1"), &c0));
// Fixed first: the second moves as usual.
let mut s = line_and_circle();
run(&mut s, "ConstraintFix", json!({"entity": "c1"}));
run(&mut s, "sketch.constraint.fix", json!({"entity": "c1"}));
let c0 = snap(&s, "c1");
run(&mut s, "ConstraintTangent", json!({"a": "c1", "b": "l1"}));
run(&mut s, "sketch.constraint.tangent", json!({"a": "c1", "b": "l1"}));
assert!(same(&snap(&s, "c1"), &c0));
}
#[test]
fn two_fully_constrained_entities_conflict_and_nothing_moves() {
let mut s = line_and_circle();
run(&mut s, "ConstraintFix", json!({"entities": ["l1.start", "l1.end", "c1.center"]}));
run(&mut s, "SketchDimension", json!({"entities": ["c1"], "type": "radius", "value": 5}));
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start", "l1.end", "c1.center"]}));
run(&mut s, "sketch.dimension", json!({"entities": ["c1"], "type": "radius", "value": 5}));
let si = run(&mut s, "sketch.inspect", json!({}));
assert!(si["curves"].as_array().is_some_and(|c| c.iter().all(|c| c["fully_constrained"] == true)), "{si}");
let before = sketch(&s);
let e = s.execute("ConstraintTangent", &json!({"a": "l1", "b": "c1"}));
let e = s.execute("sketch.constraint.tangent", &json!({"a": "l1", "b": "c1"}));
assert!(e.is_err(), "{e:?}");
assert_eq!(sketch(&s), before);
}
@@ -333,22 +333,22 @@ fn two_fully_constrained_entities_conflict_and_nothing_moves() {
fn line_pairs_keep_the_first_line() {
let lines = || {
let mut s = new_sketch();
run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [30, 2]]}));
run(&mut s, "DrawPolyline", json!({"points": [[5, 20], [25, 35]]}));
run(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 2]]}));
run(&mut s, "sketch.line", json!({"points": [[5, 20], [25, 35]]}));
s
};
let len = |s: &Session, l: &str| {
let p = snap(s, l).0;
p[0].dist(p[1])
};
for cmd in ["ConstraintParallel", "ConstraintPerpendicular", "ConstraintEqual", "ConstraintCollinear"] {
for cmd in ["sketch.constraint.parallel", "sketch.constraint.perpendicular", "sketch.constraint.equal", "sketch.constraint.collinear"] {
check_order(lines(), cmd, "l1", "l2");
check_order(lines(), cmd, "l2", "l1");
// The second line turns and slides; only Equal changes its length.
let mut s = lines();
let l0 = len(&s, "l2");
run(&mut s, cmd, json!({"a": "l1", "b": "l2"}));
if cmd != "ConstraintEqual" {
if cmd != "sketch.constraint.equal" {
assert!((len(&s, "l2") - l0).abs() < 1e-6, "{cmd}: {l0} -> {}", len(&s, "l2"));
} else {
assert!((len(&s, "l2") - len(&s, "l1")).abs() < 1e-6);
@@ -360,17 +360,17 @@ fn line_pairs_keep_the_first_line() {
fn circle_pairs_keep_the_first_circle() {
let circles = || {
let mut s = new_sketch();
run(&mut s, "CircleCenterRadius", json!({"center": [0, 0], "radius": 5}));
run(&mut s, "CircleCenterRadius", json!({"center": [30, 10], "radius": 8}));
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 5}));
run(&mut s, "sketch.circle.center", json!({"center": [30, 10], "radius": 8}));
s
};
for cmd in ["ConstraintConcentric", "ConstraintEqual", "ConstraintTangent"] {
for cmd in ["sketch.constraint.concentric", "sketch.constraint.equal", "sketch.constraint.tangent"] {
check_order(circles(), cmd, "c1", "c2");
check_order(circles(), cmd, "c2", "c1");
}
// Equal resizes the second to the first.
let mut s = circles();
run(&mut s, "ConstraintEqual", json!({"a": "c2", "b": "c1"}));
run(&mut s, "sketch.constraint.equal", json!({"a": "c2", "b": "c1"}));
assert!((snap(&s, "c1").1.unwrap_or(0.0) - 8.0).abs() < 1e-9);
}
@@ -378,25 +378,25 @@ fn circle_pairs_keep_the_first_circle() {
fn point_constraints_keep_the_first_pick() {
let pts = || {
let mut s = new_sketch();
run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [30, 0]]}));
run(&mut s, "DrawPolyline", json!({"points": [[10, 10], [20, 25]]}));
run(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 0]]}));
run(&mut s, "sketch.line", json!({"points": [[10, 10], [20, 25]]}));
s
};
check_order(pts(), "ConstraintCoincident", "l2.start", "l1.end");
check_order(pts(), "ConstraintCoincident", "l1.end", "l2.start");
check_order(pts(), "sketch.constraint.coincident", "l2.start", "l1.end");
check_order(pts(), "sketch.constraint.coincident", "l1.end", "l2.start");
// Curve picked first, point second: the point goes onto the line.
check_order(pts(), "ConstraintCoincident", "l1", "l2.start");
check_order(pts(), "sketch.constraint.coincident", "l1", "l2.start");
// Midpoint: the point stays, the line moves.
let mut s = pts();
let p0 = snap(&s, "l2.end");
run(&mut s, "ConstraintMidPoint", json!({"point": "l2.end", "line": "l1"}));
run(&mut s, "sketch.constraint.midpoint", json!({"point": "l2.end", "line": "l1"}));
assert!(same(&snap(&s, "l2.end"), &p0));
// Symmetry: the first point and the line stay, the second mirrors.
let mut s = pts();
run(&mut s, "DrawPoint", json!({"point": [3, 7]}));
run(&mut s, "DrawPoint", json!({"point": [4, -9]}));
run(&mut s, "sketch.point", json!({"point": [3, 7]}));
run(&mut s, "sketch.point", json!({"point": [4, -9]}));
let (a0, l0) = (snap(&s, "p1"), snap(&s, "l1"));
run(&mut s, "ConstraintSymmetry", json!({"a": "p1", "b": "p2", "line": "l1"}));
run(&mut s, "sketch.constraint.symmetry", json!({"a": "p1", "b": "p2", "line": "l1"}));
assert!(same(&snap(&s, "p1"), &a0) && same(&snap(&s, "l1"), &l0));
assert!(pt(&s, "p2").dist(solvecraft_geom::Vec2::new(3.0, -7.0)) < 1e-6, "{:?}", pt(&s, "p2"));
}
@@ -418,8 +418,8 @@ fn palette_options_are_kept_with_each_sketch() {
assert!(v.hide_dimensions && v.no_snap && v.slice && v.three_d && !v.hide_points);
// Another sketch starts with the defaults; the first keeps its options after finishing.
let first = s.active_sketch.expect("sketch");
run(&mut s, "SketchStop", json!({}));
run(&mut s, "SketchCreate", json!({"plane": "XZ"}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sketch.create", json!({"plane": "XZ"}));
assert_eq!(sketch(&s).view, solvecraft_sketch::SketchView::default());
let o = run(&mut s, "sketch.options", json!({"sketch": first}));
assert_eq!(o["options"]["show_dimensions"], false);
@@ -430,7 +430,7 @@ fn palette_options_are_kept_with_each_sketch() {
assert!(s.execute("sketch.options", &json!({"bogus": true})).is_err());
// Undo restores the option.
run(&mut s, "sketch.options", json!({"show_points": false}));
run(&mut s, "UndoCommand", json!({}));
run(&mut s, "edit.undo", json!({}));
assert!(!sketch(&s).view.hide_points);
}
@@ -448,7 +448,7 @@ fn v(x: f64, y: f64) -> solvecraft_geom::Vec2 {
#[test]
fn dragging_a_line_slides_it_and_an_end_keeps_the_other_end() {
let mut s = new_sketch();
run(&mut s, "DrawPolyline", json!({"points": [[10, 10], [40, 20]]}));
run(&mut s, "sketch.line", json!({"points": [[10, 10], [40, 20]]}));
drag(&mut s, "l1", [25.0, 15.0], [30.0, 25.0]);
assert!(pt(&s, "l1.start").dist(v(15.0, 20.0)) < 1e-9 && pt(&s, "l1.end").dist(v(45.0, 30.0)) < 1e-9);
drag(&mut s, "l1.end", [45.0, 30.0], [50.0, 0.0]);
@@ -459,7 +459,7 @@ fn dragging_a_line_slides_it_and_an_end_keeps_the_other_end() {
#[test]
fn dragging_a_rectangle_side_moves_that_side_only() {
let mut s = new_sketch();
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [10, 10], "p1": [40, 30]}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [10, 10], "p1": [40, 30]}));
// l2 is the right side (x = 40): dragged right, it stays vertical, the left side stays.
let left = snap(&s, "l4");
drag(&mut s, "l2", [40.0, 20.0], [50.0, 20.0]);
@@ -471,7 +471,7 @@ fn dragging_a_rectangle_side_moves_that_side_only() {
#[test]
fn dragging_circles_and_arcs() {
let mut s = new_sketch();
run(&mut s, "CircleCenterRadius", json!({"center": [0, 0], "radius": 5}));
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 5}));
// The edge changes the radius about a fixed centre; the centre moves the circle.
drag(&mut s, "c1", [5.0, 0.0], [0.0, 8.0]);
assert!((snap(&s, "c1").1.unwrap_or(0.0) - 8.0).abs() < 1e-9);
@@ -479,7 +479,7 @@ fn dragging_circles_and_arcs() {
drag(&mut s, "c1.center", [0.0, 0.0], [20.0, 5.0]);
assert!(pt(&s, "c1.center").dist(v(20.0, 5.0)) < 1e-9);
assert!((snap(&s, "c1").1.unwrap_or(0.0) - 8.0).abs() < 1e-9);
run(&mut s, "ArcCenterTwoPoint", json!({"center": [50, 0], "start": [60, 0], "end": [50, 10]}));
run(&mut s, "sketch.arc.center_point", json!({"center": [50, 0], "start": [60, 0], "end": [50, 10]}));
drag(&mut s, "a1", [57.07, 7.07], [50.0 + 200f64.sqrt(), 200f64.sqrt()]);
assert!(pt(&s, "a1.center").dist(v(50.0, 0.0)) < 1e-9);
assert!((snap(&s, "a1").1.unwrap_or(0.0) - 20.0).abs() < 1e-9, "{:?}", snap(&s, "a1"));
@@ -489,16 +489,16 @@ fn dragging_circles_and_arcs() {
#[test]
fn constrained_geometry_resists_drags() {
let mut s = new_sketch();
run(&mut s, "DrawPolyline", json!({"points": [[10, 10], [40, 10]]}));
run(&mut s, "ConstraintFix", json!({"entities": ["l1.start", "l1.end"]}));
run(&mut s, "sketch.line", json!({"points": [[10, 10], [40, 10]]}));
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start", "l1.end"]}));
let l0 = snap(&s, "l1");
drag(&mut s, "l1", [25.0, 10.0], [25.0, 30.0]);
assert!(same(&snap(&s, "l1"), &l0));
// A horizontal line dragged at its end stays horizontal; its length dimension holds.
let mut s = new_sketch();
run(&mut s, "DrawPolyline", json!({"points": [[10, 10], [40, 10]]}));
run(&mut s, "ConstraintHorizontalVertical", json!({"line": "l1"}));
run(&mut s, "SketchDimension", json!({"entities": ["l1"], "value": 30}));
run(&mut s, "sketch.line", json!({"points": [[10, 10], [40, 10]]}));
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l1"}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 30}));
drag(&mut s, "l1.end", [40.0, 10.0], [45.0, 20.0]);
let (a, b) = (pt(&s, "l1.start"), pt(&s, "l1.end"));
assert!((a.y - b.y).abs() < 1e-6 && (a.dist(b) - 30.0).abs() < 1e-6, "{a:?} {b:?}");
@@ -507,12 +507,12 @@ fn constrained_geometry_resists_drags() {
#[test]
fn fully_constrained_feedback_is_per_entity() {
let mut s = new_sketch();
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [10, 10], "p1": [50, 35]}));
run(&mut s, "CircleCenterRadius", json!({"center": [80, 20], "radius": 8}));
run(&mut s, "SketchDimension", json!({"entities": ["l1"], "value": 40}));
run(&mut s, "SketchDimension", json!({"entities": ["l2"], "value": 25}));
run(&mut s, "SketchDimension", json!({"entities": ["origin", "l1.start"], "type": "horizontal", "value": 10}));
run(&mut s, "SketchDimension", json!({"entities": ["origin", "l1.start"], "type": "vertical", "value": 10}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [10, 10], "p1": [50, 35]}));
run(&mut s, "sketch.circle.center", json!({"center": [80, 20], "radius": 8}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 40}));
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 25}));
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "l1.start"], "type": "horizontal", "value": 10}));
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "l1.start"], "type": "vertical", "value": 10}));
let si = run(&mut s, "sketch.inspect", json!({}));
assert_eq!(si["dof"], 3);
for c in si["curves"].as_array().expect("curves") {
@@ -533,10 +533,10 @@ fn fully_constrained_feedback_is_per_entity() {
#[test]
fn chain_follows_shared_and_coincident_ends() {
let mut s = new_sketch();
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [20, 10]}));
run(&mut s, "DrawPolyline", json!({"points": [[40, 0], [50, 0]]}));
run(&mut s, "DrawPolyline", json!({"points": [[50.5, 0], [60, 5]]}));
run(&mut s, "CircleCenterRadius", json!({"center": [80, 0], "radius": 3}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]}));
run(&mut s, "sketch.line", json!({"points": [[40, 0], [50, 0]]}));
run(&mut s, "sketch.line", json!({"points": [[50.5, 0], [60, 5]]}));
run(&mut s, "sketch.circle.center", json!({"center": [80, 0], "radius": 3}));
let chain = |s: &mut Session, c: &str| -> Vec<String> {
let v = run(s, "sketch.chain", json!({"curve": c}));
v["curves"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default()
@@ -545,7 +545,7 @@ fn chain_follows_shared_and_coincident_ends() {
assert_eq!(chain(&mut s, "l5"), ["l5"]);
assert_eq!(chain(&mut s, "c1"), ["c1"]);
// Joined by a coincident constraint, the two lines are one chain.
run(&mut s, "ConstraintCoincident", json!({"a": "l5.end", "b": "l6.start"}));
run(&mut s, "sketch.constraint.coincident", json!({"a": "l5.end", "b": "l6.start"}));
assert_eq!(chain(&mut s, "l6"), ["l6", "l5"]);
assert!(s.execute("sketch.chain", &json!({"curve": "nope"})).is_err());
}
@@ -553,9 +553,9 @@ fn chain_follows_shared_and_coincident_ends() {
#[test]
fn constraints_and_dimensions_can_be_selected_and_deleted_together() {
let mut s = new_sketch();
run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [30, 0]]}));
run(&mut s, "ConstraintHorizontalVertical", json!({"line": "l1"}));
run(&mut s, "SketchDimension", json!({"entities": ["l1"], "value": 30}));
run(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 0]]}));
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l1"}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 30}));
let ids: Vec<String> = sketch(&s).constraints.iter().map(|c| c.id.clone()).collect();
assert_eq!(ids.len(), 2);
let items: Vec<Value> = ids.iter().map(|id| json!({"type": "sketch_constraint", "id": id})).collect();
@@ -603,14 +603,14 @@ fn three_d_curves_need_the_option_and_move_joined() {
#[test]
fn pipe_and_sweep_follow_3d_sketch_curves() {
let mut s = Session::default();
run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "Path"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Path"}));
run(&mut s, "sketch.options", json!({"sketch_3d": true}));
let r = run(&mut s, "sketch.line3d", json!({"points": [[0, 0, 0], [0, 0, 30], [20, 0, 30]]}));
let mut path = curve_ids(&r);
let sp = run(&mut s, "sketch.spline3d", json!({"points": [[20, 0, 30], [35, 10, 35], [50, 0, 50]]}));
path.extend(curve_ids(&sp));
run(&mut s, "SketchStop", json!({}));
let p = run(&mut s, "PrimitivePipe", json!({"path_sketch": "Path", "path": path, "diameter": 4}));
run(&mut s, "sketch.finish", json!({}));
let p = run(&mut s, "solid.pipe", json!({"path_sketch": "Path", "path": path, "diameter": 4}));
let si = run(&mut s, "document.inspect", json!({"measure": true}));
let body = si["bodies"].as_array().and_then(|b| b.first()).cloned().unwrap_or_default();
// The tube runs from the first line's start to the spline's end (radius 2 around them; the
+21 -25
View File
@@ -10,30 +10,30 @@ use crate::params::{bad, bool_, expr, str_, string_list, vec3};
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("FusionSurfacePatchCommand", "Patch", patch)
CommandSpec::new("surface.patch", "Patch", patch)
.at("SURFACE", "CREATE")
.icon("patch")
.params("sketch?: id|name and profiles? (planar patches of sketch regions) | edges: [[x,y,z]] (points on a closed loop of a body's edges) and body?; name?, body_name?"),
CommandSpec::new("FusionSurfaceStitchCommand", "Stitch", stitch)
CommandSpec::new("surface.stitch", "Stitch", stitch)
.at("SURFACE", "MODIFY")
.icon("stitch")
.params("bodies: [names] (surfaces sewn into the first; a solid when they close); tolerance?: expr (default: tight)"),
CommandSpec::new("FusionSurfaceThickenCommand", "Thicken", thicken)
CommandSpec::new("surface.thicken", "Thicken", thicken)
.at("SURFACE", "CREATE")
.icon("thicken")
.params("bodies: [surface names]; thickness: expr (negative: against the normal); symmetric?: bool; operation?: new|join|cut|intersect; targets?"),
CommandSpec::new("FusionExtendCommand", "Extend", extend)
CommandSpec::new("surface.extend", "Extend", extend)
.at("SURFACE", "MODIFY")
.icon("extend")
.params("body: planar surface name; edges: [[x,y,z]] (points on straight edges to push out); distance: expr"),
CommandSpec::new("FusionSurfaceTrimCommand", "Trim", trim)
CommandSpec::new("surface.trim", "Trim", trim)
.at("SURFACE", "MODIFY")
.icon("trim")
.params("body: surface name; plane: XY|XZ|YZ|plane name|{origin, normal} or tool: {body, point} (a face, its surface extended); keep: [x,y,z] (a point on the side to keep)"),
];
fn patch(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionSurfacePatchCommand";
let cmd = "surface.patch";
let edges: Vec<solvecraft_geom::Vec3> = p.get("edges").and_then(Value::as_array).map(|a| a.iter().filter_map(vec3).collect()).unwrap_or_default();
if !edges.is_empty() {
if edges.len() > 10_000 {
@@ -60,7 +60,7 @@ fn bodies_param(s: &Session, p: &Value, cmd: &str) -> Result<Vec<String>> {
}
fn stitch(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionSurfaceStitchCommand";
let cmd = "surface.stitch";
let bodies = bodies_param(s, p, cmd)?;
let tolerance = expr(p, "tolerance").unwrap_or_else(|| "0".into());
check_expr(s, &tolerance, Kind::Length, cmd, "tolerance")?;
@@ -68,7 +68,7 @@ fn stitch(s: &mut Session, p: &Value) -> Result<Value> {
}
fn thicken(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionSurfaceThickenCommand";
let cmd = "surface.thicken";
let bodies = bodies_param(s, p, cmd)?;
let thickness = expr(p, "thickness").ok_or_else(|| bad(cmd, "`thickness` is required"))?;
check_expr(s, &thickness, Kind::Length, cmd, "thickness")?;
@@ -83,7 +83,7 @@ fn thicken(s: &mut Session, p: &Value) -> Result<Value> {
}
fn extend(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionExtendCommand";
let cmd = "surface.extend";
let body = str_(p, "body").ok_or_else(|| bad(cmd, "`body` is required"))?.to_string();
if s.model.state().body(&body).is_none() {
return Err(bad(cmd, format!("no body `{body}`")));
@@ -98,7 +98,7 @@ fn extend(s: &mut Session, p: &Value) -> Result<Value> {
}
fn trim(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionSurfaceTrimCommand";
let cmd = "surface.trim";
let body = str_(p, "body").ok_or_else(|| bad(cmd, "`body` is required"))?.to_string();
if s.model.state().body(&body).is_none() {
return Err(bad(cmd, format!("no body `{body}`")));
@@ -133,26 +133,22 @@ mod tests {
#[test]
fn patch_trim_thicken_and_stitch() {
let mut s = Session::default();
run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "S"}));
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [10, 10]}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "FusionSurfacePatchCommand", json!({"sketch": "S", "body_name": "Sheet"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "surface.patch", json!({"sketch": "S", "body_name": "Sheet"}));
let st = s.world_state();
assert!(st.body("Sheet").unwrap().body.is_surface());
run(&mut s, "FusionSurfaceTrimCommand", json!({"body": "Sheet", "plane": {"origin": [3, 0, 0], "normal": [1, 0, 0]}, "keep": [8, 5, 0]}));
run(&mut s, "FusionExtendCommand", json!({"body": "Sheet", "edges": [[10, 5, 0]], "distance": 5}));
run(&mut s, "FusionSurfaceThickenCommand", json!({"bodies": ["Sheet"], "thickness": "2 mm", "body_name": "Slab"}));
run(&mut s, "surface.trim", json!({"body": "Sheet", "plane": {"origin": [3, 0, 0], "normal": [1, 0, 0]}, "keep": [8, 5, 0]}));
run(&mut s, "surface.extend", json!({"body": "Sheet", "edges": [[10, 5, 0]], "distance": 5}));
run(&mut s, "surface.thicken", json!({"bodies": ["Sheet"], "thickness": "2 mm", "body_name": "Slab"}));
let v = volume(&s, "Slab");
assert!((v - 240.0).abs() < 1e-9, "{v}");
// Stitch the patch of a box's open top back on.
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "body_name": "Cup"}));
run(
&mut s,
"FusionSurfacePatchCommand",
json!({"edges": [[25, 0, 10], [30, 5, 10], [25, 10, 10], [20, 5, 10]], "body": "Cup", "body_name": "Lid"}),
);
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "body_name": "Cup"}));
run(&mut s, "surface.patch", json!({"edges": [[25, 0, 10], [30, 5, 10], [25, 10, 10], [20, 5, 10]], "body": "Cup", "body_name": "Lid"}));
assert!(s.world_state().body("Lid").unwrap().body.is_surface());
assert!(s.execute("FusionSurfaceThickenCommand", &json!({"bodies": ["Nope"], "thickness": 1})).is_err());
assert!(s.execute("FusionSurfaceTrimCommand", &json!({"body": "Sheet"})).is_err());
assert!(s.execute("surface.thicken", &json!({"bodies": ["Nope"], "thickness": 1})).is_err());
assert!(s.execute("surface.trim", &json!({"body": "Sheet"})).is_err());
}
}
+18 -20
View File
@@ -9,27 +9,27 @@ use crate::params::{bad, bool_, num, vec3};
use crate::{Result, Session, SurfaceAnalysis};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("FusionZebraAnalysisCommand", "Zebra Analysis", zebra)
CommandSpec::new("inspect.zebra", "Zebra Analysis", zebra)
.at("SKETCH", "INSPECT")
.icon("zebra")
.noundo()
.params("stripes?: number across the view (default 12); clear?: true turns the analysis off"),
CommandSpec::new("FusionDraftAnalysisCommand", "Draft Analysis", draft)
CommandSpec::new("inspect.draft", "Draft Analysis", draft)
.at("SKETCH", "INSPECT")
.icon("draft_analysis")
.noundo()
.params("pull?: [x,y,z] direction (default Z); angle?: degrees (default 1); clear?: true turns the analysis off"),
CommandSpec::new("FusionEnvironmentMapAnalysisCommand", "Environment Map Analysis", |s, p| set(s, p, SurfaceAnalysis::Environment))
CommandSpec::new("inspect.environment_map", "Environment Map Analysis", |s, p| set(s, p, SurfaceAnalysis::Environment))
.at("SKETCH", "INSPECT")
.icon("environment_map")
.noundo()
.params("clear?: true turns the analysis off"),
CommandSpec::new("FusionAccessibilityAnalysisCommand", "Accessibility Analysis", accessibility)
CommandSpec::new("inspect.accessibility", "Accessibility Analysis", accessibility)
.at("SKETCH", "INSPECT")
.icon("accessibility")
.noundo()
.params("direction?: [x,y,z] the tool comes from (default Z, from above); clear?: true turns the analysis off"),
CommandSpec::new("FusionCurvatureMapAnalysisCommand", "Curvature Map Analysis", curvature_map)
CommandSpec::new("inspect.curvature_map", "Curvature Map Analysis", curvature_map)
.at("SKETCH", "INSPECT")
.icon("curvature_map")
.noundo()
@@ -49,13 +49,13 @@ fn set(s: &mut Session, p: &Value, a: SurfaceAnalysis) -> Result<Value> {
fn zebra(s: &mut Session, p: &Value) -> Result<Value> {
let stripes = num(p, "stripes").unwrap_or(12.0);
if !(stripes.is_finite() && (1.0..=200.0).contains(&stripes)) {
return Err(bad("FusionZebraAnalysisCommand", "`stripes` must be 1…200"));
return Err(bad("inspect.zebra", "`stripes` must be 1…200"));
}
set(s, p, SurfaceAnalysis::Zebra { stripes })
}
fn draft(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionDraftAnalysisCommand";
let cmd = "inspect.draft";
let pull = match p.get("pull") {
Some(v) => vec3(v).and_then(Vec3::normalized).ok_or_else(|| bad(cmd, "`pull` must be a non-zero [x,y,z]"))?,
None => Vec3::Z,
@@ -69,16 +69,14 @@ fn draft(s: &mut Session, p: &Value) -> Result<Value> {
fn accessibility(s: &mut Session, p: &Value) -> Result<Value> {
let dir = match p.get("direction") {
Some(v) => {
vec3(v).and_then(Vec3::normalized).ok_or_else(|| bad("FusionAccessibilityAnalysisCommand", "`direction` must be a non-zero [x,y,z]"))?
}
Some(v) => vec3(v).and_then(Vec3::normalized).ok_or_else(|| bad("inspect.accessibility", "`direction` must be a non-zero [x,y,z]"))?,
None => Vec3::Z,
};
set(s, p, SurfaceAnalysis::Access { dir })
}
fn curvature_map(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionCurvatureMapAnalysisCommand";
let cmd = "inspect.curvature_map";
let radius = match num(p, "radius") {
Some(r) if r.is_finite() && r > 1e-6 && r < 1e9 => r,
Some(_) => return Err(bad(cmd, "`radius` must be positive")),
@@ -100,22 +98,22 @@ mod tests {
#[test]
fn surface_analyses_are_view_state() {
let mut s = Session::default();
s.execute("PrimitiveBox", &json!({"length": 10, "width": 10, "height": 10})).unwrap();
s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})).unwrap();
let undo = s.undo.len();
s.execute("FusionZebraAnalysisCommand", &json!({"stripes": 20})).unwrap();
s.execute("inspect.zebra", &json!({"stripes": 20})).unwrap();
assert_eq!(s.analysis, Some(SurfaceAnalysis::Zebra { stripes: 20.0 }));
s.execute("FusionDraftAnalysisCommand", &json!({"pull": [0, 0, 2], "angle": 3})).unwrap();
s.execute("inspect.draft", &json!({"pull": [0, 0, 2], "angle": 3})).unwrap();
assert!(matches!(s.analysis, Some(SurfaceAnalysis::Draft { angle, .. }) if angle == 3.0));
s.execute("FusionCurvatureMapAnalysisCommand", &json!({})).unwrap();
s.execute("inspect.curvature_map", &json!({})).unwrap();
assert!(matches!(s.analysis, Some(SurfaceAnalysis::Curvature { radius }) if (radius - 300f64.sqrt() / 10.0).abs() < 1e-6));
s.execute("FusionEnvironmentMapAnalysisCommand", &json!({})).unwrap();
s.execute("inspect.environment_map", &json!({})).unwrap();
assert_eq!(s.analysis, Some(SurfaceAnalysis::Environment));
s.execute("FusionAccessibilityAnalysisCommand", &json!({"direction": [0, 0, -3]})).unwrap();
s.execute("inspect.accessibility", &json!({"direction": [0, 0, -3]})).unwrap();
assert_eq!(s.analysis, Some(SurfaceAnalysis::Access { dir: solvecraft_geom::Vec3::new(0.0, 0.0, -1.0) }));
s.execute("FusionCurvatureMapAnalysisCommand", &json!({"clear": true})).unwrap();
s.execute("inspect.curvature_map", &json!({"clear": true})).unwrap();
assert_eq!(s.analysis, None);
assert_eq!(s.undo.len(), undo, "no undo steps");
assert!(s.execute("FusionZebraAnalysisCommand", &json!({"stripes": 0})).is_err());
assert!(s.execute("FusionDraftAnalysisCommand", &json!({"pull": [0, 0, 0]})).is_err());
assert!(s.execute("inspect.zebra", &json!({"stripes": 0})).is_err());
assert!(s.execute("inspect.draft", &json!({"pull": [0, 0, 0]})).is_err());
}
}