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:
bherbruckandClaude Opus 5.5 committed 2026-10-07 17:30:37 +00:00
1 parent a97af44979
commit cdca1e59f3
17 files changed
+3515 -1

No files matched your search

+233
View File
@@ -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}))
}
+320
View File
@@ -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 })
}
+85
View File
@@ -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"))
}
+200
View File
@@ -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()))
}
+116
View File
@@ -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