feat: preserve adaptive sketch profiles and curved modeling workflows

This commit is contained in:
bherbruck committed 2026-10-11 09:06:25 -04:00
1 parent 4c4a0449a4
commit 4e595deff2
62 files changed
+7114 -362

No files matched your search

+67 -4
View File
@@ -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()));
}
+11 -16
View File
@@ -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);
+78 -16
View File
@@ -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, &params).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, &params).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, &params).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, &params).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", &params).is_err());
assert_eq!(session.doc.to_json(), saved);
assert_eq!(session.undo.len(), undo);
assert_eq!(session.redo.len(), redo);
}
}
+35
View File
@@ -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}));
+455
View File
@@ -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);
}
}
+21 -1
View File
@@ -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);
+138 -15
View File
@@ -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!({}));
}
}
+106 -14
View File
@@ -91,21 +91,113 @@ fn enclosure_datums_follow_wall_height_and_board_depth_with_material_proofs() {
}
#[test]
fn enclosure_inward_offset_collapse_reports_an_error_instead_of_a_stale_lip() {
fn enclosure_arc_extinction_keeps_a_real_sharp_inner_lip_after_resize_and_reopen() {
use solvecraft_doc::FeatureKind;
use solvecraft_geom::{Vec2, Vec3};
use solvecraft_sketch::CurveKind;
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();
run(&mut s, "parameters.change", json!({"name":"wall","expression":"3 mm"}));
// R4 outer corners leave R1 inner corners. The requested 1.4 mm inward
// offset needs a topology transition, which this offset operation refuses.
let lip = s.doc.features.iter().find(|f| f.name == "Lid lip" && matches!(f.kind, solvecraft_doc::FeatureKind::Sketch { .. })).unwrap();
let result = s.model.result(lip.id).unwrap();
assert!(result.error.as_deref().is_some_and(|error| error.contains("offset collapses an arc")), "{result:?}");
assert!(result.warning.as_deref().is_none_or(|warning| !warning.contains("identity is ambiguous")), "{result:?}");
let state = s.model.state();
assert!(state.sketch(lip.id).is_none());
assert!(!state.body("Lid").unwrap().mesh().contains(solvecraft_geom::Vec3::new(0., 27.2, 24.)));
let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
assert!(reopened.model.result(lip.id).unwrap().error.as_deref().is_some_and(|error| error.contains("offset collapses an arc")));
assert!(reopened.model.state().sketch(lip.id).is_none());
for (name, expression) in [("height", "30 mm"), ("board_d", "50 mm"), ("wall", "3 mm")] {
run(&mut s, "parameters.change", json!({"name":name,"expression":expression}));
}
let check = |session: &Session| {
let errors = session.model.results.iter().filter_map(|r| r.error.as_ref().map(|e| (&r.name, e))).collect::<Vec<_>>();
let warnings = session.model.results.iter().filter_map(|r| r.warning.as_ref().map(|e| (&r.name, e))).collect::<Vec<_>>();
assert!(errors.is_empty(), "errors {errors:?}");
// The earlier snap-fit operation retains its documented legacy split-face
// notice. Offset/projection failures or frozen borders are not accepted.
assert!(
warnings.iter().all(|(name, message)| name.as_str() == "SnapFit1"
&& message.as_str() == "face `F11:side:bs#7` was split; using the piece nearest the picked point"),
"warnings {warnings:?}"
);
assert!(session.model.results.iter().filter(|r| r.name == "Lid lip").all(|r| r.warning.is_none()));
let state = session.model.state();
let feature = session.doc.features.iter().find(|f| f.name == "Lid lip" && matches!(f.kind, FeatureKind::Extrude { .. })).unwrap();
let FeatureKind::Extrude { sketch, profiles, .. } = &feature.kind else {
panic!("lip extrusion");
};
let solved = state.sketch(*sketch).unwrap();
assert!((solved.plane.origin.z - 30.).abs() < 1e-8);
let inner = &solved.sketch.offsets[1];
assert_eq!(inner.outputs.len(), 4);
let mut corners = Vec::new();
for id in &inner.outputs {
let curve = &solved.sketch.curves[solved.sketch.curve_index(id).unwrap()];
let CurveKind::Line { a, b } = curve.kind else {
panic!("extinct arcs must leave line supports: {curve:?}");
};
for point in [solved.sketch.points[a].pos, solved.sketch.points[b].pos] {
assert!((point.x.abs() - 36.6).abs() < 1e-8 && (point.y.abs() - 31.6).abs() < 1e-8, "{point:?}");
if !corners.iter().any(|p: &Vec2| p.dist(point) < 1e-8) {
corners.push(point);
}
}
}
assert_eq!(corners.len(), 4);
let regions = solvecraft_doc::select_profiles(solved, profiles).unwrap();
assert_eq!(regions.len(), 1);
assert!((regions[0].area() - 332.570619298295).abs() < 1e-7, "area {}", regions[0].area());
let ring = solvecraft_kernel::extrude(&solved.plane, &regions, 0., 2.).unwrap();
assert_eq!(ring.len(), 1);
let precise_ring = ring[0].tessellate(1e-6).unwrap();
let bounds = precise_ring.bounds();
for (actual, expected) in
[(bounds.min.x, -37.8), (bounds.max.x, 37.8), (bounds.min.y, -32.8), (bounds.max.y, 32.8), (bounds.min.z, 28.), (bounds.max.z, 30.)]
{
assert!((actual - expected).abs() < 1e-8, "ring bounds {bounds:?}");
}
let height = bounds.max.z - bounds.min.z;
assert!((height - 2.).abs() < 1e-8);
let analytic_volume = regions[0].area() * height;
assert!((analytic_volume - 665.141238596591).abs() < 2e-7, "analytic ring volume {analytic_volume}");
let arc_perimeter = regions[0]
.outer
.segs
.iter()
.map(|segment| match segment {
solvecraft_geom::Seg2::Arc { radius, sweep, .. } => radius.abs() * sweep.abs(),
solvecraft_geom::Seg2::Line { .. } => 0.,
other => panic!("unexpected ring boundary {other:?}"),
})
.sum::<f64>();
assert!((arc_perimeter - std::f64::consts::TAU * 0.8).abs() < 1e-8);
assert_eq!(regions[0].holes.len(), 1);
assert!(regions[0].holes[0].segs.iter().all(|segment| matches!(segment, solvecraft_geom::Seg2::Line { .. })));
// Body::tessellate floors the requested sag tolerance at size * 1e-6.
// Only the outer arcs incur chord error: their tolerance-wide boundary
// strip bounds the volume error without changing CAD or export tolerances.
let actual_tol = 1e-6_f64.max(ring[0].size() * 1e-6);
let discretization_bound = height * (arc_perimeter * actual_tol + std::f64::consts::PI * actual_tol.powi(2));
let measured = precise_ring.measure().volume.abs();
assert!(
(measured - analytic_volume).abs() <= discretization_bound,
"ring mesh volume {measured}, analytic {analytic_volume}, bound {discretization_bound}"
);
let lid = state.body("Lid").unwrap().body.tessellate(1e-4).unwrap();
for (point, material) in [
(Vec3::new(0., 32.2, 29.), true),
(Vec3::new(0., 32.9, 29.), false),
(Vec3::new(0., 31.5, 29.), false),
(Vec3::new(36.7, 31.7, 29.), true),
(Vec3::new(36.5, 31.5, 29.), false),
(Vec3::new(37.55, 32.55, 29.), true),
(Vec3::new(37.7, 32.7, 29.), false),
// These points expose the stale old rim and the old height.
(Vec3::new(0., 27.2, 29.), false),
(Vec3::new(0., 27.2, 24.), false),
] {
assert_eq!(precise_ring.contains(point), material, "standalone ring {point:?}");
assert_eq!(lid.contains(point), material, "joined lid {point:?}");
}
assert!(!lid.contains(Vec3::new(-33.5, -28.5, 32.)));
assert!(lid.contains(Vec3::new(-31.5, -28.5, 32.)));
};
check(&s);
let saved = s.doc.to_json();
let reopened = Session::new(solvecraft_doc::Document::from_json(&saved).unwrap());
check(&reopened);
assert_eq!(reopened.doc.to_json(), saved);
}
+44 -3
View File
@@ -73,6 +73,26 @@ pub fn grid_step(half_height: f64) -> (f64, f64) {
/// Sketch curves as world polylines with their colour.
pub fn sketch_lines(sk: &Sketch, plane: &Plane, active: bool, determined: &[bool]) -> Vec<(Vec<Vec3>, Rgb, bool)> {
sketch_lines_with_sampler(sk, plane, active, determined, world_curve_parts)
}
fn world_curve_parts(sk: &Sketch, i: usize, plane: &Plane) -> Vec<Vec<Vec3>> {
sk.segs(i).into_iter().map(|seg| seg.polyline(0.02).iter().map(|p| plane.to_world(*p)).collect()).collect()
}
/// Sketch display paths with the same visibility and styling as [`sketch_lines`].
///
/// The sampler receives each visible 2D curve once and returns world-coordinate
/// parts. Parts remain separate, so a view-dependent sampler can leave clipping
/// gaps without drawing a line across them. Already placed 3D wires retain their
/// existing paths. Sampling changes only display geometry, never the sketch.
pub fn sketch_lines_with_sampler(
sk: &Sketch,
plane: &Plane,
active: bool,
determined: &[bool],
mut sampler: impl FnMut(&Sketch, usize, &Plane) -> Vec<Vec<Vec3>>,
) -> Vec<(Vec<Vec3>, Rgb, bool)> {
let mut out = Vec::new();
for (i, c) in sk.curves.iter().enumerate() {
if c.link.is_some() && sk.view.hide_projected && !sk.is_text_curve(i) {
@@ -93,8 +113,8 @@ pub fn sketch_lines(sk: &Sketch, plane: &Plane, active: bool, determined: &[bool
} else {
colors::SKETCH
};
for seg in sk.segs(i) {
out.push((seg.polyline(0.02).iter().map(|p| plane.to_world(*p)).collect(), col, c.construction || c.centerline));
for part in sampler(sk, i, plane) {
out.push((part, col, c.construction || c.centerline));
}
}
// Virtual sharp corners (kept by fillets and chamfers): thin extension lines from the
@@ -137,6 +157,23 @@ pub fn scene(s: &Session, cam: &Camera) -> Scene {
/// the app passes the cut its viewport shows (an open Section Analysis dialog's preview, a
/// sketch's Slice), so model renders match the window.
pub fn scene_with_section(s: &Session, cam: &Camera, section: Option<(Vec3, Vec3)>) -> Scene {
scene_with_section_and_sketch_sampler(s, cam, section, world_curve_parts)
}
/// A headless scene using supplied display paths for its visible sketch curves.
/// Callers can use the same pixel sampler as a GPU viewport without changing the
/// body, grid, appearance, section or sketch styling policies of [`scene`].
pub fn scene_with_sketch_sampler(s: &Session, cam: &Camera, sampler: impl FnMut(&Sketch, usize, &Plane) -> Vec<Vec<Vec3>>) -> Scene {
scene_with_section_and_sketch_sampler(s, cam, s.section, sampler)
}
/// A headless scene with an explicit section and supplied sketch display paths.
pub fn scene_with_section_and_sketch_sampler(
s: &Session,
cam: &Camera,
section: Option<(Vec3, Vec3)>,
mut sampler: impl FnMut(&Sketch, usize, &Plane) -> Vec<Vec<Vec3>>,
) -> Scene {
let st = s.world_state();
let section = section.filter(|(o, n)| o.is_finite() && n.is_finite() && n.len() > 1e-9);
let mut sc = Scene {
@@ -215,7 +252,7 @@ pub fn scene_with_section(s: &Session, cam: &Camera, section: Option<(Vec3, Vec3
if !sketch_shown(s, ss.feature) {
continue;
}
for (pts, col, cons) in sketch_lines(&ss.sketch, &ss.plane, active, &ss.report.curve_determined) {
for (pts, col, cons) in sketch_lines_with_sampler(&ss.sketch, &ss.plane, active, &ss.report.curve_determined, &mut sampler) {
sc.lines.push(SceneLine { points: pts, color: col, width: if cons { 1.0 } else { 1.6 }, on_top: active });
}
}
@@ -402,3 +439,7 @@ mod tests {
}
}
}
#[cfg(test)]
#[path = "view_display_tests.rs"]
mod display_tests;
+142
View File
@@ -0,0 +1,142 @@
use super::*;
use serde_json::json;
use solvecraft_geom::Vec2;
use solvecraft_sketch::{LinkGeom, LinkKind, LinkSource};
fn line(sk: &mut Sketch, y: f64) -> usize {
sk.add_line(Vec2::new(0., y), Vec2::new(2., y), None, None, None).unwrap()
}
#[test]
fn sampler_keeps_style_precedence_hidden_references_text_and_disjoint_parts() {
let mut sk = Sketch::new();
let ordinary = line(&mut sk, 0.);
let fixed = line(&mut sk, 1.);
sk.curves[fixed].fixed = true;
sk.curves[fixed].construction = true;
let centerline = line(&mut sk, 2.);
sk.curves[centerline].centerline = true;
let construction = line(&mut sk, 3.);
sk.curves[construction].construction = true;
let determined = line(&mut sk, 4.);
let projected = sk.add_link(LinkKind::Project, LinkSource::Origin, &[LinkGeom::Line(Vec2::new(0., 5.), Vec2::new(2., 5.))]).unwrap();
let lost = sk.add_link(LinkKind::Project, LinkSource::Origin, &[LinkGeom::Line(Vec2::new(0., 6.), Vec2::new(2., 6.))]).unwrap();
sk.set_link_lost(&lost, true);
let text = sk
.add_link(
LinkKind::Text,
LinkSource::Text { text: "A".into(), at: Vec2::ZERO, height: 2., angle: 0. },
&[LinkGeom::Line(Vec2::new(0., 7.), Vec2::new(2., 7.))],
)
.unwrap();
let mut determined_flags = vec![false; sk.curves.len()];
determined_flags[determined] = true;
let saved = serde_json::to_value(&sk).unwrap();
let mut called = Vec::new();
let lines = sketch_lines_with_sampler(&sk, &Plane::XY, true, &determined_flags, |_, i, _| {
called.push(i);
vec![vec![Vec3::new(i as f64, 0., 0.), Vec3::new(i as f64, 1., 0.)], vec![Vec3::new(i as f64, 3., 0.), Vec3::new(i as f64, 4., 0.)]]
});
assert_eq!(called, (0..sk.curves.len()).collect::<Vec<_>>());
for (i, expected) in [
colors::SKETCH,
colors::SKETCH_LOCKED,
colors::SKETCH_CENTERLINE,
colors::SKETCH_CONSTRUCTION,
colors::SKETCH_FIXED,
colors::SKETCH_PROJECTED,
colors::SKETCH_LOST,
colors::SKETCH,
]
.into_iter()
.enumerate()
{
for part in &lines[2 * i..2 * i + 2] {
assert_eq!(part.1, expected);
assert_eq!(part.0.len(), 2);
}
assert_eq!(lines[2 * i].2, [fixed, centerline, construction].contains(&i));
}
assert_eq!(serde_json::to_value(&sk).unwrap(), saved);
sk.view.hide_projected = true;
called.clear();
let finished = sketch_lines_with_sampler(&sk, &Plane::XY, false, &determined_flags, |_, i, _| {
called.push(i);
vec![vec![Vec3::ZERO, Vec3::X]]
});
assert_eq!(called, vec![ordinary, fixed, centerline, construction, determined, 7]);
assert_eq!(finished[0].1, colors::SKETCH_DONE);
assert_eq!(finished[4].1, colors::SKETCH_DONE);
assert_eq!(finished[5].1, colors::SKETCH_DONE);
assert!(sk.link(&projected).is_some() && sk.link(&text).is_some());
}
#[test]
fn legacy_paths_match_sampler_wrapper_and_placed_wires_are_unchanged() {
let mut sk = Sketch::new();
sk.add_circle(Vec2::new(2., 3.), 1., None, None).unwrap();
sk.add_drawn_wire(&[Vec3::new(8., 9., 10.), Vec3::new(11., 12., 13.)]).unwrap();
let plane = Plane::new(Vec3::new(20., 30., 40.), Vec3::Y, Vec3::Z).unwrap();
let old = sketch_lines(&sk, &plane, true, &[false]);
let adapted = sketch_lines_with_sampler(&sk, &plane, true, &[false], world_curve_parts);
assert_eq!(old, adapted);
let clipped = sketch_lines_with_sampler(&sk, &plane, true, &[], |_, _, _| Vec::new());
assert_eq!(clipped, vec![(sk.wires[0].pts.clone(), colors::SKETCH, false)]);
let mut included = sk.wires[0].clone();
included.id = "included".into();
included.fit.clear();
sk.wires.push(included);
let wires = sketch_lines_with_sampler(&sk, &plane, false, &[], |_, _, _| Vec::new());
assert_eq!(wires[0].1, colors::SKETCH_DONE);
assert_eq!(wires[1], (sk.wires[1].pts.clone(), colors::SKETCH_PROJECTED, false));
sk.view.hide_projected = true;
assert_eq!(sketch_lines_with_sampler(&sk, &plane, false, &[], |_, _, _| Vec::new()), vec![wires[0].clone()]);
}
#[test]
fn scene_sampler_uses_exact_active_occurrence_plane_and_preserves_other_scene_content() {
let mut s = Session::default();
s.execute("solid.box", &json!({"length":4,"width":5,"height":6})).unwrap();
let component = s.execute("component.create", &json!({"name":"Part"})).unwrap()["component"].as_u64().unwrap();
let occurrence = s.execute("occurrence.copy", &json!({"component":component,"translate":[20,30,40]})).unwrap()["occurrence"].as_u64().unwrap();
s.execute("occurrence.move", &json!({"occurrence":occurrence,"angle":"90 deg","axis":[1,0,0],"origin":[20,30,40],"capture":false})).unwrap();
s.execute("component.activate", &json!({"component":component,"occurrence":occurrence,"path":[occurrence]})).unwrap();
s.execute("sketch.create", &json!({"plane":"XY"})).unwrap();
s.execute("sketch.circle.center", &json!({"center":[2,3],"radius":1})).unwrap();
s.section = Some((Vec3::new(0., 0., 3.), Vec3::Z));
let cam = Camera::default();
let old = scene(&s, &cam);
let default = scene_with_sketch_sampler(&s, &cam, world_curve_parts);
assert_eq!(old.lines.len(), default.lines.len());
for (a, b) in old.lines.iter().zip(&default.lines) {
assert_eq!((&a.points, a.color, a.width, a.on_top), (&b.points, b.color, b.width, b.on_top));
}
let mut planes = Vec::new();
let parts = vec![vec![Vec3::new(22., 33., 40.), Vec3::new(23., 33., 40.)], vec![Vec3::new(25., 33., 40.), Vec3::new(26., 33., 40.)]];
let sampled = scene_with_sketch_sampler(&s, &cam, |_, _, plane| {
planes.push(*plane);
parts.clone()
});
assert_eq!(planes.len(), 1);
assert!(planes[0].origin.dist(Vec3::new(20., 30., 40.)) < 1e-9);
assert!(planes[0].x.dist(Vec3::X) < 1e-9);
assert!(planes[0].y.dist(Vec3::Z) < 1e-9);
assert_eq!(sampled.clip, old.clip);
assert_eq!(sampled.background, old.background);
assert_eq!(sampled.radius, old.radius);
assert_eq!(sampled.meshes.len(), old.meshes.len());
assert!(Arc::ptr_eq(&sampled.meshes[0].mesh, &old.meshes[0].mesh));
assert_eq!(sampled.meshes[0].color, old.meshes[0].color);
assert_eq!(sampled.meshes[0].face_colors, old.meshes[0].face_colors);
let lines = &sampled.lines[sampled.lines.len() - 2..];
for (line, part) in lines.iter().zip(parts) {
assert_eq!(line.points, part);
assert!(line.on_top);
assert_eq!(line.width, 1.6);
}
let active = s.active_sketch.unwrap();
s.execute("sketch.finish", &json!({})).unwrap();
s.visibility.hidden_sketches.push(active);
let hidden = scene_with_sketch_sampler(&s, &cam, |_, _, _| panic!("hidden sketch sampled"));
assert!(hidden.lines.iter().all(|line| !line.on_top));
}