M0: engine command registry, file formats, view math and CPU renderer
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
a97af44979
commit
cdca1e59f3
17 files changed
+3515
-1
No files matched your search
@@ -0,0 +1,233 @@
|
||||
//! Undo/redo, parameters, the timeline, deletion and selection.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::{Feature, FeatureKind};
|
||||
|
||||
use super::CommandSpec;
|
||||
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("ChangeParameterCommand", "Change Parameters", change_param)
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("params")
|
||||
.params("name, expression (number or text), unit?: mm|deg|\"\", comment?; or delete: name"),
|
||||
CommandSpec::new("FusionComputeAllCommand", "Compute All", compute_all).at("SOLID", "MODIFY").icon("compute").noundo(),
|
||||
CommandSpec::new("FusionDeleteCommand", "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.rollback", "Move Timeline Marker", rollback).params("position: number of features to keep active (omit = end)"),
|
||||
CommandSpec::new("timeline.suppress", "Suppress Feature", suppress).params("feature: id|name, suppressed?: bool (default toggles)"),
|
||||
CommandSpec::new("timeline.edit", "Edit Feature", edit_feature)
|
||||
.params("feature: id|name, set: {fields to change, e.g. {\"extent\": {\"distance\": \"30\"}}}"),
|
||||
CommandSpec::new("select.set", "Select", select_set)
|
||||
.noundo()
|
||||
.params("items: [{type: body|edge|face|feature|sketch_curve|sketch_point|profile, …}], add?: bool"),
|
||||
CommandSpec::new("select.clear", "Clear Selection", select_clear).noundo(),
|
||||
];
|
||||
|
||||
fn undo(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let Some(prev) = s.undo.pop() else { return Err(EngineError::Other("nothing to undo".into())) };
|
||||
let label = prev.label.clone();
|
||||
s.redo.push(Snapshot { label: label.clone(), doc: s.doc.clone(), active_sketch: s.active_sketch });
|
||||
s.doc = prev.doc;
|
||||
s.active_sketch = prev.active_sketch;
|
||||
s.selection.clear();
|
||||
s.refresh();
|
||||
Ok(json!({"undone": label}))
|
||||
}
|
||||
|
||||
fn redo(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let Some(next) = s.redo.pop() else { return Err(EngineError::Other("nothing to redo".into())) };
|
||||
let label = next.label.clone();
|
||||
s.undo.push(Snapshot { label: label.clone(), doc: s.doc.clone(), active_sketch: s.active_sketch });
|
||||
s.doc = next.doc;
|
||||
s.active_sketch = next.active_sketch;
|
||||
s.selection.clear();
|
||||
s.refresh();
|
||||
Ok(json!({"redone": label}))
|
||||
}
|
||||
|
||||
fn change_param(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "ChangeParameterCommand";
|
||||
if let Some(n) = str_(p, "delete") {
|
||||
s.doc_mut().remove_param(n)?;
|
||||
return Ok(json!({"deleted": n}));
|
||||
}
|
||||
let name = str_(p, "name").ok_or_else(|| bad(cmd, "`name` is required"))?;
|
||||
let e = match p.get("expression").or_else(|| p.get("value")) {
|
||||
Some(Value::String(x)) => x.clone(),
|
||||
Some(Value::Number(n)) => n.to_string(),
|
||||
_ => return Err(bad(cmd, "`expression` must be a number or text")),
|
||||
};
|
||||
let unit = str_(p, "unit");
|
||||
let comment = str_(p, "comment");
|
||||
s.doc_mut().set_param(name, &e, unit, comment)?;
|
||||
s.refresh();
|
||||
let (vals, _) = s.doc.param_values();
|
||||
let v = vals.get(name).map(|v| v.v);
|
||||
// Report features that now fail.
|
||||
let errors: Vec<Value> = s.model.results.iter().filter_map(|r| r.error.as_ref().map(|e| json!({"feature": r.name, "error": e}))).collect();
|
||||
Ok(json!({"name": name, "expression": e, "value": v, "recomputed": s.model.last_recomputed, "errors": errors}))
|
||||
}
|
||||
|
||||
fn compute_all(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
s.model = solvecraft_doc::Model::new();
|
||||
s.refresh();
|
||||
let errors = s.model.results.iter().filter(|r| r.error.is_some()).count();
|
||||
Ok(json!({"features": s.model.results.len(), "errors": errors}))
|
||||
}
|
||||
|
||||
fn feature_id(s: &Session, v: Option<&Value>, cmd: &str) -> Result<u64> {
|
||||
let key = match v {
|
||||
Some(Value::Number(n)) => n.to_string(),
|
||||
Some(Value::String(x)) => x.clone(),
|
||||
_ => return Err(bad(cmd, "`feature` must be an id or name")),
|
||||
};
|
||||
s.doc.find_feature(&key).map(|f| f.id).ok_or_else(|| bad(cmd, format!("no feature `{key}`")))
|
||||
}
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "FusionDeleteCommand";
|
||||
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()));
|
||||
}
|
||||
if let Some(f) = p.get("feature") {
|
||||
keys.push(match f {
|
||||
Value::Number(n) => n.to_string(),
|
||||
Value::String(x) => x.clone(),
|
||||
_ => String::new(),
|
||||
});
|
||||
}
|
||||
if keys.is_empty() {
|
||||
return Err(bad(cmd, "`features` must list features"));
|
||||
}
|
||||
let mut gone = Vec::new();
|
||||
for k in keys {
|
||||
let Some(id) = s.doc.find_feature(&k).map(|f| f.id) else {
|
||||
if gone.is_empty() {
|
||||
return Err(bad(cmd, format!("no feature `{k}`")));
|
||||
}
|
||||
continue;
|
||||
};
|
||||
gone.extend(s.doc_mut().delete_feature(id)?);
|
||||
}
|
||||
if s.active_sketch.is_some_and(|a| gone.contains(&a)) {
|
||||
s.active_sketch = None;
|
||||
}
|
||||
s.selection.clear();
|
||||
Ok(json!({"deleted": gone}))
|
||||
}
|
||||
|
||||
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "FusionRenameTimelineEntryCommand";
|
||||
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"))?;
|
||||
if s.doc.features.iter().any(|f| f.name == name && f.id != id) {
|
||||
return Err(bad(cmd, format!("another feature is called `{name}`")));
|
||||
}
|
||||
if let Some(f) = s.doc_mut().feature_mut(id) {
|
||||
f.name = name.to_string();
|
||||
}
|
||||
Ok(json!({"feature": id, "name": name}))
|
||||
}
|
||||
|
||||
fn rollback(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let n = s.doc.features.len();
|
||||
let pos = num(p, "position").map(|x| x.max(0.0) as usize);
|
||||
s.doc_mut().marker = match pos {
|
||||
Some(k) if k < n => Some(k),
|
||||
_ => None,
|
||||
};
|
||||
s.selection.clear();
|
||||
Ok(json!({"marker": s.doc.marker.unwrap_or(n), "features": n}))
|
||||
}
|
||||
|
||||
fn suppress(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = feature_id(s, p.get("feature"), "timeline.suppress")?;
|
||||
let want = bool_(p, "suppressed");
|
||||
let f = s.doc_mut().feature_mut(id).ok_or_else(|| EngineError::Other("feature".into()))?;
|
||||
f.suppressed = want.unwrap_or(!f.suppressed);
|
||||
Ok(json!({"feature": id, "suppressed": f.suppressed}))
|
||||
}
|
||||
|
||||
fn merge(dst: &mut Value, src: &Value, depth: usize) {
|
||||
if depth > 16 {
|
||||
return;
|
||||
}
|
||||
match (dst, src) {
|
||||
(Value::Object(d), Value::Object(s)) => {
|
||||
for (k, v) in s {
|
||||
match d.get_mut(k) {
|
||||
Some(slot) if slot.is_object() && v.is_object() => merge(slot, v, depth + 1),
|
||||
_ => {
|
||||
d.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(d, s) => *d = s.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.edit";
|
||||
let id = feature_id(s, p.get("feature"), cmd)?;
|
||||
let set = p.get("set").filter(|v| v.is_object()).ok_or_else(|| bad(cmd, "`set` must be an object of fields"))?;
|
||||
let f = s.doc.feature(id).cloned().ok_or_else(|| bad(cmd, "feature"))?;
|
||||
let mut v = serde_json::to_value(&f).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
merge(&mut v, set, 0);
|
||||
let mut nf: Feature = serde_json::from_value(v).map_err(|e| bad(cmd, format!("invalid feature: {e}")))?;
|
||||
nf.id = f.id;
|
||||
if std::mem::discriminant(&nf.kind) != std::mem::discriminant(&f.kind) {
|
||||
return Err(bad(cmd, "the feature type cannot change"));
|
||||
}
|
||||
if let (FeatureKind::Sketch { sketch: a, .. }, FeatureKind::Sketch { sketch: b, .. }) = (&nf.kind, &f.kind)
|
||||
&& a != b
|
||||
{
|
||||
return Err(bad(cmd, "edit sketch geometry with the sketch commands"));
|
||||
}
|
||||
if let Some(slot) = s.doc_mut().feature_mut(id) {
|
||||
*slot = nf;
|
||||
}
|
||||
s.refresh();
|
||||
if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) {
|
||||
return Err(EngineError::Other(e));
|
||||
}
|
||||
Ok(json!({"feature": id, "recomputed": s.model.last_recomputed}))
|
||||
}
|
||||
|
||||
fn select_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let items = p.get("items").and_then(Value::as_array).cloned().unwrap_or_default();
|
||||
if items.len() > 10_000 {
|
||||
return Err(bad("select.set", "too many items"));
|
||||
}
|
||||
let mut sel: Vec<Sel> = Vec::new();
|
||||
for it in items {
|
||||
let one: Sel = serde_json::from_value(it).map_err(|e| bad("select.set", e.to_string()))?;
|
||||
sel.push(one);
|
||||
}
|
||||
if !bool_(p, "add").unwrap_or(false) {
|
||||
s.selection.clear();
|
||||
}
|
||||
for x in sel {
|
||||
if !s.selection.contains(&x) {
|
||||
s.selection.push(x);
|
||||
}
|
||||
}
|
||||
s.revision += 1;
|
||||
Ok(json!({"selected": s.selection.len()}))
|
||||
}
|
||||
|
||||
fn select_clear(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
s.selection.clear();
|
||||
s.revision += 1;
|
||||
Ok(json!({"selected": 0}))
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
//! Solid feature commands: extrude, revolve, fillet, chamfer, primitives, combine, move.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::{AxisRef, Direction, Extent, FeatureKind, Operation, ProfileSel, expr::Kind};
|
||||
use solvecraft_geom::Vec3;
|
||||
|
||||
use super::CommandSpec;
|
||||
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)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("extrude")
|
||||
.key("E")
|
||||
.params("distance: expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [[curve ids]] | [{point:[x,y]}]; direction?: positive|negative|symmetric; distance2?; start_offset?; operation?: new|join|cut|intersect; targets?: [body]; name?; body_name?"),
|
||||
CommandSpec::new("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?, operation?, targets?, name?, body_name?"),
|
||||
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)
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("fillet")
|
||||
.key("F")
|
||||
.params("edges: [[x,y,z] point on edge | {body, index}]; radius: expr; body?"),
|
||||
CommandSpec::new("FusionChamferCommand", "Chamfer", chamfer).at("SOLID", "MODIFY").icon("chamfer").params("edges: [[x,y,z] | {body, index}]; distance: expr; 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("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"),
|
||||
];
|
||||
|
||||
fn operation(p: &Value, cmd: &str) -> Result<Operation> {
|
||||
match str_(p, "operation") {
|
||||
None => Ok(Operation::NewBody),
|
||||
Some(o) => Operation::parse(o).ok_or_else(|| bad(cmd, format!("unknown operation `{o}` (new, join, cut, intersect)"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The sketch a feature uses: `sketch` param, the active sketch, or the last sketch.
|
||||
fn feature_sketch(s: &Session, p: &Value, cmd: &str) -> Result<u64> {
|
||||
if let Some(v) = p.get("sketch") {
|
||||
let key = match v {
|
||||
Value::Number(n) => n.to_string(),
|
||||
Value::String(x) => x.clone(),
|
||||
_ => return Err(bad(cmd, "`sketch` must be an id or name")),
|
||||
};
|
||||
let f = s.doc.find_feature(&key).ok_or_else(|| bad(cmd, format!("no sketch `{key}`")))?;
|
||||
return match f.kind {
|
||||
FeatureKind::Sketch { .. } => Ok(f.id),
|
||||
_ => Err(bad(cmd, format!("`{key}` is not a sketch"))),
|
||||
};
|
||||
}
|
||||
if let Some(a) = s.active_sketch {
|
||||
return Ok(a);
|
||||
}
|
||||
let limit = s.doc.marker.unwrap_or(usize::MAX);
|
||||
s.doc
|
||||
.features
|
||||
.iter()
|
||||
.take(limit)
|
||||
.rev()
|
||||
.find(|f| matches!(f.kind, FeatureKind::Sketch { .. }))
|
||||
.map(|f| f.id)
|
||||
.ok_or_else(|| bad(cmd, "there is no sketch to use (create one first)"))
|
||||
}
|
||||
|
||||
fn profiles(p: &Value, cmd: &str) -> Result<ProfileSel> {
|
||||
let Some(v) = p.get("profiles") else { return Ok(ProfileSel::All) };
|
||||
match v {
|
||||
Value::String(s) if s.eq_ignore_ascii_case("all") => Ok(ProfileSel::All),
|
||||
Value::Number(n) => Ok(ProfileSel::Indices { indices: vec![n.as_u64().ok_or_else(|| bad(cmd, "profile index"))? as usize] }),
|
||||
Value::Array(a) if a.is_empty() => Err(bad(cmd, "`profiles` is empty")),
|
||||
Value::Array(a) if a.len() > 10_000 => Err(bad(cmd, "too many profiles")),
|
||||
Value::Array(a) => {
|
||||
if a.iter().all(Value::is_u64) {
|
||||
return Ok(ProfileSel::Indices { indices: a.iter().filter_map(Value::as_u64).map(|x| x as usize).collect() });
|
||||
}
|
||||
if a.iter().all(|x| x.get("point").is_some()) {
|
||||
let pts =
|
||||
a.iter().map(|x| x.get("point").and_then(vec2).ok_or_else(|| bad(cmd, "profile point must be [x, y]"))).collect::<Result<_>>()?;
|
||||
return Ok(ProfileSel::Points { points: pts });
|
||||
}
|
||||
// [[ids]], [{curves: [...]}] or recipe-style [{loops: [{outer: true, curves: [...]}]}].
|
||||
let mut loops = Vec::new();
|
||||
for x in a {
|
||||
let ids: Vec<String> = match x {
|
||||
Value::Array(ids) => ids.iter().filter_map(|i| i.as_str().map(str::to_string)).collect(),
|
||||
Value::Object(o) => {
|
||||
if let Some(c) = o.get("curves") {
|
||||
string_list(&json!({"c": c}), "c")
|
||||
} else if let Some(ls) = o.get("loops").and_then(Value::as_array) {
|
||||
let outer = ls.iter().find(|l| l.get("outer").and_then(Value::as_bool).unwrap_or(true)).or(ls.first());
|
||||
outer.map(|l| string_list(l, "curves")).unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
if ids.is_empty() {
|
||||
return Err(bad(cmd, "each profile must be an index, a list of curve ids, {curves}, {loops} or {point}"));
|
||||
}
|
||||
loops.push(ids);
|
||||
}
|
||||
Ok(ProfileSel::Curves { loops })
|
||||
}
|
||||
_ => Err(bad(cmd, "`profiles` must be \"all\" or a list")),
|
||||
}
|
||||
}
|
||||
|
||||
fn check_expr(s: &Session, e: &str, kind: Kind, cmd: &str, what: &str) -> Result<()> {
|
||||
s.doc.eval(e, kind).map(|_| ()).map_err(|err| bad(cmd, format!("{what}: {err}")))
|
||||
}
|
||||
|
||||
/// Add a feature, evaluate, and report its result (an evaluation error fails the command).
|
||||
fn add_feature(s: &mut Session, p: &Value, kind: FeatureKind) -> Result<Value> {
|
||||
let name = str_(p, "name");
|
||||
let id = s.doc_mut().add_feature(kind, name)?;
|
||||
let mut names = string_list(p, "body_names");
|
||||
if let Some(b) = str_(p, "body_name") {
|
||||
names.insert(0, b.to_string());
|
||||
}
|
||||
if !names.is_empty()
|
||||
&& let Some(f) = s.doc_mut().feature_mut(id)
|
||||
{
|
||||
f.body_names = names;
|
||||
}
|
||||
s.active_sketch = None;
|
||||
s.refresh();
|
||||
let r = s.model.result(id).cloned();
|
||||
if let Some(e) = r.as_ref().and_then(|r| r.error.clone()) {
|
||||
return Err(EngineError::Other(e));
|
||||
}
|
||||
let st = s.model.state();
|
||||
let bodies: Vec<&str> = st.bodies.iter().map(|b| b.name.as_str()).collect();
|
||||
let fname = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
|
||||
Ok(json!({"feature": id, "name": fname, "bodies": bodies, "ms": r.map(|r| r.ms)}))
|
||||
}
|
||||
|
||||
fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "Extrude";
|
||||
let sketch = feature_sketch(s, p, cmd)?;
|
||||
let distance = req_expr(cmd, p, "distance")?;
|
||||
check_expr(s, &distance, Kind::Length, cmd, "distance")?;
|
||||
let direction = match str_(p, "direction").map(str::to_ascii_lowercase).as_deref() {
|
||||
None | Some("positive") | Some("one_side") => Direction::Positive,
|
||||
Some("negative") | Some("reverse") | Some("flip") => Direction::Negative,
|
||||
Some("symmetric") => Direction::Symmetric,
|
||||
Some(o) => return Err(bad(cmd, format!("unknown direction `{o}`"))),
|
||||
};
|
||||
let distance2 = expr(p, "distance2");
|
||||
if let Some(d) = &distance2 {
|
||||
check_expr(s, d, Kind::Length, cmd, "distance2")?;
|
||||
}
|
||||
let start_offset = expr(p, "start_offset");
|
||||
if let Some(d) = &start_offset {
|
||||
check_expr(s, d, Kind::Length, cmd, "start_offset")?;
|
||||
}
|
||||
let kind = FeatureKind::Extrude {
|
||||
sketch,
|
||||
profiles: profiles(p, cmd)?,
|
||||
extent: Extent { distance, direction, distance2, start_offset },
|
||||
operation: operation(p, cmd)?,
|
||||
targets: string_list(p, "targets"),
|
||||
};
|
||||
add_feature(s, p, kind)
|
||||
}
|
||||
|
||||
fn revolve(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "Revolve";
|
||||
let sketch = feature_sketch(s, p, cmd)?;
|
||||
let axis = match str_(p, "axis") {
|
||||
Some(a @ ("X" | "Y" | "Z")) => AxisRef::World { axis: a.into() },
|
||||
Some(a @ ("x" | "y")) => AxisRef::SketchAxis { axis: a.into() },
|
||||
Some(id) => AxisRef::SketchLine { curve: id.into() },
|
||||
None => return Err(bad(cmd, "`axis` must be a sketch line id, x, y (sketch axes) or X, Y, Z")),
|
||||
};
|
||||
let angle = expr(p, "angle").unwrap_or_else(|| "360 deg".into());
|
||||
check_expr(s, &angle, Kind::Angle, cmd, "angle")?;
|
||||
let kind =
|
||||
FeatureKind::Revolve { sketch, profiles: profiles(p, cmd)?, axis, angle, operation: operation(p, cmd)?, targets: string_list(p, "targets") };
|
||||
add_feature(s, p, kind)
|
||||
}
|
||||
|
||||
fn radius_expr(p: &Value, cmd: &str) -> Result<String> {
|
||||
match (expr(p, "radius"), expr(p, "diameter")) {
|
||||
(Some(r), _) => Ok(r),
|
||||
(None, Some(d)) => Ok(format!("({d}) / 2")),
|
||||
_ => Err(bad(cmd, "needs `radius` or `diameter`")),
|
||||
}
|
||||
}
|
||||
|
||||
fn prim_box(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "PrimitiveBox";
|
||||
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)?;
|
||||
}
|
||||
let corner = match (p.get("corner").and_then(vec3), p.get("center").and_then(vec3)) {
|
||||
(Some(c), _) => c,
|
||||
(None, Some(c)) => {
|
||||
let v = |e: &str| s.doc.eval(e, Kind::Length).unwrap_or(0.0);
|
||||
c - Vec3::new(v(&l), v(&w), v(&h)) * 0.5
|
||||
}
|
||||
_ => Vec3::ZERO,
|
||||
};
|
||||
add_feature(s, p, FeatureKind::Box { corner, length: l, width: w, height: h, operation: operation(p, cmd)? })
|
||||
}
|
||||
|
||||
fn prim_cylinder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "PrimitiveCylinder";
|
||||
let radius = radius_expr(p, cmd)?;
|
||||
let height = req_expr(cmd, p, "height")?;
|
||||
check_expr(s, &radius, Kind::Length, cmd, "radius")?;
|
||||
check_expr(s, &height, Kind::Length, cmd, "height")?;
|
||||
let base = p.get("base").and_then(vec3).unwrap_or(Vec3::ZERO);
|
||||
let axis = p.get("axis").and_then(vec3).unwrap_or(Vec3::Z);
|
||||
add_feature(s, p, FeatureKind::Cylinder { base, axis, radius, height, operation: operation(p, cmd)? })
|
||||
}
|
||||
|
||||
fn prim_sphere(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "PrimitiveSphere";
|
||||
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);
|
||||
add_feature(s, p, FeatureKind::Sphere { center, radius, operation: operation(p, cmd)? })
|
||||
}
|
||||
|
||||
fn prim_torus(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "PrimitiveTorus";
|
||||
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")?;
|
||||
let center = p.get("center").and_then(vec3).unwrap_or(Vec3::ZERO);
|
||||
add_feature(s, p, FeatureKind::Torus { center, major, minor, operation: operation(p, cmd)? })
|
||||
}
|
||||
|
||||
/// Edge references → points on the edges.
|
||||
fn edge_points(s: &Session, p: &Value, cmd: &str) -> Result<Vec<Vec3>> {
|
||||
let a = p.get("edges").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`edges` must be a list"))?;
|
||||
if a.is_empty() || a.len() > 1000 {
|
||||
return Err(bad(cmd, "select 1…1000 edges"));
|
||||
}
|
||||
let st = s.model.state();
|
||||
let mut out = Vec::new();
|
||||
for e in a {
|
||||
if let Some(pt) = vec3(e).or_else(|| e.get("point").and_then(vec3)) {
|
||||
out.push(pt);
|
||||
continue;
|
||||
}
|
||||
let body = e.get("body").and_then(Value::as_str).ok_or_else(|| bad(cmd, "an edge is a point [x,y,z], {point} or {body, index}"))?;
|
||||
let idx = e.get("index").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "edge `index` must be a number"))? as usize;
|
||||
let b = st.body(body).ok_or_else(|| bad(cmd, format!("no body `{body}`")))?;
|
||||
let tol = (b.body.size() * 2e-3).max(1e-3);
|
||||
let edges = b.body.edges(tol)?;
|
||||
out.push(edges.get(idx).ok_or_else(|| bad(cmd, format!("{body} has no edge {idx}")))?.mid);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "FusionFilletEdgesCommand";
|
||||
let edges = edge_points(s, p, cmd)?;
|
||||
let radius = req_expr(cmd, p, "radius")?;
|
||||
check_expr(s, &radius, Kind::Length, cmd, "radius")?;
|
||||
add_feature(s, p, FeatureKind::Fillet { edges, radius, body: str_(p, "body").map(str::to_string) })
|
||||
}
|
||||
|
||||
fn chamfer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "FusionChamferCommand";
|
||||
let edges = edge_points(s, p, cmd)?;
|
||||
let distance = req_expr(cmd, p, "distance")?;
|
||||
check_expr(s, &distance, Kind::Length, cmd, "distance")?;
|
||||
add_feature(s, p, FeatureKind::Chamfer { edges, distance, body: str_(p, "body").map(str::to_string) })
|
||||
}
|
||||
|
||||
fn combine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "FusionCombineCommand";
|
||||
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() {
|
||||
return Err(bad(cmd, "`tools` must list bodies"));
|
||||
}
|
||||
let op = match str_(p, "operation") {
|
||||
None => Operation::Join,
|
||||
Some(o) => Operation::parse(o).filter(|o| *o != Operation::NewBody).ok_or_else(|| bad(cmd, "operation must be join, cut or intersect"))?,
|
||||
};
|
||||
add_feature(s, p, FeatureKind::Combine { target, tools, operation: op, keep_tools: bool_(p, "keep_tools").unwrap_or(false) })
|
||||
}
|
||||
|
||||
fn move_bodies(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "FusionMoveCommand";
|
||||
let bodies = string_list(p, "bodies");
|
||||
if bodies.is_empty() {
|
||||
return Err(bad(cmd, "`bodies` must list bodies"));
|
||||
}
|
||||
let t = match p.get("translate") {
|
||||
Some(Value::Array(a)) if a.len() == 3 => {
|
||||
let mut out: [String; 3] = Default::default();
|
||||
for (i, v) in a.iter().enumerate() {
|
||||
let e = expr(&json!({"v": v}), "v").ok_or_else(|| bad(cmd, "translate entries must be numbers or expressions"))?;
|
||||
check_expr(s, &e, Kind::Length, cmd, "translate")?;
|
||||
if let Some(slot) = out.get_mut(i) {
|
||||
*slot = e;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
None => ["0".into(), "0".into(), "0".into()],
|
||||
_ => return Err(bad(cmd, "`translate` must be [x, y, z]")),
|
||||
};
|
||||
let angle = expr(p, "angle");
|
||||
if let Some(a) = &angle {
|
||||
check_expr(s, a, Kind::Angle, cmd, "angle")?;
|
||||
}
|
||||
add_feature(s, p, FeatureKind::Move { bodies, translate: t, rotate_axis: p.get("axis").and_then(vec3), angle })
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//! Documents and files: new, open, save, export.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::Document;
|
||||
use solvecraft_io::Format;
|
||||
|
||||
use super::CommandSpec;
|
||||
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("doc.open", "Open", open).icon("open").key("Ctrl+O").noundo().params("path: .solvecraft design"),
|
||||
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("ExportCommand", "Export", export)
|
||||
.icon("export")
|
||||
.noundo()
|
||||
.params("path; format?: stl|stla|obj|step (default from extension); bodies?: [names]"),
|
||||
CommandSpec::new("FusionSaveAsSTLCommand", "Save As Mesh", save_stl).icon("export").noundo().params("path; bodies?: [names]; ascii?: bool"),
|
||||
];
|
||||
|
||||
fn new_doc(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_(p, "name").unwrap_or("Untitled");
|
||||
*s = Session::new(Document::new(name));
|
||||
Ok(json!({"name": name}))
|
||||
}
|
||||
|
||||
fn path_arg<'a>(p: &'a Value, cmd: &str) -> Result<&'a str> {
|
||||
str_(p, "path").filter(|x| !x.trim().is_empty() && x.len() < 4096).ok_or_else(|| bad(cmd, "`path` is required"))
|
||||
}
|
||||
|
||||
fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = path_arg(p, "doc.open")?;
|
||||
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
if meta.len() as usize > solvecraft_io::MAX_DESIGN_BYTES {
|
||||
return Err(EngineError::Other(format!("{path}: file too large")));
|
||||
}
|
||||
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let doc = solvecraft_io::read_design(&bytes)?;
|
||||
*s = Session::new(doc);
|
||||
s.path = Some(path.to_string());
|
||||
let errors = s.model.results.iter().filter(|r| r.error.is_some()).count();
|
||||
Ok(json!({"path": path, "features": s.doc.features.len(), "errors": errors}))
|
||||
}
|
||||
|
||||
fn write(s: &mut Session, path: &str) -> Result<Value> {
|
||||
let bytes = solvecraft_io::write_design(&s.doc);
|
||||
std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
s.path = Some(path.to_string());
|
||||
s.mark_saved();
|
||||
Ok(json!({"path": path, "bytes": bytes.len()}))
|
||||
}
|
||||
|
||||
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`"))?,
|
||||
};
|
||||
write(s, &path)
|
||||
}
|
||||
|
||||
fn save_as(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = path_arg(p, "SaveDocumentAsCommand")?.to_string();
|
||||
write(s, &path)
|
||||
}
|
||||
|
||||
fn export_to(s: &Session, path: &str, format: Format, bodies: &[String]) -> Result<Value> {
|
||||
let name = std::path::Path::new(path).file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| s.doc.name.clone());
|
||||
let bytes = solvecraft_io::export(&s.model.state(), bodies, format, &name)?;
|
||||
std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
Ok(json!({"path": path, "bytes": bytes.len(), "format": format!("{format:?}")}))
|
||||
}
|
||||
|
||||
fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = path_arg(p, "ExportCommand")?;
|
||||
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 f = if bool_(p, "ascii").unwrap_or(false) { Format::StlAscii } else { Format::StlBinary };
|
||||
export_to(s, path, f, &string_list(p, "bodies"))
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
//! Inspection: measure bodies, describe the document, sketches and model topology.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_sketch::CurveKind;
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, bool_, str_, string_list};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("MeasureCommand", "Measure", measure)
|
||||
.at("SOLID", "INSPECT")
|
||||
.icon("measure")
|
||||
.key("I")
|
||||
.noundo()
|
||||
.params("bodies?: [names] (default all)"),
|
||||
CommandSpec::new("document.inspect", "Inspect Design", inspect).noundo().params("measure?: bool (include body measurements)"),
|
||||
CommandSpec::new("sketch.inspect", "Inspect Sketch", sketch_inspect).noundo().params("sketch?: id|name (default active)"),
|
||||
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("engine.commands", "List Commands", commands).noundo(),
|
||||
];
|
||||
|
||||
pub fn measure_json(b: &solvecraft_doc::ModelBody) -> Value {
|
||||
match solvecraft_kernel::measure(&b.body) {
|
||||
Ok(m) => json!({
|
||||
"name": b.name,
|
||||
"volume_mm3": m.volume,
|
||||
"area_mm2": m.area,
|
||||
"center_of_mass": m.centroid,
|
||||
"bbox": {"min": m.bbox.min, "max": m.bbox.max},
|
||||
"faces": m.merged.faces,
|
||||
"edges": m.merged.edges,
|
||||
"vertices": m.merged.vertices,
|
||||
"face_types": m.merged.face_types,
|
||||
"kernel": {"faces": m.faces, "edges": m.edges, "vertices": m.vertices, "shells": m.shells},
|
||||
}),
|
||||
Err(e) => json!({"name": b.name, "error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
|
||||
fn measure(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let want = string_list(p, "bodies");
|
||||
let st = s.model.state();
|
||||
let bodies: Vec<Value> = st.bodies.iter().filter(|b| want.is_empty() || want.contains(&b.name)).map(measure_json).collect();
|
||||
if !want.is_empty() && bodies.len() != want.len() {
|
||||
return Err(bad("MeasureCommand", "unknown body name"));
|
||||
}
|
||||
let tv: f64 = bodies.iter().filter_map(|b| b["volume_mm3"].as_f64()).sum();
|
||||
let ta: f64 = bodies.iter().filter_map(|b| b["area_mm2"].as_f64()).sum();
|
||||
let tf: u64 = bodies.iter().filter_map(|b| b["faces"].as_u64()).sum();
|
||||
let te: u64 = bodies.iter().filter_map(|b| b["edges"].as_u64()).sum();
|
||||
let tvx: u64 = bodies.iter().filter_map(|b| b["vertices"].as_u64()).sum();
|
||||
Ok(json!({"body_count": bodies.len(), "bodies": bodies, "total": {"volume_mm3": tv, "area_mm2": ta, "faces": tf, "edges": te, "vertices": tvx}}))
|
||||
}
|
||||
|
||||
fn sketch_json(s: &Session, id: u64) -> Option<Value> {
|
||||
let state = s.model.state();
|
||||
let ss = state.sketch(id)?;
|
||||
let sk = &ss.sketch;
|
||||
let curves: Vec<Value> = sk
|
||||
.curves
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, c)| {
|
||||
let det = ss.report.curve_determined.get(i).copied().unwrap_or(false);
|
||||
let pid = |k: usize| sk.points.get(k).map(|p| p.id.clone()).unwrap_or_default();
|
||||
match c.kind {
|
||||
CurveKind::Line { a, b } => json!({"id": c.id, "type": "line", "start": pid(a), "end": pid(b), "start_at": sk.point(a), "end_at": sk.point(b), "construction": c.construction, "fully_constrained": det}),
|
||||
CurveKind::Circle { c: cc, r } => json!({"id": c.id, "type": "circle", "center": pid(cc), "center_at": sk.point(cc), "radius": r, "construction": c.construction, "fully_constrained": det}),
|
||||
CurveKind::Arc { c: cc, a, b } => json!({"id": c.id, "type": "arc", "center": pid(cc), "start": pid(a), "end": pid(b), "center_at": sk.point(cc), "start_at": sk.point(a), "end_at": sk.point(b), "radius": sk.radius(i), "construction": c.construction, "fully_constrained": det}),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let points: Vec<Value> = sk
|
||||
.points
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, p)| json!({"id": p.id, "at": p.pos, "world": ss.plane.to_world(p.pos), "fixed": p.fixed, "fully_constrained": ss.report.point_determined.get(i).copied().unwrap_or(false)}))
|
||||
.collect();
|
||||
let constraints: Vec<Value> = sk
|
||||
.constraints
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let mut v = serde_json::to_value(&c.kind).unwrap_or_default();
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert("id".into(), json!(c.id));
|
||||
o.insert("name".into(), json!(c.kind.name()));
|
||||
if let Some(p) = &c.param {
|
||||
o.insert("param".into(), json!(p));
|
||||
o.insert("expression".into(), json!(s.doc.param(p).map(|x| x.expr.clone())));
|
||||
}
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
let profiles: Vec<Value> = ss
|
||||
.profiles
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, p)| json!({"index": i, "area_mm2": p.area, "centroid_sketch": p.centroid, "curves": p.outer_curves, "holes": p.hole_curves}))
|
||||
.collect();
|
||||
Some(json!({
|
||||
"id": id,
|
||||
"name": ss.name,
|
||||
"plane": ss.plane,
|
||||
"status": ss.report.status,
|
||||
"dof": ss.report.dof,
|
||||
"fully_constrained": ss.report.fully_constrained(),
|
||||
"failing": ss.report.failing,
|
||||
"curves": curves,
|
||||
"points": points,
|
||||
"constraints": constraints,
|
||||
"profiles": profiles,
|
||||
}))
|
||||
}
|
||||
|
||||
fn sketch_inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = match p.get("sketch") {
|
||||
Some(Value::Number(n)) => s.doc.find_feature(&n.to_string()).map(|f| f.id),
|
||||
Some(Value::String(x)) => s.doc.find_feature(x).map(|f| f.id),
|
||||
_ => s.active_sketch,
|
||||
}
|
||||
.ok_or_else(|| bad("sketch.inspect", "no such sketch (and none is active)"))?;
|
||||
sketch_json(s, id).ok_or_else(|| EngineError::Other("that sketch is not evaluated (rolled back or not a sketch)".into()))
|
||||
}
|
||||
|
||||
fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.model.state();
|
||||
let (vals, perr) = s.doc.param_values();
|
||||
let params: Vec<Value> = s
|
||||
.doc
|
||||
.params
|
||||
.iter()
|
||||
.map(|p| json!({"name": p.name, "expression": p.expr, "unit": p.unit, "value": vals.get(&p.name).map(|v| v.v), "model": p.model, "comment": p.comment, "error": perr.get(&p.name)}))
|
||||
.collect();
|
||||
let marker = s.doc.marker.unwrap_or(s.doc.features.len());
|
||||
let timeline: Vec<Value> = s
|
||||
.doc
|
||||
.features
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, f)| {
|
||||
let r = s.model.result(f.id);
|
||||
json!({
|
||||
"id": f.id, "name": f.name, "type": f.kind.type_name(), "suppressed": f.suppressed, "rolled_back": i >= marker,
|
||||
"error": r.and_then(|r| r.error.clone()), "warning": r.and_then(|r| r.warning.clone()), "ms": r.map(|r| r.ms),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let with_measure = bool_(p, "measure").unwrap_or(false);
|
||||
let bodies: Vec<Value> =
|
||||
st.bodies.iter().map(|b| if with_measure { measure_json(b) } else { json!({"name": b.name, "feature": b.feature}) }).collect();
|
||||
let sketches: Vec<Value> = st
|
||||
.sketches
|
||||
.iter()
|
||||
.map(|ss| json!({"id": ss.feature, "name": ss.name, "dof": ss.report.dof, "status": ss.report.status, "curves": ss.sketch.curves.len(), "profiles": ss.profiles.len()}))
|
||||
.collect();
|
||||
Ok(json!({
|
||||
"name": s.doc.name, "units": s.doc.units, "path": s.path, "dirty": s.is_dirty(), "revision": s.revision,
|
||||
"active_sketch": s.active_sketch, "marker": marker,
|
||||
"params": params, "timeline": timeline, "bodies": bodies, "sketches": sketches,
|
||||
"selection": s.selection, "undo": s.undo.len(), "redo": s.redo.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn body_of<'a>(st: &'a solvecraft_doc::ModelState, p: &Value, cmd: &str) -> Result<&'a solvecraft_doc::ModelBody> {
|
||||
let n = str_(p, "body").ok_or_else(|| bad(cmd, "`body` is required"))?;
|
||||
st.body(n).ok_or_else(|| bad(cmd, format!("no body `{n}`")))
|
||||
}
|
||||
|
||||
fn model_edges(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.model.state();
|
||||
let b = body_of(&st, p, "model.edges")?;
|
||||
let tol = (b.body.size() * 2e-3).max(1e-3);
|
||||
let edges: Vec<Value> = b
|
||||
.body
|
||||
.edges(tol)?
|
||||
.iter()
|
||||
.map(|e| json!({"index": e.index, "mid": e.mid, "length": e.length, "start": e.points.first(), "end": e.points.last()}))
|
||||
.collect();
|
||||
Ok(json!({"body": b.name, "edges": edges}))
|
||||
}
|
||||
|
||||
fn model_faces(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.model.state();
|
||||
let b = body_of(&st, p, "model.faces")?;
|
||||
let tol = (b.body.size() * 2e-3).max(1e-3);
|
||||
let faces: Vec<Value> = b
|
||||
.body
|
||||
.faces(tol)?
|
||||
.iter()
|
||||
.map(|f| json!({"index": f.index, "area": f.area, "centroid": f.centroid, "plane_normal": f.plane_normal}))
|
||||
.collect();
|
||||
Ok(json!({"body": b.name, "faces": faces}))
|
||||
}
|
||||
|
||||
fn commands(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
Ok(Value::Array(crate::command_specs().iter().map(|c| serde_json::to_value(c.info(s)).unwrap_or_default()).collect()))
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//! 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`).
|
||||
|
||||
mod edit;
|
||||
mod features;
|
||||
mod file;
|
||||
mod inspect;
|
||||
mod sketch;
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub type Run = fn(&mut Session, &Value) -> Result<Value>;
|
||||
pub type Enabled = fn(&Session) -> std::result::Result<(), String>;
|
||||
|
||||
pub struct CommandSpec {
|
||||
pub id: &'static str,
|
||||
pub label: &'static str,
|
||||
/// Toolbar tab (`SOLID`, `SKETCH`…); empty = not on the toolbar.
|
||||
pub tab: &'static str,
|
||||
/// Toolbar panel (`CREATE`, `MODIFY`…).
|
||||
pub panel: &'static str,
|
||||
/// Icon name for the UI (drawn in code).
|
||||
pub icon: &'static str,
|
||||
pub shortcut: Option<&'static str>,
|
||||
/// One-line parameter documentation.
|
||||
pub params: &'static str,
|
||||
pub enabled: Enabled,
|
||||
pub run: Run,
|
||||
pub undoable: bool,
|
||||
}
|
||||
|
||||
impl CommandSpec {
|
||||
pub const fn new(id: &'static str, label: &'static str, run: Run) -> Self {
|
||||
CommandSpec { id, label, tab: "", panel: "", icon: "", shortcut: None, params: "", enabled: always, run, undoable: true }
|
||||
}
|
||||
pub const fn at(mut self, tab: &'static str, panel: &'static str) -> Self {
|
||||
self.tab = tab;
|
||||
self.panel = panel;
|
||||
self
|
||||
}
|
||||
pub const fn icon(mut self, i: &'static str) -> Self {
|
||||
self.icon = i;
|
||||
self
|
||||
}
|
||||
pub const fn key(mut self, k: &'static str) -> Self {
|
||||
self.shortcut = Some(k);
|
||||
self
|
||||
}
|
||||
pub const fn params(mut self, p: &'static str) -> Self {
|
||||
self.params = p;
|
||||
self
|
||||
}
|
||||
pub const fn enabled(mut self, e: Enabled) -> Self {
|
||||
self.enabled = e;
|
||||
self
|
||||
}
|
||||
pub const fn noundo(mut self) -> Self {
|
||||
self.undoable = false;
|
||||
self
|
||||
}
|
||||
pub fn info(&self, s: &Session) -> CommandInfo {
|
||||
let e = (self.enabled)(s);
|
||||
CommandInfo {
|
||||
id: self.id,
|
||||
label: self.label,
|
||||
tab: self.tab,
|
||||
panel: self.panel,
|
||||
icon: self.icon,
|
||||
shortcut: self.shortcut,
|
||||
params: self.params,
|
||||
enabled: e.is_ok(),
|
||||
disabled_reason: e.err(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct CommandInfo {
|
||||
pub id: &'static str,
|
||||
pub label: &'static str,
|
||||
pub tab: &'static str,
|
||||
pub panel: &'static str,
|
||||
pub icon: &'static str,
|
||||
pub shortcut: Option<&'static str>,
|
||||
pub params: &'static str,
|
||||
pub enabled: bool,
|
||||
pub disabled_reason: Option<String>,
|
||||
}
|
||||
|
||||
pub fn always(_: &Session) -> std::result::Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn in_sketch(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.active_sketch.is_some() { Ok(()) } else { Err("no sketch is being edited (Create Sketch or Edit Sketch first)".into()) }
|
||||
}
|
||||
|
||||
/// Every command, in toolbar order.
|
||||
pub fn command_specs() -> Vec<&'static CommandSpec> {
|
||||
let mut v: Vec<&'static CommandSpec> = Vec::new();
|
||||
v.extend(sketch::COMMANDS.iter());
|
||||
v.extend(features::COMMANDS.iter());
|
||||
v.extend(edit::COMMANDS.iter());
|
||||
v.extend(file::COMMANDS.iter());
|
||||
v.extend(inspect::COMMANDS.iter());
|
||||
v
|
||||
}
|
||||
|
||||
pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
|
||||
let all = command_specs();
|
||||
all.iter().find(|c| c.id == id).or_else(|| all.iter().find(|c| c.id.eq_ignore_ascii_case(id))).copied()
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1 +1,256 @@
|
||||
//! The SolveCraft engine.
|
||||
//!
|
||||
//! Every user-visible action is a command with a stable id. Ids are Fusion's command ids where
|
||||
//! Fusion has the command (`Extrude`, `SketchCreate`, `FusionFilletEdgesCommand`…) and dotted
|
||||
//! lower-case ids otherwise (`timeline.rollback`, `document.inspect`). Commands take JSON
|
||||
//! parameters and never open dialogs; the UI, the command palette, scripts, the CLI and the
|
||||
//! control channel all run the same commands through [`Session::execute`].
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod cmd;
|
||||
pub mod params;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use solvecraft_doc::{Document, Model};
|
||||
use solvecraft_geom::Vec3;
|
||||
|
||||
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
|
||||
pub use solvecraft_doc as doc;
|
||||
pub use solvecraft_geom as geom;
|
||||
pub use solvecraft_io as io;
|
||||
pub use solvecraft_kernel as kernel;
|
||||
pub use solvecraft_sketch as sketch;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum EngineError {
|
||||
#[error("unknown command `{0}`")]
|
||||
UnknownCommand(String),
|
||||
#[error("`{0}` is not available right now: {1}")]
|
||||
Disabled(String, String),
|
||||
#[error("invalid parameters for `{cmd}`: {msg}")]
|
||||
BadParams { cmd: String, msg: String },
|
||||
#[error("{0}")]
|
||||
Other(String),
|
||||
/// A command panicked; the document was kept as it was (a bug: please report it).
|
||||
#[error("internal error in `{0}` (the design was kept as it was): {1}")]
|
||||
Internal(String, String),
|
||||
}
|
||||
|
||||
impl From<solvecraft_doc::DocError> for EngineError {
|
||||
fn from(e: solvecraft_doc::DocError) -> Self {
|
||||
EngineError::Other(e.to_string())
|
||||
}
|
||||
}
|
||||
impl From<solvecraft_sketch::SketchError> for EngineError {
|
||||
fn from(e: solvecraft_sketch::SketchError) -> Self {
|
||||
EngineError::Other(e.to_string())
|
||||
}
|
||||
}
|
||||
impl From<solvecraft_kernel::KernelError> for EngineError {
|
||||
fn from(e: solvecraft_kernel::KernelError) -> Self {
|
||||
EngineError::Other(e.to_string())
|
||||
}
|
||||
}
|
||||
impl From<solvecraft_io::IoError> for EngineError {
|
||||
fn from(e: solvecraft_io::IoError) -> Self {
|
||||
EngineError::Other(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, EngineError>;
|
||||
|
||||
/// Something selected in the model.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum Sel {
|
||||
Body {
|
||||
name: String,
|
||||
},
|
||||
/// An edge, identified by a point on it.
|
||||
Edge {
|
||||
body: String,
|
||||
index: usize,
|
||||
point: Vec3,
|
||||
},
|
||||
Face {
|
||||
body: String,
|
||||
index: usize,
|
||||
point: Vec3,
|
||||
},
|
||||
Feature {
|
||||
id: u64,
|
||||
},
|
||||
/// A sketch curve or point (by id) of the active sketch.
|
||||
SketchCurve {
|
||||
id: String,
|
||||
},
|
||||
SketchPoint {
|
||||
id: String,
|
||||
},
|
||||
/// A closed profile of a sketch (by index).
|
||||
Profile {
|
||||
sketch: u64,
|
||||
index: usize,
|
||||
},
|
||||
}
|
||||
|
||||
/// One undo step.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Snapshot {
|
||||
pub label: String,
|
||||
pub doc: Arc<Document>,
|
||||
pub active_sketch: Option<u64>,
|
||||
}
|
||||
|
||||
/// An editing session on one design.
|
||||
pub struct Session {
|
||||
pub doc: Arc<Document>,
|
||||
pub model: Model,
|
||||
pub undo: Vec<Snapshot>,
|
||||
pub redo: Vec<Snapshot>,
|
||||
/// Sketch feature being edited (sketch mode).
|
||||
pub active_sketch: Option<u64>,
|
||||
pub selection: Vec<Sel>,
|
||||
pub path: Option<String>,
|
||||
saved: Arc<Document>,
|
||||
/// Messages for the command line / log.
|
||||
pub log: Vec<String>,
|
||||
/// Increments on every document change (views use it to refresh).
|
||||
pub revision: u64,
|
||||
}
|
||||
|
||||
const MAX_UNDO: usize = 200;
|
||||
const MAX_LOG: usize = 2000;
|
||||
|
||||
impl Default for Session {
|
||||
fn default() -> Self {
|
||||
Session::new(Document::new("Untitled"))
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn new(doc: Document) -> Self {
|
||||
let doc = Arc::new(doc);
|
||||
let mut model = Model::new();
|
||||
model.evaluate(&doc);
|
||||
Session {
|
||||
saved: doc.clone(),
|
||||
doc,
|
||||
model,
|
||||
undo: Vec::new(),
|
||||
redo: Vec::new(),
|
||||
active_sketch: None,
|
||||
selection: Vec::new(),
|
||||
path: None,
|
||||
log: Vec::new(),
|
||||
revision: 1,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
!Arc::ptr_eq(&self.doc, &self.saved) && *self.doc != *self.saved
|
||||
}
|
||||
pub fn mark_saved(&mut self) {
|
||||
self.saved = self.doc.clone();
|
||||
}
|
||||
|
||||
pub fn echo(&mut self, s: impl Into<String>) {
|
||||
self.log.push(s.into());
|
||||
if self.log.len() > MAX_LOG {
|
||||
let n = self.log.len() - MAX_LOG;
|
||||
self.log.drain(..n);
|
||||
}
|
||||
}
|
||||
|
||||
/// Mutable document (copy-on-write).
|
||||
pub fn doc_mut(&mut self) -> &mut Document {
|
||||
Arc::make_mut(&mut self.doc)
|
||||
}
|
||||
|
||||
/// Re-evaluate the model after a document change.
|
||||
pub fn refresh(&mut self) {
|
||||
self.model.evaluate(&self.doc);
|
||||
self.revision += 1;
|
||||
if let Some(id) = self.active_sketch
|
||||
&& self.doc.feature(id).is_none()
|
||||
{
|
||||
self.active_sketch = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a command with JSON parameters (no dialogs). Undoable commands record an undo step
|
||||
/// when they change the document; a failing or panicking command leaves it unchanged.
|
||||
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
|
||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
|
||||
(spec.enabled)(self).map_err(|why| EngineError::Disabled(spec.id.to_string(), why))?;
|
||||
let before = Snapshot { label: spec.label.to_string(), doc: self.doc.clone(), active_sketch: self.active_sketch };
|
||||
let null = Value::Object(Default::default());
|
||||
let params = if params.is_null() { &null } else { params };
|
||||
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, params)));
|
||||
let r = match r {
|
||||
Ok(r) => r,
|
||||
Err(p) => {
|
||||
let msg = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default();
|
||||
Err(EngineError::Internal(spec.id.to_string(), msg))
|
||||
}
|
||||
};
|
||||
let changed = !Arc::ptr_eq(&self.doc, &before.doc);
|
||||
match r {
|
||||
Ok(v) => {
|
||||
if changed {
|
||||
if spec.undoable {
|
||||
self.undo.push(before);
|
||||
if self.undo.len() > MAX_UNDO {
|
||||
self.undo.remove(0);
|
||||
}
|
||||
self.redo.clear();
|
||||
}
|
||||
self.refresh();
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
Err(e) => {
|
||||
if changed {
|
||||
self.doc = before.doc;
|
||||
self.active_sketch = before.active_sketch;
|
||||
self.refresh();
|
||||
}
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a script: a JSON array of `{"command": id, "params": {...}}` (or an object with a
|
||||
/// `commands` array). Stops at the first error. Returns each command's result.
|
||||
pub fn run_script(&mut self, script: &Value) -> Result<Vec<Value>> {
|
||||
let list = match script {
|
||||
Value::Array(a) => a.clone(),
|
||||
Value::Object(o) => {
|
||||
o.get("commands").and_then(Value::as_array).cloned().ok_or_else(|| EngineError::Other("script: missing `commands` array".into()))?
|
||||
}
|
||||
_ => return Err(EngineError::Other("script must be a JSON array or {\"commands\": [...]}".into())),
|
||||
};
|
||||
if list.len() > 100_000 {
|
||||
return Err(EngineError::Other("script too long".into()));
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for (i, c) in list.iter().enumerate() {
|
||||
let id = c
|
||||
.get("command")
|
||||
.or_else(|| c.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| EngineError::Other(format!("script step {}: missing `command`", i + 1)))?;
|
||||
let p = c.get("params").cloned().unwrap_or(Value::Null);
|
||||
let r = self.execute(id, &p).map_err(|e| EngineError::Other(format!("step {} ({id}): {e}", i + 1)))?;
|
||||
out.push(r);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,79 @@
|
||||
//! Helpers to read hostile JSON parameters.
|
||||
|
||||
use serde_json::Value;
|
||||
use solvecraft_geom::{Vec2, Vec3};
|
||||
|
||||
use crate::{EngineError, Result};
|
||||
|
||||
pub fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
|
||||
EngineError::BadParams { cmd: cmd.to_string(), msg: msg.into() }
|
||||
}
|
||||
|
||||
pub fn finite(x: f64) -> Option<f64> {
|
||||
(x.is_finite() && x.abs() < 1e9).then_some(x)
|
||||
}
|
||||
|
||||
pub fn num(p: &Value, k: &str) -> Option<f64> {
|
||||
p.get(k).and_then(Value::as_f64).and_then(finite)
|
||||
}
|
||||
|
||||
pub fn str_<'a>(p: &'a Value, k: &str) -> Option<&'a str> {
|
||||
p.get(k).and_then(Value::as_str)
|
||||
}
|
||||
|
||||
pub fn bool_(p: &Value, k: &str) -> Option<bool> {
|
||||
p.get(k).and_then(Value::as_bool)
|
||||
}
|
||||
|
||||
pub fn vec2(v: &Value) -> Option<Vec2> {
|
||||
match v {
|
||||
Value::Array(a) if a.len() == 2 => Some(Vec2::new(finite(a.first()?.as_f64()?)?, finite(a.get(1)?.as_f64()?)?)),
|
||||
Value::Object(o) => Some(Vec2::new(finite(o.get("x")?.as_f64()?)?, finite(o.get("y")?.as_f64()?)?)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn vec3(v: &Value) -> Option<Vec3> {
|
||||
match v {
|
||||
Value::Array(a) if a.len() == 3 => Some(Vec3::new(finite(a.first()?.as_f64()?)?, finite(a.get(1)?.as_f64()?)?, finite(a.get(2)?.as_f64()?)?)),
|
||||
Value::Object(o) => Some(Vec3::new(finite(o.get("x")?.as_f64()?)?, finite(o.get("y")?.as_f64()?)?, finite(o.get("z")?.as_f64()?)?)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn req_vec2(cmd: &str, p: &Value, k: &str) -> Result<Vec2> {
|
||||
p.get(k).and_then(vec2).ok_or_else(|| bad(cmd, format!("`{k}` must be a point [x, y]")))
|
||||
}
|
||||
|
||||
pub fn req_vec3(cmd: &str, p: &Value, k: &str) -> Result<Vec3> {
|
||||
p.get(k).and_then(vec3).ok_or_else(|| bad(cmd, format!("`{k}` must be a point [x, y, z]")))
|
||||
}
|
||||
|
||||
pub fn vec2_list(cmd: &str, p: &Value, k: &str, max: usize) -> Result<Vec<Vec2>> {
|
||||
let a = p.get(k).and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("`{k}` must be a list of points")))?;
|
||||
if a.len() > max {
|
||||
return Err(bad(cmd, format!("`{k}` has too many points")));
|
||||
}
|
||||
a.iter().map(|v| vec2(v).ok_or_else(|| bad(cmd, format!("`{k}` must contain [x, y] points")))).collect()
|
||||
}
|
||||
|
||||
/// An expression parameter: a number (mm or degrees by context) or an expression string.
|
||||
pub fn expr(p: &Value, k: &str) -> Option<String> {
|
||||
match p.get(k)? {
|
||||
Value::Number(n) => n.as_f64().and_then(finite).map(|v| format!("{v}")),
|
||||
Value::String(s) if !s.trim().is_empty() && s.len() <= 4096 => Some(s.trim().to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn req_expr(cmd: &str, p: &Value, k: &str) -> Result<String> {
|
||||
expr(p, k).ok_or_else(|| bad(cmd, format!("`{k}` must be a number or an expression")))
|
||||
}
|
||||
|
||||
pub fn string_list(p: &Value, k: &str) -> Vec<String> {
|
||||
match p.get(k) {
|
||||
Some(Value::Array(a)) => a.iter().take(10_000).filter_map(|v| v.as_str().map(str::to_string)).collect(),
|
||||
Some(Value::String(s)) => vec![s.clone()],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::*;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{id} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn volume(s: &mut Session) -> f64 {
|
||||
let m = run(s, "MeasureCommand", json!({}));
|
||||
m["total"]["volume_mm3"].as_f64().unwrap()
|
||||
}
|
||||
|
||||
fn rel(a: f64, b: f64) -> f64 {
|
||||
(a - b).abs() / b.abs().max(1e-12)
|
||||
}
|
||||
|
||||
/// Sketch a 40 x 30 rectangle with dimensions, extrude 20, fillet a vertical edge, cut a hole.
|
||||
#[test]
|
||||
fn box_fillet_cut_part() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "ChangeParameterCommand", json!({"name": "width", "expression": "40 mm"}));
|
||||
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
|
||||
let r = run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [38, 29]}));
|
||||
let l: Vec<String> = r["curves"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect();
|
||||
let corner = s.doc.sketch(s.active_sketch.unwrap()).unwrap().curves[0].clone();
|
||||
let start = match corner.kind {
|
||||
solvecraft_sketch::CurveKind::Line { a, .. } => s.doc.sketch(s.active_sketch.unwrap()).unwrap().points[a].id.clone(),
|
||||
_ => panic!(),
|
||||
};
|
||||
run(&mut s, "ConstraintCoincident", json!({"a": start, "b": "origin"}));
|
||||
run(&mut s, "SketchDimension", json!({"entities": [l[0]], "value": "width"}));
|
||||
run(&mut s, "SketchDimension", json!({"entities": [l[1]], "value": 30}));
|
||||
let si = run(&mut s, "sketch.inspect", json!({}));
|
||||
assert_eq!(si["dof"], 0, "{si}");
|
||||
assert_eq!(si["profiles"].as_array().unwrap().len(), 1);
|
||||
run(&mut s, "SketchStop", json!({}));
|
||||
run(&mut s, "Extrude", json!({"distance": 20}));
|
||||
assert!(rel(volume(&mut s), 24000.0) < 1e-9);
|
||||
run(&mut s, "FusionFilletEdgesCommand", json!({"edges": [[0, 0, 10]], "radius": 3}));
|
||||
let filleted = 24000.0 - (9.0 - PI * 9.0 / 4.0) * 20.0;
|
||||
assert!(rel(volume(&mut s), filleted) < 2e-4);
|
||||
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
|
||||
run(&mut s, "CircleCenterRadius", json!({"center": [20, 15], "radius": 5}));
|
||||
run(&mut s, "Extrude", json!({"distance": 20, "operation": "cut"}));
|
||||
let expect = filleted - PI * 25.0 * 20.0;
|
||||
let v = volume(&mut s);
|
||||
assert!(rel(v, expect) < 5e-4, "{v} vs {expect}");
|
||||
let m = run(&mut s, "MeasureCommand", json!({}));
|
||||
assert_eq!(m["bodies"][0]["faces"], 8, "{m}");
|
||||
|
||||
// Parameter edit re-evaluates the timeline from the sketch.
|
||||
let r = run(&mut s, "ChangeParameterCommand", json!({"name": "width", "expression": "50"}));
|
||||
assert_eq!(r["errors"].as_array().unwrap().len(), 0, "{r}");
|
||||
let expect2 = 30000.0 - (9.0 - PI * 9.0 / 4.0) * 20.0 - PI * 25.0 * 20.0;
|
||||
assert!(rel(volume(&mut s), expect2) < 5e-4);
|
||||
|
||||
// Undo the parameter change, redo it.
|
||||
run(&mut s, "UndoCommand", json!({}));
|
||||
assert!(rel(volume(&mut s), expect) < 5e-4);
|
||||
run(&mut s, "RedoCommand", json!({}));
|
||||
assert!(rel(volume(&mut s), expect2) < 5e-4);
|
||||
|
||||
// Export STL and STEP.
|
||||
let dir = std::env::temp_dir().join(format!("solvecraft-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let stl = dir.join("part.stl");
|
||||
run(&mut s, "ExportCommand", json!({"path": stl.to_string_lossy()}));
|
||||
assert!(std::fs::metadata(&stl).unwrap().len() > 1000);
|
||||
let step = dir.join("part.step");
|
||||
run(&mut s, "ExportCommand", json!({"path": step.to_string_lossy()}));
|
||||
assert!(std::fs::read_to_string(&step).unwrap().contains("ISO-10303-21"));
|
||||
let design = dir.join("part.solvecraft");
|
||||
run(&mut s, "SaveDocumentAsCommand", json!({"path": design.to_string_lossy()}));
|
||||
let mut s2 = Session::default();
|
||||
run(&mut s2, "doc.open", json!({"path": design.to_string_lossy()}));
|
||||
assert!(rel(volume(&mut s2), expect2) < 5e-4);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revolve_and_primitives() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "SketchCreate", json!({"plane": "XZ"}));
|
||||
run(&mut s, "DrawPolyline", json!({"points": [[10, 0], [15, 0], [15, 5], [10, 5]], "closed": true}));
|
||||
run(&mut s, "Revolve", json!({"axis": "y"}));
|
||||
assert!(rel(volume(&mut s), PI * 125.0 * 5.0) < 5e-4);
|
||||
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "corner": [30, 0, 0]}));
|
||||
run(&mut s, "PrimitiveCylinder", json!({"diameter": 4, "height": 20, "base": [35, 5, -5]}));
|
||||
run(&mut s, "FusionCombineCommand", json!({"target": "Body2", "tools": ["Body3"], "operation": "cut"}));
|
||||
let m = run(&mut s, "MeasureCommand", json!({}));
|
||||
assert_eq!(m["body_count"], 2, "{m}");
|
||||
assert!(rel(m["bodies"][1]["volume_mm3"].as_f64().unwrap(), 1000.0 - PI * 4.0 * 10.0) < 5e-4);
|
||||
run(&mut s, "FusionChamferCommand", json!({"edges": [[30, 5, 10]], "distance": 1}));
|
||||
run(&mut s, "timeline.rollback", json!({"position": 3}));
|
||||
assert_eq!(run(&mut s, "MeasureCommand", json!({}))["body_count"], 2);
|
||||
run(&mut s, "timeline.rollback", json!({}));
|
||||
run(&mut s, "timeline.edit", json!({"feature": "Revolve1", "set": {"angle": "180 deg"}}));
|
||||
let m = run(&mut s, "MeasureCommand", json!({"bodies": ["Body1"]}));
|
||||
assert!(rel(m["total"]["volume_mm3"].as_f64().unwrap(), PI * 125.0 * 5.0 / 2.0) < 5e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sketch_tools_and_constraints() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "SketchCreate", json!({"plane": "YZ"}));
|
||||
run(&mut s, "ShapeRectangleCenter", json!({"center": [0, 0], "corner": [10, 5]}));
|
||||
run(&mut s, "ShapeRectangleThreePoint", json!({"p0": [20, 0], "p1": [30, 5], "p2": [28, 10]}));
|
||||
run(&mut s, "CircleTwoPoint", json!({"p0": [40, 0], "p1": [44, 0]}));
|
||||
run(&mut s, "CircleThreePoint", json!({"p0": [50, 0], "p1": [52, 2], "p2": [54, 0]}));
|
||||
run(&mut s, "ArcThreePoint", json!({"start": [60, 0], "end": [64, 0], "through": [62, 2]}));
|
||||
run(&mut s, "ArcCenterTwoPoint", json!({"center": [70, 0], "start": [72, 0], "end": [70, 2]}));
|
||||
run(&mut s, "ShapePolygonInscribed", json!({"center": [80, 0], "radius": 3, "sides": 6}));
|
||||
run(&mut s, "ShapePolygonCircumscribed", json!({"center": [90, 0], "radius": 3, "sides": 5}));
|
||||
run(&mut s, "ShapePolygonEdge", json!({"p0": [100, 0], "p1": [103, 0], "sides": 4}));
|
||||
run(&mut s, "ShapeSlotCenterToCenter", json!({"p0": [110, 0], "p1": [120, 0], "width": 4}));
|
||||
run(&mut s, "ShapeSlotOverall", json!({"p0": [130, 0], "p1": [140, 0], "width": 4}));
|
||||
run(&mut s, "DrawPoint", json!({"point": [150, 0]}));
|
||||
let r = run(&mut s, "DrawPolyline", json!({"points": [[0, 20], [10, 21], [20, 30]], "ids": ["m1", "m2"]}));
|
||||
assert_eq!(r["curves"], json!(["m1", "m2"]));
|
||||
run(&mut s, "ConstraintHorizontalVertical", json!({"line": "m1"}));
|
||||
run(&mut s, "ConstraintPerpendicular", json!({"a": "m1", "b": "m2"}));
|
||||
let _ = s.execute("SketchDimension", &json!({"entities": ["m1", "m2"], "type": "angle", "value": "90 deg"}));
|
||||
let si = run(&mut s, "sketch.inspect", json!({}));
|
||||
assert_eq!(si["status"], "solved", "{}", si["failing"]);
|
||||
// Over-constraining is rejected and leaves the sketch unchanged.
|
||||
let before = s.doc.clone();
|
||||
assert!(s.execute("ConstraintParallel", &json!({"a": "m1", "b": "m2"})).is_err());
|
||||
assert_eq!(*s.doc, *before);
|
||||
// Profiles: rectangles, circles, closed arcs/polygons/slots.
|
||||
let n = si["profiles"].as_array().unwrap().len();
|
||||
assert!(n >= 8, "{n} profiles");
|
||||
run(&mut s, "sketch.construction", json!({"curves": ["m1"]}));
|
||||
run(&mut s, "sketch.delete", json!({"entities": ["m2"]}));
|
||||
run(&mut s, "ConstraintFix", json!({"entity": "m1"}));
|
||||
run(&mut s, "sketch.move_point", json!({"point": "m1.end", "to": [15, 20]}));
|
||||
run(&mut s, "SketchStop", json!({}));
|
||||
assert!(s.execute("DrawPolyline", &json!({"points": [[0, 0], [1, 1]]})).is_err(), "needs an active sketch");
|
||||
}
|
||||
|
||||
/// The recipe format of the Fusion oracle (01-box) drives the same commands.
|
||||
#[test]
|
||||
fn script_runs_commands() {
|
||||
let mut s = Session::default();
|
||||
let script = json!({"commands": [
|
||||
{"command": "SketchCreate", "params": {"plane": "XY"}},
|
||||
{"command": "DrawPolyline", "params": {"points": [[0, 0], [40, 0], [40, 30], [0, 30]], "closed": true, "ids": ["l1", "l2", "l3", "l4"]}},
|
||||
{"command": "Extrude", "params": {"distance": 20, "profiles": [{"loops": [{"outer": true, "curves": ["l1", "l2", "l3", "l4"]}]}], "body_name": "Box"}},
|
||||
]});
|
||||
let out = s.run_script(&script).unwrap();
|
||||
assert_eq!(out.len(), 3);
|
||||
let m = run(&mut s, "MeasureCommand", json!({}));
|
||||
assert_eq!(m["bodies"][0]["name"], "Box");
|
||||
assert_eq!((m["total"]["faces"].as_u64(), m["total"]["edges"].as_u64(), m["total"]["vertices"].as_u64()), (Some(6), Some(12), Some(8)));
|
||||
assert!(s.run_script(&json!([{"command": "Nope"}])).is_err());
|
||||
}
|
||||
|
||||
fn hostile_values() -> Vec<Value> {
|
||||
vec![
|
||||
Value::Null,
|
||||
json!(true),
|
||||
json!(-1),
|
||||
json!(0),
|
||||
json!(1e308),
|
||||
json!(-1e308),
|
||||
json!(""),
|
||||
json!("nope"),
|
||||
json!("1/0"),
|
||||
json!("((((((((((1"),
|
||||
json!([]),
|
||||
json!([1e308, -1e308]),
|
||||
json!([0, 0, 0]),
|
||||
json!([[0, 0], [0, 0]]),
|
||||
json!(["origin", "origin"]),
|
||||
json!({}),
|
||||
json!({"x": 1}),
|
||||
json!([{"point": [0, 0]}]),
|
||||
json!([{"body": "Body1", "index": 999999}]),
|
||||
]
|
||||
}
|
||||
|
||||
/// Every command, every parameter name it documents, every hostile value: no panic escapes and
|
||||
/// no command panics internally.
|
||||
#[test]
|
||||
fn hostile_params_never_panic() {
|
||||
let keys = [
|
||||
"plane",
|
||||
"offset",
|
||||
"name",
|
||||
"sketch",
|
||||
"points",
|
||||
"closed",
|
||||
"construction",
|
||||
"ids",
|
||||
"infer",
|
||||
"p0",
|
||||
"p1",
|
||||
"p2",
|
||||
"center",
|
||||
"corner",
|
||||
"radius",
|
||||
"diameter",
|
||||
"start",
|
||||
"end",
|
||||
"through",
|
||||
"sides",
|
||||
"angle",
|
||||
"width",
|
||||
"point",
|
||||
"id",
|
||||
"entities",
|
||||
"type",
|
||||
"value",
|
||||
"line",
|
||||
"mode",
|
||||
"a",
|
||||
"b",
|
||||
"entity",
|
||||
"fixed",
|
||||
"curves",
|
||||
"to",
|
||||
"distance",
|
||||
"profiles",
|
||||
"direction",
|
||||
"distance2",
|
||||
"start_offset",
|
||||
"operation",
|
||||
"targets",
|
||||
"body_name",
|
||||
"axis",
|
||||
"length",
|
||||
"height",
|
||||
"base",
|
||||
"major",
|
||||
"minor",
|
||||
"edges",
|
||||
"body",
|
||||
"target",
|
||||
"tools",
|
||||
"keep_tools",
|
||||
"bodies",
|
||||
"translate",
|
||||
"expression",
|
||||
"unit",
|
||||
"comment",
|
||||
"delete",
|
||||
"features",
|
||||
"feature",
|
||||
"position",
|
||||
"suppressed",
|
||||
"set",
|
||||
"items",
|
||||
"add",
|
||||
"path",
|
||||
"format",
|
||||
"ascii",
|
||||
"measure",
|
||||
];
|
||||
let mut internal = Vec::new();
|
||||
for spec in command_specs() {
|
||||
if matches!(spec.id, "doc.open" | "SaveDocumentCommand" | "SaveDocumentAsCommand" | "ExportCommand" | "FusionSaveAsSTLCommand") {
|
||||
continue; // file system side effects are covered by their own tests
|
||||
}
|
||||
for v in hostile_values() {
|
||||
for k in keys {
|
||||
let mut s = Session::default();
|
||||
// A model with a sketch, a body and an active sketch, so most commands get far.
|
||||
let _ = s.execute("PrimitiveBox", &json!({"length": 10, "width": 10, "height": 10}));
|
||||
let _ = s.execute("SketchCreate", &json!({"plane": "XY"}));
|
||||
let _ = s.execute("ShapeRectangleTwoPoint", &json!({"p0": [0, 0], "p1": [5, 5]}));
|
||||
let p = json!({ k: v.clone() });
|
||||
if let Err(EngineError::Internal(id, msg)) = s.execute(spec.id, &p) {
|
||||
internal.push(format!("{id} {p}: {msg}"));
|
||||
}
|
||||
// Whole-value hostility too.
|
||||
if let Err(EngineError::Internal(id, msg)) = s.execute(spec.id, &v) {
|
||||
internal.push(format!("{id} {v}: {msg}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(internal.is_empty(), "commands panicked:\n{}", internal.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_is_consistent() {
|
||||
let specs = command_specs();
|
||||
let mut ids: Vec<&str> = specs.iter().map(|c| c.id).collect();
|
||||
ids.sort();
|
||||
let n = ids.len();
|
||||
ids.dedup();
|
||||
assert_eq!(ids.len(), n, "duplicate command ids");
|
||||
assert!(find_command("extrude").is_some(), "case-insensitive lookup");
|
||||
let s = Session::default();
|
||||
assert!(!find_command("DrawPolyline").unwrap().info(&s).enabled);
|
||||
}
|
||||
Reference in new issue
Block a user