feat: preserve adaptive sketch profiles and curved modeling workflows
This commit is contained in:
1 parent
4c4a0449a4
commit
4e595deff2
62 files changed
+7114
-362
No files matched your search
@@ -9,6 +9,64 @@ use solvecraft_doc::{Document, Feature, FeatureKind, Mat, Occurrence};
|
||||
const CMD: &str = "component.paste_new";
|
||||
const MAX_WORK: usize = 10_000;
|
||||
|
||||
fn mesh_distance(mesh: &solvecraft_geom::Mesh, point: solvecraft_geom::Vec3) -> f64 {
|
||||
mesh.triangles
|
||||
.iter()
|
||||
.filter_map(|triangle| mesh.tri(triangle))
|
||||
.map(|[a, b, c]| {
|
||||
let normal = (b - a).cross(c - a);
|
||||
let squared = normal.dot(normal);
|
||||
let projected = if squared > 0. { point - normal * ((point - a).dot(normal) / squared) } else { point };
|
||||
if squared > 0. && [(a, b), (b, c), (c, a)].iter().all(|(start, end)| (*end - *start).cross(projected - *start).dot(normal) >= 0.) {
|
||||
return point.dist(projected);
|
||||
}
|
||||
[(a, b), (b, c), (c, a)]
|
||||
.iter()
|
||||
.map(|(start, end)| {
|
||||
let edge = *end - *start;
|
||||
let t = if edge.dot(edge) > 0. { ((point - *start).dot(edge) / edge.dot(edge)).clamp(0., 1.) } else { 0. };
|
||||
point.dist(*start + edge * t)
|
||||
})
|
||||
.fold(f64::INFINITY, f64::min)
|
||||
})
|
||||
.fold(f64::INFINITY, f64::min)
|
||||
}
|
||||
|
||||
fn material_probe_matches(a: &solvecraft_geom::Mesh, b: &solvecraft_geom::Mesh, point: solvecraft_geom::Vec3, tol_a: f64, tol_b: f64) -> bool {
|
||||
if !point.is_finite() || !tol_a.is_finite() || !tol_b.is_finite() || tol_a <= 0. || tol_b <= 0. {
|
||||
return false;
|
||||
}
|
||||
if a.contains(point) == b.contains(point) {
|
||||
return true;
|
||||
}
|
||||
// A chord approximation does not define material ON a boundary. Certify
|
||||
// nearby stock and void instead, outside both meshes' approximation strips.
|
||||
if mesh_distance(a, point) > tol_a || mesh_distance(b, point) > tol_b {
|
||||
return false;
|
||||
}
|
||||
let offset = 4. * tol_a.max(tol_b);
|
||||
let mut material = false;
|
||||
let mut void = false;
|
||||
for axis in [solvecraft_geom::Vec3::X, solvecraft_geom::Vec3::Y, solvecraft_geom::Vec3::Z] {
|
||||
for sign in [-1., 1.] {
|
||||
let q = point + axis * (sign * offset);
|
||||
if !q.is_finite() {
|
||||
return false;
|
||||
}
|
||||
if mesh_distance(a, q) <= tol_a || mesh_distance(b, q) <= tol_b {
|
||||
continue;
|
||||
}
|
||||
let inside = a.contains(q);
|
||||
if inside != b.contains(q) {
|
||||
return false;
|
||||
}
|
||||
material |= inside;
|
||||
void |= !inside;
|
||||
}
|
||||
}
|
||||
material && void
|
||||
}
|
||||
|
||||
fn cloned_path(source: &Document, path: &[u64], root: u64, destination: &[u64], occurrences: &BTreeMap<u64, u64>) -> Result<Vec<u64>> {
|
||||
let start = path
|
||||
.iter()
|
||||
@@ -586,9 +644,13 @@ pub(super) fn paste(
|
||||
let mb = solvecraft_kernel::measure(&b)?;
|
||||
let tolerance = 1e-5 * (1.0 + (ma.bbox.max - ma.bbox.min).len()) + 64.0 * f64::EPSILON * (ma.bbox.min.len() + ma.bbox.max.len());
|
||||
let mut material_matches = true;
|
||||
let mesh_a = a.display_mesh((a.size() * 1e-3).max(1e-3))?;
|
||||
let mesh_b = b.display_mesh((b.size() * 1e-3).max(1e-3))?;
|
||||
proof_work = proof_work.saturating_add(mesh_a.triangles.len().saturating_add(mesh_b.triangles.len()).saturating_mul(27));
|
||||
let tol_a = (a.size() * 1e-3).max(1e-3).max(a.size() * 1e-6);
|
||||
let tol_b = (b.size() * 1e-3).max(1e-3).max(b.size() * 1e-6);
|
||||
let mesh_a = a.display_mesh(tol_a)?;
|
||||
let mesh_b = b.display_mesh(tol_b)?;
|
||||
// Precharge all 27 candidates, six possible replacements, their triangle
|
||||
// distances and the three containment rays before any proof traversal.
|
||||
proof_work = proof_work.saturating_add(mesh_a.triangles.len().saturating_add(mesh_b.triangles.len()).saturating_mul(27 * 7 * 4));
|
||||
if proof_work > 50_000_000 {
|
||||
return Err(bad(CMD, "geometry verification exceeds copy work limit"));
|
||||
}
|
||||
@@ -597,7 +659,8 @@ pub(super) fn paste(
|
||||
for z in [0.2, 0.5, 0.8] {
|
||||
let d = ma.bbox.max - ma.bbox.min;
|
||||
let p = ma.bbox.min + solvecraft_geom::Vec3::new(d.x * x, d.y * y, d.z * z);
|
||||
material_matches &= mesh_a.contains(p) == mesh_b.contains(p);
|
||||
let roundoff = 64. * f64::EPSILON * (1. + p.len());
|
||||
material_matches &= material_probe_matches(&mesh_a, &mesh_b, p, tol_a + roundoff, tol_b + roundoff);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,3 +465,57 @@ fn transferred_frozen_face_paint_ignores_consumed_legacy_offset() {
|
||||
let paint = s.doc.appearances.faces.iter().find(|face| face.body == "Foreign (copy)").unwrap();
|
||||
assert!(paint.point.dist(expected) < 1e-6, "{:?} versus {expected:?}", paint.point);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundary_material_proof_requires_resolvable_stock_and_void() {
|
||||
use solvecraft_geom::Vec3;
|
||||
let a = solvecraft_kernel::box_solid(Vec3::ZERO, Vec3::new(10., 10., 10.)).unwrap().tessellate(1e-3).unwrap();
|
||||
let b = solvecraft_kernel::box_solid(Vec3::new(1e-5, 0., 0.), Vec3::new(10.00001, 10., 10.)).unwrap().tessellate(1e-3).unwrap();
|
||||
let p = Vec3::new(5e-6, 5., 5.);
|
||||
assert_ne!(a.contains(p), b.contains(p));
|
||||
assert!((super::mesh_distance(&a, p) - 5e-6).abs() < 1e-12);
|
||||
assert!((super::mesh_distance(&b, p) - 5e-6).abs() < 1e-12);
|
||||
assert!(super::material_probe_matches(&a, &b, p, 1e-3, 1e-3));
|
||||
let shifted = solvecraft_kernel::box_solid(Vec3::new(1., 0., 0.), Vec3::new(11., 10., 10.)).unwrap().tessellate(1e-3).unwrap();
|
||||
assert!(!super::material_probe_matches(&a, &shifted, p, 1e-3, 1e-3));
|
||||
let thin = solvecraft_kernel::box_solid(Vec3::ZERO, Vec3::new(10., 10., 1e-4)).unwrap().tessellate(1e-6).unwrap();
|
||||
let thin_shifted = solvecraft_kernel::box_solid(Vec3::new(0., 0., 1e-4), Vec3::new(10., 10., 2e-4)).unwrap().tessellate(1e-6).unwrap();
|
||||
// Every resolvable replacement is void: thin ambiguous material is refused.
|
||||
assert!(!super::material_probe_matches(&thin, &thin_shifted, Vec3::new(5., 5., 5e-5), 1e-3, 1e-3));
|
||||
assert!(!super::material_probe_matches(&a, &b, p, f64::INFINITY, 1e-3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frozen_bore_boundary_copy_preserves_stock_void_and_reopen() {
|
||||
use solvecraft_geom::Vec3;
|
||||
let bore = |center: f64| {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":30,"width":20,"height":10,"body_name":"Drilled"}));
|
||||
run(&mut s, "solid.cylinder", json!({"base":[center,10,0],"radius":4,"height":10,"operation":"cut"}));
|
||||
s
|
||||
};
|
||||
let mut s = bore(10.);
|
||||
let source = s.model.state().body("Drilled").unwrap().body.tessellate(1e-3).unwrap();
|
||||
// The exact R4 wall passes through x6; a chord may end either side of that
|
||||
// cardinal angle, so its distance is bounded by the requested sag tolerance.
|
||||
assert!(super::mesh_distance(&source, Vec3::new(6., 10., 5.)) <= 1e-3);
|
||||
let shifted = bore(11.).model.state().body("Drilled").unwrap().body.tessellate(1e-3).unwrap();
|
||||
let lost = solvecraft_kernel::box_solid(Vec3::ZERO, Vec3::new(30., 20., 10.)).unwrap().tessellate(1e-3).unwrap();
|
||||
assert!(!super::material_probe_matches(&source, &shifted, Vec3::new(6.5, 10., 5.), 1e-3, 1e-3));
|
||||
assert!(!super::material_probe_matches(&source, &lost, Vec3::new(10., 10., 5.), 1e-3, 1e-3));
|
||||
let created = run(&mut s, "component.from_bodies", json!({"bodies":["Drilled"]}));
|
||||
let part = created["components"][0]["component"].as_u64().unwrap();
|
||||
let copied = copy(&mut s, part)["component"].as_u64().unwrap();
|
||||
let check = |session: &Session| {
|
||||
let state = session.model.state();
|
||||
let body = state.bodies.iter().find(|body| session.doc.model_body_component(body) == copied).unwrap();
|
||||
let mesh = body.body.tessellate(1e-3).unwrap();
|
||||
for (point, stock) in
|
||||
[(Vec3::new(5.5, 10., 5.), true), (Vec3::new(6.5, 10., 5.), false), (Vec3::new(10., 10., 5.), false), (Vec3::new(18., 10., 5.), true)]
|
||||
{
|
||||
assert_eq!(mesh.contains(point), stock, "{point:?}");
|
||||
}
|
||||
};
|
||||
check(&s);
|
||||
check(&Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap()));
|
||||
}
|
||||
@@ -100,22 +100,17 @@ fn generated_profiles_and_press_pull_convert_rotated_occurrence_picks_once() {
|
||||
check(&mut s, area * 30.0, hole, 30.0);
|
||||
run(&mut s, "solid.press_pull", json!({"face":[35,-10,15],"distance":5}));
|
||||
check(&mut s, area * 35.0, hole, 35.0);
|
||||
let height = if hole {
|
||||
// Curved coincident Cut is an existing kernel limit; it must be atomic.
|
||||
let before = s.doc.to_json();
|
||||
let error = s.execute("solid.press_pull", &json!({"face":[35,-15,15],"distance":-5})).unwrap_err();
|
||||
let message = error.to_string();
|
||||
assert!(message.contains("takes planar faces only"), "{error}");
|
||||
assert!(message.contains("the target has 2 curved faces (cylinder)"), "{error}");
|
||||
assert!(message.contains("the tool has 2 curved faces (cylinder)"), "{error}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
check(&mut s, area * 35.0, hole, 35.0);
|
||||
35.0
|
||||
} else {
|
||||
run(&mut s, "solid.press_pull", json!({"face":[35,-15,15],"distance":-5}));
|
||||
check(&mut s, area * 30.0, hole, 30.0);
|
||||
30.0
|
||||
};
|
||||
let before_cut = s.doc.to_json();
|
||||
run(&mut s, "solid.press_pull", json!({"face":[35,-15,15],"distance":-5}));
|
||||
let height = 30.0;
|
||||
check(&mut s, area * height, hole, height);
|
||||
let after_cut = s.doc.to_json();
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), before_cut);
|
||||
check(&mut s, area * 35.0, hole, 35.0);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), after_cut);
|
||||
check(&mut s, area * height, hole, height);
|
||||
let world = s.world_state();
|
||||
let occupied = Vec3::new(35.0, 21.0 - height, 15.0);
|
||||
let absent = Vec3::new(35.0, 19.0 - height, 15.0);
|
||||
|
||||
@@ -124,17 +124,9 @@ fn extrude_base(s: &Session, p: &Value) -> Option<(Vec3, Vec3)> {
|
||||
ProfileSel::All => ps.first()?.region.interior_point(),
|
||||
ProfileSel::Indices { indices } => ps.get(*indices.first()?)?.region.interior_point(),
|
||||
ProfileSel::Points { points } => *points.first()?,
|
||||
ProfileSel::Curves { loops } => {
|
||||
let mut want: Vec<&String> = loops.first()?.iter().collect();
|
||||
want.sort();
|
||||
ps.iter()
|
||||
.find(|x| {
|
||||
let mut have: Vec<&String> = x.outer_curves.iter().collect();
|
||||
have.sort();
|
||||
have == want
|
||||
})?
|
||||
.region
|
||||
.interior_point()
|
||||
selection @ (ProfileSel::Curves { .. } | ProfileSel::OffsetBoundaries { .. } | ProfileSel::WholeBoundaries { .. }) => {
|
||||
let indices = solvecraft_doc::profile_selection_indices(ss, &selection).ok()?;
|
||||
ps.get(*indices.first()?)?.region.interior_point()
|
||||
}
|
||||
};
|
||||
Some((ss.plane.to_world(q), ss.plane.normal()))
|
||||
@@ -216,9 +208,14 @@ pub(super) fn feature_sketch(s: &Session, p: &Value, cmd: &str) -> Result<u64> {
|
||||
fn stable_profiles(s: &Session, kind: &mut FeatureKind) -> Result<()> {
|
||||
let st = s.model.state();
|
||||
'sels: for (sketch, sel) in kind.profile_sels_mut() {
|
||||
let ProfileSel::Indices { indices } = sel else { continue };
|
||||
let Some(ss) = st.sketch(sketch) else { continue };
|
||||
let mut loops = Vec::with_capacity(indices.len());
|
||||
if !matches!(
|
||||
sel,
|
||||
ProfileSel::Indices { .. } | ProfileSel::Curves { .. } | ProfileSel::OffsetBoundaries { .. } | ProfileSel::WholeBoundaries { .. }
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let indices = solvecraft_doc::profile_selection_indices(ss, sel)?;
|
||||
for i in indices.iter() {
|
||||
let pr = ss
|
||||
.profiles
|
||||
@@ -226,12 +223,39 @@ fn stable_profiles(s: &Session, kind: &mut FeatureKind) -> Result<()> {
|
||||
.ok_or_else(|| EngineError::Other(format!("no profile {i} in sketch `{}` (it has {})", ss.name, ss.profiles.len())))?;
|
||||
// Two profiles with the same outer curves (one inside the other's holes cannot be):
|
||||
// keep the index then.
|
||||
if pr.outer_curves.is_empty() || ss.profiles.iter().filter(|q| same_loop(&q.outer_curves, &pr.outer_curves)).count() != 1 {
|
||||
if !matches!(sel, ProfileSel::WholeBoundaries { .. })
|
||||
&& (pr.outer_curves.is_empty() || ss.profiles.iter().filter(|q| same_loop(&q.outer_curves, &pr.outer_curves)).count() != 1)
|
||||
{
|
||||
continue 'sels;
|
||||
}
|
||||
loops.push(pr.outer_curves.clone());
|
||||
}
|
||||
*sel = ProfileSel::Curves { loops };
|
||||
let captured = solvecraft_doc::capture_profile_selection(ss, &indices)?;
|
||||
*sel = match captured {
|
||||
ProfileSel::OffsetBoundaries { associations }
|
||||
if associations.iter().any(|id| {
|
||||
s.doc
|
||||
.sketch(sketch)
|
||||
.is_ok_and(|raw| !raw.offsets.iter().filter_map(|offset| offset.topology.as_ref()).any(|topology| topology.id == *id))
|
||||
}) =>
|
||||
{
|
||||
// Legacy upgrade descriptors belong to the solved copy. Persist the
|
||||
// complete original role IDs instead of an unsaved association ID.
|
||||
let loops = associations
|
||||
.iter()
|
||||
.map(|id| {
|
||||
ss.sketch
|
||||
.offsets
|
||||
.iter()
|
||||
.filter_map(|offset| offset.topology.as_ref())
|
||||
.find(|topology| topology.id == *id)
|
||||
.map(|topology| topology.roles.iter().map(|role| role.output.clone()).collect())
|
||||
.ok_or_else(|| EngineError::Other(format!("offset association `{id}` changed during profile capture")))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
ProfileSel::Curves { loops }
|
||||
}
|
||||
captured => captured,
|
||||
};
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -246,6 +270,32 @@ fn same_loop(a: &[String], b: &[String]) -> bool {
|
||||
pub(super) fn profiles(p: &Value, cmd: &str) -> Result<ProfileSel> {
|
||||
let Some(v) = p.get("profiles") else { return Ok(ProfileSel::All) };
|
||||
match v {
|
||||
Value::Object(fields) => {
|
||||
if fields.get("type").and_then(Value::as_str) == Some("whole_boundaries") {
|
||||
return solvecraft_doc::whole_profile_selection_from_value(v).map_err(|error| bad(cmd, error.to_string()));
|
||||
}
|
||||
let associations = fields
|
||||
.get("offset_boundaries")
|
||||
.or_else(|| (fields.get("type").and_then(Value::as_str) == Some("offset_boundaries")).then(|| fields.get("associations")).flatten())
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| bad(cmd, "profile object must provide `offset_boundaries: [association ids]`"))?;
|
||||
if associations.is_empty() || associations.len() > 128 {
|
||||
return Err(bad(cmd, "offset profile selection needs 1 to 128 association ids"));
|
||||
}
|
||||
let associations = associations
|
||||
.iter()
|
||||
.map(|id| {
|
||||
id.as_str()
|
||||
.filter(|id| !id.is_empty() && id.len() <= 512)
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| bad(cmd, "offset association ids must be nonempty strings of at most 512 bytes"))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
if associations.iter().collect::<std::collections::BTreeSet<_>>().len() != associations.len() {
|
||||
return Err(bad(cmd, "offset association ids must be unique"));
|
||||
}
|
||||
Ok(ProfileSel::OffsetBoundaries { associations })
|
||||
}
|
||||
Value::String(s) if s.eq_ignore_ascii_case("all") => Ok(ProfileSel::All),
|
||||
Value::Number(n) => Ok(ProfileSel::Indices { indices: vec![n.as_u64().ok_or_else(|| bad(cmd, "profile index"))? as usize] }),
|
||||
Value::Array(a) if a.is_empty() => Err(bad(cmd, "`profiles` is empty")),
|
||||
@@ -1351,3 +1401,15 @@ mod patterns_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "revolve_convention_tests.rs"]
|
||||
mod revolve_convention_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "offset_topology_tests.rs"]
|
||||
mod offset_topology_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "offset_identity_tests.rs"]
|
||||
mod offset_identity_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "offset_edit_guard_tests.rs"]
|
||||
mod offset_edit_guard_tests;
|
||||
@@ -0,0 +1,183 @@
|
||||
#![allow(clippy::unwrap_used, clippy::panic)]
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::{Document, FeatureKind, ProfileSel};
|
||||
use solvecraft_geom::Vec2;
|
||||
use solvecraft_sketch::{CurveKind, Sketch};
|
||||
|
||||
use crate::{Session, Snapshot};
|
||||
|
||||
fn run(s: &mut Session, command: &str, params: Value) -> Value {
|
||||
s.execute(command, ¶ms).unwrap_or_else(|error| panic!("{command}: {error}"))
|
||||
}
|
||||
fn rounded() -> (Session, u64, String) {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY","name":"Rounded"}));
|
||||
let sketch = s.active_sketch.unwrap();
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-38,-33],"p1":[38,33]}));
|
||||
let fillet = run(&mut s, "sketch.fillet", json!({"points":["l1.start","l1.end","l2.end","l3.end"],"radius":"1 mm"}));
|
||||
(s, sketch, fillet["param"].as_str().unwrap().into())
|
||||
}
|
||||
fn offset(s: &mut Session, curve: &str, distance: f64) -> (String, Vec<String>) {
|
||||
let result = run(s, "sketch.offset", json!({"curves":[curve],"distance":distance,"side":[0,0]}));
|
||||
(result["association"].as_str().unwrap().into(), result["curves"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().into()).collect())
|
||||
}
|
||||
fn solved(s: &Session, id: u64) -> Sketch {
|
||||
s.model.state().sketch(id).unwrap().sketch.clone()
|
||||
}
|
||||
fn arc_centres(sk: &Sketch, curves: &[String]) -> Vec<(String, usize, Vec2)> {
|
||||
curves
|
||||
.iter()
|
||||
.filter_map(|id| {
|
||||
let CurveKind::Arc { c, .. } = sk.curves.get(sk.curve_index(id)?)?.kind else { return None };
|
||||
Some((id.clone(), c, sk.point(c)?))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn history(stack: &[Snapshot]) -> Vec<(String, String, Option<u64>, String)> {
|
||||
stack.iter().map(|entry| (entry.label.clone(), entry.doc.to_json(), entry.active_sketch, format!("{:?}", entry.pending_moves))).collect()
|
||||
}
|
||||
fn refuses_atomically(s: &mut Session, command: &str, params: Value) -> String {
|
||||
let document = s.doc.to_json();
|
||||
let undo = history(&s.undo);
|
||||
let redo = history(&s.redo);
|
||||
let active = s.active_sketch;
|
||||
let error = s.execute(command, ¶ms).unwrap_err().to_string();
|
||||
assert_eq!(s.doc.to_json(), document);
|
||||
assert_eq!(history(&s.undo), undo);
|
||||
assert_eq!(history(&s.redo), redo);
|
||||
assert_eq!(s.active_sketch, active);
|
||||
error
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_guard_retired_parent_refuses_new_child_without_destroying_redo() {
|
||||
let (mut s, sketch, _) = rounded();
|
||||
let (_, outputs) = offset(&mut s, "l1", 1.4);
|
||||
assert_eq!(outputs.len(), 4);
|
||||
assert_eq!(solved(&s, sketch).offsets[0].topology.as_ref().unwrap().roles.iter().filter(|r| r.retired).count(), 4);
|
||||
run(&mut s, "sketch.point", json!({"point":[100,100]}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(!s.redo.is_empty());
|
||||
let error = refuses_atomically(&mut s, "sketch.offset", json!({"curves":[outputs[0]],"distance":0.2,"side":[0,0]}));
|
||||
assert!(error.contains("retired corner") && error.contains("break its offset association"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_guard_live_child_follows_radius_then_retirement_is_honest() {
|
||||
let (mut s, sketch, radius) = rounded();
|
||||
let (_, parent) = offset(&mut s, "l1", 0.2);
|
||||
let (child_association, child) = offset(&mut s, &parent[0], 0.2);
|
||||
let arc_radius = |s: &Session| {
|
||||
let sk = solved(s, sketch);
|
||||
child
|
||||
.iter()
|
||||
.filter_map(|id| {
|
||||
let CurveKind::Arc { c, a, .. } = sk.curves.get(sk.curve_index(id)?)?.kind else { return None };
|
||||
Some(sk.point(c)?.dist(sk.point(a)?))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(arc_radius(&s).len(), 4);
|
||||
assert!(arc_radius(&s).iter().all(|r| (*r - 0.6).abs() < 1e-6));
|
||||
run(&mut s, "parameters.change", json!({"name":radius,"expression":"2 mm"}));
|
||||
assert!(s.model.results.iter().all(|r| r.error.is_none()));
|
||||
assert!(arc_radius(&s).iter().all(|r| (*r - 1.6).abs() < 1e-6));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(
|
||||
&mut s,
|
||||
"solid.extrude",
|
||||
json!({"sketch":sketch,"profiles":{"offset_boundaries":[child_association]},"distance":2,"operation":"new","body_name":"Child"}),
|
||||
);
|
||||
let before = s.doc.to_json();
|
||||
let undo = history(&s.undo);
|
||||
let redo = history(&s.redo);
|
||||
match s.execute("parameters.change", &json!({"name":radius,"expression":"0.1 mm"})) {
|
||||
Err(error) => {
|
||||
assert!(error.to_string().contains("offset"), "{error}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert_eq!(history(&s.undo), undo);
|
||||
assert_eq!(history(&s.redo), redo);
|
||||
}
|
||||
Ok(_) => {
|
||||
assert!(s.model.results.iter().any(|r| r.error.is_some()), "retired parent must not keep a stale successful child");
|
||||
assert!(s.world_state().body("Child").is_none());
|
||||
let reopened = Session::new(Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert!(reopened.model.results.iter().any(|r| r.error.is_some()));
|
||||
assert!(reopened.world_state().body("Child").is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_guard_partial_delete_requires_break_and_surviving_arc_centres_detach() {
|
||||
let (mut s, sketch, _) = rounded();
|
||||
let (association, outputs) = offset(&mut s, "l1", 0.2);
|
||||
let error = refuses_atomically(&mut s, "sketch.delete", json!({"entities":[outputs[0]]}));
|
||||
assert!(error.contains("break the offset association"), "{error}");
|
||||
let source = solved(&s, sketch);
|
||||
let source_ids: Vec<_> = source.curves.iter().filter(|c| !outputs.contains(&c.id)).map(|c| c.id.clone()).collect();
|
||||
let source_centres = arc_centres(&source, &source_ids);
|
||||
assert_eq!(source_centres.len(), 4);
|
||||
run(&mut s, "sketch.offset.break", json!({"curve":association}));
|
||||
let released = solved(&s, sketch);
|
||||
let independent = arc_centres(&released, &outputs);
|
||||
assert_eq!(independent.len(), 4);
|
||||
assert!(independent.iter().all(|(_, centre, _)| source_centres.iter().all(|(_, source, _)| centre != source)));
|
||||
// Released geometry is editable: anchor its new centres independently, then
|
||||
// move a source centre. Shared source nodes would block that actual move.
|
||||
let released_centres: Vec<_> = independent.iter().map(|(id, _, _)| format!("{id}.center")).collect();
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities":released_centres,"fixed":true}));
|
||||
run(&mut s, "sketch.delete", json!({"entities":[outputs[0]]}));
|
||||
let deleted = solved(&s, sketch);
|
||||
assert!(deleted.curve_index(&outputs[0]).is_none());
|
||||
let survivors = arc_centres(&deleted, &outputs);
|
||||
let (source_id, _, position) = source_centres.first().unwrap();
|
||||
let target = *position + Vec2::new(5.0, 5.0);
|
||||
run(&mut s, "sketch.move_point", json!({"point":format!("{source_id}.center"),"to":[target.x,target.y]}));
|
||||
assert!(s.model.results.iter().all(|r| r.error.is_none()));
|
||||
let changed = solved(&s, sketch);
|
||||
for (id, _, position) in independent {
|
||||
let after = arc_centres(&changed, std::slice::from_ref(&id));
|
||||
assert_eq!(after.len(), 1);
|
||||
assert!(
|
||||
after[0].2.dist(position) < 1e-8,
|
||||
"{id}: released {position:?}, after deletion {:?}, after source move {:?}; deleted curve {}",
|
||||
survivors.iter().find(|(curve, _, _)| curve == &id),
|
||||
after[0],
|
||||
outputs[0]
|
||||
);
|
||||
}
|
||||
let moved = arc_centres(&changed, std::slice::from_ref(source_id));
|
||||
assert_eq!(moved.len(), 1);
|
||||
assert!(moved[0].2.dist(target) < 1e-8);
|
||||
assert!(moved[0].2.dist(*position) > 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_guard_complete_delete_keeps_dangling_profile_loadable_without_circle_retarget() {
|
||||
let (mut s, sketch, _) = rounded();
|
||||
let (association, outputs) = offset(&mut s, "l1", 0.2);
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[100,0],"radius":3,"id":"Unrelated"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(
|
||||
&mut s,
|
||||
"solid.extrude",
|
||||
json!({"sketch":sketch,"profiles":{"offset_boundaries":[association]},"distance":2,"operation":"new","name":"OffsetBody","body_name":"Selected"}),
|
||||
);
|
||||
run(&mut s, "sketch.edit", json!({"sketch":sketch}));
|
||||
run(&mut s, "sketch.delete", json!({"entities":outputs}));
|
||||
let saved = s.doc.to_json();
|
||||
let FeatureKind::Extrude { profiles: ProfileSel::OffsetBoundaries { associations }, .. } = &s.doc.find_feature("OffsetBody").unwrap().kind else {
|
||||
panic!("explicit offset identity lost")
|
||||
};
|
||||
assert_eq!(associations, &vec![association]);
|
||||
let check = |s: &Session| {
|
||||
assert!(solved(s, sketch).curve_index("Unrelated").is_some());
|
||||
assert!(s.model.results.iter().find(|r| r.id == s.doc.find_feature("OffsetBody").unwrap().id).unwrap().error.is_some());
|
||||
assert!(s.world_state().body("Selected").is_none(), "dangling selection must never adopt the unrelated circle");
|
||||
};
|
||||
check(&s);
|
||||
let reopened = Session::new(Document::from_json(&saved).unwrap());
|
||||
check(&reopened);
|
||||
assert_eq!(reopened.doc.to_json(), saved);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
#![allow(clippy::unwrap_used, clippy::panic)]
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::{Document, FeatureKind, ProfileSel};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn run(session: &mut Session, command: &str, params: Value) -> Value {
|
||||
session.execute(command, ¶ms).unwrap_or_else(|error| panic!("{command}: {error}"))
|
||||
}
|
||||
|
||||
fn rounded_offset(radius: f64) -> (Session, u64, String, String) {
|
||||
let mut session = Session::default();
|
||||
run(&mut session, "sketch.create", json!({"plane":"XY","name":"Rounded"}));
|
||||
let sketch = session.active_sketch.unwrap();
|
||||
run(&mut session, "sketch.rectangle.two_point", json!({"p0":[-37.-radius,-32.-radius],"p1":[37.+radius,32.+radius]}));
|
||||
let fillet = run(&mut session, "sketch.fillet", json!({"points":["l1.start","l1.end","l2.end","l3.end"],"radius":radius}));
|
||||
// The radius is the only intended source freedom: keep the four exact arc
|
||||
// centres at (+/-37,+/-32), independently of the corner radius.
|
||||
let centers = fillet["curves"].as_array().unwrap().iter().map(|id| format!("{}.center", id.as_str().unwrap())).collect::<Vec<_>>();
|
||||
run(&mut session, "sketch.constraint.fix", json!({"entities":centers,"fixed":true}));
|
||||
let offset = run(&mut session, "sketch.offset", json!({"curves":["l1"],"distance":1.4,"side":[0,0]}));
|
||||
run(&mut session, "sketch.finish", json!({}));
|
||||
(session, sketch, fillet["param"].as_str().unwrap().into(), offset["association"].as_str().unwrap().into())
|
||||
}
|
||||
|
||||
fn volume(session: &Session, feature: u64) -> f64 {
|
||||
let state = session.model.state();
|
||||
let body = state.bodies.iter().find(|body| body.feature == feature).unwrap();
|
||||
solvecraft_kernel::measure(&body.body).unwrap().volume
|
||||
}
|
||||
|
||||
fn healthy(session: &Session) {
|
||||
assert!(session.model.results.iter().all(|result| result.error.is_none()), "{:?}", session.model.results);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_offset_capture_persists_reserved_curves_without_a_transient_association() {
|
||||
let (session, sketch, radius, _) = rounded_offset(2.);
|
||||
let mut document = (*session.doc).clone();
|
||||
let raw = document.sketch_mut(sketch).unwrap();
|
||||
let reserved = raw.offsets[0].outputs.clone();
|
||||
assert_eq!(reserved.len(), 8);
|
||||
raw.offsets[0].topology = None;
|
||||
document.format = "solvecraft/11".into();
|
||||
let mut session = Session::new(Document::from_json(&document.to_json()).unwrap());
|
||||
assert!(session.doc.sketch(sketch).unwrap().offsets[0].topology.is_none());
|
||||
let state = session.model.state();
|
||||
let solved = state.sketch(sketch).unwrap();
|
||||
assert!(solved.sketch.offsets[0].topology.is_some(), "legacy upgrade belongs only to the evaluated copy");
|
||||
let index = solvecraft_doc::profile_selection_indices(solved, &ProfileSel::Curves { loops: vec![reserved.clone()] }).unwrap()[0];
|
||||
let extrusion = run(&mut session, "solid.extrude", json!({"sketch":sketch,"profiles":[index],"distance":2,"name":"Legacy inner"}))["feature"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(&session.doc.feature(extrusion).unwrap().kind,FeatureKind::Extrude {profiles:ProfileSel::Curves {loops},..} if loops == &vec![reserved.clone()])
|
||||
);
|
||||
assert!(session.doc.sketch(sketch).unwrap().offsets[0].topology.is_none());
|
||||
run(&mut session, "parameters.change", json!({"name":radius,"expression":"1 mm"}));
|
||||
healthy(&session);
|
||||
assert_eq!(session.model.state().sketch(sketch).unwrap().sketch.offsets[0].outputs.len(), 4);
|
||||
assert!(
|
||||
(volume(&session, extrusion) - 73.2 * 63.2 * 2.).abs() < 1e-6,
|
||||
"legacy sharp volume {} bounds {:?}",
|
||||
volume(&session, extrusion),
|
||||
session.model.state().bodies.last().unwrap().mesh().bounds()
|
||||
);
|
||||
let saved = session.doc.to_json();
|
||||
let mut reopened = Session::new(Document::from_json(&saved).unwrap());
|
||||
healthy(&reopened);
|
||||
assert_eq!(reopened.doc.to_json(), saved);
|
||||
assert!(reopened.doc.sketch(sketch).unwrap().offsets[0].topology.is_none());
|
||||
assert!((volume(&reopened, extrusion) - 73.2 * 63.2 * 2.).abs() < 1e-6);
|
||||
run(&mut reopened, "parameters.change", json!({"name":radius,"expression":"2 mm"}));
|
||||
healthy(&reopened);
|
||||
let state = reopened.model.state();
|
||||
assert_eq!(state.sketch(sketch).unwrap().sketch.offsets[0].outputs, reserved);
|
||||
let expected = 2. * (75.2 * 65.2 - (4. - std::f64::consts::PI) * 0.6 * 0.6);
|
||||
assert!((volume(&reopened, extrusion) - expected).abs() < 0.002);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasted_offset_boundary_has_scoped_identity_and_independent_dimensions_and_material() {
|
||||
let (mut session, sketch, radius, association) = rounded_offset(1.);
|
||||
let extrusion = run(
|
||||
&mut session,
|
||||
"solid.extrude",
|
||||
json!({"sketch":sketch,"profiles":{"offset_boundaries":[association]},"distance":2,"name":"Inner"}),
|
||||
)["feature"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let source_body = session.model.state().bodies[0].name.clone();
|
||||
run(&mut session, "material.assign", json!({"bodies":[source_body],"material":"Brass"}));
|
||||
run(&mut session, "timeline.copy", json!({"features":[sketch,extrusion]}));
|
||||
let pasted = run(&mut session, "timeline.paste", json!({"translate":[100,0,0]}));
|
||||
assert_eq!(pasted["errors"], json!([]));
|
||||
let copied_sketch = pasted["pasted"][0]["id"].as_u64().unwrap();
|
||||
let copied_extrusion = pasted["pasted"][1]["id"].as_u64().unwrap();
|
||||
assert_ne!(copied_sketch, sketch);
|
||||
assert!(
|
||||
matches!(&session.doc.feature(copied_extrusion).unwrap().kind,FeatureKind::Extrude {sketch:source,profiles:ProfileSel::OffsetBoundaries {associations},..} if *source==copied_sketch && associations == &vec![association.clone()])
|
||||
);
|
||||
let source = session.doc.sketch(sketch).unwrap();
|
||||
let copy = session.doc.sketch(copied_sketch).unwrap();
|
||||
assert_eq!(source.offsets[0].topology.as_ref().unwrap().id, copy.offsets[0].topology.as_ref().unwrap().id);
|
||||
let dimension = source.constraints.iter().position(|constraint| constraint.param.as_deref() == Some(&radius)).unwrap();
|
||||
let copied_radius = copy.constraints[dimension].param.clone().unwrap();
|
||||
assert_ne!(copied_radius, radius);
|
||||
let copied_body = session.model.state().bodies.iter().find(|body| body.feature == copied_extrusion).unwrap().name.clone();
|
||||
run(&mut session, "material.assign", json!({"bodies":[copied_body],"material":"Aluminum"}));
|
||||
let sharp = 73.2 * 63.2 * 2.;
|
||||
assert!((volume(&session, extrusion) - sharp).abs() < 1e-6);
|
||||
assert!((volume(&session, copied_extrusion) - sharp).abs() < 1e-6);
|
||||
run(&mut session, "parameters.change", json!({"name":copied_radius,"expression":"2 mm"}));
|
||||
healthy(&session);
|
||||
let state = session.model.state();
|
||||
assert_eq!(state.sketch(sketch).unwrap().sketch.offsets[0].outputs.len(), 4);
|
||||
assert_eq!(state.sketch(copied_sketch).unwrap().sketch.offsets[0].outputs.len(), 8);
|
||||
assert!((volume(&session, extrusion) - sharp).abs() < 1e-6);
|
||||
let rounded = 2. * (75.2 * 65.2 - (4. - std::f64::consts::PI) * 0.6 * 0.6);
|
||||
assert!(
|
||||
(volume(&session, copied_extrusion) - rounded).abs() < 0.002,
|
||||
"copied rounded volume {} expected {rounded} bounds {:?}",
|
||||
volume(&session, copied_extrusion),
|
||||
session.model.state().bodies.last().unwrap().mesh().bounds()
|
||||
);
|
||||
assert_eq!(session.doc.materials.get(&source_body).map(String::as_str), Some("Brass"));
|
||||
assert_eq!(session.doc.materials.get(&copied_body).map(String::as_str), Some("Aluminum"));
|
||||
let reopened = Session::new(Document::from_json(&session.doc.to_json()).unwrap());
|
||||
healthy(&reopened);
|
||||
assert!((volume(&reopened, extrusion) - sharp).abs() < 1e-6);
|
||||
assert!((volume(&reopened, copied_extrusion) - rounded).abs() < 0.002);
|
||||
let state = reopened.world_state();
|
||||
let bounds = state.body(&copied_body).unwrap().mesh().bounds();
|
||||
assert!((bounds.min.x - (100. - 37.6)).abs() < 1e-6);
|
||||
assert!((bounds.max.x - (100. + 37.6)).abs() < 1e-6);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
#![allow(clippy::unwrap_used, clippy::panic)]
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::{FeatureKind, ProfileSel};
|
||||
use solvecraft_geom::Vec3;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn run(session: &mut Session, command: &str, params: Value) -> Value {
|
||||
session.execute(command, ¶ms).unwrap_or_else(|error| panic!("{command}: {error}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn breaking_offset_preserves_downstream_material_history_and_source_independence() {
|
||||
for distance in [0.2, 1.4] {
|
||||
let (mut session, sketch, radius) = rounded_source();
|
||||
let offset = run(&mut session, "sketch.offset", json!({"curves":["l1"],"distance":distance,"side":[0,0]}));
|
||||
let association = offset["association"].as_str().unwrap().to_string();
|
||||
let curves = session.model.state().sketch(sketch).unwrap().sketch.offset_boundary(&association).unwrap();
|
||||
run(&mut session, "sketch.finish", json!({}));
|
||||
run(&mut session, "solid.extrude", json!({"sketch":sketch,"profiles":{"offset_boundaries":[association]},"distance":2,"operation":"new"}));
|
||||
let before_volume = session.world_state().bodies.last().unwrap().mesh().measure().volume.abs();
|
||||
run(&mut session, "sketch.edit", json!({"sketch":sketch}));
|
||||
let before = session.doc.to_json();
|
||||
let history = session.undo.len();
|
||||
run(&mut session, "sketch.offset.break", json!({"curve":association}));
|
||||
assert_eq!(session.undo.len(), history + 1);
|
||||
assert!(session.model.results.iter().all(|result| result.error.is_none()));
|
||||
assert!(
|
||||
matches!(&session.doc.features.last().unwrap().kind, FeatureKind::Extrude { profiles: ProfileSel::Curves { loops }, .. } if loops == &vec![curves.clone()])
|
||||
);
|
||||
let frozen = session.doc.to_json();
|
||||
assert!((session.world_state().bodies.last().unwrap().mesh().measure().volume.abs() - before_volume).abs() < 1e-6);
|
||||
run(&mut session, "edit.undo", json!({}));
|
||||
assert_eq!(session.doc.to_json(), before);
|
||||
run(&mut session, "edit.redo", json!({}));
|
||||
assert_eq!(session.doc.to_json(), frozen);
|
||||
run(&mut session, "parameters.change", json!({"name":radius,"expression":"2 mm"}));
|
||||
assert!(session.model.results.iter().all(|result| result.error.is_none()));
|
||||
assert!((session.world_state().bodies.last().unwrap().mesh().measure().volume.abs() - before_volume).abs() < 1e-6);
|
||||
let reopened = Session::new(solvecraft_doc::Document::from_json(&session.doc.to_json()).unwrap());
|
||||
assert!(reopened.model.results.iter().all(|result| result.error.is_none()));
|
||||
assert!((reopened.world_state().bodies.last().unwrap().mesh().measure().volume.abs() - before_volume).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
fn rounded_source() -> (Session, u64, String) {
|
||||
let mut session = Session::default();
|
||||
run(&mut session, "sketch.create", json!({"plane":"XY","name":"Rounded"}));
|
||||
let sketch = session.active_sketch.unwrap();
|
||||
run(&mut session, "sketch.rectangle.two_point", json!({"p0":[-38,-33],"p1":[38,33]}));
|
||||
let fillet = run(&mut session, "sketch.fillet", json!({"points":["l1.start","l1.end","l2.end","l3.end"],"radius":"1 mm"}));
|
||||
(session, sketch, fillet["param"].as_str().unwrap().to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inset_past_corner_radius_creates_a_real_boundary_and_saved_profile_identity() {
|
||||
let (mut session, sketch, _) = rounded_source();
|
||||
let offset = run(&mut session, "sketch.offset", json!({"curves":["l1"],"distance":1.4,"side":[0,0]}));
|
||||
assert_eq!(offset["curves"].as_array().unwrap().len(), 4);
|
||||
let state = session.model.state();
|
||||
let solved = state.sketch(sketch).unwrap();
|
||||
let record = solved.sketch.offsets.last().unwrap();
|
||||
let topology = record.topology.as_ref().unwrap();
|
||||
let association = topology.id.clone();
|
||||
assert_eq!(topology.roles.len(), 8);
|
||||
assert_eq!(topology.roles.iter().filter(|role| role.retired).count(), 4);
|
||||
assert_eq!(solved.sketch.offset_boundary(&association).unwrap(), record.outputs);
|
||||
assert!(topology.roles.iter().filter(|role| role.retired).all(|role| solved.sketch.curve_index(&role.output).is_none()));
|
||||
run(&mut session, "sketch.finish", json!({}));
|
||||
run(
|
||||
&mut session,
|
||||
"solid.extrude",
|
||||
json!({"sketch":sketch,"profiles":{"offset_boundaries":[association]},"distance":2,"operation":"new","name":"Inner"}),
|
||||
);
|
||||
let feature = session.doc.features.last().unwrap();
|
||||
assert!(matches!(&feature.kind, FeatureKind::Extrude { profiles: ProfileSel::OffsetBoundaries { associations }, .. } if associations.len() == 1));
|
||||
let check = |session: &Session| {
|
||||
assert!(session.model.results.iter().all(|result| result.error.is_none()));
|
||||
let state = session.world_state();
|
||||
let body = state.bodies.last().unwrap();
|
||||
// The exact inward parallel set is a sharp 73.2 by 63.2 rectangle.
|
||||
let mesh = body.mesh();
|
||||
assert!((mesh.measure().volume.abs() - 73.2 * 63.2 * 2.0).abs() < 1e-6);
|
||||
assert!(mesh.contains(Vec3::new(36.5, 31.5, 1.0)));
|
||||
assert!(!mesh.contains(Vec3::new(36.7, 31.5, 1.0)));
|
||||
assert!(!mesh.contains(Vec3::new(36.5, 31.7, 1.0)));
|
||||
};
|
||||
check(&session);
|
||||
let saved = session.doc.to_json();
|
||||
let reopened = Session::new(solvecraft_doc::Document::from_json(&saved).unwrap());
|
||||
check(&reopened);
|
||||
assert_eq!(reopened.doc.to_json(), saved);
|
||||
let undo = session.undo.len();
|
||||
let redo = session.redo.len();
|
||||
for params in [
|
||||
json!({"profiles":{"offset_boundaries":[]}}),
|
||||
json!({"profiles":{"offset_boundaries":["missing"]}}),
|
||||
json!({"profiles":{"offset_boundaries":[association,association]}}),
|
||||
json!({"profiles":[[record.outputs[0],record.outputs[1],record.outputs[2]]]}),
|
||||
] {
|
||||
let mut params = params;
|
||||
params["sketch"] = json!(sketch);
|
||||
params["distance"] = json!(1);
|
||||
assert!(session.execute("solid.extrude", ¶ms).is_err());
|
||||
assert_eq!(session.doc.to_json(), saved);
|
||||
assert_eq!(session.undo.len(), undo);
|
||||
assert_eq!(session.redo.len(), redo);
|
||||
}
|
||||
}
|
||||
@@ -267,6 +267,7 @@ fn snap_fit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use solvecraft_geom::Vec3;
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
@@ -352,6 +353,40 @@ mod tests {
|
||||
assert!(s.execute("plastic.snap_fit", &json!({"position": [5, 5, 4], "direction": [0, 0, 1], "hook": [0, 0, 1], "length": 15, "thickness": 1.5, "width": 6, "catch_depth": 1, "catch_length": 3})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enclosure_snap_fit_follows_resized_front_wall() {
|
||||
let mut s = Session::default();
|
||||
let script: Value = serde_json::from_str(include_str!("../../../../examples/acceptance/sensor_enclosure.json")).unwrap();
|
||||
s.run_script(&script).unwrap();
|
||||
let feature = s.doc.find_feature("SnapFit1").unwrap().clone();
|
||||
run(&mut s, "parameters.change", json!({"name":"board_d","expression":"50 mm"}));
|
||||
let before = s.model.state_before(feature.id);
|
||||
let name = feature.face_names.first().unwrap();
|
||||
let point = solvecraft_doc::naming::point_on_face(&before, name, Vec3::new(0., -28., 20.))
|
||||
.expect("the original wall identity survives merged pieces")
|
||||
.0;
|
||||
assert!((point - Vec3::new(0., -33., 20.)).len() < 1e-8, "{point:?}");
|
||||
assert!(s.model.results.iter().all(|result| result.error.is_none()));
|
||||
let check = |session: &Session| {
|
||||
let before = session.model.state_before(feature.id);
|
||||
let after = &session.model.result(feature.id).unwrap().state;
|
||||
let old = before.body("Case").unwrap();
|
||||
let body = after.body("Case").unwrap();
|
||||
let added = solvecraft_kernel::measure(&body.body).unwrap().volume - solvecraft_kernel::measure(&old.body).unwrap().volume;
|
||||
// Rectangular arm + triangular catch; the small attachment overlap lies inside the wall.
|
||||
assert!((added - (8. * 1.2 + 0.8 * 1.5 / 2.) * 6.).abs() < 0.05, "{added}");
|
||||
let mesh = body.mesh();
|
||||
assert!(mesh.contains(Vec3::new(0., -29., 19.4)), "the arm follows the inner wall");
|
||||
assert!(mesh.contains(Vec3::new(0., -25.9, 20.2)), "the catch follows the arm");
|
||||
assert!(!mesh.contains(Vec3::new(0., -22., 19.4)), "the old arm placement is empty");
|
||||
assert!(!mesh.contains(Vec3::new(0., -24., 19.4)), "the arm ends at its specified length");
|
||||
};
|
||||
check(&s);
|
||||
let cold = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert!(cold.model.results.iter().all(|result| result.error.is_none()));
|
||||
check(&cold);
|
||||
}
|
||||
|
||||
fn plate() -> (Session, f64) {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 60, "width": 60, "height": 3}));
|
||||
|
||||
@@ -1103,3 +1103,458 @@ fn ring_cut_preserves_retained_island_through_unfold_and_refold() {
|
||||
assert!((volume(&mut s) - expected).abs() < 0.2);
|
||||
material_probes(&s);
|
||||
}
|
||||
|
||||
fn retained_depth_check(s: &mut Session, want: f64, z0: f64, z1: f64) {
|
||||
use solvecraft_geom::Vec3;
|
||||
assert!((volume(s) - want).abs() < 1e-6, "{} vs {want}", volume(s));
|
||||
let state = s.model.state();
|
||||
assert_eq!(state.sheets.len(), 1);
|
||||
assert_eq!(state.sheets[0].t, 2.5, "rule stock gauge is preserved");
|
||||
assert!(state.bodies.iter().any(|b| b.body.contains(Vec3::new(25.0, 25.0, (z0 + z1) * 0.5))));
|
||||
assert!(!state.bodies.iter().any(|b| b.body.contains(Vec3::new(25.0, 25.0, z0 - 0.1))));
|
||||
assert!(!state.bodies.iter().any(|b| b.body.contains(Vec3::new(25.0, 25.0, z1 + 0.1))));
|
||||
assert!(!state.bodies.iter().any(|b| b.body.contains(Vec3::new(10.0, 25.0, (z0 + z1) * 0.5))));
|
||||
let bounds = state.bodies[0].mesh().bounds();
|
||||
assert!((bounds.min.z - z0).abs() < 1e-8 && (bounds.max.z - z1).abs() < 1e-8, "{bounds:?}");
|
||||
}
|
||||
|
||||
fn depth_step_reopen(s: &mut Session, suffix: &str, want: f64, inside: solvecraft_geom::Vec3, outside: solvecraft_geom::Vec3) {
|
||||
let path = std::env::temp_dir().join(format!("solvecraft-depth-intersect-{suffix}-{}.step", std::process::id()));
|
||||
run(s, "file.export", json!({"path":path.to_string_lossy()}));
|
||||
let mut imported = Session::default();
|
||||
run(&mut imported, "doc.open", json!({"path":path.to_string_lossy()}));
|
||||
assert!((volume(&mut imported) - want).abs() < 1e-6);
|
||||
assert!(imported.model.state().bodies.iter().any(|b| b.body.contains(inside)));
|
||||
assert!(!imported.model.state().bodies.iter().any(|b| b.body.contains(outside)));
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
/// The generic sketch importer intentionally drops DXF layers/depth. Reimport only the
|
||||
/// material outline at a separately known depth, never pretend all entities reconstruct3D.
|
||||
fn dxf_outline_only(text: &str) -> String {
|
||||
let lines: Vec<_> = text.lines().collect();
|
||||
let pairs: Vec<_> = lines.as_chunks::<2>().0.iter().filter_map(|p| Some((p.first()?.trim(), *p.get(1)?))).collect();
|
||||
let mut out = String::from("0\nSECTION\n2\nENTITIES\n");
|
||||
let mut start = 0;
|
||||
for end in 1..=pairs.len() {
|
||||
if end == pairs.len() || pairs.get(end).is_some_and(|p| p.0 == "0") {
|
||||
let entity = &pairs[start..end];
|
||||
if entity.first().is_some_and(|p| matches!(p.1, "LINE" | "ARC" | "CIRCLE")) && entity.iter().any(|p| p.0 == "8" && p.1 == "OUTLINE") {
|
||||
for (code, value) in entity {
|
||||
out.push_str(&format!("{code}\n{value}\n"));
|
||||
}
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
}
|
||||
out.push_str("0\nENDSEC\n0\nEOF\n");
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_depth_intersect_retains_bottom_top_and_middle_material_through_history_and_exports() {
|
||||
use solvecraft_geom::Vec3;
|
||||
for (offset, z0, z1, layers) in [
|
||||
(0.0, 0.0, 1.0, vec![("top", 1.0, 2.5)]),
|
||||
(1.5, 1.5, 2.5, vec![("bottom", 0.0, 1.5)]),
|
||||
(0.5, 0.5, 1.5, vec![("bottom", 0.0, 0.5), ("top", 1.5, 2.5)]),
|
||||
] {
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
rectangle_tool(&mut s, "RetainDepth", [20.0, 20.0], [30.0, 30.0]);
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"RetainDepth","distance":1,"start_offset":offset,"operation":"intersect","name":"RetainSlab"}));
|
||||
retained_depth_check(&mut s, 100.0, z0, z1);
|
||||
let exported = flat(&mut s);
|
||||
assert_eq!(exported["thickness"], 2.5);
|
||||
assert_eq!(exported["pockets"].as_array().unwrap().len(), layers.len());
|
||||
let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string();
|
||||
for (side, a, b) in &layers {
|
||||
assert!(
|
||||
exported["pockets"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|p| p["side"] == *side && p["z0_mm"] == *a && p["z1_mm"] == *b && p["depth_mm"] == b - a)
|
||||
);
|
||||
assert!(dxf.contains(&format!("POCKET_{}_Z{a:.6}_TO_{b:.6}_MM", side.to_uppercase())));
|
||||
}
|
||||
let outline = dxf_outline_only(&dxf);
|
||||
assert_eq!(solvecraft_io::read_dxf(&outline).unwrap().len(), 4);
|
||||
let mut outline_import = Session::default();
|
||||
run(&mut outline_import, "sketch.insert_dxf", json!({"text":outline,"name":"Outline"}));
|
||||
run(&mut outline_import, "sketch.finish", json!({}));
|
||||
run(&mut outline_import, "solid.extrude", json!({"distance":1}));
|
||||
assert!((volume(&mut outline_import) - 100.0).abs() < 1e-6);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!((volume(&mut s) - 15000.0).abs() < 1e-6);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
for command in ["sheet.unfold", "sheet.refold"] {
|
||||
run(&mut s, command, json!({}));
|
||||
retained_depth_check(&mut s, 100.0, z0, z1);
|
||||
assert_eq!(flat(&mut s)["pockets"], exported["pockets"]);
|
||||
}
|
||||
let mut reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
retained_depth_check(&mut reopened, 100.0, z0, z1);
|
||||
depth_step_reopen(&mut s, &format!("offset{offset}"), 100.0, Vec3::new(25.0, 25.0, (z0 + z1) * 0.5), Vec3::new(25.0, 25.0, z1 + 0.1));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_depth_intersect_preserves_prior_pockets_and_actual_interval_intersections() {
|
||||
use solvecraft_geom::Vec3;
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
rectangle_tool(&mut s, "OldPocket", [20.0, 20.0], [25.0, 30.0]);
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"OldPocket","distance":1,"operation":"cut"}));
|
||||
rectangle_tool(&mut s, "RetainDepth", [20.0, 20.0], [30.0, 30.0]);
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"RetainDepth","distance":1,"start_offset":0.5,"operation":"intersect"}));
|
||||
for command in [None, Some("sheet.unfold"), Some("sheet.refold")] {
|
||||
if let Some(command) = command {
|
||||
run(&mut s, command, json!({}));
|
||||
}
|
||||
assert!((volume(&mut s) - 75.0).abs() < 1e-6);
|
||||
let state = s.model.state();
|
||||
let body = &state.bodies[0].body;
|
||||
assert!(!body.contains(Vec3::new(22.0, 25.0, 0.75)));
|
||||
assert!(body.contains(Vec3::new(22.0, 25.0, 1.25)));
|
||||
assert!(body.contains(Vec3::new(27.0, 25.0, 0.75)));
|
||||
assert!(!body.contains(Vec3::new(27.0, 25.0, 0.25)));
|
||||
}
|
||||
let records = s.model.state().sheets[0].pockets.clone();
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"RetainDepth","distance":1.5,"start_offset":0.25,"operation":"intersect"}));
|
||||
assert!((volume(&mut s) - 75.0).abs() < 1e-6);
|
||||
assert_eq!(s.model.state().sheets[0].pockets, records, "no-change leaves replay work unchanged");
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"RetainDepth","distance":0.25,"start_offset":1.0,"operation":"intersect"}));
|
||||
assert!((volume(&mut s) - 25.0).abs() < 1e-6);
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
assert!((volume(&mut s) - 25.0).abs() < 1e-6);
|
||||
let mut cold = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert!((volume(&mut cold) - 25.0).abs() < 1e-6);
|
||||
depth_step_reopen(&mut s, "prior", 25.0, Vec3::new(22.0, 25.0, 1.125), Vec3::new(22.0, 25.0, 0.75));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_depth_intersect_keeps_disconnected_tool_islands_and_annular_curves() {
|
||||
use solvecraft_geom::Vec3;
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
rectangle_tool(&mut s, "Islands", [20.0, 20.0], [30.0, 30.0]);
|
||||
run(&mut s, "sketch.edit", json!({"sketch":"Islands"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[60,20],"p1":[70,30]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"Islands","distance":1,"operation":"intersect"}));
|
||||
for command in [None, Some("sheet.unfold"), Some("sheet.refold")] {
|
||||
if let Some(command) = command {
|
||||
run(&mut s, command, json!({}));
|
||||
}
|
||||
assert!((volume(&mut s) - 200.0).abs() < 1e-6);
|
||||
assert_eq!(s.model.state().sheets[0].flat_regions().unwrap().len(), 2);
|
||||
for x in [25.0, 65.0] {
|
||||
assert!(s.model.state().bodies[0].body.contains(Vec3::new(x, 25.0, 0.5)));
|
||||
}
|
||||
assert!(!s.model.state().bodies[0].body.contains(Vec3::new(45.0, 25.0, 0.5)));
|
||||
}
|
||||
depth_step_reopen(&mut s, "islands", 200.0, Vec3::new(65.0, 25.0, 0.5), Vec3::new(45.0, 25.0, 0.5));
|
||||
let mut ring = Session::default();
|
||||
base(&mut ring, 100.0, 60.0);
|
||||
run(&mut ring, "sketch.create", json!({"plane":"XY","name":"Ring"}));
|
||||
for radius in [10.0, 5.0] {
|
||||
run(&mut ring, "sketch.circle.center", json!({"center":[50,30],"radius":radius}));
|
||||
}
|
||||
run(&mut ring, "sketch.finish", json!({}));
|
||||
run(&mut ring, "solid.extrude", json!({"sketch":"Ring","profiles":[{"point":[57,30]}],"distance":1,"operation":"intersect"}));
|
||||
for command in [None, Some("sheet.unfold"), Some("sheet.refold")] {
|
||||
if let Some(command) = command {
|
||||
run(&mut ring, command, json!({}));
|
||||
}
|
||||
let state = ring.model.state();
|
||||
let area: f64 = state.sheets[0].flat_regions().unwrap().iter().map(|r| r.area()).sum();
|
||||
assert!((area - 75.0 * PI).abs() < 1e-8);
|
||||
assert!((volume(&mut ring) - 75.0 * PI).abs() < 0.1);
|
||||
assert!(state.bodies[0].body.contains(Vec3::new(57.0, 30.0, 0.5)));
|
||||
assert!(!state.bodies[0].body.contains(Vec3::new(50.0, 30.0, 0.5)));
|
||||
assert!(!state.bodies[0].body.contains(Vec3::new(57.0, 30.0, 1.5)));
|
||||
}
|
||||
let dxf = run(&mut ring, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string();
|
||||
assert!(dxf.contains("POCKET_TOP_Z1.000000_TO_2.500000_MM"));
|
||||
assert_eq!(solvecraft_io::read_dxf(&dxf_outline_only(&dxf)).unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_depth_intersect_deletes_material_already_removed_by_a_pocket_atomically() {
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
rectangle_tool(&mut s, "Removed", [20.0, 20.0], [30.0, 30.0]);
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"Removed","distance":1,"operation":"cut"}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"Removed","distance":1,"operation":"intersect"}));
|
||||
assert!(s.model.state().bodies.is_empty());
|
||||
assert!(s.model.state().sheets.is_empty());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!((volume(&mut s) - 14900.0).abs() < 1e-6);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert!(s.model.state().bodies.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_depth_intersect_flipped_tool_and_transferred_sheet_keep_depth_side_and_world_pose() {
|
||||
use solvecraft_geom::Vec3;
|
||||
let mut flipped = Session::default();
|
||||
base(&mut flipped, 100.0, 60.0);
|
||||
run(&mut flipped, "sketch.create", json!({"name":"FlippedTool","plane":{"origin":[0,0,2.5],"x_dir":[1,0,0],"y_dir":[0,-1,0]}}));
|
||||
run(&mut flipped, "sketch.rectangle.two_point", json!({"p0":[20,-30],"p1":[30,-20]}));
|
||||
run(&mut flipped, "sketch.finish", json!({}));
|
||||
run(&mut flipped, "solid.extrude", json!({"sketch":"FlippedTool","distance":1,"operation":"intersect"}));
|
||||
retained_depth_check(&mut flipped, 100.0, 1.5, 2.5);
|
||||
assert_eq!(flat(&mut flipped)["pockets"][0]["side"], "bottom");
|
||||
assert_eq!(flat(&mut flipped)["pockets"][0]["depth_mm"], 1.5);
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
rectangle_tool(&mut s, "RetainDepth", [20.0, 20.0], [30.0, 30.0]);
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"RetainDepth","distance":1,"start_offset":0.5,"operation":"intersect","name":"RetainSlab"}));
|
||||
let before = s.world_state().bodies[0].mesh().bounds();
|
||||
let name = s.model.state().sheets[0].body.clone();
|
||||
let destination = run(&mut s, "component.create", json!({"name":"Destination","parent":0}));
|
||||
let owner = destination["component"].as_u64().unwrap();
|
||||
let occurrence = destination["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,20,10],"axis":[1,0,0],"angle":"90 deg","capture":true}));
|
||||
run(&mut s, "component.move_bodies", json!({"component":owner,"bodies":[name]}));
|
||||
for command in [None, Some("sheet.unfold"), Some("sheet.refold")] {
|
||||
if let Some(command) = command {
|
||||
run(&mut s, command, json!({}));
|
||||
}
|
||||
let state = s.model.state();
|
||||
let sh = &state.sheets[0];
|
||||
assert_eq!(sh.t, 2.5);
|
||||
assert!(sh.frame.origin.dist(Vec3::new(-30.0, -10.0, 20.0)) < 1e-8);
|
||||
assert!(sh.frame.normal().dist(Vec3::Y) < 1e-8);
|
||||
assert_eq!(sh.pockets.len(), 2);
|
||||
assert_eq!(state.body(&name).unwrap().component, owner);
|
||||
assert!(state.body(&name).unwrap().transferred);
|
||||
let world = s.world_state();
|
||||
let body = &world.body(&name).unwrap().body;
|
||||
assert!(body.contains(Vec3::new(25.0, 25.0, 1.0)));
|
||||
assert!(!body.contains(Vec3::new(25.0, 25.0, 0.25)));
|
||||
assert!(!body.contains(Vec3::new(25.0, 25.0, 2.0)));
|
||||
let bounds = world.body(&name).unwrap().mesh().bounds();
|
||||
assert!(before.min.dist(bounds.min) < 1e-8 && before.max.dist(bounds.max) < 1e-8);
|
||||
assert!((solvecraft_kernel::measure(body).unwrap().volume - 100.0).abs() < 1e-6);
|
||||
}
|
||||
let mut reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
let reopened_world = reopened.world_state();
|
||||
let body = &reopened_world.body(&name).unwrap().body;
|
||||
assert!(body.contains(Vec3::new(25.0, 25.0, 1.0)));
|
||||
assert!((solvecraft_kernel::measure(body).unwrap().volume - 100.0).abs() < 1e-6);
|
||||
run(&mut reopened, "timeline.edit", json!({"feature":"RetainSlab","set":{"extent":{"distance":"1.5"}}}));
|
||||
let world = reopened.world_state();
|
||||
let body = &world.body(&name).unwrap().body;
|
||||
assert!((solvecraft_kernel::measure(body).unwrap().volume - 150.0).abs() < 1e-6);
|
||||
assert!(body.contains(Vec3::new(25.0, 25.0, 1.75)));
|
||||
assert!(!body.contains(Vec3::new(25.0, 25.0, 2.25)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_depth_intersect_full_gauge_in_rotated_frames_does_not_invent_pockets() {
|
||||
use solvecraft_geom::Vec3;
|
||||
for (angle, translate) in [(37.0, [30.0, 20.0, 10.0]), (121.0, [12345.678, 98765.432, 4321.123])] {
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
let name = s.model.state().sheets[0].body.clone();
|
||||
let destination = run(&mut s, "component.create", json!({"name":"Destination","parent":0}));
|
||||
let owner = destination["component"].as_u64().unwrap();
|
||||
run(
|
||||
&mut s,
|
||||
"occurrence.move",
|
||||
json!({"occurrence":destination["occurrence"],"translate":translate,"axis":[1,2,3],"angle":format!("{angle} deg"),"capture":true}),
|
||||
);
|
||||
run(&mut s, "component.move_bodies", json!({"component":owner,"bodies":[name]}));
|
||||
run(&mut s, "component.activate", json!({"component":owner}));
|
||||
run(&mut s, "sketch.create", json!({"name":"WorldTool","plane":{"origin":[0,0,0],"x_dir":[1,0,0],"y_dir":[0,1,0]}}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[20,20],"p1":[30,30]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let mut tool_session = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
run(&mut tool_session, "solid.extrude", json!({"sketch":"WorldTool","distance":2.5,"body_name":"CapWitness"}));
|
||||
let witness_state = tool_session.model.state();
|
||||
let witness = &witness_state.body("CapWitness").unwrap().body;
|
||||
let tool_plane = witness_state.sketches.last().unwrap().plane;
|
||||
let sources = witness.face_sources().unwrap();
|
||||
assert_eq!(sources.len(), witness.face_count());
|
||||
let mut cap_count = 0;
|
||||
for (index, source) in sources.iter().enumerate() {
|
||||
if let Some(solvecraft_kernel::FaceSource::CertifiedExtrudeCap { high, plane, .. }) = source {
|
||||
cap_count += 1;
|
||||
let current = witness.planar_face(index).unwrap();
|
||||
assert!(current.normal().cross(tool_plane.normal()).len() < 1e-10);
|
||||
assert!((current.origin - plane.origin).dot(tool_plane.normal()).abs() < 1e-8);
|
||||
assert!(((plane.origin - tool_plane.origin).dot(tool_plane.normal()) - if *high { 2.5 } else { 0.0 }).abs() < 1e-8);
|
||||
}
|
||||
}
|
||||
assert_eq!(cap_count, 2);
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"WorldTool","distance":2.5,"operation":"intersect","name":"PostTransferRetain"}));
|
||||
for command in [None, Some("sheet.unfold"), Some("sheet.refold")] {
|
||||
if let Some(command) = command {
|
||||
run(&mut s, command, json!({}));
|
||||
}
|
||||
assert!(s.model.state().sheets[0].pockets.is_empty(), "full gauge must not acquire roundoff-depth pockets");
|
||||
let world = s.world_state();
|
||||
let body = &world.body(&name).unwrap().body;
|
||||
assert!((solvecraft_kernel::measure(body).unwrap().volume - 250.0).abs() < 1e-5);
|
||||
assert!(body.contains(Vec3::new(25.0, 25.0, 0.1)));
|
||||
assert!(body.contains(Vec3::new(25.0, 25.0, 2.4)));
|
||||
assert!(!body.contains(Vec3::new(10.0, 25.0, 1.0)));
|
||||
}
|
||||
let mut cold = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert!(cold.model.state().sheets[0].pockets.is_empty());
|
||||
assert!(flat(&mut cold)["pockets"].as_array().unwrap().is_empty());
|
||||
run(&mut s, "timeline.edit", json!({"feature":"PostTransferRetain","set":{"extent":{"distance":"1","start_offset":"0.5"}}}));
|
||||
for command in [None, Some("sheet.unfold"), Some("sheet.refold")] {
|
||||
if let Some(command) = command {
|
||||
run(&mut s, command, json!({}));
|
||||
}
|
||||
assert_eq!(s.model.state().sheets[0].pockets.len(), 2);
|
||||
let world = s.world_state();
|
||||
let body = &world.body(&name).unwrap().body;
|
||||
let measured = solvecraft_kernel::measure(body).unwrap().volume;
|
||||
assert!((measured - 100.0).abs() < 1e-5, "retained volume {measured}");
|
||||
assert!(body.contains(Vec3::new(25.0, 25.0, 1.0)));
|
||||
assert!(!body.contains(Vec3::new(25.0, 25.0, 0.1)));
|
||||
assert!(!body.contains(Vec3::new(25.0, 25.0, 2.4)));
|
||||
}
|
||||
let cold = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
let world = cold.world_state();
|
||||
let body = &world.body(&name).unwrap().body;
|
||||
assert!((solvecraft_kernel::measure(body).unwrap().volume - 100.0).abs() < 1e-5);
|
||||
assert!(body.contains(Vec3::new(25.0, 25.0, 1.0)));
|
||||
assert!(!body.contains(Vec3::new(25.0, 25.0, 0.1)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_depth_intersect_planar_to_object_retains_current_target_depth() {
|
||||
use solvecraft_geom::Vec3;
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
let name = s.model.state().sheets[0].body.clone();
|
||||
run(&mut s, "solid.box", json!({"corner":[200,0,1.5],"length":10,"width":10,"height":1,"body_name":"Roof"}));
|
||||
rectangle_tool(&mut s, "Tool", [20.0, 20.0], [30.0, 30.0]);
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"Tool","to":[205,5,1.5],"start_offset":0.5,"operation":"intersect","participants":[name]}));
|
||||
for command in [None, Some("sheet.unfold"), Some("sheet.refold")] {
|
||||
if let Some(command) = command {
|
||||
run(&mut s, command, json!({"body":name}));
|
||||
}
|
||||
let state = s.model.state();
|
||||
let body = &state.body(&name).unwrap().body;
|
||||
assert!((solvecraft_kernel::measure(body).unwrap().volume - 100.0).abs() < 1e-6);
|
||||
assert!(body.contains(Vec3::new(25.0, 25.0, 1.0)));
|
||||
assert!(!body.contains(Vec3::new(25.0, 25.0, 0.1)));
|
||||
assert!(!body.contains(Vec3::new(25.0, 25.0, 2.0)));
|
||||
assert_eq!(state.sheets[0].pockets.len(), 2);
|
||||
assert!((solvecraft_kernel::measure(&state.body("Roof").unwrap().body).unwrap().volume - 100.0).abs() < 1e-6);
|
||||
}
|
||||
let cold = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert!((solvecraft_kernel::measure(&cold.model.state().body(&name).unwrap().body).unwrap().volume - 100.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_depth_intersect_curved_to_object_refuses_unrepresentable_depth_atomically() {
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
let name = s.model.state().sheets[0].body.clone();
|
||||
run(&mut s, "solid.cylinder", json!({"base":[25,20,12],"axis":[0,1,0],"radius":11,"height":10,"body_name":"CurvedRoof"}));
|
||||
rectangle_tool(&mut s, "Tool", [24.0, 23.0], [26.0, 27.0]);
|
||||
let before = s.doc.to_json();
|
||||
let error =
|
||||
s.execute("solid.extrude", &json!({"sketch":"Tool","to":[36,25,12],"operation":"intersect","participants":[name]})).unwrap_err().to_string();
|
||||
assert!(error.contains("sheet machining"), "{error}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
for command in [None, Some("sheet.unfold"), Some("sheet.refold")] {
|
||||
if let Some(command) = command {
|
||||
run(&mut s, command, json!({"body":name}));
|
||||
}
|
||||
let state = s.model.state();
|
||||
assert!((solvecraft_kernel::measure(&state.body(&name).unwrap().body).unwrap().volume - 15000.0).abs() < 1e-6);
|
||||
assert!(state.sheets[0].pockets.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_depth_intersect_curved_to_object_preserves_current_trimmed_cap_footprint() {
|
||||
use solvecraft_geom::Vec3;
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
let name = s.model.state().sheets[0].body.clone();
|
||||
run(&mut s, "solid.cylinder", json!({"base":[25,25,0],"axis":[0,0,1],"radius":2,"height":20,"body_name":"CurvedRoof"}));
|
||||
run(&mut s, "solid.box", json!({"corner":[19,19,1],"length":12,"width":12,"height":1,"body_name":"StopPlate"}));
|
||||
run(&mut s, "solid.combine", json!({"target":"CurvedRoof","tools":["StopPlate"],"operation":"join"}));
|
||||
rectangle_tool(&mut s, "Tool", [20.0, 20.0], [30.0, 30.0]);
|
||||
let mut tool_session = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
run(&mut tool_session, "solid.extrude", json!({"sketch":"Tool","to":[27,25,10],"body_name":"TrimWitness"}));
|
||||
let ts = tool_session.model.state();
|
||||
let witness = &ts.body("TrimWitness").unwrap().body;
|
||||
assert!((solvecraft_kernel::measure(witness).unwrap().volume - (100.0 - 4.0 * std::f64::consts::PI)).abs() < 0.01);
|
||||
assert!(witness.contains(Vec3::new(21.0, 21.0, 0.5)));
|
||||
assert!(!witness.contains(Vec3::new(25.0, 25.0, 0.5)));
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"Tool","to":[27,25,10],"operation":"intersect","participants":[name]}));
|
||||
for command in [None, Some("sheet.unfold"), Some("sheet.refold")] {
|
||||
if let Some(command) = command {
|
||||
run(&mut s, command, json!({"body":name}));
|
||||
}
|
||||
let state = s.model.state();
|
||||
let body = &state.body(&name).unwrap().body;
|
||||
let measured = solvecraft_kernel::measure(body).unwrap().volume;
|
||||
assert!((measured - (100.0 - 4.0 * std::f64::consts::PI)).abs() < 0.01);
|
||||
let area: f64 = state.sheets[0].flat_regions().unwrap().iter().map(|r| r.area()).sum();
|
||||
assert!((area - (100.0 - 4.0 * std::f64::consts::PI)).abs() < 1e-8);
|
||||
assert!(body.contains(Vec3::new(21.0, 21.0, 0.5)));
|
||||
assert!(!body.contains(Vec3::new(25.0, 25.0, 0.5)));
|
||||
assert!(!body.contains(Vec3::new(21.0, 21.0, 1.5)));
|
||||
}
|
||||
let cold = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert!(!cold.model.state().body(&name).unwrap().body.contains(Vec3::new(25.0, 25.0, 0.5)));
|
||||
let dxf = run(&mut s, "sheet.export_dxf", json!({"body":name}))["dxf"].as_str().unwrap().to_string();
|
||||
assert!(dxf.contains("POCKET_TOP_Z1.000000_TO_2.500000_MM"));
|
||||
let outline = dxf_outline_only(&dxf);
|
||||
assert_eq!(solvecraft_io::read_dxf(&outline).unwrap().len(), 5);
|
||||
let mut outline_import = Session::default();
|
||||
run(&mut outline_import, "sketch.insert_dxf", json!({"text":outline,"name":"Outline"}));
|
||||
run(&mut outline_import, "sketch.finish", json!({}));
|
||||
run(&mut outline_import, "solid.extrude", json!({"distance":1,"profiles":[{"point":[21,21]}]}));
|
||||
assert!((volume(&mut outline_import) - (100.0 - 4.0 * std::f64::consts::PI)).abs() < 0.01);
|
||||
assert!(!outline_import.model.state().bodies[0].body.contains(Vec3::new(25.0, 25.0, 0.5)));
|
||||
let path = std::env::temp_dir().join(format!("solvecraft-current-trim-cap-{}.step", std::process::id()));
|
||||
run(&mut s, "file.export", json!({"path":path.to_string_lossy(),"bodies":[name]}));
|
||||
let mut imported = Session::default();
|
||||
run(&mut imported, "doc.open", json!({"path":path.to_string_lossy()}));
|
||||
assert!((volume(&mut imported) - (100.0 - 4.0 * std::f64::consts::PI)).abs() < 0.01);
|
||||
assert!(!imported.model.state().bodies[0].body.contains(Vec3::new(25.0, 25.0, 0.5)));
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_depth_intersect_sub_extrusion_resolution_layers_refuse_atomically() {
|
||||
for (distance, offset) in [(2.5 - 1e-8, 1e-8), (2.5 - 1e-8, 0.0), (1e-8, 0.0)] {
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
let name = s.model.state().sheets[0].body.clone();
|
||||
let destination = run(&mut s, "component.create", json!({"name":"Far","parent":0}));
|
||||
let owner = destination["component"].as_u64().unwrap();
|
||||
run(
|
||||
&mut s,
|
||||
"occurrence.move",
|
||||
json!({"occurrence":destination["occurrence"],"translate":[12345.678,98765.432,4321.123],"axis":[1,2,3],"angle":"121 deg","capture":true}),
|
||||
);
|
||||
run(&mut s, "component.move_bodies", json!({"component":owner,"bodies":[name]}));
|
||||
run(&mut s, "component.activate", json!({"component":owner}));
|
||||
run(&mut s, "sketch.create", json!({"name":"Tool","plane":{"origin":[0,0,0],"x_dir":[1,0,0],"y_dir":[0,1,0]}}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[20,20],"p1":[30,30]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let before = s.doc.to_json();
|
||||
let error = s
|
||||
.execute("solid.extrude", &json!({"sketch":"Tool","distance":distance,"start_offset":offset,"operation":"intersect"}))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("resolution") || error.contains("distance is zero") || error.contains("sheet machining"), "{error}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert!(s.model.state().sheets[0].pockets.is_empty());
|
||||
assert!((solvecraft_kernel::measure(&s.world_state().body(&name).unwrap().body).unwrap().volume - 15000.0).abs() < 1e-5);
|
||||
}
|
||||
}
|
||||
@@ -873,7 +873,7 @@ fn arc_center(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let end = c.pos() + Vec2::from_angle((a.pos() - c.pos()).angle() + sw.to_radians()) * a.pos().dist(c.pos());
|
||||
let cid = match &id {
|
||||
Some(i) => i.clone(),
|
||||
None => sk.fresh("a"),
|
||||
None => sk.fresh("a")?,
|
||||
};
|
||||
let ai = match a.idx() {
|
||||
Some(i) => i,
|
||||
@@ -1748,6 +1748,26 @@ fn sketch_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, format!("unknown sketch entity `{id}`")));
|
||||
}
|
||||
}
|
||||
let selected_curves = curves.iter().copied().collect::<std::collections::BTreeSet<_>>();
|
||||
let selected_points = points.iter().copied().collect::<std::collections::BTreeSet<_>>();
|
||||
let removed = sk
|
||||
.curves
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(index, curve)| selected_curves.contains(index) || curve.kind.point_ids().iter().any(|point| selected_points.contains(point)))
|
||||
.map(|(_, curve)| curve.id.as_str())
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
for offset in &sk.offsets {
|
||||
let Some(topology) = &offset.topology else { continue };
|
||||
if offset.outputs.iter().any(|id| removed.contains(id.as_str()))
|
||||
&& topology
|
||||
.roles
|
||||
.iter()
|
||||
.any(|role| !role.retired && role.kind == solvecraft_sketch::OffsetRoleKind::Arc && !removed.contains(role.output.as_str()))
|
||||
{
|
||||
return Err(bad(cmd, "break the offset association before deleting only part of its curved boundary"));
|
||||
}
|
||||
}
|
||||
sk.constraints.retain(|c| !cons.contains(&c.id));
|
||||
let pids: Vec<String> = points.iter().filter_map(|q| sk.points.get(*q).map(|x| x.id.clone())).collect();
|
||||
sk.remove_curves(&curves);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
use std::f64::consts::TAU;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::ProfileSel;
|
||||
use solvecraft_geom::Vec2;
|
||||
use solvecraft_sketch::{ConstraintKind, Curve, CurveKind, Shape, Sketch, intersections};
|
||||
|
||||
@@ -95,7 +96,9 @@ fn curve_arg(sk: &Sketch, p: &Value, cmd: &str) -> Result<usize> {
|
||||
if sk.is_linked_curve(c) {
|
||||
return Err(bad(cmd, format!("`{id}` is projected geometry: break its link first")));
|
||||
}
|
||||
if sk.freeform_offsets.iter().any(|offset| offset.output == id) {
|
||||
if sk.freeform_offsets.iter().any(|offset| offset.output == id)
|
||||
|| sk.offsets.iter().filter_map(|offset| offset.topology.as_ref()).any(|topology| topology.roles.iter().any(|role| role.output == id))
|
||||
{
|
||||
return Err(bad(cmd, "break the offset association before editing its output"));
|
||||
}
|
||||
Ok(c)
|
||||
@@ -184,7 +187,7 @@ fn split_at(sk: &mut Sketch, ci: usize, ts: &[f64]) -> Result<(Vec<usize>, Vec<u
|
||||
}
|
||||
let mut pieces = vec![ci];
|
||||
let mut push = |sk: &mut Sketch, kind: CurveKind, prefix: &str| -> Result<()> {
|
||||
let id = sk.fresh(prefix);
|
||||
let id = sk.fresh(prefix)?;
|
||||
sk.curves.push(Curve { id, kind, construction, reversed: false, link: None, centerline: false, fixed: false });
|
||||
pieces.push(sk.curves.len() - 1);
|
||||
Ok(())
|
||||
@@ -549,7 +552,7 @@ fn keep_corner(sk: &mut Sketch, c: usize, [(ca, na), (cb, nb)]: [(usize, usize);
|
||||
let virtual_corner = if used || c == 0 {
|
||||
None
|
||||
} else {
|
||||
let id = sk.fresh("corner");
|
||||
let id = sk.fresh("corner")?;
|
||||
if let Some(pt) = sk.points.get_mut(c) {
|
||||
pt.id = id.clone();
|
||||
}
|
||||
@@ -934,6 +937,14 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
sk.refresh_offsets()?;
|
||||
let sel: Vec<usize> = ids.iter().map(|i| sk.curve_index(i).ok_or_else(|| bad(cmd, format!("unknown curve `{i}`")))).collect::<Result<_>>()?;
|
||||
let first = *sel.first().ok_or_else(|| bad(cmd, "no curves"))?;
|
||||
if sk.offsets.iter().filter_map(|offset| offset.topology.as_ref()).any(|topology| {
|
||||
topology.roles.iter().any(|role| role.retired) && topology.roles.iter().any(|role| !role.retired && ids.contains(&role.output))
|
||||
}) {
|
||||
return Err(bad(
|
||||
cmd,
|
||||
"offsets of a boundary with retired corner arcs are not supported yet; break its offset association to use the current fixed topology",
|
||||
));
|
||||
}
|
||||
let mut made = Vec::new();
|
||||
let mut cons = Vec::new();
|
||||
// Circles offset on their own.
|
||||
@@ -954,8 +965,9 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
outputs: ids_of(sk, &made),
|
||||
distance: r - nr,
|
||||
closed: true,
|
||||
topology: None,
|
||||
});
|
||||
return Ok((ids_of(sk, &made), cons));
|
||||
return Ok((ids_of(sk, &made), cons, None));
|
||||
}
|
||||
let pool: Vec<usize> = if use_chain {
|
||||
(0..sk.curves.len())
|
||||
@@ -986,7 +998,31 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let (source, forward) = order[0];
|
||||
let output = sk.add_freeform_offset(source, forward, d)?;
|
||||
return Ok((ids_of(sk, &[output]), cons));
|
||||
return Ok((ids_of(sk, &[output]), cons, None));
|
||||
}
|
||||
let sources = order
|
||||
.iter()
|
||||
.map(|(index, forward)| {
|
||||
sk.curves.get(*index).map(|curve| (curve.id.clone(), *forward)).ok_or_else(|| bad(cmd, "offset chain source vanished"))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
if closed && sk.convex_offset_supported(&sources)? {
|
||||
let created = sk.add_convex_offset(sources.clone(), d)?;
|
||||
let roles = sk
|
||||
.offsets
|
||||
.iter()
|
||||
.filter_map(|offset| offset.topology.as_ref())
|
||||
.find(|topology| topology.id == created.association)
|
||||
.map(|topology| topology.roles.clone())
|
||||
.ok_or_else(|| bad(cmd, "new offset association is missing"))?;
|
||||
for (source, role) in sources.iter().zip(&roles) {
|
||||
if role.kind == solvecraft_sketch::OffsetRoleKind::Line {
|
||||
let a = sk.curve_index(&source.0).ok_or_else(|| bad(cmd, "offset source line vanished"))?;
|
||||
let b = sk.curve_index(&role.output).ok_or_else(|| bad(cmd, "offset output line vanished"))?;
|
||||
cons.push(add_c(sk, ConstraintKind::Parallel { a, b })?);
|
||||
}
|
||||
}
|
||||
return Ok((created.curves, cons, Some(created.association)));
|
||||
}
|
||||
let pieces: Vec<Piece> =
|
||||
order.iter().map(|(c, f)| offset_piece(sk, *c, *f, d)).collect::<Option<_>>().ok_or_else(|| bad(cmd, "the offset collapses an arc"))?;
|
||||
@@ -1072,23 +1108,32 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let outputs = ids_of(sk, &made);
|
||||
if pieces.iter().all(|p| !matches!(p, Piece::Free(_))) {
|
||||
let sources = order.iter().filter_map(|(i, forward)| sk.curves.get(*i).map(|c| (c.id.clone(), *forward))).collect();
|
||||
sk.offsets.push(solvecraft_sketch::OffsetChain { sources, outputs: outputs.clone(), distance: d, closed });
|
||||
sk.offsets.push(solvecraft_sketch::OffsetChain { sources, outputs: outputs.clone(), distance: d, closed, topology: None });
|
||||
sk.refresh_offsets()?;
|
||||
}
|
||||
Ok((outputs, cons))
|
||||
Ok((outputs, cons, None))
|
||||
})?;
|
||||
Ok(json!({"curves": out.0, "constraints": out.1, "sketch": info}))
|
||||
let mut response = json!({"curves":out.0,"constraints":out.1,"sketch":info});
|
||||
if let Some(association) = out.2 {
|
||||
response["association"] = json!(association);
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn break_offset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.offset.break";
|
||||
let sketch = super::sketch::target_sketch(s, p, cmd)?;
|
||||
let (out, info) = edit(s, p, cmd, true, |sk, doc| {
|
||||
let output = str_(p, "curve").ok_or_else(|| bad(cmd, "`curve` must name an offset output"))?;
|
||||
let record = sk
|
||||
.freeform_offsets
|
||||
let record = sk.freeform_offsets.iter().position(|record| record.output == output);
|
||||
let topology = sk
|
||||
.offsets
|
||||
.iter()
|
||||
.position(|record| record.output == output)
|
||||
.ok_or_else(|| bad(cmd, "that curve has no free-form offset association"))?;
|
||||
.filter_map(|record| record.topology.as_ref())
|
||||
.any(|record| record.id == output || record.roles.iter().any(|role| role.output == output));
|
||||
if record.is_none() && !topology {
|
||||
return Err(bad(cmd, "curve is not an offset output"));
|
||||
}
|
||||
// A parameter edit may have changed the evaluated source without
|
||||
// rewriting the stored fit points. Break must freeze the displayed,
|
||||
// current dimensioned geometry, rather than its stale stored predecessor.
|
||||
@@ -1102,6 +1147,19 @@ fn break_offset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, "cannot break an offset with conflicting derived dimensions"));
|
||||
}
|
||||
sk.validate_periodic_splines()?;
|
||||
if let Some(association) = sk
|
||||
.offsets
|
||||
.iter()
|
||||
.filter_map(|record| record.topology.as_ref())
|
||||
.find(|record| record.id == output || record.roles.iter().any(|role| role.output == output))
|
||||
.map(|record| record.id.clone())
|
||||
{
|
||||
freeze_offset_profiles(doc, sketch, sk, &association)?;
|
||||
}
|
||||
if let Some(curves) = sk.break_offset_topology(output)? {
|
||||
return Ok((output.to_string(), Some(curves)));
|
||||
}
|
||||
let record = record.ok_or_else(|| bad(cmd, "offset association changed while materializing its current geometry"))?;
|
||||
let ci = sk.curve_index(output).ok_or_else(|| bad(cmd, "missing offset output"))?;
|
||||
let indices = sk.curves[ci].kind.point_ids();
|
||||
sk.freeform_offsets.remove(record);
|
||||
@@ -1110,9 +1168,74 @@ fn break_offset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
point.fixed = false;
|
||||
}
|
||||
}
|
||||
Ok(output.to_string())
|
||||
Ok((output.to_string(), None))
|
||||
})?;
|
||||
Ok(json!({"curve":out,"sketch":info}))
|
||||
let mut response = json!({"curve":out.0,"sketch":info});
|
||||
if let Some(curves) = out.1 {
|
||||
response["curves"] = json!(curves);
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// A broken logical boundary becomes ordinary geometry. Keep downstream features
|
||||
/// on that exact current boundary while other associations retain their full roles.
|
||||
fn freeze_offset_profiles(doc: &mut solvecraft_doc::Document, sketch: u64, boundary: &Sketch, association: &str) -> Result<()> {
|
||||
let cmd = "sketch.offset.break";
|
||||
let mut groups = std::collections::BTreeMap::new();
|
||||
let mut work = 0usize;
|
||||
for offset in &boundary.offsets {
|
||||
let Some(topology) = &offset.topology else { continue };
|
||||
work = work.saturating_add(topology.roles.len());
|
||||
if groups.len() >= 128 || topology.roles.len() > 512 || work > 1_000_000 {
|
||||
return Err(bad(cmd, "too many offset roles to freeze safely"));
|
||||
}
|
||||
let reserved = topology.roles.iter().map(|role| role.output.clone()).collect::<Vec<_>>();
|
||||
if groups.insert(topology.id.clone(), (offset.outputs.clone(), reserved)).is_some() {
|
||||
return Err(bad(cmd, "ambiguous offset association identity"));
|
||||
}
|
||||
}
|
||||
let (active, reserved) = groups.get(association).ok_or_else(|| bad(cmd, "offset association disappeared before freezing"))?;
|
||||
let mut reserved_sorted = reserved.clone();
|
||||
reserved_sorted.sort();
|
||||
for feature in &mut doc.features {
|
||||
for (source, selection) in feature.kind.profile_sels_mut() {
|
||||
if source != sketch {
|
||||
continue;
|
||||
}
|
||||
match selection {
|
||||
ProfileSel::OffsetBoundaries { associations } if associations.iter().any(|id| id == association) => {
|
||||
let mut loops = Vec::with_capacity(associations.len());
|
||||
for id in associations.iter() {
|
||||
let (current, all_roles) = groups.get(id).ok_or_else(|| bad(cmd, format!("cannot freeze missing offset boundary `{id}`")))?;
|
||||
let roles = if id == association { current } else { all_roles };
|
||||
work = work.saturating_add(roles.len());
|
||||
if work > 1_000_000 {
|
||||
return Err(bad(cmd, "too many downstream offset references to freeze safely"));
|
||||
}
|
||||
loops.push(roles.clone());
|
||||
}
|
||||
*selection = ProfileSel::Curves { loops };
|
||||
}
|
||||
ProfileSel::Curves { loops } => {
|
||||
for ids in loops {
|
||||
work = work.saturating_add(ids.len());
|
||||
if work > 1_000_000 {
|
||||
return Err(bad(cmd, "too many downstream curve references to freeze safely"));
|
||||
}
|
||||
if ids.len() == reserved_sorted.len() {
|
||||
let mut sorted = ids.clone();
|
||||
sorted.sort();
|
||||
if sorted == reserved_sorted {
|
||||
*ids = active.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@@ -1199,7 +1322,7 @@ fn copy_through(
|
||||
}
|
||||
}
|
||||
};
|
||||
let id = sk.fresh(prefix);
|
||||
let id = sk.fresh(prefix)?;
|
||||
sk.curves.push(Curve {
|
||||
id,
|
||||
kind,
|
||||
|
||||
@@ -826,3 +826,88 @@ fn analytic_offset_partial_delete_releases_survivors_preserves_source_and_undo()
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn periodic_offset_extrusion_survives_post_extrude_span_changes_and_cold_reopen() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.ellipse", json!({"center":[0,0],"major":[10,0],"minor_radius":5}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities":["e1"],"type":"major","value":10}));
|
||||
let a = run(&mut s, "sketch.offset", json!({"curves":["e1"],"distance":-1,"chain":false}))["curves"][0].as_str().unwrap().to_string();
|
||||
run(&mut s, "sketch.offset", json!({"curves":[a],"distance":-0.5,"chain":false}));
|
||||
let sk_id = s.active_sketch.unwrap();
|
||||
let initial = solvecraft_sketch::find_profiles(&sketch(&s));
|
||||
let (index, ring) = initial.iter().enumerate().min_by(|(_, a), (_, b)| a.area.total_cmp(&b.area)).unwrap();
|
||||
let initial_count = ring.outer_curves.len();
|
||||
let boundary = ring.whole_curve_boundaries().unwrap();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch":sk_id,"profiles":[index],"distance":3,"operation":"new"}));
|
||||
let initial_doc = s.doc.to_json();
|
||||
for major in [12.0, 16.0, 20.0] {
|
||||
run(&mut s, "parameters.change", json!({"name":"d1","expression":major}));
|
||||
let state = s.world_state();
|
||||
let sk = state.sketch(sk_id).unwrap();
|
||||
let ring = sk.profiles.iter().find(|p| p.whole_curve_boundaries().as_ref() == Some(&boundary)).unwrap();
|
||||
assert_ne!(ring.outer_curves.len(), initial_count, "the repro must actually change graph span multiplicity");
|
||||
// Length-only intent leaves the centre and axis pose free. Measure that rigid frame,
|
||||
// then derive independent ellipse normal probes (not fitted-output points).
|
||||
let CurveKind::Ellipse { c, m, r } = sk.sketch.curves[sk.sketch.curve_index("e1").unwrap()].kind else { panic!("ellipse") };
|
||||
let centre = sk.sketch.point(c).unwrap();
|
||||
let axis = sk.sketch.point(m).unwrap() - centre;
|
||||
assert!((axis.len() - major).abs() < 1e-6);
|
||||
assert!((r - 5.0).abs() < 1e-6);
|
||||
let u = axis / axis.len();
|
||||
let v = Vec2::new(-u.y, u.x);
|
||||
let t: f64 = 0.2;
|
||||
let ellipse = centre + u * (major * t.cos()) + v * (5.0 * t.sin());
|
||||
let gradient = u * (t.cos() / major) + v * (t.sin() / 5.0);
|
||||
let normal = gradient / gradient.len();
|
||||
eprintln!("major={major} centre={centre:?} axis={axis:?}");
|
||||
assert!(ring.region.contains(ellipse + normal * 1.25), "major={major}, ring area={}", ring.area);
|
||||
assert!(!ring.region.contains(ellipse + normal * 0.5));
|
||||
assert!(!ring.region.contains(ellipse + normal * 1.75));
|
||||
let n = 4096;
|
||||
let h = std::f64::consts::TAU / n as f64;
|
||||
let perimeter = (0..=n)
|
||||
.map(|i| {
|
||||
let t = i as f64 * h;
|
||||
(major * major * t.sin().powi(2) + 25.0 * t.cos().powi(2)).sqrt()
|
||||
* if i == 0 || i == n {
|
||||
1.0
|
||||
} else if i % 2 == 0 {
|
||||
2.0
|
||||
} else {
|
||||
4.0
|
||||
}
|
||||
})
|
||||
.sum::<f64>()
|
||||
* h
|
||||
/ 3.0;
|
||||
let expected = (0.5 * perimeter + 1.25 * std::f64::consts::PI) * 3.0;
|
||||
assert_eq!(state.bodies.len(), 1);
|
||||
let body = &state.bodies[0].body;
|
||||
for (distance, material) in [(1.25, true), (0.5, false), (1.75, false)] {
|
||||
let point = ellipse + normal * distance;
|
||||
assert_eq!(body.contains(solvecraft_geom::Vec3::new(point.x, point.y, 1.5)), material);
|
||||
}
|
||||
// Repeat at the exact major-axis seam as a physical material control.
|
||||
for (distance, material) in [(1.25, true), (0.5, false), (1.75, false)] {
|
||||
let point = centre + u * (major + distance);
|
||||
assert_eq!(ring.region.contains(point), material);
|
||||
assert_eq!(body.contains(solvecraft_geom::Vec3::new(point.x, point.y, 1.5)), material);
|
||||
}
|
||||
let volume = solvecraft_kernel::measure(body).unwrap().volume;
|
||||
assert!((volume - expected).abs() < 0.05, "{major}: {volume} vs independent {expected}");
|
||||
let mut cold = Session { doc: solvecraft_doc::format::read(&s.doc.to_json()).unwrap().into(), ..Default::default() };
|
||||
cold.refresh();
|
||||
let cold_state = cold.world_state();
|
||||
assert_eq!(cold_state.bodies.len(), 1);
|
||||
let cold_volume = solvecraft_kernel::measure(&cold_state.bodies[0].body).unwrap().volume;
|
||||
assert!((cold_volume - volume).abs() < 1e-8);
|
||||
let edited = s.doc.to_json();
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), initial_doc);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), edited);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user