feat: strengthen modeling, configuration, and assembly workflows
Preserve linked sketch identities through deterministic face projection and resizing. Fix origin-corner picking, persistent inline navigation, appearance editing, configuration styles, component properties, contact sweeps, sheet exports, analytic STEP offsets, and bounded rolling-ball fit accuracy. Validated with full cargo xtask ci, native workflow screenshots, repeated enclosure replays, and analytic geometry regressions.
This commit is contained in:
1 parent
22672585c9
commit
d71bdb78ec
54 files changed
+4179
-316
No files matched your search
@@ -413,6 +413,14 @@ fn rename_body(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for f in d.appearances.faces.iter_mut().filter(|f| f.body == old) {
|
||||
f.body = name.to_string();
|
||||
}
|
||||
for column in &mut d.configs.columns {
|
||||
match column {
|
||||
solvecraft_doc::config::Column::Material(body) | solvecraft_doc::config::Column::Appearance(body) if *body == old => {
|
||||
*body = name.to_string();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(o) = d.body_offsets.remove(&old) {
|
||||
d.body_offsets.insert(name.to_string(), o);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@ use super::CommandSpec;
|
||||
use crate::params::{bad, bool_, num, str_, string_list, vec3};
|
||||
use crate::{Result, Session};
|
||||
|
||||
#[path = "component_properties.rs"]
|
||||
mod properties;
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("component.create", "New Component", new_component)
|
||||
.at("SOLID", "ASSEMBLE")
|
||||
@@ -21,6 +24,12 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.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.list", "List Components", list).noundo(),
|
||||
CommandSpec::new("component.properties", "Component Physical Properties", properties::component)
|
||||
.icon("measure").noundo()
|
||||
.params("component?: id or name (default active); descendants?: bool (default true). Measures the first placement, or all placements for root, including pending moves; material-aware mass and center of mass in world coordinates"),
|
||||
CommandSpec::new("occurrence.properties", "Occurrence Physical Properties", properties::occurrence)
|
||||
.icon("measure").noundo()
|
||||
.params("occurrence: id or name; path?: [occurrence ids] (required if nested under multiple instances); descendants?: bool (default true). Physical properties of this placement in world coordinates, including pending moves"),
|
||||
CommandSpec::new("component.rename", "Rename Component", rename).params("component, name"),
|
||||
CommandSpec::new("component.delete", "Delete Component", delete).params("component: deletes it, its subcomponents, occurrences and features"),
|
||||
CommandSpec::new("component.move_bodies", "Move Bodies to Component", move_bodies).params("bodies: [names], component"),
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
//! Physical properties of an assembly placement, with original body material assignments.
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::{Document, Mat};
|
||||
use solvecraft_geom::Vec3;
|
||||
|
||||
use crate::params::bad;
|
||||
use crate::{Result, Session};
|
||||
|
||||
fn descendants(p: &Value, cmd: &str) -> Result<bool> {
|
||||
match p.get("descendants") {
|
||||
None => Ok(true),
|
||||
Some(Value::Bool(v)) => Ok(*v),
|
||||
Some(_) => Err(bad(cmd, "`descendants` must be a boolean")),
|
||||
}
|
||||
}
|
||||
|
||||
fn placed_doc(s: &Session) -> Document {
|
||||
let mut doc = (*s.doc).clone();
|
||||
for occurrence in &mut doc.occurrences {
|
||||
if let Some(transform) = s.pending_moves.get(&occurrence.id) {
|
||||
occurrence.transform = *transform;
|
||||
}
|
||||
}
|
||||
doc
|
||||
}
|
||||
|
||||
/// A property total must cover the entire assembly. The display traversal intentionally
|
||||
/// truncates hostile designs, so use an explicit error instead of accepting its prefix.
|
||||
fn placements(doc: &Document, cmd: &str) -> Result<Vec<(u64, Vec<u64>, Mat)>> {
|
||||
let mut out = vec![(0, Vec::new(), solvecraft_doc::IDENTITY)];
|
||||
let mut stack = vec![(0, Vec::<u64>::new(), solvecraft_doc::IDENTITY)];
|
||||
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 = path.clone();
|
||||
child_path.push(occurrence.id);
|
||||
let world = solvecraft_doc::mat_mul(&transform, &occurrence.transform);
|
||||
out.push((occurrence.component, child_path.clone(), world));
|
||||
stack.push((occurrence.component, child_path, world));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub(super) fn component(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.properties";
|
||||
let id = if p.get("component").is_some() { super::component(s, p, "component", cmd)? } else { s.active_component };
|
||||
let doc = placed_doc(s);
|
||||
let path = doc.first_path(id);
|
||||
let name = if id == 0 {
|
||||
doc.name.clone()
|
||||
} else {
|
||||
doc.components.iter().find(|c| c.id == id).map(|c| c.name.clone()).ok_or_else(|| bad(cmd, "no active component"))?
|
||||
};
|
||||
let mut out = properties(s, &doc, &path, descendants(p, cmd)?, cmd)?;
|
||||
out["component"] = json!(id);
|
||||
out["name"] = json!(name);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
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
|
||||
} else {
|
||||
match paths.as_slice() {
|
||||
[path] => path.clone(),
|
||||
[] => return Err(bad(cmd, "occurrence has no placement")),
|
||||
_ => return Err(bad(cmd, "occurrence has multiple placements; supply its `path`")),
|
||||
}
|
||||
};
|
||||
let mut out = properties(s, &doc, &path, descendants(p, cmd)?, cmd)?;
|
||||
out["occurrence"] = json!(id);
|
||||
out["name"] = json!(doc.occurrences.iter().find(|o| o.id == id).map(|o| o.name.as_str()));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool, cmd: &str) -> Result<Value> {
|
||||
let state = s.model.state();
|
||||
let mut bodies = Vec::new();
|
||||
let (mut volume, mut area, mut mass) = (0.0, 0.0, 0.0);
|
||||
let mut moment = Vec3::ZERO;
|
||||
let (mut min, mut max) =
|
||||
(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 })
|
||||
{
|
||||
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);
|
||||
let placed = solvecraft_kernel::transform_matrix(&body.body, transform)?;
|
||||
let measured = solvecraft_kernel::measure(&placed)?;
|
||||
let density = doc.density(&body.name);
|
||||
let body_mass = measured.volume * density / 1000.0;
|
||||
volume += measured.volume;
|
||||
area += measured.area;
|
||||
mass += body_mass;
|
||||
moment += measured.centroid * body_mass;
|
||||
min = Vec3::new(min.x.min(measured.bbox.min.x), min.y.min(measured.bbox.min.y), min.z.min(measured.bbox.min.z));
|
||||
max = Vec3::new(max.x.max(measured.bbox.max.x), max.y.max(measured.bbox.max.y), max.z.max(measured.bbox.max.z));
|
||||
faces += measured.merged.faces;
|
||||
edges += measured.merged.edges;
|
||||
vertices += measured.merged.vertices;
|
||||
bodies.push(json!({"name": body.name, "component": component, "path": path,
|
||||
"material": doc.materials.get(&body.name).map(String::as_str).unwrap_or("Default"),
|
||||
"density_g_cm3": density, "mass_g": body_mass, "volume_mm3": measured.volume,
|
||||
"area_mm2": measured.area, "center_of_mass": measured.centroid,
|
||||
"bbox": {"min": measured.bbox.min, "max": measured.bbox.max}}));
|
||||
}
|
||||
}
|
||||
if !(volume.is_finite() && area.is_finite() && mass.is_finite() && moment.is_finite()) {
|
||||
return Err(bad(cmd, "physical properties exceed supported numeric range"));
|
||||
}
|
||||
let centroid = if mass > 0.0 { Some(moment / mass) } else { None };
|
||||
if centroid.is_some_and(|point| !point.is_finite()) {
|
||||
return Err(bad(cmd, "center of mass exceeds supported numeric range"));
|
||||
}
|
||||
let bbox = if bodies.is_empty() { Value::Null } else { json!({"min": min, "max": max}) };
|
||||
Ok(json!({"path": selected, "coordinate_frame": "world", "descendants": descendants,
|
||||
"placement_count": placement_count, "body_count": bodies.len(), "bodies": bodies,
|
||||
"total": {"volume_mm3": volume, "area_mm2": area, "mass_g": mass,
|
||||
"center_of_mass": centroid, "bbox": bbox, "faces": faces, "edges": edges, "vertices": vertices}}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "component_properties_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,129 @@
|
||||
use crate::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn run(s: &mut Session, cmd: &str, params: Value) -> Value {
|
||||
s.execute(cmd, ¶ms).unwrap()
|
||||
}
|
||||
fn close(actual: &Value, expected: f64) {
|
||||
assert!((actual.as_f64().unwrap() - expected).abs() < 1e-6, "{actual} != {expected}");
|
||||
}
|
||||
fn box_in(s: &mut Session, name: &str) -> (u64, u64, String) {
|
||||
let c = run(s, "component.create", json!({"name": name}));
|
||||
run(s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
|
||||
let body = s.model.state().bodies.last().unwrap().name.clone();
|
||||
(c["component"].as_u64().unwrap(), c["occurrence"].as_u64().unwrap(), body)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assembly_counts_instances_and_resolves_original_materials() {
|
||||
let mut s = Session::default();
|
||||
let (component, occurrence, body) = box_in(&mut s, "Block");
|
||||
run(&mut s, "material.assign", json!({"bodies": [body], "material": "Steel"}));
|
||||
let copy = run(&mut s, "occurrence.copy", json!({"component": component, "translate": [20, 0, 0]}));
|
||||
let rho = s.doc.density(&body);
|
||||
let root = run(&mut s, "component.properties", json!({"component": "root"}));
|
||||
assert_eq!(root["body_count"], 2);
|
||||
close(&root["total"]["mass_g"], 2.0 * rho);
|
||||
close(&root["total"]["volume_mm3"], 2000.0);
|
||||
close(&root["total"]["center_of_mass"][0], 15.0);
|
||||
let first = run(&mut s, "component.properties", json!({"component": component}));
|
||||
assert_eq!(first["body_count"], 1);
|
||||
close(&first["total"]["center_of_mass"][0], 5.0);
|
||||
let second = run(&mut s, "occurrence.properties", json!({"occurrence": copy["occurrence"]}));
|
||||
close(&second["total"]["center_of_mass"][0], 25.0);
|
||||
assert_eq!(second["bodies"][0]["material"], "Steel");
|
||||
assert_eq!(first["path"], json!([occurrence]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mass_centroid_is_density_weighted_and_pending_moves_are_read_only() {
|
||||
let mut s = Session::default();
|
||||
let (a, oa, steel) = box_in(&mut s, "Steel block");
|
||||
run(&mut s, "material.assign", json!({"bodies": [steel], "material": "Steel"}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
let (_, ob, plastic) = box_in(&mut s, "Plastic block");
|
||||
run(&mut s, "material.assign", json!({"bodies": [plastic], "material": "ABS Plastic"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": ob, "translate": [30, 0, 0], "capture": false}));
|
||||
let doc = s.doc.clone();
|
||||
let moves = s.pending_moves.clone();
|
||||
let r = run(&mut s, "component.properties", json!({"component": 0}));
|
||||
let ra = s.doc.density(&steel);
|
||||
let rb = s.doc.density(&plastic);
|
||||
close(&r["total"]["center_of_mass"][0], (5.0 * ra + 35.0 * rb) / (ra + rb));
|
||||
close(&r["total"]["bbox"]["max"][0], 40.0);
|
||||
assert!(std::sync::Arc::ptr_eq(&s.doc, &doc));
|
||||
assert_eq!(s.pending_moves, moves);
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": oa, "translate": [10, 0, 0], "axis": [0, 0, 1], "angle": "90 deg"}));
|
||||
let r = run(&mut s, "component.properties", json!({"component": a}));
|
||||
close(&r["total"]["center_of_mass"][0], 5.0);
|
||||
close(&r["total"]["center_of_mass"][1], 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_instances_require_a_path_and_can_exclude_descendants() {
|
||||
let mut s = Session::default();
|
||||
let parent = run(&mut s, "component.create", json!({"name": "Assembly"}));
|
||||
let (_, child_occurrence, _) = box_in(&mut s, "Child");
|
||||
let copy = run(&mut s, "occurrence.copy", json!({"component": parent["component"], "translate": [100, 0, 0]}));
|
||||
assert!(s.execute("occurrence.properties", &json!({"occurrence": child_occurrence})).is_err());
|
||||
let r = run(&mut s, "occurrence.properties", json!({"occurrence": child_occurrence, "path": [copy["occurrence"], child_occurrence]}));
|
||||
close(&r["total"]["center_of_mass"][0], 105.0);
|
||||
let r = run(&mut s, "component.properties", json!({"component": parent["component"]}));
|
||||
assert_eq!(r["body_count"], 1);
|
||||
let r = run(&mut s, "component.properties", json!({"component": parent["component"], "descendants": false}));
|
||||
assert_eq!(r["body_count"], 0);
|
||||
assert!(r["total"]["center_of_mass"].is_null());
|
||||
assert!(r["total"]["bbox"].is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_reassignment_offsets_preserve_world_properties() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
|
||||
let body = s.model.state().bodies.first().unwrap().name.clone();
|
||||
let c = run(&mut s, "component.create", json!({"name": "Moved"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": c["occurrence"], "translate": [100, 0, 0]}));
|
||||
run(&mut s, "component.move_bodies", json!({"bodies": [body], "component": c["component"]}));
|
||||
let r = run(&mut s, "component.properties", json!({"component": c["component"]}));
|
||||
close(&r["total"]["center_of_mass"][0], 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn properties_reject_hostile_identifiers_and_options() {
|
||||
let mut s = Session::default();
|
||||
let (_, occurrence, _) = box_in(&mut s, "Block");
|
||||
for p in [json!({"component": []}), json!({"component": "missing"}), json!({"descendants": 1})] {
|
||||
assert!(s.execute("component.properties", &p).is_err());
|
||||
}
|
||||
for path in [json!([]), json!(["bad"]), json!([0]), json!(vec![occurrence; 65])] {
|
||||
assert!(s.execute("occurrence.properties", &json!({"occurrence": occurrence, "path": path})).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_exponentially_instanced_assembly_refuses_partial_totals() {
|
||||
let mut s = Session::default();
|
||||
let mut last_occurrence = 0;
|
||||
// Only 17 component definitions and 34 occurrences, but over 100000 placements.
|
||||
for _ in 0..17 {
|
||||
let parent = s.active_component;
|
||||
let component = s.doc_mut().add_component(None, parent).unwrap();
|
||||
last_occurrence = s.doc_mut().add_occurrence(component, parent, solvecraft_doc::IDENTITY).unwrap();
|
||||
s.active_component = component;
|
||||
}
|
||||
for command in ["component.properties", "occurrence.properties"] {
|
||||
let error = s.execute(command, &json!({"component": 0, "occurrence": last_occurrence})).unwrap_err();
|
||||
assert!(error.to_string().contains("traversal limits"), "{error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excessively_nested_assembly_refuses_partial_totals() {
|
||||
let mut s = Session::default();
|
||||
for _ in 0..65 {
|
||||
let parent = s.active_component;
|
||||
s.active_component = s.doc_mut().add_component(None, parent).unwrap();
|
||||
}
|
||||
let error = s.execute("component.properties", &json!({"component": 0})).unwrap_err();
|
||||
assert!(error.to_string().contains("traversal limits"), "{error}");
|
||||
}
|
||||
@@ -10,21 +10,33 @@ use crate::{Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("config.configure", "Configure", configure).at("SOLID", "CONFIGURE").icon("params").params(
|
||||
"params?: [parameter names]; features?: [feature names] (columns to add; with no rows yet, a \"Default\" row takes the current values)",
|
||||
"params?: [parameter names]; features?: [feature names]; materials?: [body names]; appearances?: [body names] (columns to add; with no rows yet, a \"Default\" row takes the current values)",
|
||||
),
|
||||
CommandSpec::new("config.table", "Display Configuration Table", show)
|
||||
.at("SOLID", "CONFIGURE")
|
||||
.icon("params")
|
||||
.noundo()
|
||||
.params("→ columns (param:<name> or suppress:<feature>), rows, the active row and whether the design still matches it"),
|
||||
CommandSpec::new("config.column", "Configuration Column", column).params("param? | feature? (name); delete?: bool"),
|
||||
CommandSpec::new("config.column", "Configuration Column", column).params("param? | feature? | material? | appearance? (target name); delete?: bool"),
|
||||
CommandSpec::new("config.row", "Configuration Row", row).params(
|
||||
"name; from? (row to copy; default the current values); values?: {param name or feature name: expression or true/false}; rename?; delete?: bool",
|
||||
"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"),
|
||||
];
|
||||
|
||||
fn column_of(s: &Session, p: &Value, cmd: &str) -> Result<Column> {
|
||||
if ["param", "feature", "material", "appearance"].iter().filter(|k| p.get(**k).is_some()).count() != 1 {
|
||||
return Err(bad(cmd, "give exactly one of `param`, `feature`, `material`, or `appearance`"));
|
||||
}
|
||||
for (key, appearance) in [("material", false), ("appearance", true)] {
|
||||
if let Some(body) = str_(p, key) {
|
||||
let c = if appearance { Column::Appearance(body.to_string()) } else { Column::Material(body.to_string()) };
|
||||
if !s.doc.configs.columns.contains(&c) && s.model.state().body(body).is_none() {
|
||||
return Err(bad(cmd, format!("no body `{body}`")));
|
||||
}
|
||||
return Ok(c);
|
||||
}
|
||||
}
|
||||
if let Some(n) = str_(p, "param") {
|
||||
if !s.doc.all_param_exprs().iter().any(|(m, _, _)| m == n) {
|
||||
return Err(bad(cmd, format!("no parameter `{n}`")));
|
||||
@@ -63,6 +75,13 @@ fn configure(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = column_of(s, &json!({ "feature": n }), cmd)?;
|
||||
add_column(s, c, cmd)?;
|
||||
}
|
||||
for key in ["materials", "appearances"] {
|
||||
for n in string_list(p, key) {
|
||||
let field = if key == "materials" { "material" } else { "appearance" };
|
||||
let c = column_of(s, &json!({ field: n }), cmd)?;
|
||||
add_column(s, c, cmd)?;
|
||||
}
|
||||
}
|
||||
if s.doc.configs.rows.is_empty() {
|
||||
let cells = s.doc.configs.columns.iter().map(|c| s.doc.config_current(c).unwrap_or_default()).collect();
|
||||
let t = &mut s.doc_mut().configs;
|
||||
@@ -122,32 +141,54 @@ fn row(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(vals) = p.get("values") {
|
||||
let vals = vals.as_object().ok_or_else(|| bad(cmd, "`values` must map columns to values"))?;
|
||||
for (k, v) in vals {
|
||||
let text = match v {
|
||||
let mut text = match v {
|
||||
Value::String(t) => t.clone(),
|
||||
Value::Bool(b) => b.to_string(),
|
||||
Value::Number(n) => n.to_string(),
|
||||
Value::Object(_) if k.starts_with("appearance:") => serde_json::to_string(v).map_err(|e| bad(cmd, e.to_string()))?,
|
||||
_ => return Err(bad(cmd, format!("value for `{k}` must be an expression or true/false"))),
|
||||
};
|
||||
if text.len() > 4096 {
|
||||
return Err(bad(cmd, "value too long"));
|
||||
}
|
||||
let key = k.strip_prefix("param:").or_else(|| k.strip_prefix("suppress:")).unwrap_or(k);
|
||||
let col = s
|
||||
.doc
|
||||
.configs
|
||||
.columns
|
||||
.iter()
|
||||
.position(|c| match c {
|
||||
Column::Param(n) => n == key,
|
||||
Column::Suppress(id) => s.doc.feature(*id).is_some_and(|f| f.name == key),
|
||||
.position(|c| {
|
||||
s.doc.config_key(c) == *k
|
||||
|| match c {
|
||||
Column::Param(n) => n == k,
|
||||
Column::Suppress(id) => s.doc.feature(*id).is_some_and(|f| f.name == *k),
|
||||
Column::Material(_) | Column::Appearance(_) => false,
|
||||
}
|
||||
})
|
||||
.ok_or_else(|| bad(cmd, format!("`{k}` is not a column (config.column adds one)")))?;
|
||||
match s.doc.configs.columns.get(col) {
|
||||
Some(Column::Material(_)) => {
|
||||
text = solvecraft_doc::config::material_cell(&text).map_err(|e| bad(cmd, e.to_string()))?.unwrap_or_else(|| "Default".into());
|
||||
}
|
||||
Some(Column::Appearance(_)) => {
|
||||
text = match solvecraft_doc::config::appearance_cell(&text).map_err(|e| bad(cmd, e.to_string()))? {
|
||||
Some(look) => serde_json::to_string(&look).map_err(|e| bad(cmd, e.to_string()))?,
|
||||
None => String::new(),
|
||||
};
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some(cell) = cells.get_mut(col) {
|
||||
*cell = text;
|
||||
}
|
||||
}
|
||||
}
|
||||
let rename = str_(p, "rename").map(str::trim).filter(|n| !n.is_empty() && n.len() <= 128).map(str::to_string);
|
||||
if p.get("rename").is_some() && (rename.is_none() || idx.is_none()) {
|
||||
return Err(bad(cmd, "rename requires an existing row and a nonempty name up to 128 bytes"));
|
||||
}
|
||||
if rename.as_ref().is_some_and(|n| n != &name && s.doc.configs.rows.iter().any(|r| &r.name == n)) {
|
||||
return Err(bad(cmd, "configuration name already exists"));
|
||||
}
|
||||
let t = &mut s.doc_mut().configs;
|
||||
match idx {
|
||||
Some(i) => {
|
||||
@@ -236,4 +277,136 @@ mod tests {
|
||||
run(&mut s, "config.column", json!({"feature": fillet, "delete": true}));
|
||||
assert_eq!(run(&mut s, "config.table", json!({}))["columns"].as_array().map(Vec::len), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_materials_and_looks_are_undoable_and_persistent() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
|
||||
let body = s.model.state().bodies.first().unwrap().name.clone();
|
||||
run(&mut s, "config.configure", json!({"materials": [body], "appearances": [body]}));
|
||||
let material = format!("material:{body}");
|
||||
let appearance = format!("appearance:{body}");
|
||||
run(&mut s, "config.row", json!({"name": "Metal", "values": {material.clone(): "steel", appearance.clone(): "ABS - Red"}}));
|
||||
run(
|
||||
&mut s,
|
||||
"config.row",
|
||||
json!({"name": "Custom", "from": "Metal", "values": {
|
||||
material.clone(): "Aluminum", appearance.clone(): {"name": "Ocean", "color": [1, 2, 3], "opacity": 0.4}
|
||||
}}),
|
||||
);
|
||||
run(&mut s, "config.activate", json!({"row": "Metal"}));
|
||||
assert_eq!(s.doc.materials.get(&body).map(String::as_str), Some("Steel"));
|
||||
assert_eq!(s.doc.appearances.bodies.get(&body).unwrap().color, [196, 40, 40]);
|
||||
let mass = run(&mut s, "inspect.measure", json!({}));
|
||||
assert!((mass["bodies"][0]["mass_g"].as_f64().unwrap() - 7.85).abs() < 1e-6);
|
||||
assert_eq!(run(&mut s, "config.table", json!({}))["matches"], true);
|
||||
run(&mut s, "config.activate", json!({"row": "Custom"}));
|
||||
let mass = run(&mut s, "inspect.measure", json!({}));
|
||||
assert!((mass["bodies"][0]["mass_g"].as_f64().unwrap() - 2.7).abs() < 1e-6);
|
||||
assert_eq!(s.doc.appearances.bodies.get(&body).unwrap().opacity, 0.4);
|
||||
let reopened = solvecraft_doc::format::read(&s.doc.to_json()).unwrap();
|
||||
assert_eq!(reopened, *s.doc);
|
||||
assert!(reopened.configuration_matches());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.materials.get(&body).map(String::as_str), Some("Steel"));
|
||||
run(&mut s, "config.activate", json!({"row": "Default"}));
|
||||
assert!(!s.doc.materials.contains_key(&body));
|
||||
assert!(!s.doc.appearances.bodies.contains_key(&body));
|
||||
run(&mut s, "config.activate", json!({"row": "Metal"}));
|
||||
run(&mut s, "appearance.assign", json!({"bodies": [body], "appearance": "ABS - Blue"}));
|
||||
assert!(!s.doc.configuration_matches());
|
||||
run(&mut s, "config.column", json!({"appearance": body, "delete": true}));
|
||||
assert_eq!(s.doc.configs.columns.len(), 1);
|
||||
assert!(s.doc.configs.rows.iter().all(|r| r.cells.len() == 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_styles_reject_hostile_values_without_changes() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
|
||||
let body = s.model.state().bodies.first().unwrap().name.clone();
|
||||
run(&mut s, "config.configure", json!({"materials": [body], "appearances": [body]}));
|
||||
let material = format!("material:{body}");
|
||||
let appearance = format!("appearance:{body}");
|
||||
for params in [
|
||||
json!({"name": "Bad", "values": {material: "Unobtainium"}}),
|
||||
json!({"name": "Bad", "values": {appearance.clone(): "#oops"}}),
|
||||
json!({"name": "Bad", "values": {appearance.clone(): {"name": "Invalid", "color": [1, 2, 300], "opacity": 1}}}),
|
||||
json!({"name": "Bad", "values": {appearance: {"name": "Invalid", "color": [1, 2, 3], "opacity": -1}}}),
|
||||
json!({"name": "Default", "rename": ""}),
|
||||
] {
|
||||
let before = s.doc.clone();
|
||||
assert!(s.execute("config.row", ¶ms).is_err(), "{params}");
|
||||
assert_eq!(s.doc, before);
|
||||
}
|
||||
assert!(s.execute("config.column", &json!({"appearance": "missing"})).is_err());
|
||||
assert!(s.execute("config.column", &json!({"appearance": body, "material": body})).is_err());
|
||||
run(&mut s, "config.row", json!({"name": "Other"}));
|
||||
assert!(s.execute("config.row", &json!({"name": "Other", "rename": "Default"})).is_err());
|
||||
// A malformed persisted row must fail atomically rather than silently apply its prefix.
|
||||
s.doc_mut().configs.rows.first_mut().unwrap().cells.pop();
|
||||
let before = s.doc.clone();
|
||||
assert!(s.execute("config.activate", &json!({"row": "Default"})).is_err());
|
||||
assert_eq!(s.doc, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_captures_unicode_custom_appearance_names() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
|
||||
let body = s.model.state().bodies.first().unwrap().name.clone();
|
||||
let name = "é".repeat(128);
|
||||
run(&mut s, "appearance.assign", json!({"bodies": [body], "appearance": name, "color": [40, 90, 186]}));
|
||||
run(&mut s, "config.configure", json!({"appearances": [body]}));
|
||||
run(&mut s, "appearance.assign", json!({"bodies": [body]}));
|
||||
run(&mut s, "config.activate", json!({"row": "Default"}));
|
||||
assert_eq!(s.doc.appearances.bodies.get(&body).unwrap().name, name);
|
||||
assert!(s.doc.configuration_matches());
|
||||
let reopened = solvecraft_doc::format::read(&s.doc.to_json()).unwrap();
|
||||
assert!(reopened.configuration_matches());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_styles_survive_parameter_and_body_renames() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.change", json!({"name": "Steel", "expression": "10 mm"}));
|
||||
run(&mut s, "parameters.change", json!({"name": "double", "expression": "Steel * 2"}));
|
||||
run(&mut s, "solid.box", json!({"length": "double", "width": 10, "height": 10, "body_name": "Block"}));
|
||||
run(&mut s, "config.configure", json!({"params": ["Steel", "double"], "materials": ["Block"], "appearances": ["Block"]}));
|
||||
run(
|
||||
&mut s,
|
||||
"config.row",
|
||||
json!({"name": "Styled", "values": {
|
||||
"param:Steel": "20 mm", "param:double": "Steel * 3", "material:Block": "Steel", "appearance:Block": "Steel - Satin"
|
||||
}}),
|
||||
);
|
||||
run(&mut s, "config.activate", json!({"row": "Styled"}));
|
||||
let styles: Vec<Vec<String>> = s.doc.configs.rows.iter().map(|r| r.cells.iter().skip(2).cloned().collect()).collect();
|
||||
run(&mut s, "parameters.rename", json!({"name": "Steel", "new_name": "gauge"}));
|
||||
let after: Vec<Vec<String>> = s.doc.configs.rows.iter().map(|r| r.cells.iter().skip(2).cloned().collect()).collect();
|
||||
assert_eq!(after, styles, "renaming an expression reference must preserve style cells verbatim");
|
||||
let table = run(&mut s, "config.table", json!({}));
|
||||
assert_eq!(table["columns"][0], "param:gauge");
|
||||
assert_eq!(table["rows"][1]["values"]["param:double"], "gauge * 3");
|
||||
assert!(s.doc.configuration_matches());
|
||||
run(&mut s, "body.rename", json!({"body": "Block", "name": "Housing"}));
|
||||
let table = run(&mut s, "config.table", json!({}));
|
||||
assert_eq!(table["columns"][2], "material:Housing");
|
||||
assert_eq!(table["columns"][3], "appearance:Housing");
|
||||
assert!(s.doc.configuration_matches());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(run(&mut s, "config.table", json!({}))["columns"][2], "material:Block");
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
let mut reopened = Session::new(solvecraft_doc::format::read(&s.doc.to_json()).unwrap());
|
||||
run(&mut reopened, "config.activate", json!({"row": "Default"}));
|
||||
assert!(!reopened.doc.materials.contains_key("Housing"));
|
||||
assert!(!reopened.doc.appearances.bodies.contains_key("Housing"));
|
||||
run(&mut reopened, "config.activate", json!({"row": "Styled"}));
|
||||
assert_eq!(reopened.doc.materials.get("Housing").map(String::as_str), Some("Steel"));
|
||||
assert_eq!(reopened.doc.appearances.bodies.get("Housing").unwrap().name, "Steel - Satin");
|
||||
assert!(!reopened.doc.materials.contains_key("Block"));
|
||||
assert!(!reopened.doc.appearances.bodies.contains_key("Block"));
|
||||
assert!((volume(&mut reopened) - 6000.0).abs() < 1e-6);
|
||||
assert!(reopened.doc.configuration_matches());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
//! Bounded, chronological checks of a joint trajectory. This samples tessellated geometry;
|
||||
//! it is deliberately not advertised as exact continuous collision detection.
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::sync::Arc;
|
||||
|
||||
use solvecraft_doc::joints;
|
||||
use solvecraft_doc::{IDENTITY, Mat, apply_point, mat_mul};
|
||||
use solvecraft_geom::{Aabb3, Mesh, Vec3};
|
||||
|
||||
use crate::params::bad;
|
||||
use crate::{Result, Session};
|
||||
|
||||
const MAX_SAMPLES: usize = 4096;
|
||||
const MAX_PLACEMENTS: usize = 4096;
|
||||
const MAX_BODIES: usize = 256;
|
||||
const MAX_TRIANGLES: usize = 50_000;
|
||||
const MAX_WORK: usize = 50_000_000;
|
||||
|
||||
fn limit(message: &str) -> crate::EngineError {
|
||||
bad("contact.sweep", message)
|
||||
}
|
||||
|
||||
struct CachedBody {
|
||||
path: Vec<u64>,
|
||||
offset: Mat,
|
||||
mesh: Arc<Mesh>,
|
||||
size: f64,
|
||||
}
|
||||
|
||||
pub(super) struct ContactGeometry {
|
||||
bodies: Vec<CachedBody>,
|
||||
pairs: Vec<(usize, usize, String, String)>,
|
||||
step: f64,
|
||||
}
|
||||
|
||||
impl ContactGeometry {
|
||||
pub(super) fn new(s: &Session) -> Result<Self> {
|
||||
let assembly = &s.doc.assembly;
|
||||
let sets: Vec<Vec<u64>> = if assembly.contact_all {
|
||||
vec![s.doc.occurrences.iter().map(|o| o.id).collect()]
|
||||
} else {
|
||||
assembly.contact_sets.iter().filter(|set| !set.suppressed).map(|set| set.occurrences.clone()).collect()
|
||||
};
|
||||
let mut out = Self { bodies: Vec::new(), pairs: Vec::new(), step: f64::INFINITY };
|
||||
if sets.is_empty() {
|
||||
return Ok(out);
|
||||
}
|
||||
let state = s.model.state();
|
||||
let mut meshes: HashMap<String, Arc<Mesh>> = HashMap::new();
|
||||
let mut stack = vec![(0, Vec::<u64>::new())];
|
||||
let mut count = 0;
|
||||
while let Some((component, path)) = stack.pop() {
|
||||
count += 1;
|
||||
if count > MAX_PLACEMENTS || path.len() > 64 {
|
||||
return Err(limit("assembly exceeds contact sweep placement budget"));
|
||||
}
|
||||
for occurrence in s.doc.occurrences.iter().filter(|o| o.parent == component) {
|
||||
let mut child = path.clone();
|
||||
child.push(occurrence.id);
|
||||
stack.push((occurrence.component, child));
|
||||
if stack.len() > MAX_PLACEMENTS {
|
||||
return Err(limit("assembly exceeds contact sweep placement budget"));
|
||||
}
|
||||
}
|
||||
if !sets.iter().any(|set| path.iter().any(|id| set.contains(id))) {
|
||||
continue;
|
||||
}
|
||||
for body in state.bodies.iter().filter(|b| s.doc.body_component(&b.name, b.feature) == component) {
|
||||
if body.body.is_mesh() {
|
||||
return Err(limit("contact sweep does not support mesh bodies; disable their contact set to move them"));
|
||||
}
|
||||
if out.bodies.len() >= MAX_BODIES {
|
||||
return Err(limit("too many bodies for a contact sweep"));
|
||||
}
|
||||
let mesh = if let Some(mesh) = meshes.get(&body.name) {
|
||||
mesh.clone()
|
||||
} else {
|
||||
let mesh = body.body.tessellate((body.body.size() * 1e-3).max(1e-3))?;
|
||||
if mesh.triangles.len() > MAX_TRIANGLES || mesh.positions.len().saturating_add(mesh.triangles.len().saturating_mul(4)) > 200_000 {
|
||||
return Err(limit("body exceeds contact sweep tessellation budget"));
|
||||
}
|
||||
let extent = mesh.bounds().size();
|
||||
// Whole-body widths along bounded distinct facet normals catch tilted
|
||||
// plates without interpreting skinny mesh triangles as thin material.
|
||||
// Local thin walls of a wider body remain a sampling limitation.
|
||||
let mut thickness = extent.x.min(extent.y).min(extent.z);
|
||||
let mut directions: Vec<Vec3> = Vec::new();
|
||||
for triangle in &mesh.triangles {
|
||||
let Some([a, b, c]) = mesh.tri(triangle) else {
|
||||
return Err(limit("invalid contact tessellation"));
|
||||
};
|
||||
let Some(direction) = (b - a).cross(c - a).normalized() else { continue };
|
||||
if directions.len() < 32 && !directions.iter().any(|d| d.dot(direction).abs() > 1.0 - 1e-6) {
|
||||
directions.push(direction);
|
||||
}
|
||||
}
|
||||
for direction in directions {
|
||||
let (mut low, mut high) = (f64::INFINITY, f64::NEG_INFINITY);
|
||||
for point in &mesh.positions {
|
||||
let projected = point.dot(direction);
|
||||
low = low.min(projected);
|
||||
high = high.max(projected);
|
||||
}
|
||||
thickness = thickness.min(high - low);
|
||||
}
|
||||
if !thickness.is_finite() || thickness <= 1e-9 {
|
||||
return Err(limit("body is too thin for a bounded contact sweep"));
|
||||
}
|
||||
out.step = out.step.min(thickness * 0.25);
|
||||
let mesh = Arc::new(mesh);
|
||||
meshes.insert(body.name.clone(), mesh.clone());
|
||||
mesh
|
||||
};
|
||||
out.bodies.push(CachedBody {
|
||||
path: path.clone(),
|
||||
offset: s.doc.body_offsets.get(&body.name).copied().unwrap_or(IDENTITY),
|
||||
mesh,
|
||||
size: body.body.size(),
|
||||
});
|
||||
}
|
||||
}
|
||||
let mut pairs = BTreeSet::new();
|
||||
for set in &sets {
|
||||
let tagged: Vec<(usize, u64)> =
|
||||
out.bodies.iter().enumerate().filter_map(|(i, b)| b.path.iter().rev().find(|id| set.contains(id)).map(|id| (i, *id))).collect();
|
||||
for (i, (a, oa)) in tagged.iter().enumerate() {
|
||||
for (b, ob) in tagged.iter().skip(i + 1) {
|
||||
if oa != ob {
|
||||
pairs.insert((*a, *b, *oa, *ob));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let name = |id| s.doc.occurrences.iter().find(|o| o.id == id).map(|o| o.name.clone()).unwrap_or_default();
|
||||
out.pairs = pairs.into_iter().map(|(a, b, oa, ob)| (a, b, name(oa), name(ob))).collect();
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub(super) fn has_pairs(&self) -> bool {
|
||||
!self.pairs.is_empty()
|
||||
}
|
||||
|
||||
fn frame(body: &CachedBody, s: &Session) -> Result<Mat> {
|
||||
let mut frame = IDENTITY;
|
||||
for id in &body.path {
|
||||
let occurrence = s.doc.occurrences.iter().find(|o| o.id == *id).ok_or_else(|| limit("contact occurrence disappeared"))?;
|
||||
frame = mat_mul(&frame, &occurrence.transform);
|
||||
}
|
||||
Ok(mat_mul(&frame, &body.offset))
|
||||
}
|
||||
|
||||
fn bounds(&self, s: &Session) -> Result<Aabb3> {
|
||||
let mut bounds = Aabb3::EMPTY;
|
||||
for body in &self.bodies {
|
||||
let frame = Self::frame(body, s)?;
|
||||
for point in &body.mesh.positions {
|
||||
bounds.add(apply_point(&frame, *point));
|
||||
}
|
||||
}
|
||||
Ok(bounds)
|
||||
}
|
||||
|
||||
pub(super) fn samples(&self, start: &Session, end: &Session) -> Result<usize> {
|
||||
let a = joints::joint_values(&start.doc.assembly);
|
||||
let b = joints::joint_values(&end.doc.assembly);
|
||||
let bounds = self.bounds(start)?.union(&self.bounds(end)?);
|
||||
let state = start.model.state();
|
||||
let (mut linear, mut angular, mut radius) = (0.0, 0.0, 0.0_f64);
|
||||
for joint in start.doc.assembly.joints.iter().filter(|j| !j.suppressed) {
|
||||
for (index, _) in joint.kind.dofs().iter().enumerate() {
|
||||
let delta = (a.get(&joint.id).and_then(|q| q.get(index)).copied().unwrap_or(0.0)
|
||||
- b.get(&joint.id).and_then(|q| q.get(index)).copied().unwrap_or(0.0))
|
||||
.abs();
|
||||
if joint.kind.is_angle(index) {
|
||||
angular += delta;
|
||||
if delta == 0.0 {
|
||||
continue;
|
||||
}
|
||||
for session in [start, end] {
|
||||
for origin in [&joint.a, &joint.b] {
|
||||
let frame = session.doc.origin_world(&state, origin)?;
|
||||
let pivot = apply_point(&frame, Vec3::ZERO);
|
||||
let far = Vec3::new(
|
||||
(bounds.min.x - pivot.x).abs().max((bounds.max.x - pivot.x).abs()),
|
||||
(bounds.min.y - pivot.y).abs().max((bounds.max.y - pivot.y).abs()),
|
||||
(bounds.min.z - pivot.z).abs().max((bounds.max.z - pivot.z).abs()),
|
||||
);
|
||||
radius = radius.max(far.len());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
linear += delta;
|
||||
}
|
||||
}
|
||||
}
|
||||
let required = ((linear + angular * (radius + linear)) / self.step).max(angular / 1_f64.to_radians()).ceil().max(1.0);
|
||||
if !required.is_finite() || required > MAX_SAMPLES as f64 {
|
||||
return Err(limit("motion exceeds contact sweep sampling budget; drive the joint in smaller increments"));
|
||||
}
|
||||
let vertices: usize = self.bodies.iter().map(|body| body.mesh.positions.len()).sum();
|
||||
if (required as usize).saturating_mul(vertices.saturating_add(self.pairs.len())) > MAX_WORK {
|
||||
return Err(limit("motion exceeds contact sweep transform budget; simplify the contact set or use smaller increments"));
|
||||
}
|
||||
Ok(required as usize)
|
||||
}
|
||||
|
||||
pub(super) fn first_hit(&self, s: &Session, work: &mut usize) -> Result<Option<(String, String, f64)>> {
|
||||
let meshes: Vec<Mesh> = self
|
||||
.bodies
|
||||
.iter()
|
||||
.map(|body| {
|
||||
let frame = Self::frame(body, s)?;
|
||||
let positions = body.mesh.positions.iter().map(|p| apply_point(&frame, *p)).collect();
|
||||
Ok(Mesh { positions, triangles: body.mesh.triangles.clone(), ..Mesh::default() })
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
for (a, b, aname, bname) in &self.pairs {
|
||||
let (Some(ma), Some(mb), Some(ba), Some(bb)) = (meshes.get(*a), meshes.get(*b), self.bodies.get(*a), self.bodies.get(*b)) else {
|
||||
return Err(limit("invalid contact geometry pair"));
|
||||
};
|
||||
let (aabb, bbb) = (ma.bounds(), mb.bounds());
|
||||
if aabb.max.x < bbb.min.x
|
||||
|| bbb.max.x < aabb.min.x
|
||||
|| aabb.max.y < bbb.min.y
|
||||
|| bbb.max.y < aabb.min.y
|
||||
|| aabb.max.z < bbb.min.z
|
||||
|| bbb.max.z < aabb.min.z
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let cost = ma
|
||||
.positions
|
||||
.len()
|
||||
.saturating_add(ma.triangles.len().saturating_mul(4))
|
||||
.saturating_mul(mb.triangles.len())
|
||||
.saturating_add(mb.positions.len().saturating_add(mb.triangles.len().saturating_mul(4)).saturating_mul(ma.triangles.len()));
|
||||
*work = work.saturating_add(cost);
|
||||
if *work > MAX_WORK {
|
||||
return Err(limit("motion exceeds contact sweep collision budget; simplify the contact set or use smaller increments"));
|
||||
}
|
||||
if let Some(depth) = super::mesh_overlap(ma, mb, 1e-4 * ba.size.max(bb.size).max(1.0)) {
|
||||
return Ok(Some((aname.clone(), bname.clone(), depth)));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "contact_sweep_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,170 @@
|
||||
use crate::Session;
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_geom::Vec3;
|
||||
|
||||
fn run(s: &mut Session, command: &str, p: Value) -> Value {
|
||||
s.execute(command, &p).unwrap()
|
||||
}
|
||||
fn occurrence(s: &Session, name: &str) -> u64 {
|
||||
s.doc.occurrence_of(s.doc.find_component(name).unwrap()).unwrap().id
|
||||
}
|
||||
fn slider() -> Session {
|
||||
let mut s = Session::default();
|
||||
for (name, x) in [("A", 0), ("B", 20)] {
|
||||
run(&mut s, "component.activate", json!({"component": 0}));
|
||||
run(&mut s, "component.create", json!({"name": name}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]}));
|
||||
}
|
||||
let a = occurrence(&s, "A");
|
||||
run(&mut s, "occurrence.ground", json!({"occurrence": a, "grounded": true}));
|
||||
run(&mut s, "joint.create", json!({"type": "slider", "a": {"face": [10, 5, 5]}, "b": {"face": [20, 5, 5]}, "name": "Rail"}));
|
||||
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 10}));
|
||||
run(&mut s, "contact.create", json!({"occurrences": ["A", "B"]}));
|
||||
s
|
||||
}
|
||||
fn rail_value(s: &Session) -> f64 {
|
||||
s.doc.assembly.joints.iter().find(|j| j.name == "Rail").unwrap().values[0]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slider_cannot_tunnel_to_a_collision_free_endpoint() {
|
||||
let mut s = slider();
|
||||
let result = run(&mut s, "joint.drive", json!({"joint": "Rail", "value": -30}));
|
||||
assert!(result["stopped_by_contact"].is_object(), "{result}");
|
||||
assert!(rail_value(&s).abs() < 2e-3, "{}", rail_value(&s));
|
||||
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 10}));
|
||||
assert!((rail_value(&s) - 10.0).abs() < 1e-6, "can move away from contact");
|
||||
run(&mut s, "contact.disable_all", json!({}));
|
||||
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": -100000}));
|
||||
assert!((rail_value(&s) + 100000.0).abs() < 1e-6, "disabled contact skips sweep budget");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revolute_cannot_cross_a_blocker_between_clear_endpoints() {
|
||||
let mut s = Session::default();
|
||||
for (name, corner) in [("Arm", [20, -2, -2]), ("Blocker", [-2, 20, -2])] {
|
||||
run(&mut s, "component.activate", json!({"component": 0}));
|
||||
run(&mut s, "component.create", json!({"name": name}));
|
||||
run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4, "corner": corner}));
|
||||
}
|
||||
run(
|
||||
&mut s,
|
||||
"joint.create",
|
||||
json!({"type": "revolute", "a": {"occurrence": 0, "point": [0,0,0]}, "b": {"occurrence": "Arm", "point": [0,0,0]}, "flip": true, "name": "Pivot"}),
|
||||
);
|
||||
run(&mut s, "contact.create", json!({"occurrences": ["Arm", "Blocker"]}));
|
||||
let result = run(&mut s, "joint.drive", json!({"joint": "Pivot", "value": "180 deg"}));
|
||||
assert!(result["stopped_by_contact"].is_object(), "{result}");
|
||||
let angle = s.doc.assembly.joints.iter().find(|j| j.name == "Pivot").unwrap().values[0];
|
||||
assert!(angle > 60_f64.to_radians() && angle < 90_f64.to_radians(), "angle={angle}");
|
||||
let arm = solvecraft_doc::apply_point(&solvecraft_doc::joints::occurrence_world(&s.doc, occurrence(&s, "Arm")), Vec3::new(22.0, 0.0, 0.0));
|
||||
assert!(arm.y > 20.0 && arm.x > 2.0, "{arm:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_slider_uses_effective_travel_for_contact() {
|
||||
let mut s = slider();
|
||||
run(&mut s, "component.activate", json!({"component": 0}));
|
||||
run(&mut s, "component.create", json!({"name": "Input"}));
|
||||
run(
|
||||
&mut s,
|
||||
"joint.create",
|
||||
json!({"type": "slider", "a": {"occurrence": 0, "point": [0,0,0]}, "b": {"occurrence": "Input", "point": [0,0,0]}, "flip": true, "name": "Input Joint"}),
|
||||
);
|
||||
run(&mut s, "joint.drive", json!({"joint": "Input Joint", "value": 1}));
|
||||
run(&mut s, "joint.motion_link", json!({"a": "Input Joint", "b": "Rail", "ratio": 10}));
|
||||
let result = run(&mut s, "joint.drive", json!({"joint": "Input Joint", "value": -3}));
|
||||
assert!(result["stopped_by_contact"].is_object(), "{result}");
|
||||
let input = s.doc.assembly.joints.iter().find(|j| j.name == "Input Joint").unwrap().values[0];
|
||||
assert!(input.abs() < 2e-4, "{input}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_play_and_sparse_export_share_swept_contact_checks() {
|
||||
let mut s = slider();
|
||||
run(&mut s, "motion.study", json!({"name": "Crossing", "steps": 100, "keys": [{"joint": "Rail", "points": [[0,10], [100,-30]]}]}));
|
||||
let doc = s.doc.clone();
|
||||
let exported = run(&mut s, "motion.export", json!({"study": "Crossing", "samples": 2}));
|
||||
assert!(std::sync::Arc::ptr_eq(&doc, &s.doc), "export leaves document untouched");
|
||||
assert!(exported["samples"][1]["stopped_by_contact"].is_object(), "{exported}");
|
||||
let played = run(&mut s, "motion.play", json!({"study": "Crossing", "step": 100}));
|
||||
assert!(played["stopped_by_contact"].is_object(), "{played}");
|
||||
assert!(rail_value(&s).abs() < 2e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn over_budget_sweep_errors_and_restores_document() {
|
||||
let mut s = slider();
|
||||
let doc = s.doc.clone();
|
||||
let error = s.execute("joint.drive", &json!({"joint": "Rail", "value": -100000})).unwrap_err();
|
||||
assert!(error.to_string().contains("sampling budget"), "{error}");
|
||||
assert_eq!(*s.doc, *doc);
|
||||
assert!((rail_value(&s) - 10.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thin_tilted_plates_cannot_cross_between_axis_bound_samples() {
|
||||
let mut s = Session::default();
|
||||
let normal = solvecraft_doc::apply_vector(&solvecraft_doc::rigid(Vec3::ZERO, Vec3::ZERO, Vec3::new(1.0, 1.0, 0.0), 55_f64.to_radians()), Vec3::Z);
|
||||
for name in ["Plate A", "Plate B"] {
|
||||
run(&mut s, "component.activate", json!({"component": 0}));
|
||||
run(&mut s, "component.create", json!({"name": name}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 0.05, "corner": [-5,-5,-0.025], "body_name": name}));
|
||||
run(&mut s, "solid.move", json!({"bodies": [name], "axis": [1,1,0], "angle": "55 deg"}));
|
||||
}
|
||||
let (a, b) = (occurrence(&s, "Plate A"), occurrence(&s, "Plate B"));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": b, "translate": normal * 2.0}));
|
||||
run(&mut s, "occurrence.ground", json!({"occurrence": a, "grounded": true}));
|
||||
run(
|
||||
&mut s,
|
||||
"joint.as_built",
|
||||
json!({"type": "slider", "a": "Plate A", "b": "Plate B", "at": {"occurrence": 0, "point": [0,0,0], "z": normal}, "name": "Slide"}),
|
||||
);
|
||||
run(&mut s, "contact.create", json!({"occurrences": ["Plate A", "Plate B"]}));
|
||||
let result = run(&mut s, "joint.drive", json!({"joint": "Slide", "value": -4}));
|
||||
assert!(result["stopped_by_contact"].is_object(), "{result}");
|
||||
let value = s.doc.assembly.joints.iter().find(|j| j.name == "Slide").unwrap().values[0];
|
||||
assert!(value > -2.0 && value < -1.9, "{value}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_curved_bodies_keep_a_practical_sampling_step() {
|
||||
for curved in ["sphere", "fillet"] {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "component.create", json!({"name": "Blocker"}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [0,-5,-5]}));
|
||||
run(&mut s, "component.activate", json!({"component": 0}));
|
||||
run(&mut s, "component.create", json!({"name": "Curved"}));
|
||||
if curved == "sphere" {
|
||||
run(&mut s, "solid.sphere", json!({"radius": 5, "center": [20,0,0]}));
|
||||
} else {
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [15,-5,-5]}));
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[15,-5,0]], "radius": 2}));
|
||||
}
|
||||
run(
|
||||
&mut s,
|
||||
"joint.as_built",
|
||||
json!({"type": "slider", "a": 0, "b": "Curved", "at": {"occurrence": 0, "point": [0,0,0], "z": [1,0,0]}, "name": "Slide"}),
|
||||
);
|
||||
run(&mut s, "contact.create", json!({"occurrences": ["Blocker", "Curved"]}));
|
||||
let geometry = super::ContactGeometry::new(&s).unwrap();
|
||||
assert!(geometry.step > 1.0, "{curved}: tessellation must not force tiny steps: {}", geometry.step);
|
||||
let result = run(&mut s, "joint.drive", json!({"joint": "Slide", "value": -30}));
|
||||
assert!(result["stopped_by_contact"].is_object(), "{curved}: {result}");
|
||||
let value = s.doc.assembly.joints.iter().find(|j| j.name == "Slide").unwrap().values[0];
|
||||
assert!(value > -5.02 && value < -4.9, "{curved}: {value}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initially_overlapping_assembly_can_move_out_for_recovery() {
|
||||
let mut s = slider();
|
||||
run(&mut s, "contact.disable_all", json!({}));
|
||||
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": -5}));
|
||||
run(&mut s, "contact.enable_sets", json!({"enabled": true}));
|
||||
assert!(!run(&mut s, "contact.check", json!({}))["collisions"].as_array().unwrap().is_empty());
|
||||
let result = run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 10}));
|
||||
assert!(result["stopped_by_contact"].is_null(), "recovery must not stick: {result}");
|
||||
assert!((rail_value(&s) - 10.0).abs() < 1e-6);
|
||||
assert!(run(&mut s, "contact.check", json!({}))["collisions"].as_array().unwrap().is_empty());
|
||||
}
|
||||
@@ -296,36 +296,111 @@ fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"feature": id, "position": to}))
|
||||
}
|
||||
|
||||
fn input_keys(kind: &FeatureKind) -> Vec<(String, solvecraft_doc::expr::Kind)> {
|
||||
let mut keys: Vec<_> = kind.inputs().into_iter().map(|(label, _, unit)| (label.to_string(), unit)).collect();
|
||||
if let FeatureKind::Fillet { style, .. } = kind {
|
||||
if let Some((key, _)) = keys.first_mut() {
|
||||
*key = "fillet.radius".into();
|
||||
}
|
||||
if let solvecraft_doc::FilletStyle::Variable { mid, .. } = style {
|
||||
for ((key, _), (position, _)) in keys.iter_mut().skip(2).zip(mid) {
|
||||
*key = format!("fillet.mid.{}", position.to_bits());
|
||||
}
|
||||
}
|
||||
}
|
||||
keys
|
||||
}
|
||||
|
||||
fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.redefine";
|
||||
let id = feature_id(s, p.get("feature"), cmd)?;
|
||||
let cid = str_(p, "command").ok_or_else(|| bad(cmd, "`command` must name the feature's command"))?;
|
||||
let spec = super::find_command(cid).ok_or_else(|| bad(cmd, format!("no command `{cid}`")))?;
|
||||
if spec.id.starts_with("timeline.") || spec.id.starts_with("select.") || spec.id.starts_with("sketch.") {
|
||||
// Only modeling constructors may run here. In particular, a scratch session does not
|
||||
// isolate filesystem writes performed by export commands.
|
||||
let constructor = super::features::COMMANDS
|
||||
.iter()
|
||||
.chain(super::features_more::COMMANDS)
|
||||
.chain(super::construct::COMMANDS)
|
||||
.chain(super::surface::COMMANDS)
|
||||
.any(|candidate| candidate.id == spec.id)
|
||||
|| matches!(
|
||||
spec.id,
|
||||
"sheet.flange"
|
||||
| "sheet.hem"
|
||||
| "sheet.fold"
|
||||
| "sheet.unfold"
|
||||
| "sheet.refold"
|
||||
| "sheet.convert"
|
||||
| "plastic.boss"
|
||||
| "plastic.lip"
|
||||
| "plastic.snap_fit"
|
||||
| "plastic.rest"
|
||||
);
|
||||
if !constructor {
|
||||
return Err(bad(cmd, format!("`{cid}` does not make a feature")));
|
||||
}
|
||||
let params = p.get("params").cloned().unwrap_or_else(|| json!({}));
|
||||
let mut params = p.get("params").cloned().unwrap_or_else(|| json!({}));
|
||||
let idx = s.doc.feature_index(id).ok_or_else(|| bad(cmd, "feature"))?;
|
||||
let old = s.doc.feature(id).cloned().ok_or_else(|| bad(cmd, "feature"))?;
|
||||
let marker = s.doc.marker;
|
||||
// Build the new definition where the feature sits, against the model before it.
|
||||
s.doc_mut().marker = Some(idx);
|
||||
s.refresh();
|
||||
let r = (spec.run)(s, ¶ms);
|
||||
let made = r.as_ref().ok().and_then(|v| v.get("feature")).and_then(Value::as_u64);
|
||||
let new = made.and_then(|nid| s.doc.feature(nid).cloned());
|
||||
if let Some(nid) = made {
|
||||
s.doc_mut().features.retain(|f| f.id != nid);
|
||||
if !old.participants.is_empty()
|
||||
&& let Some(parameters) = params.as_object_mut()
|
||||
{
|
||||
parameters.entry("participants").or_insert_with(|| json!(old.participants));
|
||||
}
|
||||
s.doc_mut().marker = marker;
|
||||
r?;
|
||||
let new = new.ok_or_else(|| bad(cmd, format!("`{cid}` did not make a feature")))?;
|
||||
// Run the constructor on a scratch session: failed edits must not disturb sketch mode,
|
||||
// selections, component authoring state or the original feature's definition.
|
||||
let mut trial = s.scratch();
|
||||
trial.active_component = old.component;
|
||||
trial.active_sketch = None;
|
||||
trial.selection = s.selection.clone();
|
||||
trial.doc_mut().marker = Some(idx);
|
||||
trial.refresh();
|
||||
(spec.enabled)(&trial).map_err(|why| EngineError::Disabled(spec.id.to_string(), why))?;
|
||||
let r = (spec.run)(&mut trial, ¶ms)?;
|
||||
let made = r.get("feature").and_then(Value::as_u64).ok_or_else(|| bad(cmd, format!("`{cid}` did not make a feature")))?;
|
||||
let new = trial.doc.feature(made).cloned().ok_or_else(|| bad(cmd, format!("`{cid}` did not make a feature")))?;
|
||||
if std::mem::discriminant(&new.kind) != std::mem::discriminant(&old.kind) {
|
||||
return Err(bad(cmd, format!("{} is not a {} feature", old.name, cid)));
|
||||
}
|
||||
if let Some(slot) = s.doc_mut().feature_mut(id) {
|
||||
slot.kind = new.kind;
|
||||
// Optional inputs can move other inputs' positions (e.g. Distance 2 goes before Taper).
|
||||
// Keep each existing model parameter attached to its semantic input, rather than its
|
||||
// old index. Constructor-assigned names for new inputs cannot reuse the old dimensions.
|
||||
let mut old_inputs: Vec<_> = input_keys(&old.kind).into_iter().zip(&old.param_names).collect();
|
||||
let param_names: Vec<String> = input_keys(&new.kind)
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (label, kind))| {
|
||||
if let Some(k) = old_inputs.iter().position(|((old_label, old_kind), _)| old_label == label && old_kind == kind) {
|
||||
old_inputs.remove(k).1.clone()
|
||||
} else {
|
||||
new.param_names.get(i).cloned().unwrap_or_default()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
trial.doc_mut().features.retain(|f| f.id != made);
|
||||
let added = trial.doc.features.len().saturating_sub(s.doc.features.len());
|
||||
trial.doc_mut().marker = s.doc.marker.map(|marker| if marker > idx { marker.saturating_add(added) } else { marker });
|
||||
if made == s.doc.next_id && trial.doc.next_id == made.saturating_add(1) {
|
||||
trial.doc_mut().next_id = s.doc.next_id;
|
||||
}
|
||||
if let Some(slot) = trial.doc_mut().feature_mut(id) {
|
||||
slot.kind = new.kind;
|
||||
slot.edge_refs = new.edge_refs;
|
||||
slot.edge_names = new.edge_names;
|
||||
slot.face_names = new.face_names;
|
||||
slot.param_names = param_names;
|
||||
if params.get("participants").is_some() {
|
||||
slot.participants = new.participants;
|
||||
}
|
||||
}
|
||||
// New optional inputs need model parameter names, while existing dimensions keep theirs.
|
||||
trial.doc_mut().name_feature_inputs(id);
|
||||
trial.refresh();
|
||||
if let Some(e) = trial.model.result(id).and_then(|r| r.error.clone()) {
|
||||
return Err(EngineError::Other(e));
|
||||
}
|
||||
s.doc = trial.doc;
|
||||
s.refresh();
|
||||
if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) {
|
||||
return Err(EngineError::Other(e));
|
||||
@@ -344,3 +419,7 @@ fn dependents(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let broken: Vec<String> = newly_broken(&s.model, &after).into_iter().map(|(n, _)| n).collect();
|
||||
Ok(json!({"deleted_with_it": names(&gone), "would_fail": broken}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "edit_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,268 @@
|
||||
//! Edit Feature must replace the selections as well as the numerical definition.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Sel, Session};
|
||||
|
||||
fn run(s: &mut Session, id: &str, params: Value) -> Value {
|
||||
s.execute(id, ¶ms).unwrap_or_else(|e| panic!("{id} {params}: {e}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redefining_a_blend_replaces_persistent_edge_selection_and_survives_resize() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20, "name": "Base"}));
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[20, 0, 20]], "radius": 2, "name": "Round"}));
|
||||
let round_id = s.doc.find_feature("Round").unwrap().id;
|
||||
let body_name = s.model.state().bodies[0].name.clone();
|
||||
s.doc_mut().feature_mut(round_id).unwrap().participants = vec![body_name];
|
||||
s.refresh();
|
||||
let old = s.doc.find_feature("Round").unwrap().clone();
|
||||
let before = (*s.doc).clone();
|
||||
run(&mut s, "timeline.redefine", json!({"feature": "Round", "command": "solid.fillet", "params": {"edges": [[0, 15, 20]], "radius": 2}}));
|
||||
let edited = s.doc.find_feature("Round").unwrap().clone();
|
||||
assert_eq!(edited.id, old.id);
|
||||
assert_eq!(edited.param_names, old.param_names);
|
||||
assert_eq!(edited.participants, old.participants);
|
||||
assert_ne!(edited.edge_names, old.edge_names, "the new edge must be the reference used on recompute");
|
||||
assert_ne!(edited.edge_refs, old.edge_refs);
|
||||
assert_eq!(s.doc.next_id, before.next_id, "temporary definitions must not consume feature ids");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(*s.doc, before);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(s.doc.find_feature("Round").unwrap().edge_names, edited.edge_names);
|
||||
run(&mut s, "timeline.edit", json!({"feature": "Base", "set": {"length": "60", "height": "25"}}));
|
||||
let volume = run(&mut s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
|
||||
let expected = 60.0 * 30.0 * 25.0 - (4.0 - std::f64::consts::PI) * 30.0;
|
||||
assert!((volume - expected).abs() < expected * 1e-4, "{volume} != {expected}");
|
||||
assert!(s.model.result(old.id).unwrap().warning.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redefining_preserves_body_names_participants_and_marker_without_temporary_features() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 20, "width": 10, "height": 5, "name": "Base", "body_name": "Housing"}));
|
||||
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [30, 0, 0], "name": "Other"}));
|
||||
let id = s.doc.find_feature("Base").unwrap().id;
|
||||
run(&mut s, "timeline.rollback", json!({"position": 1}));
|
||||
let before = s.doc.feature(id).unwrap().clone();
|
||||
run(&mut s, "timeline.redefine", json!({"feature": id, "command": "solid.box", "params": {"length": 25, "width": 10, "height": 5}}));
|
||||
let after = s.doc.feature(id).unwrap();
|
||||
assert_eq!(s.doc.features.len(), 2);
|
||||
assert_eq!(s.doc.marker, Some(1));
|
||||
assert_eq!(after.body_names, before.body_names);
|
||||
assert_eq!(after.participants, before.participants);
|
||||
assert_eq!(after.param_names, before.param_names);
|
||||
assert_eq!(after.name, before.name);
|
||||
assert!(s.model.state().body("Housing").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_or_non_feature_redefinitions_leave_session_and_history_intact() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 20, "width": 10, "height": 5, "name": "Base"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
s.selection = vec![Sel::Feature { id: s.doc.find_feature("Base").unwrap().id }];
|
||||
let doc = s.doc.clone();
|
||||
let sketch = s.active_sketch;
|
||||
let selection = s.selection.clone();
|
||||
let undo = s.undo.len();
|
||||
for (command, params) in [
|
||||
("solid.cylinder", json!({"radius": 5, "height": 10})),
|
||||
("solid.box", json!({"length": "unknown_parameter", "width": 10, "height": 5})),
|
||||
("file.export", json!({"path": "should-not-be-created.step"})),
|
||||
("edit.undo", json!({})),
|
||||
] {
|
||||
assert!(s.execute("timeline.redefine", &json!({"feature": "Base", "command": command, "params": params})).is_err());
|
||||
assert_eq!(*s.doc, *doc, "{command}");
|
||||
assert_eq!(s.active_sketch, sketch, "{command}");
|
||||
assert_eq!(s.selection, selection, "{command}");
|
||||
assert_eq!(s.undo.len(), undo, "{command}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redefining_an_extrude_names_new_optional_inputs() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 5, "name": "Pad"}));
|
||||
let distance = s.doc.find_feature("Pad").unwrap().param_names[0].clone();
|
||||
run(&mut s, "timeline.redefine", json!({"feature": "Pad", "command": "solid.extrude", "params": {"distance": 5, "start_offset": 2}}));
|
||||
let f = s.doc.find_feature("Pad").unwrap();
|
||||
assert_eq!(f.param_names.len(), f.kind.inputs().len());
|
||||
assert_eq!(f.param_names[0], distance);
|
||||
assert!(f.param_names.iter().all(|name| !name.is_empty()));
|
||||
assert_eq!(s.doc.features.len(), 2);
|
||||
let offset = s.doc.find_feature("Pad").unwrap().param_names.last().unwrap().clone();
|
||||
run(
|
||||
&mut s,
|
||||
"timeline.redefine",
|
||||
json!({"feature": "Pad", "command": "solid.extrude", "params": {"distance": 5, "distance2": 2, "start_offset": 2}}),
|
||||
);
|
||||
let f = s.doc.find_feature("Pad").unwrap();
|
||||
let inputs = f.kind.inputs();
|
||||
let offset_index = inputs.iter().position(|(label, _, _)| *label == "Start offset").unwrap();
|
||||
assert_eq!(f.param_names[offset_index], offset, "adding Distance 2 must not steal the offset dimension's parameter name");
|
||||
let distance2_index = inputs.iter().position(|(label, _, _)| *label == "Distance 2").unwrap();
|
||||
assert_ne!(f.param_names[distance2_index], offset);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redefining_a_shell_replaces_persistent_face_selection() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20, "name": "Base"}));
|
||||
run(&mut s, "solid.shell", json!({"faces": [[20, 15, 20]], "thickness": 2, "name": "Hollow"}));
|
||||
let before = s.doc.find_feature("Hollow").unwrap().face_names.clone();
|
||||
assert!(!before.is_empty());
|
||||
run(&mut s, "timeline.redefine", json!({"feature": "Hollow", "command": "solid.shell", "params": {"faces": [[20, 15, 0]], "thickness": 2}}));
|
||||
let after = s.doc.find_feature("Hollow").unwrap().face_names.clone();
|
||||
assert!(!after.is_empty());
|
||||
assert_ne!(before, after);
|
||||
run(&mut s, "timeline.edit", json!({"feature": "Base", "set": {"height": "30"}}));
|
||||
let id = s.doc.find_feature("Hollow").unwrap().id;
|
||||
assert!(s.model.result(id).unwrap().error.is_none());
|
||||
assert!(s.model.result(id).unwrap().warning.is_none());
|
||||
assert_eq!(s.doc.find_feature("Hollow").unwrap().face_names, after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redefine_rejects_sheet_exports_before_they_touch_any_files() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [30, 20]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sheet.flange", json!({"name": "Plate"}));
|
||||
let doc = s.doc.clone();
|
||||
let base = std::env::temp_dir().join(format!("solvecraft-redefine-export-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
for command in ["sheet.export_dxf", "sheet.export_bend_table"] {
|
||||
for existing in [false, true] {
|
||||
let path = base.join(format!("{}-{existing}.out", command.replace('.', "-")));
|
||||
if existing {
|
||||
std::fs::write(&path, b"original file").unwrap();
|
||||
} else if path.exists() {
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
}
|
||||
let error = s.execute("timeline.redefine", &json!({"feature": "Plate", "command": command, "params": {"path": path}})).unwrap_err();
|
||||
assert!(error.to_string().contains("does not make a feature"), "{error}");
|
||||
assert_eq!(*s.doc, *doc);
|
||||
if existing {
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"original file");
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
} else {
|
||||
assert!(!path.exists(), "{command} must not create a file before refusing the edit");
|
||||
}
|
||||
}
|
||||
}
|
||||
for command in ["sheet.manage_rules", "sheet.rules", "sheet.flat_pattern", "parameters.list", "plastic.manage_rules"] {
|
||||
let error = s.execute("timeline.redefine", &json!({"feature": "Plate", "command": command, "params": {"name": "unexpected"}})).unwrap_err();
|
||||
assert!(error.to_string().contains("does not make a feature"), "{error}");
|
||||
assert_eq!(*s.doc, *doc);
|
||||
}
|
||||
std::fs::remove_dir(&base).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redefine_keeps_allocator_above_component_and_occurrence_ids() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 10, "body_name": "Base"}));
|
||||
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 10, "corner": [10, 0, 0], "body_name": "Tool"}));
|
||||
run(&mut s, "solid.combine", json!({"target": "Base", "tools": ["Tool"], "name": "Join"}));
|
||||
run(
|
||||
&mut s,
|
||||
"timeline.redefine",
|
||||
json!({"feature": "Join", "command": "solid.combine", "params": {"target": "Base", "tools": ["Tool"], "new_component": true, "component_name": "Merged"}}),
|
||||
);
|
||||
let max_id =
|
||||
s.doc.components.iter().map(|c| c.id).chain(s.doc.occurrences.iter().map(|o| o.id)).chain(s.doc.features.iter().map(|f| f.id)).max().unwrap();
|
||||
assert!(s.doc.next_id > max_id);
|
||||
run(&mut s, "component.create", json!({"name": "Next", "parent": 0}));
|
||||
run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2}));
|
||||
let ids: Vec<u64> =
|
||||
s.doc.components.iter().map(|c| c.id).chain(s.doc.occurrences.iter().map(|o| o.id)).chain(s.doc.features.iter().map(|f| f.id)).collect();
|
||||
let unique: std::collections::BTreeSet<u64> = ids.iter().copied().collect();
|
||||
assert_eq!(ids.len(), unique.len(), "feature, component and occurrence ids must remain unique: {ids:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redefine_maps_placement_inputs_in_the_feature_owner_frame() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4, "name": "RootBox", "body_name": "RootBody"}));
|
||||
let owner = run(&mut s, "component.create", json!({"name": "Owner"}));
|
||||
run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4, "corner": [10, 0, 0], "name": "OwnedBox", "body_name": "OwnedBody"}));
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[12, 0, 4]], "radius": 0.5, "body": "OwnedBody", "name": "OwnerRound"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": owner["occurrence"], "translate": [100, 0, 0]}));
|
||||
let other = run(&mut s, "component.create", json!({"name": "Other", "parent": 0}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": other["occurrence"], "translate": [200, 0, 0]}));
|
||||
let active = s.active_component;
|
||||
run(
|
||||
&mut s,
|
||||
"timeline.redefine",
|
||||
json!({"feature": "RootBox", "command": "solid.box", "params": {"length": 4, "width": 4, "height": 4, "corner": [2, 0, 0]}}),
|
||||
);
|
||||
run(
|
||||
&mut s,
|
||||
"timeline.redefine",
|
||||
json!({"feature": "OwnedBox", "command": "solid.box", "params": {"length": 4, "width": 4, "height": 4, "corner": [110, 0, 0]}}),
|
||||
);
|
||||
run(
|
||||
&mut s,
|
||||
"timeline.redefine",
|
||||
json!({"feature": "OwnerRound", "command": "solid.fillet", "params": {"edges": [[112, 0, 4]], "radius": 0.75, "body": "OwnedBody"}}),
|
||||
);
|
||||
assert_eq!(s.active_component, active, "editing must not activate the owner in the real session");
|
||||
assert_eq!(s.doc.find_feature("OwnedBox").unwrap().component, owner["component"].as_u64().unwrap());
|
||||
let root = run(&mut s, "inspect.measure", json!({"bodies": ["RootBody"]}));
|
||||
let owned = run(&mut s, "inspect.measure", json!({"bodies": ["OwnedBody"]}));
|
||||
assert!((root["bodies"][0]["bbox"]["min"][0].as_f64().unwrap() - 2.0).abs() < 1e-8);
|
||||
assert!((owned["bodies"][0]["bbox"]["min"][0].as_f64().unwrap() - 110.0).abs() < 1e-8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redefining_fillet_style_keeps_primary_parameter_and_downstream_references() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 20, "height": 30}));
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[10, 10, 30]], "radius": 2, "name": "Round"}));
|
||||
let parameter = s.doc.find_feature("Round").unwrap().param_names[0].clone();
|
||||
run(&mut s, "solid.box", json!({"length": parameter, "width": 2, "height": 2, "corner": [50, 0, 0], "name": "Dependent"}));
|
||||
run(
|
||||
&mut s,
|
||||
"timeline.redefine",
|
||||
json!({"feature": "Round", "command": "solid.fillet", "params": {"edges": [[10, 10, 30]], "radius": 3, "type": "chord"}}),
|
||||
);
|
||||
assert_eq!(s.doc.find_feature("Round").unwrap().param_names[0], parameter);
|
||||
let dependent = s.doc.find_feature("Dependent").unwrap().id;
|
||||
assert!(s.model.result(dependent).unwrap().error.is_none());
|
||||
let value = run(&mut s, "expr.evaluate", json!({"expression": parameter}));
|
||||
assert_eq!(value["value"], 3.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserting_variable_fillet_control_preserves_parameters_at_existing_positions() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 20, "height": 30}));
|
||||
run(
|
||||
&mut s,
|
||||
"solid.fillet",
|
||||
json!({"edges": [[10, 10, 30]], "radius": 2, "radius2": 2, "type": "variable", "start": [10, 0, 30], "mid": [[0.25, 3], [0.75, 4]], "name": "Round"}),
|
||||
);
|
||||
let parameters = s.doc.find_feature("Round").unwrap().param_names.clone();
|
||||
run(&mut s, "solid.box", json!({"length": parameters[2], "width": parameters[3], "height": 2, "corner": [50, 0, 0], "name": "Dependent"}));
|
||||
run(
|
||||
&mut s,
|
||||
"timeline.redefine",
|
||||
json!({"feature": "Round", "command": "solid.fillet", "params": {"edges": [[10, 10, 30]], "radius": 2, "radius2": 2, "type": "variable", "start": [10, 0, 30], "mid": [[0.1, 2.5], [0.25, 3], [0.75, 4]]}}),
|
||||
);
|
||||
let edited = s.doc.find_feature("Round").unwrap();
|
||||
assert_eq!(edited.param_names[0..2], parameters[0..2]);
|
||||
assert_eq!(edited.param_names[3..5], parameters[2..4]);
|
||||
assert!(!parameters.contains(&edited.param_names[2]), "the inserted control needs a new dimension");
|
||||
let dependent = s.doc.find_feature("Dependent").unwrap().id;
|
||||
assert!(s.model.result(dependent).unwrap().error.is_none());
|
||||
let length = run(&mut s, "expr.evaluate", json!({"expression": parameters[2]}));
|
||||
let width = run(&mut s, "expr.evaluate", json!({"expression": parameters[3]}));
|
||||
assert_eq!(length["value"], 3.0);
|
||||
assert_eq!(width["value"], 4.0);
|
||||
}
|
||||
@@ -12,6 +12,9 @@ use super::joints::{joint_id, occurrence_id, resolve, value};
|
||||
use crate::params::{bad, bool_, str_, vec3};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
#[path = "contact_sweep.rs"]
|
||||
mod contact_sweep;
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("contact.enable_sets", "Enable Contact Sets", enable_sets).at("SOLID", "ASSEMBLE").icon("joint").params(
|
||||
"enabled?: bool (default true) — driving joints and motion studies stop where bodies in one contact set would pass through each other",
|
||||
@@ -87,7 +90,11 @@ fn placed(s: &Session) -> Vec<(Vec<u64>, Body)> {
|
||||
continue;
|
||||
}
|
||||
for b in st.bodies.iter().filter(|b| s.doc.body_component(&b.name, b.feature) == comp && !b.body.is_mesh()) {
|
||||
if let Ok(t) = solvecraft_kernel::transform_matrix(&b.body, m) {
|
||||
// Moving a body into another component preserves its world placement using an
|
||||
// offset from its authoring frame. Contact and explode centres must use that
|
||||
// same offset as display and physical properties.
|
||||
let frame = s.doc.body_offsets.get(&b.name).map(|offset| solvecraft_doc::mat_mul(&m, offset)).unwrap_or(m);
|
||||
if let Ok(t) = solvecraft_kernel::transform_matrix(&b.body, frame) {
|
||||
out.push((path.clone(), t));
|
||||
}
|
||||
}
|
||||
@@ -131,8 +138,11 @@ pub fn collisions(s: &Session) -> Vec<(String, String, f64)> {
|
||||
/// tested against the other closed mesh: inside by ray parity, and deeper than the tolerance.
|
||||
fn overlap(a: &Body, b: &Body) -> Option<f64> {
|
||||
let (ma, mb) = (a.tessellate((a.size() * 1e-3).max(1e-3)).ok()?, b.tessellate((b.size() * 1e-3).max(1e-3)).ok()?);
|
||||
mesh_overlap(&ma, &mb, 1e-4 * a.size().max(b.size()).max(1.0))
|
||||
}
|
||||
|
||||
fn mesh_overlap(ma: &solvecraft_geom::Mesh, mb: &solvecraft_geom::Mesh, tol: f64) -> Option<f64> {
|
||||
let (ba, bb) = (ma.bounds(), mb.bounds());
|
||||
let tol = 1e-4 * a.size().max(b.size()).max(1.0);
|
||||
if ba.max.x < bb.min.x + tol
|
||||
|| bb.max.x < ba.min.x + tol
|
||||
|| ba.max.y < bb.min.y + tol
|
||||
@@ -142,7 +152,7 @@ fn overlap(a: &Body, b: &Body) -> Option<f64> {
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let depth = deepest(&ma, &mb, tol).max(deepest(&mb, &ma, tol));
|
||||
let depth = deepest(ma, mb, tol).max(deepest(mb, ma, tol));
|
||||
(depth > tol).then_some(depth)
|
||||
}
|
||||
|
||||
@@ -247,9 +257,12 @@ fn closest(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Vec3 {
|
||||
/// Set joint values, stopping short where contact sets would collide: the values are moved
|
||||
/// from where they are toward `want` as far as they go without a collision.
|
||||
fn drive_with_contact(s: &mut Session, want: &[(u64, Vec<f64>)]) -> Result<Value> {
|
||||
let baseline = s.doc.clone();
|
||||
let start: Vec<(u64, Vec<f64>)> =
|
||||
want.iter().map(|(id, _)| (*id, s.doc.assembly.joints.iter().find(|j| j.id == *id).map(|j| j.values.clone()).unwrap_or_default())).collect();
|
||||
let set = |s: &mut Session, t: f64| {
|
||||
// Trial order must not affect free anchors or linked joint placements.
|
||||
s.doc = baseline.clone();
|
||||
let doc = s.doc_mut();
|
||||
for ((id, a), (_, b)) in start.iter().zip(want) {
|
||||
if let Some(j) = doc.assembly.joints.iter_mut().find(|j| j.id == *id) {
|
||||
@@ -260,25 +273,45 @@ fn drive_with_contact(s: &mut Session, want: &[(u64, Vec<f64>)]) -> Result<Value
|
||||
}
|
||||
resolve(s)
|
||||
};
|
||||
let mut sol = set(s, 1.0);
|
||||
let cache = if s.doc.assembly.contact_enabled { Some(contact_sweep::ContactGeometry::new(s)?) } else { None };
|
||||
let sol;
|
||||
let mut stopped = None;
|
||||
if s.doc.assembly.contact_enabled
|
||||
&& let Some(hit) = collisions(s).into_iter().next()
|
||||
{
|
||||
// Already colliding where it started: don't search, just report.
|
||||
if let Some(cache) = cache.filter(|cache| cache.has_pairs()) {
|
||||
let mut end = s.scratch();
|
||||
set(&mut end, 1.0);
|
||||
let samples = cache.samples(s, &end)?;
|
||||
let mut work = 0;
|
||||
set(s, 0.0);
|
||||
if collisions(s).is_empty() {
|
||||
let (mut lo, mut hi) = (0.0, 1.0);
|
||||
for _ in 0..14 {
|
||||
let mid = (lo + hi) / 2.0;
|
||||
set(s, mid);
|
||||
if collisions(s).is_empty() { lo = mid } else { hi = mid }
|
||||
// Keep the existing recovery behaviour: an initially overlapping assembly may move
|
||||
// out of its invalid placement. Sweeps constrain motions that start collision-free.
|
||||
if cache.first_hit(s, &mut work)?.is_none() {
|
||||
let mut lo = 0.0;
|
||||
let mut bracket = None;
|
||||
for sample in 1..=samples {
|
||||
let hi = sample as f64 / samples as f64;
|
||||
set(s, hi);
|
||||
if let Some(hit) = cache.first_hit(s, &mut work)? {
|
||||
bracket = Some((lo, hi, hit));
|
||||
break;
|
||||
}
|
||||
lo = hi;
|
||||
}
|
||||
if let Some((mut lo, mut hi, hit)) = bracket {
|
||||
for _ in 0..14 {
|
||||
let mid = (lo + hi) / 2.0;
|
||||
set(s, mid);
|
||||
if cache.first_hit(s, &mut work)?.is_none() { lo = mid } else { hi = mid }
|
||||
}
|
||||
sol = set(s, lo);
|
||||
stopped = Some(json!({"a": hit.0, "b": hit.1, "fraction": lo}));
|
||||
} else {
|
||||
sol = set(s, 1.0);
|
||||
}
|
||||
sol = set(s, lo);
|
||||
stopped = Some(json!({"a": hit.0, "b": hit.1, "fraction": lo}));
|
||||
} else {
|
||||
sol = set(s, 1.0);
|
||||
}
|
||||
} else {
|
||||
sol = set(s, 1.0);
|
||||
}
|
||||
let values: Vec<Value> = want
|
||||
.iter()
|
||||
|
||||
@@ -71,6 +71,40 @@ fn contact_sets_stop_a_driven_joint_at_touching() {
|
||||
assert!(s.execute("contact.create", &json!({"occurrences": ["A"]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contact_uses_preserved_body_placements_after_component_reassignment() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Transferred"}));
|
||||
let component = run(&mut s, "component.create", json!({"name": "Carrier"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": component["occurrence"], "translate": [100, 0, 0]}));
|
||||
run(&mut s, "component.move_bodies", json!({"bodies": ["Transferred"], "component": component["component"]}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
let probe = run(&mut s, "component.create", json!({"name": "Probe"}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [5, 0, 0]}));
|
||||
run(&mut s, "contact.create", json!({"occurrences": [component["occurrence"], probe["occurrence"]]}));
|
||||
let collisions = run(&mut s, "contact.check", json!({}));
|
||||
assert_eq!(collisions["collisions"].as_array().map(Vec::len), Some(1), "body stayed at x=0: {collisions}");
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": probe["occurrence"], "translate": [100, 0, 0]}));
|
||||
let collisions = run(&mut s, "contact.check", json!({}));
|
||||
assert_eq!(collisions["collisions"].as_array().map(Vec::len), Some(0), "probe moved away: {collisions}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_explode_centres_follow_reassigned_bodies() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Transferred"}));
|
||||
let component = run(&mut s, "component.create", json!({"name": "Carrier"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": component["occurrence"], "translate": [100, 0, 0]}));
|
||||
run(&mut s, "component.move_bodies", json!({"bodies": ["Transferred"], "component": component["component"]}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "component.create", json!({"name": "Neighbour"}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0]}));
|
||||
let exploded = run(&mut s, "explode.create", json!({"scale": 1}));
|
||||
let moves = exploded["moves"].as_array().unwrap();
|
||||
let carrier = moves.iter().find(|m| m["occurrence"] == component["occurrence"]).unwrap();
|
||||
assert!((carrier["translate"][0].as_f64().unwrap() + 10.0).abs() < 1e-6, "{exploded}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_study_plays_and_exports() {
|
||||
let mut s = rail();
|
||||
|
||||
@@ -45,6 +45,9 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sheet.export_dxf", "Export Flat Pattern as DXF", export_dxf)
|
||||
.noundo()
|
||||
.params("body?, path? → DXF (mm): OUTLINE, BEND_UP and BEND_DOWN (centre lines) layers"),
|
||||
CommandSpec::new("sheet.export_bend_table", "Export Bend Table", export_bend_table)
|
||||
.noundo()
|
||||
.params("body?, format?: csv|json (default csv), path? → bend table in mm and degrees, including rule and flat centre-line coordinates"),
|
||||
];
|
||||
|
||||
fn points(p: &Value, k: &str, cmd: &str) -> Result<Vec<Vec3>> {
|
||||
@@ -142,8 +145,9 @@ fn fold(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sheet.fold";
|
||||
let (sketch, curve, a, b) = match p.get("points") {
|
||||
Some(v) => {
|
||||
let pts = v.as_array().map(|a| a.iter().filter_map(vec3).collect::<Vec<Vec3>>()).unwrap_or_default();
|
||||
let [a, b] = pts[..] else { return Err(bad(cmd, "`points` must be two points [x, y, z]")) };
|
||||
let pts = v.as_array().filter(|a| a.len() == 2).ok_or_else(|| bad(cmd, "`points` must be two points [x, y, z]"))?;
|
||||
let a = pts.first().and_then(vec3).ok_or_else(|| bad(cmd, "`points` must be two finite points [x, y, z]"))?;
|
||||
let b = pts.get(1).and_then(vec3).ok_or_else(|| bad(cmd, "`points` must be two finite points [x, y, z]"))?;
|
||||
(None, None, a, b)
|
||||
}
|
||||
None => {
|
||||
@@ -306,6 +310,7 @@ pub fn flat_dxf(sh: &SheetBody) -> Result<String> {
|
||||
w(2, "TABLES");
|
||||
w(0, "TABLE");
|
||||
w(2, "LAYER");
|
||||
w(70, "3");
|
||||
for (name, colour) in [("OUTLINE", "7"), ("BEND_UP", "1"), ("BEND_DOWN", "5")] {
|
||||
w(0, "LAYER");
|
||||
w(2, name);
|
||||
@@ -338,17 +343,25 @@ pub fn flat_dxf(sh: &SheetBody) -> Result<String> {
|
||||
for seg in &h.outer.segs {
|
||||
match *seg {
|
||||
solvecraft_geom::Seg2::Arc { center, radius, start, sweep } => {
|
||||
if (sweep.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;
|
||||
}
|
||||
let (a0, a1) = if sweep >= 0.0 { (start, start + sweep) } else { (start + sweep, start) };
|
||||
w(0, "ARC");
|
||||
w(8, "OUTLINE");
|
||||
w(10, &f(center.x));
|
||||
w(20, &f(center.y));
|
||||
w(40, &f(radius));
|
||||
w(50, &f(a0.to_degrees()));
|
||||
w(51, &f(a1.to_degrees()));
|
||||
w(50, &f(a0.to_degrees().rem_euclid(360.0)));
|
||||
w(51, &f(a1.to_degrees().rem_euclid(360.0)));
|
||||
}
|
||||
other => {
|
||||
let pts: Vec<Vec2> = (0..=8).map(|k| other.point_at(k as f64 / 8.0)).collect();
|
||||
let pts = other.polyline(1e-3);
|
||||
for p in pts.windows(2) {
|
||||
line(&mut w, "OUTLINE", p[0], p[1]);
|
||||
}
|
||||
@@ -375,6 +388,50 @@ fn export_dxf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"dxf": text, "bends": sh.bends().len()}))
|
||||
}
|
||||
|
||||
fn export_bend_table(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sheet.export_bend_table";
|
||||
let format = str_(p, "format").unwrap_or("csv").to_ascii_lowercase();
|
||||
if !matches!(format.as_str(), "csv" | "json") {
|
||||
return Err(bad(cmd, "`format` must be csv or json"));
|
||||
}
|
||||
let sh = sheet_of(s, p, cmd)?;
|
||||
let table = flat_json(&sh)?;
|
||||
let text = if format == "json" {
|
||||
serde_json::to_string_pretty(&table).map_err(|e| EngineError::Other(e.to_string()))?
|
||||
} else {
|
||||
// Always quote text fields: body/rule names may include commas, quotes or newlines.
|
||||
let quote = |s: &str| format!("\"{}\"", s.replace('"', "\"\""));
|
||||
let mut csv =
|
||||
"body,rule,thickness_mm,k_factor,bend,angle_deg,direction,radius_mm,allowance_mm,length_mm,start_x_mm,start_y_mm,end_x_mm,end_y_mm\r\n"
|
||||
.to_string();
|
||||
for b in sh.bends() {
|
||||
csv.push_str(&format!(
|
||||
"{},{},{:.9},{:.9},{},{:.9},{},{:.9},{:.9},{:.9},{:.9},{:.9},{:.9},{:.9}\r\n",
|
||||
quote(&sh.body),
|
||||
quote(&sh.rule),
|
||||
sh.t,
|
||||
sh.k,
|
||||
b.index,
|
||||
b.angle_deg,
|
||||
if b.up { "up" } else { "down" },
|
||||
b.radius,
|
||||
b.allowance,
|
||||
b.length,
|
||||
b.start.x,
|
||||
b.start.y,
|
||||
b.end.x,
|
||||
b.end.y
|
||||
));
|
||||
}
|
||||
csv
|
||||
};
|
||||
if let Some(path) = str_(p, "path").filter(|x| !x.trim().is_empty()) {
|
||||
solvecraft_io::vfs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
return Ok(json!({"path": path, "format": format, "bends": sh.bends().len()}));
|
||||
}
|
||||
Ok(json!({"format": format, "text": text, "bends": sh.bends().len()}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sheet_tests.rs"]
|
||||
mod tests;
|
||||
@@ -344,3 +344,80 @@ fn fold_keeps_the_larger_side_either_way() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bend_table_exports_shop_dimensions_and_tracks_parameter_changes() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sheet.manage_rules", json!({"name": "Shop, \"steel\"", "thickness": 2, "bend_radius": 3, "k_factor": 0.4}));
|
||||
base(&mut s, 100.0, 60.0);
|
||||
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2]], "height": 20}));
|
||||
let undo = s.undo.len();
|
||||
let csv = run(&mut s, "sheet.export_bend_table", json!({}));
|
||||
let text = csv["text"].as_str().unwrap();
|
||||
assert!(text.starts_with("body,rule,thickness_mm,k_factor,bend,angle_deg,direction,"));
|
||||
assert!(text.contains("\"Shop, \"\"steel\"\"\""), "{text}");
|
||||
assert_eq!(text.lines().count(), 2);
|
||||
assert_eq!(csv["bends"], 1);
|
||||
let exported = run(&mut s, "sheet.export_bend_table", json!({"format": "JSON"}));
|
||||
let table: Value = serde_json::from_str(exported["text"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(table["thickness"], 2.0);
|
||||
assert_eq!(table["bends"][0]["direction"], "up");
|
||||
assert!((table["bends"][0]["allowance"].as_f64().unwrap() - FRAC_PI_2 * 3.8).abs() < 1e-9);
|
||||
assert_eq!(s.undo.len(), undo, "export does not create an undo step");
|
||||
run(&mut s, "sheet.manage_rules", json!({"name": "Shop, \"steel\"", "k_factor": 0.5}));
|
||||
let updated = run(&mut s, "sheet.export_bend_table", json!({"format": "json"}));
|
||||
let table: Value = serde_json::from_str(updated["text"].as_str().unwrap()).unwrap();
|
||||
assert!((table["bends"][0]["allowance"].as_f64().unwrap() - FRAC_PI_2 * 4.0).abs() < 1e-9);
|
||||
assert!(s.execute("sheet.export_bend_table", &json!({"format": "xml"})).is_err());
|
||||
assert!(s.execute("sheet.export_bend_table", &json!({"body": "Missing"})).is_err());
|
||||
let mut empty = Session::default();
|
||||
assert!(empty.execute("sheet.export_bend_table", &json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_fold_points_are_rejected_atomically() {
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
let before = serde_json::to_value(&s.doc).unwrap();
|
||||
for points in [
|
||||
json!([[40, 0, 2.5], null, [40, 60, 2.5]]),
|
||||
json!([[40, 0, 2.5], [40, 60, 2.5], "junk"]),
|
||||
json!([[40, 0, 2.5], [40, 60]]),
|
||||
json!([[40, 0, 2.5]]),
|
||||
json!("not points"),
|
||||
] {
|
||||
assert!(s.execute("sheet.fold", &json!({"points": points})).is_err());
|
||||
assert_eq!(serde_json::to_value(&s.doc).unwrap(), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_dxf_preserves_exact_cutout_entities() {
|
||||
use solvecraft_geom::{Loop2, Region2, Seg2, Vec2};
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
let mut sh = s.model.state().sheets[0].clone();
|
||||
sh.holes = vec![Region2 {
|
||||
outer: Loop2 { segs: vec![Seg2::Arc { center: Vec2::new(40.0, 30.0), radius: 5.0, start: -PI, sweep: -2.0 * PI }] },
|
||||
holes: Vec::new(),
|
||||
}];
|
||||
let dxf = super::flat_dxf(&sh).unwrap();
|
||||
assert_eq!(dxf.matches("0\nCIRCLE\n").count(), 1, "{dxf}");
|
||||
assert!(!dxf.contains("0\nARC\n"), "a full circle must not become a zero-length ARC");
|
||||
let read = solvecraft_io::read_dxf(&dxf).unwrap();
|
||||
assert_eq!(read.len(), 5);
|
||||
assert!(read.iter().any(|g| matches!(g, solvecraft_io::Geom2::Circle(c, r) if *c == Vec2::new(40.0, 30.0) && (*r - 5.0).abs() < 1e-9)));
|
||||
sh.holes[0].outer = Loop2 {
|
||||
segs: vec![
|
||||
Seg2::Line { a: Vec2::new(10.0, 10.0), b: Vec2::new(30.0, 10.0) },
|
||||
Seg2::Arc { center: Vec2::new(20.0, 10.0), radius: 10.0, start: 0.0, sweep: PI },
|
||||
],
|
||||
};
|
||||
let dxf = super::flat_dxf(&sh).unwrap();
|
||||
assert_eq!(dxf.matches("0\nLINE\n").count(), 5, "four outline lines and one exact cutout line");
|
||||
assert_eq!(dxf.matches("0\nARC\n").count(), 1);
|
||||
assert!(dxf.contains("50\n0.000000\n51\n180.000000\n"));
|
||||
let read = solvecraft_io::read_dxf(&dxf).unwrap();
|
||||
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)));
|
||||
}
|
||||
@@ -12,7 +12,7 @@ use crate::params::bad;
|
||||
|
||||
/// Exact pieces of a free-form curve (polyline lines for the few kinds without them).
|
||||
fn pieces(sk: &Sketch, ci: usize) -> Vec<Seg2> {
|
||||
sk.exact_segs(ci).unwrap_or_else(|| sk.polyline(ci).windows(2).map(|w| Seg2::Line { a: w[0], b: w[1] }).collect())
|
||||
sk.exact_segs(ci).unwrap_or_else(|| sk.segs(ci))
|
||||
}
|
||||
|
||||
/// Position along the pieces (piece index + local parameter) closest to `p`, and the distance.
|
||||
@@ -44,7 +44,7 @@ fn project(sk: &Sketch, ci: usize, p: Vec2) -> Vec2 {
|
||||
}
|
||||
|
||||
/// Where other curves meet free-form curve `ci`: (position along it, the point, the other curve).
|
||||
fn cuts(sk: &Sketch, ci: usize, segs: &[Seg2]) -> Vec<(f64, Vec2, usize)> {
|
||||
fn cuts(sk: &Sketch, ci: usize, segs: &[Seg2], only_freeform: bool) -> Vec<(f64, Vec2, usize)> {
|
||||
let mine = sk.polyline(ci);
|
||||
let tol = 1e-6;
|
||||
let mut out: Vec<(f64, Vec2, usize)> = Vec::new();
|
||||
@@ -57,8 +57,8 @@ fn cuts(sk: &Sketch, ci: usize, segs: &[Seg2]) -> Vec<(f64, Vec2, usize)> {
|
||||
let (t, u) = ((c - a).cross(s) / den, (c - a).cross(r) / den);
|
||||
((-1e-9..=1.0 + 1e-9).contains(&t) && (-1e-9..=1.0 + 1e-9).contains(&u)).then(|| a + r * t)
|
||||
};
|
||||
for (oi, _) in sk.curves.iter().enumerate() {
|
||||
if oi == ci {
|
||||
for (oi, curve) in sk.curves.iter().enumerate() {
|
||||
if oi == ci || (only_freeform && !curve.kind.is_freeform()) {
|
||||
continue;
|
||||
}
|
||||
let other = sk.polyline(oi);
|
||||
@@ -90,7 +90,7 @@ fn cuts(sk: &Sketch, ci: usize, segs: &[Seg2]) -> Vec<(f64, Vec2, usize)> {
|
||||
q = n;
|
||||
}
|
||||
let (u, d) = locate(segs, q);
|
||||
if d < tol && !out.iter().any(|(_, y, _)| y.dist(q) < tol * 10.0) {
|
||||
if d < tol && project(sk, oi, q).dist(q) < tol && !out.iter().any(|(_, y, _)| y.dist(q) < tol * 10.0) {
|
||||
out.push((u, q, oi));
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,15 @@ fn cuts(sk: &Sketch, ci: usize, segs: &[Seg2]) -> Vec<(f64, Vec2, usize)> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Crossings for an analytic target with free-form cutters. Analytic-to-analytic crossings
|
||||
/// remain in the shape intersection routine; this supplies the missing mixed pairs.
|
||||
pub(super) fn analytic_cut_points(sk: &Sketch, ci: usize) -> Vec<(Vec2, usize)> {
|
||||
if !sk.curves.iter().any(|c| c.kind.is_freeform()) {
|
||||
return Vec::new();
|
||||
}
|
||||
cuts(sk, ci, &pieces(sk, ci), true).into_iter().map(|(_, q, oi)| (q, oi)).collect()
|
||||
}
|
||||
|
||||
/// Exact sub-pieces between positions `ua` < `ub` (positions may run past the end on closed
|
||||
/// curves; they wrap).
|
||||
fn span(segs: &[Seg2], ua: f64, ub: f64) -> Vec<Seg2> {
|
||||
@@ -176,18 +185,45 @@ fn add_pieces(sk: &mut Sketch, segs: &[Seg2], start: Option<usize>, end: Option<
|
||||
|
||||
/// Trim (`remove`) or break a free-form curve at the crossings around `at`.
|
||||
pub(super) fn trim_or_break(sk: &mut Sketch, ci: usize, at: Vec2, remove: bool, cmd: &str) -> Result<Value> {
|
||||
modify(sk, ci, at, remove, None, cmd)
|
||||
}
|
||||
|
||||
/// Break at explicit positions, projected onto the exact curve. Closed curves can be opened
|
||||
/// at one point; open curves require interior positions.
|
||||
pub(super) fn break_at_points(sk: &mut Sketch, ci: usize, points: &[Vec2], cmd: &str) -> Result<Value> {
|
||||
modify(sk, ci, Vec2::ZERO, false, Some(points), cmd)
|
||||
}
|
||||
|
||||
fn modify(sk: &mut Sketch, ci: usize, at: Vec2, remove: bool, points: Option<&[Vec2]>, cmd: &str) -> Result<Value> {
|
||||
let segs = pieces(sk, ci);
|
||||
if segs.is_empty() {
|
||||
return Err(bad(cmd, "the curve has no shape"));
|
||||
}
|
||||
let n = segs.len() as f64;
|
||||
let closed = segs.first().zip(segs.last()).is_some_and(|(a, b)| a.start().dist(b.end()) < 1e-9);
|
||||
let cs: Vec<(f64, Vec2, usize)> = cuts(sk, ci, &segs).into_iter().filter(|c| closed || (c.0 > 1e-9 && c.0 < n - 1e-9)).collect();
|
||||
let cs: Vec<(f64, Vec2, Option<usize>)> = if let Some(points) = points {
|
||||
let mut positions: Vec<f64> = points.iter().map(|q| locate(&segs, *q).0).collect();
|
||||
if positions.iter().any(|u| !u.is_finite() || (!closed && (*u <= 1e-9 || *u >= n - 1e-9))) {
|
||||
return Err(bad(cmd, "break points must project inside the curve, away from its ends"));
|
||||
}
|
||||
if closed {
|
||||
for u in &mut positions {
|
||||
if *u >= n - 1e-9 {
|
||||
*u = 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
positions.sort_by(f64::total_cmp);
|
||||
positions.dedup_by(|a, b| (*a - *b).abs() <= 1e-9);
|
||||
positions.into_iter().map(|u| (u, point_at(&segs, u), None)).collect()
|
||||
} else {
|
||||
cuts(sk, ci, &segs, false).into_iter().filter(|c| closed || (c.0 > 1e-9 && c.0 < n - 1e-9)).map(|(u, q, c)| (u, q, Some(c))).collect()
|
||||
};
|
||||
let (uq, _) = locate(&segs, at);
|
||||
let ends = sk.curves.get(ci).and_then(|c| c.kind.ends());
|
||||
let construction = sk.curves.get(ci).is_some_and(|c| c.construction);
|
||||
// Intervals between consecutive cuts (closed curves wrap round).
|
||||
let mut bounds: Vec<(f64, Option<usize>)> = cs.iter().map(|c| (c.0, Some(c.2))).collect();
|
||||
let mut bounds: Vec<(f64, Option<usize>)> = cs.iter().map(|c| (c.0, c.2)).collect();
|
||||
if bounds.is_empty() {
|
||||
if remove {
|
||||
sk.remove_curves(&[ci]);
|
||||
@@ -239,7 +275,7 @@ pub(super) fn trim_or_break(sk: &mut Sketch, ci: usize, at: Vec2, remove: bool,
|
||||
// Cut points stay on the curves that cut there.
|
||||
for ((_, _, oi), (_, pi)) in cs.iter().zip(&cut_pt) {
|
||||
let used = sk.curves.iter().any(|c| c.kind.uses(*pi));
|
||||
if used {
|
||||
if used && let Some(oi) = oi {
|
||||
add_c(sk, ConstraintKind::PointOnCurve { p: *pi, c: *oi })?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use solvecraft_sketch::{ConstraintKind, Curve, CurveKind, Shape, Sketch, interse
|
||||
|
||||
use super::sketch::{add_c, edit, ids_of};
|
||||
use super::{CommandSpec, in_sketch};
|
||||
use crate::params::{bad, bool_, expr, num, req_vec2, str_, string_list, vec2};
|
||||
use crate::params::{bad, bool_, expr, num, req_vec2, str_, string_list, vec2, vec2_list};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
@@ -54,7 +54,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SKETCH", "MODIFY")
|
||||
.icon("break")
|
||||
.enabled(in_sketch)
|
||||
.params("curve: id, at: [x,y] (split at the nearest intersections around it) | params: split at points: [[x,y]…]"),
|
||||
.params("curve: id, at: [x,y] (split at intersections) | points: [[x,y]…] (1…1000 positions projected onto the curve; duplicates are merged)"),
|
||||
CommandSpec::new("sketch.scale", "Sketch Scale", scale)
|
||||
.at("SKETCH", "MODIFY")
|
||||
.icon("scale")
|
||||
@@ -135,6 +135,13 @@ fn cut_points(sk: &Sketch, ci: usize) -> Vec<(f64, Vec2, usize)> {
|
||||
}
|
||||
}
|
||||
}
|
||||
for (q, oi) in super::sketch_freeform::analytic_cut_points(sk, ci) {
|
||||
if let Some(t) = on_bounded(&sh, q)
|
||||
&& !out.iter().any(|(_, x, _)| x.dist(q) < TOL * 10.0)
|
||||
{
|
||||
out.push((t, q, oi));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -314,17 +321,24 @@ fn trim(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn break_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.break";
|
||||
let (out, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let points = if p.get("points").is_some() { Some(vec2_list(cmd, p, "points", 1000)?) } else { None };
|
||||
let (out, info) = edit(s, p, cmd, false, |sk, doc| {
|
||||
let ci = curve_arg(sk, p, cmd)?;
|
||||
if sk.curves.get(ci).is_some_and(|c| c.kind.is_freeform()) {
|
||||
let at = req_vec2(cmd, p, "at")?;
|
||||
let r = super::sketch_freeform::trim_or_break(sk, ci, at, false, cmd)?;
|
||||
let r = if let Some(points) = &points {
|
||||
super::sketch_freeform::break_at_points(sk, ci, points, cmd)?
|
||||
} else {
|
||||
let at = req_vec2(cmd, p, "at")?;
|
||||
super::sketch_freeform::trim_or_break(sk, ci, at, false, cmd)?
|
||||
};
|
||||
doc.prune_model_params();
|
||||
return Ok(r["curves"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default());
|
||||
}
|
||||
let sh = sk.shape(ci).ok_or_else(|| bad(cmd, "curve"))?;
|
||||
let (ts, cutters): (Vec<f64>, Vec<Option<usize>>) = if let Some(list) = p.get("points").and_then(Value::as_array) {
|
||||
let mut v: Vec<f64> = list.iter().take(1000).filter_map(vec2).map(|q| sh.param(sh.project(q))).collect();
|
||||
let (ts, cutters): (Vec<f64>, Vec<Option<usize>>) = if let Some(list) = &points {
|
||||
let mut v: Vec<f64> = list.iter().map(|q| sh.param(sh.project(*q))).collect();
|
||||
v.sort_by(|a, b| a.total_cmp(b));
|
||||
v.dedup_by(|a, b| (*a - *b).abs() <= 1e-9);
|
||||
let n = v.len();
|
||||
(v, vec![None; n])
|
||||
} else {
|
||||
|
||||
@@ -37,6 +37,102 @@ fn close(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-6
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_freeform_break_preserves_exact_geometry_and_undo() {
|
||||
for (command, params) in [
|
||||
("sketch.spline.control_point", json!({"points": [[0,0], [0,12], [20,12], [20,0]], "connect": false})),
|
||||
("sketch.spline.control_point_5", json!({"points": [[0,0], [0,12], [5,20], [12,20], [20,12], [20,0]], "connect": false})),
|
||||
("sketch.spline.fit_point", json!({"points": [[0,0], [6,12], [14,8], [20,0]], "connect": false})),
|
||||
("sketch.conic", json!({"start": [0,0], "end": [20,0], "apex": [10,20], "rho": 0.65, "connect": false})),
|
||||
] {
|
||||
let mut s = new_sketch();
|
||||
let id = ids(&run(&mut s, command, params)["curves"])[0].clone();
|
||||
let before = sketch(&s);
|
||||
let exact = before.exact_segs(before.curve_index(&id).unwrap()).unwrap();
|
||||
let a = exact.first().unwrap().point_at(0.25);
|
||||
let b = exact.last().unwrap().point_at(0.75);
|
||||
let out = run(&mut s, "sketch.break", json!({"curve": id, "points": [b, a, a]}));
|
||||
assert!(ids(&out["curves"]).len() >= 3, "{command}: {out}");
|
||||
let after = sketch(&s);
|
||||
let segs: Vec<_> = after.curves.iter().enumerate().flat_map(|(i, _)| after.exact_segs(i).unwrap_or_default()).collect();
|
||||
for old in &exact {
|
||||
for k in 0..=40 {
|
||||
let q = old.point_at(k as f64 / 40.0);
|
||||
let d = segs.iter().map(|seg| seg.point_at(seg.closest_param(q)).dist(q)).fold(f64::INFINITY, f64::min);
|
||||
assert!(d < 1e-6, "{command}: geometry moved {d} at {q:?}");
|
||||
}
|
||||
}
|
||||
let encoded = serde_json::to_value(&after).unwrap();
|
||||
let restored: solvecraft_sketch::Sketch = serde_json::from_value(encoded).unwrap();
|
||||
assert_eq!(restored.curves.len(), after.curves.len());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(sketch(&s).curve_index(&id).is_some());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_ellipse_break_keeps_closed_profile() {
|
||||
let mut s = new_sketch();
|
||||
let id = ids(&run(&mut s, "sketch.ellipse", json!({"center": [0,0], "major": [10,0], "minor": [0,4]}))["curves"])[0].clone();
|
||||
let area = profiles(&s)[0];
|
||||
let out = run(&mut s, "sketch.break", json!({"curve": id, "points": [[10,0], [0,4], [10,0]]}));
|
||||
assert_eq!(ids(&out["curves"]).len(), 4, "{out}");
|
||||
let after = sketch(&s);
|
||||
assert!(after.curves.iter().all(|c| matches!(c.kind, CurveKind::Conic { .. })));
|
||||
assert_eq!(profiles(&s).len(), 1);
|
||||
assert!((profiles(&s)[0] - area).abs() < 1e-6);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
let original = sketch(&s);
|
||||
let q = original.exact_segs(original.curve_index(&id).unwrap()).unwrap()[0].point_at(0.4);
|
||||
let out = run(&mut s, "sketch.break", json!({"curve": id, "points": [q]}));
|
||||
assert_eq!(ids(&out["curves"]).len(), 5, "a single cut inside a quadrant: {out}");
|
||||
assert_eq!(profiles(&s).len(), 1);
|
||||
assert!((profiles(&s)[0] - area).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_break_rejects_bad_points_atomically_and_merges_duplicates() {
|
||||
for freeform in [false, true] {
|
||||
let mut s = new_sketch();
|
||||
let out = if freeform {
|
||||
run(&mut s, "sketch.spline.control_point", json!({"points": [[0,0], [0,10], [20,10], [20,0]], "connect": false}))
|
||||
} else {
|
||||
run(&mut s, "sketch.line", json!({"points": [[0,0], [20,0]], "connect": false}))
|
||||
};
|
||||
let id = ids(&out["curves"])[0].clone();
|
||||
let before = serde_json::to_value(sketch(&s)).unwrap();
|
||||
for points in [json!([]), json!("wrong"), json!([[8, 4], ["bad", 0]]), json!([[0, 0]]), json!([[1e20, 0]]), json!(vec![[10, 5]; 1001])] {
|
||||
assert!(s.execute("sketch.break", &json!({"curve": id, "points": points})).is_err());
|
||||
assert_eq!(serde_json::to_value(sketch(&s)).unwrap(), before);
|
||||
}
|
||||
let out = run(&mut s, "sketch.break", json!({"curve": id, "points": [[10,7], [10,7]]}));
|
||||
assert_eq!(ids(&out["curves"]).len(), 2, "{out}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analytic_trim_and_break_use_freeform_cutters() {
|
||||
let mut s = new_sketch();
|
||||
let line = ids(&run(&mut s, "sketch.line", json!({"points": [[-5,5], [25,5]], "connect": false}))["curves"])[0].clone();
|
||||
run(&mut s, "sketch.spline.control_point", json!({"points": [[0,0], [0,10], [20,10], [20,0]], "connect": false}));
|
||||
let out = run(&mut s, "sketch.break", json!({"curve": line, "at": [10,5]}));
|
||||
assert_eq!(ids(&out["curves"]).len(), 3, "{out}");
|
||||
let sk = sketch(&s);
|
||||
for id in ids(&out["curves"]) {
|
||||
let shape = sk.shape(sk.curve_index(&id).unwrap()).unwrap();
|
||||
assert!(matches!(shape, solvecraft_sketch::Shape::Line { a, b } if (a.y - 5.0).abs() < 1e-6 && (b.y - 5.0).abs() < 1e-6));
|
||||
}
|
||||
|
||||
let mut s = new_sketch();
|
||||
let circle = ids(&run(&mut s, "sketch.circle.center", json!({"center": [0,0], "radius": 5}))["curves"])[0].clone();
|
||||
run(&mut s, "sketch.ellipse", json!({"center": [0,0], "major": [8,0], "minor": [0,3]}));
|
||||
run(&mut s, "sketch.trim", json!({"curve": circle, "at": [5,0]}));
|
||||
let sk = sketch(&s);
|
||||
let ci = sk.curve_index(&circle).unwrap();
|
||||
assert!(matches!(sk.curves[ci].kind, CurveKind::Arc { .. }));
|
||||
assert!(sk.shape(ci).unwrap().dist(Vec2::new(-5.0, 0.0)) < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_extend_break() {
|
||||
let mut s = new_sketch();
|
||||
@@ -219,13 +315,9 @@ fn ellipse_splines_conic() {
|
||||
run(&mut s, "sketch.constraint.coincident", json!({"a": "q", "b": eid}));
|
||||
let sk = sketch(&s);
|
||||
let q = sk.point(sk.point_index("q").unwrap()).unwrap();
|
||||
let poly = sk.polyline(sk.curve_index(&eid).unwrap());
|
||||
let d = poly.windows(2).map(|w| {
|
||||
let (a, b) = (w[0], w[1]);
|
||||
let t = ((q - a).dot(b - a) / (b - a).len2()).clamp(0.0, 1.0);
|
||||
q.dist(a + (b - a) * t)
|
||||
});
|
||||
assert!(d.fold(f64::INFINITY, f64::min) < 1e-6, "{q:?}");
|
||||
let exact = sk.exact_segs(sk.curve_index(&eid).unwrap()).unwrap();
|
||||
let distance = exact.iter().map(|seg| q.dist(seg.point_at(seg.closest_param(q)))).fold(f64::INFINITY, f64::min);
|
||||
assert!(distance < 1e-6, "on the exact ellipse: {q:?}");
|
||||
// Fit spline closed by a line: one profile.
|
||||
let sp = run(&mut s, "sketch.spline.fit_point", json!({"points": [[100, 0], [110, 8], [120, 3], [130, 0]]}));
|
||||
let sid = ids(&sp["curves"])[0].clone();
|
||||
|
||||
@@ -345,7 +345,13 @@ impl Drop for Nested {
|
||||
pub fn to_local(s: &Session, id: &str, params: &Value) -> Option<Value> {
|
||||
if id == "timeline.redefine" {
|
||||
let cmd = params.get("command")?.as_str()?;
|
||||
let inner = to_local(s, cmd, params.get("params")?)?;
|
||||
let key = params.get("feature").and_then(|v| v.as_str().map(str::to_string).or_else(|| v.as_u64().map(|id| id.to_string())))?;
|
||||
let feature = s.doc.find_feature(&key)?;
|
||||
// Edit Feature authors in the existing feature's frame, even when another
|
||||
// component is active. Body-relative picks still use the picked body's frame.
|
||||
let mut context = s.scratch();
|
||||
context.active_component = feature.component;
|
||||
let inner = to_local(&context, cmd, params.get("params")?)?;
|
||||
let mut out = params.clone();
|
||||
out["params"] = inner;
|
||||
return Some(out);
|
||||
|
||||
@@ -77,10 +77,14 @@ fn world_picks_map_into_the_component() {
|
||||
assert!(volume(&mut s) < v0 - 1.0, "the fillet cut material");
|
||||
// A sketch on the moved top face sits at world z = 10 over the moved block.
|
||||
run(&mut s, "sketch.create", json!({"plane": {"face": [70, 10, 10]}, "name": "Pad"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [20, 10], "radius": 2}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [20, 10], "radius": 2, "id": "PadCircle"}));
|
||||
let sketch = run(&mut s, "sketch.inspect", json!({"sketch": "Pad"}));
|
||||
let center = sketch["curves"].as_array().unwrap().iter().find(|c| c["id"] == "PadCircle").unwrap()["center"].as_str().unwrap().to_string();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let pad = sketch_point_world(&mut s, "Pad", "p1");
|
||||
assert!((pad.z - 10.0).abs() < 1e-6 && pad.x > 50.0, "{pad:?}");
|
||||
// A projected boundary's first point is an enumeration detail; check the circle
|
||||
// that was actually authored in the moved component's local sketch coordinates.
|
||||
let pad = sketch_point_world(&mut s, "Pad", ¢er);
|
||||
assert!(near(pad, Vec3::new(70.0, 10.0, 10.0)), "{pad:?}");
|
||||
let v1 = volume(&mut s);
|
||||
let h = run(&mut s, "solid.hole", json!({"position": [60, 10, 10], "diameter": 4, "depth": 5}));
|
||||
assert!(volume(&mut s) < v1 - 1.0, "the hole cut material: {h}");
|
||||
|
||||
Reference in new issue
Block a user