Files
OneCAD/crates/engine/src/cmd/edit.rs
T
Jonas Wrede 54a8c807bf Sheet metal: sheet.perforate, a perforation field exported to the flat pattern and DXF (#732) (#1041)
Adds sheet.perforate: round holes on a staggered or square grid over the flat sheet, kept clear of edges, cut-outs and bend zones. The holes go to the flat pattern, the DXF and the measured volume rather than the B-rep, so a panel can carry many thousands of them.
2026-10-14 12:43:31 -04:00

1000 lines
43 KiB
Rust

//! Undo/redo, the timeline, deletion and selection.
use std::collections::BTreeMap;
use serde_json::{Value, json};
use solidcraft_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("edit.undo", "Undo", undo).icon("undo").key("Ctrl+Z").noundo().doc_only(),
CommandSpec::new("edit.redo", "Redo", redo).icon("redo").key("Ctrl+Y").noundo().doc_only(),
CommandSpec::new("edit.checkpoint", "Checkpoint", checkpoint)
.noundo()
.params("→ {checkpoint: id}: remember the design, active sketch and undo history (restore with edit.restore_checkpoint)"),
CommandSpec::new("edit.restore_checkpoint", "Restore Checkpoint", restore_checkpoint)
.noundo()
.params("checkpoint: id; forget?: bool (true = drop it without going back). Goes back to it, undo history included"),
CommandSpec::new("timeline.compute_all", "Compute All", compute_all).at("SOLID", "MODIFY").icon("compute").noundo(),
CommandSpec::new("timeline.delete", "Delete", delete)
.at("SOLID", "MODIFY")
.icon("delete")
.key("Delete")
.params("features: [id or name] (a sketch takes its dependent features with it) → deleted: [ids]; failing?: [{feature, error}] (later features that worked and now fail)"),
CommandSpec::new("timeline.rename", "Rename", rename).params("feature: id|name, name (references to it follow)"),
CommandSpec::new("timeline.group", "New Feature Folder", group)
.icon("folder")
.doc_only()
.params("name?; features?: [id|name]; parent?: folder id (features leave any other timeline folder)"),
CommandSpec::new("timeline.ungroup", "Remove Feature Folders", ungroup)
.doc_only()
.params("group: id | groups: [ids] (features remain in the timeline)"),
CommandSpec::new("timeline.rename_group", "Rename Feature Folder", rename_group).doc_only().params("group: id, name"),
CommandSpec::new("timeline.move_to_group", "Move Features to Folder", move_to_group)
.doc_only()
.params("features?: [id|name], groups?: [ids], group?: id (omit or null to move to the timeline root)"),
CommandSpec::new("timeline.move_many", "Move Timeline Items", move_many).params(
"features?: [id|name], groups?: [folder ids], group?: folder id, position: insertion slot; atomically changes folder membership and moves all selected contents as one block",
),
CommandSpec::new("timeline.rollback", "Move Timeline Marker", rollback).params("position: number of features to keep active (omit = end)"),
CommandSpec::new("timeline.roll_to", "Roll History Marker", rollback)
.params("position: number of features to keep active (omit = end) | feature: id|name (the marker goes right after it)"),
CommandSpec::new("timeline.reorder", "Reorder Feature", reorder)
.params("feature: id|name, position: its new index; refused when a feature that worked would fail"),
CommandSpec::new("timeline.reorder_many", "Reorder Features", reorder_many).params(
"features: [id|name], position: insertion slot in the current history; selected features keep their relative order; refused when a feature that worked would fail",
),
CommandSpec::new("timeline.redefine", "Edit Feature", redefine)
.params("feature: id|name, command: the feature's command id, params: its parameters (the feature is rebuilt in place)"),
CommandSpec::new("timeline.dependents", "Feature Dependents", dependents)
.noundo()
.params("feature: id|name → features deleted with it, and features that would fail without it"),
CommandSpec::new("timeline.suppress", "Suppress Feature", suppress).params("feature: id|name, suppressed?: bool (default toggles)"),
CommandSpec::new("timeline.edit", "Edit Feature", edit_feature).params(
"feature: id|name, set: {fields to change, merged into the feature's definition as timeline.inspect returns it, \
e.g. {\"extent\": {\"distance\": \"30\"}} (extrude), {\"angle\": \"90 deg\"} (revolve); \
expressions are text} → recomputed: features rebuilt, restored: features whose earlier result for the same inputs was reused",
),
CommandSpec::new("timeline.inspect", "Inspect Feature", inspect_feature).noundo().params(
"feature: id|name → type, definition (the fields timeline.edit's set changes, with their current values), \
inputs: [{label, param, expression, unit}] (named inputs parameters.change can also set), error, warning",
),
CommandSpec::new("select.set", "Select", select_set)
.noundo()
.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 now = go_to(s, prev);
let label = now.label.clone();
s.redo.push(now);
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 now = go_to(s, next);
let label = now.label.clone();
s.undo.push(now);
Ok(json!({"redone": label}))
}
/// Put the session at `snap`; returns where it was, under the same label.
fn go_to(s: &mut Session, snap: Snapshot) -> Snapshot {
let was = Snapshot { label: snap.label, doc: s.doc.clone(), active_sketch: s.active_sketch, pending_moves: s.pending_moves.clone() };
let same = std::sync::Arc::ptr_eq(&s.doc, &snap.doc);
s.doc = snap.doc;
s.active_sketch = snap.active_sketch;
s.pending_moves = snap.pending_moves;
s.selection.clear();
// A new document is evaluated by `execute`.
if same {
s.refresh();
}
was
}
fn checkpoint(s: &mut Session, _p: &Value) -> Result<Value> {
Ok(json!({"checkpoint": s.checkpoint()}))
}
fn restore_checkpoint(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "edit.restore_checkpoint";
let id = p.get("checkpoint").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "`checkpoint` must be an id from edit.checkpoint"))?;
if bool_(p, "forget") == Some(true) {
s.drop_checkpoint(id);
return Ok(json!({"checkpoint": id, "restored": false}));
}
s.restore_checkpoint(id)?;
Ok(json!({"checkpoint": id, "restored": true}))
}
fn compute_all(s: &mut Session, _p: &Value) -> Result<Value> {
s.model = solidcraft_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 = "timeline.delete";
let mut keys = string_list(p, "features");
if let Some(Value::Array(a)) = p.get("features") {
keys.extend(a.iter().filter_map(Value::as_u64).map(|n| n.to_string()));
}
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"));
}
// Features that work now; any of them failing after the delete is reported (a later
// feature that names a deleted body, #267).
let worked: std::collections::BTreeSet<u64> = s.model.results.iter().filter(|r| r.error.is_none() && !r.skipped).map(|r| r.id).collect();
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();
s.refresh();
let failing: Vec<Value> = s
.model
.results
.iter()
.filter(|r| worked.contains(&r.id))
.filter_map(|r| r.error.as_ref().map(|e| json!({"feature": r.name, "error": e})))
.collect();
if failing.is_empty() {
return Ok(json!({"deleted": gone}));
}
Ok(json!({"deleted": gone, "failing": failing}))
}
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "timeline.rename";
let id = feature_id(s, p.get("feature"), cmd)?;
let name =
str_(p, "name").map(str::trim).filter(|n| !n.is_empty() && n.len() <= 128).ok_or_else(|| bad(cmd, "`name` must be 1…128 characters"))?;
let old = s.doc.feature(id).map(|f| f.name.clone()).ok_or_else(|| bad(cmd, "feature"))?;
if s.doc.features.iter().any(|f| f.name == name && f.id != id) {
return Err(bad(cmd, format!("another feature is called `{name}`")));
}
if s.doc.feature(id).is_some_and(|f| matches!(f.kind, FeatureKind::ConstructionPlane { .. })) {
super::features::check_plane_name(name, cmd)?;
}
if name == old {
return Ok(json!({"feature": id, "name": name}));
}
let names = BTreeMap::from([(old.clone(), name.to_string())]);
let sketches = BTreeMap::new();
let doc = s.doc_mut();
if let Some(f) = doc.feature_mut(id) {
f.name = name.to_string();
}
for f in doc.features.iter_mut() {
f.kind.remap_refs(&sketches, &names);
}
Ok(json!({"feature": id, "name": name}))
}
fn timeline_group_id(s: &Session, p: &Value, cmd: &str) -> Result<u64> {
timeline_group_id_field(s, p, "group", cmd)
}
fn timeline_group_id_field(s: &Session, p: &Value, field: &str, cmd: &str) -> Result<u64> {
let id = p.get(field).and_then(Value::as_u64).ok_or_else(|| bad(cmd, format!("`{field}` must be a timeline folder id")))?;
if s.doc.timeline_groups.iter().any(|group| group.id == id) { Ok(id) } else { Err(bad(cmd, format!("no timeline folder {id}"))) }
}
fn group_features(s: &Session, p: &Value, required: bool, cmd: &str) -> Result<Vec<u64>> {
let values = match p.get("features") {
Some(Value::Array(values)) => values,
None if !required => return Ok(Vec::new()),
_ => return Err(bad(cmd, "`features` must be a list of feature ids or names")),
};
if values.is_empty() && required {
return Err(bad(cmd, "`features` must list at least one feature"));
}
if values.len() > 10_000 {
return Err(bad(cmd, "too many features"));
}
let mut ids = Vec::new();
for value in values {
let id = feature_id(s, Some(value), cmd)?;
if !ids.contains(&id) {
ids.push(id);
}
}
Ok(ids)
}
fn group_ids(s: &Session, p: &Value, required: bool, cmd: &str) -> Result<Vec<u64>> {
let values = match p.get("groups") {
Some(Value::Array(values)) => values,
None if !required => return Ok(Vec::new()),
None => return Err(bad(cmd, "`groups` must be a list of timeline folder ids")),
_ => return Err(bad(cmd, "`groups` must be a list of timeline folder ids")),
};
if values.is_empty() && required {
return Err(bad(cmd, "`groups` must list at least one folder"));
}
if values.len() > 10_000 {
return Err(bad(cmd, "too many folders"));
}
let mut ids = Vec::new();
for value in values {
let id = value.as_u64().ok_or_else(|| bad(cmd, "`groups` must contain folder ids"))?;
if !s.doc.timeline_groups.iter().any(|group| group.id == id) {
return Err(bad(cmd, format!("no timeline folder {id}")));
}
if !ids.contains(&id) {
ids.push(id);
}
}
Ok(ids)
}
fn group_move_would_cycle(s: &Session, target: u64, sources: &[u64]) -> bool {
let sources: std::collections::BTreeSet<u64> = sources.iter().copied().collect();
let parents: std::collections::BTreeMap<u64, Option<u64>> = s.doc.timeline_groups.iter().map(|group| (group.id, group.parent)).collect();
let mut current = Some(target);
let mut steps = 0usize;
while let Some(id) = current {
if sources.contains(&id) {
return true;
}
steps += 1;
if steps > s.doc.timeline_groups.len() {
return true;
}
current = parents.get(&id).copied().flatten();
}
false
}
fn group_feature_ids(doc: &solidcraft_doc::Document, roots: &[u64]) -> std::collections::BTreeSet<u64> {
let by_id: std::collections::BTreeMap<u64, &solidcraft_doc::TimelineGroup> = doc.timeline_groups.iter().map(|group| (group.id, group)).collect();
let mut children = std::collections::BTreeMap::<u64, Vec<u64>>::new();
for group in &doc.timeline_groups {
if let Some(parent) = group.parent {
children.entry(parent).or_default().push(group.id);
}
}
let mut pending = roots.to_vec();
let mut visited = std::collections::BTreeSet::new();
let mut features = std::collections::BTreeSet::new();
while let Some(id) = pending.pop() {
if !visited.insert(id) {
continue;
}
if let Some(group) = by_id.get(&id) {
features.extend(group.features.iter().copied());
pending.extend(children.get(&id).into_iter().flatten().copied());
}
}
features
}
fn sort_group_features(doc: &mut solidcraft_doc::Document) {
let positions: std::collections::BTreeMap<u64, usize> = doc.features.iter().enumerate().map(|(index, feature)| (feature.id, index)).collect();
for group in &mut doc.timeline_groups {
group.features.sort_by_key(|feature| positions.get(feature).copied().unwrap_or(usize::MAX));
}
}
fn require_contiguous_groups(doc: &solidcraft_doc::Document, cmd: &str) -> Result<()> {
let Some(id) = solidcraft_doc::non_contiguous_timeline_group(&doc.features, &doc.timeline_groups) else { return Ok(()) };
let name = doc.timeline_groups.iter().find(|group| group.id == id).map(|group| group.name.as_str()).unwrap_or("unknown");
Err(bad(cmd, format!("timeline folder `{name}` must contain one contiguous run of features")))
}
fn group(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "timeline.group";
let mut features = group_features(s, p, false, cmd)?;
let groups = group_ids(s, p, false, cmd)?;
let name = match str_(p, "name").map(str::trim).filter(|name| !name.is_empty()) {
Some(name) if name.len() <= 128 => name.to_string(),
Some(_) => return Err(bad(cmd, "`name` must be 1…128 characters")),
None => format!("Folder{}", s.doc.timeline_groups.len() + 1),
};
let id = s
.doc
.timeline_groups
.iter()
.map(|group| group.id)
.max()
.unwrap_or(0)
.checked_add(1)
.ok_or_else(|| bad(cmd, "no more timeline folder ids are available"))?;
let parent = match p.get("parent") {
Some(Value::Null) | None => None,
Some(_) => Some(timeline_group_id_field(s, p, "parent", cmd)?),
};
if let Some(parent) = parent
&& group_move_would_cycle(s, parent, &groups)
{
return Err(bad(cmd, "a new folder cannot contain an ancestor of its parent"));
}
let nested = group_feature_ids(&s.doc, &groups);
features.retain(|feature| !nested.contains(feature));
let mut next = (*s.doc).clone();
for group in &mut next.timeline_groups {
group.features.retain(|feature| !features.contains(feature));
if groups.contains(&group.id) {
group.parent = Some(id);
}
}
next.timeline_groups.push(solidcraft_doc::TimelineGroup { id, name: name.clone(), parent, features });
sort_group_features(&mut next);
require_contiguous_groups(&next, cmd)?;
*s.doc_mut() = next;
Ok(json!({"group": id, "name": name, "parent": parent, "groups": groups}))
}
fn ungroup(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "timeline.ungroup";
let ids = match p.get("groups") {
Some(Value::Array(values)) if !values.is_empty() && values.len() <= 10_000 => {
let mut ids = Vec::new();
for value in values {
let id = value.as_u64().ok_or_else(|| bad(cmd, "`groups` must contain timeline folder ids"))?;
if !s.doc.timeline_groups.iter().any(|group| group.id == id) {
return Err(bad(cmd, format!("no timeline folder {id}")));
}
if !ids.contains(&id) {
ids.push(id);
}
}
ids
}
Some(Value::Array(_)) => return Err(bad(cmd, "`groups` must list 1…10000 timeline folder ids")),
Some(_) => return Err(bad(cmd, "`groups` must be a list of timeline folder ids")),
None => vec![timeline_group_id(s, p, cmd)?],
};
let parents: std::collections::BTreeMap<u64, Option<u64>> =
s.doc.timeline_groups.iter().filter(|group| ids.contains(&group.id)).map(|group| (group.id, group.parent)).collect();
let surviving_parent = |mut parent: Option<u64>| {
let mut steps = 0usize;
while let Some(id) = parent.filter(|id| ids.contains(id)) {
parent = parents.get(&id).copied().flatten();
steps += 1;
if steps > parents.len() {
return None;
}
}
parent
};
let mut next = (*s.doc).clone();
let moved_features: Vec<(Option<u64>, Vec<u64>)> = next
.timeline_groups
.iter()
.filter(|group| ids.contains(&group.id))
.map(|group| (surviving_parent(group.parent), group.features.clone()))
.collect();
for group in &mut next.timeline_groups {
if ids.contains(&group.id) {
continue;
}
group.parent = surviving_parent(group.parent);
for (_, features) in moved_features.iter().filter(|(parent, _)| *parent == Some(group.id)) {
group.features.extend(features.iter().copied());
}
}
next.timeline_groups.retain(|group| !ids.contains(&group.id));
sort_group_features(&mut next);
require_contiguous_groups(&next, cmd)?;
*s.doc_mut() = next;
Ok(json!({"groups": ids}))
}
fn rename_group(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "timeline.rename_group";
let id = timeline_group_id(s, p, cmd)?;
let name = str_(p, "name")
.map(str::trim)
.filter(|name| !name.is_empty() && name.len() <= 128)
.ok_or_else(|| bad(cmd, "`name` must be 1…128 characters"))?;
if let Some(group) = s.doc_mut().timeline_groups.iter_mut().find(|group| group.id == id) {
group.name = name.to_string();
}
Ok(json!({"group": id, "name": name}))
}
fn move_to_group(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "timeline.move_to_group";
let features = group_features(s, p, false, cmd)?;
let groups = group_ids(s, p, false, cmd)?;
if features.is_empty() && groups.is_empty() {
return Err(bad(cmd, "list at least one feature or folder"));
}
let target = match p.get("group") {
Some(Value::Null) | None => None,
Some(_) => Some(timeline_group_id(s, p, cmd)?),
};
if let Some(target) = target
&& group_move_would_cycle(s, target, &groups)
{
return Err(bad(cmd, "a folder cannot be moved into itself or one of its descendants"));
}
let mut next = (*s.doc).clone();
for group in &mut next.timeline_groups {
group.features.retain(|feature| !features.contains(feature));
if Some(group.id) == target {
group.features.extend(features.iter().copied());
}
}
for group in &mut next.timeline_groups {
if groups.contains(&group.id) {
group.parent = target;
}
}
sort_group_features(&mut next);
require_contiguous_groups(&next, cmd)?;
*s.doc_mut() = next;
Ok(json!({"features": features, "groups": groups, "group": target}))
}
fn move_many(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "timeline.move_many";
let mut features = group_features(s, p, false, cmd)?;
let groups = group_ids(s, p, false, cmd)?;
if features.is_empty() && groups.is_empty() {
return Err(bad(cmd, "list at least one feature or folder"));
}
let target = match p.get("group") {
Some(Value::Null) | None => None,
Some(_) => Some(timeline_group_id(s, p, cmd)?),
};
if let Some(target) = target
&& group_move_would_cycle(s, target, &groups)
{
return Err(bad(cmd, "a folder cannot be moved into itself or one of its descendants"));
}
let nested = group_feature_ids(&s.doc, &groups);
features.retain(|feature| !nested.contains(feature));
let moving: Vec<u64> =
s.doc.features.iter().filter(|feature| nested.contains(&feature.id) || features.contains(&feature.id)).map(|feature| feature.id).collect();
let mut next = (*s.doc).clone();
for group in &mut next.timeline_groups {
group.features.retain(|feature| !features.contains(feature));
if Some(group.id) == target {
group.features.extend(features.iter().copied());
}
if groups.contains(&group.id) {
group.parent = target;
}
}
sort_group_features(&mut next);
let slot = num(p, "position").map(|x| x.max(0.0) as usize).ok_or_else(|| bad(cmd, "`position` must be a number"))?;
let (next, ordered) = reorder_features(s, next, &moving, slot, cmd)?;
if next != *s.doc {
*s.doc_mut() = next;
}
Ok(json!({"features": ordered, "groups": groups, "group": target, "position": slot}))
}
fn rollback(s: &mut Session, p: &Value) -> Result<Value> {
let n = s.doc.features.len();
let pos = match p.get("feature") {
Some(f) => {
let id = feature_id(s, Some(f), "timeline.roll_to")?;
s.doc.feature_index(id).map(|i| i + 1)
}
None => 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(),
}
}
/// `profiles` (also a loft section's) given as the feature commands take it (`"all"`,
/// `[index]`, `[{curves}]`, `[{point}]`...) becomes the stored form; the stored form passes.
fn command_profiles(set: &Value, cmd: &str) -> Result<Value> {
let conv = |v: &mut Value| -> Result<()> {
if let Some(pv) = v.get("profiles").filter(|x| x.get("type").is_none()) {
let sel = super::features::profiles(&json!({"profiles": pv}), cmd)?;
let sv = serde_json::to_value(sel).map_err(|e| EngineError::Other(e.to_string()))?;
if let Some(o) = v.as_object_mut() {
o.insert("profiles".into(), sv);
}
}
Ok(())
};
let mut set = set.clone();
conv(&mut set)?;
if let Some(secs) = set.get_mut("sections").and_then(Value::as_array_mut) {
for sec in secs.iter_mut().take(10_000) {
conv(sec)?;
}
}
Ok(set)
}
/// Fields of a stored feature left out of its definition: bookkeeping timeline.edit recomputes, and
/// the name and suppression, which timeline.rename and timeline.suppress change.
const NOT_EDITABLE: &[&str] = &["id", "type", "name", "suppressed", "param_names", "edge_refs", "edge_names", "face_names"];
fn inspect_feature(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "timeline.inspect";
let id = feature_id(s, p.get("feature"), cmd)?;
let f = s.doc.feature(id).ok_or_else(|| bad(cmd, "feature"))?;
let mut def = serde_json::to_value(f).map_err(|e| EngineError::Other(e.to_string()))?;
let mut omitted = Vec::new();
if let Some(o) = def.as_object_mut() {
for k in NOT_EDITABLE {
o.remove(*k);
}
// Large payloads that timeline.edit cannot change.
let heavy: &[(&str, &str)] = match &f.kind {
FeatureKind::Sketch { .. } => &[("sketch", "the sketch geometry (sketch.inspect; edit it with the sketch commands)")],
FeatureKind::Import { .. } => &[("step", "the imported STEP text"), ("components", "the imported product tree")],
FeatureKind::MeshImport { .. } => &[("meshes", "the imported meshes")],
_ => &[],
};
for (k, why) in heavy {
if o.remove(*k).is_some() {
omitted.push(json!({"field": k, "what": why}));
}
}
}
let inputs: Vec<Value> = f
.kind
.inputs()
.into_iter()
.enumerate()
.map(|(i, (label, e, k))| {
json!({"label": label, "param": f.param_names.get(i).filter(|n| !n.is_empty()), "expression": e, "unit": k.default_unit()})
})
.collect();
let marker = s.doc.marker.unwrap_or(usize::MAX);
let rolled_back = s.doc.features.iter().position(|x| x.id == id).is_some_and(|i| i >= marker);
let r = s.model.result(id);
Ok(json!({
"id": f.id, "name": f.name, "type": f.kind.type_name(), "suppressed": f.suppressed, "rolled_back": rolled_back,
"definition": def, "omitted": omitted, "inputs": inputs,
"error": r.and_then(|r| r.error.clone()), "warning": r.and_then(|r| r.warning.clone()),
"boolean_diagnostics": r.map(|r| &r.boolean_diagnostics),
}))
}
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()))?;
let set = &command_profiles(set, cmd)?;
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::BodyTransfer { bodies, from, to, transform } = &nf.kind {
solidcraft_doc::validate_body_transfer(&s.doc, bodies, *from, *to, transform)?;
nf.component = *to;
}
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"));
}
// New edges for a blend: remember where they sit now (in the model before the feature).
if let (
FeatureKind::Fillet { edges: a, body, .. } | FeatureKind::Chamfer { edges: a, body, .. },
FeatureKind::Fillet { edges: b, .. } | FeatureKind::Chamfer { edges: b, .. },
) = (&nf.kind, &f.kind)
&& a != b
{
let before = s.model.state_before(id);
nf.edge_refs = solidcraft_doc::edge_refs(&before, body, a);
nf.edge_names = solidcraft_doc::edge_names_for(&before, body, a);
}
// New or reordered flange edges: their names now, so each edge's own angle and height stay
// with the edge they were given for.
if let (FeatureKind::SheetFlange { edges: a, .. }, FeatureKind::SheetFlange { edges: b, .. }) = (&nf.kind, &f.kind) {
let points = |e: &[solidcraft_doc::FlangeEdge]| e.iter().map(|x| x.point).collect::<Vec<_>>();
if points(a) != points(b) {
nf.edge_names = solidcraft_doc::sheet::sheet_edge_names(&s.model.state_before(id), &points(a));
}
}
// New bend edges for a conversion: the names of the body's edges there now.
if let (FeatureKind::SheetConvert { bends: a, body, .. }, FeatureKind::SheetConvert { bends: b, body: was, .. }) = (&nf.kind, &f.kind)
&& (a != b || body != was)
{
nf.edge_names = solidcraft_doc::sheet::bend_edge_names(&s.model.state_before(id), body, a);
}
// Profiles picked by index or point are kept by their curves, as when the feature was made.
if nf.kind.clone().profile_sels_mut() != f.kind.clone().profile_sels_mut() {
super::features::stable_profiles(s, &mut nf.kind)?;
}
// New face points: their faces' names now.
if nf.kind.face_points() != f.kind.face_points() {
nf.face_names = solidcraft_doc::face_point_names(&s.doc, &s.model.state_before(id), &nf.kind, nf.component);
}
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, "restored": s.model.last_restored}))
}
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()))?;
if let Sel::Occurrence { path } = &one {
s.occurrence_bodies(path, true).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}))
}
/// Features that worked in `before` and fail in `after`: (name, error).
fn newly_broken(before: &solidcraft_doc::Model, after: &solidcraft_doc::Model) -> Vec<(String, String)> {
after
.results
.iter()
.filter_map(|r| {
let e = r.error.clone()?;
let was_ok = before.result(r.id).is_some_and(|b| b.error.is_none() && !b.skipped);
was_ok.then(|| (r.name.clone(), e))
})
.collect()
}
fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "timeline.reorder";
let id = feature_id(s, p.get("feature"), cmd)?;
let n = s.doc.features.len();
let to = num(p, "position").map(|x| x.max(0.0) as usize).ok_or_else(|| bad(cmd, "`position` must be a number"))?.min(n.saturating_sub(1));
let from = s.doc.feature_index(id).ok_or_else(|| bad(cmd, "feature"))?;
if from == to {
return Ok(json!({"feature": id, "position": to}));
}
let mut next = (*s.doc).clone();
let f = next.features.remove(from);
let name = f.name.clone();
next.features.insert(to, f);
sort_group_features(&mut next);
require_contiguous_groups(&next, cmd)?;
// Everything that evaluated before must still evaluate.
let mut after = solidcraft_doc::Model::new();
after.evaluate(&next);
let broken = newly_broken(&s.model, &after);
if !broken.is_empty() {
let list: Vec<String> = broken.iter().map(|(n, e)| format!("{n} ({e})")).collect();
return Err(bad(cmd, format!("moving {name} there breaks {}", list.join("; "))));
}
*s.doc_mut() = next;
Ok(json!({"feature": id, "position": to}))
}
fn reorder_many(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "timeline.reorder_many";
let ids = group_features(s, p, true, cmd)?;
let slot = num(p, "position").map(|x| x.max(0.0) as usize).ok_or_else(|| bad(cmd, "`position` must be a number"))?;
let (next, ordered) = reorder_features(s, (*s.doc).clone(), &ids, slot, cmd)?;
if next != *s.doc {
*s.doc_mut() = next;
}
Ok(json!({"features": ordered, "position": slot.min(s.doc.features.len())}))
}
fn reorder_features(
s: &Session,
mut next: solidcraft_doc::Document,
ids: &[u64],
slot: usize,
cmd: &str,
) -> Result<(solidcraft_doc::Document, Vec<u64>)> {
let n = next.features.len();
let slot = slot.min(n);
let selected: std::collections::BTreeSet<u64> = ids.iter().copied().collect();
let ordered: Vec<u64> = next.features.iter().filter(|feature| selected.contains(&feature.id)).map(|feature| feature.id).collect();
let before_slot = next.features.iter().take(slot).filter(|feature| selected.contains(&feature.id)).count();
let insert_at = slot.saturating_sub(before_slot).min(n.saturating_sub(ordered.len()));
let mut moving = Vec::new();
next.features.retain(|feature| {
if selected.contains(&feature.id) {
moving.push(feature.clone());
false
} else {
true
}
});
let unchanged = s.doc.features.iter().map(|feature| feature.id).eq(next.features[..insert_at]
.iter()
.chain(moving.iter())
.chain(next.features[insert_at..].iter())
.map(|feature| feature.id));
next.features.splice(insert_at..insert_at, moving);
sort_group_features(&mut next);
require_contiguous_groups(&next, cmd)?;
if !unchanged {
let mut after = solidcraft_doc::Model::new();
after.evaluate(&next);
let broken = newly_broken(&s.model, &after);
if !broken.is_empty() {
let list: Vec<String> = broken.iter().map(|(name, error)| format!("{name} ({error})")).collect();
return Err(bad(cmd, format!("moving the selected features there breaks {}", list.join("; "))));
}
}
Ok((next, ordered))
}
fn input_keys(kind: &FeatureKind) -> Vec<(String, solidcraft_doc::expr::Kind)> {
let mut keys: Vec<_> = kind.inputs().into_iter().map(|(label, _, unit)| (label.to_string(), unit)).collect();
if let FeatureKind::Fillet { style, .. } = kind {
if let Some((key, _)) = keys.first_mut() {
*key = "fillet.radius".into();
}
match style {
solidcraft_doc::FilletStyle::Variable { mid, .. } => {
for ((key, _), (position, _)) in keys.iter_mut().skip(2).zip(mid) {
*key = format!("fillet.mid.{}", position.to_bits());
}
}
solidcraft_doc::FilletStyle::Spline { .. } => {
for (i, (key, _)) in keys.iter_mut().enumerate().skip(1) {
*key = format!("fillet.control.{i}");
}
}
solidcraft_doc::FilletStyle::Constant | solidcraft_doc::FilletStyle::Chord | solidcraft_doc::FilletStyle::Curvature => {}
}
}
keys
}
fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "timeline.redefine";
let id = feature_id(s, p.get("feature"), cmd)?;
let cid = str_(p, "command").ok_or_else(|| bad(cmd, "`command` must name the feature's command"))?;
let spec = super::find_command(cid).ok_or_else(|| bad(cmd, format!("no command `{cid}`")))?;
// Only modeling constructors may run here. In particular, a scratch session does not
// isolate filesystem writes performed by export commands.
let constructor = super::features::COMMANDS
.iter()
.chain(super::features_more::COMMANDS)
.chain(super::construct::COMMANDS)
.chain(super::surface::COMMANDS)
.any(|candidate| candidate.id == spec.id)
|| matches!(
spec.id,
"sheet.flange"
| "sheet.hem"
| "sheet.perforate"
| "sheet.fold"
| "sheet.unfold"
| "sheet.refold"
| "sheet.convert"
| "plastic.boss"
| "plastic.lip"
| "plastic.snap_fit"
| "plastic.rest"
);
if !constructor {
return Err(bad(cmd, format!("`{cid}` does not make a feature")));
}
let mut params = p.get("params").cloned().unwrap_or_else(|| json!({}));
let idx = s.doc.feature_index(id).ok_or_else(|| bad(cmd, "feature"))?;
let old = s.doc.feature(id).cloned().ok_or_else(|| bad(cmd, "feature"))?;
if let FeatureKind::Revolve { convention, .. } = &old.kind
&& cid == "solid.revolve"
&& let Some(parameters) = params.as_object_mut()
{
parameters.entry("convention").or_insert_with(|| json!(convention));
}
if !old.participants.is_empty()
&& let Some(parameters) = params.as_object_mut()
{
parameters.entry("participants").or_insert_with(|| json!(old.participants));
}
// Run the constructor on a scratch session: failed edits must not disturb sketch mode,
// selections, component authoring state or the original feature's definition.
let mut trial = s.scratch();
trial.active_component = old.component;
if let FeatureKind::Emboss { face: Some(face), .. } = &old.kind {
trial.active_occurrence_path = (!face.author_path.is_empty()).then(|| face.author_path.clone());
}
trial.active_sketch = None;
trial.selection = s.selection.clone();
// Keep the original identity as a suppressed placeholder. The constructor and
// any ownership boundary it adds go AFTER this slot, so discarding its temporary
// feature leaves auxiliary history after the rewritten original.
if let Some(slot) = trial.doc_mut().feature_mut(id) {
slot.suppressed = true;
}
trial.doc_mut().marker = Some(idx + 1);
trial.refresh();
let r = spec.run_nested(&mut trial, &params)?;
let made = r.get("feature").and_then(Value::as_u64).ok_or_else(|| bad(cmd, format!("`{cid}` did not make a feature")))?;
let mut new = trial.doc.feature(made).cloned().ok_or_else(|| bad(cmd, format!("`{cid}` did not make a feature")))?;
if let (FeatureKind::Emboss { face: Some(before), .. }, FeatureKind::Emboss { face: Some(after), .. }) = (&old.kind, &mut new.kind)
&& before.body == after.body
&& before.path == after.path
&& before.author_path == after.author_path
&& before.at.dist(after.at) < 1e-8
{
*after = before.clone();
}
if std::mem::discriminant(&new.kind) != std::mem::discriminant(&old.kind) {
return Err(bad(cmd, format!("{} is not a {} feature", old.name, cid)));
}
// Optional inputs can move other inputs' positions (e.g. Distance 2 goes before Taper).
// Keep each existing model parameter attached to its semantic input, rather than its
// old index. Constructor-assigned names for new inputs cannot reuse the old dimensions.
let mut old_inputs: Vec<_> = input_keys(&old.kind).into_iter().zip(&old.param_names).collect();
let param_names: Vec<String> = input_keys(&new.kind)
.iter()
.enumerate()
.map(|(i, (label, kind))| {
if let Some(k) = old_inputs.iter().position(|((old_label, old_kind), _)| old_label == label && old_kind == kind) {
old_inputs.remove(k).1.clone()
} else {
new.param_names.get(i).cloned().unwrap_or_default()
}
})
.collect();
trial.doc_mut().features.retain(|f| f.id != made);
let added = trial.doc.features.len().saturating_sub(s.doc.features.len());
trial.doc_mut().marker = s.doc.marker.map(|marker| if marker > idx { marker.saturating_add(added) } else { marker });
if made == s.doc.next_id && trial.doc.next_id == made.saturating_add(1) {
trial.doc_mut().next_id = s.doc.next_id;
}
if let Some(slot) = trial.doc_mut().feature_mut(id) {
slot.suppressed = old.suppressed;
slot.kind = new.kind;
slot.edge_refs = new.edge_refs;
slot.edge_names = new.edge_names;
slot.face_names = new.face_names;
slot.param_names = param_names;
if params.get("participants").is_some() {
slot.participants = new.participants;
}
}
// New optional inputs need model parameter names, while existing dimensions keep theirs.
trial.doc_mut().name_feature_inputs(id);
trial.refresh();
if let Some(e) = trial.model.result(id).and_then(|r| r.error.clone()) {
return Err(EngineError::Other(e));
}
let original_ids: std::collections::BTreeSet<u64> = s.doc.features.iter().map(|feature| feature.id).collect();
let added_ids: std::collections::BTreeSet<u64> =
trial.doc.features.iter().filter(|feature| !original_ids.contains(&feature.id)).map(|feature| feature.id).collect();
let adds_transfer =
trial.doc.features.iter().any(|feature| added_ids.contains(&feature.id) && matches!(feature.kind, FeatureKind::BodyTransfer { .. }));
let baseline = if adds_transfer && s.doc.marker.is_some() {
let mut baseline = s.scratch();
baseline.doc_mut().marker = None;
baseline.refresh();
Some(baseline)
} else {
None
};
let proof = if (adds_transfer && trial.doc.marker.is_some()) || added_ids.iter().any(|id| trial.model.result(*id).is_none()) {
let mut proof = trial.scratch();
proof.doc_mut().marker = None;
proof.refresh();
Some(proof)
} else {
None
};
let validated = proof.as_ref().map(|proof| &proof.model).unwrap_or(&trial.model);
let original_model = baseline.as_ref().map(|baseline| &baseline.model).unwrap_or(&s.model);
for feature in &trial.doc.features {
let auxiliary = added_ids.contains(&feature.id);
let protected_transfer = matches!(feature.kind, FeatureKind::BodyTransfer { .. })
&& original_model.result(feature.id).is_some_and(|result| !result.skipped && result.error.is_none());
if (auxiliary || protected_transfer)
&& let Some(error) = validated.result(feature.id).and_then(|result| result.error.as_ref())
{
return Err(bad(cmd, format!("{}: {error}", feature.name)));
}
}
s.pending_moves = trial.pending_moves;
s.doc = trial.doc;
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, "restored": s.model.last_restored}))
}
fn dependents(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "timeline.dependents";
let id = feature_id(s, p.get("feature"), cmd)?;
let mut next = (*s.doc).clone();
let gone = next.delete_feature(id)?;
let mut after = solidcraft_doc::Model::new();
after.evaluate(&next);
let names = |ids: &[u64]| -> Vec<String> { ids.iter().filter(|x| **x != id).filter_map(|x| s.doc.feature(*x).map(|f| f.name.clone())).collect() };
let broken: Vec<String> = newly_broken(&s.model, &after).into_iter().map(|(n, _)| n).collect();
Ok(json!({"deleted_with_it": names(&gone), "would_fail": broken}))
}
#[cfg(test)]
#[path = "edit_tests.rs"]
mod tests;