fix: restore shell and cut wall topology in 0.2.1
This commit is contained in:
1 parent
1aedbf6631
commit
ef8f6ca6f9
31 files changed
+2976
-678
No files matched your search
@@ -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::*;
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)] {
|
||||
|
||||
Reference in new issue
Block a user