Record ownership transfers in the timeline so earlier Join/Cut membership, world placement, machining data and styles survive component moves, edits, independent copying, undo and reopening. Preserve pending positions in history snapshots and keep constructor transfers ordered during feature redefinition. Retain shallow sheet pockets and exact planar intersection islands through unfold/refold, with depth-aware flat previews and DXF export. Store explicit 3D wire coordinate spaces and migrate known legacy provenance without moving saved points or analytic fits. Infer unique eligible cylindrical Emboss targets, align Revolve manipulators with actual extent geometry, and discard obsolete preview errors after input correction. Certify constructed and immutable imported face styles using exact source metadata while preserving legacy face names. Upgrade designs to format10 with bounded validation and geometry-preserving legacy migration.
738 lines
35 KiB
Rust
738 lines
35 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: XY|XZ|YZ | construction plane name | {face: [x,y,z]} | {origin, normal} (the sketch keeps its geometry)"),
|
|
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(),
|
|
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();
|
|
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)?;
|
|
}
|
|
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; the names before it are kept as they are.
|
|
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() <= k {
|
|
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) {
|
|
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 plane = super::sketch::plane_ref(s, p, cmd)?;
|
|
let (vals, _) = s.doc.param_values();
|
|
s.doc.resolve_plane_in(&vals, &plane, 0, Some(&s.model.state()))?;
|
|
if let Some(FeatureKind::Sketch { plane: slot, .. }) = s.doc_mut().feature_mut(id).map(|f| &mut f.kind) {
|
|
*slot = plane;
|
|
}
|
|
Ok(json!({ "sketch": id }))
|
|
}
|
|
|
|
#[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");
|
|
}
|
|
}
|