fix: restore shell and cut wall topology in 0.2.1

This commit is contained in:
bherbruck committed 2026-10-11 15:17:05 -04:00
1 parent 1aedbf6631
commit ef8f6ca6f9
31 files changed
+2976 -678

No files matched your search

+104
View File
@@ -154,10 +154,19 @@ pub enum FaceCertificate {
ExtrudeCap { sketch: u64, high: bool, outer: Vec<String>, holes: Vec<Vec<String>> },
ExtrudeSide { sketch: u64, curve: String },
Imported { payload_sha256: String, item: u64, face: u64 },
ShellPlanar { source_producer: u64, source: Box<FaceCertificate>, inner: bool },
}
impl FaceCertificate {
pub fn remap_features(&mut self, features: &std::collections::BTreeMap<u64, u64>) {
if let Self::ShellPlanar { source_producer, source, .. } = self {
if let Some(mapped) = features.get(source_producer) {
*source_producer = *mapped;
}
if !matches!(source.as_ref(), Self::ShellPlanar { .. }) {
source.remap_features(features);
}
}
if let Self::ExtrudeSide { sketch, .. } | Self::ExtrudeCap { sketch, .. } = self
&& let Some(mapped) = features.get(sketch)
{
@@ -175,6 +184,9 @@ impl FaceCertificate {
&& payload_sha256.len() == 64
&& payload_sha256.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
}
Self::ShellPlanar { source_producer, source, .. } => {
*source_producer != 0 && !matches!(source.as_ref(), Self::ShellPlanar { .. }) && source.validate().is_ok()
}
};
if valid { Ok(()) } else { Err(DocError::Invalid("invalid face source certificate".into())) }
}
@@ -216,11 +228,83 @@ pub(crate) fn validate_certificate_json(value: &serde_json::Value) -> Result<()>
.and_then(serde_json::Value::as_str)
.is_some_and(|digest| digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)))
}
Some("shell_planar") => {
fields.len() == 4
&& fields.keys().all(|key| ["kind", "source_producer", "source", "inner"].contains(&key.as_str()))
&& nonzero("source_producer")
&& fields.get("inner").and_then(serde_json::Value::as_bool).is_some()
&& fields.get("source").is_some_and(|source| {
source.as_object().is_some_and(|source| source.get("kind").and_then(serde_json::Value::as_str) != Some("shell_planar"))
&& validate_certificate_json(source).is_ok()
})
}
_ => false,
};
if valid { Ok(()) } else { Err(DocError::Invalid("malformed or oversized face source certificate".into())) }
}
/// Canonical diagnostic role; producer identity remains in the typed certificate.
pub(crate) fn shell_certificate_role(source: &FaceCertificate, inner: bool) -> Result<String> {
source.validate()?;
let role = match source {
FaceCertificate::Box { axis, high } => {
let axis = ["x", "y", "z"].get(*axis as usize).ok_or_else(|| DocError::Invalid("invalid shell source axis".into()))?;
format!("box:{}{axis}", if *high { "+" } else { "-" })
}
FaceCertificate::ExtrudeSide { curve, .. } => format!("side:{curve}"),
FaceCertificate::ExtrudeCap { high, outer, holes, .. } => format!("cap:{}:{}", if *high { "high" } else { "low" }, cap_key(outer, holes)),
FaceCertificate::Imported { payload_sha256, item, face } => format!("import:{payload_sha256}:{item}:{face}"),
FaceCertificate::ShellPlanar { .. } => return Err(DocError::Invalid("nested shell source certificates are unsupported".into())),
};
Ok(format!("shell:{}:{role}", if inner { "inner" } else { "outer" }))
}
pub(crate) fn flat_source_certificate(source: &solvecraft_kernel::FaceSource) -> Option<(u64, FaceCertificate)> {
use solvecraft_kernel::FaceSource;
match source {
FaceSource::Box { producer: Some(producer), axis, high, .. } => Some((*producer, FaceCertificate::Box { axis: *axis, high: *high })),
FaceSource::CertifiedExtrudeSide { producer, sketch, curve, .. } => {
Some((*producer, FaceCertificate::ExtrudeSide { sketch: *sketch, curve: curve.clone() }))
}
FaceSource::CertifiedExtrudeCap { producer, sketch, high, outer, holes, .. } => {
Some((*producer, FaceCertificate::ExtrudeCap { sketch: *sketch, high: *high, outer: outer.clone(), holes: holes.clone() }))
}
FaceSource::Imported { producer: Some(producer), payload_sha256: Some(digest), item, face } => {
Some((*producer, FaceCertificate::Imported { payload_sha256: digest.clone(), item: *item, face: *face }))
}
_ => None,
}
}
pub(crate) fn flat_source_matches(certificate: &FaceCertificate, source: &solvecraft_kernel::FaceSource, producer: u64) -> bool {
use solvecraft_kernel::FaceSource;
match (certificate, source) {
(FaceCertificate::Box { axis, high }, FaceSource::Box { producer: Some(current), axis: current_axis, high: current_high, .. }) => {
producer == *current && axis == current_axis && high == current_high
}
(
FaceCertificate::ExtrudeSide { sketch, curve },
FaceSource::CertifiedExtrudeSide { producer: current, sketch: current_sketch, curve: current_curve, .. },
) => producer == *current && sketch == current_sketch && curve == current_curve,
(
FaceCertificate::ExtrudeCap { sketch, high, outer, holes },
FaceSource::CertifiedExtrudeCap {
producer: current,
sketch: current_sketch,
high: current_high,
outer: current_outer,
holes: current_holes,
..
},
) => producer == *current && sketch == current_sketch && high == current_high && outer == current_outer && holes == current_holes,
(
FaceCertificate::Imported { payload_sha256, item, face },
FaceSource::Imported { producer: Some(current), payload_sha256: Some(digest), item: current_item, face: current_face },
) => producer == *current && payload_sha256 == digest && item == current_item && face == current_face,
_ => false,
}
}
/// Match a generated segment to one exact analytic sketch curve. Coincident
/// candidates are ambiguous and never resolved by distance or candidate order.
pub(crate) fn exact_sketch_curve(sketch: &SolvedSketch, generated: &Seg2) -> Option<String> {
@@ -306,6 +390,7 @@ impl Document {
Some(solvecraft_kernel::FaceSource::Imported { producer: Some(producer), .. })
| Some(solvecraft_kernel::FaceSource::CertifiedExtrudeSide { producer, .. })
| Some(solvecraft_kernel::FaceSource::CertifiedExtrudeCap { producer, .. })
| Some(solvecraft_kernel::FaceSource::ShellPlanar { producer: Some(producer), .. })
| Some(solvecraft_kernel::FaceSource::Box { producer: Some(producer), .. }) => *producer,
_ => name
.strip_prefix('F')
@@ -316,6 +401,8 @@ impl Document {
let certificate = self.configuration_face_certificate(body, index)?;
if let Some(FaceCertificate::ExtrudeSide { curve, .. }) = &certificate {
name = format!("F{origin}:side:{curve}");
} else if let Some(FaceCertificate::ShellPlanar { source, inner, .. }) = &certificate {
name = format!("F{origin}:{}", shell_certificate_role(source, *inner)?);
} else if !name.starts_with(&format!("F{origin}:")) {
name = format!("F{origin}:face:{index}");
}
@@ -332,6 +419,19 @@ impl Document {
let Some(source) = body.body.face_sources().and_then(|sources| sources.get(index)).and_then(Option::as_ref) else {
return Ok(None);
};
if let solvecraft_kernel::FaceSource::ShellPlanar { producer: Some(producer), source, inner, .. } = source {
if !self.feature(*producer).is_some_and(|feature| matches!(feature.kind, FeatureKind::Shell { .. })) {
return Err(DocError::Invalid("shell source producer missing".into()));
}
let (source_producer, source) =
flat_source_certificate(source).ok_or_else(|| DocError::Invalid("shell has no flat certified source".into()))?;
if !self.flat_certificate_producer_matches(source_producer, &source) {
return Err(DocError::Invalid("shell original source producer missing".into()));
}
let certificate = FaceCertificate::ShellPlanar { source_producer, source: Box::new(source), inner: *inner };
certificate.validate()?;
return Ok(Some(certificate));
}
if let solvecraft_kernel::FaceSource::Imported { item, face, payload_sha256: Some(digest), producer: Some(producer) } = source {
if !self.feature(*producer).is_some_and(|feature| matches!(feature.kind, FeatureKind::Import { .. })) {
return Err(DocError::Invalid("import source producer missing".into()));
@@ -378,6 +478,10 @@ pub(crate) fn payload_digest(payload: &str) -> String {
format!("{:x}", Sha256::digest(payload.as_bytes()))
}
#[cfg(test)]
#[path = "config_shell_tests.rs"]
mod shell_tests;
#[cfg(test)]
mod tests {
use super::*;
+459
View File
@@ -0,0 +1,459 @@
use super::*;
use crate::{FeatureKind, Model, PlaneRef};
use serde_json::json;
use solvecraft_geom::{Vec2, Vec3};
fn shell_document() -> (Document, u64, u64) {
let mut doc = Document::new("certified shell");
doc.set_param("width", "20", Some("mm"), None).unwrap();
doc.set_param("wall", "2", Some("mm"), None).unwrap();
let base = doc
.add_feature(
serde_json::from_value(json!({"type":"box","corner":[0,0,0],"length":"width","width":"12","height":"10"})).unwrap(),
Some("Case"),
)
.unwrap();
doc.feature_mut(base).unwrap().body_names = vec!["Case".into()];
let shell = doc
.add_feature(
FeatureKind::Shell {
faces: vec![Vec3::new(5., 6., 10.)],
thickness: "wall".into(),
body: Some("Case".into()),
direction: "inside".into(),
tangent_chain: false,
},
Some("Shell"),
)
.unwrap();
(doc, base, shell)
}
fn face_index(body: &ModelBody, inner: bool) -> usize {
body.body
.face_sources()
.unwrap()
.iter()
.position(|source| {
matches!(source, Some(solvecraft_kernel::FaceSource::ShellPlanar { inner: current, source, .. })
if *current == inner && matches!(source.as_ref(), solvecraft_kernel::FaceSource::Box { axis:0,high:true,.. }))
})
.unwrap()
}
#[test]
fn shell_planar_raw_bounds_flat_identity_and_clone_remap() {
let plane = solvecraft_geom::Plane::XY;
let same = |planes: &[Option<solvecraft_geom::Plane>]| super::super::styles::shell_plane_family(planes.iter().copied());
assert!(same(&[Some(plane), Some(plane)]));
assert!(!same(&[]));
assert!(!same(&[Some(plane), None]));
for different in [
solvecraft_geom::Plane { origin: Vec3::X, ..plane },
solvecraft_geom::Plane { x: -plane.x, y: -plane.y, ..plane },
solvecraft_geom::Plane { y: -plane.y, ..plane },
] {
assert!(!same(&[Some(plane), Some(different)]));
}
let valid = json!({"kind":"shell_planar","source_producer":3,"source":{"kind":"extrude_side","sketch":2,"curve":"c4"},"inner":true});
assert!(validate_certificate_json(&valid).is_ok());
let mut certificate: FaceCertificate = serde_json::from_value(valid.clone()).unwrap();
certificate.remap_features(&[(2, 20), (3, 30)].into_iter().collect());
assert_eq!(serde_json::to_value(&certificate).unwrap()["source_producer"], 30);
assert_eq!(serde_json::to_value(&certificate).unwrap()["source"]["sketch"], 20);
for bad in [
json!({"kind":"shell_planar","source_producer":0,"source":{"kind":"box","axis":0,"high":true},"inner":false}),
json!({"kind":"shell_planar","source_producer":3,"source":null,"inner":false}),
json!({"kind":"shell_planar","source_producer":3,"source":valid,"inner":false}),
json!({"kind":"shell_planar","source_producer":3,"source":{"kind":"extrude_side","sketch":2,"curve":"x".repeat(1_000_000)},"inner":false}),
json!({"kind":"shell_planar","source_producer":3,"source":{"kind":"box","axis":0,"high":true,"extra":[[[]]]},"inner":false}),
] {
assert!(validate_certificate_json(&bad).is_err());
}
}
#[test]
fn shell_planar_anchor_rebuild_cold_geometry_and_missing_source_errors() {
let (mut doc, base, shell) = shell_document();
let mut model = Model::new();
model.evaluate(&doc);
assert!(model.results.iter().all(|result| result.error.is_none()), "{:?}", model.results);
let state = model.state();
let body = state.body("Case").unwrap();
let outer = doc.strong_face_target(body, face_index(body, false), Vec3::default()).unwrap();
let outer_matches: Vec<_> = body.body.face_sources().unwrap().iter().enumerate().filter_map(|(index, source)| {
matches!(source, Some(solvecraft_kernel::FaceSource::ShellPlanar { inner: false, source, .. }) if matches!(source.as_ref(), solvecraft_kernel::FaceSource::Box { axis: 0, high: true, .. })).then_some((index, body.body.face_plane(index)))
}).collect();
assert_eq!(outer_matches.len(), 1, "outer support must be one physical face: {outer_matches:?}");
assert!(super::super::styles::shell_plane_family(outer_matches.iter().map(|(_, plane)| *plane)));
let outer_plane = body.body.face_plane(face_index(body, false)).unwrap();
let outer_anchor = doc.capture_face_anchor(body, face_index(body, false), &outer_plane, &[], 0, &[]).unwrap();
let projected = crate::project::resolve_anchored_face(&doc, &state, &outer_plane, solvecraft_sketch::LinkKind::Project, &outer_anchor).unwrap();
let mut linked = solvecraft_sketch::Sketch::new();
let link = linked.add_link(solvecraft_sketch::LinkKind::Project, projected.source, &projected.geom).unwrap();
assert!(!linked.update_link(&link, &projected.geom).unwrap());
let inner_index = face_index(body, true);
let inner = doc.strong_face_target(body, inner_index, Vec3::default()).unwrap();
assert_ne!(outer.key(), inner.key());
assert!(matches!(&inner.certificate, Some(FaceCertificate::ShellPlanar { source_producer,inner:true,.. }) if *source_producer==base));
assert_eq!(inner.origin_feature, shell);
let picked = body.body.face_plane(inner_index).unwrap();
assert!((picked.origin.x - 18.).abs() < 1e-8);
let anchor = doc.capture_face_anchor(body, inner_index, &picked, &[], 0, &[]).unwrap();
let mut sketch = solvecraft_sketch::Sketch::new();
let points: Vec<_> =
[(2., 2.), (4., 2.), (4., 4.), (2., 4.)].into_iter().map(|(x, y)| sketch.add_point(Vec2::new(x, y), None).unwrap()).collect();
for edge in 0..4 {
sketch.add_line_pts(points[edge], points[(edge + 1) % 4], None).unwrap();
}
let sk = doc
.add_feature(
FeatureKind::Sketch {
plane: PlaneRef::Face { plane: picked, at: picked.origin, name: None, anchor: Some(Box::new(anchor.clone())) },
sketch,
},
Some("Inner face sketch"),
)
.unwrap();
let boss = doc
.add_feature(
FeatureKind::Extrude {
sketch: sk,
profiles: crate::ProfileSel::All,
extent: serde_json::from_value(json!({"distance":"2"})).unwrap(),
operation: crate::Operation::NewBody,
targets: vec![],
},
Some("Face boss"),
)
.unwrap();
model.evaluate(&doc);
let before = model.state().bodies.iter().find(|body| body.feature == boss).unwrap().mesh().bounds();
doc.set_param("width", "30", Some("mm"), None).unwrap();
doc.set_param("wall", "3", Some("mm"), None).unwrap();
model.evaluate(&doc);
let changed = model.state();
let plane = doc.resolve_face_anchor(&anchor, 0, &changed).unwrap();
assert!((plane.origin.x - 27.).abs() < 1e-8);
let after = changed.bodies.iter().find(|body| body.feature == boss).unwrap().mesh().bounds();
assert!((solvecraft_kernel::measure(&changed.bodies.iter().find(|body| body.feature == boss).unwrap().body).unwrap().volume - 8.).abs() < 1e-8);
assert!((doc.resolve_face_anchor(&outer_anchor, 0, &changed).unwrap().origin.x - 30.).abs() < 1e-8);
let changed_outer_plane = doc.resolve_face_anchor(&outer_anchor, 0, &changed).unwrap();
let refreshed =
crate::project::resolve_anchored_face(&doc, &changed, &changed_outer_plane, solvecraft_sketch::LinkKind::Project, &outer_anchor).unwrap();
assert!(!linked.update_link(&link, &refreshed.geom).unwrap(), "same exterior boundary must retain projected IDs");
assert!((after.min.x - before.min.x - 9.).abs() < 1e-8, "{before:?} -> {after:?}");
assert!((after.max.x - before.max.x - 9.).abs() < 1e-8, "{before:?} -> {after:?}");
assert!((after.min.y - before.min.y).abs() < 1e-8 && (after.min.z - before.min.z).abs() < 1e-8);
let saved = doc.to_json();
assert!(saved.contains("solvecraft/13"));
let loaded = crate::format::read(&saved).unwrap();
let mut cold = Model::new();
cold.evaluate(&loaded);
let cold_state = cold.state();
let cold_bounds = cold_state.bodies.iter().find(|body| body.feature == boss).unwrap().mesh().bounds();
assert!(after.min.dist(cold_bounds.min) < 1e-9 && after.max.dist(cold_bounds.max) < 1e-9);
assert!(loaded.resolve_face_anchor(&anchor, 0, &cold_state).unwrap().origin.dist(plane.origin) < 1e-9);
let mut wrong = anchor.clone();
wrong.target.origin_feature = base;
assert!(doc.resolve_face_anchor(&wrong, 0, &changed).is_err());
let mut wrong_source = anchor.clone();
if let Some(FaceCertificate::ShellPlanar { source_producer, .. }) = &mut wrong_source.target.certificate {
*source_producer = shell;
}
assert!(doc.resolve_face_anchor(&wrong_source, 0, &changed).is_err());
doc.feature_mut(shell).unwrap().suppressed = true;
model.evaluate(&doc);
let lost = model.state();
assert!(doc.resolve_face_anchor(&anchor, 0, &lost).is_err());
assert!(!lost.bodies.iter().any(|body| body.feature == boss), "lost attachment must prevent downstream geometry");
}
#[test]
fn shell_planar_format13_keeps_v12_documents_and_refuses_future() {
let (doc, _, _) = shell_document();
let mut legacy: serde_json::Value = serde_json::from_str(&doc.to_json()).unwrap();
legacy["format"] = json!("solvecraft/12");
let loaded = crate::format::read(&legacy.to_string()).unwrap();
let mut current: serde_json::Value = serde_json::from_str(&loaded.to_json()).unwrap();
assert_eq!(current["format"], "solvecraft/13");
current["format"] = json!("solvecraft/12");
assert_eq!(legacy, current, "identity migration must preserve every prior field");
legacy["format"] = json!("solvecraft/14");
assert!(crate::format::read(&legacy.to_string()).is_err());
}
#[test]
fn regularized_shell_keeps_legacy_and_explicit_single_face_projections_closed() {
let (mut doc, _, _) = shell_document();
let mut model = Model::new();
for (width, wall) in [(20., 2.), (30., 3.)] {
doc.set_param("width", &width.to_string(), Some("mm"), None).unwrap();
doc.set_param("wall", &wall.to_string(), Some("mm"), None).unwrap();
model.evaluate(&doc);
let loaded = crate::format::read(&doc.to_json()).unwrap();
let mut cold = Model::new();
cold.evaluate(&loaded);
for state in [model.state(), cold.state()] {
let body = state.body("Case").unwrap();
let index = face_index(body, false);
let mesh = body.mesh();
assert_eq!(body.body.face_sources().unwrap().iter().filter(|source|matches!(source,Some(solvecraft_kernel::FaceSource::ShellPlanar {inner:false,source,..}) if matches!(source.as_ref(),solvecraft_kernel::FaceSource::Box {axis:0,high:true,..}))).count(),1,"no physical outer-wall strip face may remain");
let plane = body.body.face_plane(index).unwrap();
let point = mesh
.triangles
.iter()
.zip(&mesh.tri_face)
.find_map(|(tri, face)| (*face as usize == index).then(|| mesh.tri(tri)).flatten())
.map(|[a, b, c]| (a + b + c) / 3.)
.unwrap();
let source = solvecraft_sketch::LinkSource::Face { body: "Case".into(), at: point };
let (values, _) = doc.param_values();
let legacy = crate::project::resolve(&doc, &values, &state, &plane, solvecraft_sketch::LinkKind::Project, &source).unwrap();
let explicit = crate::project::resolve_face_indices(&state, &plane, solvecraft_sketch::LinkKind::Project, "Case", &[index]).unwrap();
assert_eq!(legacy.geom, explicit.geom);
assert_eq!(explicit.geom.len(), 4, "raw planar piece keeps all four edges");
let mut sketch = solvecraft_sketch::Sketch::new();
let link = sketch.add_link(solvecraft_sketch::LinkKind::Project, source, &legacy.geom).unwrap();
sketch.links[0].face_name = Some(crate::naming::face_names(body)[index].clone());
let warnings = crate::project::refresh_links(&doc, &values, &state, &plane, &mut sketch);
assert!(warnings.is_empty(), "{warnings:?}");
assert!(!sketch.links[0].lost);
assert!(!sketch.update_link(&link, &explicit.geom).unwrap());
}
}
}
#[test]
fn saved_former_strip_projection_reports_lost_without_expanding_healthy_material() {
let (mut doc, _, shell) = shell_document();
let mut model = Model::new();
model.evaluate(&doc);
let state = model.state();
let body = state.body("Case").unwrap();
let index = face_index(body, false);
let plane = body.body.face_plane(index).unwrap();
let stem = crate::naming::face_names(body)[index].clone();
assert_eq!(crate::naming::strip_piece(&stem), stem);
// Reconstruct the original raw shell before certificate stamping/regularization,
// and run the unchanged historical naming/capture path on its actual strip.
let before = model.state_before(shell);
// Legacy Shell did not propagate construction certificates. Omit transient
// metadata here; operand paint also disables new wall-run coalescence so
// this control retains the actual historical raw pieces.
let raw_source = solvecraft_kernel::box_solid(Vec3::ZERO, Vec3::new(20., 12., 10.)).unwrap().with_paint(Some(solvecraft_kernel::Paint {
opacity: 1.,
faces: vec![solvecraft_kernel::FacePaint { face: 0, color: [1., 0., 0.], opacity: 1. }],
}));
let raw = solvecraft_kernel::shell(&raw_source, &[Vec3::new(5., 6., 10.)], 2.).unwrap();
let mut old_body = ModelBody::new("Case".into(), raw, shell);
old_body.names = crate::naming::NameCell::new(Some(std::sync::Arc::new(crate::naming::NamingOrigin {
feature: doc.feature(shell).unwrap().clone(),
before,
depth: 1,
mats: vec![],
})));
let former = crate::naming::face_name_at_body(&old_body, Vec3::new(20., 6., 1.)).unwrap();
assert_eq!(former, format!("{stem}#1"), "historical actual strip capture records full piece name");
let mut sketch = solvecraft_sketch::Sketch::new();
let corners = [Vec2::new(0., 0.), Vec2::new(12., 0.), Vec2::new(12., 2.), Vec2::new(0., 2.)];
let geometry: Vec<_> = (0..4).map(|i| solvecraft_sketch::LinkGeom::Line(corners[i], corners[(i + 1) % 4])).collect();
sketch
.add_link(
solvecraft_sketch::LinkKind::Project,
solvecraft_sketch::LinkSource::Face { body: "Case".into(), at: Vec3::new(20., 6., 1.) },
&geometry,
)
.unwrap();
sketch.links[0].face_name = Some(former.clone());
let original = sketch.clone();
let sk = doc
.add_feature(
FeatureKind::Sketch { plane: PlaneRef::Face { plane, at: Vec3::new(20., 6., 1.), name: Some(former), anchor: None }, sketch },
Some("Saved strip sketch"),
)
.unwrap();
let boss = doc
.add_feature(
FeatureKind::Extrude {
sketch: sk,
profiles: crate::ProfileSel::All,
extent: serde_json::from_value(json!({"distance":"1"})).unwrap(),
operation: crate::Operation::NewBody,
targets: vec![],
},
Some("Saved strip profile material"),
)
.unwrap();
let mut legacy: serde_json::Value = serde_json::from_str(&doc.to_json()).unwrap();
legacy["format"] = json!("solvecraft/12");
let mut doc = crate::format::read(&legacy.to_string()).unwrap();
for width in [20., 30.] {
doc.set_param("width", &width.to_string(), Some("mm"), None).unwrap();
doc.set_param("wall", "3", Some("mm"), None).unwrap();
model.evaluate(&doc);
let loaded = crate::format::read(&doc.to_json()).unwrap();
let mut cold = Model::new();
cold.evaluate(&loaded);
for current in [&model, &cold] {
let warning = current.results.iter().find(|r| r.id == sk).unwrap().warning.as_deref().unwrap();
assert!(warning.contains("no longer exists; last geometry retained"), "{warning}");
let state = current.state();
let solved = state.sketch(sk).unwrap();
assert!(solved.sketch.links[0].lost);
assert_eq!(solved.sketch.curves, original.curves);
assert_eq!(solved.sketch.points, original.points);
let material = state.bodies.iter().find(|body| body.feature == boss).unwrap();
assert!(
(solvecraft_kernel::measure(&material.body).unwrap().volume - 24.).abs() < 1e-8,
"old24mm3 profile must not silently become120mm3 wall profile"
);
let bounds = material.mesh().bounds();
assert!((bounds.min.x - width).abs() < 1e-8 && (bounds.max.x - width - 1.).abs() < 1e-8);
assert!((bounds.max.y - 12.).abs() < 1e-8 && (bounds.max.z - 2.).abs() < 1e-8);
}
}
}
#[test]
fn saved_curved_strip_projection_is_lost_after_exact_wall_coalescence() {
use solvecraft_kernel::{FacePaint, Paint};
use solvecraft_sketch::{LinkKind, LinkSource, Sketch};
let mut doc = Document::new("saved curved strip");
doc.set_param("width", "80", Some("mm"), None).unwrap();
doc.set_param("wall", "2", Some("mm"), None).unwrap();
let base = doc
.add_feature(
serde_json::from_value(json!({"type":"box","corner":["-width/2",-30,0],"length":"width","width":"60","height":"30"})).unwrap(),
Some("Case"),
)
.unwrap();
doc.feature_mut(base).unwrap().body_names = vec!["Case".into()];
doc.add_feature(
serde_json::from_value(json!({"type":"fillet","body":"Case","edges":[[40,30,15],[-40,30,15],[-40,-30,15],[40,-30,15]],"radius":"6"}))
.unwrap(),
Some("Corners"),
)
.unwrap();
let shell = doc
.add_feature(
FeatureKind::Shell {
faces: vec![Vec3::new(0., 0., 30.)],
thickness: "wall".into(),
body: Some("Case".into()),
direction: "inside".into(),
tangent_chain: false,
},
Some("Shell"),
)
.unwrap();
let mut model = Model::new();
model.evaluate(&doc);
assert!(model.results.iter().all(|r| r.error.is_none()), "{:?}", model.results);
let before = model.state_before(shell);
// Per-face paint disables the construction optimization: this control keeps
// the real pre-repair slab faces without forging a name or source witness.
let old_source = before
.body("Case")
.unwrap()
.body
.clone()
.with_paint(Some(Paint { opacity: 1., faces: vec![FacePaint { face: 0, color: [1., 0., 0.], opacity: 1. }] }));
let cavity_box = solvecraft_kernel::box_solid(Vec3::new(-38., -28., 2.), Vec3::new(38., 28., 35.)).unwrap();
let cavity = solvecraft_kernel::fillet(
&cavity_box,
&[Vec3::new(38., 28., 15.), Vec3::new(-38., 28., 15.), Vec3::new(-38., -28., 15.), Vec3::new(38., -28., 15.)],
4.,
)
.unwrap();
let raw = solvecraft_kernel::boolean(&old_source, &cavity, solvecraft_kernel::BoolOp::Cut).unwrap().unwrap();
let mut old_body = ModelBody::new("Case".into(), raw, shell);
old_body.names = crate::naming::NameCell::new(Some(std::sync::Arc::new(crate::naming::NamingOrigin {
feature: doc.feature(shell).unwrap().clone(),
before,
depth: 1,
mats: vec![],
})));
let t = std::f64::consts::FRAC_1_SQRT_2;
let at = Vec3::new(34. + 6. * t, 24. + 6. * t, 1.);
let former = crate::naming::face_name_at_body(&old_body, at).unwrap();
let stem = crate::naming::strip_piece(&former).to_string();
assert_ne!(former, stem, "actual old cylindrical strip must have a piece suffix");
let state = model.state();
let current = state.body("Case").unwrap();
let names = crate::naming::face_names(current);
let index = names.iter().position(|name| name == &stem).unwrap();
assert!(current.body.face_was_coalesced(index));
assert!(current.body.face_plane(index).is_none(), "witness grants no planar attachment authority");
assert!(old_body.body.face_sources().is_none_or(|sources| sources.iter().enumerate().all(|(index, _)| !old_body.body.face_was_coalesced(index))));
let mut old_state = (*model.state()).clone();
old_state.bodies = vec![old_body];
let source = LinkSource::Face { body: "Case".into(), at };
// Recover the saved projection from the old face's complete raw topology.
// Display seam suppression must not discard a historical strip boundary.
let old_body = old_state.body("Case").unwrap();
let old_index = crate::naming::face_on_body(old_body, &former, at).unwrap().0;
let mesh = old_body.mesh();
let mut geometry = Vec::new();
for (index, faces) in mesh.edge_faces.iter().enumerate() {
if !faces.contains(&(old_index as u32)) {
continue;
}
let points = &mesh.edges[index];
let a = solvecraft_geom::Plane::YZ.to_local(*points.first().unwrap());
let b = solvecraft_geom::Plane::YZ.to_local(*points.last().unwrap());
let (a, b) = (Vec2::new(a.x, a.y), Vec2::new(b.x, b.y));
assert!(a.dist(b) > 1e-6);
for point in points {
let q = solvecraft_geom::Plane::YZ.to_local(*point);
let q = Vec2::new(q.x, q.y);
let direction = b - a;
let t = ((q - a).dot(direction) / direction.dot(direction)).clamp(0., 1.);
assert!(q.dist(a + direction * t) < 1e-7, "exact cylindrical quadrant projects to a straight support");
}
geometry.push(solvecraft_sketch::LinkGeom::Line(a, b));
}
assert_eq!(geometry.len(), 4, "historical curved strip has a complete four-edge rectangular projection");
let mut sketch = Sketch::new();
sketch.add_link(LinkKind::Project, source, &geometry).unwrap();
sketch.links[0].face_name = Some(former);
let original = sketch.clone();
let sk = doc
.add_feature(FeatureKind::Sketch { plane: PlaneRef::Custom { plane: solvecraft_geom::Plane::YZ }, sketch }, Some("Saved corner strip"))
.unwrap();
let boss = doc
.add_feature(
FeatureKind::Extrude {
sketch: sk,
profiles: crate::ProfileSel::All,
extent: serde_json::from_value(json!({"distance":"1"})).unwrap(),
operation: crate::Operation::NewBody,
targets: vec![],
},
Some("Retained strip material"),
)
.unwrap();
for (width, wall) in [(80., 2.), (100., 3.)] {
doc.set_param("width", &width.to_string(), Some("mm"), None).unwrap();
doc.set_param("wall", &wall.to_string(), Some("mm"), None).unwrap();
model.evaluate(&doc);
let loaded = crate::format::read(&doc.to_json()).unwrap();
let mut cold = Model::new();
cold.evaluate(&loaded);
for current in [&model, &cold] {
let result = current.results.iter().find(|r| r.id == sk).unwrap();
assert!(result.error.is_none());
assert!(result.warning.as_deref().unwrap().contains("no longer exists; last geometry retained"));
let state = current.state();
let solved = state.sketch(sk).unwrap();
assert!(solved.sketch.links[0].lost);
assert_eq!(solved.sketch.points, original.points);
assert_eq!(solved.sketch.curves, original.curves);
let material = state.bodies.iter().find(|body| body.feature == boss).unwrap();
assert!(
(solvecraft_kernel::measure(&material.body).unwrap().volume - 12.).abs() < 1e-7,
"two-mm strip cannot expand to a full thirty-mm wall"
);
}
}
}
+36
View File
@@ -5,6 +5,12 @@ use serde::{Deserialize, Serialize};
use serde_json::Value;
use solvecraft_geom::Vec3;
pub(crate) fn shell_plane_family(planes: impl IntoIterator<Item = Option<solvecraft_geom::Plane>>) -> bool {
let mut planes = planes.into_iter();
let Some(Some(first)) = planes.next() else { return false };
first.origin.is_finite() && first.x.is_finite() && first.y.is_finite() && planes.all(|plane| plane == Some(first))
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ConfigFaceTarget {
@@ -98,6 +104,20 @@ pub fn validate_styles_json(value: &Value) -> Result<()> {
}
impl Document {
pub(crate) fn flat_certificate_producer_matches(&self, producer: u64, certificate: &super::FaceCertificate) -> bool {
use super::FaceCertificate;
let Some(feature) = self.feature(producer) else { return false };
match (certificate, &feature.kind) {
(FaceCertificate::Box { .. }, FeatureKind::Box { operation: crate::Operation::NewBody, .. }) => true,
(FaceCertificate::ExtrudeCap { sketch, .. }, FeatureKind::Extrude { sketch: current, extent, operation: crate::Operation::NewBody, .. }) => sketch == current && extent.taper.is_none(),
(FaceCertificate::ExtrudeSide { sketch, curve }, FeatureKind::Extrude { sketch: current, extent, operation: crate::Operation::NewBody, .. }) => {
sketch == current && extent.taper.is_none()
&& self.feature(*sketch).is_some_and(|feature| matches!(&feature.kind, FeatureKind::Sketch { sketch, .. } if sketch.curves.iter().any(|candidate| candidate.id == *curve && !candidate.construction && matches!(candidate.kind, solvecraft_sketch::CurveKind::Line { .. } | solvecraft_sketch::CurveKind::Circle { .. } | solvecraft_sketch::CurveKind::Arc { .. }))))
}
(FaceCertificate::Imported { .. }, FeatureKind::Import { .. }) => true,
_ => false,
}
}
/// Certify named generator lineage; generic index-derived naming is unsupported.
pub fn certify_config_face(&self, target: &ConfigFaceTarget) -> Result<()> {
target.validate()?;
@@ -121,6 +141,11 @@ impl Document {
&& self.feature(*sketch).is_some_and(|feature| matches!(&feature.kind, FeatureKind::Sketch { sketch, .. } if sketch.curves.iter().any(|candidate| candidate.id == *curve && !candidate.construction && matches!(candidate.kind, solvecraft_sketch::CurveKind::Line { .. } | solvecraft_sketch::CurveKind::Circle { .. } | solvecraft_sketch::CurveKind::Arc { .. }))))
}
(super::FaceCertificate::Imported { .. }, FeatureKind::Import { .. }) => role.strip_prefix("face:").is_some_and(|index| index.parse::<usize>().is_ok()),
(super::FaceCertificate::ShellPlanar { source_producer, source, inner }, FeatureKind::Shell { .. }) => {
*source_producer != target.origin_feature
&& self.flat_certificate_producer_matches(*source_producer, source)
&& super::shell_certificate_role(source, *inner).is_ok_and(|expected| role == expected)
}
_ => false,
};
return if valid { Ok(()) } else { Err(DocError::Invalid("face certificate no longer matches its producer".into())) };
@@ -156,6 +181,17 @@ impl Document {
if body.body.is_mesh() || body.definition_name() != target.body {
return Err(DocError::Invalid("configured face definition does not match".into()));
}
if let Some(super::FaceCertificate::ShellPlanar { source_producer, source, inner }) = &target.certificate {
let indices: Vec<_> = body.body.face_sources().into_iter().flatten().enumerate().filter_map(|(index, current)| {
matches!(current, Some(solvecraft_kernel::FaceSource::ShellPlanar { producer: Some(producer), source: current_source, inner: current_inner, .. }) if *producer == target.origin_feature && inner == current_inner && super::flat_source_matches(source, current_source, *source_producer)).then_some(index)
}).collect();
// A shell can split one original support into several pieces. All pieces
// must retain the same canonical frame, not merely lie on a common plane.
if !shell_plane_family(indices.iter().map(|index| body.body.face_plane(*index))) {
return Err(DocError::Unknown("shell planar face certificate is missing or ambiguous".into()));
}
return Ok(indices);
}
if matches!(target.certificate, Some(super::FaceCertificate::Box { .. } | super::FaceCertificate::ExtrudeCap { .. })) {
let indices: Vec<_> = body
.body
+2
View File
@@ -2910,6 +2910,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
}
o => return Err(DocError::Invalid(format!("unknown shell direction `{o}` (inside, outside or both)"))),
};
let nb = nb.with_shell_certificate(f.id)?;
if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::new(mb.name, nb, mb.feature);
}
@@ -3159,6 +3160,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
FeatureKind::Chamfer { distance2, angle, flip, .. } => chamfer_with(vals, &mb.body, &edges, r, distance2, angle, *flip)?,
_ => return Err(DocError::Invalid("blend".into())),
};
let nb = nb.with_preserved_planar_sources(&mb.body)?;
let (name, feat) = (mb.name.clone(), mb.feature);
if let Some(slot) = st.bodies.get_mut(ti) {
*slot = ModelBody::new(name, nb, feat);
+6 -3
View File
@@ -36,13 +36,16 @@
//! - 12: persistent analytic offset boundary roles and association profile references.
//! Older analytic chains retain their source/output correspondence and saved geometry;
//! migration never invents retired entities or renumbers existing curves.
//! - 13: exact planar shell face anchors retain the shell producer, original certified
//! source and distinct inner/outer role. Version 12 anchors retain their identities;
//! migration never guesses attachments for older designs.
use serde_json::Value;
use crate::{DocError, Document, Result};
/// The format this build writes.
pub const FORMAT: u32 = 12;
pub const FORMAT: u32 = 13;
pub fn format_string(n: u32) -> String {
format!("solvecraft/{n}")
@@ -55,8 +58,8 @@ pub fn version_of(s: &str) -> Option<u32> {
/// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`.
type JsonStep = fn(&mut Value) -> Result<()>;
const JSON_STEPS: [JsonStep; 11] =
[|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, |_| Ok(()), |_| Ok(()), |_| Ok(()), |_| Ok(()), |_| Ok(()), |_| Ok(())];
const JSON_STEPS: [JsonStep; 12] =
[|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, |_| Ok(()), |_| Ok(()), |_| Ok(()), |_| Ok(()), |_| Ok(()), |_| Ok(()), |_| Ok(())];
/// Old XZ references retain their exact frame, including nested offset and angled planes.
/// New origin references use +Y; freezing the old frame avoids changing sketch coordinates,
+130 -20
View File
@@ -141,15 +141,13 @@ fn resolve_uncached(
LinkKind::Intersect => section(plane, &mesh, Some(f)),
_ => {
let mut g = Vec::new();
for e in order::face_edges(&mesh, f, plane)? {
if mesh.seams.get(e).copied().unwrap_or(false) {
continue;
}
let target = st.bodies.get(bi).ok_or_else(|| lost("the projection body"))?;
for e in projection_face_edges(target, &mesh, f, plane)? {
if let Some(pts) = mesh.edges.get(e) {
g.extend(fit_world(plane, pts));
}
}
g
if certified_planar_construction(target, f as usize) { merge_family_lines(g)? } else { g }
}
}
}
@@ -293,15 +291,13 @@ fn resolve_named_face(st: &ModelState, plane: &Plane, kind: LinkKind, body: &str
section(plane, &mesh, Some(index))
} else {
let mut geom = Vec::new();
for edge in order::face_edges(&mesh, index, plane)? {
if mesh.seams.get(edge).copied().unwrap_or(false) {
continue;
}
for edge in projection_face_edges(target, &mesh, index, plane)? {
if let Some(points) = mesh.edges.get(edge) {
geom.extend(fit_world(plane, points));
}
}
merge_split_circles(geom)
let geom = merge_split_circles(geom);
if certified_planar_construction(target, index as usize) { merge_family_lines(geom)? } else { geom }
};
if geom.is_empty() {
return Err(DocError::Invalid("named face projects to nothing on this sketch plane".into()));
@@ -309,6 +305,29 @@ fn resolve_named_face(st: &ModelState, plane: &Plane, kind: LinkKind, body: &str
Ok(Resolved { geom, wires: Vec::new(), source: LinkSource::Face { body: body.into(), at }, moved: false, face_index })
}
fn projection_face_edges(body: &crate::ModelBody, mesh: &Mesh, index: u32, plane: &Plane) -> Result<Vec<usize>> {
let shell_plane = body
.body
.face_sources()
.and_then(|sources| sources.get(index as usize))
.is_some_and(|source| matches!(source, Some(solvecraft_kernel::FaceSource::ShellPlanar { .. })));
if shell_plane { order::raw_face_edges(mesh, index, plane) } else { order::face_edges(mesh, index, plane) }
}
fn certified_planar_construction(body: &crate::ModelBody, index: usize) -> bool {
body.body.face_sources().and_then(|sources| sources.get(index)).is_some_and(|source| {
matches!(
source,
Some(
solvecraft_kernel::FaceSource::ShellPlanar { producer: Some(_), .. }
| solvecraft_kernel::FaceSource::Box { producer: Some(_), .. }
| solvecraft_kernel::FaceSource::CertifiedExtrudeSide { plane: Some(_), .. }
| solvecraft_kernel::FaceSource::CertifiedExtrudeCap { .. }
)
)
})
}
pub fn resolve_anchored_face(doc: &Document, st: &ModelState, plane: &Plane, kind: LinkKind, anchor: &crate::FaceAnchor) -> Result<Resolved> {
let body = st.body(&anchor.target.body).ok_or_else(|| lost("anchored projection body"))?;
let indices = doc.configuration_face_indices(body, &anchor.target)?;
@@ -329,21 +348,31 @@ pub fn resolve_face_indices(st: &ModelState, plane: &Plane, kind: LinkKind, body
}
let selected: std::collections::BTreeSet<_> = indices.iter().copied().collect();
let mut geometry = Vec::new();
for index in indices {
let index = u32::try_from(*index).map_err(|_| DocError::Invalid("anchored face index exceeds format bounds".into()))?;
if kind == LinkKind::Intersect {
if kind == LinkKind::Intersect {
for index in indices {
let index = u32::try_from(*index).map_err(|_| DocError::Invalid("anchored face index exceeds format bounds".into()))?;
geometry.extend(section(plane, &mesh, Some(index)));
}
} else {
let edges = if let [index] = indices {
projection_face_edges(
body,
&mesh,
u32::try_from(*index).map_err(|_| DocError::Invalid("face index exceeds format bounds".into()))?,
plane,
)?
} else {
for edge in order::face_edges(&mesh, index, plane)? {
if mesh.seams.get(edge).copied().unwrap_or(false) {
continue;
}
if let Some(points) = mesh.edges.get(edge) {
geometry.extend(fit_world(plane, points));
}
order::family_edges(&mesh, &selected, plane)?
};
for edge in edges {
if let Some(points) = mesh.edges.get(edge) {
geometry.extend(fit_world(plane, points));
}
}
}
if kind != LinkKind::Intersect && (indices.len() > 1 || indices.first().is_some_and(|index| certified_planar_construction(body, *index))) {
geometry = merge_family_lines(geometry)?;
}
let point = mesh
.triangles
.iter()
@@ -373,6 +402,75 @@ pub fn refresh_face_anchor_links(
refresh_links_in(doc, vals, st, plane, sk, Some(anchor))
}
/// Remove degree-two straight stations introduced by coplanar face subdivision.
/// The walk uses endpoint incidence; geometric sorting does not imply adjacency.
fn merge_family_lines(geom: Vec<LinkGeom>) -> Result<Vec<LinkGeom>> {
if geom.len().saturating_mul(geom.len()).saturating_mul(3) > 10_000_000 {
return Err(DocError::Invalid("face family straight-boundary work limit exceeded".into()));
}
let ends = |curve: &LinkGeom| match curve {
LinkGeom::Line(a, b) | LinkGeom::Arc { a, b, .. } | LinkGeom::Conic { a, b, .. } => Some((*a, *b)),
LinkGeom::Spline { pts, periodic: false, .. } => pts.first().zip(pts.last()).map(|(a, b)| (*a, *b)),
_ => None,
};
let scale = geom.iter().filter_map(&ends).flat_map(|(a, b)| [a.len(), b.len()]).fold(1., f64::max);
let tol = scale * f64::EPSILON * 512.;
let mut used = vec![false; geom.len()];
let mut result = Vec::new();
for (index, curve) in geom.iter().enumerate() {
if used.get(index).copied().unwrap_or(true) {
continue;
}
if let Some(slot) = used.get_mut(index) {
*slot = true;
}
let LinkGeom::Line(a, b) = curve else {
result.push(curve.clone());
continue;
};
let mut endpoints = [*a, *b];
for side in 0..2 {
let mut current = index;
for _ in 0..geom.len() {
let Some(point) = endpoints.get(side).copied() else { break };
let mut incidence = geom
.iter()
.enumerate()
.filter_map(|(other, curve)| ends(curve).filter(|(a, b)| a.dist(point) <= tol || b.dist(point) <= tol).map(|_| other));
let (Some(one), Some(two), None) = (incidence.next(), incidence.next(), incidence.next()) else { break };
let other = if one == current {
two
} else if two == current {
one
} else {
break;
};
if used.get(other).copied().unwrap_or(true) {
break;
}
let Some(LinkGeom::Line(a, b)) = geom.get(other) else { break };
let next = if a.dist(point) <= tol { *b } else { *a };
let Some(opposite) = endpoints.get(1 - side).copied() else { break };
let (Some(incoming), Some(outgoing)) = ((point - opposite).normalized(), (next - point).normalized()) else { break };
if incoming.dot(outgoing) <= 0. || incoming.cross(outgoing).abs() > f64::EPSILON * 512. {
break;
}
if let Some(slot) = used.get_mut(other) {
*slot = true;
}
if let Some(slot) = endpoints.get_mut(side) {
*slot = next;
}
current = other;
}
}
if let (Some(a), Some(b)) = (endpoints.first(), endpoints.get(1)) {
result.push(LinkGeom::Line(*a, *b));
}
}
Ok(result)
}
pub fn refresh_links(doc: &Document, vals: &BTreeMap<String, Value>, st: &ModelState, plane: &Plane, sk: &mut Sketch) -> Vec<String> {
refresh_links_in(doc, vals, st, plane, sk, None)
}
@@ -394,6 +492,18 @@ fn refresh_links_in(
&& let (Some(name), LinkSource::Face { body, at }) = (&l.face_name, &mut source)
{
if let Some((index, p, split)) = st.body(body).and_then(|target| crate::naming::face_on_body(target, name, *at)) {
let stem = crate::naming::strip_piece(name);
let removed_piece = split
&& name != stem
&& st.body(body).is_some_and(|target| {
(certified_planar_construction(target, index) || target.body.face_was_coalesced(index))
&& crate::naming::face_names(target).get(index).is_some_and(|current| current == stem)
});
if removed_piece {
sk.set_link_lost(&l.id, true);
warn.push(format!("projected face piece `{name}` no longer exists; last geometry retained"));
continue;
}
*at = p;
named_face = Some((body.clone(), index, p));
if split {
+103 -1
View File
@@ -10,6 +10,36 @@ use crate::{DocError, Result};
type Key = (i64, i64, i64);
pub(super) fn face_edges(mesh: &Mesh, face: u32, plane: &Plane) -> Result<Vec<usize>> {
let edges = mesh.face_edges(face).into_iter().filter(|e| !mesh.seams.get(*e).copied().unwrap_or(false)).collect();
boundary_edges(mesh, edges, plane)
}
/// Full boundary of an explicitly selected analytic planar piece. Display seams
/// hide subdivisions for picking, but cannot remove a raw piece's closing edge.
pub(super) fn raw_face_edges(mesh: &Mesh, face: u32, plane: &Plane) -> Result<Vec<usize>> {
boundary_edges(mesh, mesh.face_edges(face), plane)
}
/// Exterior of an exact current face family. Shared topological edges are internal;
/// geometric proximity never merges distinct edges or chooses a different face.
pub(super) fn family_edges(mesh: &Mesh, selected: &std::collections::BTreeSet<usize>, plane: &Plane) -> Result<Vec<usize>> {
let mut edges = Vec::new();
for (index, faces) in mesh.edge_faces.iter().enumerate() {
let count = faces.iter().filter(|face| selected.contains(&(**face as usize))).count();
if count == 0 {
continue;
}
if faces.len() > 2 || (faces.len() == 2 && faces.first() == faces.get(1)) {
return Err(DocError::Invalid("face family has ambiguous edge adjacency".into()));
}
if count == 1 && !mesh.seams.get(index).copied().unwrap_or(false) {
edges.push(index);
}
}
boundary_edges(mesh, edges, plane)
}
fn boundary_edges(mesh: &Mesh, mut edges: Vec<usize>, plane: &Plane) -> Result<Vec<usize>> {
let bounds = mesh.bounds();
let origin = bounds.min;
let local_origin = plane.to_local(origin);
@@ -21,7 +51,6 @@ pub(super) fn face_edges(mesh: &Mesh, face: u32, plane: &Plane) -> Result<Vec<us
let p = p - origin;
((p.x / tol).round() as i64, (p.y / tol).round() as i64, (p.z / tol).round() as i64)
};
let mut edges: Vec<usize> = mesh.face_edges(face).into_iter().filter(|e| !mesh.seams.get(*e).copied().unwrap_or(false)).collect();
if edges.iter().any(|e| !mesh.edges.get(*e).is_some_and(|p| p.len() >= 2 && p.iter().all(|p| p.is_finite()))) {
return Err(DocError::Invalid("face projection has an invalid boundary edge".into()));
}
@@ -119,6 +148,79 @@ pub(super) fn face_edges(mesh: &Mesh, face: u32, plane: &Plane) -> Result<Vec<us
mod tests {
use super::*;
#[test]
fn face_family_omits_only_exact_shared_topological_edges() {
let mut mesh = Mesh::default();
for (a, b, faces) in [
((0., 0.), (2., 0.), vec![0]),
((2., 0.), (2., 1.), vec![0]),
((2., 1.), (0., 1.), vec![0, 1]),
((0., 1.), (0., 0.), vec![0]),
((2., 1.), (2., 2.), vec![1]),
((2., 2.), (0., 2.), vec![1]),
((0., 2.), (0., 1.), vec![1]),
] {
mesh.edges.push(vec![Vec3::new(a.0, a.1, 0.), Vec3::new(b.0, b.1, 0.)]);
mesh.positions.extend([Vec3::new(a.0, a.1, 0.), Vec3::new(b.0, b.1, 0.)]);
mesh.edge_faces.push(faces);
}
let selected = [0, 1].into_iter().collect();
let edges = family_edges(&mesh, &selected, &Plane::XY).unwrap();
assert_eq!(edges.len(), 6);
assert!(!edges.contains(&2));
let geometry: Vec<_> = edges.iter().flat_map(|edge| super::super::fit_world(&Plane::XY, &mesh.edges[*edge])).collect();
assert_eq!(super::super::merge_family_lines(geometry).unwrap().len(), 4);
assert_eq!(family_edges(&mesh, &[0].into_iter().collect(), &Plane::XY).unwrap().len(), 4);
mesh.edge_faces[2] = vec![0, 1, 2];
assert!(family_edges(&mesh, &selected, &Plane::XY).is_err());
mesh.edge_faces[2] = vec![0, 0];
assert!(family_edges(&mesh, &selected, &Plane::XY).is_err());
}
#[test]
fn distinct_crossing_boundaries_are_not_geometrically_cancelled() {
let mut mesh = Mesh::default();
for (face, x, y) in [(0, 0., 0.), (1, 1., 1.)] {
let corners = [Vec3::new(x, y, 0.), Vec3::new(x + 2., y, 0.), Vec3::new(x + 2., y + 2., 0.), Vec3::new(x, y + 2., 0.)];
mesh.positions.extend(corners);
for i in 0..4 {
mesh.edges.push(vec![corners[i], corners[(i + 1) % 4]]);
mesh.edge_faces.push(vec![face]);
}
}
let edges = family_edges(&mesh, &[0, 1].into_iter().collect(), &Plane::XY).unwrap();
assert_eq!(edges.len(), 8);
let geom: Vec<_> = edges.iter().flat_map(|edge| super::super::fit_world(&Plane::XY, &mesh.edges[*edge])).collect();
let mut sk = solvecraft_sketch::Sketch::new();
let link = sk
.add_link(solvecraft_sketch::LinkKind::Project, solvecraft_sketch::LinkSource::Face { body: "ambiguous".into(), at: Vec3::ZERO }, &geom)
.unwrap();
assert!(sk.update_link(&link, &geom).is_err());
}
#[test]
fn family_line_normalization_preserves_corners_arcs_branches_and_retracing() {
use solvecraft_geom::Vec2;
use solvecraft_sketch::LinkGeom;
let line = |a, b| LinkGeom::Line(Vec2::new(a, 0.), Vec2::new(b, 0.));
assert_eq!(super::super::merge_family_lines(vec![line(0., 1.), line(2., 1.)]).unwrap(), vec![line(0., 2.)]);
for geometry in [
vec![line(0., 1.), line(1., 0.5)],
vec![line(0., 1.), LinkGeom::Line(Vec2::new(1., 0.), Vec2::new(2., 1e-8))],
vec![line(0., 1.), line(1., 2.), LinkGeom::Line(Vec2::new(1., 0.), Vec2::new(1., 1.))],
vec![line(0., 1.), line(1., 2.), LinkGeom::Conic { a: Vec2::new(1., 0.), apex: Vec2::new(2., 1.), b: Vec2::new(3., 1.), rho: 0.5 }],
vec![
line(0., 1.),
line(1., 2.),
LinkGeom::Spline { pts: vec![Vec2::new(1., 0.), Vec2::new(2., 1.), Vec2::new(3., 1.)], control: false, degree: 2, periodic: false },
],
vec![line(0., 1.), LinkGeom::Arc { c: Vec2::new(1., 1.), a: Vec2::new(1., 0.), b: Vec2::new(2., 1.) }],
vec![line(0., 1.), line(1.00001, 2.)],
] {
assert_eq!(super::super::merge_family_lines(geometry.clone()).unwrap(), geometry);
}
}
fn rectangles() -> Mesh {
let mut mesh = Mesh::default();
for (x, y) in [(40.0, 30.0), (38.0, 28.0), (4.0, 2.0)] {