feat: preserve modeling intent across sketches, assemblies, and fabrication
Persist typed sketch dimensions and bounded ordered configuration rules in format 8. Use exact occurrence paths and source identity for editing, movement, appearance, and mass. Preserve sheet material regions through cutting, folding, and exact DXF export; retain nested cavities and material islands. Validation: cargo xtask ci passed, including 368 engine tests, 178 UI tests, 68 UI scenarios, 136 kernel tests, and the 10-crate wasm check. Actual preview workflows and legacy format-7 origin-corner reopening were inspected.
This commit is contained in:
1 parent
d71bdb78ec
commit
07932d0f42
65 files changed
+6145
-484
No files matched your search
@@ -36,7 +36,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
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). View state: not saved, not an undo step",
|
||||
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",
|
||||
),
|
||||
];
|
||||
|
||||
@@ -191,6 +191,66 @@ fn shown_item(s: &Session, key: &str) -> Option<Shown> {
|
||||
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.body_component(&b.name, b.feature) == 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(),
|
||||
@@ -211,6 +271,9 @@ fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(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) => {
|
||||
@@ -247,6 +310,7 @@ fn document_units(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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
|
||||
@@ -278,6 +342,10 @@ fn document_units(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
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 }))
|
||||
}
|
||||
|
||||
|
||||
@@ -8,21 +8,26 @@ use solvecraft_doc::{FeatureKind, Mat, Occurrence};
|
||||
use solvecraft_geom::Vec3;
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, bool_, num, str_, string_list, vec3};
|
||||
use crate::params::{bad, bool_, str_, string_list, vec3};
|
||||
use crate::{Result, Session};
|
||||
|
||||
#[path = "component_properties.rs"]
|
||||
mod properties;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "occurrence_tests.rs"]
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
mod occurrence_tests;
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("component.create", "New Component", new_component)
|
||||
.at("SOLID", "ASSEMBLE")
|
||||
.icon("component")
|
||||
.params("name?, parent?: component id or name (default: the active one); activate?: bool (default true)"),
|
||||
.params("name?, parent?: component id or name (default: the active one); parent_path?: exact parent placement; activate?: bool (default true)"),
|
||||
CommandSpec::new("component.from_bodies", "Create Components from Bodies", from_bodies)
|
||||
.icon("component")
|
||||
.params("bodies: [names] → one new component per body, named after it"),
|
||||
CommandSpec::new("component.activate", "Activate Component", activate).noundo().params("component: id or name (0 or \"root\" for the design)"),
|
||||
CommandSpec::new("component.activate", "Activate Component", activate).noundo().params("component: id or name (0 or \"root\" for the design); occurrence?, path?: exact placement used to author shared geometry"),
|
||||
CommandSpec::new("component.list", "List Components", list).noundo(),
|
||||
CommandSpec::new("component.properties", "Component Physical Properties", properties::component)
|
||||
.icon("measure").noundo()
|
||||
@@ -35,13 +40,13 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("component.move_bodies", "Move Bodies to Component", move_bodies).params("bodies: [names], component"),
|
||||
CommandSpec::new("component.move_sketches", "Move Sketches to Component", move_sketches).params("sketches: [ids or names], component"),
|
||||
CommandSpec::new("occurrence.copy", "Paste (Instance)", copy_occurrence)
|
||||
.params("component (or occurrence): what to place again; parent?: component (default: the same parent); translate?: [x,y,z]"),
|
||||
CommandSpec::new("component.paste_new", "Paste New", paste_new).params("component: copied with its features into a new, independent component; translate?: [x,y,z]"),
|
||||
.params("component (or occurrence): what to place again; path?: validates selected occurrence; parent?: component (default: the same parent); translate?: [x,y,z]. Copies the selected effective transform, including pending moves"),
|
||||
CommandSpec::new("component.paste_new", "Paste New", paste_new).params("component: copied with its own features into a new, independent component; occurrence?, path?: selected source placement; translate?: [x,y,z]"),
|
||||
CommandSpec::new("occurrence.move", "Move Occurrence", move_occurrence)
|
||||
.params("occurrence; translate?: [x,y,z]; axis?: [x,y,z], angle?: radians or \"30 deg\", origin?: [x,y,z]; capture?: bool (default true; false keeps it pending until Capture Position)"),
|
||||
.params("occurrence; path?: exact placement; space?: local|world (default local); translate?: [x,y,z]; axis?: [x,y,z], angle?: radians or \"30 deg\", origin?: [x,y,z]; capture?: bool (default true; false keeps it pending until Capture Position). Nested occurrence definitions remain shared"),
|
||||
CommandSpec::new("occurrence.ground", "Ground", ground).params("occurrence; grounded?: bool (default toggles)"),
|
||||
CommandSpec::new("component.capture_position", "Capture Position", capture).at("SOLID", "POSITION").icon("capture").params("keeps pending occurrence moves"),
|
||||
CommandSpec::new("component.revert_position", "Revert Position", revert).at("SOLID", "POSITION").icon("revert").noundo().params("drops pending occurrence moves"),
|
||||
CommandSpec::new("component.revert_position", "Revert Position", revert).at("SOLID", "POSITION").icon("revert").noundo().params("occurrences?: [ids] (default all); drops selected pending occurrence moves"),
|
||||
];
|
||||
|
||||
fn key(v: Option<&Value>) -> Option<String> {
|
||||
@@ -66,12 +71,61 @@ fn translation(t: Vec3) -> Mat {
|
||||
solvecraft_doc::rigid(t, Vec3::ZERO, Vec3::Z, 0.0)
|
||||
}
|
||||
|
||||
fn move_vector(p: &Value, key: &str, default: Vec3, coordinate: bool, cmd: &str) -> Result<Vec3> {
|
||||
let v = match p.get(key) {
|
||||
None => default,
|
||||
Some(value) => vec3(value).ok_or_else(|| bad(cmd, format!("{key} must contain three finite numbers")))?,
|
||||
};
|
||||
if !v.is_finite() || (coordinate && [v.x, v.y, v.z].iter().any(|x| x.abs() > 1e9)) {
|
||||
return Err(bad(cmd, format!("{key} exceeds supported numeric range")));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn valid_position(m: &Mat) -> bool {
|
||||
let column = |i: usize| Vec3::new(m[i][0], m[i][1], m[i][2]);
|
||||
let (x, y, z) = (column(0), column(1), column(2));
|
||||
m.iter().flatten().all(|x| x.is_finite())
|
||||
&& m[3][..3].iter().all(|x| x.abs() <= 1e9)
|
||||
&& [x, y, z].iter().all(|axis| (axis.dot(*axis) - 1.0).abs() < 1e-6)
|
||||
&& x.dot(y).abs() < 1e-6
|
||||
&& x.dot(z).abs() < 1e-6
|
||||
&& y.dot(z).abs() < 1e-6
|
||||
&& x.cross(y).dot(z) > 0.0
|
||||
&& [m[0][3], m[1][3], m[2][3]].iter().all(|x| x.abs() < 1e-9)
|
||||
&& (m[3][3] - 1.0).abs() < 1e-9
|
||||
}
|
||||
|
||||
fn new_component(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.create";
|
||||
let parent = if p.get("parent").is_some() { component(s, p, "parent", cmd)? } else { s.active_component };
|
||||
let parent_path = if let Some(value) = p.get("parent_path") {
|
||||
let path = value
|
||||
.as_array()
|
||||
.filter(|a| a.len() < 64)
|
||||
.and_then(|a| a.iter().map(Value::as_u64).collect::<Option<Vec<_>>>())
|
||||
.ok_or_else(|| bad(cmd, "parent_path must list up to 63 occurrence ids"))?;
|
||||
let component = if path.is_empty() { 0 } else { s.occurrence_placement(&path).map_err(|e| bad(cmd, e.to_string()))?.component };
|
||||
if component != parent {
|
||||
return Err(bad(cmd, "parent_path belongs to another component"));
|
||||
}
|
||||
path
|
||||
} else if parent == s.active_component {
|
||||
s.active_occurrence_path.clone().unwrap_or_else(|| s.doc.first_path(parent))
|
||||
} else {
|
||||
s.doc.first_path(parent)
|
||||
};
|
||||
if parent_path.len() >= 64 {
|
||||
return Err(bad(cmd, "new component exceeds occurrence depth limit"));
|
||||
}
|
||||
let id = s.doc_mut().add_component(str_(p, "name"), parent)?;
|
||||
if p.get("activate").and_then(Value::as_bool).unwrap_or(true) {
|
||||
let mut path = parent_path;
|
||||
if let Some(o) = s.doc.occurrence_of(id) {
|
||||
path.push(o.id);
|
||||
}
|
||||
s.active_component = id;
|
||||
s.active_occurrence_path = Some(path);
|
||||
}
|
||||
let name = s.doc.components.iter().find(|c| c.id == id).map(|c| c.name.clone()).unwrap_or_default();
|
||||
let occ = s.doc.occurrence_of(id).map(|o| o.id);
|
||||
@@ -102,7 +156,18 @@ fn from_bodies(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn activate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = component(s, p, "component", "component.activate")?;
|
||||
let path = if p.get("path").is_some() || p.get("occurrence").is_some() {
|
||||
let occurrence = occurrence(s, p, "component.activate")?;
|
||||
let path = occurrence_path(s, p, occurrence, "component.activate")?;
|
||||
if s.occurrence_placement(&path)?.component != id {
|
||||
return Err(bad("component.activate", "path belongs to another component"));
|
||||
}
|
||||
Some(path)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
s.active_component = id;
|
||||
s.active_occurrence_path = path;
|
||||
s.revision += 1;
|
||||
Ok(json!({ "active": id }))
|
||||
}
|
||||
@@ -231,16 +296,30 @@ fn move_sketches(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn copy_occurrence(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "occurrence.copy";
|
||||
let comp = if p.get("occurrence").is_some() {
|
||||
let selected = if p.get("occurrence").is_some() {
|
||||
let o = occurrence(s, p, cmd)?;
|
||||
s.doc.occurrences.iter().find(|x| x.id == o).map(|x| x.component).unwrap_or(0)
|
||||
Some(o)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let comp = if let Some(id) = selected {
|
||||
s.doc.occurrences.iter().find(|o| o.id == id).map(|o| o.component).unwrap_or(0)
|
||||
} else {
|
||||
component(s, p, "component", cmd)?
|
||||
};
|
||||
let src = s.doc.occurrence_of(comp).cloned().ok_or_else(|| bad(cmd, "that component has no occurrence"))?;
|
||||
let src = if let Some(id) = selected { s.doc.occurrences.iter().find(|o| o.id == id) } else { s.doc.occurrence_of(comp) }
|
||||
.cloned()
|
||||
.ok_or_else(|| bad(cmd, "that component has no occurrence"))?;
|
||||
if p.get("path").is_some() {
|
||||
occurrence_path(s, p, src.id, cmd)?;
|
||||
}
|
||||
let parent = if p.get("parent").is_some() { component(s, p, "parent", cmd)? } else { src.parent };
|
||||
let t = p.get("translate").and_then(vec3).unwrap_or(Vec3::ZERO);
|
||||
let m = solvecraft_doc::mat_mul(&translation(t), &src.transform);
|
||||
let t = move_vector(p, "translate", Vec3::ZERO, true, cmd)?;
|
||||
let base = s.pending_moves.get(&src.id).unwrap_or(&src.transform);
|
||||
let m = solvecraft_doc::mat_mul(&translation(t), base);
|
||||
if !valid_position(&m) {
|
||||
return Err(bad(cmd, "copy transform exceeds supported numeric range"));
|
||||
}
|
||||
let id = s.doc_mut().add_occurrence(comp, parent, m)?;
|
||||
Ok(json!({"occurrence": id, "component": comp}))
|
||||
}
|
||||
@@ -252,8 +331,27 @@ fn paste_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, "pick a component, not the design"));
|
||||
}
|
||||
let src = s.doc.components.iter().find(|c| c.id == comp).cloned().ok_or_else(|| bad(cmd, "component"))?;
|
||||
let occ = s.doc.occurrence_of(comp).cloned();
|
||||
let new_id = s.doc_mut().add_component(Some(&format!("{} (copy)", src.name)), src.parent)?;
|
||||
let occ = if p.get("occurrence").is_some() {
|
||||
let id = occurrence(s, p, cmd)?;
|
||||
if p.get("path").is_some() {
|
||||
occurrence_path(s, p, id, cmd)?;
|
||||
}
|
||||
let occurrence = s.doc.occurrences.iter().find(|o| o.id == id).cloned().ok_or_else(|| bad(cmd, "no occurrence"))?;
|
||||
if occurrence.component != comp {
|
||||
return Err(bad(cmd, "occurrence belongs to another component"));
|
||||
}
|
||||
Some(occurrence)
|
||||
} else {
|
||||
s.doc.occurrence_of(comp).cloned()
|
||||
};
|
||||
let t = move_vector(p, "translate", Vec3::ZERO, true, cmd)?;
|
||||
let base = occ.as_ref().map(|o| s.pending_moves.get(&o.id).copied().unwrap_or(o.transform)).unwrap_or(solvecraft_doc::IDENTITY);
|
||||
let transform = solvecraft_doc::mat_mul(&translation(t), &base);
|
||||
if !valid_position(&transform) {
|
||||
return Err(bad(cmd, "copy transform exceeds supported numeric range"));
|
||||
}
|
||||
let parent = occ.as_ref().map(|o| o.parent).unwrap_or(src.parent);
|
||||
let new_id = s.doc_mut().add_component(Some(&format!("{} (copy)", src.name)), parent)?;
|
||||
// Copy the component's features with fresh ids and names; references between them follow.
|
||||
let feats: Vec<solvecraft_doc::Feature> = s.doc.features.iter().filter(|f| f.component == comp).cloned().collect();
|
||||
let mut idmap: std::collections::HashMap<u64, u64> = Default::default();
|
||||
@@ -276,10 +374,8 @@ fn paste_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
slot.component = new_id;
|
||||
}
|
||||
}
|
||||
let t = p.get("translate").and_then(vec3).unwrap_or(Vec3::ZERO);
|
||||
let base = occ.map(|o| o.transform).unwrap_or(solvecraft_doc::IDENTITY);
|
||||
if let Some(o) = s.doc_mut().occurrences.iter_mut().find(|o| o.component == new_id) {
|
||||
o.transform = solvecraft_doc::mat_mul(&translation(t), &base);
|
||||
o.transform = transform;
|
||||
}
|
||||
Ok(json!({"component": new_id, "features": new_ids}))
|
||||
}
|
||||
@@ -316,6 +412,34 @@ fn remap(v: &mut Value, ids: &std::collections::HashMap<u64, u64>, names: &std::
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn occurrence_path(s: &Session, p: &Value, id: u64, cmd: &str) -> Result<Vec<u64>> {
|
||||
let path = if let Some(value) = p.get("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, "`path` must list 1..64 occurrence ids"))?
|
||||
} else {
|
||||
let o = s.doc.occurrences.iter().find(|o| o.id == id).ok_or_else(|| bad(cmd, "no occurrence"))?;
|
||||
let mut path = s.doc.first_path(o.parent);
|
||||
// A world frame requires an explicit containing placement when any ancestor is instanced.
|
||||
for ancestor in &path {
|
||||
if let Some(a) = s.doc.occurrences.iter().find(|a| a.id == *ancestor)
|
||||
&& s.doc.occurrences.iter().filter(|x| x.component == a.component).count() > 1
|
||||
{
|
||||
return Err(bad(cmd, "occurrence has multiple placements; supply its `path`"));
|
||||
}
|
||||
}
|
||||
path.push(id);
|
||||
path
|
||||
};
|
||||
if path.last() != Some(&id) {
|
||||
return Err(bad(cmd, "path and occurrence disagree"));
|
||||
}
|
||||
s.occurrence_placement(&path).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn move_occurrence(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "occurrence.move";
|
||||
let id = occurrence(s, p, cmd)?;
|
||||
@@ -323,19 +447,56 @@ fn move_occurrence(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if o.grounded {
|
||||
return Err(bad(cmd, format!("{} is grounded", o.name)));
|
||||
}
|
||||
let t = p.get("translate").and_then(vec3).unwrap_or(Vec3::ZERO);
|
||||
let axis = p.get("axis").and_then(vec3).unwrap_or(Vec3::Z);
|
||||
let t = move_vector(p, "translate", Vec3::ZERO, true, cmd)?;
|
||||
let axis = move_vector(p, "axis", Vec3::Z, false, cmd)?;
|
||||
let angle = match p.get("angle") {
|
||||
Some(Value::String(e)) => s.doc.eval(e, solvecraft_doc::expr::Kind::Angle).map_err(|e| bad(cmd, e.to_string()))?,
|
||||
_ => num(p, "angle").unwrap_or(0.0),
|
||||
None => 0.0,
|
||||
Some(value) => value.as_f64().ok_or_else(|| bad(cmd, "angle must be a number or angle expression"))?,
|
||||
};
|
||||
if !(t.is_finite() && angle.is_finite()) {
|
||||
if !(t.is_finite() && axis.is_finite() && angle.is_finite()) {
|
||||
return Err(bad(cmd, "non-finite move"));
|
||||
}
|
||||
let origin = p.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO);
|
||||
if angle != 0.0 && axis.normalized().is_none() {
|
||||
return Err(bad(cmd, "rotation axis must have a finite nonzero length"));
|
||||
}
|
||||
let origin = move_vector(p, "origin", Vec3::ZERO, true, cmd)?;
|
||||
let delta = solvecraft_doc::rigid(t, origin, axis, angle);
|
||||
let cur = s.pending_moves.get(&id).copied().unwrap_or(o.transform);
|
||||
let space = match p.get("space") {
|
||||
None => "local",
|
||||
Some(Value::String(space)) => space.as_str(),
|
||||
_ => return Err(bad(cmd, "space must be local or world")),
|
||||
};
|
||||
if !matches!(space, "local" | "world") {
|
||||
return Err(bad(cmd, "space must be local or world"));
|
||||
}
|
||||
let path = if p.get("path").is_some() || space == "world" { Some(occurrence_path(s, p, id, cmd)?) } else { None };
|
||||
let delta = if space == "world" {
|
||||
let placement = s.occurrence_placement(path.as_deref().unwrap_or(&[]))?;
|
||||
let inverse = solvecraft_doc::mat_inverse(&placement.parent_world).ok_or_else(|| bad(cmd, "parent frame is singular"))?;
|
||||
solvecraft_doc::mat_mul(&solvecraft_doc::mat_mul(&inverse, &delta), &placement.parent_world)
|
||||
} else {
|
||||
delta
|
||||
};
|
||||
let cur = if let Some(value) = p.get("base_transform") {
|
||||
let base: Mat = serde_json::from_value(value.clone()).map_err(|_| bad(cmd, "base_transform must be a 4x4 affine matrix"))?;
|
||||
if !valid_position(&base)
|
||||
|| base[0][3].abs() > 1e-12
|
||||
|| base[1][3].abs() > 1e-12
|
||||
|| base[2][3].abs() > 1e-12
|
||||
|| (base[3][3] - 1.0).abs() > 1e-12
|
||||
|| solvecraft_doc::mat_inverse(&base).is_none()
|
||||
{
|
||||
return Err(bad(cmd, "base_transform must be finite, affine and invertible"));
|
||||
}
|
||||
base
|
||||
} else {
|
||||
s.pending_moves.get(&id).copied().unwrap_or(o.transform)
|
||||
};
|
||||
let next = solvecraft_doc::mat_mul(&delta, &cur);
|
||||
if !valid_position(&next) {
|
||||
return Err(bad(cmd, "move transform exceeds supported numeric range"));
|
||||
}
|
||||
if bool_(p, "capture").unwrap_or(true) {
|
||||
s.pending_moves.remove(&id);
|
||||
if let Some(x) = s.doc_mut().occurrences.iter_mut().find(|x| x.id == id) {
|
||||
@@ -351,6 +512,9 @@ fn move_occurrence(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn ground(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "occurrence.ground";
|
||||
let id = occurrence(s, p, cmd)?;
|
||||
if p.get("path").is_some() {
|
||||
occurrence_path(s, p, id, cmd)?;
|
||||
}
|
||||
let want = bool_(p, "grounded");
|
||||
let o = s.doc_mut().occurrences.iter_mut().find(|o| o.id == id).ok_or_else(|| bad(cmd, "occurrence"))?;
|
||||
o.grounded = want.unwrap_or(!o.grounded);
|
||||
@@ -370,9 +534,24 @@ fn capture(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "captured": n }))
|
||||
}
|
||||
|
||||
fn revert(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let n = s.pending_moves.len();
|
||||
s.pending_moves.clear();
|
||||
fn revert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let before = s.pending_moves.len();
|
||||
if let Some(value) = p.get("occurrences") {
|
||||
let ids = value
|
||||
.as_array()
|
||||
.filter(|a| !a.is_empty() && a.len() <= 10_000)
|
||||
.and_then(|a| a.iter().map(Value::as_u64).collect::<Option<Vec<_>>>())
|
||||
.ok_or_else(|| bad("component.revert_position", "occurrences must list 1..10000 ids"))?;
|
||||
if ids.iter().any(|id| !s.doc.occurrences.iter().any(|o| o.id == *id)) {
|
||||
return Err(bad("component.revert_position", "no occurrence"));
|
||||
}
|
||||
for id in ids {
|
||||
s.pending_moves.remove(&id);
|
||||
}
|
||||
} else {
|
||||
s.pending_moves.clear();
|
||||
}
|
||||
let n = before - s.pending_moves.len();
|
||||
s.revision += 1;
|
||||
Ok(json!({ "reverted": n }))
|
||||
}
|
||||
@@ -383,7 +562,7 @@ pub fn to_active_frame(s: &Session, kind: &mut FeatureKind) {
|
||||
if s.active_component == 0 || crate::frames::mapped() {
|
||||
return;
|
||||
}
|
||||
let m = s.doc.component_transform(s.active_component);
|
||||
let m = s.active_component_frame();
|
||||
if solvecraft_doc::is_identity(&m) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -64,18 +64,11 @@ pub(super) fn occurrence(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "occurrence.properties";
|
||||
let id = super::occurrence(s, p, cmd)?;
|
||||
let doc = placed_doc(s);
|
||||
let paths: Vec<Vec<u64>> = placements(&doc, cmd)?.into_iter().filter(|(_, path, _)| path.last() == Some(&id)).map(|(_, path, _)| path).collect();
|
||||
let path = if let Some(value) = p.get("path") {
|
||||
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, "`path` must list 1…64 occurrence ids"))?;
|
||||
if !paths.contains(&path) {
|
||||
return Err(bad(cmd, "`path` does not identify a placement of this occurrence"));
|
||||
}
|
||||
path
|
||||
let path = if p.get("path").is_some() {
|
||||
super::occurrence_path(s, p, id, cmd)?
|
||||
} else {
|
||||
let paths: Vec<Vec<u64>> =
|
||||
placements(&doc, cmd)?.into_iter().filter(|(_, path, _)| path.last() == Some(&id)).map(|(_, path, _)| path).collect();
|
||||
match paths.as_slice() {
|
||||
[path] => path.clone(),
|
||||
[] => return Err(bad(cmd, "occurrence has no placement")),
|
||||
@@ -97,9 +90,27 @@ fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool,
|
||||
(Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY), Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY));
|
||||
let (mut faces, mut edges, mut vertices) = (0, 0, 0);
|
||||
let mut placement_count = 0;
|
||||
for (component, path, transform) in
|
||||
placements(doc, cmd)?.into_iter().filter(|(_, path, _)| if descendants { path.starts_with(selected) } else { path == selected })
|
||||
{
|
||||
let selected_placements = if selected.is_empty() {
|
||||
if descendants { placements(doc, cmd)? } else { vec![(0, Vec::new(), solvecraft_doc::IDENTITY)] }
|
||||
} else {
|
||||
let placement = s.occurrence_placement(selected).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let mut out = vec![(placement.component, selected.to_vec(), placement.world)];
|
||||
let mut stack = if descendants { out.clone() } else { Vec::new() };
|
||||
while let Some((component, path, transform)) = stack.pop() {
|
||||
for occurrence in doc.occurrences.iter().filter(|o| o.parent == component) {
|
||||
if path.len() >= 64 || out.len() >= 100_000 {
|
||||
return Err(bad(cmd, "assembly exceeds physical-properties traversal limits (64 levels or 100000 placements)"));
|
||||
}
|
||||
let mut child = path.clone();
|
||||
child.push(occurrence.id);
|
||||
let world = solvecraft_doc::mat_mul(&transform, &occurrence.transform);
|
||||
out.push((occurrence.component, child.clone(), world));
|
||||
stack.push((occurrence.component, child, world));
|
||||
}
|
||||
}
|
||||
out
|
||||
};
|
||||
for (component, path, transform) in selected_placements {
|
||||
placement_count += 1;
|
||||
for body in state.bodies.iter().filter(|b| doc.body_component(&b.name, b.feature) == component) {
|
||||
let transform: Mat = doc.body_offsets.get(&body.name).map(|offset| solvecraft_doc::mat_mul(&transform, offset)).unwrap_or(transform);
|
||||
|
||||
@@ -22,6 +22,9 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
"name; from? (row to copy; default current values); values?: {column key: expression, suppression, material name, appearance name or Look object}; rename?; delete?: bool",
|
||||
),
|
||||
CommandSpec::new("config.activate", "Activate Configuration", activate).params("row: name — sets its parameter values and suppressions"),
|
||||
CommandSpec::new("config.preview", "Preview Configuration", preview).noundo().params("row: name — validates a rebuild without changing the design or history"),
|
||||
CommandSpec::new("config.rule", "Configuration Rule", rule).params("rule?: {id,name,enabled,condition,effect}; id? + delete?; id? + index? (ordered move)"),
|
||||
CommandSpec::new("config.rules", "Configuration Rules", show).noundo().params("ordered typed rules, rows and active state"),
|
||||
];
|
||||
|
||||
fn column_of(s: &Session, p: &Value, cmd: &str) -> Result<Column> {
|
||||
@@ -99,7 +102,7 @@ fn show(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
.iter()
|
||||
.map(|r| json!({"name": r.name, "values": keys.iter().zip(&r.cells).map(|(k, v)| (k.clone(), json!(v))).collect::<serde_json::Map<_, _>>()}))
|
||||
.collect();
|
||||
Ok(json!({"columns": keys, "rows": rows, "active": t.active, "matches": s.doc.configuration_matches()}))
|
||||
Ok(json!({"columns": keys, "rows": rows, "rules": t.rules, "active": t.active, "matches": s.doc.configuration_matches()}))
|
||||
}
|
||||
|
||||
fn column(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -117,6 +120,9 @@ fn column(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else {
|
||||
add_column(s, c, cmd)?;
|
||||
}
|
||||
if !s.doc.configs.rules.is_empty() {
|
||||
validate_rows(s, cmd)?;
|
||||
}
|
||||
show(s, p)
|
||||
}
|
||||
|
||||
@@ -126,6 +132,9 @@ fn row(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let idx = s.doc.configs.rows.iter().position(|r| r.name == name);
|
||||
if bool_(p, "delete").unwrap_or(false) {
|
||||
let i = idx.ok_or_else(|| bad(cmd, format!("no row `{name}`")))?;
|
||||
if s.doc.configs.rows.len() == 1 && !s.doc.configs.rules.is_empty() {
|
||||
return Err(bad(cmd, "delete rules before deleting the last configuration row"));
|
||||
}
|
||||
let t = &mut s.doc_mut().configs;
|
||||
t.rows.remove(i);
|
||||
if t.active.as_deref() == Some(name.as_str()) {
|
||||
@@ -190,6 +199,7 @@ fn row(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, "configuration name already exists"));
|
||||
}
|
||||
let t = &mut s.doc_mut().configs;
|
||||
let validated_name = rename.clone().unwrap_or_else(|| name.clone());
|
||||
match idx {
|
||||
Some(i) => {
|
||||
if let Some(r) = t.rows.get_mut(i) {
|
||||
@@ -209,17 +219,104 @@ fn row(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
t.rows.push(ConfigRow { name, cells });
|
||||
}
|
||||
}
|
||||
if !s.doc.configs.rules.is_empty() {
|
||||
let mut trial = s.scratch();
|
||||
activate(&mut trial, &json!({"row":validated_name})).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
}
|
||||
show(s, p)
|
||||
}
|
||||
|
||||
fn validate_rows(s: &Session, cmd: &str) -> Result<()> {
|
||||
if !s.doc.configs.rules.is_empty() && s.doc.configs.rows.is_empty() {
|
||||
return Err(bad(cmd, "rules require at least one configuration row"));
|
||||
}
|
||||
for row in &s.doc.configs.rows {
|
||||
let mut trial = s.scratch();
|
||||
activate(&mut trial, &json!({"row":row.name})).map_err(|e| bad(cmd, format!("row `{}`: {e}", row.name)))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rule(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "config.rule";
|
||||
if let Some(value) = p.get("rule") {
|
||||
solvecraft_doc::config::validate_rules_json(&json!({"configs":{"rules":[value]}})).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let mut rule: solvecraft_doc::config::ConfigRule = serde_json::from_value(value.clone()).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
if rule.length_unit.is_none() {
|
||||
rule.length_unit = s
|
||||
.doc
|
||||
.configs
|
||||
.rules
|
||||
.iter()
|
||||
.find(|existing| existing.id == rule.id)
|
||||
.and_then(|existing| existing.length_unit.clone())
|
||||
.or_else(|| Some(s.doc.units.clone()));
|
||||
}
|
||||
let rules = &mut s.doc_mut().configs.rules;
|
||||
if let Some(index) = rules.iter().position(|r| r.id == rule.id) {
|
||||
rules[index] = rule;
|
||||
} else {
|
||||
if rules.len() >= solvecraft_doc::config::MAX_RULES {
|
||||
return Err(bad(cmd, "too many rules"));
|
||||
}
|
||||
rules.push(rule);
|
||||
}
|
||||
} else {
|
||||
let id = str_(p, "id").ok_or_else(|| bad(cmd, "give rule or id"))?;
|
||||
let rules = &mut s.doc_mut().configs.rules;
|
||||
let index = rules.iter().position(|r| r.id == id).ok_or_else(|| bad(cmd, "unknown rule"))?;
|
||||
if bool_(p, "delete").unwrap_or(false) {
|
||||
rules.remove(index);
|
||||
} else {
|
||||
let position = p
|
||||
.get("index")
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|v| usize::try_from(v).ok())
|
||||
.filter(|v| *v < rules.len())
|
||||
.ok_or_else(|| bad(cmd, "valid index required"))?;
|
||||
let rule = rules.remove(index);
|
||||
rules.insert(position, rule);
|
||||
}
|
||||
}
|
||||
validate_rows(s, cmd)?;
|
||||
show(s, p)
|
||||
}
|
||||
|
||||
fn activate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "config.activate";
|
||||
let r = str_(p, "row").ok_or_else(|| bad(cmd, "`row` is required"))?.to_string();
|
||||
s.doc_mut().apply_configuration(&r).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let mut trial = s.scratch();
|
||||
let trace = trial.doc_mut().apply_configuration_with_trace(&r).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
trial.refresh();
|
||||
let new_errors: Vec<String> = trial
|
||||
.model
|
||||
.results
|
||||
.iter()
|
||||
.filter_map(|result| {
|
||||
result
|
||||
.error
|
||||
.as_ref()
|
||||
.filter(|error| s.model.result(result.id).is_none_or(|before| before.error.as_ref() != Some(*error)))
|
||||
.map(|error| format!("{}: {error}", result.name))
|
||||
})
|
||||
.collect();
|
||||
if !new_errors.is_empty() {
|
||||
return Err(bad(cmd, format!("configuration `{r}` would fail to rebuild: {}", new_errors.join("; "))));
|
||||
}
|
||||
s.doc = trial.doc;
|
||||
s.refresh();
|
||||
let errors: Vec<String> = s.model.results.iter().filter_map(|x| x.error.clone()).collect();
|
||||
let mut out = show(s, p)?;
|
||||
out["errors"] = json!(errors);
|
||||
out["trace"] = json!(trace);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn preview(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut trial = s.scratch();
|
||||
let mut out = activate(&mut trial, p)?;
|
||||
out["preview"] = json!(true);
|
||||
out["parameters"] = json!(trial.doc.param_rows());
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -240,6 +337,186 @@ mod tests {
|
||||
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_activation_tolerates_identical_existing_errors_but_rejects_reenabled_failure() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.change", json!({"name":"radius","expression":"10 mm"}));
|
||||
run(&mut s, "solid.sphere", json!({"radius":"radius"}));
|
||||
let feature = s.doc.features[0].name.clone();
|
||||
run(&mut s, "config.configure", json!({"params":["radius"],"features":[feature]}));
|
||||
run(&mut s, "config.row", json!({"name":"Existing error","values":{"radius":"-1 mm"}}));
|
||||
run(&mut s, "parameters.change", json!({"name":"radius","expression":"-1 mm"}));
|
||||
assert!(s.model.results[0].error.is_some());
|
||||
let existing = run(&mut s, "config.activate", json!({"row":"Existing error"}));
|
||||
assert_eq!(existing["errors"].as_array().unwrap().len(), 1);
|
||||
s.doc_mut().features[0].suppressed = true;
|
||||
s.refresh();
|
||||
assert!(s.model.results[0].error.is_none());
|
||||
run(&mut s, "config.row", json!({"name":"Reenabled failure","values":{"radius":"-1 mm",format!("suppress:{feature}"):false}}));
|
||||
let before = s.doc.clone();
|
||||
assert!(s.execute("config.activate", &json!({"row":"Reenabled failure"})).is_err());
|
||||
assert_eq!(s.doc, before);
|
||||
assert!(s.model.results[0].error.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_rows_and_literal_rule_thresholds_keep_physical_units_on_switch() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10}));
|
||||
let length = s.doc.features[0].param_names[0].clone();
|
||||
run(&mut s, "config.configure", json!({"params":[length]}));
|
||||
run(
|
||||
&mut s,
|
||||
"config.rule",
|
||||
json!({"rule":{"id":"limit","name":"Metric stock","condition":{"type":"parameter","parameter":length,"operator":"le","expression":"20"},"effect":{"type":"parameter_range","parameter":length,"minimum":"5","maximum":"20"}}}),
|
||||
);
|
||||
run(&mut s, "document.units", json!({"units":"in"}));
|
||||
assert_eq!(s.doc.configs.rows[0].cells[0], "10 mm");
|
||||
let rule = serde_json::to_value(&s.doc.configs.rules[0]).unwrap();
|
||||
assert_eq!(rule["condition"]["expression"], "20 mm");
|
||||
assert_eq!(rule["effect"]["minimum"], "5 mm");
|
||||
let preview = run(&mut s, "config.preview", json!({"row":"Default"}));
|
||||
assert_eq!(preview["trace"][0]["matched"], true);
|
||||
run(&mut s, "config.activate", json!({"row":"Default"}));
|
||||
assert!((volume(&mut s) - 1000.0).abs() < 1e-6);
|
||||
assert!(s.doc.configuration_matches());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.units, "mm");
|
||||
assert_eq!(s.doc.configs.rows[0].cells[0], "10");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_mixed_rule_expressions_keep_authoring_units_without_rewriting_source() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":20,"height":10}));
|
||||
let length = s.doc.features[0].param_names[0].clone();
|
||||
let width = s.doc.features[0].param_names[1].clone();
|
||||
run(&mut s, "config.configure", json!({"params":[length,width]}));
|
||||
let expression = format!("{width} + 1");
|
||||
run(
|
||||
&mut s,
|
||||
"config.rule",
|
||||
json!({"rule":{"id":"drive","name":"Stock extra","condition":{"type":"always"},"effect":{"type":"drive_parameter","parameter":length,"expression":expression}}}),
|
||||
);
|
||||
run(
|
||||
&mut s,
|
||||
"config.rule",
|
||||
json!({"rule":{"id":"range","name":"Stock bound","condition":{"type":"parameter","parameter":length,"operator":"lt","expression":format!("{width} + 2")},"effect":{"type":"parameter_range","parameter":length,"minimum":width,"maximum":format!("{width} + 2")}}}),
|
||||
);
|
||||
run(&mut s, "config.activate", json!({"row":"Default"}));
|
||||
let before = volume(&mut s);
|
||||
assert!((before - 4200.0).abs() < 1e-6);
|
||||
run(&mut s, "document.units", json!({"units":"in"}));
|
||||
assert!((volume(&mut s) - before).abs() < 1e-6, "matched row rederives drives in their authoring unit");
|
||||
assert!(s.doc.configuration_matches());
|
||||
let rule = serde_json::to_value(&s.doc.configs.rules[0]).unwrap();
|
||||
assert_eq!(rule["effect"]["expression"], expression);
|
||||
assert_eq!(rule["length_unit"], "mm");
|
||||
let preview = run(&mut s, "config.preview", json!({"row":"Default"}));
|
||||
assert_eq!(preview["trace"][1]["matched"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_rule_edits_enforce_existing_rows_and_are_undoable() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.change", json!({"name":"length","expression":"10 mm"}));
|
||||
run(&mut s, "solid.box", json!({"length":"length","width":10,"height":10}));
|
||||
run(&mut s, "config.configure", json!({"params":["length"]}));
|
||||
let rule = json!({"id":"limit","name":"Length limit","condition":{"type":"always"},"effect":{"type":"parameter_range","parameter":"length","minimum":"5 mm","maximum":"20 mm"}});
|
||||
run(&mut s, "config.rule", json!({"rule":rule}));
|
||||
let before = s.doc.clone();
|
||||
assert!(s.execute("config.row", &json!({"name":"Default","delete":true})).is_err());
|
||||
assert_eq!(s.doc, before, "last row remains while rules exist");
|
||||
assert!(s.execute("config.row", &json!({"name":"Too large","values":{"length":"30 mm"}})).is_err());
|
||||
assert_eq!(s.doc, before);
|
||||
let impossible = json!({"id":"limit","name":"Length limit","condition":{"type":"always"},"effect":{"type":"parameter_range","parameter":"length","minimum":"15 mm","maximum":"20 mm"}});
|
||||
assert!(s.execute("config.rule", &json!({"rule":impossible})).is_err());
|
||||
assert_eq!(s.doc, before);
|
||||
let preview = run(&mut s, "config.preview", json!({"row":"Default"}));
|
||||
assert_eq!(preview["trace"][0]["name"], "Length limit");
|
||||
assert_eq!(preview["trace"][0]["matched"], true);
|
||||
assert_eq!(s.doc, before);
|
||||
run(&mut s, "config.rule", json!({"id":"limit","delete":true}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc, before);
|
||||
let reopened = solvecraft_doc::format::read(&s.doc.to_json()).unwrap();
|
||||
assert_eq!(reopened.configs.rules, s.doc.configs.rules);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_preview_and_failed_geometry_leave_design_unchanged() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.change", json!({"name":"length", "expression":"10 mm"}));
|
||||
run(&mut s, "solid.box", json!({"length":"length", "width":10, "height":10}));
|
||||
run(&mut s, "config.configure", json!({"params":["length"]}));
|
||||
run(&mut s, "config.row", json!({"name":"Large", "values":{"length":"20 mm"}}));
|
||||
run(&mut s, "config.row", json!({"name":"Degenerate", "values":{"length":"0 mm"}}));
|
||||
let before = s.doc.clone();
|
||||
let revision = s.revision;
|
||||
assert_eq!(run(&mut s, "config.preview", json!({"row":"Large"}))["preview"], true);
|
||||
assert_eq!(s.doc, before);
|
||||
assert_eq!(s.revision, revision);
|
||||
assert!(s.execute("config.preview", &json!({"row":"Degenerate"})).is_err());
|
||||
assert_eq!(s.doc, before);
|
||||
assert!(s.execute("config.activate", &json!({"row":"Degenerate"})).is_err());
|
||||
assert_eq!(s.doc, before);
|
||||
assert!((volume(&mut s) - 1000.0).abs() < 1e-6);
|
||||
run(&mut s, "config.activate", json!({"row":"Large"}));
|
||||
assert!((volume(&mut s) - 2000.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_feature_inputs_and_suppression_match_typed_cells() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10, "width":10, "height":10}));
|
||||
let feature = s.doc.features[0].name.clone();
|
||||
let length = s.doc.features[0].param_names[0].clone();
|
||||
s.doc_mut().units = "in".into();
|
||||
run(&mut s, "config.configure", json!({"params":[length], "features":[feature]}));
|
||||
assert_eq!(s.doc.configs.rows[0].cells[0], "10 mm", "Default captures stored feature literals in their base unit");
|
||||
run(&mut s, "config.activate", json!({"row":"Default"}));
|
||||
assert!((volume(&mut s) - 1000.0).abs() < 1e-6, "captured Default preserves geometry in an inch design");
|
||||
run(&mut s, "config.row", json!({"name":"Inches", "values":{length.clone():"2", format!("suppress:{feature}"):"NO"}}));
|
||||
run(&mut s, "config.activate", json!({"row":"Inches"}));
|
||||
assert_eq!(s.doc.config_current(&solvecraft_doc::config::Column::Param(length.clone())).as_deref(), Some("2 in"));
|
||||
assert!(s.doc.configuration_matches());
|
||||
assert!((volume(&mut s) - 5080.0).abs() < 1e-6);
|
||||
let feature_id = s.doc.features[0].id;
|
||||
run(
|
||||
&mut s,
|
||||
"config.rule",
|
||||
json!({"rule":{"id":"inch-condition","name":"Inch condition","condition":{"type":"parameter","parameter":length,"operator":"gt","expression":"5"},"effect":{"type":"drive_suppression","feature":feature_id,"value":true}}}),
|
||||
);
|
||||
run(
|
||||
&mut s,
|
||||
"config.rule",
|
||||
json!({"rule":{"id":"inch-range","name":"Inch range","condition":{"type":"always"},"effect":{"type":"parameter_range","parameter":length,"minimum":"0","maximum":"5"}}}),
|
||||
);
|
||||
let preview = run(&mut s, "config.preview", json!({"row":"Inches"}));
|
||||
assert_eq!(preview["trace"][0]["matched"], false, "bare comparison means inches for a feature length");
|
||||
assert_eq!(preview["trace"][1]["matched"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_parameters_cannot_be_deleted_and_deleted_features_prune_columns() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.change", json!({"name":"spare", "expression":"10 mm"}));
|
||||
run(&mut s, "config.configure", json!({"params":["spare"]}));
|
||||
assert!(s.doc_mut().remove_param("spare").is_err());
|
||||
run(&mut s, "config.column", json!({"param":"spare", "delete":true}));
|
||||
s.doc_mut().remove_param("spare").unwrap();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10}));
|
||||
let feature = &s.doc.features[0];
|
||||
let id = feature.id;
|
||||
let length = feature.param_names[0].clone();
|
||||
let name = feature.name.clone();
|
||||
run(&mut s, "config.configure", json!({"params":[length], "features":[name]}));
|
||||
s.doc_mut().delete_feature(id).unwrap();
|
||||
assert!(s.doc.configs.columns.is_empty());
|
||||
assert!(s.doc.configs.rows.iter().all(|row| row.cells.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configurations_switch_sizes_and_suppressions() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -65,8 +65,12 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut features: Vec<u64> = Vec::new();
|
||||
let mut bodies: Vec<String> = Vec::new();
|
||||
let mut faces: Vec<(String, Vec<Vec3>)> = Vec::new();
|
||||
let mut selected_occurrences = Vec::new();
|
||||
for x in &items {
|
||||
match x {
|
||||
Sel::Occurrence { path } => {
|
||||
selected_occurrences.push(s.occurrence_placement(path)?.occurrence.to_string());
|
||||
}
|
||||
Sel::SketchCurve { id } | Sel::SketchPoint { id } | Sel::SketchConstraint { id } => entities.push(id.clone()),
|
||||
Sel::Feature { id } => {
|
||||
if s.doc.feature(*id).is_none() {
|
||||
@@ -118,7 +122,10 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
// An occurrence goes alone while its component has others; the last one takes the component.
|
||||
let mut components: Vec<u64> = Vec::new();
|
||||
for k in keys(p, "occurrences") {
|
||||
selected_occurrences.extend(keys(p, "occurrences"));
|
||||
selected_occurrences.sort();
|
||||
selected_occurrences.dedup();
|
||||
for k in selected_occurrences {
|
||||
let Some(o) = s.doc.occurrences.iter().find(|o| o.id.to_string() == k || o.name == k).cloned() else {
|
||||
return Err(bad(cmd, format!("no occurrence `{k}`")));
|
||||
};
|
||||
|
||||
@@ -238,6 +238,9 @@ fn select_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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) {
|
||||
|
||||
@@ -62,13 +62,14 @@ fn measure(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let want = string_list(p, "bodies");
|
||||
let st = s.world_state();
|
||||
let mut bodies: Vec<Value> = st.bodies.iter().filter(|b| want.is_empty() || want.contains(&b.name)).map(measure_json).collect();
|
||||
let selected: Vec<_> = st.bodies.iter().filter(|b| want.is_empty() || want.contains(&b.name)).collect();
|
||||
let mut bodies: Vec<Value> = selected.iter().map(|b| measure_json(b)).collect();
|
||||
// Mass from each body's material.
|
||||
for b in &mut bodies {
|
||||
let name = b["name"].as_str().unwrap_or_default().to_string();
|
||||
for (b, body) in bodies.iter_mut().zip(selected) {
|
||||
if let Some(v) = b["volume_mm3"].as_f64() {
|
||||
let rho = s.doc.density(&name);
|
||||
b["material"] = json!(s.doc.materials.get(&name).cloned().unwrap_or_else(|| "Default".into()));
|
||||
let source = body.definition_name();
|
||||
let rho = s.doc.density(source);
|
||||
b["material"] = json!(s.doc.materials.get(source).cloned().unwrap_or_else(|| "Default".into()));
|
||||
b["density_g_cm3"] = json!(rho);
|
||||
b["mass_g"] = json!(v * rho / 1000.0);
|
||||
}
|
||||
@@ -214,7 +215,7 @@ fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.map(|b| {
|
||||
let mut v = if with_measure { measure_json(b) } else { json!({"name": b.name, "feature": b.feature}) };
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert("visible".into(), json!(!s.visibility.hidden_bodies.contains(&b.name)));
|
||||
o.insert("visible".into(), json!(s.body_visible(&b.name)));
|
||||
}
|
||||
v
|
||||
})
|
||||
@@ -495,7 +496,7 @@ fn appearance_list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let b = st.body(name).ok_or_else(|| bad("appearance.list", format!("no body `{name}`")))?;
|
||||
out["body"] = json!({
|
||||
"name": name,
|
||||
"appearance": s.doc.body_look(&b.name, b.feature).map(|l| look_json(&l)),
|
||||
"appearance": s.doc.model_body_look(b).map(|l| look_json(&l)),
|
||||
"color": s.doc.body_color(b),
|
||||
"faces": s.doc.face_colors(b).iter().map(|(i, l)| json!({"face": i, "appearance": look_json(l)})).collect::<Vec<_>>(),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
// Exact occurrence workflows through the same commands used by the Browser.
|
||||
use crate::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn run(s: &mut Session, command: &str, p: Value) -> Value {
|
||||
s.execute(command, &p).unwrap()
|
||||
}
|
||||
fn created(s: &mut Session, name: &str) -> (u64, u64) {
|
||||
let c = run(s, "component.create", json!({"name": name}));
|
||||
(c["component"].as_u64().unwrap(), c["occurrence"].as_u64().unwrap())
|
||||
}
|
||||
fn near(value: &Value, expected: f64) {
|
||||
assert!((value.as_f64().unwrap() - expected).abs() < 1e-6, "{value} != {expected}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_copy_uses_selected_source_transform() {
|
||||
let mut s = Session::default();
|
||||
let (component, first) = created(&mut s, "Block");
|
||||
let second = run(&mut s, "occurrence.copy", json!({"component": component, "translate": [20,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": second, "translate": [30,0,0]}));
|
||||
let third = run(&mut s, "occurrence.copy", json!({"occurrence": second, "translate": [5,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
let placement = s.doc.occurrences.iter().find(|o| o.id == third).unwrap();
|
||||
assert!((placement.transform[3][0] - 55.0).abs() < 1e-6);
|
||||
assert_eq!(s.doc.occurrences.iter().find(|o| o.id == first).unwrap().transform[3][0], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_world_move_under_rotated_parent_uses_selected_path() {
|
||||
let mut s = Session::default();
|
||||
let (_, parent) = created(&mut s, "Parent");
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": parent, "axis": [0,0,1], "angle": "90 deg"}));
|
||||
let (_, child) = created(&mut s, "Child");
|
||||
run(&mut s, "solid.box", json!({"length": 1, "width": 1, "height": 1}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": child, "path": [parent, child], "space": "world", "translate": [10,0,0]}));
|
||||
let result = run(&mut s, "occurrence.properties", json!({"occurrence": child, "path": [parent,child]}));
|
||||
near(&result["total"]["center_of_mass"][0], 9.5);
|
||||
near(&result["total"]["center_of_mass"][1], 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_authoring_at_second_placement_keeps_definition_shared() {
|
||||
let mut s = Session::default();
|
||||
let (component, first) = created(&mut s, "Block");
|
||||
let second = run(&mut s, "occurrence.copy", json!({"component": component, "translate": [20,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "component.activate", json!({"component": component, "occurrence": second, "path": [second]}));
|
||||
run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2, "corner": [20,0,0], "operation": "new"}));
|
||||
for (occurrence, x) in [(first, 1.0), (second, 21.0)] {
|
||||
let result = run(&mut s, "occurrence.properties", json!({"occurrence": occurrence, "path": [occurrence]}));
|
||||
near(&result["total"]["center_of_mass"][0], x);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_selection_survives_rename_move_capture_and_serde() {
|
||||
let mut s = Session::default();
|
||||
let (component, _) = created(&mut s, "Block");
|
||||
let second = run(&mut s, "occurrence.copy", json!({"component": component, "translate": [20,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
let selected = json!({"type": "occurrence", "path": [second]});
|
||||
run(&mut s, "select.set", json!({"items": [selected]}));
|
||||
run(&mut s, "component.rename", json!({"component": component, "name": "Renamed"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": second, "translate": [5,0,0], "capture": false}));
|
||||
run(&mut s, "component.capture_position", json!({}));
|
||||
assert_eq!(serde_json::to_value(&s.selection).unwrap(), json!([selected]));
|
||||
let roundtrip: Vec<crate::Sel> = serde_json::from_value(json!([selected])).unwrap();
|
||||
assert_eq!(roundtrip, s.selection);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_selection_delete_removes_only_selected_instance() {
|
||||
let mut s = Session::default();
|
||||
let (component, first) = created(&mut s, "Block");
|
||||
run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2}));
|
||||
let second = run(&mut s, "occurrence.copy", json!({"component": component, "translate": [20,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "select.set", json!({"items": [{"type": "occurrence", "path": [second]}]}));
|
||||
let items = s.selection.clone();
|
||||
run(&mut s, "selection.delete", json!({"items": items}));
|
||||
assert!(s.doc.occurrences.iter().any(|o| o.id == first));
|
||||
assert!(!s.doc.occurrences.iter().any(|o| o.id == second));
|
||||
assert_eq!(s.world_state().bodies.len(), 1);
|
||||
assert!(s.doc.components.iter().any(|c| c.id == component));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_move_includes_pending_parent_and_leaf_and_shared_scope() {
|
||||
let mut s = Session::default();
|
||||
let (parent_component, parent) = created(&mut s, "Parent");
|
||||
let (_, child) = created(&mut s, "Child");
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":parent,"axis":[0,0,1],"angle":"90 deg","translate":[3,0,0],"capture":false}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":child,"translate":[2,0,0],"capture":false}));
|
||||
let before = s.occurrence_placement(&[parent, child]).unwrap().world;
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":child,"path":[parent,child],"space":"world","translate":[10,0,0],"capture":false}));
|
||||
let after = s.occurrence_placement(&[parent, child]).unwrap().world;
|
||||
assert!((after[3][0] - before[3][0] - 10.0).abs() < 1e-8);
|
||||
assert!((after[3][1] - before[3][1]).abs() < 1e-8);
|
||||
run(
|
||||
&mut s,
|
||||
"occurrence.move",
|
||||
json!({"occurrence":child,"path":[parent,child],"space":"world","axis":[0,0,1],"angle":"90 deg","origin":[0,0,0],"capture":false}),
|
||||
);
|
||||
let rotated = s.occurrence_placement(&[parent, child]).unwrap().world;
|
||||
assert!((rotated[3][0] + after[3][1]).abs() < 1e-8);
|
||||
assert!((rotated[3][1] - after[3][0]).abs() < 1e-8);
|
||||
run(&mut s, "component.activate", json!({"component":parent_component}));
|
||||
let second_parent = run(&mut s, "occurrence.copy", json!({"occurrence":parent,"translate":[30,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
assert_eq!(s.occurrence_scope_count(&[second_parent, child]).unwrap(), 2);
|
||||
assert!(s.execute("occurrence.move", &json!({"occurrence":child,"space":"world","translate":[1,0,0]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_paths_are_atomic_for_session_state() {
|
||||
let mut s = Session::default();
|
||||
let (component, first) = created(&mut s, "Part");
|
||||
let second = run(&mut s, "occurrence.copy", json!({"occurrence":first}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "component.activate", json!({"component":component,"occurrence":second,"path":[second]}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":second,"translate":[5,0,0],"capture":false}));
|
||||
run(&mut s, "browser.visibility", json!({"visible":false,"paths":[[second]]}));
|
||||
let pending = s.pending_moves.clone();
|
||||
let active = s.active_occurrence_path.clone();
|
||||
let vis = s.visibility.clone();
|
||||
let doc = s.doc.clone();
|
||||
for (cmd, p) in [
|
||||
("occurrence.move", json!({"occurrence":second,"path":[first],"space":"world","translate":[10,0,0]})),
|
||||
("occurrence.move", json!({"occurrence":second,"space":17,"translate":[10,0,0]})),
|
||||
("component.activate", json!({"component":component,"occurrence":second,"path":[first,second]})),
|
||||
("browser.visibility", json!({"visible":true,"paths":[[second],[9999]]})),
|
||||
("select.set", json!({"items":[{"type":"occurrence","path":[first,second]}]})),
|
||||
("occurrence.copy", json!({"occurrence":second,"path":[first]})),
|
||||
] {
|
||||
assert!(s.execute(cmd, &p).is_err(), "{cmd}");
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
assert_eq!(s.active_occurrence_path, active);
|
||||
assert_eq!(s.visibility, vis);
|
||||
assert_eq!(s.doc, doc);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_visibility_isolate_rename_and_async_accept() {
|
||||
let mut s = Session::default();
|
||||
let (component, first) = created(&mut s, "Part");
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2}));
|
||||
let second = run(&mut s, "occurrence.copy", json!({"occurrence":first,"translate":[20,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
let first_body = s.occurrence_bodies(&[first], true).unwrap()[0].clone();
|
||||
let second_body = s.occurrence_bodies(&[second], true).unwrap()[0].clone();
|
||||
run(&mut s, "browser.visibility", json!({"visible":false,"paths":[[second]]}));
|
||||
assert!(s.body_visible(&first_body));
|
||||
assert!(!s.body_visible(&second_body));
|
||||
let mut scratch = s.scratch();
|
||||
assert_eq!(scratch.visibility, s.visibility);
|
||||
run(&mut scratch, "component.rename", json!({"component":component,"name":"Changed"}));
|
||||
s.accept_computed(scratch);
|
||||
assert!(!s.occurrence_visible(&[second]));
|
||||
run(&mut s, "browser.visibility", json!({"visible":true,"paths":[[second]],"isolate":true}));
|
||||
assert!(!s.body_visible(&first_body));
|
||||
assert!(s.body_visible(&second_body));
|
||||
run(&mut s, "browser.visibility", json!({"visible":true,"folder":"bodies"}));
|
||||
assert!(s.body_visible(&first_body));
|
||||
assert!(s.body_visible(&second_body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthesized_name_collision_keeps_source_and_instance_separate() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":1,"width":1,"height":1}));
|
||||
let root = s.model.state().bodies[0].name.clone();
|
||||
let (_, first) = created(&mut s, "Part");
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2}));
|
||||
let second = run(&mut s, "occurrence.copy", json!({"occurrence":first,"translate":[20,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
let candidate = s.occurrence_bodies(&[second], false).unwrap()[0].clone();
|
||||
run(&mut s, "body.rename", json!({"body":root,"name":candidate}));
|
||||
let instance = s.occurrence_bodies(&[second], false).unwrap()[0].clone();
|
||||
assert_ne!(instance, candidate);
|
||||
let frames = s.doc.body_frames(&s.model.state());
|
||||
let world = s.world_state();
|
||||
assert!(frames.iter().all(|(name, _, _, _)| world.body(name).is_some()));
|
||||
assert_eq!(world.bodies.len(), world.bodies.iter().map(|b| &b.name).collect::<std::collections::BTreeSet<_>>().len());
|
||||
run(&mut s, "browser.visibility", json!({"visible":false,"paths":[[second]]}));
|
||||
assert!(s.body_visible(&candidate));
|
||||
assert!(!s.body_visible(&instance));
|
||||
let source = s.occurrence_placement(&[second]).unwrap().bodies[0].source.clone();
|
||||
run(&mut s, "material.assign", json!({"bodies":[instance],"material":"Brass"}));
|
||||
assert_eq!(s.doc.materials.get(&source).map(String::as_str), Some("Brass"));
|
||||
assert!(!s.doc.materials.contains_key(&candidate));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_nested_labels_use_full_path_and_reopen_undo_preserve_placements() {
|
||||
let mut s = Session::default();
|
||||
let (parent_component, parent) = created(&mut s, "Parent");
|
||||
let (_, child) = created(&mut s, "Child");
|
||||
run(&mut s, "solid.box", json!({"length":1,"width":1,"height":1}));
|
||||
let parent2 = s.doc_mut().add_occurrence(parent_component, 0, solvecraft_doc::IDENTITY).unwrap();
|
||||
let parent3 = s.doc_mut().add_occurrence(parent_component, 0, solvecraft_doc::IDENTITY).unwrap();
|
||||
for o in s.doc_mut().occurrences.iter_mut().filter(|o| o.id == parent2 || o.id == parent3) {
|
||||
o.name = "Same".into();
|
||||
}
|
||||
s.refresh();
|
||||
let a = s.occurrence_bodies(&[parent2, child], false).unwrap()[0].clone();
|
||||
let b = s.occurrence_bodies(&[parent3, child], false).unwrap()[0].clone();
|
||||
assert_ne!(a, b);
|
||||
let mut reopened = Session::new(solvecraft_doc::format::read(&s.doc.to_json()).unwrap());
|
||||
assert_eq!(reopened.occurrence_bodies(&[parent2, child], false).unwrap()[0], a);
|
||||
assert_eq!(reopened.occurrence_bodies(&[parent3, child], false).unwrap()[0], b);
|
||||
run(&mut reopened, "selection.delete", json!({"occurrences":[parent3]}));
|
||||
assert!(reopened.occurrence_placement(&[parent3, child]).is_err());
|
||||
run(&mut reopened, "edit.undo", json!({}));
|
||||
assert!(reopened.occurrence_placement(&[parent3, child]).is_ok());
|
||||
assert!(reopened.occurrence_placement(&[parent, child]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_duplicated_assembly_exact_resolution_and_expansion_limit() {
|
||||
let mut s = Session::default();
|
||||
let mut parent = 0;
|
||||
let mut path = Vec::new();
|
||||
for i in 0..18 {
|
||||
let component = s.doc_mut().add_component(Some(&format!("Level{i}")), parent).unwrap();
|
||||
let occurrence = s.doc.occurrence_of(component).unwrap().id;
|
||||
path.push(occurrence);
|
||||
s.doc_mut().add_occurrence(component, parent, solvecraft_doc::IDENTITY).unwrap();
|
||||
parent = component;
|
||||
}
|
||||
assert!(s.occurrence_placement(&path).is_ok());
|
||||
let top = [path[0]];
|
||||
assert!(s.occurrence_bodies(&top, true).is_err());
|
||||
assert!(s.execute("select.set", &json!({"items":[{"type":"occurrence","path":top}]})).is_err());
|
||||
assert!(s.selection.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_instances_map_world_keys_to_shared_sources() {
|
||||
let mut s = Session::default();
|
||||
let (_, first) = created(&mut s, "Part");
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2}));
|
||||
let second = run(&mut s, "occurrence.copy", json!({"occurrence":first}))["occurrence"].as_u64().unwrap();
|
||||
let placement = s.occurrence_placement(&[second]).unwrap();
|
||||
let body = &placement.bodies[0];
|
||||
assert_ne!(body.source, body.world);
|
||||
run(&mut s, "material.assign", json!({"bodies":[body.world],"material":"Brass"}));
|
||||
assert_eq!(s.doc.materials.get(&body.source).map(String::as_str), Some("Brass"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dialog_baseline_move_preserves_other_pending_positions_without_drift() {
|
||||
let mut s = Session::default();
|
||||
let (_, first) = created(&mut s, "Part");
|
||||
let second = run(&mut s, "occurrence.copy", json!({"occurrence":first,"translate":[20,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":first,"translate":[5,0,0],"capture":false}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":second,"translate":[3,0,0],"capture":false}));
|
||||
let baseline = s.occurrence_placement(&[second]).unwrap().local;
|
||||
for offset in [10.0, 15.0, 0.0] {
|
||||
run(
|
||||
&mut s,
|
||||
"occurrence.move",
|
||||
json!({"occurrence":second,"path":[second],"space":"world","base_transform":baseline,"translate":[offset,0,0],"capture":false}),
|
||||
);
|
||||
assert_eq!(s.pending_moves.get(&first).unwrap()[3][0], 5.0);
|
||||
assert_eq!(s.pending_moves.get(&second).unwrap()[3][0], 23.0 + offset);
|
||||
}
|
||||
let pending = s.pending_moves.clone();
|
||||
assert!(s.execute("occurrence.move", &json!({"occurrence":second,"base_transform":[[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,1]]})).is_err());
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
for base in [
|
||||
[[2.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]],
|
||||
[[-1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]],
|
||||
] {
|
||||
assert!(s.execute("occurrence.move", &json!({"occurrence":second,"base_transform":base})).is_err());
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
}
|
||||
assert!(s.execute("component.revert_position", &json!({"occurrences":[second,9999]})).is_err());
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
run(&mut s, "component.revert_position", json!({"occurrences":[second]}));
|
||||
assert!(!s.pending_moves.contains_key(&second));
|
||||
assert_eq!(s.pending_moves.get(&first).unwrap()[3][0], 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_moves_copies_and_reused_ids_do_not_leak_state() {
|
||||
let mut s = Session::default();
|
||||
let (_, first) = created(&mut s, "Part");
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":first,"translate":[10,0,0],"capture":false}));
|
||||
let pending = s.pending_moves.clone();
|
||||
let doc = s.doc.clone();
|
||||
for (cmd, p) in [
|
||||
("occurrence.move", json!({"occurrence":first,"origin":[1e308,0,0],"angle":"180 deg"})),
|
||||
("occurrence.move", json!({"occurrence":first,"translate":[1e9,0,0],"capture":false})),
|
||||
("occurrence.move", json!({"occurrence":first,"translate":[1,2]})),
|
||||
("occurrence.move", json!({"occurrence":first,"angle":false})),
|
||||
("occurrence.move", json!({"occurrence":first,"axis":[1e308,0,0],"angle":"90 deg"})),
|
||||
("occurrence.move", json!({"occurrence":first,"axis":[0,0,0],"angle":"90 deg"})),
|
||||
("occurrence.copy", json!({"occurrence":first,"translate":[1e9,0,0]})),
|
||||
("occurrence.copy", json!({"occurrence":first,"translate":"oops"})),
|
||||
] {
|
||||
assert!(s.execute(cmd, &p).is_err(), "{cmd}");
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
assert_eq!(s.doc, doc);
|
||||
}
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.pending_moves.is_empty());
|
||||
let (_, other) = created(&mut s, "Other");
|
||||
assert_eq!(first, other);
|
||||
assert_eq!(s.occurrence_placement(&[other]).unwrap().world[3][0], 0.0);
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":other,"translate":[20,0,0],"capture":false}));
|
||||
run(&mut s, "component.capture_position", json!({}));
|
||||
assert!(s.pending_moves.is_empty());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.occurrence_placement(&[other]).unwrap().world[3][0], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isolate_nested_child_hides_ancestor_material_and_sibling() {
|
||||
let mut s = Session::default();
|
||||
let (_, parent) = created(&mut s, "Parent");
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10}));
|
||||
let (child_component, child) = created(&mut s, "Child");
|
||||
run(&mut s, "solid.box", json!({"length":1,"width":1,"height":1}));
|
||||
let parent_component = s.doc.occurrences.iter().find(|o| o.id == child).unwrap().parent;
|
||||
let sibling = s
|
||||
.doc_mut()
|
||||
.add_occurrence(
|
||||
child_component,
|
||||
parent_component,
|
||||
solvecraft_doc::rigid(crate::geom::Vec3::new(20.0, 0.0, 0.0), crate::geom::Vec3::ZERO, crate::geom::Vec3::Z, 0.0),
|
||||
)
|
||||
.unwrap();
|
||||
s.refresh();
|
||||
run(&mut s, "browser.visibility", json!({"visible":true,"paths":[[parent,child]],"isolate":true}));
|
||||
for (path, expected) in [(vec![parent], false), (vec![parent, child], true), (vec![parent, sibling], false)] {
|
||||
let body = s.occurrence_bodies(&path, false).unwrap()[0].clone();
|
||||
assert_eq!(s.body_visible(&body), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_new_child_uses_inactive_parent_placement() {
|
||||
let mut s = Session::default();
|
||||
let (parent_component, parent) = created(&mut s, "Parent");
|
||||
let second = run(&mut s, "occurrence.copy", json!({"occurrence":parent,"translate":[20,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "component.activate", json!({"component":0}));
|
||||
let child =
|
||||
run(&mut s, "component.create", json!({"parent":parent_component,"parent_path":[second],"name":"Child"}))["occurrence"].as_u64().unwrap();
|
||||
assert_eq!(s.active_occurrence_path, Some(vec![second, child]));
|
||||
assert_eq!(s.active_component_frame()[3][0], 20.0);
|
||||
let doc = s.doc.clone();
|
||||
let active = s.active_occurrence_path.clone();
|
||||
assert!(s.execute("component.create", &json!({"parent":parent_component,"parent_path":[999],"name":"Invalid"})).is_err());
|
||||
assert_eq!(s.doc, doc);
|
||||
assert_eq!(s.active_occurrence_path, active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_new_uses_selected_pending_placement() {
|
||||
let mut s = Session::default();
|
||||
let (component, first) = created(&mut s, "Part");
|
||||
run(&mut s, "solid.box", json!({"length":1,"width":1,"height":1}));
|
||||
let second = run(&mut s, "occurrence.copy", json!({"occurrence":first,"translate":[20,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":second,"translate":[30,0,0],"capture":false}));
|
||||
let copied =
|
||||
run(&mut s, "component.paste_new", json!({"component":component,"occurrence":second,"path":[second],"translate":[5,0,0]}))["component"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
assert_eq!(s.doc.occurrence_of(copied).unwrap().transform[3][0], 55.0);
|
||||
let result = run(&mut s, "component.properties", json!({"component":copied}));
|
||||
near(&result["total"]["center_of_mass"][0], 55.5);
|
||||
let result = run(&mut s, "occurrence.properties", json!({"occurrence":first,"path":[first]}));
|
||||
near(&result["total"]["center_of_mass"][0], 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_styles_follow_source_identity_after_collision_rename_and_pending_move() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":1,"width":1,"height":1}));
|
||||
let root = s.model.state().bodies[0].name.clone();
|
||||
let (component, first) = created(&mut s, "Part");
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2}));
|
||||
let second = run(&mut s, "occurrence.copy", json!({"occurrence":first,"translate":[20,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
let candidate = s.occurrence_bodies(&[second], false).unwrap()[0].clone();
|
||||
run(&mut s, "body.rename", json!({"body":root,"name":candidate}));
|
||||
let instance = s.occurrence_bodies(&[second], false).unwrap()[0].clone();
|
||||
let source = s.occurrence_placement(&[second]).unwrap().bodies[0].source.clone();
|
||||
run(&mut s, "material.assign", json!({"bodies":[instance],"material":"Brass"}));
|
||||
let world = s.world_state();
|
||||
assert_eq!(s.doc.body_color(world.body(&instance).unwrap()), s.doc.body_color(world.body(&source).unwrap()));
|
||||
run(&mut s, "appearance.assign", json!({"components":[component.to_string()],"color":"#0000ff","opacity":0.7}));
|
||||
assert_eq!(s.doc.body_color(s.world_state().body(&instance).unwrap()), Some([0.0, 0.0, 1.0]));
|
||||
run(&mut s, "appearance.assign", json!({"bodies":[instance],"color":"#ff0000","opacity":0.5}));
|
||||
run(&mut s, "appearance.assign", json!({"faces":[{"body":instance,"point":[21,1,2]}],"color":"#00ff00","opacity":0.4}));
|
||||
// Exercise the point-based face fallback as well as persistent face-name matching.
|
||||
for face in &mut s.doc_mut().appearances.faces {
|
||||
face.name = None;
|
||||
}
|
||||
run(&mut s, "body.rename", json!({"body":source,"name":"Shared Renamed"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":second,"translate":[5,0,0],"capture":false}));
|
||||
let world = s.world_state();
|
||||
for id in [first, second] {
|
||||
let bodyname = s.occurrence_bodies(&[id], false).unwrap()[0].clone();
|
||||
let body = world.body(&bodyname).unwrap();
|
||||
assert_eq!(body.definition_name(), "Shared Renamed");
|
||||
assert_eq!(body.component, component);
|
||||
let look = s.doc.model_body_look(body).unwrap();
|
||||
assert_eq!(look.color, [255, 0, 0]);
|
||||
assert_eq!(look.opacity, 0.5);
|
||||
assert!(s.doc.face_colors(body).iter().any(|(_, l)| l.color == [0, 255, 0] && l.opacity == 0.4));
|
||||
let measured = run(&mut s, "inspect.measure", json!({"bodies":[bodyname]}));
|
||||
assert_eq!(measured["bodies"][0]["material"], "Brass");
|
||||
assert!((measured["bodies"][0]["mass_g"].as_f64().unwrap() - 0.068).abs() < 1e-8);
|
||||
}
|
||||
let definition = s.model.state();
|
||||
assert_eq!(solvecraft_kernel::measure(&definition.body("Shared Renamed").unwrap().body).unwrap().bbox.min, crate::geom::Vec3::ZERO);
|
||||
let painted = s.doc.painted(&world);
|
||||
let secondname = s.occurrence_bodies(&[second], false).unwrap()[0].clone();
|
||||
let body = painted.body(&secondname).unwrap();
|
||||
assert_eq!(body.body.color(), Some([1.0, 0.0, 0.0]));
|
||||
assert!(body.body.paint().unwrap().faces.iter().any(|f| f.color == [0.0, 1.0, 0.0]));
|
||||
let bytes = solvecraft_io::export(&painted, &[secondname], solvecraft_io::Format::Step, "InstanceStyles").unwrap();
|
||||
let imported = solvecraft_kernel::step_import(&String::from_utf8(bytes).unwrap()).unwrap();
|
||||
assert_eq!(imported.bodies.len(), 1);
|
||||
assert_eq!(imported.bodies[0].body.color(), Some([1.0, 0.0, 0.0]));
|
||||
assert!(imported.bodies[0].body.paint().unwrap().faces.iter().any(|f| f.color == [0.0, 1.0, 0.0]));
|
||||
}
|
||||
@@ -274,11 +274,17 @@ fn sheet_of(s: &Session, p: &Value, cmd: &str) -> Result<SheetBody> {
|
||||
}
|
||||
|
||||
fn flat_json(sh: &SheetBody) -> Result<Value> {
|
||||
let (w, h) = sh.flat_size()?;
|
||||
let loops = sh.outline()?;
|
||||
let regions = sh.flat_regions()?;
|
||||
let loops: Vec<Vec<Vec2>> = regions.iter().map(|r| r.outer.polyline(1e-3)).collect();
|
||||
let (mut lo, mut hi) = (Vec2::new(f64::MAX, f64::MAX), Vec2::new(f64::MIN, f64::MIN));
|
||||
for p in loops.iter().flatten() {
|
||||
lo = Vec2::new(lo.x.min(p.x), lo.y.min(p.y));
|
||||
hi = Vec2::new(hi.x.max(p.x), hi.y.max(p.y));
|
||||
}
|
||||
let (w, h) = (hi.x - lo.x, hi.y - lo.y);
|
||||
let pt = |v: &Vec2| json!([v.x, v.y]);
|
||||
let outline: Vec<Value> = loops.iter().map(|l| Value::Array(l.iter().map(pt).collect())).collect();
|
||||
let holes: Vec<Value> = sh.holes.iter().map(|h| Value::Array(h.outer.polyline(1e-3).iter().map(pt).collect())).collect();
|
||||
let holes: Vec<Value> = regions.iter().flat_map(|r| &r.holes).map(|h| Value::Array(h.polyline(1e-3).iter().map(pt).collect())).collect();
|
||||
let bends: Vec<Value> = sh
|
||||
.bends()
|
||||
.iter()
|
||||
@@ -331,16 +337,23 @@ pub fn flat_dxf(sh: &SheetBody) -> Result<String> {
|
||||
w(11, &f(b.x));
|
||||
w(21, &f(b.y));
|
||||
};
|
||||
for l in sh.outline()? {
|
||||
let m = l.len();
|
||||
for i in 0..m {
|
||||
if let (Some(a), Some(b)) = (l.get(i), l.get((i + 1) % m)) {
|
||||
line(&mut w, "OUTLINE", *a, *b);
|
||||
let regions = sh.flat_regions()?;
|
||||
for h in regions.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)) {
|
||||
// A circle is often represented by two semicircles. Keep the whole analytic
|
||||
// boundary as one CIRCLE rather than approximating outer boundaries with chords.
|
||||
if let Some(solvecraft_geom::Seg2::Arc { center, radius, sweep, .. }) = h.segs.first() {
|
||||
let total: f64 = h.segs.iter().filter_map(|s| if let solvecraft_geom::Seg2::Arc { sweep, .. } = s { Some(*sweep) } else { None }).sum();
|
||||
let circular = h.segs.iter().all(|s| matches!(s, solvecraft_geom::Seg2::Arc { center: c, radius: r, sweep: a, .. } if c.dist(*center) < 1e-9 && (*r-*radius).abs() < 1e-9 && a.signum() == sweep.signum()));
|
||||
if circular && (total.abs() - std::f64::consts::TAU).abs() < 1e-9 {
|
||||
w(0, "CIRCLE");
|
||||
w(8, "OUTLINE");
|
||||
w(10, &f(center.x));
|
||||
w(20, &f(center.y));
|
||||
w(40, &f(*radius));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
for h in &sh.holes {
|
||||
for seg in &h.outer.segs {
|
||||
for seg in &h.segs {
|
||||
match *seg {
|
||||
solvecraft_geom::Seg2::Arc { center, radius, start, sweep } => {
|
||||
if (sweep.abs() - std::f64::consts::TAU).abs() < 1e-9 {
|
||||
|
||||
@@ -421,3 +421,328 @@ fn flat_dxf_preserves_exact_cutout_entities() {
|
||||
assert_eq!(read.len(), 6);
|
||||
assert!(read.iter().any(|g| matches!(g, solvecraft_io::Geom2::Arc { c, a, b } if *c == Vec2::new(20.0, 10.0) && a.dist(Vec2::new(30.0, 10.0)) < 1e-9 && b.dist(Vec2::new(10.0, 10.0)) < 1e-9)));
|
||||
}
|
||||
|
||||
// A profile chosen by a point outside its inner loop must keep that inner loop through
|
||||
// sheet construction, flattening and manufacturing export.
|
||||
#[test]
|
||||
fn inherited_base_profile_holes_survive_every_sheet_state() {
|
||||
for flip in [false, true] {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Perforated"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [40, 20], "p1": [60, 40]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sheet.flange", json!({"sketch": "Perforated", "profiles": [{"point": [10, 10]}], "flip": flip}));
|
||||
assert!((volume(&mut s) - 14000.0).abs() < 1e-6);
|
||||
let f = flat(&mut s);
|
||||
assert_eq!(f["cutouts"].as_array().map(Vec::len), Some(1));
|
||||
let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string();
|
||||
assert_eq!(solvecraft_io::read_dxf(&dxf).unwrap().len(), 8);
|
||||
let mut reopened = Session::default();
|
||||
run(&mut reopened, "sketch.insert_dxf", json!({"text": dxf, "name": "Blank"}));
|
||||
run(&mut reopened, "sketch.finish", json!({}));
|
||||
let pick = if flip { [10, -10] } else { [10, 10] };
|
||||
run(&mut reopened, "solid.extrude", json!({"sketch": "Blank", "profiles": [{"point": pick}], "distance": 2.5}));
|
||||
assert!((volume(&mut reopened) - 14000.0).abs() < 1e-5);
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
assert!((volume(&mut s) - 14000.0).abs() < 1e-6);
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
assert!((volume(&mut s) - 14000.0).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_notch_uses_the_same_material_boundary_in_solid_flat_and_dxf() {
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Notch"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-5, 20], "p1": [10, 40]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Notch", "through_all": true, "operation": "cut", "direction": "symmetric"}));
|
||||
assert!((volume(&mut s) - 14500.0).abs() < 1e-6);
|
||||
let f = flat(&mut s);
|
||||
assert_eq!(f["outline"].as_array().map(Vec::len), Some(1));
|
||||
assert_eq!(f["outline"][0].as_array().map(Vec::len), Some(8));
|
||||
assert_eq!(f["cutouts"].as_array().map(Vec::len), Some(0));
|
||||
let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string();
|
||||
assert_eq!(solvecraft_io::read_dxf(&dxf).unwrap().len(), 8);
|
||||
let mut reopened = Session::default();
|
||||
run(&mut reopened, "sketch.insert_dxf", json!({"text": dxf, "name": "Blank"}));
|
||||
run(&mut reopened, "sketch.finish", json!({}));
|
||||
run(&mut reopened, "solid.extrude", json!({"sketch": "Blank", "distance": 2.5}));
|
||||
assert!((volume(&mut reopened) - 14500.0).abs() < 1e-5);
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
assert!((volume(&mut s) - 14500.0).abs() < 1e-6);
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
assert!((volume(&mut s) - 14500.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inherited_round_hole_survives_flange_and_unfold_in_flipped_frames() {
|
||||
for flip in [false, true] {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Perforated"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [50, 30], "radius": 5}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sheet.flange", json!({"sketch": "Perforated", "profiles": [{"point": [10, 10]}], "flip": flip}));
|
||||
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, if flip { -2.5 } else { 2.5 }]], "height": 20}));
|
||||
let folded = volume(&mut s);
|
||||
assert!((folded - (18972.62196081845 - PI * 25.0 * 2.5)).abs() < 0.2, "{folded}");
|
||||
let f = flat(&mut s);
|
||||
assert_eq!(f["cutouts"].as_array().map(Vec::len), Some(1));
|
||||
let sh = s.model.state().sheets[0].clone();
|
||||
let regions = sh.flat_regions().unwrap();
|
||||
assert_eq!(regions[0].holes[0].segs.len(), 2);
|
||||
assert!(regions[0].holes[0].segs.iter().all(|s| matches!(s, solvecraft_geom::Seg2::Arc { radius, .. } if (*radius - 5.0).abs() < 1e-9)));
|
||||
let expected = regions.iter().map(solvecraft_geom::Region2::area).sum::<f64>() * 2.5;
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
assert!((volume(&mut s) - expected).abs() < 0.1);
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
assert!((volume(&mut s) - folded).abs() < 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crossing_only_disconnecting_cut_preserves_all_material_states() {
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Split"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [45, -5], "p1": [55, 65]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Split", "through_all": true, "operation": "cut", "direction": "symmetric"}));
|
||||
assert!((volume(&mut s) - 13500.0).abs() < 1e-6);
|
||||
assert_eq!(flat(&mut s)["outline"].as_array().map(Vec::len), Some(2));
|
||||
let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string();
|
||||
let mut reopened = Session::default();
|
||||
run(&mut reopened, "sketch.insert_dxf", json!({"text": dxf, "name": "SplitBlank"}));
|
||||
run(&mut reopened, "sketch.finish", json!({}));
|
||||
run(&mut reopened, "solid.extrude", json!({"sketch": "SplitBlank", "distance": 2.5}));
|
||||
assert!((volume(&mut reopened) - 13500.0).abs() < 1e-6);
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
assert!((volume(&mut s) - 13500.0).abs() < 1e-6);
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
assert!((volume(&mut s) - 13500.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splitting_an_unfolded_flanged_sheet_preserves_both_bent_pieces() {
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
run(&mut s, "sheet.flange", json!({"edges": [[50,0,2.5]], "height": 20}));
|
||||
let folded = volume(&mut s);
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
let unfolded = volume(&mut s);
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "BlankSplit"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [45,-100], "p1": [55,100]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "BlankSplit", "through_all": true, "operation": "cut", "direction": "symmetric"}));
|
||||
assert!((volume(&mut s) - unfolded * 0.9).abs() < 0.1);
|
||||
assert_eq!(flat(&mut s)["outline"].as_array().map(Vec::len), Some(2));
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
assert!((volume(&mut s) - folded * 0.9).abs() < 0.2);
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
assert!((volume(&mut s) - unfolded * 0.9).abs() < 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_and_nested_sheet_cuts_are_redundant() {
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
for (i, radius) in [3.0, 5.0, 5.0].into_iter().enumerate() {
|
||||
let name = format!("Hole{i}");
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": name}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [20, 30], "radius": radius}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": name, "through_all": true, "operation": "cut", "direction": "symmetric"}));
|
||||
}
|
||||
assert!((volume(&mut s) - (6000.0 - PI * 25.0) * 2.5).abs() < 0.1);
|
||||
assert_eq!(flat(&mut s)["cutouts"].as_array().map(Vec::len), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_round_cuts_keep_exact_union_in_export_and_roundtrip() {
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
for (i, x) in [20.0, 22.0].into_iter().enumerate() {
|
||||
let name = format!("Overlap{i}");
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": name}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [x, 30], "radius": 5}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": name, "through_all": true, "operation": "cut", "direction": "symmetric"}));
|
||||
}
|
||||
let intersection = 50.0 * (0.2f64).acos() - (96.0f64).sqrt();
|
||||
let expected = (6000.0 - (50.0 * PI - intersection)) * 2.5;
|
||||
assert!((volume(&mut s) - expected).abs() < 0.1);
|
||||
assert_eq!(flat(&mut s)["cutouts"].as_array().map(Vec::len), Some(1));
|
||||
let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string();
|
||||
assert!(dxf.contains("0\nARC\n"));
|
||||
let mut reopened = Session::default();
|
||||
run(&mut reopened, "sketch.insert_dxf", json!({"text": dxf, "name": "Blank"}));
|
||||
run(&mut reopened, "sketch.finish", json!({}));
|
||||
run(&mut reopened, "solid.extrude", json!({"sketch": "Blank", "profiles": [{"point": [10, 10]}], "distance": 2.5}));
|
||||
assert!((volume(&mut reopened) - expected).abs() < 0.1);
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
assert!((volume(&mut s) - expected).abs() < 0.1);
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
assert!((volume(&mut s) - expected).abs() < 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cut_across_a_concave_notch_uses_crossings_not_just_contained_corners() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "U"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0,0],[100,0],[100,60],[70,60],[70,20],[30,20],[30,60],[0,60],[0,0]]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sheet.flange", json!({"sketch": "U"}));
|
||||
assert!((volume(&mut s) - 11000.0).abs() < 1e-6);
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "ArmNotches"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [20,30], "p1": [80,40]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "ArmNotches", "through_all": true, "operation": "cut", "direction": "symmetric"}));
|
||||
assert!((volume(&mut s) - 10500.0).abs() < 1e-6);
|
||||
let f = flat(&mut s);
|
||||
assert_eq!(f["cutouts"].as_array().map(Vec::len), Some(0));
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
assert!((volume(&mut s) - 10500.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiny_cut_inside_exact_circle_survives_unfold_refold_and_dxf() {
|
||||
for initially_flat in [false, true] {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Disc"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [0,0], "radius": 10}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sheet.flange", json!({"sketch": "Disc"}));
|
||||
let circle = s.model.state().sheets[0].base.outer.clone();
|
||||
let displayed = circle.polyline(1e-3);
|
||||
let at = (displayed[0] + displayed[1]).normalized().unwrap() * 9.9998;
|
||||
assert!(!circle.contains(at));
|
||||
if initially_flat {
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
}
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "TinyCut"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [at.x-0.000025,at.y-0.000025], "p1": [at.x+0.000025,at.y+0.000025]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "TinyCut", "through_all": true, "operation": "cut", "direction": "symmetric"}));
|
||||
for stage in ["sheet.unfold", "sheet.refold"] {
|
||||
let regions = s.model.state().sheets[0].flat_regions().unwrap();
|
||||
assert_eq!(regions[0].holes.len(), 1);
|
||||
assert!((regions[0].holes[0].signed_area().abs() - 2.5e-9).abs() < 1e-13);
|
||||
assert_eq!(s.model.state().bodies.len(), 1);
|
||||
let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string();
|
||||
assert_eq!(solvecraft_io::read_dxf(&dxf).unwrap().len(), 5);
|
||||
if stage == "sheet.unfold" && initially_flat {
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
}
|
||||
run(&mut s, stage, json!({}));
|
||||
}
|
||||
assert_eq!(s.model.state().sheets[0].flat_regions().unwrap()[0].holes.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrelated_box_cut_preserves_logical_ring_sheet_compound() {
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
let sheet_name = s.model.state().sheets[0].body.clone();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Ring"}));
|
||||
for radius in [10, 5] {
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [50,30], "radius": radius}));
|
||||
}
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(
|
||||
&mut s,
|
||||
"solid.extrude",
|
||||
json!({"sketch": "Ring", "profiles": [{"point": [57,30]}], "through_all": true, "operation": "cut", "direction": "symmetric"}),
|
||||
);
|
||||
let material = s.model.state().sheets[0].flat_regions().unwrap();
|
||||
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 10, "corner": [200,0,0], "body_name": "SeparateBox"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "BoxCut"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [205,5], "p1": [210,10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(
|
||||
&mut s,
|
||||
"solid.extrude",
|
||||
json!({"sketch": "BoxCut", "participants": ["SeparateBox"], "through_all": true, "operation": "cut", "direction": "symmetric"}),
|
||||
);
|
||||
assert_eq!(s.model.state().bodies.len(), 2);
|
||||
assert_eq!(s.model.state().sheets[0].flat_regions().unwrap(), material);
|
||||
for stage in ["sheet.unfold", "sheet.refold"] {
|
||||
run(&mut s, stage, json!({"body": sheet_name}));
|
||||
assert_eq!(s.model.state().bodies.len(), 2);
|
||||
let state = s.model.state();
|
||||
let body = &state.bodies.iter().find(|b| b.name == sheet_name).unwrap().body;
|
||||
assert!(body.contains(solvecraft_geom::Vec3::new(50.0, 30.0, 1.25)));
|
||||
assert!(!body.contains(solvecraft_geom::Vec3::new(57.0, 30.0, 1.25)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn specialized_sheet_cuts_respect_component_and_explicit_participants() {
|
||||
for explicit in [false, true] {
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
let name = s.model.state().sheets[0].body.clone();
|
||||
run(&mut s, "component.create", json!({"name": "Other"}));
|
||||
run(&mut s, "solid.box", json!({"length": 100, "width": 60, "height": 10, "body_name": "OtherBox"}));
|
||||
if explicit {
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
}
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Cut"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [20,20], "p1": [30,30]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let mut params = json!({"sketch": "Cut", "through_all": true, "operation": "cut", "direction": "symmetric"});
|
||||
if explicit {
|
||||
params["participants"] = json!(["OtherBox"]);
|
||||
}
|
||||
run(&mut s, "solid.extrude", params);
|
||||
let m = run(&mut s, "inspect.measure", json!({"bodies": [name]}));
|
||||
assert!((m["total"]["volume_mm3"].as_f64().unwrap() - 15000.0).abs() < 1e-6);
|
||||
assert!(s.model.state().sheets[0].holes.is_empty());
|
||||
let m = run(&mut s, "inspect.measure", json!({"bodies": ["OtherBox"]}));
|
||||
assert!((m["total"]["volume_mm3"].as_f64().unwrap() - 59000.0).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ring_cut_preserves_retained_island_through_unfold_and_refold() {
|
||||
fn material_probes(s: &Session) {
|
||||
let body = &s.model.state().bodies[0].body;
|
||||
assert!(body.contains(solvecraft_geom::Vec3::new(50.0, 30.0, 1.25)));
|
||||
assert!(!body.contains(solvecraft_geom::Vec3::new(57.0, 30.0, 1.25)));
|
||||
assert!(body.contains(solvecraft_geom::Vec3::new(65.0, 30.0, 1.25)));
|
||||
}
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Ring"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [50,30], "radius": 10}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [50,30], "radius": 5}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(
|
||||
&mut s,
|
||||
"solid.extrude",
|
||||
json!({"sketch": "Ring", "profiles": [{"point": [57,30]}], "through_all": true, "operation": "cut", "direction": "symmetric"}),
|
||||
);
|
||||
let expected = (6000.0 - PI * 75.0) * 2.5;
|
||||
assert!((volume(&mut s) - expected).abs() < 0.2);
|
||||
material_probes(&s);
|
||||
assert_eq!(flat(&mut s)["outline"].as_array().map(Vec::len), Some(2));
|
||||
let regions = s.model.state().sheets[0].flat_regions().unwrap();
|
||||
assert!(regions.iter().any(|r| r.contains(solvecraft_geom::Vec2::new(50.0, 30.0))));
|
||||
assert!(!regions.iter().any(|r| r.contains(solvecraft_geom::Vec2::new(57.0, 30.0))));
|
||||
assert!(regions.iter().any(|r| r.contains(solvecraft_geom::Vec2::new(65.0, 30.0))));
|
||||
let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string();
|
||||
let mut reopened = Session::default();
|
||||
run(&mut reopened, "sketch.insert_dxf", json!({"text": dxf, "name": "RingBlank"}));
|
||||
run(&mut reopened, "sketch.finish", json!({}));
|
||||
run(&mut reopened, "solid.extrude", json!({"sketch": "RingBlank", "profiles": [{"point": [10,10]}, {"point": [50,30]}], "distance": 2.5}));
|
||||
assert!((volume(&mut reopened) - expected).abs() < 0.2);
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
assert!((volume(&mut s) - expected).abs() < 0.2);
|
||||
material_probes(&s);
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
assert!((volume(&mut s) - expected).abs() < 0.2);
|
||||
material_probes(&s);
|
||||
}
|
||||
@@ -9,6 +9,10 @@ use super::{CommandSpec, in_sketch};
|
||||
use crate::params::{bad, bool_, expr, num, req_vec2, str_, string_list, vec2, vec3};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
#[path = "sketch_dimensioned.rs"]
|
||||
mod dimensioned;
|
||||
use dimensioned::draw as draw_dimensioned;
|
||||
|
||||
/// The `connect` parameter every drawing command takes (see `connect_points`).
|
||||
macro_rules! connect_doc {
|
||||
() => {
|
||||
@@ -29,6 +33,9 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.key("L")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("points: [[x,y] | \"l1.end\" | {at, ref}…], closed?: bool, construction?: bool, ids?: [..], infer?: bool (add horizontal/vertical)", connect_doc!())),
|
||||
CommandSpec::new("sketch.draw.dimensioned", "Draw Dimensioned Sketch Geometry", draw_dimensioned)
|
||||
.enabled(in_sketch)
|
||||
.params("command: sketch creation command, params: object, dimensions: object of role to numeric or expression value; creates geometry and all driving dimensions atomically in one undo step"),
|
||||
CommandSpec::new("sketch.rectangle.two_point", "2-Point Rectangle", rect_two_point)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("rect")
|
||||
@@ -355,7 +362,8 @@ pub(super) fn plane_ref(s: &Session, p: &Value, cmd: &str) -> Result<PlaneRef> {
|
||||
// A planar face of a body at this point, as the active component sees the model
|
||||
// (another component's face where it is placed).
|
||||
let model = s.model.state();
|
||||
let st = solvecraft_doc::state_in_frame(&s.doc, &model, s.active_component);
|
||||
let placed = s.placed_document();
|
||||
let st = solvecraft_doc::state_in_frame_at(&placed, &model, s.active_component, s.active_component_frame());
|
||||
let mut found = None;
|
||||
let mut face_name = None;
|
||||
for b in &st.bodies {
|
||||
@@ -792,6 +800,7 @@ fn arc_center(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn polygon(s: &mut Session, p: &Value, cmd: &str, center: Vec2, r_vertex: f64, start: f64, n: usize, inscribed: bool) -> Result<Value> {
|
||||
let mut support_circle = String::new();
|
||||
let (out, info) = draw(s, p, cmd, |sk, _| {
|
||||
let corners: Vec<Vec2> = (0..n).map(|i| center + Vec2::from_angle(start + std::f64::consts::TAU * i as f64 / n as f64) * r_vertex).collect();
|
||||
let (l, pts) = polygon_lines(sk, &corners)?;
|
||||
@@ -804,6 +813,7 @@ fn polygon(s: &mut Session, p: &Value, cmd: &str, center: Vec2, r_vertex: f64, s
|
||||
// Construction circle tying the polygon to its centre.
|
||||
let rc = if inscribed { r_vertex } else { r_vertex * (std::f64::consts::PI / n as f64).cos() };
|
||||
let circ = sk.add_circle(center, rc, None, None)?;
|
||||
support_circle = sk.curves.get(circ).map(|c| c.id.clone()).unwrap_or_default();
|
||||
if let Some(cu) = sk.curves.get_mut(circ) {
|
||||
cu.construction = true;
|
||||
}
|
||||
@@ -818,13 +828,15 @@ fn polygon(s: &mut Session, p: &Value, cmd: &str, center: Vec2, r_vertex: f64, s
|
||||
}
|
||||
Ok((ids_of(sk, &l), cons))
|
||||
})?;
|
||||
Ok(result(out, info))
|
||||
let mut value = result(out, info);
|
||||
value["support_circle"] = json!(support_circle);
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn sides(p: &Value, cmd: &str) -> Result<usize> {
|
||||
let n = num(p, "sides").unwrap_or(6.0);
|
||||
if !(3.0..=200.0).contains(&n) {
|
||||
return Err(bad(cmd, "sides must be 3…200"));
|
||||
if !(3.0..=200.0).contains(&n) || n.fract() != 0.0 {
|
||||
return Err(bad(cmd, "sides must be an integer from 3 to 200"));
|
||||
}
|
||||
Ok(n as usize)
|
||||
}
|
||||
@@ -1333,9 +1345,18 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// The other reading of an angle between two lines (see below).
|
||||
let mut alt: Option<(ConstraintKind, f64)> = None;
|
||||
let (mut k, mut cur): (ConstraintKind, f64) = match (e0, e1) {
|
||||
((None, Some(c)), None) if ty == "sweep" => {
|
||||
let angle = arc_len(sk, c).zip(sk.radius(c)).map(|(len, r)| len / r).ok_or_else(|| bad(cmd, "sweep needs an arc"))?;
|
||||
let angle = if sk.curves.get(c).is_some_and(|c| c.reversed) { -angle } else { angle };
|
||||
(ConstraintKind::ArcSweep { c, value: angle }, angle)
|
||||
}
|
||||
((None, Some(c)), None) if is_line(c) => {
|
||||
let (a, b) = line_pts(sk, c).unwrap_or_default();
|
||||
match ty.as_str() {
|
||||
"angle" => {
|
||||
let angle = (b - a).angle();
|
||||
(ConstraintKind::LineAngle { l: c, value: angle }, angle)
|
||||
}
|
||||
"horizontal" => {
|
||||
(ConstraintKind::DistanceX { p: line_ends(sk, c).0, q: line_ends(sk, c).1, value: (b.x - a.x).abs() }, (b.x - a.x).abs())
|
||||
}
|
||||
@@ -1384,6 +1405,15 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(ConstraintKind::PointLineDistance { p: q, l, value: d }, d)
|
||||
}
|
||||
}
|
||||
((None, Some(a)), Some((None, Some(b)))) if ty == "overall_length" => {
|
||||
let current = sk
|
||||
.center(a)
|
||||
.zip(sk.center(b))
|
||||
.zip(sk.radius(a).zip(sk.radius(b)))
|
||||
.map(|((ca, cb), (ra, rb))| ca.dist(cb) + ra + rb)
|
||||
.ok_or_else(|| bad(cmd, "overall slot length needs two arc caps"))?;
|
||||
(ConstraintKind::SlotOverallLength { a, b, value: current }, current)
|
||||
}
|
||||
((None, Some(a)), Some((None, Some(b)))) if is_line(a) && is_line(b) => {
|
||||
let (a0, a1) = line_pts(sk, a).unwrap_or_default();
|
||||
let (b0, b1) = line_pts(sk, b).unwrap_or_default();
|
||||
|
||||
@@ -434,8 +434,9 @@ fn anchor(sk: &Sketch, k: &ConstraintKind) -> Option<Vec2> {
|
||||
}
|
||||
Distance { p, q, .. } | DistanceX { p, q, .. } | DistanceY { p, q, .. } => Some((pt(p)? + pt(q)?) * 0.5),
|
||||
PointLineDistance { p, .. } | LinearDiameter { p, .. } => pt(p),
|
||||
Length { l, .. } => mid(l),
|
||||
Radius { c, .. } | Diameter { c, .. } | ArcLength { c, .. } => mid(c),
|
||||
Length { l, .. } | LineAngle { l, .. } => mid(l),
|
||||
Radius { c, .. } | Diameter { c, .. } | ArcLength { c, .. } | ArcSweep { c, .. } => mid(c),
|
||||
SlotOverallLength { a, b, .. } => Some((sk.center(a)? + sk.center(b)?) * 0.5),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
//! Shape creation and its typed driving dimensions form one command and one undo step.
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::expr::Kind;
|
||||
|
||||
use crate::{
|
||||
Result, Session,
|
||||
params::{bad, expr, str_},
|
||||
};
|
||||
|
||||
pub(super) fn draw(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.draw.dimensioned";
|
||||
let create = str_(p, "command").ok_or_else(|| bad(cmd, "`command` must name a supported sketch creator"))?;
|
||||
let roles: &[&str] = match create {
|
||||
"sketch.line" => &["length", "angle"],
|
||||
"sketch.rectangle.two_point" | "sketch.rectangle.center" | "sketch.rectangle.three_point" => &["width", "height"],
|
||||
"sketch.circle.center" | "sketch.circle.two_point" | "sketch.circle.three_point" => &["radius", "diameter"],
|
||||
"sketch.arc.center_point" => &["radius", "sweep"],
|
||||
"sketch.slot.center_to_center" | "sketch.slot.overall" => &["length", "width"],
|
||||
"sketch.polygon.inscribed" | "sketch.polygon.circumscribed" | "sketch.polygon.edge" => &["radius"],
|
||||
_ => return Err(bad(cmd, "unsupported sketch creator")),
|
||||
};
|
||||
let mut params = p.get("params").filter(|v| v.is_object()).cloned().ok_or_else(|| bad(cmd, "`params` must be an object"))?;
|
||||
let dimensions = p.get("dimensions").and_then(Value::as_object).ok_or_else(|| bad(cmd, "`dimensions` must be an object"))?;
|
||||
if dimensions.len() > roles.len() || dimensions.keys().any(|key| !roles.contains(&key.as_str())) {
|
||||
return Err(bad(cmd, "unsupported dimension role for this creator"));
|
||||
}
|
||||
if dimensions.contains_key("radius") && dimensions.contains_key("diameter") {
|
||||
return Err(bad(cmd, "choose radius or diameter, not both"));
|
||||
}
|
||||
let mut overall_length = None;
|
||||
let mut slot_width = None;
|
||||
for (role, value) in dimensions {
|
||||
let e = expr(&json!({"value": value}), "value")
|
||||
.filter(|e| e.len() <= 4096)
|
||||
.ok_or_else(|| bad(cmd, "dimension values must be bounded expressions or finite numbers"))?;
|
||||
let kind = if role == "angle" || role == "sweep" { Kind::Angle } else { Kind::Length };
|
||||
let evaluated = s.doc.eval(&e, kind)?;
|
||||
if role == "length" {
|
||||
overall_length = Some(evaluated);
|
||||
}
|
||||
if role == "width" {
|
||||
slot_width = Some(evaluated);
|
||||
}
|
||||
if !evaluated.is_finite() || evaluated.abs() > 1e9 || kind == Kind::Length && evaluated <= 1e-9 {
|
||||
return Err(bad(cmd, format!("invalid {role} dimension")));
|
||||
}
|
||||
if role == "sweep" && !(evaluated.abs() > 1e-9 && evaluated.abs() < std::f64::consts::TAU - 1e-9) {
|
||||
return Err(bad(cmd, "sweep must be nonzero and less than a full turn"));
|
||||
}
|
||||
if role == "sweep" {
|
||||
params["sweep"] = json!(evaluated.to_degrees());
|
||||
if let Some(o) = params.as_object_mut() {
|
||||
o.remove("end");
|
||||
}
|
||||
}
|
||||
}
|
||||
if create == "sketch.slot.overall"
|
||||
&& let Some(length) = overall_length
|
||||
&& let Some(width) = slot_width.or_else(|| params.get("width").and_then(Value::as_f64))
|
||||
&& length <= width + 1e-9
|
||||
{
|
||||
return Err(bad(cmd, "overall slot length must exceed its width"));
|
||||
}
|
||||
if create == "sketch.line" {
|
||||
if params.get("closed").and_then(Value::as_bool).unwrap_or(false)
|
||||
|| params.get("points").and_then(Value::as_array).is_none_or(|points| points.len() != 2)
|
||||
{
|
||||
return Err(bad(cmd, "dimensioned line creation needs exactly two points"));
|
||||
}
|
||||
if dimensions.contains_key("angle") {
|
||||
params["infer"] = json!(false);
|
||||
}
|
||||
}
|
||||
let mut scratch = s.scratch();
|
||||
let mut out = scratch.execute(create, ¶ms)?;
|
||||
let curves = out.get("curves").and_then(Value::as_array).cloned().unwrap_or_default();
|
||||
let curve = |index: usize| -> Result<Value> {
|
||||
curves.get(index).filter(|v| v.is_string()).cloned().ok_or_else(|| bad(cmd, "creator did not return the expected curve"))
|
||||
};
|
||||
let mut added = serde_json::Map::new();
|
||||
for (role, value) in dimensions {
|
||||
let (entities, ty) = match (create, role.as_str()) {
|
||||
("sketch.line", "length") => (vec![curve(0)?], "length"),
|
||||
("sketch.line", "angle") => (vec![curve(0)?], "angle"),
|
||||
(c, "width" | "height") if c.starts_with("sketch.rectangle.") => (vec![curve(usize::from(role == "height"))?], "length"),
|
||||
(c, "radius" | "diameter") if c.starts_with("sketch.circle.") => (vec![curve(0)?], role.as_str()),
|
||||
("sketch.arc.center_point", "radius" | "sweep") => (vec![curve(0)?], role.as_str()),
|
||||
("sketch.slot.center_to_center", "length") => (vec![curve(0)?], "length"),
|
||||
("sketch.slot.overall", "length") => (vec![curve(1)?, curve(3)?], "overall_length"),
|
||||
("sketch.slot.center_to_center" | "sketch.slot.overall", "width") => (vec![curve(1)?], "diameter"),
|
||||
(c, "radius") if c.starts_with("sketch.polygon.") => {
|
||||
let support =
|
||||
out.get("support_circle").filter(|v| v.is_string()).cloned().ok_or_else(|| bad(cmd, "polygon support circle missing"))?;
|
||||
(vec![support], "radius")
|
||||
}
|
||||
_ => return Err(bad(cmd, "unsupported dimension role")),
|
||||
};
|
||||
let mut dimension = json!({"entities": entities, "type": ty, "value": value});
|
||||
if let Some(sketch) = params.get("sketch") {
|
||||
dimension["sketch"] = sketch.clone();
|
||||
}
|
||||
let result = scratch.execute("sketch.dimension", &dimension)?;
|
||||
added.insert(role.clone(), result);
|
||||
}
|
||||
// Session::execute records the wrapper's before snapshot. Nested history must not leak.
|
||||
scratch.undo.clear();
|
||||
s.accept_computed(scratch);
|
||||
out["dimensions"] = Value::Object(added);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_dimensioned_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,167 @@
|
||||
use crate::Session;
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_sketch::ConstraintKind;
|
||||
|
||||
fn run(s: &mut Session, command: &str, params: Value) -> Value {
|
||||
s.execute(command, ¶ms).unwrap_or_else(|error| panic!("{command} {params}: {error}"))
|
||||
}
|
||||
fn session() -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY"}));
|
||||
s
|
||||
}
|
||||
fn sketch(s: &Session) -> solvecraft_sketch::Sketch {
|
||||
s.model.state().sketch(s.active_sketch.unwrap()).unwrap().sketch.clone()
|
||||
}
|
||||
fn draw(s: &mut Session, command: &str, params: Value, dimensions: Value) -> Value {
|
||||
run(s, "sketch.draw.dimensioned", json!({"command":command,"params":params,"dimensions":dimensions}))
|
||||
}
|
||||
fn close(a: f64, b: f64) {
|
||||
assert!((a - b).abs() < 1e-6, "{a} != {b}");
|
||||
}
|
||||
fn measure(s: &Session, kind: fn(&ConstraintKind) -> bool) -> f64 {
|
||||
let sk = sketch(s);
|
||||
let c = sk.constraints.iter().find(|c| kind(&c.kind)).unwrap();
|
||||
solvecraft_sketch::measure_dimension(&sk, &c.kind).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_line_angle_survives_quadrants_move_reopen_and_one_undo() {
|
||||
let mut s = session();
|
||||
run(&mut s, "parameters.add", json!({"name":"heading","expression":"-45 deg","unit":"deg"}));
|
||||
let undo = s.undo.len();
|
||||
let out = draw(&mut s, "sketch.line", json!({"points":[[0,0],[10,0]],"infer":true}), json!({"length":"10 mm","angle":"heading"}));
|
||||
assert_eq!(s.undo.len(), undo + 1);
|
||||
assert!(!sketch(&s).constraints.iter().any(|c| matches!(c.kind, ConstraintKind::Horizontal { .. } | ConstraintKind::Vertical { .. })));
|
||||
close(measure(&s, |k| matches!(k, ConstraintKind::LineAngle { .. })), -45f64.to_radians());
|
||||
for degrees in [135.0_f64, 270.0, -135.0, 0.0, 90.0] {
|
||||
let result = run(&mut s, "parameters.change", json!({"name":"heading","expression":format!("{degrees} deg")}));
|
||||
assert_eq!(result["errors"], json!([]));
|
||||
close(measure(&s, |k| matches!(k, ConstraintKind::LineAngle { .. })), degrees.to_radians());
|
||||
close(measure(&s, |k| matches!(k, ConstraintKind::Length { .. })), 10.0);
|
||||
}
|
||||
let encoded = serde_json::to_value(&*s.doc).unwrap();
|
||||
let doc: solvecraft_doc::Document = serde_json::from_value(encoded).unwrap();
|
||||
let mut reopened = Session { doc: std::sync::Arc::new(doc), active_sketch: s.active_sketch, ..Session::default() };
|
||||
reopened.refresh();
|
||||
close(measure(&reopened, |k| matches!(k, ConstraintKind::LineAngle { .. })), 90f64.to_radians());
|
||||
run(&mut reopened, "sketch.move", json!({"entities":out["curves"],"translate":[4,3],"angle":20}));
|
||||
close(measure(&reopened, |k| matches!(k, ConstraintKind::LineAngle { .. })), 90f64.to_radians());
|
||||
let mut atomic = session();
|
||||
draw(&mut atomic, "sketch.line", json!({"points":[[0,0],[5,5]]}), json!({"length":10,"angle":45}));
|
||||
run(&mut atomic, "edit.undo", json!({}));
|
||||
assert!(sketch(&atomic).curves.is_empty());
|
||||
assert!(atomic.doc.params.iter().all(|p| !p.model));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_arc_sweep_retains_radius_and_logical_endpoint_identity() {
|
||||
let mut s = session();
|
||||
run(&mut s, "parameters.add", json!({"name":"turn","expression":"-90 deg","unit":"deg"}));
|
||||
let out = draw(&mut s, "sketch.arc.center_point", json!({"center":[0,0],"start":[10,0],"end":[0,-10]}), json!({"radius":10,"sweep":"turn"}));
|
||||
let id = out["curves"][0].as_str().unwrap().to_string();
|
||||
let original = sketch(&s).resolve_point(&format!("{id}.start")).unwrap();
|
||||
for degrees in [-90.0_f64, -270.0, 90.0, 270.0, -45.0] {
|
||||
let result = run(&mut s, "parameters.change", json!({"name":"turn","expression":format!("{degrees} deg")}));
|
||||
assert_eq!(result["errors"], json!([]));
|
||||
let sk = sketch(&s);
|
||||
assert_eq!(sk.resolve_point(&format!("{id}.start")), Some(original));
|
||||
assert_eq!(sk.curves[sk.curve_index(&id).unwrap()].reversed, degrees < 0.0);
|
||||
close(measure(&s, |k| matches!(k, ConstraintKind::ArcSweep { .. })), degrees.to_radians());
|
||||
close(measure(&s, |k| matches!(k, ConstraintKind::Radius { .. })), 10.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slots_and_polygon_keep_permanent_typed_dimensions() {
|
||||
for command in ["sketch.slot.center_to_center", "sketch.slot.overall"] {
|
||||
let mut s = session();
|
||||
let out = draw(&mut s, command, json!({"p0":[2,3],"p1":[22,3],"width":4,"connect":false}), json!({"length":20,"width":4}));
|
||||
let length = out["dimensions"]["length"]["param"].as_str().unwrap();
|
||||
let result = run(&mut s, "parameters.change", json!({"name":length,"expression":30}));
|
||||
assert_eq!(result["errors"], json!([]));
|
||||
close(measure(&s, |k| matches!(k, ConstraintKind::Length { .. } | ConstraintKind::SlotOverallLength { .. })), 30.0);
|
||||
let width = out["dimensions"]["width"]["param"].as_str().unwrap();
|
||||
let result = run(&mut s, "parameters.change", json!({"name":width,"expression":6}));
|
||||
assert_eq!(result["errors"], json!([]));
|
||||
close(measure(&s, |k| matches!(k, ConstraintKind::Diameter { .. })), 6.0);
|
||||
close(measure(&s, |k| matches!(k, ConstraintKind::Length { .. } | ConstraintKind::SlotOverallLength { .. })), 30.0);
|
||||
}
|
||||
for command in ["sketch.polygon.inscribed", "sketch.polygon.circumscribed"] {
|
||||
let mut s = session();
|
||||
let out = draw(&mut s, command, json!({"center":[4,5],"radius":10,"sides":6,"connect":false}), json!({"radius":10}));
|
||||
let support = out["support_circle"].as_str().unwrap();
|
||||
assert!(sketch(&s).curves[sketch(&s).curve_index(support).unwrap()].construction);
|
||||
let radius = out["dimensions"]["radius"]["param"].as_str().unwrap();
|
||||
let result = run(&mut s, "parameters.change", json!({"name":radius,"expression":15}));
|
||||
assert_eq!(result["errors"], json!([]));
|
||||
close(measure(&s, |k| matches!(k, ConstraintKind::Radius { .. })), 15.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_dimensioned_shapes_are_atomic_including_late_constraint_failure() {
|
||||
let mut s = session();
|
||||
let before = serde_json::to_value(&*s.doc).unwrap();
|
||||
let history = s.undo.len();
|
||||
for params in [
|
||||
json!({"command":"edit.undo","params":{},"dimensions":{}}),
|
||||
json!({"command":"sketch.line","params":{"points":[[0,0],[10,0]]},"dimensions":{"length":"missing+1"}}),
|
||||
json!({"command":"sketch.line","params":{"points":[[0,0],[10,0]]},"dimensions":{"length":-1}}),
|
||||
json!({"command":"sketch.line","params":{"points":[[0,0],[10,0]]},"dimensions":{"length":"1".repeat(4097)}}),
|
||||
json!({"command":"sketch.arc.center_point","params":{"center":[0,0],"start":[10,0]},"dimensions":{"sweep":360}}),
|
||||
json!({"command":"sketch.polygon.inscribed","params":{"center":[0,0],"radius":10,"sides":3.5},"dimensions":{"radius":10}}),
|
||||
json!({"command":"sketch.line","params":{"points":[[0,0],[10,0]]},"dimensions":{"length":20,"angle":0}}),
|
||||
] {
|
||||
// The last case connects both endpoints to fixed existing points and fails only after creation.
|
||||
if params["dimensions"]["length"] == 20 {
|
||||
run(&mut s, "sketch.point", json!({"point":[10,0]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entity":"p1","fixed":true}));
|
||||
}
|
||||
let snapshot = serde_json::to_value(&*s.doc).unwrap();
|
||||
let undo = s.undo.len();
|
||||
// Preserve a live redo branch, which scratch-only dimension failure must not consume.
|
||||
run(&mut s, "sketch.point", json!({"point":[100,100]}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
s.selection = vec![crate::Sel::SketchPoint { id: "origin".into() }];
|
||||
let redo = s.redo.len();
|
||||
let selection = s.selection.clone();
|
||||
let pending = s.pending_moves.clone();
|
||||
let active = (s.active_sketch, s.active_component, s.active_occurrence_path.clone());
|
||||
assert!(s.execute("sketch.draw.dimensioned", ¶ms).is_err(), "{params}");
|
||||
assert_eq!(serde_json::to_value(&*s.doc).unwrap(), snapshot);
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
assert_eq!(s.redo.len(), redo);
|
||||
assert_eq!(s.selection, selection);
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
assert_eq!((s.active_sketch, s.active_component, s.active_occurrence_path.clone()), active);
|
||||
}
|
||||
assert_eq!(before["features"].as_array().unwrap().len(), 1);
|
||||
assert!(s.undo.len() >= history);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimensioned_authoring_uses_selected_occurrence_and_keeps_local_geometry() {
|
||||
let mut s = Session::default();
|
||||
let component = run(&mut s, "component.create", json!({"name":"Shared"}))["component"].as_u64().unwrap();
|
||||
let second = run(&mut s, "occurrence.copy", json!({"component":component,"translate":[20,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "component.activate", json!({"component":component,"occurrence":second,"path":[second]}));
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"corner":[20,0,0],"operation":"new"}));
|
||||
s.auto_project = false;
|
||||
run(&mut s, "sketch.create", json!({"plane":{"face":[21,1,2]}}));
|
||||
let out = draw(&mut s, "sketch.line", json!({"points":[[0,0],[5,5]]}), json!({"length":5,"angle":45}));
|
||||
let model = s.model.state();
|
||||
let local = model.sketch(s.active_sketch.unwrap()).unwrap();
|
||||
close(local.plane.origin.x, 0.0);
|
||||
close(local.plane.origin.z, 2.0);
|
||||
let world = s.world_state();
|
||||
let displayed = world.sketch(s.active_sketch.unwrap()).unwrap();
|
||||
close(displayed.plane.origin.x, 20.0);
|
||||
close(displayed.plane.origin.z, 2.0);
|
||||
assert_eq!(s.active_occurrence_path, Some(vec![second]));
|
||||
let sk = sketch(&s);
|
||||
let line = sk.curve_index(out["curves"][0].as_str().unwrap()).unwrap();
|
||||
let solvecraft_sketch::CurveKind::Line { a, b } = sk.curves[line].kind else { panic!("line") };
|
||||
close(sk.points[a].pos.x, 0.0);
|
||||
close(sk.points[b].pos.x, 5.0 / std::f64::consts::SQRT_2);
|
||||
}
|
||||
Reference in new issue
Block a user