* feat(sketch): constructive solving ahead of the numeric solver Part 1 of splitting #251: the sketch crate only. A graph-constructive front end (Owen 1991; Fudos & Hoffmann 1997) places what it can by ruler-and-compass loci and cluster decomposition, Levenberg-Marquardt finishes and verifies the rest, and the rank analysis stays authoritative for degrees of freedom. Solves can follow a reference (the previous solution), so edits and drags keep their branch instead of flipping. New API: solve_with (hold, reference, analysis, budget) and solve_after_value_edit; solve and solve_holding keep their signatures as wrappers, so no caller changes here. When the seed moves an arc's centre, the arc's unconstructed ends start carried along (the arc translates) instead of left behind for LM to turn; found by the reference test 20-slot-constrained. Refs #203 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TWc1smHYwTSVrVc8SNvS44 * feat(doc): keep a sketch's solved state across value edits Part 2 of splitting #251 (builds on part 1, the sketch crate): - Sketch edits solve for the document's values continuing from the stored solved state (Document::solve_sketch), so a dimension edit keeps the sketch's branch instead of re-solving from scratch. - A value edit (parameters, a configuration, the units) that would make a sketch that solved before fail or turn over is refused with the conflicting constraints named; otherwise the solved sketches are written back into the document (Model::adopt_solved_sketches), which changes what saved designs store. - Later features are keyed by a sketch's solved geometry (to a 1e-6 mm quantum), so setting a value back reuses their earlier results. - Solved sketches in a model state are shared (Arc) between states. Refs #203 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TWc1smHYwTSVrVc8SNvS44 * feat(sketch): live drag that keeps its solution, ended by sketch.drag_end Part 3 of splitting #251 (builds on part 2): - sketch.drag follows a long move in short held steps, so the geometry keeps its branch, and reports how far it got (reached, at). - live: true runs one frame of an interactive drag: the sketch is solved once and shown, later features keep their last result (marked stale) until sketch.drag_end or any command that reads the model evaluates it (CommandSpec::live, reads_model, doc_only). - sketch.drag_end adds the constraint the drag let go on and evaluates the model once. - Undo and redo evaluate a new document once (execute does) instead of twice. - The viewport drives drags through live frames and drag_end; dimension text drags start only off sketch geometry. Refs #203 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TWc1smHYwTSVrVc8SNvS44 --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
830 lines
39 KiB
Rust
830 lines
39 KiB
Rust
//! Browser commands: renaming bodies, moving a sketch to another plane, and the browser's
|
|
//! groups and item order (kept in the design, so they are saved and undone like features).
|
|
//!
|
|
//! Browser items are keyed per component folder (`bodies`, `sketches`, `construction`): a body
|
|
//! by name, a sketch or construction plane by feature id.
|
|
|
|
use serde_json::{Value, json};
|
|
|
|
use solvecraft_doc::FeatureKind;
|
|
|
|
use super::CommandSpec;
|
|
use crate::params::{bad, bool_, str_};
|
|
use crate::{Result, Session};
|
|
|
|
pub static COMMANDS: &[CommandSpec] = &[
|
|
CommandSpec::new("body.rename", "Rename Body", rename_body).params("body: its name, name: the new name (features that use the body follow)"),
|
|
CommandSpec::new("sketch.redefine", "Redefine Sketch Plane", redefine_sketch)
|
|
.icon("plane")
|
|
.params("sketch: id or name; plane: as sketch.create; author_path?: exact owner occurrence ids; explicitly rebinds or freezes the plane while preserving geometry and dimension names"),
|
|
CommandSpec::new("browser.group", "New Group", group)
|
|
.icon("folder")
|
|
.params("folder: bodies|sketches|construction; component?: id (default 0); name?; items?: [keys] (they leave other groups)"),
|
|
CommandSpec::new("browser.ungroup", "Ungroup", ungroup).params("group: id (its items go back to the folder)"),
|
|
CommandSpec::new("browser.rename_group", "Rename Group", rename_group).params("group: id, name"),
|
|
CommandSpec::new("browser.move", "Move Browser Items", move_items)
|
|
.params("folder, component?, items: [keys]; group?: id (into it; omit to take them out of groups); before?: key (position in the group)"),
|
|
CommandSpec::new("document.units", "Change Design Units", document_units)
|
|
.icon("settings")
|
|
.params("units: mm|cm|m|in|ft (display and unit-less parameters; values keep their meaning)"),
|
|
CommandSpec::new("view.save", "New Named View", view_save)
|
|
.icon("camera")
|
|
.params("name?; camera: {target: [x,y,z], yaw, pitch, distance, fov?} (replaces a view of the same name)"),
|
|
CommandSpec::new("view.rename", "Rename Named View", view_rename).params("view: name, name"),
|
|
CommandSpec::new("view.delete", "Delete Named View", view_delete).params("view: name"),
|
|
CommandSpec::new("view.list", "List Named Views", view_list).noundo().doc_only(),
|
|
CommandSpec::new("browser.order", "Order Browser Items", order).params("folder, component?, order: [keys] (the folder's items in display order)"),
|
|
CommandSpec::new("browser.visibility", "Show/Hide", visibility).icon("eye").noundo().params(
|
|
"visible: bool; items?: [sketch id or name | body name | construction name | O|X|Y|Z|XY|XZ|YZ]; \
|
|
or folder?: bodies|sketches|construction|origin (all of it); or paths?: [[occurrence ids]] (exact placements and descendants), isolate?: bool (requires paths and visible:true). View state: not saved, not an undo step",
|
|
),
|
|
];
|
|
|
|
const FOLDERS: [&str; 3] = ["bodies", "sketches", "construction"];
|
|
|
|
fn folder_of(p: &Value, cmd: &str) -> Result<String> {
|
|
match str_(p, "folder") {
|
|
Some(f) if FOLDERS.contains(&f) => Ok(f.to_string()),
|
|
_ => Err(bad(cmd, "`folder` must be bodies, sketches or construction")),
|
|
}
|
|
}
|
|
|
|
fn component_of(s: &Session, p: &Value, cmd: &str) -> Result<u64> {
|
|
let c = p.get("component").and_then(Value::as_u64).unwrap_or(0);
|
|
if c != 0 && !s.doc.components.iter().any(|x| x.id == c) {
|
|
return Err(bad(cmd, format!("no component {c}")));
|
|
}
|
|
Ok(c)
|
|
}
|
|
|
|
fn keys(p: &Value, k: &str, cmd: &str) -> Result<Vec<String>> {
|
|
let v = match p.get(k) {
|
|
Some(Value::Array(a)) => {
|
|
a.iter().map(|x| x.as_str().map(str::to_string).or_else(|| x.as_u64().map(|n| n.to_string()))).collect::<Option<Vec<_>>>()
|
|
}
|
|
None => Some(Vec::new()),
|
|
_ => None,
|
|
};
|
|
let v = v.ok_or_else(|| bad(cmd, format!("`{k}` must be a list of item keys")))?;
|
|
if v.len() > 10_000 || v.iter().any(|x| x.is_empty() || x.len() > 256) {
|
|
return Err(bad(cmd, format!("`{k}` has bad keys")));
|
|
}
|
|
Ok(v)
|
|
}
|
|
|
|
fn group_id(s: &Session, p: &Value, cmd: &str) -> Result<u64> {
|
|
let id = p.get("group").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "`group` must be a group id"))?;
|
|
if s.doc.browser_groups.iter().any(|g| g.id == id) { Ok(id) } else { Err(bad(cmd, format!("no group {id}"))) }
|
|
}
|
|
|
|
fn group(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let cmd = "browser.group";
|
|
let folder = folder_of(p, cmd)?;
|
|
let component = component_of(s, p, cmd)?;
|
|
let items = keys(p, "items", cmd)?;
|
|
let d = s.doc_mut();
|
|
let id = d.browser_groups.iter().map(|g| g.id).max().unwrap_or(0) + 1;
|
|
let name = match str_(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
|
|
Some(n) if n.len() <= 128 => n.to_string(),
|
|
Some(_) => return Err(bad(cmd, "`name` is too long")),
|
|
None => format!("Group{}", d.browser_groups.iter().filter(|g| g.folder == folder).count() + 1),
|
|
};
|
|
for g in d.browser_groups.iter_mut().filter(|g| g.component == component && g.folder == folder) {
|
|
g.items.retain(|k| !items.contains(k));
|
|
}
|
|
d.browser_groups.push(solvecraft_doc::BrowserGroup { id, name: name.clone(), component, folder, items });
|
|
Ok(json!({ "group": id, "name": name }))
|
|
}
|
|
|
|
fn ungroup(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let id = group_id(s, p, "browser.ungroup")?;
|
|
s.doc_mut().browser_groups.retain(|g| g.id != id);
|
|
Ok(json!({ "group": id }))
|
|
}
|
|
|
|
fn rename_group(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let cmd = "browser.rename_group";
|
|
let id = group_id(s, p, 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 let Some(g) = s.doc_mut().browser_groups.iter_mut().find(|g| g.id == id) {
|
|
g.name = name.to_string();
|
|
}
|
|
Ok(json!({ "group": id, "name": name }))
|
|
}
|
|
|
|
fn move_items(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let cmd = "browser.move";
|
|
let folder = folder_of(p, cmd)?;
|
|
let component = component_of(s, p, cmd)?;
|
|
let items = keys(p, "items", cmd)?;
|
|
if items.is_empty() {
|
|
return Err(bad(cmd, "`items` must list item keys"));
|
|
}
|
|
let to = match p.get("group") {
|
|
Some(Value::Null) | None => None,
|
|
Some(_) => Some(group_id(s, p, cmd)?),
|
|
};
|
|
if let Some(g) = to
|
|
&& !s.doc.browser_groups.iter().any(|x| x.id == g && x.component == component && x.folder == folder)
|
|
{
|
|
return Err(bad(cmd, "that group is in another folder"));
|
|
}
|
|
let before = str_(p, "before").map(str::to_string);
|
|
let d = s.doc_mut();
|
|
for g in d.browser_groups.iter_mut().filter(|g| g.component == component && g.folder == folder) {
|
|
g.items.retain(|k| !items.contains(k));
|
|
if Some(g.id) == to {
|
|
let at = before.as_ref().and_then(|b| g.items.iter().position(|k| k == b)).unwrap_or(g.items.len());
|
|
for (i, k) in items.iter().enumerate() {
|
|
g.items.insert(at + i, k.clone());
|
|
}
|
|
}
|
|
}
|
|
Ok(json!({ "items": items, "group": to }))
|
|
}
|
|
|
|
fn order(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let cmd = "browser.order";
|
|
let folder = folder_of(p, cmd)?;
|
|
let component = component_of(s, p, cmd)?;
|
|
let order = keys(p, "order", cmd)?;
|
|
let key = format!("{component}/{folder}");
|
|
let d = s.doc_mut();
|
|
if order.is_empty() {
|
|
d.browser_order.remove(&key);
|
|
} else {
|
|
d.browser_order.insert(key, order);
|
|
}
|
|
Ok(json!({}))
|
|
}
|
|
|
|
const ORIGIN: [&str; 7] = ["O", "X", "Y", "Z", "XY", "XZ", "YZ"];
|
|
|
|
/// A browser item whose visibility can change.
|
|
enum Shown {
|
|
Sketch(u64),
|
|
Body(String),
|
|
/// Origin items and construction geometry, by name.
|
|
Origin(String),
|
|
}
|
|
|
|
fn is_construction(k: &FeatureKind) -> bool {
|
|
matches!(k, FeatureKind::ConstructionPlane { .. } | FeatureKind::ConstructionAxis { .. } | FeatureKind::ConstructionPoint { .. })
|
|
}
|
|
|
|
fn shown_item(s: &Session, key: &str) -> Option<Shown> {
|
|
if let Some(f) = s.doc.find_feature(key) {
|
|
if matches!(f.kind, FeatureKind::Sketch { .. }) {
|
|
return Some(Shown::Sketch(f.id));
|
|
}
|
|
if is_construction(&f.kind) {
|
|
return Some(Shown::Origin(f.name.clone()));
|
|
}
|
|
}
|
|
if s.world_state().bodies.iter().any(|b| b.name == key) {
|
|
return Some(Shown::Body(key.to_string()));
|
|
}
|
|
ORIGIN.iter().find(|o| o.eq_ignore_ascii_case(key)).map(|o| Shown::Origin(o.to_string()))
|
|
}
|
|
|
|
fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let cmd = "browser.visibility";
|
|
let visible = bool_(p, "visible").ok_or_else(|| bad(cmd, "`visible` must be true or false"))?;
|
|
if let Some(value) = p.get("paths") {
|
|
if p.get("items").is_some() || p.get("folder").is_some() {
|
|
return Err(bad(cmd, "give paths, items or folder, not several"));
|
|
}
|
|
let paths =
|
|
value.as_array().filter(|a| !a.is_empty() && a.len() <= 10_000).ok_or_else(|| bad(cmd, "paths must list 1..10000 occurrence paths"))?;
|
|
let paths: Vec<Vec<u64>> = paths
|
|
.iter()
|
|
.map(|value| {
|
|
let path = value
|
|
.as_array()
|
|
.filter(|a| !a.is_empty() && a.len() <= 64)
|
|
.and_then(|a| a.iter().map(Value::as_u64).collect::<Option<Vec<_>>>())
|
|
.ok_or_else(|| bad(cmd, "each path must list 1..64 occurrence ids"))?;
|
|
s.occurrence_placement(&path).map_err(|e| bad(cmd, e.to_string()))?;
|
|
Ok(path)
|
|
})
|
|
.collect::<Result<_>>()?;
|
|
if bool_(p, "isolate").unwrap_or(false) {
|
|
if !visible {
|
|
return Err(bad(cmd, "isolate requires visible:true"));
|
|
}
|
|
let mut hidden = Vec::new();
|
|
let mut ancestor_bodies = Vec::new();
|
|
let mut stack = vec![Vec::<u64>::new()];
|
|
let mut count = 0;
|
|
while let Some(parent) = stack.pop() {
|
|
for child in s.occurrence_children(&parent)? {
|
|
count += 1;
|
|
if count > 100_000 {
|
|
return Err(bad(cmd, "isolate exceeds 100000 placements"));
|
|
}
|
|
if paths.iter().any(|selected| child.path.starts_with(selected)) {
|
|
continue;
|
|
}
|
|
if paths.iter().any(|selected| selected.starts_with(&child.path)) {
|
|
ancestor_bodies.extend(child.bodies.into_iter().map(|b| b.world));
|
|
stack.push(child.path);
|
|
} else {
|
|
hidden.push(child.path);
|
|
}
|
|
}
|
|
}
|
|
let mut root_bodies: Vec<String> =
|
|
s.model.state().bodies.iter().filter(|b| s.doc.model_body_component(b) == 0).map(|b| b.name.clone()).collect();
|
|
root_bodies.extend(ancestor_bodies);
|
|
s.visibility.hidden_occurrences = hidden;
|
|
s.visibility.hidden_bodies = root_bodies;
|
|
s.revision += 1;
|
|
return Ok(json!({"visible":true,"isolated":paths,"visibility":s.visibility}));
|
|
}
|
|
for path in &paths {
|
|
s.visibility.hidden_occurrences.retain(|p| p != path);
|
|
if !visible {
|
|
s.visibility.hidden_occurrences.push(path.clone());
|
|
}
|
|
}
|
|
s.revision += 1;
|
|
return Ok(json!({"visible":visible,"changed":paths.len(),"visibility":s.visibility}));
|
|
}
|
|
let items: Vec<Shown> = match (str_(p, "folder"), p.get("items")) {
|
|
(Some(_), Some(_)) => return Err(bad(cmd, "give `items` or `folder`, not both")),
|
|
(Some("bodies"), None) => s.world_state().bodies.iter().map(|b| Shown::Body(b.name.clone())).collect(),
|
|
(Some("sketches"), None) => s.model.state().sketches.iter().map(|x| Shown::Sketch(x.feature)).collect(),
|
|
(Some("construction"), None) => s.doc.features.iter().filter(|f| is_construction(&f.kind)).map(|f| Shown::Origin(f.name.clone())).collect(),
|
|
(Some("origin"), None) => ORIGIN.iter().map(|o| Shown::Origin(o.to_string())).collect(),
|
|
(Some(f), None) => return Err(bad(cmd, format!("unknown folder `{f}` (bodies, sketches, construction, origin)"))),
|
|
(None, Some(_)) => {
|
|
let keys = keys(p, "items", cmd)?;
|
|
if keys.is_empty() {
|
|
return Err(bad(cmd, "`items` must list sketches, bodies or construction geometry"));
|
|
}
|
|
// Every item is found before anything changes.
|
|
keys.iter()
|
|
.map(|k| shown_item(s, k).ok_or_else(|| bad(cmd, format!("no sketch, body or construction item `{k}`"))))
|
|
.collect::<Result<_>>()?
|
|
}
|
|
(None, None) => return Err(bad(cmd, "`items` or `folder` is required")),
|
|
};
|
|
let v = &mut s.visibility;
|
|
if visible && str_(p, "folder") == Some("bodies") {
|
|
v.hidden_occurrences.clear();
|
|
}
|
|
for it in &items {
|
|
match it {
|
|
Shown::Sketch(id) => {
|
|
v.hidden_sketches.retain(|x| x != id);
|
|
v.shown_sketches.retain(|x| x != id);
|
|
if visible { v.shown_sketches.push(*id) } else { v.hidden_sketches.push(*id) }
|
|
}
|
|
Shown::Body(n) => {
|
|
v.hidden_bodies.retain(|x| x != n);
|
|
if !visible {
|
|
v.hidden_bodies.push(n.clone());
|
|
}
|
|
}
|
|
Shown::Origin(n) => {
|
|
v.hidden_origin.retain(|x| x != n);
|
|
if !visible {
|
|
v.hidden_origin.push(n.clone());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
s.revision += 1;
|
|
Ok(json!({ "visible": visible, "changed": items.len(), "visibility": s.visibility }))
|
|
}
|
|
|
|
fn document_units(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let cmd = "document.units";
|
|
let u = str_(p, "units").ok_or_else(|| bad(cmd, "`units` is required"))?;
|
|
if !["mm", "cm", "m", "in", "ft"].contains(&u) {
|
|
return Err(bad(cmd, "`units` must be mm, cm, m, in or ft"));
|
|
}
|
|
let old = s.doc.units.clone();
|
|
if old == u {
|
|
return Ok(json!({ "units": u, "kept": [] }));
|
|
}
|
|
// What the geometry reads: every feature's length inputs.
|
|
let matched_configuration = s.doc.configuration_matches().then(|| s.doc.configs.active.clone()).flatten();
|
|
let before = s.doc.length_inputs();
|
|
let solved = s.solved_sketches();
|
|
s.doc_mut().units = u.to_string();
|
|
// Unit-less user parameters holding a bare number read it in the design's units where they
|
|
// stand for lengths. If any input moved, those numbers keep the old unit, written out.
|
|
let same = |a: &[Option<f64>]| {
|
|
a.iter().zip(&before).filter(|(x, y)| matches!((x, y), (Some(x), Some(y)) if (x - y).abs() <= 1e-9 * y.abs().max(1.0))).count()
|
|
};
|
|
let after = s.doc.length_inputs();
|
|
let mut kept = Vec::new();
|
|
if same(&after) < before.len() {
|
|
let bare: Vec<(String, String)> = s
|
|
.doc
|
|
.params
|
|
.iter()
|
|
.filter(|p| !p.model && p.unit.is_empty() && solvecraft_doc::expr::is_literal(&p.expr) && p.expr.trim().parse::<f64>().is_ok())
|
|
.map(|p| (p.name.clone(), p.expr.trim().to_string()))
|
|
.collect();
|
|
for (name, e) in bare {
|
|
let now = same(&s.doc.length_inputs());
|
|
let probe = {
|
|
let mut d = (*s.doc).clone();
|
|
let _ = d.change_param(&name, &format!("{e} {old}"), Some(&old), None);
|
|
same(&d.length_inputs())
|
|
};
|
|
// Only when writing the old unit puts more inputs back where they were.
|
|
if probe > now {
|
|
s.doc_mut().change_param(&name, &format!("{e} {old}"), Some(&old), None)?;
|
|
kept.push(name);
|
|
}
|
|
}
|
|
}
|
|
s.doc_mut().preserve_configuration_units(&old, &kept);
|
|
if let Some(row) = matched_configuration {
|
|
s.doc_mut().apply_configuration(&row)?;
|
|
}
|
|
s.settle_value_edit(&solved)?;
|
|
Ok(json!({ "units": u, "kept": kept }))
|
|
}
|
|
|
|
fn view_save(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let cmd = "view.save";
|
|
let c = p.get("camera").ok_or_else(|| bad(cmd, "`camera` is required"))?;
|
|
let mut v: solvecraft_doc::NamedView = serde_json::from_value(c.clone()).map_err(|e| bad(cmd, format!("bad camera: {e}")))?;
|
|
let ok = [v.target.x, v.target.y, v.target.z, v.yaw, v.pitch, v.distance, v.fov].iter().all(|x| x.is_finite()) && v.distance > 0.0;
|
|
if !ok {
|
|
return Err(bad(cmd, "the camera must be finite with a positive distance"));
|
|
}
|
|
let n = s.doc.named_views.len();
|
|
v.name = match str_(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
|
|
Some(n) if n.len() <= 128 => n.to_string(),
|
|
Some(_) => return Err(bad(cmd, "`name` is too long")),
|
|
None => (n + 1..).map(|k| format!("Named View{k}")).find(|x| !s.doc.named_views.iter().any(|v| v.name == *x)).unwrap_or_default(),
|
|
};
|
|
if n >= 1000 {
|
|
return Err(bad(cmd, "too many named views"));
|
|
}
|
|
let name = v.name.clone();
|
|
let d = s.doc_mut();
|
|
d.named_views.retain(|x| x.name != name);
|
|
d.named_views.push(v);
|
|
Ok(json!({ "view": name }))
|
|
}
|
|
|
|
fn view_index(s: &Session, p: &Value, cmd: &str) -> Result<usize> {
|
|
let k = str_(p, "view").ok_or_else(|| bad(cmd, "`view` must be a view name"))?;
|
|
s.doc.named_views.iter().position(|v| v.name == k).ok_or_else(|| bad(cmd, format!("no named view `{k}`")))
|
|
}
|
|
|
|
fn view_rename(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let cmd = "view.rename";
|
|
let i = view_index(s, p, 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.named_views.iter().enumerate().any(|(j, v)| j != i && v.name == name) {
|
|
return Err(bad(cmd, format!("another view is called `{name}`")));
|
|
}
|
|
if let Some(v) = s.doc_mut().named_views.get_mut(i) {
|
|
v.name = name.to_string();
|
|
}
|
|
Ok(json!({ "view": name }))
|
|
}
|
|
|
|
fn view_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let i = view_index(s, p, "view.delete")?;
|
|
let v = s.doc_mut().named_views.remove(i);
|
|
Ok(json!({ "deleted": v.name }))
|
|
}
|
|
|
|
fn view_list(s: &mut Session, _p: &Value) -> Result<Value> {
|
|
Ok(json!({ "views": s.doc.named_views }))
|
|
}
|
|
|
|
/// Rename an item key in the browser's groups and orders of a folder.
|
|
fn rename_key(d: &mut solvecraft_doc::Document, folder: &str, old: &str, new: &str) {
|
|
for g in d.browser_groups.iter_mut().filter(|g| g.folder == folder) {
|
|
for k in g.items.iter_mut().filter(|k| *k == old) {
|
|
*k = new.to_string();
|
|
}
|
|
}
|
|
for (_, v) in d.browser_order.iter_mut().filter(|(k, _)| k.ends_with(&format!("/{folder}"))) {
|
|
for k in v.iter_mut().filter(|k| *k == old) {
|
|
*k = new.to_string();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Replace every string equal to `old` with `new`; true when something changed.
|
|
fn replace_str(v: &mut Value, old: &str, new: &str, depth: usize) -> bool {
|
|
if depth > 64 {
|
|
return false;
|
|
}
|
|
match v {
|
|
Value::String(s) if s == old => {
|
|
*s = new.to_string();
|
|
true
|
|
}
|
|
Value::Array(a) => a.iter_mut().fold(false, |acc, x| replace_str(x, old, new, depth + 1) | acc),
|
|
Value::Object(o) => o.values_mut().fold(false, |acc, x| replace_str(x, old, new, depth + 1) | acc),
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
fn rename_body(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let cmd = "body.rename";
|
|
let old = str_(p, "body").ok_or_else(|| bad(cmd, "`body` must be a body name"))?.to_string();
|
|
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 name == old {
|
|
return Ok(json!({"body": old, "name": name}));
|
|
}
|
|
let st = s.model.state();
|
|
let b = st.body(&old).ok_or_else(|| bad(cmd, format!("no body `{old}`")))?;
|
|
if st.body(name).is_some() {
|
|
return Err(bad(cmd, format!("another body is called `{name}`")));
|
|
}
|
|
// The body is the k-th new body of its feature; its siblings keep the names they have now
|
|
// (unpinned pattern copies would otherwise be renumbered into the freed name, #265).
|
|
let fid = b.feature;
|
|
let siblings: Vec<String> = st.bodies.iter().filter(|x| x.feature == fid).map(|x| x.name.clone()).collect();
|
|
let k = siblings.iter().position(|n| *n == old).unwrap_or(0);
|
|
let d = s.doc_mut();
|
|
let f = d.feature_mut(fid).ok_or_else(|| bad(cmd, "the body's feature is gone"))?;
|
|
while f.body_names.len() < siblings.len().max(k + 1) {
|
|
let i = f.body_names.len();
|
|
f.body_names.push(siblings.get(i).cloned().unwrap_or_default());
|
|
}
|
|
if let Some(slot) = f.body_names.get_mut(k) {
|
|
*slot = name.to_string();
|
|
}
|
|
// Later features refer to bodies by name.
|
|
let mut failed = None;
|
|
for f in d.features.iter_mut().filter(|f| f.id != fid) {
|
|
if let FeatureKind::Sketch { plane, sketch } = &mut f.kind
|
|
&& let Some(anchor) = super::sketch::face::anchor_mut(plane)
|
|
&& anchor.target.body == old
|
|
{
|
|
let ids = std::collections::BTreeMap::from([(anchor.target.origin_feature, anchor.target.origin_feature)]);
|
|
let bodies = std::collections::BTreeMap::from([(old.clone(), name.to_string())]);
|
|
anchor.remap_target(&ids, &bodies)?;
|
|
for link in &mut sketch.links {
|
|
if link.auto_face && matches!(&link.source,solvecraft_sketch::LinkSource::Face {body,..} if *body==old) {
|
|
link.face_name = Some(anchor.target.name.clone());
|
|
}
|
|
}
|
|
}
|
|
let Ok(mut v) = serde_json::to_value(&f.kind) else { continue };
|
|
if replace_str(&mut v, &old, name, 0) {
|
|
match serde_json::from_value(v) {
|
|
Ok(k) => f.kind = k,
|
|
Err(e) => failed = Some(e.to_string()),
|
|
}
|
|
}
|
|
}
|
|
if let Some(e) = failed {
|
|
return Err(bad(cmd, e));
|
|
}
|
|
if let Some(m) = d.materials.remove(&old) {
|
|
d.materials.insert(name.to_string(), m);
|
|
}
|
|
if let Some(c) = d.appearances.bodies.remove(&old) {
|
|
d.appearances.bodies.insert(name.to_string(), c);
|
|
}
|
|
for f in d.appearances.faces.iter_mut().filter(|f| f.body == old) {
|
|
f.body = name.to_string();
|
|
}
|
|
for column in &mut d.configs.columns {
|
|
match column {
|
|
solvecraft_doc::config::Column::Material(body) | solvecraft_doc::config::Column::Appearance(body) if *body == old => {
|
|
*body = name.to_string();
|
|
}
|
|
solvecraft_doc::config::Column::FaceAppearance(target) if target.body == old => {
|
|
target.body = name.to_string();
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
if let Some(o) = d.body_offsets.remove(&old) {
|
|
d.body_offsets.insert(name.to_string(), o);
|
|
}
|
|
if let Some(c) = d.body_components.remove(&old) {
|
|
d.body_components.insert(name.to_string(), c);
|
|
}
|
|
rename_key(d, "bodies", &old, name);
|
|
for b in s.visibility.hidden_bodies.iter_mut().filter(|b| **b == old) {
|
|
*b = name.to_string();
|
|
}
|
|
Ok(json!({"body": old, "name": name}))
|
|
}
|
|
|
|
fn redefine_sketch(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let cmd = "sketch.redefine";
|
|
let key = match p.get("sketch") {
|
|
Some(Value::Number(n)) => n.to_string(),
|
|
Some(Value::String(x)) => x.clone(),
|
|
_ => return Err(bad(cmd, "`sketch` must be an id or name")),
|
|
};
|
|
let id = match s.doc.find_feature(&key) {
|
|
Some(f) if matches!(f.kind, FeatureKind::Sketch { .. }) => f.id,
|
|
_ => return Err(bad(cmd, format!("no sketch `{key}`"))),
|
|
};
|
|
if p.get("plane").is_none() {
|
|
return Err(bad(cmd, "`plane` is required"));
|
|
}
|
|
let feature = s.doc.feature(id).ok_or_else(|| bad(cmd, "sketch disappeared"))?;
|
|
let component = feature.component;
|
|
let index = s.doc.feature_index(id).ok_or_else(|| bad(cmd, "sketch disappeared"))?;
|
|
let author_path = super::sketch::author_path_for_rebind(s, feature, p, cmd)?;
|
|
let marker = s.doc.marker;
|
|
let mut baseline = s.scratch();
|
|
baseline.doc_mut().marker = Some(index + 1);
|
|
baseline.refresh();
|
|
let visible_geometry = baseline.model.state().sketch(id).map(|resolved| resolved.sketch.clone());
|
|
let mut trial = s.scratch();
|
|
trial.active_component = component;
|
|
trial.active_occurrence_path = (!author_path.is_empty()).then_some(author_path);
|
|
// A new attachment may only depend on geometry preceding this sketch.
|
|
trial.doc_mut().marker = Some(index);
|
|
trial.refresh();
|
|
let old_anchor = trial
|
|
.doc
|
|
.feature(id)
|
|
.and_then(|feature| match &feature.kind {
|
|
FeatureKind::Sketch { plane, .. } => super::sketch::face::anchor(plane),
|
|
_ => None,
|
|
})
|
|
.cloned();
|
|
let plane = super::sketch::plane_ref(&trial, p, cmd)?;
|
|
if super::sketch::face::is_face(p) {
|
|
super::sketch::face::capture_positions(&mut trial, &plane)?;
|
|
}
|
|
if let Some(anchor) = old_anchor
|
|
&& super::sketch::face::anchor(&plane)
|
|
.is_none_or(|next| next.target != anchor.target || next.component != anchor.component || next.path != anchor.path)
|
|
{
|
|
let sketch = trial.doc_mut().sketch_mut(id)?;
|
|
if let Some(mut visible) = visible_geometry {
|
|
let constraints: std::collections::BTreeMap<_, _> =
|
|
sketch.constraints.iter().map(|constraint| (constraint.id.clone(), constraint.clone())).collect();
|
|
for constraint in &mut visible.constraints {
|
|
if let Some(original) = constraints.get(&constraint.id) {
|
|
if std::mem::discriminant(&constraint.kind) != std::mem::discriminant(&original.kind) {
|
|
return Err(bad(cmd, "a dimension role changed during border refresh; repair it explicitly before rebinding"));
|
|
}
|
|
// Keep evaluated entity indices after linked topology rebuilds.
|
|
constraint.param = original.param.clone();
|
|
constraint.driven = original.driven;
|
|
constraint.text = original.text;
|
|
if let Some(value) = original.kind.value() {
|
|
constraint.kind.set_value(value);
|
|
}
|
|
}
|
|
}
|
|
*sketch = visible;
|
|
}
|
|
let affected: Vec<_> = sketch
|
|
.links
|
|
.iter()
|
|
.filter(|link| {
|
|
link.auto_face
|
|
&& link.face_name.as_ref() == Some(&anchor.target.name)
|
|
&& matches!(&link.source,solvecraft_sketch::LinkSource::Face {body,..} if *body==anchor.target.body)
|
|
})
|
|
.map(|link| link.id.clone())
|
|
.collect();
|
|
for link in affected {
|
|
sketch.break_link(&link)?;
|
|
}
|
|
}
|
|
if let Some(FeatureKind::Sketch { plane: slot, .. }) = trial.doc_mut().feature_mut(id).map(|feature| &mut feature.kind) {
|
|
*slot = plane;
|
|
}
|
|
trial.doc_mut().marker = Some(index + 1);
|
|
trial.refresh();
|
|
if let Some(error) = trial.model.result(id).and_then(|result| result.error.as_ref()) {
|
|
return Err(bad(cmd, error.clone()));
|
|
}
|
|
trial.doc_mut().marker = marker;
|
|
trial.refresh();
|
|
s.doc = trial.doc;
|
|
s.pending_moves = trial.pending_moves;
|
|
|
|
let attachment = s.doc.feature(id).and_then(|feature| match &feature.kind {
|
|
FeatureKind::Sketch { plane, .. } => Some(if super::sketch::face::anchor(plane).is_some() { "exact_face" } else { "fixed" }),
|
|
_ => None,
|
|
});
|
|
Ok(json!({ "sketch": id,"attachment":attachment }))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::Session;
|
|
|
|
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 names(s: &Session) -> Vec<String> {
|
|
s.model.state().bodies.iter().map(|b| b.name.clone()).collect()
|
|
}
|
|
|
|
#[test]
|
|
fn renamed_body_keeps_its_users() {
|
|
let mut s = Session::default();
|
|
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
|
|
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0]}));
|
|
let [a, b] = [names(&s)[0].clone(), names(&s)[1].clone()];
|
|
run(&mut s, "solid.move", json!({"bodies": [b.clone()], "translate": [0, 0, 5]}));
|
|
run(&mut s, "body.rename", json!({"body": b, "name": "Lid"}));
|
|
assert_eq!(names(&s), vec![a.clone(), "Lid".to_string()]);
|
|
assert!(s.model.results.iter().all(|r| r.error.is_none()), "the move still finds its body");
|
|
// Duplicate names and unknown bodies are refused; undo restores the old name.
|
|
assert!(s.execute("body.rename", &json!({"body": "Lid", "name": a})).is_err());
|
|
assert!(s.execute("body.rename", &json!({"body": "Nope", "name": "X"})).is_err());
|
|
assert!(s.execute("body.rename", &json!({"body": "Lid", "name": " "})).is_err());
|
|
run(&mut s, "edit.undo", json!({}));
|
|
assert_eq!(names(&s)[1], b);
|
|
}
|
|
|
|
#[test]
|
|
fn redefined_sketch_moves_its_extrude() {
|
|
let mut s = Session::default();
|
|
let sk = run(&mut s, "sketch.create", json!({"plane": "XY"}))["sketch"].as_u64().unwrap();
|
|
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 20]}));
|
|
run(&mut s, "sketch.finish", json!({}));
|
|
run(&mut s, "solid.extrude", json!({"distance": 5}));
|
|
let zmax = |s: &mut Session| run(s, "inspect.measure", json!({}))["bodies"][0]["bbox"]["max"][2].as_f64().unwrap();
|
|
assert!((zmax(&mut s) - 5.0).abs() < 1e-6);
|
|
run(&mut s, "sketch.redefine", json!({"sketch": sk, "plane": "XZ"}));
|
|
let z = zmax(&mut s);
|
|
assert!(z.abs() < 1e-6, "positive sketch Y on XZ extends toward -Z: {z}");
|
|
let ymin = run(&mut s, "inspect.measure", json!({}))["bodies"][0]["bbox"]["min"][2].as_f64().unwrap();
|
|
assert!((ymin + 20.0).abs() < 1e-6);
|
|
assert!(s.execute("sketch.redefine", &json!({"sketch": sk, "plane": "nope"})).is_err());
|
|
assert!(s.execute("sketch.redefine", &json!({"sketch": 999, "plane": "XY"})).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn design_units_change_without_moving_geometry() {
|
|
let mut s = Session::default();
|
|
run(&mut s, "solid.box", json!({"length": 20, "width": 10, "height": 5}));
|
|
let vol = |s: &mut Session| run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
|
|
let v0 = vol(&mut s);
|
|
run(&mut s, "document.units", json!({"units": "in"}));
|
|
assert_eq!(s.doc.units, "in");
|
|
assert!((vol(&mut s) - v0).abs() < 1e-6, "feature inputs keep their millimetres");
|
|
assert!(s.execute("document.units", &json!({"units": "furlong"})).is_err());
|
|
// A unit-less parameter used as a length keeps its millimetres; a plain count stays a number.
|
|
let mut s = Session::default();
|
|
run(&mut s, "parameters.add", json!({"name": "w", "expression": "5", "unit": ""}));
|
|
run(&mut s, "parameters.add", json!({"name": "n", "expression": "3", "unit": ""}));
|
|
run(&mut s, "solid.box", json!({"length": "w", "width": 10, "height": 5}));
|
|
let b = s.model.state().bodies[0].name.clone();
|
|
run(&mut s, "solid.pattern.rectangular", json!({"bodies": [b], "dir1": [1, 0, 0], "count1": "n", "spacing1": 20}));
|
|
let v0 = vol(&mut s);
|
|
let r = run(&mut s, "document.units", json!({"units": "cm"}));
|
|
assert!((vol(&mut s) - v0).abs() < 1e-6, "{r}");
|
|
assert!(s.doc.param("n").is_some_and(|p| p.expr == "3"), "the count stays bare");
|
|
run(&mut s, "edit.undo", json!({}));
|
|
assert_eq!(s.doc.units, "mm");
|
|
}
|
|
|
|
#[test]
|
|
fn a_bare_width_keeps_its_inches_when_the_design_goes_metric() {
|
|
let mut s = Session::default();
|
|
run(&mut s, "document.units", json!({"units": "in"}));
|
|
run(&mut s, "parameters.add", json!({"name": "w", "expression": "2", "unit": ""}));
|
|
run(&mut s, "solid.box", json!({"length": 10, "width": "w", "height": 5}));
|
|
let width = |s: &mut Session| {
|
|
let b = &run(s, "inspect.measure", json!({}))["bodies"][0]["bbox"];
|
|
b["max"][1].as_f64().unwrap() - b["min"][1].as_f64().unwrap()
|
|
};
|
|
assert!((width(&mut s) - 50.8).abs() < 1e-6, "2 in an inch design");
|
|
let r = run(&mut s, "document.units", json!({"units": "mm"}));
|
|
assert!((width(&mut s) - 50.8).abs() < 1e-6, "still 50.8 mm: {r}");
|
|
assert_eq!(r["kept"], json!(["w"]));
|
|
assert_eq!(s.doc.param("w").map(|p| p.expr.as_str()), Some("2 in"));
|
|
}
|
|
|
|
#[test]
|
|
fn named_views_are_saved_renamed_and_deleted() {
|
|
let mut s = Session::default();
|
|
let cam = json!({"target": [1, 2, 3], "yaw": 0.5, "pitch": 0.3, "distance": 100});
|
|
assert_eq!(run(&mut s, "view.save", json!({"camera": cam}))["view"], "Named View1");
|
|
run(&mut s, "view.save", json!({"camera": cam, "name": "Detail"}));
|
|
run(&mut s, "view.rename", json!({"view": "Named View1", "name": "Overview"}));
|
|
assert!(s.execute("view.rename", &json!({"view": "Overview", "name": "Detail"})).is_err());
|
|
let back: solvecraft_doc::Document = serde_json::from_str(&serde_json::to_string(&*s.doc).unwrap()).unwrap();
|
|
assert_eq!(back.named_views.len(), 2);
|
|
assert_eq!(back.named_views[0].target.z, 3.0);
|
|
run(&mut s, "view.delete", json!({"view": "Detail"}));
|
|
assert_eq!(run(&mut s, "view.list", json!({}))["views"].as_array().unwrap().len(), 1);
|
|
for p in [json!({}), json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": -1}}), json!({"camera": "x"})] {
|
|
assert!(s.execute("view.save", &p).is_err(), "{p}");
|
|
}
|
|
run(&mut s, "edit.undo", json!({}));
|
|
assert_eq!(s.doc.named_views.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn helper_sketches_hide_and_show() {
|
|
let mut s = Session::default();
|
|
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
|
|
let floor = run(&mut s, "sketch.create", json!({"plane": "XY"}))["sketch"].as_u64().unwrap();
|
|
run(&mut s, "sketch.line", json!({"points": [[0, 0], [50, 0]], "construction": true}));
|
|
run(&mut s, "sketch.finish", json!({}));
|
|
let drain = run(&mut s, "sketch.create", json!({"plane": "XY"}))["sketch"].as_u64().unwrap();
|
|
run(&mut s, "sketch.point", json!({"point": [25, 5]}));
|
|
run(&mut s, "sketch.finish", json!({}));
|
|
run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": 5, "name": "Floor"}));
|
|
let body = s.model.state().bodies[0].name.clone();
|
|
let drain_name = s.doc.feature(drain).unwrap().name.clone();
|
|
let cam = solvecraft_render::Camera::default();
|
|
let lines = |s: &Session| crate::view::scene(s, &cam).lines.len();
|
|
let shown = |s: &mut Session| {
|
|
let d = run(s, "document.inspect", json!({}));
|
|
d["sketches"].as_array().unwrap().iter().filter(|x| x["visible"] == true).count()
|
|
};
|
|
let (n0, undo0, doc0) = (lines(&s), s.undo.len(), s.doc.clone());
|
|
assert_eq!(shown(&mut s), 2);
|
|
// By id and by name; the view loses their lines, the design and its history don't change.
|
|
let r = run(&mut s, "browser.visibility", json!({"items": [floor, drain_name], "visible": false}));
|
|
assert_eq!(r["changed"], 2);
|
|
assert_eq!(shown(&mut s), 0);
|
|
assert!(lines(&s) < n0);
|
|
assert!(std::sync::Arc::ptr_eq(&s.doc, &doc0) && s.undo.len() == undo0, "view state, not a design change");
|
|
run(&mut s, "browser.visibility", json!({"folder": "sketches", "visible": true}));
|
|
assert_eq!(lines(&s), n0);
|
|
// Bodies (following a rename) and construction planes.
|
|
run(&mut s, "browser.visibility", json!({"items": [body.clone(), "Floor", "xy"], "visible": false}));
|
|
assert!(crate::view::scene(&s, &cam).meshes.is_empty());
|
|
assert_eq!(s.visibility.hidden_origin, vec!["Floor".to_string(), "XY".to_string()]);
|
|
run(&mut s, "body.rename", json!({"body": body, "name": "Tray"}));
|
|
assert_eq!(s.visibility.hidden_bodies, vec!["Tray".to_string()]);
|
|
let d = run(&mut s, "document.inspect", json!({}));
|
|
assert_eq!(d["bodies"][0]["visible"], false);
|
|
// Hostile input is refused and changes nothing.
|
|
let before = s.visibility.clone();
|
|
for p in [
|
|
json!({"items": [floor]}),
|
|
json!({"items": [floor], "visible": "no"}),
|
|
json!({"items": [floor, "Nope"], "visible": false}),
|
|
json!({"items": [], "visible": false}),
|
|
json!({"items": "Floor", "visible": false}),
|
|
json!({"folder": "sketches", "items": [floor], "visible": false}),
|
|
json!({"folder": "nope", "visible": false}),
|
|
json!({"visible": false}),
|
|
] {
|
|
assert!(s.execute("browser.visibility", &p).is_err(), "{p}");
|
|
}
|
|
assert_eq!(s.visibility, before);
|
|
}
|
|
|
|
#[test]
|
|
fn groups_hold_items_and_follow_renames() {
|
|
let mut s = Session::default();
|
|
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
|
|
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0]}));
|
|
let [a, b] = [names(&s)[0].clone(), names(&s)[1].clone()];
|
|
let g = run(&mut s, "browser.group", json!({"folder": "bodies", "items": [a.clone()]}))["group"].as_u64().unwrap();
|
|
assert_eq!(s.doc.browser_groups[0].name, "Group1");
|
|
run(&mut s, "browser.move", json!({"folder": "bodies", "items": [b.clone()], "group": g, "before": a.clone()}));
|
|
assert_eq!(s.doc.browser_groups[0].items, vec![b.clone(), a.clone()]);
|
|
run(&mut s, "browser.rename_group", json!({"group": g, "name": "Fasteners"}));
|
|
run(&mut s, "body.rename", json!({"body": a, "name": "Bolt"}));
|
|
assert_eq!(s.doc.browser_groups[0].items, vec![b.clone(), "Bolt".to_string()]);
|
|
// Out of the group, then a folder order; saved with the design.
|
|
run(&mut s, "browser.move", json!({"folder": "bodies", "items": [b.clone()]}));
|
|
assert_eq!(s.doc.browser_groups[0].items, vec!["Bolt".to_string()]);
|
|
run(&mut s, "browser.order", json!({"folder": "bodies", "order": [b.clone(), "Bolt"]}));
|
|
let saved: solvecraft_doc::Document = serde_json::from_str(&serde_json::to_string(&*s.doc).unwrap()).unwrap();
|
|
assert_eq!(saved.browser_groups, s.doc.browser_groups);
|
|
assert_eq!(saved.browser_order["0/bodies"], vec![b, "Bolt".to_string()]);
|
|
run(&mut s, "browser.ungroup", json!({"group": g}));
|
|
assert!(s.doc.browser_groups.is_empty());
|
|
// Hostile input is refused.
|
|
for (c, p) in [
|
|
("browser.group", json!({"folder": "nope"})),
|
|
("browser.group", json!({"folder": "bodies", "component": 99})),
|
|
("browser.group", json!({"folder": "bodies", "items": "x"})),
|
|
("browser.move", json!({"folder": "bodies", "items": []})),
|
|
("browser.move", json!({"folder": "bodies", "items": ["x"], "group": 42})),
|
|
("browser.ungroup", json!({"group": -1})),
|
|
("browser.rename_group", json!({"group": g, "name": "x"})),
|
|
] {
|
|
assert!(s.execute(c, &p).is_err(), "{c} {p}");
|
|
}
|
|
run(&mut s, "edit.undo", json!({}));
|
|
assert_eq!(s.doc.browser_groups.len(), 1, "ungroup undoes");
|
|
}
|
|
}
|