feat: preserve body history and placed modelling workflows
Record ownership transfers in the timeline so earlier Join/Cut membership, world placement, machining data and styles survive component moves, edits, independent copying, undo and reopening. Preserve pending positions in history snapshots and keep constructor transfers ordered during feature redefinition. Retain shallow sheet pockets and exact planar intersection islands through unfold/refold, with depth-aware flat previews and DXF export. Store explicit 3D wire coordinate spaces and migrate known legacy provenance without moving saved points or analytic fits. Infer unique eligible cylindrical Emboss targets, align Revolve manipulators with actual extent geometry, and discard obsolete preview errors after input correction. Certify constructed and immutable imported face styles using exact source metadata while preserving legacy face names. Upgrade designs to format10 with bounded validation and geometry-preserving legacy migration.
This commit is contained in:
1 parent
0398a813c8
commit
c1d819f235
86 files changed
+6011
-488
No files matched your search
@@ -235,7 +235,7 @@ fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
let mut root_bodies: Vec<String> =
|
||||
s.model.state().bodies.iter().filter(|b| s.doc.body_component(&b.name, b.feature) == 0).map(|b| b.name.clone()).collect();
|
||||
s.model.state().bodies.iter().filter(|b| s.doc.model_body_component(b) == 0).map(|b| b.name.clone()).collect();
|
||||
root_bodies.extend(ancestor_bodies);
|
||||
s.visibility.hidden_occurrences = hidden;
|
||||
s.visibility.hidden_bodies = root_bodies;
|
||||
|
||||
@@ -13,6 +13,8 @@ use crate::{Result, Session};
|
||||
|
||||
#[path = "component_properties.rs"]
|
||||
mod properties;
|
||||
#[path = "component_transfer.rs"]
|
||||
mod transfer;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "occurrence_tests.rs"]
|
||||
@@ -133,25 +135,7 @@ fn new_component(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn from_bodies(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.from_bodies";
|
||||
let bodies = string_list(p, "bodies");
|
||||
if bodies.is_empty() || bodies.len() > 1000 {
|
||||
return Err(bad(cmd, "`bodies` must list 1…1000 body names"));
|
||||
}
|
||||
let st = s.model.state();
|
||||
for b in &bodies {
|
||||
if st.body(b).is_none() {
|
||||
return Err(bad(cmd, format!("no body `{b}`")));
|
||||
}
|
||||
}
|
||||
let parent = s.active_component;
|
||||
let mut made = Vec::new();
|
||||
for b in bodies {
|
||||
let id = s.doc_mut().add_component(Some(&b), parent)?;
|
||||
s.doc_mut().body_components.insert(b.clone(), id);
|
||||
made.push(json!({"component": id, "body": b}));
|
||||
}
|
||||
Ok(json!({ "components": made }))
|
||||
transfer::from_bodies(s, p)
|
||||
}
|
||||
|
||||
fn activate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -176,7 +160,7 @@ fn list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let st = s.model.state();
|
||||
let doc = &s.doc;
|
||||
let node = |id: u64, name: &str, parent: Option<u64>| {
|
||||
let bodies: Vec<&str> = st.bodies.iter().filter(|b| doc.body_component(&b.name, b.feature) == id).map(|b| b.name.as_str()).collect();
|
||||
let bodies: Vec<&str> = st.bodies.iter().filter(|b| doc.model_body_component(b) == id).map(|b| b.name.as_str()).collect();
|
||||
let features: Vec<&str> = doc
|
||||
.features
|
||||
.iter()
|
||||
@@ -223,6 +207,15 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, "the design itself can't be deleted"));
|
||||
}
|
||||
let gone: Vec<u64> = s.doc.components.iter().filter(|c| s.doc.component_within(c.id, id)).map(|c| c.id).collect();
|
||||
if s.doc.features.iter().any(|feature| {
|
||||
!gone.contains(&feature.component)
|
||||
&& matches!(&feature.kind, FeatureKind::BodyTransfer { from, to, .. } if gone.contains(from) || gone.contains(to))
|
||||
}) {
|
||||
return Err(bad(
|
||||
cmd,
|
||||
"component supplies a recorded body transfer outside this subtree; remove that transfer history before deleting its source",
|
||||
));
|
||||
}
|
||||
let feats: Vec<u64> = s.doc.features.iter().filter(|f| gone.contains(&f.component)).map(|f| f.id).collect();
|
||||
let d = s.doc_mut();
|
||||
for f in &feats {
|
||||
@@ -241,36 +234,11 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn move_bodies(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.move_bodies";
|
||||
let to = component(s, p, "component", cmd)?;
|
||||
let bodies = string_list(p, "bodies");
|
||||
let st = s.model.state();
|
||||
if bodies.is_empty() || bodies.iter().any(|b| st.body(b).is_none()) {
|
||||
return Err(bad(cmd, "`bodies` must list existing bodies"));
|
||||
}
|
||||
for b in &bodies {
|
||||
let feature = st.body(b).map(|x| x.feature).unwrap_or(0);
|
||||
let own = s.doc.feature(feature).map(|f| f.component).unwrap_or(0);
|
||||
// The body keeps its place in the world: where it is shown now (its component's frame and
|
||||
// any earlier offset), seen from the new component's frame.
|
||||
let from = s.doc.body_component(b, feature);
|
||||
let now = solvecraft_doc::mat_mul(&s.doc.component_transform(from), s.doc.body_offsets.get(b).unwrap_or(&solvecraft_doc::IDENTITY));
|
||||
let offset = solvecraft_doc::mat_inverse(&s.doc.component_transform(to))
|
||||
.map(|inv| solvecraft_doc::mat_mul(&inv, &now))
|
||||
.unwrap_or(solvecraft_doc::IDENTITY);
|
||||
let doc = s.doc_mut();
|
||||
if own == to {
|
||||
doc.body_components.remove(b);
|
||||
} else {
|
||||
doc.body_components.insert(b.clone(), to);
|
||||
}
|
||||
if solvecraft_doc::is_identity(&offset) {
|
||||
doc.body_offsets.remove(b);
|
||||
} else {
|
||||
doc.body_offsets.insert(b.clone(), offset);
|
||||
}
|
||||
}
|
||||
Ok(json!({"bodies": bodies, "component": to}))
|
||||
transfer::move_bodies(s, p)
|
||||
}
|
||||
|
||||
pub(crate) fn transfer_constructor_result(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
transfer::move_result(s, p)
|
||||
}
|
||||
|
||||
fn move_sketches(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -232,20 +232,29 @@ pub(super) fn paste(
|
||||
}
|
||||
// Body assignment may put externally authored geometry into this graph. Freeze it explicitly.
|
||||
let owned_features: BTreeSet<_> = feats.iter().map(|f| f.id).collect();
|
||||
for body in state.bodies.iter().filter(|b| selected.contains(&source.body_component(&b.name, b.feature)) && !owned_features.contains(&b.feature))
|
||||
{
|
||||
for body in state.bodies.iter().filter(|b| selected.contains(&source.model_body_component(b)) && !owned_features.contains(&b.feature)) {
|
||||
if body.body.is_mesh() {
|
||||
return Err(bad(CMD, "Paste New of a reassigned mesh body is not supported yet"));
|
||||
}
|
||||
let comp = source.body_component(&body.name, body.feature);
|
||||
let offset = source.body_offsets.get(&body.name).copied().unwrap_or(solvecraft_doc::IDENTITY);
|
||||
let offset = source.model_body_offset(body).copied().unwrap_or(solvecraft_doc::IDENTITY);
|
||||
// An external transfer establishes the local baseline in this graph.
|
||||
// Freeze AFTER that boundary, then replay it as an identity ownership event.
|
||||
let entry = feats.iter().find(|f| {
|
||||
!f.suppressed
|
||||
&& matches!(&f.kind, FeatureKind::BodyTransfer { bodies, from, to, .. }
|
||||
if !selected.contains(from) && bodies.contains(&body.name)
|
||||
&& s.model.result(f.id).is_some_and(|result| !result.skipped && result.error.is_none()
|
||||
&& result.state.body(&body.name).is_some_and(|entered| source.model_body_component(entered) == *to)))
|
||||
});
|
||||
let before = feats.iter().find_map(|f| {
|
||||
let before = s.model.state_before(f.id);
|
||||
let prior = before.body(&body.name)?;
|
||||
let after = s.model.result(f.id)?.state.body(&body.name);
|
||||
if after.is_none_or(|after| !prior.body.same(&after.body)) { Some(before) } else { None }
|
||||
});
|
||||
let base = before.as_ref().and_then(|st| st.body(&body.name)).unwrap_or(body);
|
||||
let entered = entry.and_then(|f| s.model.result(f.id)).map(|r| r.state.clone());
|
||||
let base = entered.as_ref().and_then(|st| st.body(&body.name)).or_else(|| before.as_ref().and_then(|st| st.body(&body.name))).unwrap_or(body);
|
||||
let baseline_component = source.model_body_component(base);
|
||||
let geometry = solvecraft_kernel::transform_matrix(&base.body, offset)?;
|
||||
let step = solvecraft_kernel::step_export(&[&geometry], "SolveCraft independent reassigned body")?;
|
||||
let new_name = unique(&body.name, &mut used);
|
||||
@@ -256,7 +265,7 @@ pub(super) fn paste(
|
||||
Some(&feature_name),
|
||||
)?;
|
||||
if let Some(f) = scratch.doc_mut().feature_mut(id) {
|
||||
f.component = *components.get(&comp).ok_or_else(|| bad(CMD, "missing assigned component"))?;
|
||||
f.component = *components.get(&baseline_component).ok_or_else(|| bad(CMD, "reassigned baseline is outside copied component graph"))?;
|
||||
f.body_names = vec![new_name];
|
||||
}
|
||||
new_ids.push(id);
|
||||
@@ -316,6 +325,15 @@ pub(super) fn paste(
|
||||
f.body_names = outputs.get(&old.id).cloned().unwrap_or_default();
|
||||
}
|
||||
f.remap_clone(&features, &names, &bodies, ¶meters)?;
|
||||
if let FeatureKind::BodyTransfer { from, to, transform, .. } = &mut f.kind {
|
||||
*to = *components.get(to).ok_or_else(|| bad(CMD, "transfer destination is outside the copied graph"))?;
|
||||
if let Some(mapped) = components.get(from) {
|
||||
*from = *mapped;
|
||||
} else {
|
||||
*from = *to;
|
||||
*transform = solvecraft_doc::IDENTITY;
|
||||
}
|
||||
}
|
||||
if let FeatureKind::Emboss { face: Some(face), .. } = &mut f.kind
|
||||
&& !face.path.is_empty()
|
||||
&& let FeatureKind::Emboss { face: Some(old_face), .. } = &old.kind
|
||||
@@ -361,7 +379,7 @@ pub(super) fn paste(
|
||||
let moved_out: Vec<_> = state
|
||||
.bodies
|
||||
.iter()
|
||||
.filter(|b| owned_features.contains(&b.feature) && !selected.contains(&source.body_component(&b.name, b.feature)))
|
||||
.filter(|b| owned_features.contains(&b.feature) && !selected.contains(&source.model_body_component(b)))
|
||||
.filter_map(|b| bodies.get(&b.name).cloned())
|
||||
.collect();
|
||||
if !moved_out.is_empty() {
|
||||
@@ -376,17 +394,24 @@ pub(super) fn paste(
|
||||
if let Some(new) = bodies.get(&face.body) {
|
||||
let mut face = face.clone();
|
||||
if frozen.contains(&face.body) {
|
||||
if face.strict_name {
|
||||
return Err(bad(CMD, "cannot copy strict face paint through uncertified foreign re-export"));
|
||||
}
|
||||
if let Some(name) = &face.name {
|
||||
face.point = solvecraft_doc::naming::point_on_face(&state, name, face.point)
|
||||
.map(|(point, _)| point)
|
||||
.ok_or_else(|| bad(CMD, "source frozen face appearance lost its identity"))?;
|
||||
}
|
||||
face.point = solvecraft_doc::apply_point(source.body_offsets.get(&face.body).unwrap_or(&solvecraft_doc::IDENTITY), face.point);
|
||||
let source_body = state.body(&face.body).ok_or_else(|| bad(CMD, "source frozen face appearance lost its body"))?;
|
||||
face.point = solvecraft_doc::apply_point(source.model_body_offset(source_body).unwrap_or(&solvecraft_doc::IDENTITY), face.point);
|
||||
face.name = None;
|
||||
} else if let Some(name) = &mut face.name {
|
||||
*name = solvecraft_doc::remap_topology(name, &features, &bodies);
|
||||
}
|
||||
face.body = new.clone();
|
||||
if let Some(certificate) = &mut face.certificate {
|
||||
certificate.remap_features(&features);
|
||||
}
|
||||
scratch.doc_mut().appearances.faces.push(face);
|
||||
}
|
||||
}
|
||||
@@ -519,9 +544,9 @@ pub(super) fn paste(
|
||||
scratch.doc_mut().configs.columns = all_columns;
|
||||
style_validation?;
|
||||
let mut proof_work = 0usize;
|
||||
let expected_count = state.bodies.iter().filter(|b| selected.contains(&source.body_component(&b.name, b.feature))).count();
|
||||
let expected_count = state.bodies.iter().filter(|b| selected.contains(&source.model_body_component(b))).count();
|
||||
let copied_components: BTreeSet<_> = components.values().copied().collect();
|
||||
let actual_count = copied.bodies.iter().filter(|b| copied_components.contains(&scratch.doc.body_component(&b.name, b.feature))).count();
|
||||
let actual_count = copied.bodies.iter().filter(|b| copied_components.contains(&scratch.doc.model_body_component(b))).count();
|
||||
if actual_count != expected_count {
|
||||
return Err(bad(CMD, "copy produced an incomplete or unexpected set of bodies"));
|
||||
}
|
||||
@@ -533,11 +558,11 @@ pub(super) fn paste(
|
||||
return Err(bad(CMD, "copy would modify a source body; external destructive dependencies are not supported"));
|
||||
}
|
||||
}
|
||||
for body in state.bodies.iter().filter(|b| selected.contains(&source.body_component(&b.name, b.feature))) {
|
||||
for body in state.bodies.iter().filter(|b| selected.contains(&source.model_body_component(b))) {
|
||||
let new = bodies.get(&body.name).ok_or_else(|| bad(CMD, format!("body {} has no clone identity", body.name)))?;
|
||||
let cloned = copied_bodies.get(new.as_str()).ok_or_else(|| bad(CMD, format!("copy did not reproduce body {}", body.name)))?;
|
||||
let a = solvecraft_kernel::transform_matrix(&body.body, *source.body_offsets.get(&body.name).unwrap_or(&solvecraft_doc::IDENTITY))?;
|
||||
let b = solvecraft_kernel::transform_matrix(&cloned.body, *scratch.doc.body_offsets.get(new).unwrap_or(&solvecraft_doc::IDENTITY))?;
|
||||
let a = solvecraft_kernel::transform_matrix(&body.body, *source.model_body_offset(body).unwrap_or(&solvecraft_doc::IDENTITY))?;
|
||||
let b = solvecraft_kernel::transform_matrix(&cloned.body, *scratch.doc.model_body_offset(cloned).unwrap_or(&solvecraft_doc::IDENTITY))?;
|
||||
let ma = solvecraft_kernel::measure(&a)?;
|
||||
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());
|
||||
|
||||
@@ -238,6 +238,7 @@ fn configuration_rows_follow_copied_parameters_material_and_strict_face_lineage(
|
||||
origin_feature: source,
|
||||
name: format!("F{source}:+z"),
|
||||
point: solvecraft_geom::Vec3::new(1.0, 1.0, 2.0),
|
||||
certificate: None,
|
||||
};
|
||||
run(&mut s, "config.configure", json!({"params":[length],"materials":["Cube"],"component_appearances":[part],"face_appearances":[target]}));
|
||||
let face_key = s.doc.config_key(&solvecraft_doc::config::Column::FaceAppearance(target.clone()));
|
||||
@@ -275,6 +276,7 @@ fn configured_extrude_cap_copy_keeps_independent_semantic_lineage() {
|
||||
origin_feature: feature,
|
||||
name: format!("F{feature}:top"),
|
||||
point: solvecraft_geom::Vec3::new(1.0, 1.0, 2.0),
|
||||
certificate: None,
|
||||
};
|
||||
run(&mut s, "config.configure", json!({"face_appearances":[target]}));
|
||||
let key = s.doc.config_key(&solvecraft_doc::config::Column::FaceAppearance(target));
|
||||
@@ -391,3 +393,75 @@ fn generated_copy_parameters_cannot_heal_an_unrelated_missing_reference() {
|
||||
assert_eq!(s.selection, selection);
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_transfer_entry_and_internal_rotated_transfer_clone_once() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":4,"width":3,"height":2,"body_name":"Foreign"}));
|
||||
let parent = component(&mut s, "Assembly");
|
||||
let a = component(&mut s, "A");
|
||||
let ao = s.doc.occurrence_of(a).unwrap().id;
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":ao,"translate":[10,0,0],"angle":90,"axis":[0,0,1],"capture":true}));
|
||||
run(&mut s, "component.move_bodies", json!({"component":a,"bodies":["Foreign"]}));
|
||||
run(&mut s, "component.activate", json!({"component":parent}));
|
||||
let b = component(&mut s, "B");
|
||||
let bo = s.doc.occurrence_of(b).unwrap().id;
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":bo,"translate":[0,20,0],"angle":90,"axis":[1,0,0],"capture":true}));
|
||||
run(&mut s, "component.move_bodies", json!({"component":b,"bodies":["Foreign"]}));
|
||||
run(&mut s, "solid.move", json!({"bodies":["Foreign"],"translate":[0,0,5]}));
|
||||
let before = s.model.state().body("Foreign").unwrap().mesh().bounds();
|
||||
let copied = copy(&mut s, parent);
|
||||
assert_eq!(copied["frozen_bodies"], json!(["Foreign"]));
|
||||
let state = s.model.state();
|
||||
let body = state.body("Foreign (copy)").unwrap();
|
||||
let after = body.mesh().bounds();
|
||||
assert!(before.min.dist(after.min) < 1e-6);
|
||||
assert!(before.max.dist(after.max) < 1e-6);
|
||||
assert!(body.transferred);
|
||||
let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() };
|
||||
reopened.refresh();
|
||||
assert!(reopened.model.state().body("Foreign (copy)").unwrap().mesh().bounds().min.dist(before.min) < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suppressed_external_entry_does_not_supply_clone_baseline() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":4,"width":3,"height":2,"body_name":"Foreign"}));
|
||||
let part = component(&mut s, "Part");
|
||||
let occurrence = s.doc.occurrence_of(part).unwrap().id;
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[25,0,0],"angle":90,"axis":[0,0,1],"capture":true}));
|
||||
let skipped = s
|
||||
.doc_mut()
|
||||
.add_feature(
|
||||
solvecraft_doc::FeatureKind::BodyTransfer { bodies: vec!["Foreign".into()], from: 0, to: part, transform: solvecraft_doc::IDENTITY },
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
s.doc_mut().feature_mut(skipped).unwrap().suppressed = true;
|
||||
s.refresh();
|
||||
run(&mut s, "component.move_bodies", json!({"component":part,"bodies":["Foreign"]}));
|
||||
let before = s.model.state().body("Foreign").unwrap().mesh().bounds();
|
||||
copy(&mut s, part);
|
||||
let after = s.model.state().body("Foreign (copy)").unwrap().mesh().bounds();
|
||||
assert!(before.min.dist(after.min) < 1e-6);
|
||||
assert!(before.max.dist(after.max) < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transferred_frozen_face_paint_ignores_consumed_legacy_offset() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"body_name":"Foreign"}));
|
||||
run(&mut s, "appearance.assign", json!({"body":"Foreign","faces":[[1,1,2]],"color":"#00ff00"}));
|
||||
s.doc_mut().body_offsets.insert(
|
||||
"Foreign".into(),
|
||||
solvecraft_doc::rigid(solvecraft_geom::Vec3::new(7.0, 0.0, 0.0), solvecraft_geom::Vec3::ZERO, solvecraft_geom::Vec3::Z, 0.0),
|
||||
);
|
||||
s.refresh();
|
||||
let part = component(&mut s, "Part");
|
||||
run(&mut s, "component.move_bodies", json!({"component":part,"bodies":["Foreign"]}));
|
||||
let original = s.doc.appearances.faces.iter().find(|face| face.body == "Foreign").unwrap();
|
||||
let expected = solvecraft_doc::naming::point_on_face(&s.model.state(), original.name.as_ref().unwrap(), original.point).unwrap().0;
|
||||
copy(&mut s, part);
|
||||
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);
|
||||
}
|
||||
@@ -26,7 +26,7 @@ fn placed_doc(s: &Session) -> Document {
|
||||
|
||||
/// A property total must cover the entire assembly. The display traversal intentionally
|
||||
/// truncates hostile designs, so use an explicit error instead of accepting its prefix.
|
||||
fn placements(doc: &Document, cmd: &str) -> Result<Vec<(u64, Vec<u64>, Mat)>> {
|
||||
pub(super) fn placements(doc: &Document, cmd: &str) -> Result<Vec<(u64, Vec<u64>, Mat)>> {
|
||||
let mut out = vec![(0, Vec::new(), solvecraft_doc::IDENTITY)];
|
||||
let mut stack = vec![(0, Vec::<u64>::new(), solvecraft_doc::IDENTITY)];
|
||||
while let Some((component, path, transform)) = stack.pop() {
|
||||
@@ -112,8 +112,8 @@ fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool,
|
||||
};
|
||||
for (component, path, transform) in selected_placements {
|
||||
placement_count += 1;
|
||||
for body in state.bodies.iter().filter(|b| doc.body_component(&b.name, b.feature) == component) {
|
||||
let transform: Mat = doc.body_offsets.get(&body.name).map(|offset| solvecraft_doc::mat_mul(&transform, offset)).unwrap_or(transform);
|
||||
for body in state.bodies.iter().filter(|b| doc.model_body_component(b) == component) {
|
||||
let transform: Mat = doc.model_body_offset(body).map(|offset| solvecraft_doc::mat_mul(&transform, offset)).unwrap_or(transform);
|
||||
let placed = solvecraft_kernel::transform_matrix(&body.body, transform)?;
|
||||
let measured = solvecraft_kernel::measure(&placed)?;
|
||||
let density = doc.density(&body.name);
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
//! Atomic timeline ownership changes, preserving the selected world geometry.
|
||||
use super::*;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
type UniqueFrames = BTreeMap<u64, Option<(Vec<u64>, Mat)>>;
|
||||
|
||||
fn unique_frames(s: &Session, cmd: &str) -> Result<UniqueFrames> {
|
||||
let doc = s.placed_document();
|
||||
let mut children = BTreeMap::<u64, Vec<&Occurrence>>::new();
|
||||
for occurrence in &doc.occurrences {
|
||||
children.entry(occurrence.parent).or_default().push(occurrence);
|
||||
}
|
||||
let mut frames = BTreeMap::from([(0, Some((Vec::new(), solvecraft_doc::IDENTITY)))]);
|
||||
let mut stack = vec![(0, Vec::new(), solvecraft_doc::IDENTITY)];
|
||||
let mut work = 0usize;
|
||||
while let Some((component, path, matrix)) = stack.pop() {
|
||||
for occurrence in children.get(&component).into_iter().flatten() {
|
||||
work += 1;
|
||||
if path.len() >= 64 || work > 100_000 {
|
||||
return Err(bad(cmd, "transfer assembly exceeds 64 levels or 100000 placements"));
|
||||
}
|
||||
let mut child_path = path.clone();
|
||||
child_path.push(occurrence.id);
|
||||
let frame = solvecraft_doc::mat_mul(&matrix, &occurrence.transform);
|
||||
frames.entry(occurrence.component).and_modify(|entry| *entry = None).or_insert_with(|| Some((child_path.clone(), frame)));
|
||||
stack.push((occurrence.component, child_path, frame));
|
||||
}
|
||||
}
|
||||
Ok(frames)
|
||||
}
|
||||
|
||||
fn body_names(s: &Session, p: &Value, cmd: &str, allow_boundary: bool) -> Result<Vec<String>> {
|
||||
if p.get("bodies").and_then(Value::as_array).is_none_or(|names| names.iter().any(|name| !name.is_string())) {
|
||||
return Err(bad(cmd, "bodies must be an array of body names"));
|
||||
}
|
||||
let names = string_list(p, "bodies");
|
||||
if names.is_empty() || names.len() > 1000 || names.iter().collect::<BTreeSet<_>>().len() != names.len() {
|
||||
return Err(bad(cmd, "bodies must list 1..1000 distinct existing definition bodies"));
|
||||
}
|
||||
if s.doc.marker.is_some() && !allow_boundary {
|
||||
return Err(bad(cmd, "finish timeline rollback before transferring bodies"));
|
||||
}
|
||||
let state = s.model.state();
|
||||
for name in &names {
|
||||
if state.body(name).is_none() {
|
||||
return Err(bad(cmd, format!("no body `{name}`")));
|
||||
}
|
||||
}
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
fn transfer(s: &mut Session, name: &str, to: u64, frames: &UniqueFrames, cmd: &str) -> Result<u64> {
|
||||
let state = s.model.state();
|
||||
let body = state.body(name).ok_or_else(|| bad(cmd, "body is missing"))?;
|
||||
let from = s.doc.model_body_component(body);
|
||||
let doc = s.placed_document();
|
||||
let unique = |component| {
|
||||
frames.get(&component).and_then(Option::as_ref).ok_or_else(|| {
|
||||
bad(cmd, "body transfer requires one source and destination placement; shared instances cannot preserve a unique world pose")
|
||||
})
|
||||
};
|
||||
let source = unique(from)?;
|
||||
let destination = unique(to)?;
|
||||
let old = doc.model_body_offset(body).map(|offset| solvecraft_doc::mat_mul(&source.1, offset)).unwrap_or(source.1);
|
||||
let inv = solvecraft_doc::mat_inverse(&destination.1).ok_or_else(|| bad(cmd, "destination frame is not invertible"))?;
|
||||
let transform = solvecraft_doc::mat_mul(&inv, &old);
|
||||
if !solvecraft_doc::valid_transfer_matrix(&transform) {
|
||||
return Err(bad(cmd, "body transfer exceeds supported rigid coordinate range"));
|
||||
}
|
||||
let inherited = (!s.doc.appearances.bodies.contains_key(name)).then(|| s.doc.appearances.components.get(&from).cloned()).flatten();
|
||||
let paths: BTreeSet<u64> = source.0.iter().chain(&destination.0).copied().collect();
|
||||
let captures: Vec<_> = paths.into_iter().filter_map(|id| s.pending_moves.get(&id).map(|transform| (id, *transform))).collect();
|
||||
if captures.iter().any(|(_, transform)| !solvecraft_doc::valid_transfer_matrix(transform)) {
|
||||
return Err(bad(cmd, "pending transfer position is not a supported rigid transform"));
|
||||
}
|
||||
drop(doc);
|
||||
for (id, transform) in captures {
|
||||
if let Some(occurrence) = s.doc_mut().occurrences.iter_mut().find(|occurrence| occurrence.id == id) {
|
||||
occurrence.transform = transform;
|
||||
}
|
||||
s.pending_moves.remove(&id);
|
||||
}
|
||||
if let Some(look) = inherited {
|
||||
s.doc_mut().appearances.bodies.insert(name.to_string(), look);
|
||||
}
|
||||
let id = s.doc_mut().add_feature(FeatureKind::BodyTransfer { bodies: vec![name.to_string()], from, to, transform }, None)?;
|
||||
if let Some(feature) = s.doc_mut().feature_mut(id) {
|
||||
feature.component = to;
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
fn finish(s: &mut Session, scratch: &mut Session, features: &[u64], cmd: &str) -> Result<()> {
|
||||
scratch.refresh();
|
||||
for id in features {
|
||||
if let Some(error) = scratch.model.result(*id).and_then(|result| result.error.as_ref()) {
|
||||
return Err(bad(cmd, error));
|
||||
}
|
||||
}
|
||||
s.accept_computed(std::mem::take(scratch));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn from_bodies(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.from_bodies";
|
||||
let bodies = body_names(s, p, cmd, false)?;
|
||||
let mut scratch = s.scratch();
|
||||
let parent = s.active_component;
|
||||
let mut made = Vec::new();
|
||||
let mut targets = Vec::new();
|
||||
for name in &bodies {
|
||||
let component = scratch.doc_mut().add_component(Some(name), parent)?;
|
||||
targets.push(component);
|
||||
}
|
||||
let frames = unique_frames(&scratch, cmd)?;
|
||||
let mut features = Vec::new();
|
||||
for (name, component) in bodies.into_iter().zip(targets) {
|
||||
let feature = transfer(&mut scratch, &name, component, &frames, cmd)?;
|
||||
features.push(feature);
|
||||
made.push(json!({"component":component,"body":name,"feature":feature}));
|
||||
}
|
||||
finish(s, &mut scratch, &features, cmd)?;
|
||||
Ok(json!({"components":made}))
|
||||
}
|
||||
|
||||
pub(super) fn move_bodies(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
move_at_boundary(s, p, false)
|
||||
}
|
||||
|
||||
/// A solid constructor can attach its result immediately after the new feature,
|
||||
/// including inside the scratch timeline used by Edit Feature.
|
||||
pub(super) fn move_result(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
move_at_boundary(s, p, true)
|
||||
}
|
||||
|
||||
fn move_at_boundary(s: &mut Session, p: &Value, allow_boundary: bool) -> Result<Value> {
|
||||
let cmd = "component.move_bodies";
|
||||
let to = super::component(s, p, "component", cmd)?;
|
||||
let bodies = body_names(s, p, cmd, allow_boundary)?;
|
||||
let mut scratch = s.scratch();
|
||||
let frames = unique_frames(&scratch, cmd)?;
|
||||
let mut features = Vec::new();
|
||||
for name in &bodies {
|
||||
features.push(transfer(&mut scratch, name, to, &frames, cmd)?);
|
||||
}
|
||||
finish(s, &mut scratch, &features, cmd)?;
|
||||
Ok(json!({"bodies":bodies,"component":to,"features":features}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "component_transfer_tests.rs"]
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,287 @@
|
||||
use super::*;
|
||||
|
||||
fn run(s: &mut Session, command: &str, params: Value) -> Value {
|
||||
s.execute(command, ¶ms).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recorded_assignment_preserves_sample_joined_material_and_timeline() {
|
||||
let mut s = Session::default();
|
||||
s.run_script(&crate::sample::script()).unwrap();
|
||||
let before = s.model.state();
|
||||
assert_eq!(before.bodies.len(), 1);
|
||||
let volume = solvecraft_kernel::measure(&before.body("Plate").unwrap().body).unwrap().volume;
|
||||
let created = run(&mut s, "component.from_bodies", json!({"bodies":["Plate"]}));
|
||||
let to = created["components"][0]["component"].as_u64().unwrap();
|
||||
let feature = created["components"][0]["feature"].as_u64().unwrap();
|
||||
let after = s.model.state();
|
||||
assert_eq!(after.bodies.len(), 1);
|
||||
let body = after.body("Plate").unwrap();
|
||||
assert_eq!(body.component, to);
|
||||
assert!(body.transferred);
|
||||
assert!((solvecraft_kernel::measure(&body.body).unwrap().volume - volume).abs() < 1e-6);
|
||||
assert!(body.mesh().contains(Vec3::new(40.0, 35.0, 12.0)));
|
||||
let index = s.doc.feature_index(feature).unwrap();
|
||||
s.doc_mut().marker = Some(index);
|
||||
s.refresh();
|
||||
assert_eq!(s.world_state().body("Plate").unwrap().component, 0);
|
||||
s.doc_mut().marker = None;
|
||||
s.refresh();
|
||||
assert_eq!(s.world_state().body("Plate").unwrap().component, to);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transformed_pending_destination_repeated_transfer_preserves_world_pose() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Block"}));
|
||||
let created = run(&mut s, "component.create", json!({"name":"Destination"}));
|
||||
let to = created["component"].as_u64().unwrap();
|
||||
let occurrence = created["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,20,0],"angle":90,"axis":[0,0,1],"capture":false}));
|
||||
run(&mut s, "component.move_bodies", json!({"component":to,"bodies":["Block"]}));
|
||||
let state = s.world_state();
|
||||
let bounds = state.body("Block").unwrap().mesh().bounds();
|
||||
assert!(bounds.min.dist(Vec3::ZERO) < 1e-6);
|
||||
assert!(bounds.max.dist(Vec3::new(10.0, 10.0, 10.0)) < 1e-6);
|
||||
assert!(!s.pending_moves.contains_key(&occurrence));
|
||||
let mut destination_reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() };
|
||||
destination_reopened.refresh();
|
||||
assert!(destination_reopened.world_state().body("Block").unwrap().mesh().bounds().min.dist(Vec3::ZERO) < 1e-6);
|
||||
run(&mut s, "component.move_bodies", json!({"component":0,"bodies":["Block"]}));
|
||||
assert!(s.world_state().body("Block").unwrap().mesh().bounds().min.dist(Vec3::ZERO) < 1e-6);
|
||||
let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() };
|
||||
reopened.refresh();
|
||||
assert!(reopened.world_state().body("Block").unwrap().mesh().bounds().min.dist(Vec3::ZERO) < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_destination_refuses_without_mutating_document_or_session() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Block"}));
|
||||
let c = run(&mut s, "component.create", json!({"name":"Shared"}))["component"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.copy", json!({"component":c,"translate":[20,0,0]}));
|
||||
let document = s.doc.to_json();
|
||||
let active = s.active_component;
|
||||
assert!(s.execute("component.move_bodies", &json!({"component":c,"bodies":["Block"]})).is_err());
|
||||
assert_eq!(s.doc.to_json(), document);
|
||||
assert_eq!(s.active_component, active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn historical_join_cut_and_new_destination_modeling_keep_material() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Block"}));
|
||||
run(&mut s, "solid.box", json!({"corner":[8,0,0],"length":10,"width":10,"height":10,"operation":"join"}));
|
||||
run(&mut s, "solid.box", json!({"corner":[2,2,0],"length":2,"width":2,"height":10,"operation":"cut"}));
|
||||
let original = s.model.state();
|
||||
assert_eq!(original.bodies.len(), 1);
|
||||
let v = solvecraft_kernel::measure(&original.body("Block").unwrap().body).unwrap().volume;
|
||||
assert!((v - 1760.0).abs() < 1e-6);
|
||||
run(&mut s, "material.assign", json!({"bodies":["Block"],"material":"Brass"}));
|
||||
run(&mut s, "appearance.assign", json!({"bodies":["Block"],"color":"#bc8537"}));
|
||||
let created = run(&mut s, "component.from_bodies", json!({"bodies":["Block"]}));
|
||||
let c = created["components"][0]["component"].as_u64().unwrap();
|
||||
assert_eq!(s.doc.materials["Block"], "Brass");
|
||||
assert_eq!(s.doc.appearances.bodies["Block"].color, [188, 133, 55]);
|
||||
run(&mut s, "component.activate", json!({"component":c}));
|
||||
run(&mut s, "solid.box", json!({"corner":[18,0,0],"length":2,"width":10,"height":10,"operation":"join"}));
|
||||
assert_eq!(s.model.state().bodies.len(), 1);
|
||||
assert_eq!(s.model.state().body("Block").unwrap().component, c);
|
||||
let final_volume = solvecraft_kernel::measure(&s.model.state().body("Block").unwrap().body).unwrap().volume;
|
||||
assert!((final_volume - 1960.0).abs() < 1e-6);
|
||||
run(&mut s, "component.move_bodies", json!({"component":0,"bodies":["Block"]}));
|
||||
assert_eq!(s.model.state().body("Block").unwrap().component, 0);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.model.state().body("Block").unwrap().component, c);
|
||||
let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() };
|
||||
reopened.refresh();
|
||||
assert!((solvecraft_kernel::measure(&reopened.model.state().body("Block").unwrap().body).unwrap().volume - final_volume).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ownership_only_boundary_invalidates_old_boolean_cache() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Block"}));
|
||||
run(&mut s, "solid.box", json!({"corner":[8,0,0],"length":10,"width":10,"height":10,"operation":"join"}));
|
||||
assert_eq!(s.model.state().bodies.len(), 1);
|
||||
let c = s.doc_mut().add_component(Some("Destination"), 0).unwrap();
|
||||
let id = s
|
||||
.doc_mut()
|
||||
.add_feature(FeatureKind::BodyTransfer { bodies: vec!["Block".into()], from: 0, to: c, transform: solvecraft_doc::IDENTITY }, None)
|
||||
.unwrap();
|
||||
let index = s.doc.feature_index(id).unwrap();
|
||||
let transfer = s.doc_mut().features.remove(index);
|
||||
s.doc_mut().features.insert(1, transfer);
|
||||
s.refresh();
|
||||
assert_eq!(s.model.state().bodies.len(), 2);
|
||||
assert_eq!(s.model.state().body("Block").unwrap().component, c);
|
||||
let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() };
|
||||
reopened.refresh();
|
||||
assert_eq!(reopened.model.state().bodies.len(), 2);
|
||||
assert_eq!(reopened.model.state().body("Block").unwrap().component, c);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_transfer_cannot_change_evaluated_world_owner() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Block"}));
|
||||
let c = s.doc_mut().add_component(Some("Destination"), 0).unwrap();
|
||||
let id = s
|
||||
.doc_mut()
|
||||
.add_feature(FeatureKind::BodyTransfer { bodies: vec!["Block".into()], from: c, to: c, transform: solvecraft_doc::IDENTITY }, None)
|
||||
.unwrap();
|
||||
s.refresh();
|
||||
assert!(s.model.result(id).unwrap().error.is_some());
|
||||
assert_eq!(s.world_state().body("Block").unwrap().component, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transferred_thread_follows_body_frame_without_changing_world_axis() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius":4,"height":20,"body_name":"Shaft"}));
|
||||
run(&mut s, "solid.thread", json!({"face":[4,0,10],"designation":"M8"}));
|
||||
let before = s.world_state().threads[0].clone();
|
||||
let c = run(&mut s, "component.create", json!({"name":"Threaded"}));
|
||||
let to = c["component"].as_u64().unwrap();
|
||||
let occurrence = c["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,20,10],"axis":[1,0,0],"angle":90,"capture":true}));
|
||||
run(&mut s, "component.move_bodies", json!({"component":to,"bodies":["Shaft"]}));
|
||||
let after = s.world_state().threads[0].clone();
|
||||
assert!(after.axis_point.dist(before.axis_point) < 1e-6);
|
||||
assert!(after.axis.dist(before.axis) < 1e-6);
|
||||
let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() };
|
||||
reopened.refresh();
|
||||
assert!(reopened.world_state().threads[0].axis.dist(before.axis) < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inherited_component_appearance_and_legacy_assignment_survive_transfer() {
|
||||
let mut s = Session::default();
|
||||
let source = run(&mut s, "component.create", json!({"name":"Source"}))["component"].as_u64().unwrap();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Block"}));
|
||||
s.doc_mut().appearances.components.insert(source, solvecraft_doc::appearance::Look { name: "Green".into(), color: [0, 150, 0], opacity: 0.7 });
|
||||
s.refresh();
|
||||
let look = s.doc.model_body_look(s.model.state().body("Block").unwrap()).unwrap();
|
||||
run(&mut s, "component.move_bodies", json!({"component":0,"bodies":["Block"]}));
|
||||
assert_eq!(s.doc.model_body_look(s.model.state().body("Block").unwrap()), Some(look));
|
||||
// An unrecorded legacy assignment retains its established replay policy.
|
||||
let mut legacy = Session::default();
|
||||
run(&mut legacy, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Old"}));
|
||||
let c = legacy.doc_mut().add_component(Some("Legacy"), 0).unwrap();
|
||||
legacy.doc_mut().body_components.insert("Old".into(), c);
|
||||
legacy.doc_mut().body_offsets.insert("Old".into(), solvecraft_doc::rigid(Vec3::new(7.0, 0.0, 0.0), Vec3::ZERO, Vec3::Z, 0.0));
|
||||
legacy.refresh();
|
||||
assert!(!legacy.model.state().body("Old").unwrap().transferred);
|
||||
run(&mut legacy, "component.move_bodies", json!({"component":0,"bodies":["Old"]}));
|
||||
assert!((legacy.world_state().body("Old").unwrap().mesh().bounds().min.x - 7.0).abs() < 1e-6);
|
||||
let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&legacy.doc.to_json()).unwrap().into(), ..Session::default() };
|
||||
reopened.refresh();
|
||||
assert!((reopened.world_state().body("Old").unwrap().mesh().bounds().min.x - 7.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_external_transfer_source_refuses_without_dangling_archive() {
|
||||
let mut s = Session::default();
|
||||
let a = run(&mut s, "component.create", json!({"name":"Source"}))["component"].as_u64().unwrap();
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"body_name":"Block"}));
|
||||
run(&mut s, "component.activate", json!({"component":0}));
|
||||
let b = run(&mut s, "component.create", json!({"name":"Destination"}))["component"].as_u64().unwrap();
|
||||
run(&mut s, "component.move_bodies", json!({"component":b,"bodies":["Block"]}));
|
||||
let document = s.doc.to_json();
|
||||
assert!(s.execute("component.delete", &json!({"component":a})).is_err());
|
||||
assert_eq!(s.doc.to_json(), document);
|
||||
assert!(solvecraft_doc::Document::from_json(&document).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn earlier_selected_cylinder_emboss_keeps_source_frame_after_transfer_and_edit() {
|
||||
let mut s = Session::default();
|
||||
let source = run(&mut s, "component.create", json!({"name":"Source"}));
|
||||
let occurrence = source["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,0,0],"capture":true}));
|
||||
run(&mut s, "sketch.create", json!({"name":"Badge","plane":{"origin":[30,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]}}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let emboss = run(&mut s, "solid.emboss", json!({"sketch":"Badge","face":[30,10,7.5],"depth":1}))["feature"].as_u64().unwrap();
|
||||
let before = solvecraft_kernel::measure(&s.world_state().body("Sleeve").unwrap().body).unwrap().volume;
|
||||
run(&mut s, "component.activate", json!({"component":0}));
|
||||
let destination = run(&mut s, "component.create", json!({"name":"Destination"}));
|
||||
let to = destination["component"].as_u64().unwrap();
|
||||
let destination_occurrence = destination["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":destination_occurrence,"translate":[0,40,0],"axis":[1,0,0],"angle":90,"capture":true}));
|
||||
run(&mut s, "component.move_bodies", json!({"component":to,"bodies":["Sleeve"]}));
|
||||
assert!(s.doc.features.iter().all(|feature| s.model.result(feature.id).is_some_and(|result| result.error.is_none())));
|
||||
let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() };
|
||||
reopened.refresh();
|
||||
assert!(reopened.doc.features.iter().all(|feature| reopened.model.result(feature.id).is_some_and(|result| result.error.is_none())));
|
||||
assert!((solvecraft_kernel::measure(&reopened.world_state().body("Sleeve").unwrap().body).unwrap().volume - before).abs() < 1e-5);
|
||||
let depth = reopened.doc.feature(emboss).unwrap().param_names[0].clone();
|
||||
run(&mut reopened, "parameters.change", json!({"name":depth,"expression":"2 mm"}));
|
||||
assert!(solvecraft_kernel::measure(&reopened.world_state().body("Sleeve").unwrap().body).unwrap().volume > before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transfer_undo_redo_restores_original_pending_source_world_pose() {
|
||||
let mut s = Session::default();
|
||||
let created = run(&mut s, "component.create", json!({"name":"Source"}));
|
||||
let source = created["component"].as_u64().unwrap();
|
||||
let occurrence = created["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"body_name":"Block"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[10,0,0],"capture":false}));
|
||||
let pending = s.pending_moves.clone();
|
||||
let before = s.world_state().body("Block").unwrap().mesh().bounds();
|
||||
run(&mut s, "component.move_bodies", json!({"component":0,"bodies":["Block"]}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
assert_eq!(s.world_state().body("Block").unwrap().component, source);
|
||||
assert!(s.world_state().body("Block").unwrap().mesh().bounds().min.dist(before.min) < 1e-6);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert!(s.pending_moves.is_empty());
|
||||
assert_eq!(s.world_state().body("Block").unwrap().component, 0);
|
||||
assert!(s.world_state().body("Block").unwrap().mesh().bounds().min.dist(before.min) < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transfer_checkpoint_restores_pending_world_pose_and_history() {
|
||||
let mut s = Session::default();
|
||||
let created = run(&mut s, "component.create", json!({"name":"Source"}));
|
||||
let source = created["component"].as_u64().unwrap();
|
||||
let occurrence = created["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"body_name":"Block"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[10,0,0],"capture":false}));
|
||||
let pending = s.pending_moves.clone();
|
||||
let before = s.world_state().body("Block").unwrap().mesh().bounds();
|
||||
let undo_count = s.undo.len();
|
||||
let checkpoint = s.checkpoint();
|
||||
run(&mut s, "component.move_bodies", json!({"component":0,"bodies":["Block"]}));
|
||||
s.restore_checkpoint(checkpoint).unwrap();
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
assert_eq!(s.undo.len(), undo_count);
|
||||
assert_eq!(s.world_state().body("Block").unwrap().component, source);
|
||||
assert!(s.world_state().body("Block").unwrap().mesh().bounds().min.dist(before.min) < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edited_transfer_destination_updates_history_owner_copy_and_reopen() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"body_name":"Block"}));
|
||||
let a = run(&mut s, "component.create", json!({"name":"A","parent":0}))["component"].as_u64().unwrap();
|
||||
let b = run(&mut s, "component.create", json!({"name":"B","parent":0}))["component"].as_u64().unwrap();
|
||||
let transferred = run(&mut s, "component.move_bodies", json!({"component":a,"bodies":["Block"]}));
|
||||
let feature = transferred["features"][0].as_u64().unwrap();
|
||||
run(&mut s, "timeline.edit", json!({"feature":feature,"set":{"to":b,"component":a}}));
|
||||
assert_eq!(s.doc.feature(feature).unwrap().component, b);
|
||||
assert_eq!(s.model.state().body("Block").unwrap().component, b);
|
||||
let saved = s.doc.to_json();
|
||||
let pending = s.pending_moves.clone();
|
||||
assert!(s.execute("timeline.edit", &json!({"feature":feature,"set":{"to":u64::MAX}})).is_err());
|
||||
assert_eq!(s.doc.to_json(), saved);
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
let copied = run(&mut s, "component.paste_new", json!({"component":b}))["component"].as_u64().unwrap();
|
||||
assert_eq!(s.model.state().body("Block (copy)").unwrap().component, copied);
|
||||
let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() };
|
||||
reopened.refresh();
|
||||
assert_eq!(reopened.model.state().body("Block").unwrap().component, b);
|
||||
assert_eq!(reopened.model.state().body("Block (copy)").unwrap().component, copied);
|
||||
}
|
||||
@@ -63,6 +63,15 @@ fn column_of(s: &Session, p: &Value, cmd: &str) -> Result<Column> {
|
||||
.cloned()
|
||||
.ok_or_else(|| bad(cmd, "no face column"));
|
||||
}
|
||||
if let Some(target) = value.get("target") {
|
||||
solvecraft_doc::config::validate_face_target_json(target).map_err(|error| bad(cmd, error.to_string()))?;
|
||||
let target: solvecraft_doc::config::ConfigFaceTarget =
|
||||
serde_json::from_value(target.clone()).map_err(|error| bad(cmd, error.to_string()))?;
|
||||
let state = s.model.state();
|
||||
let body = state.body(&target.body).ok_or_else(|| bad(cmd, "no face definition"))?;
|
||||
s.doc.configuration_face_indices(body, &target).map_err(|error| bad(cmd, error.to_string()))?;
|
||||
return Ok(Column::FaceAppearance(target));
|
||||
}
|
||||
let body_name = value.get("body").and_then(Value::as_str).ok_or_else(|| bad(cmd, "face body required"))?;
|
||||
let point = value.get("point").and_then(crate::params::vec3).ok_or_else(|| bad(cmd, "finite face point required"))?;
|
||||
let world = s.world_state();
|
||||
@@ -71,20 +80,7 @@ fn column_of(s: &Session, p: &Value, cmd: &str) -> Result<Column> {
|
||||
return Err(bad(cmd, "mesh faces have no supported semantic lineage"));
|
||||
}
|
||||
let index = solvecraft_doc::appearance::face_index_at(body, point).ok_or_else(|| bad(cmd, "no face at picked point"))?;
|
||||
let names = solvecraft_doc::naming::face_names(body);
|
||||
let name = names.get(index).map(|name| solvecraft_doc::naming::strip_piece(name).to_string()).ok_or_else(|| bad(cmd, "face name missing"))?;
|
||||
let origin_feature = name
|
||||
.strip_prefix('F')
|
||||
.and_then(|name| name.split_once(':'))
|
||||
.and_then(|(id, _)| id.parse::<u64>().ok())
|
||||
.ok_or_else(|| bad(cmd, "face lineage missing"))?;
|
||||
let inverse = solvecraft_doc::mat_inverse(&body.source_transform).ok_or_else(|| bad(cmd, "invalid definition placement transform"))?;
|
||||
let target = solvecraft_doc::config::ConfigFaceTarget {
|
||||
body: body.definition_name().into(),
|
||||
origin_feature,
|
||||
name,
|
||||
point: solvecraft_doc::apply_point(&inverse, point),
|
||||
};
|
||||
let target = s.doc.configuration_face_target(body, index, point).map_err(|error| bad(cmd, error.to_string()))?;
|
||||
s.doc.certify_config_face(&target).map_err(|error| bad(cmd, error.to_string()))?;
|
||||
s.doc.configuration_face_indices(body, &target).map_err(|error| bad(cmd, error.to_string()))?;
|
||||
return Ok(Column::FaceAppearance(target));
|
||||
@@ -415,6 +411,10 @@ fn preview(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "config_certificate_tests.rs"]
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
mod certificate_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "config_style_tests.rs"]
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
use crate::Session;
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::{
|
||||
FeatureKind,
|
||||
config::{Column, FaceCertificate},
|
||||
};
|
||||
use solvecraft_geom::Vec3;
|
||||
|
||||
fn run(session: &mut Session, command: &str, params: Value) -> Value {
|
||||
session.execute(command, ¶ms).unwrap_or_else(|error| panic!("{command} {params}: {error}"))
|
||||
}
|
||||
fn paint(session: &mut Session, body: &str, point: Value) {
|
||||
let table = run(session, "config.configure", json!({"face_appearances":[{"body":body,"point":point}]}));
|
||||
let key = table["columns"][0].as_str().unwrap();
|
||||
run(session, "config.row", json!({"name":"Painted","values":{key:"ABS - Red"}}));
|
||||
run(session, "config.activate", json!({"row":"Painted"}));
|
||||
}
|
||||
fn painted_radius(session: &Session, name: &str, radius: f64) -> usize {
|
||||
let state = session.model.state();
|
||||
let body =
|
||||
state.body(name).unwrap_or_else(|| panic!("missing {name}; bodies {:?}", state.bodies.iter().map(|body| &body.name).collect::<Vec<_>>()));
|
||||
let painted = session.doc.face_colors(body);
|
||||
let mesh = body.body.tessellate(0.001).unwrap();
|
||||
let inverse = solvecraft_doc::mat_inverse(&body.source_transform).unwrap();
|
||||
for (triangle, face) in mesh.triangles.iter().zip(&mesh.tri_face) {
|
||||
if painted.iter().any(|(index, _)| *index == *face as usize) {
|
||||
for vertex in mesh.tri(triangle).unwrap() {
|
||||
let vertex = solvecraft_doc::apply_point(&inverse, vertex);
|
||||
assert!((vertex.x.hypot(vertex.y) - radius).abs() < 1e-7, "{name}: {vertex:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(!painted.is_empty());
|
||||
painted.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn certified_annulus_side_rows_follow_radius_edits_and_clone() {
|
||||
let mut s = Session::default();
|
||||
let part = run(&mut s, "component.create", json!({"name":"RingPart"}))["component"].as_u64().unwrap();
|
||||
let sketch = run(&mut s, "sketch.create", json!({"plane":"XY"}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":10,"id":"outer"}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities":["outer"],"type":"radius","value":10}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":9,"id":"inner"}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities":["inner"],"type":"radius","value":9}));
|
||||
let FeatureKind::Sketch { sketch: source_sketch, .. } = &s.doc.feature(sketch).unwrap().kind else { panic!() };
|
||||
let dimensions: Vec<_> = source_sketch.constraints.iter().filter_map(|dimension| dimension.param.clone()).collect();
|
||||
assert_eq!(dimensions.len(), 2);
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance":10,"profiles":[{"point":[9.5,0]}],"body_name":"Ring"}));
|
||||
paint(&mut s, "Ring", json!([10, 0, 5]));
|
||||
assert_eq!(painted_radius(&s, "Ring", 10.), 2, "both constructed arc pieces share outer curve");
|
||||
let Column::FaceAppearance(target) = &s.doc.configs.columns[0] else { panic!() };
|
||||
assert_eq!(target.certificate, Some(FaceCertificate::ExtrudeSide { sketch, curve: "outer".into() }));
|
||||
run(&mut s, "parameters.change", json!({"name":dimensions[0],"expression":"12 mm"}));
|
||||
run(&mut s, "parameters.change", json!({"name":dimensions[1],"expression":"9.25 mm"}));
|
||||
run(&mut s, "config.activate", json!({"row":"Painted"}));
|
||||
assert_eq!(painted_radius(&s, "Ring", 12.), 2);
|
||||
run(&mut s, "component.paste_new", json!({"component":part,"translate":[30,0,0]}));
|
||||
assert_eq!(s.doc.configs.columns.len(), 2);
|
||||
run(&mut s, "config.activate", json!({"row":"Painted"}));
|
||||
assert_eq!(painted_radius(&s, "Ring (copy)", 12.), 2);
|
||||
let reopened = solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||
let mut reopened = Session::new(reopened);
|
||||
run(&mut reopened, "config.activate", json!({"row":"Painted"}));
|
||||
assert_eq!(painted_radius(&reopened, "Ring", 12.), 2);
|
||||
assert_eq!(painted_radius(&reopened, "Ring (copy)", 12.), 2);
|
||||
run(&mut reopened, "config.activate", json!({"row":"Default"}));
|
||||
assert!(reopened.doc.appearances.faces.is_empty());
|
||||
}
|
||||
|
||||
fn imported(session: &mut Session, step: String, name: &str) -> u64 {
|
||||
let owner = session.active_component;
|
||||
let id = session.doc_mut().add_feature(FeatureKind::Import { file: "fixture.step".into(), step, components: vec![] }, Some(name)).unwrap();
|
||||
let feature = session.doc_mut().feature_mut(id).unwrap();
|
||||
feature.body_names = vec![name.into()];
|
||||
feature.component = owner;
|
||||
session.refresh();
|
||||
assert!(session.model.result(id).unwrap().error.is_none());
|
||||
id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_v9_imported_emboss_anchor_and_ordinary_face_paint_replay_without_renaming() {
|
||||
let mut s = Session::default();
|
||||
let cylinder = solvecraft_kernel::cylinder(Vec3::ZERO, Vec3::Z, 10., 20.).unwrap();
|
||||
let step = solvecraft_kernel::step_export(&[&cylinder], "legacy anchored cylinder").unwrap();
|
||||
let source = imported(&mut s, step, "Sleeve");
|
||||
run(&mut s, "material.assign", json!({"bodies":["Sleeve"],"material":"Steel"}));
|
||||
let before_volume = s.model.state().body("Sleeve").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume;
|
||||
let legacy_names = solvecraft_doc::naming::face_names(s.model.state().body("Sleeve").unwrap());
|
||||
assert!(legacy_names.iter().all(|name| name.starts_with(&format!("F{source}:face:"))));
|
||||
run(&mut s, "appearance.assign", json!({"faces":[{"body":"Sleeve","point":[6,0,20]}],"appearance":"ABS - Blue"}));
|
||||
assert!(s.doc.appearances.faces[0].certificate.is_none());
|
||||
let badge = run(&mut s, "sketch.create", json!({"plane":{"face":[0,0,20]},"name":"Badge","project_edges":false}))["sketch"].as_u64().unwrap();
|
||||
let FeatureKind::Sketch { plane: saved_plane, .. } = &s.doc.feature(badge).unwrap().kind else { panic!() };
|
||||
let saved_plane = saved_plane.clone();
|
||||
assert!(matches!(&saved_plane, solvecraft_doc::PlaneRef::Face { name: Some(name), .. } if legacy_names.contains(name)));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,-2],"p1":[2,2]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.emboss", json!({"sketch":"Badge","profiles":"all","depth":1,"targets":["Sleeve"],"name":"Raised Badge"}));
|
||||
let anchored = s.doc.find_feature("Raised Badge").unwrap().clone();
|
||||
assert!(matches!(anchored.kind, FeatureKind::Emboss { face: None, .. }));
|
||||
let volume = s.model.state().body("Sleeve").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume;
|
||||
assert!((volume - std::f64::consts::PI * 2000. - 16.).abs() < 0.1, "volume {volume}");
|
||||
assert!((volume - before_volume - 16.).abs() < 0.01, "addedvolume {} baseline {before_volume}", volume - before_volume);
|
||||
let paint = s.doc.appearances.faces.clone();
|
||||
let mut legacy: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
|
||||
legacy["format"] = json!("solvecraft/9");
|
||||
let reopened = Session::new(solvecraft_doc::Document::from_json(&legacy.to_string()).unwrap());
|
||||
assert_eq!(reopened.doc.feature(anchored.id).unwrap(), &anchored);
|
||||
let FeatureKind::Sketch { plane, .. } = &reopened.doc.feature(badge).unwrap().kind else { panic!() };
|
||||
assert_eq!(plane, &saved_plane);
|
||||
assert_eq!(reopened.doc.appearances.faces, paint);
|
||||
assert_eq!(reopened.doc.materials.get("Sleeve"), Some(&"Steel".to_string()));
|
||||
assert!(reopened.model.result(anchored.id).unwrap().error.is_none());
|
||||
let state = reopened.model.state();
|
||||
let body = state.body("Sleeve").unwrap();
|
||||
assert!((body.body.tessellate_with(1e-4, true).unwrap().measure().volume - volume).abs() < 1e-8);
|
||||
assert!(!reopened.doc.face_colors(body).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn certified_side_refuses_reused_body_name_from_a_new_producer() {
|
||||
let mut s = Session::default();
|
||||
let sketch = run(&mut s, "sketch.create", json!({"plane":"XY"}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":10,"id":"circle"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let source = run(&mut s, "solid.extrude", json!({"distance":10,"body_name":"Round"}))["feature"].as_u64().unwrap();
|
||||
paint(&mut s, "Round", json!([10, 0, 5]));
|
||||
let Column::FaceAppearance(target) = s.doc.configs.columns[0].clone() else { panic!() };
|
||||
let mut replacement = s.doc.feature(source).unwrap().clone();
|
||||
replacement.id = s.doc.next_id;
|
||||
assert!(matches!(replacement.kind, FeatureKind::Extrude { sketch: original, .. } if original == sketch));
|
||||
s.doc_mut().feature_mut(source).unwrap().suppressed = true;
|
||||
s.doc_mut().next_id += 1;
|
||||
s.doc_mut().features.push(replacement);
|
||||
s.refresh();
|
||||
let state = s.model.state();
|
||||
let body = state.body("Round").unwrap();
|
||||
assert!(s.doc.configuration_face_indices(body, &target).is_err());
|
||||
assert!(s.doc.face_colors(body).is_empty());
|
||||
let before = s.doc.clone();
|
||||
let history = s.undo.len();
|
||||
assert!(s.execute("config.activate", &json!({"row":"Painted"})).is_err());
|
||||
assert_eq!(s.doc, before);
|
||||
assert_eq!(s.undo.len(), history);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_v9_annulus_outer_side_emboss_keeps_its_original_heuristic_anchor() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":10,"id":"outer"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":9,"id":"inner"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance":20,"profiles":[{"point":[9.5,0]}],"body_name":"Sleeve"}));
|
||||
let names = solvecraft_doc::naming::face_names(s.model.state().body("Sleeve").unwrap());
|
||||
// Preserve the legacy centroid-family name even though strict paint uses
|
||||
// the separately certified outer source curve.
|
||||
let name = solvecraft_doc::naming::face_names_at(&s.model.state(), &[Vec3::new(0., 10., 7.5)]).remove(0);
|
||||
assert!(name.contains("side:inner"));
|
||||
run(&mut s, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.emboss", json!({"sketch":"Badge","profiles":"all","face":[0,10,7.5],"depth":1,"targets":["Sleeve"],"name":"Raised Badge"}));
|
||||
let feature = s.doc.find_feature("Raised Badge").unwrap();
|
||||
assert_eq!(feature.face_names, vec![name]);
|
||||
assert!(feature.face_names.iter().all(|name| names.contains(name)));
|
||||
let feature_id = feature.id;
|
||||
let volume = s.model.state().body("Sleeve").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume;
|
||||
assert!((volume - std::f64::consts::PI * 380. - 21.).abs() < 0.2, "volume {volume}");
|
||||
let mut legacy: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
|
||||
legacy["format"] = json!("solvecraft/9");
|
||||
let reopened = Session::new(solvecraft_doc::Document::from_json(&legacy.to_string()).unwrap());
|
||||
assert!(reopened.model.result(feature_id).unwrap().error.is_none());
|
||||
assert!((reopened.model.state().body("Sleeve").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume - volume).abs() < 1e-8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn certified_side_transfer_preserves_geometry_and_unsupported_taper_refuses_atomically() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":10}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let source = run(&mut s, "solid.extrude", json!({"distance":10,"body_name":"Round"}))["feature"].as_u64().unwrap();
|
||||
paint(&mut s, "Round", json!([10, 0, 5]));
|
||||
let created = run(&mut s, "component.create", json!({"name":"Placed"}));
|
||||
let destination = created["component"].as_u64().unwrap();
|
||||
let occurrence = created["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,20,0],"angle":90,"axis":[0,0,1],"capture":false}));
|
||||
run(&mut s, "component.move_bodies", json!({"component":destination,"bodies":["Round"]}));
|
||||
run(&mut s, "config.activate", json!({"row":"Painted"}));
|
||||
assert_eq!(painted_radius(&s, "Round", 10.), 2);
|
||||
let before = s.world_state().body("Round").unwrap().mesh().bounds();
|
||||
let mut s = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
run(&mut s, "config.activate", json!({"row":"Painted"}));
|
||||
assert_eq!(painted_radius(&s, "Round", 10.), 2);
|
||||
assert!(
|
||||
s.world_state().body("Round").unwrap().mesh().bounds().min.dist(before.min) < 1e-8,
|
||||
"before {before:?}, reopened {:?}",
|
||||
s.world_state().body("Round").unwrap().mesh().bounds()
|
||||
);
|
||||
// A successful ordinary modifier has no exact untapered source certificate.
|
||||
let FeatureKind::Extrude { extent, .. } = &mut s.doc_mut().feature_mut(source).unwrap().kind else { panic!() };
|
||||
extent.taper = Some("5 deg".into());
|
||||
s.refresh();
|
||||
assert!(s.model.result(source).unwrap().error.is_none());
|
||||
assert!(s.doc.face_colors(s.model.state().body("Round").unwrap()).is_empty());
|
||||
let document = s.doc.clone();
|
||||
let history = s.undo.len();
|
||||
assert!(s.execute("config.preview", &json!({"row":"Painted"})).is_err());
|
||||
assert!(s.execute("config.activate", &json!({"row":"Painted"})).is_err());
|
||||
assert_eq!(s.doc, document);
|
||||
assert_eq!(s.undo.len(), history);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn certified_import_payload_binding_roundtrip_clone_and_replacement_refusal() {
|
||||
let geometry = solvecraft_kernel::cylinder(Vec3::ZERO, Vec3::Z, 10., 10.).unwrap();
|
||||
let step = solvecraft_kernel::step_export(&[&geometry], "certificate fixture").unwrap();
|
||||
let mut s = Session::default();
|
||||
let part = run(&mut s, "component.create", json!({"name":"ImportedPart"}))["component"].as_u64().unwrap();
|
||||
let source = imported(&mut s, step.clone(), "Imported");
|
||||
paint(&mut s, "Imported", json!([10, 0, 5]));
|
||||
let pieces = painted_radius(&s, "Imported", 10.);
|
||||
let Column::FaceAppearance(target) = &s.doc.configs.columns[0] else { panic!() };
|
||||
assert!(
|
||||
matches!(&target.certificate, Some(FaceCertificate::Imported { item, face, payload_sha256 }) if *item != 0 && *face != 0 && payload_sha256.len() == 64)
|
||||
);
|
||||
run(&mut s, "component.paste_new", json!({"component":part,"translate":[30,0,0]}));
|
||||
run(&mut s, "config.activate", json!({"row":"Painted"}));
|
||||
assert_eq!(painted_radius(&s, "Imported (copy)", 10.), pieces);
|
||||
let reopened = solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||
let mut s = Session::new(reopened);
|
||||
run(&mut s, "config.activate", json!({"row":"Painted"}));
|
||||
assert_eq!(painted_radius(&s, "Imported", 10.), pieces);
|
||||
// Identical geometry, entity IDs and item IDs, but a replaced embedded payload.
|
||||
let FeatureKind::Import { step: payload, .. } = &mut s.doc_mut().feature_mut(source).unwrap().kind else { panic!() };
|
||||
*payload = step.replace("certificate fixture", "replacement fixture");
|
||||
assert_ne!(*payload, step);
|
||||
s.refresh();
|
||||
assert!(s.doc.face_colors(s.model.state().body("Imported").unwrap()).is_empty());
|
||||
assert_eq!(painted_radius(&s, "Imported (copy)", 10.), pieces);
|
||||
let before = s.doc.clone();
|
||||
let history = s.undo.len();
|
||||
assert!(s.execute("config.preview", &json!({"row":"Painted"})).is_err());
|
||||
assert!(s.execute("config.activate", &json!({"row":"Painted"})).is_err());
|
||||
assert_eq!(s.doc, before);
|
||||
assert_eq!(s.undo.len(), history);
|
||||
}
|
||||
@@ -133,6 +133,7 @@ fn strict_face_missing_lineage_never_retargets_and_rolls_back() {
|
||||
point: solvecraft_geom::Vec3::new(5., 5., 10.),
|
||||
name: Some(format!("F{other}:+z")),
|
||||
strict_name: true,
|
||||
certificate: None,
|
||||
look,
|
||||
});
|
||||
assert!(s.doc.face_colors(s.model.state().body(&body).unwrap()).is_empty());
|
||||
@@ -175,7 +176,7 @@ fn extrude_cap_certification_follows_actual_positive_negative_and_symmetric_name
|
||||
for face in faces {
|
||||
let name = solvecraft_doc::naming::strip_piece(&names[face.index]);
|
||||
let origin = name.strip_prefix('F').unwrap().split_once(':').unwrap().0.parse().unwrap();
|
||||
let target = ConfigFaceTarget { body: "Pad".into(), origin_feature: origin, name: name.into(), point: face.centroid };
|
||||
let target = ConfigFaceTarget { body: "Pad".into(), origin_feature: origin, name: name.into(), point: face.centroid, certificate: None };
|
||||
if name.ends_with(":start") || name.ends_with(":top") || name.ends_with(":bottom") {
|
||||
s.doc.certify_config_face(&target).unwrap();
|
||||
s.doc.configuration_face_indices(body, &target).unwrap();
|
||||
@@ -211,7 +212,13 @@ fn hostile_and_uncertified_face_targets_are_rejected() {
|
||||
let mut s = Session::default();
|
||||
let body = block(&mut s);
|
||||
let id = s.doc.features[0].id;
|
||||
let target = ConfigFaceTarget { body: body.clone(), origin_feature: id, name: format!("F{id}:face:0"), point: solvecraft_geom::Vec3::ZERO };
|
||||
let target = ConfigFaceTarget {
|
||||
body: body.clone(),
|
||||
origin_feature: id,
|
||||
name: format!("F{id}:face:0"),
|
||||
point: solvecraft_geom::Vec3::ZERO,
|
||||
certificate: None,
|
||||
};
|
||||
assert!(s.doc.certify_config_face(&target).is_err());
|
||||
for value in [
|
||||
json!({"body":body,"origin_feature":id,"name":"x".repeat(1_000_000),"point":[0,0,0]}),
|
||||
|
||||
@@ -65,7 +65,7 @@ impl ContactGeometry {
|
||||
if !sets.iter().any(|set| path.iter().any(|id| set.contains(id))) {
|
||||
continue;
|
||||
}
|
||||
for body in state.bodies.iter().filter(|b| s.doc.body_component(&b.name, b.feature) == component) {
|
||||
for body in state.bodies.iter().filter(|b| s.doc.model_body_component(b) == component) {
|
||||
if body.body.is_mesh() {
|
||||
return Err(limit("contact sweep does not support mesh bodies; disable their contact set to move them"));
|
||||
}
|
||||
@@ -113,7 +113,7 @@ impl ContactGeometry {
|
||||
};
|
||||
out.bodies.push(CachedBody {
|
||||
path: path.clone(),
|
||||
offset: s.doc.body_offsets.get(&body.name).copied().unwrap_or(IDENTITY),
|
||||
offset: s.doc.model_body_offset(body).copied().unwrap_or(IDENTITY),
|
||||
mesh,
|
||||
size: body.body.size(),
|
||||
});
|
||||
|
||||
@@ -47,9 +47,10 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
fn undo(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let Some(prev) = s.undo.pop() else { return Err(EngineError::Other("nothing to undo".into())) };
|
||||
let label = prev.label.clone();
|
||||
s.redo.push(Snapshot { label: label.clone(), doc: s.doc.clone(), active_sketch: s.active_sketch });
|
||||
s.redo.push(Snapshot { label: label.clone(), doc: s.doc.clone(), active_sketch: s.active_sketch, pending_moves: s.pending_moves.clone() });
|
||||
s.doc = prev.doc;
|
||||
s.active_sketch = prev.active_sketch;
|
||||
s.pending_moves = prev.pending_moves;
|
||||
s.selection.clear();
|
||||
s.refresh();
|
||||
Ok(json!({"undone": label}))
|
||||
@@ -58,9 +59,10 @@ fn undo(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
fn redo(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let Some(next) = s.redo.pop() else { return Err(EngineError::Other("nothing to redo".into())) };
|
||||
let label = next.label.clone();
|
||||
s.undo.push(Snapshot { label: label.clone(), doc: s.doc.clone(), active_sketch: s.active_sketch });
|
||||
s.undo.push(Snapshot { label: label.clone(), doc: s.doc.clone(), active_sketch: s.active_sketch, pending_moves: s.pending_moves.clone() });
|
||||
s.doc = next.doc;
|
||||
s.active_sketch = next.active_sketch;
|
||||
s.pending_moves = next.pending_moves;
|
||||
s.selection.clear();
|
||||
s.refresh();
|
||||
Ok(json!({"redone": label}))
|
||||
@@ -200,6 +202,10 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if std::mem::discriminant(&nf.kind) != std::mem::discriminant(&f.kind) {
|
||||
return Err(bad(cmd, "the feature type cannot change"));
|
||||
}
|
||||
if let FeatureKind::BodyTransfer { bodies, from, to, transform } = &nf.kind {
|
||||
solvecraft_doc::validate_body_transfer(&s.doc, bodies, *from, *to, transform)?;
|
||||
nf.component = *to;
|
||||
}
|
||||
if let (FeatureKind::Sketch { sketch: a, .. }, FeatureKind::Sketch { sketch: b, .. }) = (&nf.kind, &f.kind)
|
||||
&& a != b
|
||||
{
|
||||
@@ -360,7 +366,13 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
trial.active_sketch = None;
|
||||
trial.selection = s.selection.clone();
|
||||
trial.doc_mut().marker = Some(idx);
|
||||
// Keep the original identity as a suppressed placeholder. The constructor and
|
||||
// any ownership boundary it adds go AFTER this slot, so discarding its temporary
|
||||
// feature leaves auxiliary history after the rewritten original.
|
||||
if let Some(slot) = trial.doc_mut().feature_mut(id) {
|
||||
slot.suppressed = true;
|
||||
}
|
||||
trial.doc_mut().marker = Some(idx + 1);
|
||||
trial.refresh();
|
||||
(spec.enabled)(&trial).map_err(|why| EngineError::Disabled(spec.id.to_string(), why))?;
|
||||
let r = (spec.run)(&mut trial, ¶ms)?;
|
||||
@@ -399,6 +411,7 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
trial.doc_mut().next_id = s.doc.next_id;
|
||||
}
|
||||
if let Some(slot) = trial.doc_mut().feature_mut(id) {
|
||||
slot.suppressed = old.suppressed;
|
||||
slot.kind = new.kind;
|
||||
slot.edge_refs = new.edge_refs;
|
||||
slot.edge_names = new.edge_names;
|
||||
@@ -414,6 +427,40 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(e) = trial.model.result(id).and_then(|r| r.error.clone()) {
|
||||
return Err(EngineError::Other(e));
|
||||
}
|
||||
let original_ids: std::collections::BTreeSet<u64> = s.doc.features.iter().map(|feature| feature.id).collect();
|
||||
let added_ids: std::collections::BTreeSet<u64> =
|
||||
trial.doc.features.iter().filter(|feature| !original_ids.contains(&feature.id)).map(|feature| feature.id).collect();
|
||||
let adds_transfer =
|
||||
trial.doc.features.iter().any(|feature| added_ids.contains(&feature.id) && matches!(feature.kind, FeatureKind::BodyTransfer { .. }));
|
||||
let baseline = if adds_transfer && s.doc.marker.is_some() {
|
||||
let mut baseline = s.scratch();
|
||||
baseline.doc_mut().marker = None;
|
||||
baseline.refresh();
|
||||
Some(baseline)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let proof = if (adds_transfer && trial.doc.marker.is_some()) || added_ids.iter().any(|id| trial.model.result(*id).is_none()) {
|
||||
let mut proof = trial.scratch();
|
||||
proof.doc_mut().marker = None;
|
||||
proof.refresh();
|
||||
Some(proof)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let validated = proof.as_ref().map(|proof| &proof.model).unwrap_or(&trial.model);
|
||||
let original_model = baseline.as_ref().map(|baseline| &baseline.model).unwrap_or(&s.model);
|
||||
for feature in &trial.doc.features {
|
||||
let auxiliary = added_ids.contains(&feature.id);
|
||||
let protected_transfer = matches!(feature.kind, FeatureKind::BodyTransfer { .. })
|
||||
&& original_model.result(feature.id).is_some_and(|result| !result.skipped && result.error.is_none());
|
||||
if (auxiliary || protected_transfer)
|
||||
&& let Some(error) = validated.result(feature.id).and_then(|result| result.error.as_ref())
|
||||
{
|
||||
return Err(bad(cmd, format!("{}: {error}", feature.name)));
|
||||
}
|
||||
}
|
||||
s.pending_moves = trial.pending_moves;
|
||||
s.doc = trial.doc;
|
||||
s.refresh();
|
||||
if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) {
|
||||
|
||||
@@ -170,11 +170,31 @@ fn redefine_keeps_allocator_above_component_and_occurrence_ids() {
|
||||
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 10, "body_name": "Base"}));
|
||||
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 10, "corner": [10, 0, 0], "body_name": "Tool"}));
|
||||
run(&mut s, "solid.combine", json!({"target": "Base", "tools": ["Tool"], "name": "Join"}));
|
||||
let original = s.doc.clone();
|
||||
let original_feature = s.doc.find_feature("Join").unwrap().id;
|
||||
run(
|
||||
&mut s,
|
||||
"timeline.redefine",
|
||||
json!({"feature": "Join", "command": "solid.combine", "params": {"target": "Base", "tools": ["Tool"], "new_component": true, "component_name": "Merged"}}),
|
||||
);
|
||||
let destination = s.doc.find_component("Merged").unwrap();
|
||||
assert_eq!(s.model.state().body("Base").unwrap().component, destination);
|
||||
assert_eq!(s.doc.find_feature("Join").unwrap().id, original_feature);
|
||||
let combine_index = s.doc.feature_index(original_feature).unwrap();
|
||||
assert!(
|
||||
matches!(&s.doc.features[combine_index + 1].kind, solvecraft_doc::FeatureKind::BodyTransfer { bodies, to, .. } if bodies == &["Base"] && *to == destination)
|
||||
);
|
||||
assert!((solvecraft_kernel::measure(&s.world_state().body("Base").unwrap().body).unwrap().volume - 6000.0).abs() < 1e-6);
|
||||
assert_eq!(s.world_state().bodies.len(), 1);
|
||||
run(&mut s, "timeline.rollback", json!({"position":combine_index + 1}));
|
||||
assert_eq!(s.world_state().body("Base").unwrap().component, 0, "the result transfers only AFTER Combine");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(*s.doc, *original);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert_eq!(reopened.world_state().body("Base").unwrap().component, destination);
|
||||
assert!((solvecraft_kernel::measure(&reopened.world_state().body("Base").unwrap().body).unwrap().volume - 6000.0).abs() < 1e-6);
|
||||
let max_id =
|
||||
s.doc.components.iter().map(|c| c.id).chain(s.doc.occurrences.iter().map(|o| o.id)).chain(s.doc.features.iter().map(|f| f.id)).max().unwrap();
|
||||
assert!(s.doc.next_id > max_id);
|
||||
@@ -186,6 +206,30 @@ fn redefine_keeps_allocator_above_component_and_occurrence_ids() {
|
||||
assert_eq!(ids.len(), unique.len(), "feature, component and occurrence ids must remain unique: {ids:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redefine_cannot_publish_a_second_transfer_that_breaks_existing_ownership() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"body_name":"Base"}));
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"corner":[1,0,0],"body_name":"Tool"}));
|
||||
run(&mut s, "solid.combine", json!({"target":"Base","tools":["Tool"],"name":"Join","new_component":true,"component_name":"Original"}));
|
||||
let full = s.doc.to_json();
|
||||
let join_index = s.doc.features.iter().position(|feature| feature.name == "Join").unwrap();
|
||||
for marker in [None, Some(join_index), Some(join_index + 1)] {
|
||||
let mut s = Session::new(solvecraft_doc::Document::from_json(&full).unwrap());
|
||||
s.doc_mut().marker = marker;
|
||||
s.refresh();
|
||||
let before = s.doc.to_json();
|
||||
let pending = s.pending_moves.clone();
|
||||
let error = s.execute("timeline.redefine", &json!({"feature":"Join","command":"solid.combine","params":{"target":"Base","tools":["Tool"],"new_component":true,"component_name":"Duplicate"}})).unwrap_err();
|
||||
assert!(error.to_string().contains("transfer source component"), "marker {marker:?}: {error}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
}
|
||||
let reopened = Session::new(solvecraft_doc::Document::from_json(&full).unwrap());
|
||||
assert_eq!(reopened.world_state().body("Base").unwrap().component, reopened.doc.find_component("Original").unwrap());
|
||||
assert!(reopened.doc.features.iter().all(|feature| reopened.model.result(feature.id).is_some_and(|result| result.error.is_none())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redefine_maps_placement_inputs_in_the_feature_owner_frame() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -158,7 +158,7 @@ pub fn auto_operation(s: &Session, p: &Value) -> Option<&'static str> {
|
||||
let bodies: Vec<_> = st
|
||||
.bodies
|
||||
.iter()
|
||||
.filter(|b| if parts.is_empty() { s.doc.body_component(&b.name, b.feature) == s.active_component } else { parts.contains(&b.name) })
|
||||
.filter(|b| if parts.is_empty() { s.doc.model_body_component(b) == s.active_component } else { parts.contains(&b.name) })
|
||||
.collect();
|
||||
if bodies.is_empty() {
|
||||
return Some("new");
|
||||
@@ -324,7 +324,7 @@ pub(super) fn add_feature(s: &mut Session, p: &Value, kind: FeatureKind) -> Resu
|
||||
if let FeatureKind::Emboss { face: Some(face), .. } = &kind {
|
||||
let state = s.model.state();
|
||||
let body = state.body(&face.body).ok_or_else(|| EngineError::Other("selected cylinder body no longer exists".into()))?;
|
||||
let frame = face.body_frame(&s.doc, body.feature).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let frame = face.body_frame(&s.doc, body).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let inverse = solvecraft_doc::mat_inverse(&frame).ok_or_else(|| EngineError::Other("invalid cylinder placement".into()))?;
|
||||
let at = solvecraft_doc::apply_point(&solvecraft_doc::mat_mul(&inverse, &s.active_component_frame()), face.at);
|
||||
let mut scoped = solvecraft_doc::ModelState::default();
|
||||
@@ -727,7 +727,7 @@ fn combine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if bool_(p, "new_component").unwrap_or(false) {
|
||||
let name = str_(p, "component_name").map(str::to_string).unwrap_or_else(|| format!("{target} Combined"));
|
||||
let c = s.execute("component.create", &json!({"name": name, "activate": false}))?;
|
||||
s.execute("component.move_bodies", &json!({"bodies": [target], "component": c["component"]}))?;
|
||||
super::component::transfer_constructor_result(s, &json!({"bodies": [target], "component": c["component"]}))?;
|
||||
out["component"] = c["component"].clone();
|
||||
}
|
||||
Ok(out)
|
||||
|
||||
@@ -29,7 +29,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("solid.emboss", "Emboss", emboss)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("emboss")
|
||||
.params("sketch, profiles?; depth; mode?: emboss|deboss; targets?; face?: world point [x,y,z] selecting a planar or complete exterior cylindrical wall (required for wrapped orthogonal line profiles)"),
|
||||
.params("sketch, profiles?; depth; mode?: emboss|deboss; targets?; face?: world point [x,y,z] selecting a planar or complete exterior cylindrical wall (inferred when exactly one supported tangent cylinder matches the selected profiles)"),
|
||||
CommandSpec::new("solid.replace_face", "Replace Face", replace_face)
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("offset_face")
|
||||
@@ -192,50 +192,120 @@ fn emboss(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// angular position, and certify their distance before storing an
|
||||
// exact surface anchor for later replay.
|
||||
let at = solvecraft_kernel::cylinder_emboss_anchor(body, index, at).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let original = s.model.state();
|
||||
let owner = s.doc.body_component(name, selected.feature);
|
||||
let paths: Vec<_> = s
|
||||
.doc
|
||||
.placements()
|
||||
.into_iter()
|
||||
.filter(|(comp, path, _)| *comp == owner && s.doc.world_body_name(&original, name, owner, path) == selected.name)
|
||||
.map(|(_, path, _)| path)
|
||||
.collect();
|
||||
if paths.len() != 1 {
|
||||
return Err(bad(cmd, "selected cylinder placement is ambiguous"));
|
||||
}
|
||||
let path = paths.into_iter().next().ok_or_else(|| bad(cmd, "selected cylinder placement"))?;
|
||||
if path.len() > 64 {
|
||||
return Err(bad(cmd, "selected cylinder placement is too deep"));
|
||||
}
|
||||
let author_path = s.active_occurrence_path.clone().unwrap_or_else(|| s.doc.first_path(s.active_component));
|
||||
if author_path.len() > 64 {
|
||||
return Err(bad(cmd, "author placement is too deep"));
|
||||
}
|
||||
let sketch_owner = s.doc.feature(sketch).map(|f| f.component).ok_or_else(|| bad(cmd, "profile sketch no longer exists"))?;
|
||||
let sketch_path = if sketch_owner == owner {
|
||||
path.clone()
|
||||
} else if sketch_owner == s.active_component {
|
||||
author_path.clone()
|
||||
} else {
|
||||
s.doc.first_path(sketch_owner)
|
||||
};
|
||||
if path.iter().chain(&author_path).chain(&sketch_path).any(|id| s.pending_moves.contains_key(id)) {
|
||||
return Err(bad(cmd, "capture pending source, author and profile positions with Capture Position before cylinder emboss"));
|
||||
}
|
||||
let inverse = solvecraft_doc::mat_inverse(&s.active_component_frame()).ok_or_else(|| bad(cmd, "invalid active frame"))?;
|
||||
let at = solvecraft_doc::apply_point(&inverse, at);
|
||||
let face = emboss_selection(s, selected, at)?;
|
||||
check_emboss_positions(s, sketch, selected, &face)?;
|
||||
targets = vec![name.to_owned()];
|
||||
Some(solvecraft_doc::EmbossFace { body: name.to_owned(), at, path, author_path })
|
||||
Some(face)
|
||||
}
|
||||
_ => return Err(bad(cmd, "only planar faces and complete exterior cylindrical walls support emboss")),
|
||||
}
|
||||
} else {
|
||||
None
|
||||
let inferred = infer_emboss_selection(s, sketch, &profiles, &depth, deboss, &targets)?;
|
||||
if let Some(face) = &inferred {
|
||||
targets = vec![face.body.clone()];
|
||||
}
|
||||
inferred
|
||||
};
|
||||
add_feature(s, p, FeatureKind::Emboss { sketch, profiles, depth, deboss, targets, face })
|
||||
}
|
||||
|
||||
fn emboss_selection(s: &Session, selected: &solvecraft_doc::ModelBody, at: Vec3) -> Result<solvecraft_doc::EmbossFace> {
|
||||
let cmd = "solid.emboss";
|
||||
let name = selected.definition_name();
|
||||
let original = s.model.state();
|
||||
let owner = s.doc.model_body_component(selected);
|
||||
let paths: Vec<_> = s
|
||||
.doc
|
||||
.placements()
|
||||
.into_iter()
|
||||
.filter(|(comp, path, _)| *comp == owner && s.doc.world_body_name(&original, name, owner, path) == selected.name)
|
||||
.map(|(_, path, _)| path)
|
||||
.collect();
|
||||
if paths.len() != 1 {
|
||||
return Err(bad(cmd, "selected cylinder placement is ambiguous"));
|
||||
}
|
||||
let path = paths.into_iter().next().ok_or_else(|| bad(cmd, "selected cylinder placement"))?;
|
||||
let author_path = s.active_occurrence_path.clone().unwrap_or_else(|| s.doc.first_path(s.active_component));
|
||||
if path.len() > 64 || author_path.len() > 64 {
|
||||
return Err(bad(cmd, "cylinder placement is too deep"));
|
||||
}
|
||||
let inverse = solvecraft_doc::mat_inverse(&s.active_component_frame()).ok_or_else(|| bad(cmd, "invalid active frame"))?;
|
||||
Ok(solvecraft_doc::EmbossFace { body: name.to_owned(), at: solvecraft_doc::apply_point(&inverse, at), path, author_path })
|
||||
}
|
||||
|
||||
fn check_emboss_positions(s: &Session, sketch: u64, body: &solvecraft_doc::ModelBody, face: &solvecraft_doc::EmbossFace) -> Result<()> {
|
||||
let cmd = "solid.emboss";
|
||||
let owner = s.doc.model_body_component(body);
|
||||
let sketch_owner = s.doc.feature(sketch).map(|f| f.component).ok_or_else(|| bad(cmd, "profile sketch no longer exists"))?;
|
||||
let sketch_path = if sketch_owner == owner {
|
||||
face.path.clone()
|
||||
} else if sketch_owner == s.active_component {
|
||||
face.author_path.clone()
|
||||
} else {
|
||||
s.doc.first_path(sketch_owner)
|
||||
};
|
||||
if face.path.iter().chain(&face.author_path).chain(&sketch_path).any(|id| s.pending_moves.contains_key(id)) {
|
||||
return Err(bad(cmd, "capture pending source, author and profile positions with Capture Position before cylinder emboss"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn infer_emboss_selection(
|
||||
s: &Session,
|
||||
sketch: u64,
|
||||
profiles: &solvecraft_doc::ProfileSel,
|
||||
depth: &str,
|
||||
deboss: bool,
|
||||
targets: &[String],
|
||||
) -> Result<Option<solvecraft_doc::EmbossFace>> {
|
||||
let cmd = "solid.emboss";
|
||||
let original = s.model.state();
|
||||
let ss = original.sketch(sketch).ok_or_else(|| bad(cmd, "the sketch is not evaluated"))?;
|
||||
let regions = solvecraft_doc::select_profiles(ss, profiles).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let sketch_owner = s.doc.feature(sketch).map(|f| f.component).ok_or_else(|| bad(cmd, "profile sketch no longer exists"))?;
|
||||
let d = s.doc.eval(&s.doc.with_design_unit(depth, Kind::Length), Kind::Length).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let world = s.world_state();
|
||||
let placed = s.placed_document();
|
||||
let mut chosen = None;
|
||||
let mut faces = 0usize;
|
||||
for selected in &world.bodies {
|
||||
let name = selected.definition_name();
|
||||
if !targets.is_empty() && !targets.iter().any(|n| n == name || n == &selected.name) {
|
||||
continue;
|
||||
}
|
||||
let raw = original.body(name).ok_or_else(|| bad(cmd, "target definition no longer exists"))?;
|
||||
faces = faces.saturating_add(raw.body.face_count());
|
||||
if faces > 50_000 {
|
||||
return Err(bad(cmd, "cylinder target search exceeds face work limit"));
|
||||
}
|
||||
if !solvecraft_kernel::has_cylinder_emboss_wall(&raw.body).map_err(|e| bad(cmd, e.to_string()))? {
|
||||
continue;
|
||||
}
|
||||
let mut face = emboss_selection(s, selected, Vec3::ZERO)?;
|
||||
let body_frame = face.body_frame(&placed, raw).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let inverse = solvecraft_doc::mat_inverse(&body_frame).ok_or_else(|| bad(cmd, "invalid cylinder body frame"))?;
|
||||
let displayed = world.sketch(sketch).ok_or_else(|| bad(cmd, "the profile sketch is not displayed"))?;
|
||||
let plane = solvecraft_geom::Plane::new(
|
||||
solvecraft_doc::apply_point(&inverse, displayed.plane.origin),
|
||||
solvecraft_doc::apply_vector(&inverse, displayed.plane.x),
|
||||
solvecraft_doc::apply_vector(&inverse, displayed.plane.y),
|
||||
)
|
||||
.ok_or_else(|| bad(cmd, "invalid cylinder sketch frame"))?;
|
||||
if let Some((_, at)) =
|
||||
solvecraft_kernel::infer_cylinder_emboss(&raw.body, &plane, ®ions, if deboss { -d } else { d }).map_err(|e| bad(cmd, e.to_string()))?
|
||||
{
|
||||
face.sketch_frame(&placed, s.active_component, sketch_owner, raw).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
check_emboss_positions(s, sketch, selected, &face)?;
|
||||
let author_inverse = solvecraft_doc::mat_inverse(&s.active_component_frame()).ok_or_else(|| bad(cmd, "invalid author frame"))?;
|
||||
face.at = solvecraft_doc::apply_point(&solvecraft_doc::mat_mul(&author_inverse, &body_frame), at);
|
||||
if chosen.is_some() {
|
||||
return Err(bad(cmd, "multiple tangent cylinders match the selected profiles; select a target face or body"));
|
||||
}
|
||||
chosen = Some(face);
|
||||
}
|
||||
}
|
||||
Ok(chosen)
|
||||
}
|
||||
fn face_points(p: &Value, k: &str, cmd: &str) -> Result<Vec<Vec3>> {
|
||||
let a = p.get(k).and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("`{k}` must list points [x, y, z]")))?;
|
||||
if a.is_empty() || a.len() > 1000 {
|
||||
|
||||
@@ -9,6 +9,17 @@ fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
fn auto_emboss_params(mut p: Value, auto: bool) -> Value {
|
||||
if auto {
|
||||
if let Some(params) = p.get_mut("params").and_then(Value::as_object_mut) {
|
||||
params.remove("face");
|
||||
} else if let Some(object) = p.as_object_mut() {
|
||||
object.remove("face");
|
||||
}
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
fn measure(s: &mut Session) -> Value {
|
||||
run(s, "inspect.measure", json!({}))
|
||||
}
|
||||
@@ -169,7 +180,8 @@ fn cylinder_small_chord_pick_is_saved_on_the_exact_surface() {
|
||||
|
||||
#[test]
|
||||
fn cylinder_cross_component_emboss_keeps_source_owner_and_placements() {
|
||||
for pick_offset in [0., 80.] {
|
||||
for (pick_offset, auto) in [(0., false), (80., false), (0., true), (80., true)] {
|
||||
let run = |s: &mut Session, id: &str, p: Value| run(s, id, auto_emboss_params(p, auto && matches!(id, "solid.emboss" | "timeline.redefine")));
|
||||
let mut s = Session::default();
|
||||
let a = run(&mut s, "component.create", json!({"name":"Source"}))["component"].as_u64().unwrap();
|
||||
run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"}));
|
||||
@@ -255,6 +267,11 @@ fn cylinder_cross_component_emboss_keeps_source_owner_and_placements() {
|
||||
|
||||
#[test]
|
||||
fn cylinder_active_second_source_occurrence_authors_shared_geometry() {
|
||||
check_active_second_source(false);
|
||||
check_active_second_source(true);
|
||||
}
|
||||
fn check_active_second_source(auto: bool) {
|
||||
let run = |s: &mut Session, id: &str, p: Value| run(s, id, auto_emboss_params(p, auto && id == "solid.emboss"));
|
||||
let mut s = Session::default();
|
||||
let component = run(&mut s, "component.create", json!({"name":"Source"}))["component"].as_u64().unwrap();
|
||||
run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"}));
|
||||
@@ -279,7 +296,7 @@ fn cylinder_active_second_source_occurrence_authors_shared_geometry() {
|
||||
|
||||
#[test]
|
||||
fn cylinder_pending_leaf_or_ancestor_positions_require_capture_atomically() {
|
||||
for ancestor in [false, true] {
|
||||
for (ancestor, auto) in [(false, false), (true, false), (false, true), (true, true)] {
|
||||
let mut s = Session::default();
|
||||
let parent = run(&mut s, "component.create", json!({"name":"Parent"}))["occurrence"].as_u64().unwrap();
|
||||
let leaf = run(&mut s, "component.create", json!({"name":"Source"}))["occurrence"].as_u64().unwrap();
|
||||
@@ -291,7 +308,10 @@ fn cylinder_pending_leaf_or_ancestor_positions_require_capture_atomically() {
|
||||
let before = s.doc.to_json();
|
||||
let pending = s.pending_moves.clone();
|
||||
let volume = exact_cylinder_volume(&s);
|
||||
let error = s.execute("solid.emboss", &json!({"sketch":"Badge","profiles":"all","face":[5,10,7.5],"depth":1})).unwrap_err().to_string();
|
||||
let error = s
|
||||
.execute("solid.emboss", &auto_emboss_params(json!({"sketch":"Badge","profiles":"all","face":[5,10,7.5],"depth":1}), auto))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("Capture Position"), "{error}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
@@ -301,6 +321,10 @@ fn cylinder_pending_leaf_or_ancestor_positions_require_capture_atomically() {
|
||||
|
||||
#[test]
|
||||
fn cylinder_pending_third_component_profile_requires_capture_atomically() {
|
||||
check_pending_third_profile(false);
|
||||
check_pending_third_profile(true);
|
||||
}
|
||||
fn check_pending_third_profile(auto: bool) {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"}));
|
||||
let profile_occurrence = run(&mut s, "component.create", json!({"name":"ProfileOwner"}))["occurrence"].as_u64().unwrap();
|
||||
@@ -314,7 +338,10 @@ fn cylinder_pending_third_component_profile_requires_capture_atomically() {
|
||||
let pending = s.pending_moves.clone();
|
||||
let volume = exact_cylinder_volume(&s);
|
||||
let error = s
|
||||
.execute("solid.emboss", &json!({"sketch":"Badge","profiles":"all","face":[0,10,7.5],"depth":1,"targets":["Sleeve"]}))
|
||||
.execute(
|
||||
"solid.emboss",
|
||||
&auto_emboss_params(json!({"sketch":"Badge","profiles":"all","face":[0,10,7.5],"depth":1,"targets":["Sleeve"]}), auto),
|
||||
)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("Capture Position"), "{error}");
|
||||
@@ -325,12 +352,21 @@ fn cylinder_pending_third_component_profile_requires_capture_atomically() {
|
||||
|
||||
#[test]
|
||||
fn cylinder_body_offset_keeps_exact_profile_and_anchor_frames() {
|
||||
check_body_offset(false);
|
||||
check_body_offset(true);
|
||||
}
|
||||
fn check_body_offset(auto: bool) {
|
||||
let run = |s: &mut Session, id: &str, p: Value| run(s, id, auto_emboss_params(p, auto && id == "solid.emboss"));
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"}));
|
||||
let before = exact_cylinder_volume(&s);
|
||||
let holder = run(&mut s, "component.create", json!({"name":"Holder"}))["component"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":"Holder:1","translate":[0,50,0]}));
|
||||
run(&mut s, "component.move_bodies", json!({"bodies":["Sleeve"],"component":holder}));
|
||||
std::sync::Arc::make_mut(&mut s.doc).body_components.insert("Sleeve".into(), holder);
|
||||
std::sync::Arc::make_mut(&mut s.doc)
|
||||
.body_offsets
|
||||
.insert("Sleeve".into(), [[1., 0., 0., 0.], [0., 1., 0., 0.], [0., 0., 1., 0.], [0., -50., 0., 1.]]);
|
||||
s.refresh();
|
||||
assert!(s.doc.body_offsets.contains_key("Sleeve"));
|
||||
let offset = s.doc.body_offsets["Sleeve"];
|
||||
run(&mut s, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"}));
|
||||
@@ -399,3 +435,122 @@ fn coil_and_loft_to_a_point() {
|
||||
assert!(rel(volume(&mut s), 400.0 * 30.0 / 3.0) < 1e-6);
|
||||
assert!(s.execute("solid.loft", &json!({"sections": [{"sketch": "Base"}, {"sketch": "Top", "point": "nope"}]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_auto_inference_saves_a_profile_anchor_and_replays_both_depth_signs() {
|
||||
use solvecraft_geom::Vec3;
|
||||
for mode in ["emboss", "deboss"] {
|
||||
let mut s = cylinder_badge();
|
||||
let before = exact_cylinder_volume(&s);
|
||||
let initial = s.doc.to_json();
|
||||
run(&mut s, "solid.emboss", json!({"sketch":"Badge","profiles":"all","depth":"badge_depth","mode":mode,"name":"Auto Badge"}));
|
||||
let feature = s.doc.find_feature("Auto Badge").unwrap().clone();
|
||||
let solvecraft_doc::FeatureKind::Emboss { face: Some(face), targets, .. } = &feature.kind else {
|
||||
panic!("missing inferred association");
|
||||
};
|
||||
assert_eq!(targets, &["Sleeve"]);
|
||||
assert_eq!(face.body, "Sleeve");
|
||||
assert!(face.at.z > 0. && face.at.z < 20.);
|
||||
assert!((face.at.x.hypot(face.at.y) - 10.).abs() < 1e-10);
|
||||
assert!(!feature.face_names.first().unwrap().is_empty());
|
||||
let expected = if mode == "emboss" { 21. } else { -19. };
|
||||
assert!((exact_cylinder_volume(&s) - before - expected).abs() < 0.02);
|
||||
let state = s.model.state();
|
||||
let body = &state.body("Sleeve").unwrap().body;
|
||||
assert_eq!(body.shell_count(), 1);
|
||||
assert_eq!(
|
||||
body.tessellate_with(0.001, true).unwrap().contains(Vec3::new(0., if mode == "emboss" { 10.5 } else { 9.5 }, 7.5)),
|
||||
mode == "emboss"
|
||||
);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), initial);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(s.doc.find_feature("Auto Badge").unwrap().kind, feature.kind);
|
||||
let mut loaded = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Default::default() };
|
||||
loaded.refresh();
|
||||
assert!(loaded.model.results.iter().all(|r| r.error.is_none()), "{:?}", loaded.model.results);
|
||||
run(
|
||||
&mut loaded,
|
||||
"timeline.redefine",
|
||||
json!({"feature":feature.id,"command":"solid.emboss","params":{"sketch":"Badge","profiles":"all","depth":"badge_depth","mode":mode}}),
|
||||
);
|
||||
assert_eq!(loaded.doc.feature(feature.id).unwrap().kind, feature.kind);
|
||||
assert_eq!(loaded.doc.feature(feature.id).unwrap().face_names, feature.face_names);
|
||||
run(&mut loaded, "parameters.change", json!({"name":"badge_depth","expression":"2mm"}));
|
||||
assert!((exact_cylinder_volume(&loaded) - before - if mode == "emboss" { 44. } else { -36. }).abs() < 0.02);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_auto_inference_ignores_remote_finite_tangent_walls_in_each_body_order() {
|
||||
for remote_first in [false, true] {
|
||||
let mut s = Session::default();
|
||||
for (name, z) in if remote_first { [("Remote", 100.), ("Sleeve", 0.)] } else { [("Sleeve", 0.), ("Remote", 100.)] } {
|
||||
run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"base":[0,0,z],"body_name":name}));
|
||||
}
|
||||
run(&mut s, "solid.cylinder", json!({"radius":8,"height":10,"base":[0,0,120],"body_name":"Remote Step"}));
|
||||
run(&mut s, "solid.combine", json!({"target":"Remote","tools":["Remote Step"],"operation":"join"}));
|
||||
run(&mut s, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let before = exact_cylinder_volume(&s);
|
||||
let remote = s.model.state().body("Remote").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume;
|
||||
run(&mut s, "solid.emboss", json!({"sketch":"Badge","depth":1}));
|
||||
assert!((exact_cylinder_volume(&s) - before - 21.).abs() < 0.02);
|
||||
assert_eq!(s.model.state().body("Remote").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume, remote);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_auto_inference_retains_selected_holes_and_nested_islands() {
|
||||
use solvecraft_geom::Vec3;
|
||||
for mode in ["emboss", "deboss"] {
|
||||
let mut s = cylinder_badge();
|
||||
run(&mut s, "sketch.edit", json!({"sketch":"Badge"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-1,6],"p1":[1,9]}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-0.5,6.5],"p1":[0.5,8.5]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let before = exact_cylinder_volume(&s);
|
||||
run(&mut s, "solid.emboss", json!({"sketch":"Badge","profiles":[{"point":[1.5,7.5]},{"point":[0,7.5]}],"depth":1,"mode":mode}));
|
||||
let depth = if mode == "emboss" { 1. } else { -1. };
|
||||
let expected = 16. * (depth + depth * depth / 20.);
|
||||
assert!((exact_cylinder_volume(&s) - before - expected).abs() < 0.02);
|
||||
let st = s.model.state();
|
||||
let body = &st.body("Sleeve").unwrap().body;
|
||||
assert_eq!(body.shell_count(), 1);
|
||||
let mesh = body.tessellate_with(0.001, true).unwrap();
|
||||
for (s, raised) in [(0., true), (0.75, false), (1.5, true)] {
|
||||
let theta = s / 10.0_f64;
|
||||
let radius = if mode == "emboss" { 10.5 } else { 9.5 };
|
||||
assert_eq!(mesh.contains(Vec3::new(-radius * theta.sin(), radius * theta.cos(), 7.5)), if mode == "emboss" { raised } else { !raised });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_auto_inference_refuses_ambiguity_curves_and_seam_crossing_atomically() {
|
||||
let mut s = cylinder_badge();
|
||||
run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Coincident"}));
|
||||
let before = s.doc.to_json();
|
||||
let error = s.execute("solid.emboss", &json!({"sketch":"Badge","depth":1})).unwrap_err().to_string();
|
||||
assert!(error.contains("multiple tangent cylinders"), "{error}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
run(&mut s, "solid.emboss", json!({"sketch":"Badge","depth":1,"targets":["Sleeve"]}));
|
||||
assert!(matches!(&s.doc.features.last().unwrap().kind,solvecraft_doc::FeatureKind::Emboss{face:Some(face),..} if face.body=="Sleeve"));
|
||||
for circle in [false, true] {
|
||||
let mut s = cylinder_badge();
|
||||
run(&mut s, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Unsupported"}));
|
||||
if circle {
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[0,7.5],"radius":1}));
|
||||
} else {
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-20,5],"p1":[20,10]}));
|
||||
}
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let before = s.doc.to_json();
|
||||
let volume = exact_cylinder_volume(&s);
|
||||
let error = s.execute("solid.emboss", &json!({"sketch":"Unsupported","depth":1})).unwrap_err().to_string();
|
||||
assert!(error.contains(if circle { "orthogonal line" } else { "seam" }), "{error}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert_eq!(exact_cylinder_volume(&s), volume);
|
||||
}
|
||||
}
|
||||
@@ -401,7 +401,7 @@ fn appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, "too many face appearances"));
|
||||
}
|
||||
let name = st_names.iter().find(|(b, p, _)| b == body && p.dist(*pt) < 1e-12).and_then(|(_, _, n)| n.clone());
|
||||
a.faces.push(FaceLook { body: body.clone(), point: *pt, look: l.clone(), name, strict_name: false });
|
||||
a.faces.push(FaceLook { body: body.clone(), point: *pt, look: l.clone(), name, strict_name: false, certificate: None });
|
||||
}
|
||||
}
|
||||
for c in &comps {
|
||||
|
||||
@@ -85,7 +85,7 @@ pub(crate) fn occurrence_at(s: &Session, p: Vec3) -> Option<u64> {
|
||||
let st = s.model.state();
|
||||
let mut best: Option<(f64, u64)> = None;
|
||||
for b in &st.bodies {
|
||||
let comp = s.doc.body_component(&b.name, b.feature);
|
||||
let comp = s.doc.model_body_component(b);
|
||||
let occs: Vec<u64> = if comp == 0 { vec![0] } else { s.doc.occurrences.iter().filter(|o| o.component == comp).map(|o| o.id).collect() };
|
||||
for occ in occs {
|
||||
let t = if occ == 0 { solvecraft_doc::IDENTITY } else { joints::occurrence_world(&s.doc, occ) };
|
||||
|
||||
@@ -89,11 +89,11 @@ fn placed(s: &Session) -> Vec<(Vec<u64>, Body)> {
|
||||
if comp == 0 {
|
||||
continue;
|
||||
}
|
||||
for b in st.bodies.iter().filter(|b| s.doc.body_component(&b.name, b.feature) == comp && !b.body.is_mesh()) {
|
||||
for b in st.bodies.iter().filter(|b| s.doc.model_body_component(b) == comp && !b.body.is_mesh()) {
|
||||
// Moving a body into another component preserves its world placement using an
|
||||
// offset from its authoring frame. Contact and explode centres must use that
|
||||
// same offset as display and physical properties.
|
||||
let frame = s.doc.body_offsets.get(&b.name).map(|offset| solvecraft_doc::mat_mul(&m, offset)).unwrap_or(m);
|
||||
let frame = s.doc.model_body_offset(b).map(|offset| solvecraft_doc::mat_mul(&m, offset)).unwrap_or(m);
|
||||
if let Ok(t) = solvecraft_kernel::transform_matrix(&b.body, frame) {
|
||||
out.push((path.clone(), t));
|
||||
}
|
||||
|
||||
@@ -305,7 +305,12 @@ fn hostile_moves_copies_and_reused_ids_do_not_leak_state() {
|
||||
run(&mut s, "component.capture_position", json!({}));
|
||||
assert!(s.pending_moves.is_empty());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.occurrence_placement(&[other]).unwrap().world[3][0], 0.0);
|
||||
assert_eq!(s.pending_moves.get(&other).unwrap()[3][0], 20.0);
|
||||
assert_eq!(s.doc.occurrences.iter().find(|occurrence| occurrence.id == other).unwrap().transform[3][0], 0.0);
|
||||
assert_eq!(s.occurrence_placement(&[other]).unwrap().world[3][0], 20.0);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert!(s.pending_moves.is_empty());
|
||||
assert_eq!(s.doc.occurrences.iter().find(|occurrence| occurrence.id == other).unwrap().transform[3][0], 20.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -464,7 +464,7 @@ mod tests {
|
||||
let st = s.world_state();
|
||||
let comp = s.doc.components.iter().find(|c| c.name.starts_with(prefix)).map(|c| c.id).unwrap_or(0);
|
||||
let (mut lo, mut hi) = (solvecraft_geom::Vec3::new(f64::MAX, f64::MAX, f64::MAX), solvecraft_geom::Vec3::new(f64::MIN, f64::MIN, f64::MIN));
|
||||
for b in st.bodies.iter().filter(|b| s.doc.body_component(&b.name, b.feature) == comp) {
|
||||
for b in st.bodies.iter().filter(|b| s.doc.model_body_component(b) == comp) {
|
||||
let m = b.mesh().bounds();
|
||||
lo = solvecraft_geom::Vec3::new(lo.x.min(m.min.x), lo.y.min(m.min.y), lo.z.min(m.min.z));
|
||||
hi = solvecraft_geom::Vec3::new(hi.x.max(m.max.x), hi.y.max(m.max.y), hi.z.max(m.max.z));
|
||||
|
||||
@@ -293,8 +293,24 @@ fn flat_json(sh: &SheetBody) -> Result<Value> {
|
||||
"allowance": b.allowance, "length": b.length, "start": pt(&b.start), "end": pt(&b.end)})
|
||||
})
|
||||
.collect();
|
||||
let pockets: Vec<Value> = sh
|
||||
.machining()?
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let side = if p.z0 == 0.0 {
|
||||
"bottom"
|
||||
} else if p.z1 == sh.t {
|
||||
"top"
|
||||
} else {
|
||||
"internal"
|
||||
};
|
||||
json!({"outline": p.region.outer.polyline(1e-3).iter().map(pt).collect::<Vec<_>>(),
|
||||
"cutouts": p.region.holes.iter().map(|h| h.polyline(1e-3).iter().map(pt).collect::<Vec<_>>()).collect::<Vec<_>>(),
|
||||
"side": side, "depth_mm": p.z1-p.z0, "z0_mm": p.z0, "z1_mm": p.z1})
|
||||
})
|
||||
.collect();
|
||||
Ok(
|
||||
json!({"body": sh.body, "rule": sh.rule, "thickness": sh.t, "k_factor": sh.k, "flat_size_mm": [w, h, sh.t], "outline": outline, "cutouts": holes, "bends": bends, "unfolded": sh.flat}),
|
||||
json!({"body": sh.body, "rule": sh.rule, "thickness": sh.t, "k_factor": sh.k, "flat_size_mm": [w, h, sh.t], "outline": outline, "cutouts": holes, "pockets": pockets, "bends": bends, "unfolded": sh.flat}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -305,6 +321,22 @@ fn flat_pattern(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// A DXF (R12 entities, mm) of the flat pattern on named layers.
|
||||
pub fn flat_dxf(sh: &SheetBody) -> Result<String> {
|
||||
let regions = sh.flat_regions()?;
|
||||
let pockets = sh.machining()?;
|
||||
let pocket_layer = |p: &solvecraft_doc::sheet::SheetPocket| {
|
||||
let side = if p.z0 == 0.0 {
|
||||
"BOTTOM"
|
||||
} else if p.z1 == sh.t {
|
||||
"TOP"
|
||||
} else {
|
||||
"INTERNAL"
|
||||
};
|
||||
format!("POCKET_{side}_Z{:.6}_TO_{:.6}_MM", p.z0, p.z1)
|
||||
};
|
||||
let mut layers = std::collections::BTreeSet::from(["OUTLINE".to_string(), "BEND_UP".to_string(), "BEND_DOWN".to_string()]);
|
||||
for p in &pockets {
|
||||
layers.insert(pocket_layer(p));
|
||||
}
|
||||
let mut out = String::new();
|
||||
let mut w = |c: i32, v: &str| out.push_str(&format!("{c}\n{v}\n"));
|
||||
w(0, "SECTION");
|
||||
@@ -316,8 +348,14 @@ pub fn flat_dxf(sh: &SheetBody) -> Result<String> {
|
||||
w(2, "TABLES");
|
||||
w(0, "TABLE");
|
||||
w(2, "LAYER");
|
||||
w(70, "3");
|
||||
for (name, colour) in [("OUTLINE", "7"), ("BEND_UP", "1"), ("BEND_DOWN", "5")] {
|
||||
w(70, &layers.len().to_string());
|
||||
for name in &layers {
|
||||
let colour = match name.as_str() {
|
||||
"OUTLINE" => "7",
|
||||
"BEND_UP" => "1",
|
||||
"BEND_DOWN" => "5",
|
||||
_ => "3",
|
||||
};
|
||||
w(0, "LAYER");
|
||||
w(2, name);
|
||||
w(70, "0");
|
||||
@@ -337,8 +375,13 @@ pub fn flat_dxf(sh: &SheetBody) -> Result<String> {
|
||||
w(11, &f(b.x));
|
||||
w(21, &f(b.y));
|
||||
};
|
||||
let regions = sh.flat_regions()?;
|
||||
for h in regions.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)) {
|
||||
let mut loops: Vec<_> = regions.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)).map(|lp| (lp, "OUTLINE".to_string())).collect();
|
||||
for p in &pockets {
|
||||
for lp in std::iter::once(&p.region.outer).chain(&p.region.holes) {
|
||||
loops.push((lp, pocket_layer(p)));
|
||||
}
|
||||
}
|
||||
for (h, layer) in loops {
|
||||
// A circle is often represented by two semicircles. Keep the whole analytic
|
||||
// boundary as one CIRCLE rather than approximating outer boundaries with chords.
|
||||
if let Some(solvecraft_geom::Seg2::Arc { center, radius, sweep, .. }) = h.segs.first() {
|
||||
@@ -346,7 +389,7 @@ pub fn flat_dxf(sh: &SheetBody) -> Result<String> {
|
||||
let circular = h.segs.iter().all(|s| matches!(s, solvecraft_geom::Seg2::Arc { center: c, radius: r, sweep: a, .. } if c.dist(*center) < 1e-9 && (*r-*radius).abs() < 1e-9 && a.signum() == sweep.signum()));
|
||||
if circular && (total.abs() - std::f64::consts::TAU).abs() < 1e-9 {
|
||||
w(0, "CIRCLE");
|
||||
w(8, "OUTLINE");
|
||||
w(8, &layer);
|
||||
w(10, &f(center.x));
|
||||
w(20, &f(center.y));
|
||||
w(40, &f(*radius));
|
||||
@@ -358,7 +401,7 @@ pub fn flat_dxf(sh: &SheetBody) -> Result<String> {
|
||||
solvecraft_geom::Seg2::Arc { center, radius, start, sweep } => {
|
||||
if (sweep.abs() - std::f64::consts::TAU).abs() < 1e-9 {
|
||||
w(0, "CIRCLE");
|
||||
w(8, "OUTLINE");
|
||||
w(8, &layer);
|
||||
w(10, &f(center.x));
|
||||
w(20, &f(center.y));
|
||||
w(40, &f(radius));
|
||||
@@ -366,7 +409,7 @@ pub fn flat_dxf(sh: &SheetBody) -> Result<String> {
|
||||
}
|
||||
let (a0, a1) = if sweep >= 0.0 { (start, start + sweep) } else { (start + sweep, start) };
|
||||
w(0, "ARC");
|
||||
w(8, "OUTLINE");
|
||||
w(8, &layer);
|
||||
w(10, &f(center.x));
|
||||
w(20, &f(center.y));
|
||||
w(40, &f(radius));
|
||||
@@ -376,7 +419,7 @@ pub fn flat_dxf(sh: &SheetBody) -> Result<String> {
|
||||
other => {
|
||||
let pts = other.polyline(1e-3);
|
||||
for p in pts.windows(2) {
|
||||
line(&mut w, "OUTLINE", p[0], p[1]);
|
||||
line(&mut w, &layer, p[0], p[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,363 @@ fn base(s: &mut Session, w: f64, h: f64) {
|
||||
run(s, "sheet.flange", json!({"sketch": "Base"}));
|
||||
}
|
||||
|
||||
fn rectangle_tool(s: &mut Session, name: &str, p0: [f64; 2], p1: [f64; 2]) {
|
||||
run(s, "sketch.create", json!({"plane":"XY","name":name}));
|
||||
run(s, "sketch.rectangle.two_point", json!({"p0":p0,"p1":p1}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shallow_sheet_pocket_preserves_material_depth_export_undo_and_reopen() {
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
rectangle_tool(&mut s, "Pocket", [20.0, 20.0], [30.0, 30.0]);
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"Pocket","distance":1,"operation":"cut"}));
|
||||
assert!((volume(&mut s) - 14900.0).abs() < 1e-6);
|
||||
let f = flat(&mut s);
|
||||
assert_eq!(f["cutouts"], json!([]));
|
||||
assert_eq!(f["pockets"][0]["side"], "bottom");
|
||||
assert_eq!(f["pockets"][0]["depth_mm"], 1.0);
|
||||
let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string();
|
||||
assert!(dxf.contains("POCKET_BOTTOM_Z0.000000_TO_1.000000_MM"));
|
||||
assert_eq!(solvecraft_io::read_dxf(&dxf).unwrap().len(), 8);
|
||||
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!({}));
|
||||
assert!((volume(&mut s) - 14900.0).abs() < 1e-6);
|
||||
let state = s.model.state();
|
||||
let body = &state.bodies[0].body;
|
||||
assert!(!body.contains(solvecraft_geom::Vec3::new(25.0, 25.0, 0.5)));
|
||||
assert!(body.contains(solvecraft_geom::Vec3::new(25.0, 25.0, 1.5)));
|
||||
}
|
||||
let mut reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert!((volume(&mut reopened) - 14900.0).abs() < 1e-6);
|
||||
assert_eq!(flat(&mut reopened)["pockets"], f["pockets"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planar_sheet_intersection_retains_exact_outline_and_islands() {
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
rectangle_tool(&mut s, "Retain", [20.0, 20.0], [30.0, 30.0]);
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"Retain","through_all":true,"direction":"symmetric","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) - 250.0).abs() < 1e-6);
|
||||
assert_eq!(flat(&mut s)["flat_size_mm"], json!([10.0, 10.0, 2.5]));
|
||||
}
|
||||
let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string();
|
||||
assert_eq!(solvecraft_io::read_dxf(&dxf).unwrap().len(), 4);
|
||||
let mut imported = Session::default();
|
||||
run(&mut imported, "sketch.insert_dxf", json!({"name":"Retained","text":dxf}));
|
||||
run(&mut imported, "sketch.finish", json!({}));
|
||||
run(&mut imported, "solid.extrude", json!({"sketch":"Retained","distance":2.5}));
|
||||
assert!((volume(&mut imported) - 250.0).abs() < 1e-6);
|
||||
// An annular retained region keeps its central hole, with no first-solid shortcut.
|
||||
let mut annulus = Session::default();
|
||||
base(&mut annulus, 100.0, 60.0);
|
||||
run(&mut annulus, "sketch.create", json!({"plane":"XY","name":"Ring"}));
|
||||
run(&mut annulus, "sketch.circle.center", json!({"center":[50,30],"radius":10}));
|
||||
run(&mut annulus, "sketch.circle.center", json!({"center":[50,30],"radius":5}));
|
||||
run(&mut annulus, "sketch.finish", json!({}));
|
||||
run(
|
||||
&mut annulus,
|
||||
"solid.extrude",
|
||||
json!({"sketch":"Ring","profiles":[{"point":[57,30]}],"through_all":true,"direction":"symmetric","operation":"intersect"}),
|
||||
);
|
||||
run(&mut annulus, "sheet.unfold", json!({}));
|
||||
let area: f64 = annulus.model.state().sheets[0].flat_regions().unwrap().iter().map(|r| r.area()).sum();
|
||||
assert!((area - PI * 75.0).abs() < 1e-8, "exact retained area: {area}");
|
||||
// inspect.measure integrates a tessellation; exact arc area is checked above.
|
||||
let measured = volume(&mut annulus);
|
||||
assert!((measured - PI * 75.0 * 2.5).abs() < 0.1, "measured={measured}");
|
||||
assert_eq!(flat(&mut annulus)["cutouts"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_opposite_side_pockets_become_true_through_material() {
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
rectangle_tool(&mut s, "Bottom", [20.0, 20.0], [30.0, 30.0]);
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"Bottom","distance":1.5,"operation":"cut"}));
|
||||
rectangle_tool(&mut s, "Top", [25.0, 20.0], [35.0, 30.0]);
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"Top","start_offset":2.5,"direction":"negative","distance":1.5,"operation":"cut"}));
|
||||
let expected = 15000.0 - 100.0 * 1.5 * 2.0 + 50.0 * 0.5;
|
||||
for command in [None, Some("sheet.unfold"), Some("sheet.refold")] {
|
||||
if let Some(command) = command {
|
||||
run(&mut s, command, json!({}));
|
||||
}
|
||||
assert!((volume(&mut s) - expected).abs() < 1e-6);
|
||||
assert_eq!(flat(&mut s)["cutouts"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(flat(&mut s)["pockets"].as_array().unwrap().len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pockets_survive_forming_away_from_bends_and_refuse_bend_overlap_atomically() {
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
rectangle_tool(&mut s, "Pocket", [20.0, 20.0], [30.0, 30.0]);
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"Pocket","distance":1,"operation":"cut"}));
|
||||
run(&mut s, "sheet.flange", json!({"edges":[[50,0,2.5]],"height":20}));
|
||||
assert!((volume(&mut s) - (18972.62196081845 - 100.0)).abs() < 0.2);
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
let unfolded = volume(&mut s);
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
assert!((volume(&mut s) - (18972.62196081845 - 100.0)).abs() < 0.2);
|
||||
assert!(unfolded > 14900.0);
|
||||
let mut cross = Session::default();
|
||||
base(&mut cross, 100.0, 60.0);
|
||||
rectangle_tool(&mut cross, "Pocket", [45.0, 20.0], [55.0, 30.0]);
|
||||
run(&mut cross, "solid.extrude", json!({"sketch":"Pocket","distance":1,"operation":"cut"}));
|
||||
let before = cross.doc.to_json();
|
||||
let undo = cross.undo.len();
|
||||
let error = cross.execute("sheet.fold", &json!({"points":[[50,0,2.5],[50,60,2.5]]})).unwrap_err().to_string();
|
||||
assert!(error.contains("pocket") && error.contains("bend"), "{error}");
|
||||
assert_eq!(cross.doc.to_json(), before);
|
||||
assert_eq!(cross.undo.len(), undo);
|
||||
assert!((volume(&mut cross) - 14900.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_sheet_machining_is_atomic_but_true_no_change_is_allowed() {
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
rectangle_tool(&mut s, "Taper", [20.0, 20.0], [30.0, 30.0]);
|
||||
let before = s.doc.to_json();
|
||||
let undo = s.undo.len();
|
||||
let error = s.execute("solid.extrude", &json!({"sketch":"Taper","distance":1,"operation":"cut","taper":"5 deg"})).unwrap_err().to_string();
|
||||
assert!(error.contains("sheet") && error.contains("preserv"), "{error}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
rectangle_tool(&mut s, "Outside", [120.0, 20.0], [130.0, 30.0]);
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"Outside","distance":1,"operation":"cut","taper":"5 deg"}));
|
||||
assert!((volume(&mut s) - 15000.0).abs() < 1e-6);
|
||||
rectangle_tool(&mut s, "All", [-20.0, -20.0], [120.0, 80.0]);
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"All","through_all":true,"direction":"symmetric","operation":"intersect"}));
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
assert!((volume(&mut s) - 15000.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_sheet_intersection_removes_geometry_and_metadata_together() {
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
rectangle_tool(&mut s, "Outside", [120.0, 20.0], [130.0, 30.0]);
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"Outside","through_all":true,"direction":"symmetric","operation":"intersect"}));
|
||||
assert!(s.model.state().bodies.is_empty());
|
||||
assert!(s.model.state().sheets.is_empty());
|
||||
assert!(s.execute("sheet.unfold", &json!({})).is_err());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!((volume(&mut s) - 15000.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intersect_preserves_both_material_regions_of_a_logical_ring_sheet() {
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY","name":"Ring"}));
|
||||
for radius in [10.0, 5.0] {
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[50,30],"radius":radius}));
|
||||
}
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(
|
||||
&mut s,
|
||||
"solid.extrude",
|
||||
json!({"sketch":"Ring","profiles":[{"point":[57,30]}],"through_all":true,"direction":"symmetric","operation":"cut"}),
|
||||
);
|
||||
rectangle_tool(&mut s, "Crop", [35.0, 15.0], [65.0, 45.0]);
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"Crop","through_all":true,"direction":"symmetric","operation":"intersect"}));
|
||||
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 regions = state.sheets[0].flat_regions().unwrap();
|
||||
assert_eq!(regions.len(), 2);
|
||||
let area: f64 = regions.iter().map(|r| r.area()).sum();
|
||||
assert!((area - (900.0 - PI * 75.0)).abs() < 1e-8, "area={area}");
|
||||
let body = &state.bodies[0].body;
|
||||
assert!(body.contains(solvecraft_geom::Vec3::new(50.0, 30.0, 1.0)));
|
||||
assert!(!body.contains(solvecraft_geom::Vec3::new(57.0, 30.0, 1.0)));
|
||||
assert!(body.contains(solvecraft_geom::Vec3::new(64.0, 30.0, 1.0)));
|
||||
assert!(!body.contains(solvecraft_geom::Vec3::new(70.0, 30.0, 1.0)));
|
||||
assert!((volume(&mut s) - (900.0 - PI * 75.0) * 2.5).abs() < 0.2);
|
||||
}
|
||||
let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string();
|
||||
assert_eq!(solvecraft_io::read_dxf(&dxf).unwrap().len(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn machining_a_folded_parallel_panel_preserves_its_pocket_on_unfold() {
|
||||
use solvecraft_doc::{apply_point, apply_vector};
|
||||
use solvecraft_geom::Vec3;
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
run(&mut s, "sheet.flange", json!({"edges":[[50,0,2.5]],"height":20}));
|
||||
let sh = s.model.state().sheets[0].clone();
|
||||
let points = sh.panel(&sh.flanges[0]).outer.polyline(1e-3);
|
||||
let c = points.iter().copied().fold(solvecraft_geom::Vec2::ZERO, |sum, p| sum + p) / (points.len() as f64);
|
||||
let w = sh.world(Some(0));
|
||||
let origin = apply_point(&w, Vec3::ZERO);
|
||||
let x = apply_vector(&w, Vec3::X);
|
||||
let y = apply_vector(&w, Vec3::Y);
|
||||
run(
|
||||
&mut s,
|
||||
"sketch.create",
|
||||
json!({"name":"PanelPocket","plane":{"origin":[origin.x,origin.y,origin.z],"x_dir":[x.x,x.y,x.z],"y_dir":[y.x,y.y,y.z]}}),
|
||||
);
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[c.x-2.0,c.y-2.0],"p1":[c.x+2.0,c.y+2.0]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"PanelPocket","distance":1,"operation":"cut"}));
|
||||
assert!((volume(&mut s) - (18972.62196081845 - 16.0)).abs() < 0.2);
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
let state = s.model.state();
|
||||
let body = &state.bodies[0].body;
|
||||
assert!(!body.contains(Vec3::new(c.x, c.y, 0.5)));
|
||||
assert!(body.contains(Vec3::new(c.x, c.y, 1.5)));
|
||||
assert_eq!(flat(&mut s)["pockets"][0]["depth_mm"], 1.0);
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
assert!((volume(&mut s) - (18972.62196081845 - 16.0)).abs() < 0.2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flipped_sheet_pocket_keeps_depth_in_the_local_thickness_direction() {
|
||||
let mut s = Session::default();
|
||||
rectangle_tool(&mut s, "Base", [0.0, 0.0], [100.0, 60.0]);
|
||||
run(&mut s, "sheet.flange", json!({"sketch":"Base","flip":true}));
|
||||
rectangle_tool(&mut s, "Pocket", [20.0, 20.0], [30.0, 30.0]);
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"Pocket","distance":1,"direction":"negative","operation":"cut"}));
|
||||
for command in [None, Some("sheet.unfold"), Some("sheet.refold")] {
|
||||
if let Some(command) = command {
|
||||
run(&mut s, command, json!({}));
|
||||
}
|
||||
assert!((volume(&mut s) - 14900.0).abs() < 1e-6);
|
||||
let state = s.model.state();
|
||||
assert!(!state.bodies[0].body.contains(solvecraft_geom::Vec3::new(25.0, 25.0, -0.5)));
|
||||
assert!(state.bodies[0].body.contains(solvecraft_geom::Vec3::new(25.0, 25.0, -1.5)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_profile_intersect_keeps_tool_union_for_solids_and_sheet_groups() {
|
||||
for sheet in [false, true] {
|
||||
let mut s = Session::default();
|
||||
if sheet {
|
||||
base(&mut s, 100.0, 60.0);
|
||||
} else {
|
||||
run(&mut s, "solid.box", json!({"length":100,"width":60,"height":2.5,"body_name":"Blank"}));
|
||||
}
|
||||
let target = s.model.state().bodies[0].name.clone();
|
||||
run(&mut s, "solid.box", json!({"corner":[200,0,0],"length":10,"width":10,"height":10,"body_name":"Control"}));
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY","name":"TwoIslands"}));
|
||||
for x in [20.0, 60.0] {
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[x,20],"p1":[x+10.0,30]}));
|
||||
}
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"TwoIslands","operation":"intersect","distance":3,"targets":[target]}));
|
||||
assert!((volume(&mut s) - 1500.0).abs() < 1e-6);
|
||||
if sheet {
|
||||
for command in ["sheet.unfold", "sheet.refold"] {
|
||||
run(&mut s, command, json!({"body":target}));
|
||||
assert!((volume(&mut s) - 1500.0).abs() < 1e-6);
|
||||
assert_eq!(flat(&mut s)["outline"].as_array().unwrap().len(), 2);
|
||||
}
|
||||
}
|
||||
let state = s.model.state();
|
||||
for x in [25.0, 65.0] {
|
||||
assert!(state.bodies.iter().any(|b| b.body.contains(solvecraft_geom::Vec3::new(x, 25.0, 1.0))));
|
||||
}
|
||||
assert!(!state.bodies.iter().any(|b| b.body.contains(solvecraft_geom::Vec3::new(50.0, 25.0, 1.0))));
|
||||
assert!(state.bodies.iter().any(|b| b.name == "Control" && b.body.contains(solvecraft_geom::Vec3::new(205.0, 5.0, 5.0))));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_sheet_material_prunes_cosmetic_threads_and_undo_restores_them() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY","name":"ThreadPlate"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[100,60]}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[50,30],"radius":5}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sheet.flange", json!({"sketch":"ThreadPlate","profiles":[{"point":[10,10]}]}));
|
||||
run(&mut s, "solid.thread", json!({"face":[55,30,1],"designation":"M10"}));
|
||||
assert_eq!(s.model.state().threads.len(), 1);
|
||||
rectangle_tool(&mut s, "Outside", [120.0, 20.0], [130.0, 30.0]);
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"Outside","through_all":true,"direction":"symmetric","operation":"intersect"}));
|
||||
assert!(s.model.state().bodies.is_empty());
|
||||
assert!(s.model.state().sheets.is_empty());
|
||||
assert!(s.model.state().threads.is_empty());
|
||||
assert!(solvecraft_doc::world_state(&s.doc, &s.model.state()).threads.is_empty());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.model.state().threads.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sheet_pocket_transfer_keeps_local_depth_world_pose_and_history() {
|
||||
use solvecraft_geom::Vec3;
|
||||
fn check(s: &Session, name: &str, owner: u64, local_origin: Vec3, local_y: Vec3, transferred: bool) {
|
||||
let state = s.model.state();
|
||||
let mb = state.body(name).unwrap();
|
||||
assert_eq!(mb.component, owner);
|
||||
assert_eq!(mb.transferred, transferred);
|
||||
let sh = state.sheets.iter().find(|sh| sh.body == name).unwrap();
|
||||
assert!(sh.frame.origin.dist(local_origin) < 1e-8, "{:?}", sh.frame);
|
||||
assert!(sh.frame.x.dist(Vec3::X) < 1e-8);
|
||||
assert!(sh.frame.y.dist(local_y) < 1e-8);
|
||||
assert_eq!(sh.pockets.len(), 1);
|
||||
assert_eq!((sh.pockets[0].z0, sh.pockets[0].z1), (0.0, 1.0));
|
||||
let cut = sh.frame.to_world(solvecraft_geom::Vec2::new(25.0, 25.0)) + sh.frame.normal() * 0.5;
|
||||
let floor = sh.frame.to_world(solvecraft_geom::Vec2::new(25.0, 25.0)) + sh.frame.normal() * 1.5;
|
||||
assert!(!mb.body.contains(cut));
|
||||
assert!(mb.body.contains(floor));
|
||||
let world = s.world_state();
|
||||
let body = &world.body(name).unwrap().body;
|
||||
let bounds = body.tessellate(1e-3).unwrap().bounds();
|
||||
assert!(bounds.min.dist(Vec3::ZERO) < 1e-6, "{bounds:?}");
|
||||
assert!(bounds.max.dist(Vec3::new(100.0, 60.0, 2.5)) < 1e-6, "{bounds:?}");
|
||||
assert!(!body.contains(Vec3::new(25.0, 25.0, 0.5)));
|
||||
assert!(body.contains(Vec3::new(25.0, 25.0, 1.5)));
|
||||
assert!((solvecraft_kernel::measure(body).unwrap().volume - 14900.0).abs() < 1e-6);
|
||||
}
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
rectangle_tool(&mut s, "Pocket", [20.0, 20.0], [30.0, 30.0]);
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"Pocket","distance":1,"operation":"cut"}));
|
||||
let name = s.model.state().sheets[0].body.clone();
|
||||
check(&s, &name, 0, Vec3::ZERO, Vec3::Y, false);
|
||||
let destination = run(&mut s, "component.create", json!({"name":"Machined","parent":0}));
|
||||
let component = 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":component,"bodies":[name]}));
|
||||
assert!(!s.doc.body_offsets.contains_key(&name), "recorded transfer must not leave a legacy placement offset");
|
||||
let origin = Vec3::new(-30.0, -10.0, 20.0);
|
||||
let y = Vec3::new(0.0, 0.0, -1.0);
|
||||
check(&s, &name, component, origin, y, true);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
check(&s, &name, 0, Vec3::ZERO, Vec3::Y, false);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
check(&s, &name, component, origin, y, true);
|
||||
run(&mut s, "component.activate", json!({"component":component}));
|
||||
for command in ["sheet.unfold", "sheet.refold"] {
|
||||
run(&mut s, command, json!({"body":name}));
|
||||
check(&s, &name, component, origin, y, true);
|
||||
let f = run(&mut s, "sheet.flat_pattern", json!({"body":name}));
|
||||
assert_eq!(f["pockets"][0]["side"], "bottom");
|
||||
assert_eq!(f["pockets"][0]["depth_mm"], 1.0);
|
||||
}
|
||||
let mut reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
check(&reopened, &name, component, origin, y, true);
|
||||
run(&mut reopened, "component.activate", json!({"component":component}));
|
||||
run(&mut reopened, "sheet.unfold", json!({"body":name}));
|
||||
check(&reopened, &name, component, origin, y, true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base_and_edge_flange_follow_the_k_factor() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_geom::Vec3;
|
||||
use solvecraft_sketch::WireSpace;
|
||||
|
||||
use super::sketch::edit;
|
||||
use super::sketch::{edit, target_sketch};
|
||||
use super::{CommandSpec, in_sketch};
|
||||
use crate::params::{bad, req_vec3, vec3};
|
||||
use crate::{Result, Session};
|
||||
@@ -35,32 +36,71 @@ fn needs_3d(sk: &solvecraft_sketch::Sketch, cmd: &str) -> Result<()> {
|
||||
if sk.view.three_d { Ok(()) } else { Err(bad(cmd, "turn on 3D Sketch (sketch.options sketch_3d: true) to draw off the sketch plane")) }
|
||||
}
|
||||
|
||||
/// World command inputs belong to the target sketch's owner, which may differ from the
|
||||
/// active component when a script supplies an explicit sketch.
|
||||
fn inverse_owner_frame(s: &Session, p: &Value, cmd: &str) -> Result<solvecraft_doc::Mat> {
|
||||
let id = target_sketch(s, p, cmd)?;
|
||||
let owner = s.doc.feature(id).map(|f| f.component).ok_or_else(|| bad(cmd, "target sketch is missing"))?;
|
||||
if owner != 0 && owner != s.active_component {
|
||||
if s.doc.occurrences.len() > 100_000 {
|
||||
return Err(bad(cmd, "sketch owner placement expansion exceeds supported bounds"));
|
||||
}
|
||||
let mut children: std::collections::BTreeMap<u64, Vec<&solvecraft_doc::Occurrence>> = std::collections::BTreeMap::new();
|
||||
for occurrence in &s.doc.occurrences {
|
||||
children.entry(occurrence.parent).or_default().push(occurrence);
|
||||
}
|
||||
let mut stack = vec![(0, 0usize)];
|
||||
let mut count = 0;
|
||||
let mut visited = 0;
|
||||
while let Some((component, depth)) = stack.pop() {
|
||||
for o in children.get(&component).into_iter().flatten() {
|
||||
visited += 1;
|
||||
if depth >= 64 || visited > 100_000 {
|
||||
return Err(bad(cmd, "sketch owner placement expansion exceeds supported bounds"));
|
||||
}
|
||||
if o.component == owner {
|
||||
count += 1;
|
||||
if count > 1 {
|
||||
return Err(bad(cmd, "activate the desired sketch owner occurrence before editing world 3D points"));
|
||||
}
|
||||
}
|
||||
stack.push((o.component, depth + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
let frame = if owner == s.active_component { s.active_component_frame() } else { s.placed_document().component_transform(owner) };
|
||||
solvecraft_doc::mat_inverse(&frame).ok_or_else(|| bad(cmd, "the sketch owner placement is not invertible"))
|
||||
}
|
||||
|
||||
fn line3d(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.line3d";
|
||||
let pts = points(cmd, p, 2)?;
|
||||
let inverse = inverse_owner_frame(s, p, cmd)?;
|
||||
let pts: Vec<Vec3> = points(cmd, p, 2)?.into_iter().map(|at| solvecraft_doc::apply_point(&inverse, at)).collect();
|
||||
let (ids, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
needs_3d(sk, cmd)?;
|
||||
pts.windows(2).map(|w| sk.add_drawn_wire(w).map_err(Into::into)).collect::<Result<Vec<String>>>()
|
||||
pts.windows(2).map(|w| sk.add_drawn_wire_in(w, WireSpace::Component).map_err(Into::into)).collect::<Result<Vec<String>>>()
|
||||
})?;
|
||||
Ok(json!({"curves": ids, "sketch": info}))
|
||||
}
|
||||
|
||||
fn spline3d(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.spline3d";
|
||||
let pts = points(cmd, p, 2)?;
|
||||
let inverse = inverse_owner_frame(s, p, cmd)?;
|
||||
let pts: Vec<Vec3> = points(cmd, p, 2)?.into_iter().map(|at| solvecraft_doc::apply_point(&inverse, at)).collect();
|
||||
let (id, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
needs_3d(sk, cmd)?;
|
||||
Ok(sk.add_drawn_wire(&pts)?)
|
||||
Ok(sk.add_drawn_wire_in(&pts, WireSpace::Component)?)
|
||||
})?;
|
||||
Ok(json!({"curves": [id], "sketch": info}))
|
||||
}
|
||||
|
||||
fn point3d(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.point3d";
|
||||
let at = req_vec3(cmd, p, "point")?;
|
||||
let inverse = inverse_owner_frame(s, p, cmd)?;
|
||||
let at = solvecraft_doc::apply_point(&inverse, req_vec3(cmd, p, "point")?);
|
||||
let (id, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
needs_3d(sk, cmd)?;
|
||||
Ok(sk.add_drawn_wire(&[at])?)
|
||||
Ok(sk.add_drawn_wire_in(&[at], WireSpace::Component)?)
|
||||
})?;
|
||||
Ok(json!({"point": id, "sketch": info}))
|
||||
}
|
||||
@@ -68,23 +108,41 @@ fn point3d(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn move3d(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.move3d";
|
||||
let wire = p.get("wire").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`wire` must be a 3D curve or point id"))?.to_string();
|
||||
let index = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let to = p.get("to").and_then(vec3);
|
||||
let by = p.get("by").and_then(vec3);
|
||||
let index = match p.get("index") {
|
||||
None => 0,
|
||||
Some(v) => v
|
||||
.as_u64()
|
||||
.and_then(|v| usize::try_from(v).ok())
|
||||
.filter(|v| *v < solvecraft_sketch::MAX_FIT_POINTS)
|
||||
.ok_or_else(|| bad(cmd, "`index` must be a fit point index below 500"))?,
|
||||
};
|
||||
let parse = |name| p.get(name).map(|v| vec3(v).ok_or_else(|| bad(cmd, format!("`{name}` must be [x,y,z]")))).transpose();
|
||||
let to = parse("to")?;
|
||||
let by = parse("by")?;
|
||||
let inverse = inverse_owner_frame(s, p, cmd)?;
|
||||
let (n, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let from = sk
|
||||
let drawn = sk
|
||||
.wire_index(&wire)
|
||||
.and_then(|w| sk.wires.get(w))
|
||||
.filter(|w| !w.fit.is_empty())
|
||||
.and_then(|w| w.fit.get(index))
|
||||
.copied()
|
||||
.ok_or_else(|| bad(cmd, format!("`{wire}` has no drawn point {index}")))?;
|
||||
.filter(|w| !w.fit.is_empty() && w.link.is_none())
|
||||
.ok_or_else(|| bad(cmd, format!("`{wire}` is not an editable drawn 3D curve")))?;
|
||||
let from = drawn.fit.get(index).copied().ok_or_else(|| bad(cmd, format!("`{wire}` has no drawn point {index}")))?;
|
||||
let to = match (to, by) {
|
||||
(Some(t), _) => t,
|
||||
(None, Some(d)) => from + d,
|
||||
(Some(t), _) => {
|
||||
if drawn.space == WireSpace::Component {
|
||||
solvecraft_doc::apply_point(&inverse, t)
|
||||
} else {
|
||||
t
|
||||
}
|
||||
}
|
||||
(None, Some(d)) => from + if drawn.space == WireSpace::Component { solvecraft_doc::apply_vector(&inverse, d) } else { d },
|
||||
_ => return Err(bad(cmd, "give `to` or `by`")),
|
||||
};
|
||||
Ok(sk.move_drawn_point(&wire, index, to)?)
|
||||
})?;
|
||||
Ok(json!({"moved": n, "sketch": info}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_3d_frame_tests.rs"]
|
||||
mod frame_tests;
|
||||
@@ -0,0 +1,312 @@
|
||||
use crate::Session;
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_geom::Vec3;
|
||||
use solvecraft_sketch::WireSpace;
|
||||
|
||||
fn run(s: &mut Session, cmd: &str, params: Value) -> Value {
|
||||
s.execute(cmd, ¶ms).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
fn near(actual: Vec3, expected: Vec3) {
|
||||
assert!(actual.dist(expected) < 1e-7, "{actual:?} != {expected:?}");
|
||||
}
|
||||
|
||||
fn second_frame() -> (Session, u64, u64, u64) {
|
||||
let mut s = Session::default();
|
||||
let c = run(&mut s, "component.create", json!({"name":"Wire owner"}));
|
||||
let component = c["component"].as_u64().unwrap();
|
||||
let first = c["occurrence"].as_u64().unwrap();
|
||||
let second = run(&mut s, "occurrence.copy", json!({"occurrence":first}))["occurrence"].as_u64().unwrap();
|
||||
// One rigid transform: (x,y,z) -> (20-y,30+x,40+z), independent of implementation math.
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":second,"axis":[0,0,1],"angle":"90 deg","translate":[20,30,40]}));
|
||||
run(&mut s, "component.activate", json!({"component":component,"occurrence":second,"path":[second]}));
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY","name":"Space path"}));
|
||||
run(&mut s, "sketch.options", json!({"sketch_3d":true}));
|
||||
(s, component, first, second)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_frame_second_occurrence_world_inputs_move_and_reopen() {
|
||||
let (mut s, component, first, second) = second_frame();
|
||||
let sketch = s.active_sketch.unwrap();
|
||||
let r = run(&mut s, "sketch.line3d", json!({"points":[[17,32,45],[17,32,55],[14,32,55]]}));
|
||||
let a = r["curves"][0].as_str().unwrap().to_string();
|
||||
let b = r["curves"][1].as_str().unwrap().to_string();
|
||||
let spline = run(&mut s, "sketch.spline3d", json!({"points":[[14,32,55],[12,34,56],[10,35,58]]}))["curves"][0].as_str().unwrap().to_string();
|
||||
let point = run(&mut s, "sketch.point3d", json!({"point":[17,32,45]}))["point"].as_str().unwrap().to_string();
|
||||
let sk = s.doc.sketch(sketch).unwrap();
|
||||
for w in &sk.wires {
|
||||
assert_eq!(w.space, WireSpace::Component);
|
||||
}
|
||||
near(sk.wires[sk.wire_index(&a).unwrap()].fit[0], Vec3::new(2.0, 3.0, 5.0));
|
||||
near(sk.wires[sk.wire_index(&point).unwrap()].fit[0], Vec3::new(2.0, 3.0, 5.0));
|
||||
let world = s.world_state();
|
||||
let ws = &world.sketch(sketch).unwrap().sketch;
|
||||
let sw = &ws.wires[ws.wire_index(&spline).unwrap()];
|
||||
near(sw.fit[0], Vec3::new(14.0, 32.0, 55.0));
|
||||
near(sw.pts[0], sw.fit[0]);
|
||||
near(*sw.pts.last().unwrap(), Vec3::new(10.0, 35.0, 58.0));
|
||||
let before = s.doc.to_json();
|
||||
let moved = run(&mut s, "sketch.move3d", json!({"wire":a,"index":1,"by":[1,0,0]}));
|
||||
assert_eq!(moved["moved"], 2);
|
||||
let sk = s.doc.sketch(sketch).unwrap();
|
||||
near(sk.wires[sk.wire_index(&a).unwrap()].fit[1], Vec3::new(2.0, 2.0, 15.0));
|
||||
near(sk.wires[sk.wire_index(&b).unwrap()].fit[0], Vec3::new(2.0, 2.0, 15.0));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
run(&mut s, "sketch.move3d", json!({"wire":point,"to":[18,33,46]}));
|
||||
near(s.doc.sketch(sketch).unwrap().wires.iter().find(|w| w.id == point).unwrap().fit[0], Vec3::new(3.0, 2.0, 6.0));
|
||||
let restored = solvecraft_doc::format::read(&s.doc.to_json()).unwrap();
|
||||
assert_eq!(restored.sketch(sketch).unwrap().wires, s.doc.sketch(sketch).unwrap().wires);
|
||||
// Same definition at the first placement has the local geometry; selected second is exact.
|
||||
run(&mut s, "component.activate", json!({"component":component,"occurrence":first,"path":[first]}));
|
||||
near(s.world_state().sketch(sketch).unwrap().sketch.wires.iter().find(|w| w.id == point).unwrap().fit[0], Vec3::new(3.0, 2.0, 6.0));
|
||||
run(&mut s, "component.activate", json!({"component":component,"occurrence":second,"path":[second]}));
|
||||
near(s.world_state().sketch(sketch).unwrap().sketch.wires.iter().find(|w| w.id == point).unwrap().fit[0], Vec3::new(18.0, 33.0, 46.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_frame_pending_moves_other_owner_and_legacy_world_do_not_join() {
|
||||
let (mut s, _, _, second) = second_frame();
|
||||
let sketch = s.active_sketch.unwrap();
|
||||
let legacy = s.doc_mut().sketch_mut(sketch).unwrap().add_drawn_wire(&[Vec3::new(2.0, 3.0, 5.0)]).unwrap();
|
||||
let local = run(&mut s, "sketch.point3d", json!({"point":[17,32,45]}))["point"].as_str().unwrap().to_string();
|
||||
assert_eq!(run(&mut s, "sketch.move3d", json!({"wire":local,"by":[1,0,0]}))["moved"], 1);
|
||||
near(s.doc.sketch(sketch).unwrap().wires.iter().find(|w| w.id == legacy).unwrap().fit[0], Vec3::new(2.0, 3.0, 5.0));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":second,"translate":[5,0,0],"capture":false}));
|
||||
let point = run(&mut s, "sketch.point3d", json!({"point":[22,32,45]}))["point"].as_str().unwrap().to_string();
|
||||
near(s.doc.sketch(sketch).unwrap().wires.iter().find(|w| w.id == point).unwrap().fit[0], Vec3::new(2.0, 3.0, 5.0));
|
||||
near(s.world_state().sketch(sketch).unwrap().sketch.wires.iter().find(|w| w.id == legacy).unwrap().fit[0], Vec3::new(2.0, 3.0, 5.0));
|
||||
near(s.world_state().sketch(sketch).unwrap().sketch.wires.iter().find(|w| w.id == point).unwrap().fit[0], Vec3::new(22.0, 32.0, 45.0));
|
||||
run(&mut s, "component.capture_position", json!({}));
|
||||
near(s.world_state().sketch(sketch).unwrap().sketch.wires.iter().find(|w| w.id == point).unwrap().fit[0], Vec3::new(22.0, 32.0, 45.0));
|
||||
// Explicit target in root must not inherit the unrelated active component frame.
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "component.activate", json!({"component":0}));
|
||||
let root = run(&mut s, "sketch.create", json!({"plane":"XY","name":"Root path"}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.options", json!({"sketch_3d":true}));
|
||||
let unrelated = run(&mut s, "component.create", json!({"name":"Unrelated"}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":unrelated,"translate":[100,200,300]}));
|
||||
run(&mut s, "sketch.point3d", json!({"sketch":root,"point":[9,8,7]}));
|
||||
near(s.doc.sketch(root).unwrap().wires[0].fit[0], Vec3::new(9.0, 8.0, 7.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_frame_hostile_inputs_rollback_full_session() {
|
||||
let (mut s, _, _, second) = second_frame();
|
||||
let id = run(&mut s, "sketch.point3d", json!({"point":[17,32,45]}))["point"].as_str().unwrap().to_string();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":second,"translate":[1,0,0],"capture":false}));
|
||||
run(&mut s, "select.set", json!({"items":[{"type":"occurrence","path":[second]}]}));
|
||||
let doc = s.doc.to_json();
|
||||
let pending = s.pending_moves.clone();
|
||||
let selection = s.selection.clone();
|
||||
let history = (s.undo.len(), s.redo.len());
|
||||
for (cmd, p) in [
|
||||
("sketch.move3d", json!({"wire":id,"index":u64::MAX,"to":[0,0,0]})),
|
||||
("sketch.move3d", json!({"wire":id,"index":-1,"to":[0,0,0]})),
|
||||
("sketch.move3d", json!({"wire":id,"index":"0","to":[0,0,0]})),
|
||||
("sketch.move3d", json!({"wire":id,"to":[null,0,0],"by":[1,0,0]})),
|
||||
("sketch.point3d", json!({"point":[1e100,0,0]})),
|
||||
("sketch.line3d", json!({"points":[[17,32,45],[17,32,45]]})),
|
||||
("sketch.spline3d", json!({"points":vec![[0,0,0];501]})),
|
||||
] {
|
||||
assert!(s.execute(cmd, &p).is_err(), "{cmd}: {p}");
|
||||
assert_eq!(s.doc.to_json(), doc);
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
assert_eq!(s.selection, selection);
|
||||
assert_eq!((s.undo.len(), s.redo.len()), history);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_frame_include_other_component_refresh_break_lost_and_reopen() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10}));
|
||||
let component = run(&mut s, "component.create", json!({"name":"Receiver"}));
|
||||
let occurrence = component["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[20,0,0]}));
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY","name":"Include target"}));
|
||||
let sketch = s.active_sketch.unwrap();
|
||||
run(&mut s, "sketch.include_3d", json!({"refs":[{"edge":[10,10,5],"body":"Body1"}]}));
|
||||
let initial = s.doc.sketch(sketch).unwrap().wires[0].clone();
|
||||
assert_eq!(initial.space, WireSpace::Component);
|
||||
assert!(initial.pts.iter().all(|p| (p.x + 10.0).abs() < 1e-7 && (p.y - 10.0).abs() < 1e-7));
|
||||
let link = initial.link.clone().unwrap();
|
||||
// A real sketch edit exercises engine refresh, not only document evaluation.
|
||||
run(&mut s, "sketch.line", json!({"points":[[0,0],[1,0]]}));
|
||||
let sk = s.doc.sketch(sketch).unwrap();
|
||||
assert_eq!(sk.wires[0], initial);
|
||||
assert!(!sk.link(&link).unwrap().lost);
|
||||
let world = s.world_state();
|
||||
assert!(world.sketch(sketch).unwrap().sketch.wires[0].pts.iter().all(|p| (p.x - 10.0).abs() < 1e-7));
|
||||
let before = s.doc.to_json();
|
||||
run(&mut s, "sketch.break_link", json!({"link":link}));
|
||||
assert_eq!(s.doc.sketch(sketch).unwrap().wires[0].space, WireSpace::Component);
|
||||
assert!(s.doc.sketch(sketch).unwrap().wires[0].link.is_none());
|
||||
let restored = solvecraft_doc::format::read(&s.doc.to_json()).unwrap();
|
||||
assert_eq!(restored.sketch(sketch).unwrap().wires[0], s.doc.sketch(sketch).unwrap().wires[0]);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
// Lost references retain the last coordinates and explicit marker, with no guessing.
|
||||
let sk = s.doc_mut().sketch_mut(sketch).unwrap();
|
||||
sk.links.iter_mut().find(|l| l.id == link).unwrap().source = solvecraft_sketch::LinkSource::Edge { body: "Missing".into(), at: Vec3::ZERO };
|
||||
run(&mut s, "sketch.line", json!({"points":[[2,0],[3,0]]}));
|
||||
let sk = s.doc.sketch(sketch).unwrap();
|
||||
assert!(sk.link(&link).unwrap().lost);
|
||||
assert_eq!(sk.wires[0], initial);
|
||||
let restored = solvecraft_doc::format::read(&s.doc.to_json()).unwrap();
|
||||
assert_eq!(restored.sketch(sketch).unwrap().wires[0], initial);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_frame_pipe_component_and_world_paths_have_analytic_volume_and_position() {
|
||||
for space in [WireSpace::Component, WireSpace::World] {
|
||||
let (mut s, _, first, second) = second_frame();
|
||||
if space == WireSpace::World {
|
||||
// A single placed owner lets an immutable world path stay physically fixed;
|
||||
// replicated solids still follow their occurrence placements by design.
|
||||
run(&mut s, "selection.delete", json!({"items":[{"type":"occurrence","path":[first]}]}));
|
||||
}
|
||||
let sketch = s.active_sketch.unwrap();
|
||||
let wire = if space == WireSpace::Component {
|
||||
run(&mut s, "sketch.line3d", json!({"points":[[17,32,45],[17,32,55]]}))["curves"][0].as_str().unwrap().to_string()
|
||||
} else {
|
||||
s.doc_mut().sketch_mut(sketch).unwrap().add_drawn_wire(&[Vec3::new(17.0, 32.0, 45.0), Vec3::new(17.0, 32.0, 55.0)]).unwrap()
|
||||
};
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.pipe", json!({"path_sketch":sketch,"path":[wire],"diameter":4}));
|
||||
let world = s.world_state();
|
||||
let body = world.bodies.last().unwrap();
|
||||
// Property measurement uses fine adaptive tessellation; the viewport mesh alone
|
||||
// undershoots a small circular profile by about 0.64% at its display chord tolerance.
|
||||
let measure = solvecraft_kernel::measure(&body.body).unwrap();
|
||||
assert!((measure.volume - 40.0 * std::f64::consts::PI).abs() < 0.05, "{}", measure.volume);
|
||||
let bounds = body.mesh().bounds();
|
||||
near((bounds.min + bounds.max) / 2.0, Vec3::new(17.0, 32.0, 50.0));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":second,"translate":[5,0,0]}));
|
||||
let world = s.world_state();
|
||||
let body = world.bodies.last().unwrap();
|
||||
let bounds = body.mesh().bounds();
|
||||
near((bounds.min + bounds.max) / 2.0, Vec3::new(if space == WireSpace::Component { 22.0 } else { 17.0 }, 32.0, 50.0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_frame_nonactive_repeated_owner_refuses_ambiguous_world_input() {
|
||||
let (mut s, _, _, _) = second_frame();
|
||||
let target = s.active_sketch.unwrap();
|
||||
run(&mut s, "component.activate", json!({"component":0}));
|
||||
let before = s.doc.to_json();
|
||||
let error = s.execute("sketch.point3d", &json!({"sketch":target,"point":[0,0,5]})).unwrap_err();
|
||||
assert!(error.to_string().contains("activate the desired sketch owner occurrence"));
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_frame_wide_owner_graph_has_bounded_indexed_traversal() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "component.create", json!({"name":"Target"}));
|
||||
let target = run(&mut s, "sketch.create", json!({"plane":"XY"}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "component.activate", json!({"component":0}));
|
||||
// A legal wide assembly used to force one whole occurrence scan for each leaf.
|
||||
let doc = s.doc_mut();
|
||||
for i in 0..9998u64 {
|
||||
doc.components.push(solvecraft_doc::Component { id: 10_000 + i, name: format!("Sibling{i}"), parent: 0 });
|
||||
doc.occurrences.push(solvecraft_doc::Occurrence {
|
||||
id: 100_000 + i,
|
||||
component: 10_000 + i,
|
||||
parent: 0,
|
||||
name: format!("Sibling{i}"),
|
||||
transform: solvecraft_doc::IDENTITY,
|
||||
grounded: false,
|
||||
});
|
||||
}
|
||||
assert_eq!(super::inverse_owner_frame(&s, &json!({"sketch":target}), "sketch.point3d").unwrap(), solvecraft_doc::IDENTITY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_frame_cross_owner_sweep_follows_world_path_edit_undo_and_reopen() {
|
||||
let mut s = Session::default();
|
||||
let path_sketch = run(&mut s, "sketch.create", json!({"plane":"XY","name":"Root path"}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.options", json!({"sketch_3d":true}));
|
||||
let wire = run(&mut s, "sketch.line3d", json!({"points":[[17,32,45],[17,32,55]]}))["curves"][0].as_str().unwrap().to_string();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let owner = run(&mut s, "component.create", json!({"name":"Sweep owner"}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":owner,"axis":[0,0,1],"angle":"90 deg","translate":[20,30,40]}));
|
||||
let profile = run(&mut s, "sketch.create", json!({"plane":"XY","offset":5,"name":"Profile"}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[2,3],"radius":2}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.sweep", json!({"sketch":profile,"path_sketch":path_sketch,"path":[wire],"operation":"new"}));
|
||||
let check = |s: &Session, length: f64| {
|
||||
let world = s.world_state();
|
||||
let body = world.bodies.last().unwrap();
|
||||
let measure = solvecraft_kernel::measure(&body.body).unwrap();
|
||||
assert!((measure.volume - 4.0 * std::f64::consts::PI * length).abs() < 0.05, "{}", measure.volume);
|
||||
near(measure.centroid, Vec3::new(17.0, 32.0, 45.0 + length / 2.0));
|
||||
};
|
||||
check(&s, 10.0);
|
||||
let saved = s.doc.to_json();
|
||||
run(&mut s, "sketch.edit", json!({"sketch":path_sketch}));
|
||||
run(&mut s, "sketch.move3d", json!({"sketch":path_sketch,"wire":wire,"index":1,"by":[0,0,10]}));
|
||||
check(&s, 20.0);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), saved);
|
||||
check(&s, 10.0);
|
||||
let mut reopened = Session { doc: solvecraft_doc::format::read(&saved).unwrap().into(), ..Default::default() };
|
||||
reopened.refresh();
|
||||
check(&reopened, 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_frame_three_owner_sweep_profile_move_invalidates_warm_cache() {
|
||||
let mut s = Session::default();
|
||||
let profile_owner = run(&mut s, "component.create", json!({"name":"Profile owner","parent":0}));
|
||||
let profile_occurrence = profile_owner["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":profile_occurrence,"translate":[4,7,5]}));
|
||||
let profile = run(&mut s, "sketch.create", json!({"plane":"XY","name":"Foreign profile"}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":2}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
|
||||
let path_owner = run(&mut s, "component.create", json!({"name":"Path owner","parent":0}));
|
||||
let path_occurrence = path_owner["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":path_occurrence,"translate":[-12,9,-3]}));
|
||||
let path_sketch = run(&mut s, "sketch.create", json!({"plane":"XY","name":"Foreign path"}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.options", json!({"sketch_3d":true}));
|
||||
let wire = run(&mut s, "sketch.line3d", json!({"points":[[4,7,5],[4,7,15]]}))["curves"][0].as_str().unwrap().to_string();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
|
||||
let consumer = run(&mut s, "component.create", json!({"name":"Sweep consumer","parent":0}));
|
||||
let consumer_occurrence = consumer["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":consumer_occurrence,"axis":[0,0,1],"angle":"90 deg","translate":[100,-30,20]}));
|
||||
run(&mut s, "solid.sweep", json!({"sketch":profile,"path_sketch":path_sketch,"path":[wire],"operation":"new"}));
|
||||
let measure = |session: &Session, x: f64| {
|
||||
let state = session.world_state();
|
||||
let body = state.bodies.last().unwrap();
|
||||
assert_eq!(body.component, consumer["component"].as_u64().unwrap());
|
||||
let result = solvecraft_kernel::measure(&body.body).unwrap();
|
||||
assert!((result.volume - 40.0 * std::f64::consts::PI).abs() < 0.05, "{}", result.volume);
|
||||
near(result.centroid, Vec3::new(x, 7.0, 10.0));
|
||||
result
|
||||
};
|
||||
measure(&s, 4.0);
|
||||
// A repeated refresh warms the exact same evaluator cache before changing only
|
||||
// the foreign profile placement, whose frame must be part of Sweep's digest.
|
||||
s.refresh();
|
||||
measure(&s, 4.0);
|
||||
let saved = s.doc.to_json();
|
||||
let fixed_path = s.doc.component_transform(path_owner["component"].as_u64().unwrap());
|
||||
let fixed_consumer = s.doc.component_transform(consumer["component"].as_u64().unwrap());
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":profile_occurrence,"translate":[3,0,0]}));
|
||||
assert_eq!(s.doc.component_transform(path_owner["component"].as_u64().unwrap()), fixed_path);
|
||||
assert_eq!(s.doc.component_transform(consumer["component"].as_u64().unwrap()), fixed_consumer);
|
||||
let warm = measure(&s, 7.0);
|
||||
let mut cold = Session { doc: solvecraft_doc::format::read(&s.doc.to_json()).unwrap().into(), ..Default::default() };
|
||||
cold.refresh();
|
||||
let reopened = measure(&cold, 7.0);
|
||||
near(warm.centroid, reopened.centroid);
|
||||
assert!((warm.volume - reopened.volume).abs() < 1e-8);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), saved);
|
||||
measure(&s, 4.0);
|
||||
}
|
||||
@@ -123,27 +123,49 @@ fn sketch_plane(doc: &Document, id: u64) -> Result<Plane> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep candidate feature edits while overlaying uncommitted occurrence placements.
|
||||
fn placed_candidate<'a>(s: &Session, doc: &'a Document) -> std::borrow::Cow<'a, Document> {
|
||||
if s.pending_moves.is_empty() {
|
||||
return std::borrow::Cow::Borrowed(doc);
|
||||
}
|
||||
let mut placed = doc.clone();
|
||||
for o in &mut placed.occurrences {
|
||||
if let Some(m) = s.pending_moves.get(&o.id) {
|
||||
o.transform = *m;
|
||||
}
|
||||
}
|
||||
std::borrow::Cow::Owned(placed)
|
||||
}
|
||||
|
||||
fn owner_frame(s: &Session, doc: &Document, comp: u64) -> solvecraft_doc::Mat {
|
||||
if comp == s.active_component { s.active_component_frame() } else { doc.component_transform(comp) }
|
||||
}
|
||||
|
||||
/// Re-resolve the links of a sketch being edited against the model before it, so edits see
|
||||
/// current reference geometry. Returns warnings.
|
||||
pub(super) fn refresh(s: &Session, doc: &Document, id: u64, sk: &mut Sketch) -> Vec<String> {
|
||||
if sk.links.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let Ok(plane) = sketch_plane(doc, id) else { return Vec::new() };
|
||||
let doc = placed_candidate(s, doc);
|
||||
let Ok(plane) = sketch_plane(&doc, id) else { return Vec::new() };
|
||||
let (vals, _) = doc.param_values();
|
||||
let st = s.model.state_before(id);
|
||||
solvecraft_doc::project::refresh_links(doc, &vals, &st, &plane, sk)
|
||||
let comp = doc.feature(id).map(|f| f.component).unwrap_or(0);
|
||||
let st = solvecraft_doc::state_in_frame_at(&doc, &st, comp, owner_frame(s, &doc, comp));
|
||||
solvecraft_doc::project::refresh_links(&doc, &vals, &st, &plane, sk)
|
||||
}
|
||||
|
||||
/// Add a link for `src` to sketch `id` in `doc` (not stored in the session). Returns the link id.
|
||||
pub(super) fn add_link(s: &Session, doc: &Document, id: u64, sk: &mut Sketch, kind: LinkKind, src: LinkSource) -> Result<String> {
|
||||
let plane = sketch_plane(doc, id)?;
|
||||
let doc = placed_candidate(s, doc);
|
||||
let plane = sketch_plane(&doc, id)?;
|
||||
let (vals, _) = doc.param_values();
|
||||
let st = s.model.state_before(id);
|
||||
// The model as the sketch's component sees it.
|
||||
let comp = doc.feature(id).map(|f| f.component).unwrap_or(0);
|
||||
let st = solvecraft_doc::state_in_frame(doc, &st, comp);
|
||||
let r = resolve(doc, &vals, &st, &plane, kind, &src)?;
|
||||
let st = solvecraft_doc::state_in_frame_at(&doc, &st, comp, owner_frame(s, &doc, comp));
|
||||
let r = resolve(&doc, &vals, &st, &plane, kind, &src)?;
|
||||
let name = if let LinkSource::Face { at, .. } = &r.source {
|
||||
solvecraft_doc::naming::face_names_at(&st, &[*at]).into_iter().next().filter(|n| !n.is_empty())
|
||||
} else {
|
||||
|
||||
@@ -133,6 +133,7 @@ pub struct Snapshot {
|
||||
pub label: String,
|
||||
pub doc: Arc<Document>,
|
||||
pub active_sketch: Option<u64>,
|
||||
pub pending_moves: std::collections::BTreeMap<u64, solvecraft_doc::Mat>,
|
||||
}
|
||||
|
||||
/// What some commands would make, evaluated on a scratch copy of a session
|
||||
@@ -184,12 +185,13 @@ pub struct Session {
|
||||
next_checkpoint: u64,
|
||||
}
|
||||
|
||||
/// The design, active sketch and undo history at an `edit.checkpoint`.
|
||||
/// The design, active sketch, pending positions and undo history at an `edit.checkpoint`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Checkpoint {
|
||||
pub id: u64,
|
||||
pub doc: Arc<Document>,
|
||||
pub active_sketch: Option<u64>,
|
||||
pub pending_moves: std::collections::BTreeMap<u64, solvecraft_doc::Mat>,
|
||||
pub undo: Vec<Snapshot>,
|
||||
pub redo: Vec<Snapshot>,
|
||||
}
|
||||
@@ -332,6 +334,13 @@ impl Session {
|
||||
) {
|
||||
sketch.plane = mapped;
|
||||
}
|
||||
for wire in &mut sketch.sketch.wires {
|
||||
if wire.space == solvecraft_doc::sketch::WireSpace::Component {
|
||||
for point in wire.pts.iter_mut().chain(&mut wire.fit) {
|
||||
*point = solvecraft_doc::apply_point(&delta, *point);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -362,7 +371,12 @@ impl Session {
|
||||
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
|
||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
|
||||
(spec.enabled)(self).map_err(|why| EngineError::Disabled(spec.id.to_string(), why))?;
|
||||
let before = Snapshot { label: spec.label.to_string(), doc: self.doc.clone(), active_sketch: self.active_sketch };
|
||||
let before = Snapshot {
|
||||
label: spec.label.to_string(),
|
||||
doc: self.doc.clone(),
|
||||
active_sketch: self.active_sketch,
|
||||
pending_moves: self.pending_moves.clone(),
|
||||
};
|
||||
let null = Value::Object(Default::default());
|
||||
let params = if params.is_null() { &null } else { params };
|
||||
// Picks in the world go into their component's frame (once: commands run by commands
|
||||
@@ -394,9 +408,10 @@ impl Session {
|
||||
Ok(v)
|
||||
}
|
||||
Err(e) => {
|
||||
if changed {
|
||||
if changed || self.pending_moves != before.pending_moves {
|
||||
self.doc = before.doc;
|
||||
self.active_sketch = before.active_sketch;
|
||||
self.pending_moves = before.pending_moves;
|
||||
self.refresh();
|
||||
}
|
||||
Err(e)
|
||||
@@ -404,7 +419,7 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Remember the design, active sketch and undo history; returns the checkpoint's id.
|
||||
/// Remember the design, active sketch, pending positions and undo history; returns the checkpoint's id.
|
||||
pub fn checkpoint(&mut self) -> u64 {
|
||||
let id = self.next_checkpoint;
|
||||
self.next_checkpoint += 1;
|
||||
@@ -413,6 +428,7 @@ impl Session {
|
||||
doc: self.doc.clone(),
|
||||
active_sketch: self.active_sketch,
|
||||
undo: self.undo.clone(),
|
||||
pending_moves: self.pending_moves.clone(),
|
||||
redo: self.redo.clone(),
|
||||
});
|
||||
if self.checkpoints.len() > MAX_CHECKPOINTS {
|
||||
@@ -428,6 +444,7 @@ impl Session {
|
||||
let Some(c) = self.checkpoints.drain(i..).next() else { return Err(EngineError::Other(format!("no checkpoint {id}"))) };
|
||||
self.doc = c.doc;
|
||||
self.active_sketch = c.active_sketch;
|
||||
self.pending_moves = c.pending_moves;
|
||||
self.undo = c.undo;
|
||||
self.redo = c.redo;
|
||||
self.selection.clear();
|
||||
|
||||
@@ -79,7 +79,7 @@ impl Session {
|
||||
let bodies = state
|
||||
.bodies
|
||||
.iter()
|
||||
.filter(|b| self.doc.body_component(&b.name, b.feature) == component)
|
||||
.filter(|b| self.doc.model_body_component(b) == component)
|
||||
.map(|b| OccurrenceBody { source: b.name.clone(), world: self.doc.world_body_name(&state, &b.name, component, path) })
|
||||
.collect();
|
||||
Ok(OccurrencePlacement {
|
||||
|
||||
@@ -864,7 +864,14 @@ fn holes_at_sketch_points_threads_and_components() {
|
||||
let text = s.doc.to_json();
|
||||
let back = solvecraft_doc::Document::from_json(&text).unwrap();
|
||||
assert_eq!(back.components.len(), 2);
|
||||
assert_eq!(back.body_components.len(), 1);
|
||||
let assigned = r["components"][0]["component"].as_u64().unwrap();
|
||||
let transfer = r["components"][0]["feature"].as_u64().unwrap();
|
||||
assert!(
|
||||
matches!(&back.feature(transfer).unwrap().kind, solvecraft_doc::FeatureKind::BodyTransfer { bodies, to, .. } if bodies == &["Shaft"] && *to == assigned)
|
||||
);
|
||||
let reopened = Session::new(back);
|
||||
assert_eq!(reopened.model.state().body("Shaft").unwrap().component, assigned);
|
||||
assert_eq!(reopened.world_state().threads.len(), s.world_state().threads.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -985,11 +992,22 @@ fn components_occurrences_and_world_placement() {
|
||||
run(&mut s, "component.capture_position", json!({}));
|
||||
assert!(s.pending_moves.is_empty());
|
||||
assert!((bbox(&mut s, "ArmBody")["min"][2].as_f64().unwrap() - 5.0).abs() < 1e-9);
|
||||
// Bodies and sketches move between components.
|
||||
run(&mut s, "component.move_bodies", json!({"bodies": ["Base"], "component": "Arm"}));
|
||||
// A definition placed twice cannot receive one world body without duplicating it.
|
||||
let document = s.doc.to_json();
|
||||
let pending = s.pending_moves.clone();
|
||||
let error = s.execute("component.move_bodies", &json!({"bodies": ["Base"], "component": "Arm"})).unwrap_err();
|
||||
assert!(error.to_string().contains("one source and destination placement"), "{error}");
|
||||
assert_eq!(s.doc.to_json(), document);
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
// The independent Paste New result has one placement and can receive it.
|
||||
let copied = pn["component"].as_u64().unwrap();
|
||||
run(&mut s, "component.move_bodies", json!({"bodies": ["Base"], "component": copied}));
|
||||
let l = run(&mut s, "component.list", json!({}));
|
||||
let arm = l["components"].as_array().unwrap().iter().find(|x| x["id"] == cid).unwrap().clone();
|
||||
assert!(arm["bodies"].as_array().unwrap().iter().any(|b| b == "Base"), "{arm}");
|
||||
assert!(!arm["bodies"].as_array().unwrap().iter().any(|b| b == "Base"), "{arm}");
|
||||
let receiver = l["components"].as_array().unwrap().iter().find(|x| x["id"] == copied).unwrap();
|
||||
assert!(receiver["bodies"].as_array().unwrap().iter().any(|b| b == "Base"), "{receiver}");
|
||||
assert_eq!(bbox(&mut s, "Base")["min"], json!([0.0, 0.0, 0.0]));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S1"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "component.move_sketches", json!({"sketches": ["S1"], "component": cid}));
|
||||
@@ -999,8 +1017,9 @@ fn components_occurrences_and_world_placement() {
|
||||
assert_eq!(s.doc.find_feature("S1").unwrap().component, 0);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
run(&mut s, "timeline.roll_to", json!({"position": 1}));
|
||||
// Only Base is left; it now lives in Arm, which is placed twice (original and instance).
|
||||
assert_eq!(s.world_state().bodies.len(), 2);
|
||||
// The ownership boundary is later: before it Base remains the sole root body.
|
||||
assert_eq!(s.world_state().bodies.len(), 1);
|
||||
assert_eq!(s.world_state().body("Base").unwrap().component, 0);
|
||||
run(&mut s, "timeline.roll_to", json!({}));
|
||||
// Saved and loaded; older designs get occurrences.
|
||||
let back = solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||
|
||||
Reference in new issue
Block a user