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

Generated
+13 -13
View File
@@ -3256,7 +3256,7 @@ dependencies = [
[[package]]
name = "solvecraft"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"eframe",
"egui",
@@ -3270,7 +3270,7 @@ dependencies = [
[[package]]
name = "solvecraft-cli"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"serde_json",
"solvecraft-engine",
@@ -3279,7 +3279,7 @@ dependencies = [
[[package]]
name = "solvecraft-doc"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"log",
"serde",
@@ -3294,7 +3294,7 @@ dependencies = [
[[package]]
name = "solvecraft-engine"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"log",
"serde",
@@ -3310,7 +3310,7 @@ dependencies = [
[[package]]
name = "solvecraft-geom"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"serde",
"serde_json",
@@ -3318,7 +3318,7 @@ dependencies = [
[[package]]
name = "solvecraft-io"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"quick-xml 0.38.4",
"solvecraft-doc",
@@ -3330,7 +3330,7 @@ dependencies = [
[[package]]
name = "solvecraft-kernel"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"log",
"serde",
@@ -3345,7 +3345,7 @@ dependencies = [
[[package]]
name = "solvecraft-mcp"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"serde_json",
"solvecraft-engine",
@@ -3353,7 +3353,7 @@ dependencies = [
[[package]]
name = "solvecraft-render"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"image",
"serde",
@@ -3362,7 +3362,7 @@ dependencies = [
[[package]]
name = "solvecraft-sketch"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"serde",
"serde_json",
@@ -3373,7 +3373,7 @@ dependencies = [
[[package]]
name = "solvecraft-ui-egui"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"egui",
"egui-wgpu",
@@ -3388,7 +3388,7 @@ dependencies = [
[[package]]
name = "solvecraft-web"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"eframe",
"egui",
@@ -4760,7 +4760,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"serde_json",
]
+1 -1
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"]
[workspace.package]
version = "0.2.0"
version = "0.2.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
+23 -3
View File
@@ -90,6 +90,27 @@ headline counts in-scope tabs only and lists the deferred ones separately.
the original electronics enclosure story and animated pointer in the actual English
0.2.0 UI, at 4× playback. Publication follows [the release workflow](docs/releasing.md).
## 0.2.1 critical fixes
- Axis view and navigation controls stay anchored to the viewport when feature dialogs or
the command palette open, at normal and enlarged display scales. Fit still accounts for
dialog coverage; covered controls do not receive clicks through dialogs.
- Eligible planar faces survive fillet, chamfer and shell operations with exact source
certificates. Sketches placed directly on the enclosure wall and inner floor follow
width and wall changes, including after the USB cut and cold reopening.
- Remove artificial straight-wall and rounded-corner subdivisions left by shell and
extrusion cuts. Identical generated wall segments span adjacent slabs as one physical
face, with exact full-height supports and boundary stitching. Preserve authored face
splits and face appearances. Permanent regressions cover material, STEP, reopening and
unaffected bodies across the extrusion end plane.
- Feature dialogs use the available screen height for their fields, with OK and Cancel
outside the scrolling region, including enlarged display scales.
- Design format 13 records shell-face attachments. Older designs remain readable;
newly saved designs require 0.2.1 or later. Ambiguous or unsupported faces still refuse
attachment rather than guessing a plane.
- Saved projections from a removed strip retain their geometry and report the lost
reference. Explicitly reselect the wall to repair that projection.
## View navigation
- Compact clickable signed X/Y/Z axis tips, smooth drag-to-orbit, and Shift-orbit snapping
@@ -99,9 +120,8 @@ headline counts in-scope tabs only and lists the deferred ones separately.
## Known limitations
- Some shelled planar faces cannot supply the exact source required for an associative
face sketch. The enclosure demo uses parameter-driven offset planes (`width/2` for the
USB port, `wall` for the mounting bosses), so width and wall edits still rebuild the case.
- Associative shell-face sketches require a unique certified planar source. Nested shell
attachments and ambiguous coincident sources remain unsupported.
- Fillets and chamfers: constant, variable-radius and chord-length fillets; a radius per edge,
rounds next to earlier rounds of another radius, and smooth corner patches where three rounds
meet (a sphere for equal radii); straight edges (convex or concave) between planar faces with perpendicular
+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)] {
+8 -6
View File
@@ -10,7 +10,7 @@ fn run(s: &mut Session, command: &str, params: Value) -> Value {
}
fn check(s: &mut Session, want: f64, hole: bool, height: f64) {
s.refresh();
assert!(s.model.results.iter().all(|result| result.error.is_none()));
assert!(s.model.results.iter().all(|result| result.error.is_none() && result.warning.is_none()), "{:?}", s.model.results);
let state = s.model.state();
let got: f64 = state.bodies.iter().map(|b| solvecraft_kernel::measure(&b.body).unwrap().volume).sum();
// Legacy linked outlines fit tessellated curves; enforce the existing 1e-4 material envelope.
@@ -22,10 +22,10 @@ fn check(s: &mut Session, want: f64, hole: bool, height: f64) {
}
}
#[test]
fn generated_face_profiles_keep_certified_and_internal_legacy_links_live_after_edit_and_reopen() {
fn generated_face_profiles_keep_exact_links_live_after_edit_and_reopen() {
for hole in [false, true] {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"name":"Base"}));
let base = run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"name":"Base"}))["feature"].as_u64().unwrap();
if hole {
run(&mut s, "sketch.create", json!({"plane":"XY"}));
run(&mut s, "sketch.circle.center", json!({"center":[20,15],"radius":5}));
@@ -37,9 +37,11 @@ fn generated_face_profiles_keep_certified_and_internal_legacy_links_live_after_e
let FeatureKind::Extrude { sketch, .. } = pad.kind else { panic!("extrude") };
let generated = s.doc.feature(sketch).unwrap();
let FeatureKind::Sketch { plane, sketch: profile } = &generated.kind else { panic!("sketch") };
assert!(matches!(plane,PlaneRef::Face{anchor,..} if anchor.is_some()!=hole));
let PlaneRef::Face { anchor: Some(anchor), .. } = plane else { panic!("the original Box cap remains exact after a hole cut") };
assert_eq!(anchor.target.origin_feature, base);
assert!(matches!(anchor.target.certificate, Some(solvecraft_doc::config::FaceCertificate::Box { axis: 2, high: true })));
assert!(!profile.links.is_empty());
assert!(profile.links.iter().all(|link| link.auto_face != hole && !link.lost));
assert!(profile.links.iter().all(|link| link.auto_face && !link.lost));
let area = 1200.0 - if hole { 25.0 * PI } else { 0.0 };
check(&mut s, area * 30.0, hole, 30.0);
run(&mut s, "timeline.edit", json!({"feature":"Base","set":{"height":"25","length":"50"}}));
@@ -48,7 +50,7 @@ fn generated_face_profiles_keep_certified_and_internal_legacy_links_live_after_e
let edited = s.model.state();
assert!(edited.bodies.iter().any(|body| body.body.contains(Vec3::new(49.0, 5.0, 34.0))));
assert!(!edited.bodies.iter().any(|body| body.body.contains(Vec3::new(51.0, 5.0, 34.0))));
assert!(edited.sketch(sketch).unwrap().sketch.links.iter().all(|link| !link.lost));
assert!(edited.sketch(sketch).unwrap().sketch.links.iter().all(|link| link.auto_face && !link.lost));
let solved = edited.sketch(sketch).unwrap().plane;
assert!((solved.origin.z - 25.0).abs() < 1e-6);
let before = s.model.state();
+181
View File
@@ -399,3 +399,184 @@ fn angled_attachment_tracks_source_plane_and_reopens() {
let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
assert_eq!(reopened.model.state().sketch(id).unwrap().plane, placed);
}
#[test]
fn rounded_enclosure_shell_side_and_floor_attach_through_width_wall_and_reopen() {
use solvecraft_doc::{FeatureKind, config::FaceCertificate, face_anchor::plane_face_anchor};
let mut s = Session::default();
for (name, expression) in [("width", "80 mm"), ("depth", "60 mm"), ("height", "30 mm"), ("wall", "2 mm")] {
run(&mut s, "parameters.add", json!({"name":name,"expression":expression}));
}
let base = run(&mut s, "sketch.create", json!({"plane":"XY","name":"Outline"}))["sketch"].as_u64().unwrap();
run(&mut s, "sketch.rectangle.center", json!({"center":[0,0],"corner":[40,30]}));
run(&mut s, "sketch.dimension", json!({"entities":["l1"],"value":"width"}));
run(&mut s, "sketch.dimension", json!({"entities":["l2"],"value":"depth"}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch":base,"distance":"height","operation":"new","body_name":"Case"}));
run(&mut s, "solid.fillet", json!({"body":"Case","edges":[[40,30,15],[-40,30,15],[-40,-30,15],[40,-30,15]],"radius":6}));
run(&mut s, "solid.shell", json!({"body":"Case","faces":[[0,0,30]],"thickness":"wall","name":"Hollow"}));
assert_eq!(s.model.state().body("Case").unwrap().body.face_count(), 19, "rounded Shell has no separate base strips");
let pick = |s: &Session, normal: Vec3, point: Vec3| {
let state = s.model.state();
let body = state.body("Case").unwrap();
let face = body
.body
.faces(0.01)
.unwrap()
.into_iter()
.find(|f| f.plane_normal.is_some_and(|n| n.dot(normal) > 0.999) && (f.centroid - point).dot(normal).abs() < 1e-6)
.unwrap();
json!({"body":"Case","index":face.index,"face":[point.x,point.y,point.z]})
};
let side_pick = pick(&s, Vec3::X, Vec3::new(40., 10., 20.));
let side = run(&mut s, "sketch.create", json!({"name":"USB","plane":side_pick,"project_edges":true}))["sketch"].as_u64().unwrap();
let port_plane = s.model.state().sketch(side).unwrap().plane;
let p0 = port_plane.to_local(Vec3::new(40., -6., 8.5));
let p1 = port_plane.to_local(Vec3::new(40., 6., 15.5));
let port_curves = run(&mut s, "sketch.rectangle.two_point", json!({"p0":[p0.x,p0.y],"p1":[p1.x,p1.y]}))["curves"].clone();
run(&mut s, "sketch.finish", json!({}));
run(
&mut s,
"solid.extrude",
json!({"sketch":side,"profiles":[{"curves":port_curves}],"distance":"wall","direction":"negative","operation":"cut","targets":["Case"],"name":"USBcut"}),
);
let floor_pick = pick(&s, Vec3::Z, Vec3::new(6., -9., 2.));
let floor = run(&mut s, "sketch.create", json!({"name":"Bosses","plane":floor_pick,"project_edges":true}))["sketch"].as_u64().unwrap();
let boss_curves = run(&mut s, "sketch.circle.center", json!({"center":[30,20],"radius":3.5}))["curves"].clone();
let centre = format!("{}.center", boss_curves[0].as_str().unwrap());
run(&mut s, "sketch.dimension", json!({"entities":["origin",centre],"type":"horizontal","value":"width / 2 - 10 mm"}));
run(&mut s, "sketch.dimension", json!({"entities":["origin",centre],"type":"vertical","value":"depth / 2 - 10 mm"}));
run(&mut s, "sketch.finish", json!({}));
run(
&mut s,
"solid.extrude",
json!({"sketch":floor,"profiles":[{"curves":boss_curves}],"distance":12,"operation":"join","targets":["Case"],"name":"Boss"}),
);
run(
&mut s,
"solid.pattern.rectangular",
json!({"features":["Boss"],"dir1":[-1,0,0],"count1":2,"spacing1":"width - 20 mm","dir2":[0,-1,0],"count2":2,"spacing2":"depth - 20 mm","name":"BossPattern"}),
);
for id in [side, floor] {
let FeatureKind::Sketch { plane, .. } = &s.doc.feature(id).unwrap().kind else { panic!() };
assert!(matches!(plane_face_anchor(plane).unwrap().target.certificate, Some(FaceCertificate::ShellPlanar { .. })));
}
run(&mut s, "parameters.change", json!({"name":"width","expression":"100 mm"}));
run(&mut s, "parameters.change", json!({"name":"wall","expression":"3 mm"}));
let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
for session in [&s, &reopened] {
for id in [side, floor] {
let result = session.model.result(id).unwrap();
assert!(result.error.is_none(), "{}: {:?}", result.name, result.error);
assert!(result.warning.is_none(), "{}: {:?}", result.name, result.warning);
assert!(
session.model.state().sketch(id).unwrap().sketch.links.iter().all(|link| !link.lost),
"projected boundary must resolve current geometry"
);
}
let state = session.model.state();
assert!(state.body("Case").is_some());
assert!((state.sketch(side).unwrap().plane.origin.x - 50.).abs() < 1e-6);
assert!((state.sketch(floor).unwrap().plane.origin.z - 3.).abs() < 1e-6);
assert!(state.sketch(floor).unwrap().plane.normal().dot(Vec3::Z) > 0.999);
let mesh = state.body("Case").unwrap().body.tessellate_with(1e-4, true).unwrap();
let case = &state.body("Case").unwrap().body;
let corners: Vec<_> = (0..case.face_count()).filter(|i| matches!(solvecraft_kernel::face_surface(case, *i), Some(solvecraft_kernel::FaceSurface::Cylinder { radius, .. }) if (radius - 6.).abs() < 1e-6)).collect();
assert_eq!(corners.len(), 4, "later port/boss features must not subdivide untouched outer corners");
for index in corners {
let points: Vec<_> = mesh
.edge_faces
.iter()
.enumerate()
.filter(|(_, faces)| faces.contains(&(index as u32)))
.flat_map(|(edge, _)| &mesh.edges[edge])
.collect();
assert!(points.iter().any(|p| p.z.abs() < 1e-8));
assert!(points.iter().any(|p| (p.z - 30.).abs() < 1e-8));
}
assert!(!mesh.contains(Vec3::new(49., 0., 12.)), "USB must remain open after resize/reopen");
assert!(mesh.contains(Vec3::new(49., 15., 12.)), "adjacent right wall remains solid");
assert!(mesh.contains(Vec3::new(-49., 0., 12.)), "opposite wall must stay solid");
let seed = state.sketch(floor).unwrap().plane.to_world(solvecraft_geom::Vec2::new(40., 20.)) + Vec3::Z * 6.;
assert!(mesh.contains(seed), "seed boss starts on current floor");
for shift in [Vec3::ZERO, Vec3::new(-80., 0., 0.), Vec3::new(0., -40., 0.), Vec3::new(-80., -40., 0.)] {
assert!(mesh.contains(seed + shift), "each patterned boss must survive resize/reopen at {:?}", seed + shift);
}
assert!(!mesh.contains(Vec3::new(0., 0., 6.)), "the cavity remains hollow");
let area = |w: f64, d: f64, r: f64| w * d - (4. - std::f64::consts::PI) * r * r;
let expected = area(100., 60., 6.) * 30. - area(94., 54., 3.) * 27. - 12. * 7. * 3. + 4. * std::f64::consts::PI * 3.5 * 3.5 * 12.;
assert!((mesh.measure().volume - expected).abs() < 0.1, "actual {}, analytic {expected}", mesh.measure().volume);
}
run(&mut s, "timeline.suppress", json!({"feature":"Hollow","suppressed":true}));
assert!(s.model.result(side).unwrap().error.is_some());
assert!(s.model.result(floor).unwrap().error.is_some());
}
#[test]
fn offset_rectangle_pocket_has_one_logical_outer_wall_and_closed_face_projection() {
let mut s = Session::default();
let base = run(&mut s, "sketch.create", json!({"plane":"XY"}))["sketch"].as_u64().unwrap();
run(&mut s, "sketch.rectangle.center", json!({"center":[0,0],"corner":[30,27]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch":base,"distance":10,"operation":"new","body_name":"Stock"}));
let control_sketch = run(&mut s, "sketch.create", json!({"plane":"XY"}))["sketch"].as_u64().unwrap();
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[100,100],"p1":[104,104]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch":control_sketch,"distance":10,"operation":"new","body_name":"Control"}));
let untouched = s.model.state().body("Control").unwrap().body.clone();
let before_control = solvecraft_kernel::step_export(&[&untouched], "control snapshot").unwrap();
let pick = top_pick(&s, "Stock");
let pocket = run(&mut s, "sketch.create", json!({"plane":pick}))["sketch"].as_u64().unwrap();
let side = {
let state = s.model.state();
let sk = &state.sketch(pocket).unwrap().sketch;
let curve = sk.curves.iter().find(|c| c.id == "l1").unwrap();
let solvecraft_sketch::CurveKind::Line { a, b } = curve.kind else { panic!() };
sk.point(a).unwrap() + sk.point(b).unwrap()
};
let outer = run(&mut s, "sketch.offset", json!({"curves":["l1"],"distance":0.3425231845240546,"side":[side.x,side.y]}))["curves"].clone();
let inner = run(&mut s, "sketch.offset", json!({"curves":[outer[0]],"distance":5.609348914858092,"side":[0,0]}))["curves"].clone();
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch":pocket,"profiles":[{"curves":inner}],"distance":-9,"operation":"cut"}));
let control = s.model.state().body("Control").unwrap().body.clone();
assert!(control.same(&untouched), "a separate body crossing the cutter end plane remains the exact same solid");
assert_eq!(solvecraft_kernel::step_export(&[&control], "control snapshot").unwrap(), before_control);
let state = s.model.state();
let body = state.body("Stock").unwrap();
let display = body.body.display_mesh(0.01).unwrap();
let face = body
.body
.faces(0.01)
.unwrap()
.into_iter()
.find(|face| face.plane_normal.is_some_and(|n| n.dot(Vec3::X) > 0.999) && (face.centroid.x - 30.).abs() < 1e-6)
.unwrap();
assert_eq!(body.body.faces(0.01).unwrap().len(), 11, "actual pocket topology has no outer floor-height strips");
assert_eq!(display.surface_patch(face.index).len(), 1);
let attached = run(&mut s, "sketch.create", json!({"plane":{"body":"Stock","index":face.index,"face":[30,0,0.5]}}))["sketch"].as_u64().unwrap();
run(&mut s, "sketch.finish", json!({}));
let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
for session in [&s, &reopened] {
let result = session.model.result(attached).unwrap();
assert!(result.error.is_none(), "{:?}", result.error);
assert!(result.warning.is_none(), "{:?}", result.warning);
let state = session.model.state();
let sketch = &state.sketch(attached).unwrap().sketch;
assert!(sketch.links.iter().all(|link| !link.lost));
assert_eq!(sketch.curves.len(), 4, "whole outer-wall rectangle has no floor-height stations");
let mesh = state.body("Stock").unwrap().body.display_mesh(0.01).unwrap();
assert!(mesh.contains(Vec3::new(0., 0., 0.5)));
assert!(!mesh.contains(Vec3::new(0., 0., 5.)));
assert!(mesh.contains(Vec3::new(28., 0., 5.)));
let control = &state.body("Control").unwrap().body;
assert_eq!(control.faces(0.01).unwrap().len(), 6);
assert_eq!(control.edges(0.01).unwrap().len(), 12);
let control_mesh = control.tessellate(0.01).unwrap();
assert!((control_mesh.measure().volume - 160.).abs() < 1e-6);
assert!(control_mesh.contains(Vec3::new(102., 102., 0.5)));
assert!(control_mesh.contains(Vec3::new(102., 102., 5.)));
let expected = 60. * 54. * 10. - 4. * 24.73317426966596 * 21.733174269665962 * 9.;
assert!((mesh.measure().volume - expected).abs() < 1e-6, "actual {}, expected {expected}", mesh.measure().volume);
}
}
+39 -14
View File
@@ -23,28 +23,53 @@ fn rel(a: f64, b: f64) -> f64 {
#[test]
fn join_extrude_from_a_chamfered_top_face() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name":"height","expression":"20 mm"}));
run(&mut s, "parameters.add", json!({"name":"bevel","expression":"2 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 10}));
run(&mut s, "solid.extrude", json!({"distance": 20}));
run(&mut s, "solid.chamfer", json!({"edges": [[10, 0, 20]], "distance": 2}));
let source = run(&mut s, "solid.extrude", json!({"distance": "height","name":"Stem"}))["feature"].as_u64().unwrap();
run(&mut s, "solid.chamfer", json!({"edges": [[10, 0, 20]], "distance": "bevel"}));
let base = volume(&mut s);
let before = s.doc.to_json();
let active = s.active_sketch;
assert!(s.execute("sketch.create", &json!({"plane":{"face":[0,0,20]}})).is_err(), "modified faces require an explicit fixed plane");
assert_eq!(s.doc.to_json(), before);
assert_eq!(s.active_sketch, active);
assert!(rel(volume(&mut s), base) < 1e-12);
let id = run(&mut s, "sketch.create", json!({"plane": {"face": [0, 0, 20],"fixed":true}}))["sketch"].as_u64().unwrap();
let solvecraft_doc::FeatureKind::Sketch { plane: solvecraft_doc::PlaneRef::Custom { plane }, sketch } = &s.doc.feature(id).unwrap().kind else {
panic!("explicit fixed face must become a custom plane");
let id = run(&mut s, "sketch.create", json!({"plane": {"face": [0, 0, 20]}}))["sketch"].as_u64().unwrap();
let solvecraft_doc::FeatureKind::Sketch { plane: solvecraft_doc::PlaneRef::Face { anchor: Some(anchor), .. }, sketch } =
&s.doc.feature(id).unwrap().kind
else {
panic!("the chamfered cap retains its exact original construction support");
};
assert!((plane.origin.z - 20.).abs() < 1e-9);
assert!(sketch.links.is_empty(), "fresh fixed plane does not create live auto-projections");
run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":8}));
assert_eq!(anchor.target.origin_feature, source);
assert!(matches!(anchor.target.certificate, Some(solvecraft_doc::config::FaceCertificate::ExtrudeCap { high: true, .. })));
assert!(!sketch.links.is_empty());
assert!(sketch.links.iter().all(|link| link.auto_face && !link.lost));
// The default linked cap outline is radius8; no explicit fixed plane or
// duplicate manually drawn circle is required to join a pad to that face.
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 5, "operation": "join"}));
assert_eq!(s.model.state().bodies.len(), 1);
assert!(rel(volume(&mut s), base + PI * 64.0 * 5.0) < 5e-4);
for (height, bevel) in [(20., 2.), (25., 3.)] {
run(&mut s, "parameters.change", json!({"name":"height","expression":format!("{height} mm")}));
run(&mut s, "parameters.change", json!({"name":"bevel","expression":format!("{bevel} mm")}));
let expected = PI * (100. * height - (10. * bevel * bevel - bevel * bevel * bevel / 3.) + (10. - bevel) * (10. - bevel) * 5.);
let loaded = solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
let mut cold = Session::new(loaded);
let warm_volume = volume(&mut s);
let cold_volume = volume(&mut cold);
assert!(rel(warm_volume, expected) < 5e-4, "{warm_volume} vs {expected}");
assert!((warm_volume - cold_volume).abs() < 1e-8);
for current in [&s, &cold] {
assert!(current.model.results.iter().all(|result| result.error.is_none() && result.warning.is_none()), "{:?}", current.model.results);
let state = current.model.state();
assert_eq!(state.bodies.len(), 1);
let solved = state.sketch(id).unwrap();
assert!((solved.plane.origin.z - height).abs() < 1e-8);
assert!(solved.sketch.links.iter().all(|link| link.auto_face && !link.lost));
let body = &state.bodies[0];
assert!(body.body.contains(solvecraft_geom::Vec3::new(0., 0., height + 4.)));
assert!(!body.body.contains(solvecraft_geom::Vec3::new(0., 0., height + 6.)));
assert!(body.body.contains(solvecraft_geom::Vec3::new(10. - bevel - 0.5, 0., height + 4.)));
assert!(!body.body.contains(solvecraft_geom::Vec3::new(10. - bevel + 0.5, 0., height + 4.)));
}
}
}
/// A join that bridges two bodies makes them one body, not an overlap of the first and the second.
+19 -3
View File
@@ -5,6 +5,10 @@ use truck_modeling as mt;
use crate::{KernelError, Result, guard};
#[path = "planar_regularize.rs"]
mod planar_regularize;
#[path = "planar_sources.rs"]
mod planar_sources;
#[path = "shell_groups.rs"]
mod shell_groups;
@@ -33,11 +37,12 @@ pub struct Body {
#[derive(Clone, Debug, PartialEq)]
pub enum FaceSource {
ExtrudeSide { segment: solvecraft_geom::Seg2, plane: Option<solvecraft_geom::Plane> },
ExtrudeSide { segment: solvecraft_geom::Seg2, plane: Option<solvecraft_geom::Plane>, coalesced: bool },
CertifiedExtrudeSide { producer: u64, sketch: u64, curve: String, plane: Option<solvecraft_geom::Plane> },
ExtrudeCap { high: bool, region: solvecraft_geom::Region2, plane: solvecraft_geom::Plane },
CertifiedExtrudeCap { producer: u64, sketch: u64, high: bool, outer: Vec<String>, holes: Vec<Vec<String>>, plane: solvecraft_geom::Plane },
Box { producer: Option<u64>, axis: u8, high: bool, plane: solvecraft_geom::Plane },
ShellPlanar { producer: Option<u64>, source: Box<FaceSource>, inner: bool, plane: solvecraft_geom::Plane },
Imported { item: u64, face: u64, payload_sha256: Option<String>, producer: Option<u64> },
}
@@ -48,7 +53,9 @@ impl FaceSource {
fn plane_mut(&mut self) -> Option<&mut solvecraft_geom::Plane> {
match self {
Self::ExtrudeSide { plane, .. } | Self::CertifiedExtrudeSide { plane, .. } => plane.as_mut(),
Self::ExtrudeCap { plane, .. } | Self::CertifiedExtrudeCap { plane, .. } | Self::Box { plane, .. } => Some(plane),
Self::ExtrudeCap { plane, .. } | Self::CertifiedExtrudeCap { plane, .. } | Self::Box { plane, .. } | Self::ShellPlanar { plane, .. } => {
Some(plane)
}
Self::Imported { .. } => None,
}
}
@@ -358,6 +365,12 @@ impl Body {
self.face_sources.as_deref().map(Vec::as_slice)
}
/// A current wall reconstructed from an exact contiguous section run.
/// This transient witness grants no durable source/producer identity.
pub fn face_was_coalesced(&self, index: usize) -> bool {
matches!(self.face_sources().and_then(|s| s.get(index)), Some(Some(FaceSource::ExtrudeSide { coalesced: true, .. })))
}
/// An exact construction/import plane; never inferred from tessellation.
pub fn face_plane(&self, index: usize) -> Option<solvecraft_geom::Plane> {
if index >= 50_000 {
@@ -366,7 +379,10 @@ impl Body {
let source = self.face_sources()?.get(index)?.as_ref()?;
match source {
FaceSource::ExtrudeSide { plane, .. } | FaceSource::CertifiedExtrudeSide { plane, .. } => *plane,
FaceSource::ExtrudeCap { plane, .. } | FaceSource::CertifiedExtrudeCap { plane, .. } | FaceSource::Box { plane, .. } => Some(*plane),
FaceSource::ExtrudeCap { plane, .. }
| FaceSource::CertifiedExtrudeCap { plane, .. }
| FaceSource::Box { plane, .. }
| FaceSource::ShellPlanar { plane, .. } => Some(*plane),
FaceSource::Imported { .. } => self.planar_face(index),
}
}
+1 -1
View File
@@ -179,7 +179,7 @@ pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Resu
}
_ => None,
};
Some(crate::FaceSource::ExtrudeSide { segment: **segment, plane: basis })
Some(crate::FaceSource::ExtrudeSide { segment: **segment, plane: basis, coalesced: false })
} else {
None
};
+22 -8
View File
@@ -116,18 +116,32 @@ fn construction_planes_follow_reflected_and_sheared_affine_images() {
}
#[test]
fn material_changes_discard_producer_certificates() {
fn material_changes_preserve_only_unchanged_planar_support_certificates() {
let base = crate::box_solid(Vec3::ZERO, Vec3::new(10., 10., 10.)).unwrap().with_box_certificate(42).unwrap();
let tool = crate::box_solid(Vec3::new(5., -1., -1.), Vec3::new(11., 11., 11.)).unwrap();
let cut = crate::boolean(&base, &tool, crate::BoolOp::Cut).unwrap().unwrap();
assert!((cut.tessellate(1e-4).unwrap().measure().volume - 500.).abs() < 1e-8);
assert!(cut.face_sources().into_iter().flatten().flatten().all(|source| matches!(
source,
FaceSource::ExtrudeSide { .. }
| FaceSource::ExtrudeCap { .. }
| FaceSource::Box { producer: None, .. }
| FaceSource::Imported { producer: None, .. }
)));
// Unchanged uniquely certified supports retain their role; a newly cut
// support must never inherit the removed opposite wall's source identity.
let mut retained = 0;
let mut new_wall = 0;
for index in 0..cut.face_count() {
let actual = cut.planar_face(index).unwrap();
let source = cut.face_sources().and_then(|s| s.get(index)).and_then(Option::as_ref);
if let Some(FaceSource::Box { producer: Some(42), axis, high, .. }) = source {
assert!(!(*axis == 0 && *high));
let canonical = cut.face_plane(index).unwrap();
assert!(actual.normal().dot(canonical.normal()) > 1. - 1e-10);
assert!(actual.height(canonical.origin).abs() < 1e-10);
retained += 1;
}
if actual.normal().x > 0.9 {
assert!((actual.origin.x - 5.).abs() < 1e-10);
assert!(!matches!(source, Some(FaceSource::Box { producer: Some(_), .. })));
new_wall += 1;
}
}
assert_eq!((retained, new_wall), (5, 1));
}
#[test]
+3
View File
@@ -221,6 +221,9 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
)),
};
}
if matches!(op, BoolOp::Cut | BoolOp::Union) {
r = r.and_then(|result| result.map(|body| body.with_preserved_boolean_planar_sources(a, b, op == BoolOp::Cut)).transpose());
}
r
}
+284
View File
@@ -0,0 +1,284 @@
//! Exact topological removal of construction-induced planar subdivisions.
//! Shared edge identity and complete construction lineage select the groups.
use super::{Body, from_p3};
use crate::{Result, guard};
use solvecraft_geom::Plane;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::sync::Arc;
use truck_modeling as mt;
fn root(parent: &mut [usize], mut i: usize) -> usize {
while parent[i] != i {
parent[i] = parent[parent[i]];
i = parent[i];
}
i
}
fn merged_face(faces: &[&mt::Face], members: &[usize], plane: Plane, eligible: &HashSet<mt::EdgeID>) -> Option<mt::Face> {
let mut counts = HashMap::<mt::EdgeID, (usize, bool)>::new();
for i in members {
for edge in faces.get(*i)?.boundary_iters().into_iter().flatten() {
// Whole linear supports, not a sampled plane fit.
let mt::Curve::Line(line) = edge.curve() else { return None };
let (a, b) = (from_p3(line.0), from_p3(line.1));
if !a.is_finite() || !b.is_finite() || a.len().max(b.len()) > 1e9 {
return None;
}
let front = from_p3(edge.absolute_front().point());
let back = from_p3(edge.absolute_back().point());
if !front.is_finite() || !back.is_finite() {
return None;
}
let error = 4096. * f64::EPSILON * (1. + a.len() + b.len() + plane.origin.len());
if plane.height(a).abs() > error || plane.height(b).abs() > error || a.dist(front) > error || b.dist(back) > error {
return None;
}
let count = counts.entry(edge.id()).or_insert((0, edge.orientation()));
if count.0 == 1 && (!eligible.contains(&edge.id()) || count.1 == edge.orientation()) {
return None;
}
count.0 += 1;
if count.0 > 2 {
return None;
}
}
}
let mut boundary = Vec::new();
for i in members {
for edge in faces.get(*i)?.boundary_iters().into_iter().flatten() {
if counts.get(&edge.id()).is_some_and(|(n, _)| *n == 1) {
boundary.push(edge);
}
}
}
let mut next = HashMap::new();
let mut incoming = HashMap::new();
for (i, edge) in boundary.iter().enumerate() {
if next.insert(edge.front().id(), i).is_some() || incoming.insert(edge.back().id(), i).is_some() {
return None;
}
}
if next.len() != incoming.len() || next.keys().any(|id| !incoming.contains_key(id)) {
return None;
}
let mut used = vec![false; boundary.len()];
let mut outer = None;
let mut holes = Vec::new();
for first in 0..boundary.len() {
if used[first] {
continue;
}
let start = boundary.get(first)?.front().id();
let origin = from_p3(boundary.get(first)?.front().point());
let mut index = first;
let mut edges = Vec::new();
let (mut area, mut magnitude, mut arithmetic_error) = (0., 0., 0.);
loop {
if *used.get(index)? {
return None;
}
used[index] = true;
let edge = boundary.get(index)?.clone();
let da = from_p3(edge.front().point()) - origin;
let db = from_p3(edge.back().point()) - origin;
let a = solvecraft_geom::Vec2::new(da.dot(plane.x), da.dot(plane.y));
let b = solvecraft_geom::Vec2::new(db.dot(plane.x), db.dot(plane.y));
let term = a.cross(b);
area += term;
magnitude += term.abs();
let coordinate_error =
128. * f64::EPSILON * (1. + origin.len() + from_p3(edge.front().point()).len() + from_p3(edge.back().point()).len());
arithmetic_error += coordinate_error * (da.len() + db.len() + coordinate_error) * 16.;
let back = edge.back().id();
edges.push(edge);
if back == start {
break;
}
index = *next.get(&back)?;
}
if edges.len() < 3 || !area.is_finite() || area.abs() <= arithmetic_error + 64. * f64::EPSILON * (edges.len() as f64 + 1.) * magnitude.max(1.)
{
return None;
}
let wire = mt::Wire::from(edges);
if area > 0. {
if outer.replace(wire).is_some() {
return None;
}
} else {
holes.push(wire);
}
}
let mut wires = vec![outer?];
wires.extend(holes);
mt::Face::try_new(wires, faces.get(*members.first()?)?.oriented_surface()).ok()
}
impl Body {
pub(crate) fn regularize_certified_planar_faces(self) -> Result<Self> {
let Some(sources) = self.face_sources() else { return Ok(self) };
if sources.len() > 16_384 || self.paint().is_some_and(|p| !p.faces.is_empty()) {
return Ok(self);
}
let split_points = self.split_keep().iter().try_fold(0usize, |sum, line| sum.checked_add(line.len()));
let Some(split_points) = split_points.filter(|n| *n <= 65_536) else { return Ok(self) };
let result = guard("certified planar topology regularization", || {
let mut faces = Vec::new();
let mut shell_owner = Vec::new();
let mut use_budget = 65_536usize;
for (shell_index, shell) in self.solid.boundaries().iter().enumerate() {
for face in shell.face_iter() {
if faces.len() >= sources.len() {
return Ok(None);
}
for _ in face.edge_iter() {
let Some(left) = use_budget.checked_sub(1) else { return Ok(None) };
use_budget = left;
}
faces.push(face);
shell_owner.push(shell_index);
}
}
if faces.len() != sources.len() {
return Ok(None);
}
let valid: Vec<_> = faces
.iter()
.zip(sources)
.map(|(f, s)| {
s.as_ref()
.and_then(super::planar_sources::certified_plane)
.filter(|p| super::planar_sources::actual_plane(f).is_some_and(|actual| super::planar_sources::same_support(p, &actual)))
})
.collect();
let mut adjacency = HashMap::<mt::EdgeID, Vec<(usize, mt::Edge)>>::new();
for (i, face) in faces.iter().enumerate() {
for edge in face.boundary_iters().into_iter().flatten() {
adjacency.entry(edge.id()).or_default().push((i, edge));
}
}
let mut eligible = HashSet::new();
let mut parent: Vec<_> = (0..faces.len()).collect();
let mut identity = 16_000_000usize;
let mut work = 2_000_000usize;
for uses in adjacency.values() {
let [(a, ea), (b, eb)] = uses.as_slice() else { continue };
if a == b || shell_owner[*a] != shell_owner[*b] || ea.orientation() == eb.orientation() {
continue;
}
let (Some(pa), Some(pb)) = (valid[*a], valid[*b]) else { continue };
if pa != pb {
continue;
}
let (Some(sa), Some(sb)) = (sources[*a].as_ref(), sources[*b].as_ref()) else { continue };
let Some(cost) = super::planar_sources::descriptor_cost(sa)
.and_then(|a| super::planar_sources::descriptor_cost(sb).and_then(|b| a.checked_add(b)))
else {
continue;
};
let Some(left) = identity.checked_sub(cost) else { return Ok(None) };
identity = left;
if sa != sb {
continue;
}
let Some(cost) = split_points.checked_mul(6).and_then(|n| n.checked_add(1)) else { return Ok(None) };
let Some(left) = work.checked_sub(cost) else { return Ok(None) };
work = left;
if crate::heal::on_split_line(&crate::heal::edge_points(ea), self.split_keep()) {
continue;
}
eligible.insert(ea.id());
let (ra, rb) = (root(&mut parent, *a), root(&mut parent, *b));
parent[ra.max(rb)] = ra.min(rb);
}
let mut groups = BTreeMap::<usize, Vec<usize>>::new();
for i in 0..faces.len() {
groups.entry(root(&mut parent, i)).or_default().push(i);
}
let mut replacements = HashMap::new();
let mut changed = false;
let mut metadata = 2_000_000usize;
for (leader, members) in &groups {
if members.len() < 2 {
continue;
}
let Some(face) = valid[*leader].and_then(|p| merged_face(&faces, members, p, &eligible)) else { continue };
replacements.insert(*leader, face);
changed = true;
}
if !changed {
return Ok(None);
}
let mut shells = Vec::new();
let mut output_sources = Vec::new();
let mut output_ids = Vec::new();
let mut offset = 0;
for shell in self.solid.boundaries() {
let mut output = Vec::new();
for face in shell.face_iter() {
let leader = root(&mut parent, offset);
if let Some(merged) = replacements.get(&leader) {
if offset == leader {
output.push(merged.clone());
} else {
offset += 1;
continue;
}
} else {
output.push(face.clone());
}
output_ids.push(output.last().ok_or_else(|| crate::KernelError::Failed("missing regularized face".into()))?.id());
let source = sources[offset].as_ref();
if let Some(source) = source {
let Some(cost) = super::planar_sources::descriptor_cost(source) else { return Ok(None) };
let Some(left) = metadata.checked_sub(cost) else { return Ok(None) };
metadata = left;
}
output_sources.push(source.cloned());
offset += 1;
}
shells.push(mt::Shell::from(output));
}
let solid = mt::Solid::try_new(shells).map_err(|e| crate::KernelError::Failed(format!("planar regularization does not close: {e}")))?;
let body = Body::new(solid)?.with_color(self.color()).with_paint(self.paint().cloned()).with_split_lines(self.split_lines().cloned());
if !body.solid.face_iter().map(|f| f.id()).eq(output_ids) {
return Ok(None);
}
Ok(Some(body.with_face_sources(Some(Arc::new(output_sources)))))
})?;
Ok(result.unwrap_or(self))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn alternate_adjacency_path_cannot_remove_protected_internal_edge() {
let vertices: Vec<_> =
(0..9).map(|i| mt::builder::vertex(super::super::p3(solvecraft_geom::Vec3::new((i % 3) as f64, (i / 3) as f64, 0.)))).collect();
let mut edges = HashMap::<(usize, usize), mt::Edge>::new();
let mut faces = Vec::new();
for (x, y) in [(0, 0), (1, 0), (0, 1), (1, 1)] {
let ids = [y * 3 + x, y * 3 + x + 1, (y + 1) * 3 + x + 1, (y + 1) * 3 + x];
let wire: mt::Wire = (0..4)
.map(|i| {
let (a, b) = (ids[i], ids[(i + 1) % 4]);
let key = (a.min(b), a.max(b));
let edge = edges.entry(key).or_insert_with(|| mt::builder::line(&vertices[key.0], &vertices[key.1]));
if a == key.0 { edge.clone() } else { edge.inverse() }
})
.collect();
faces.push(mt::builder::try_attach_plane(&[wire]).unwrap());
}
let refs: Vec<_> = faces.iter().collect();
let mut eligible: HashSet<_> = edges.values().map(|e| e.id()).collect();
assert!(merged_face(&refs, &[0, 1, 2, 3], Plane::XY, &eligible).is_some());
eligible.remove(&edges[&(1, 4)].id());
assert!(
merged_face(&refs, &[0, 1, 2, 3], Plane::XY, &eligible).is_none(),
"remaining three internal joins cannot bypass authored split protection"
);
}
}
+516
View File
@@ -0,0 +1,516 @@
//! Exact oriented planar support lineage through a trim and an inside shell.
//! No tessellated face names, centroids, or nearest-plane choices are used.
use super::{Body, FaceSource, from_p3};
use crate::{KernelError, Result, guard};
use solvecraft_geom::{Plane, Vec3};
use std::sync::Arc;
use truck_modeling as mt;
pub(super) fn actual_plane(face: &mt::Face) -> Option<Plane> {
let mt::Surface::Plane(p) = face.oriented_surface() else { return None };
let u = p.u_axis();
let v = p.v_axis();
let u = Vec3::new(u.x, u.y, u.z);
let v = Vec3::new(v.x, v.y, v.z);
if !u.is_finite() || !v.is_finite() || u.cross(v).len() <= u.len() * v.len() * 1e-8 {
return None;
}
Plane::new(from_p3(p.origin()), u, v)
}
pub(super) fn same_support(a: &Plane, b: &Plane) -> bool {
let error = 4096. * f64::EPSILON * (1. + a.origin.len() + b.origin.len());
a.normal().cross(b.normal()).len() <= 4096. * f64::EPSILON
&& a.normal().dot(b.normal()) > 0.
&& (a.origin - b.origin).dot(a.normal()).abs() <= error
}
fn base_plane(source: &FaceSource, actual: &Plane) -> Option<Plane> {
match source {
FaceSource::CertifiedExtrudeSide { plane: Some(p), .. }
| FaceSource::CertifiedExtrudeCap { plane: p, .. }
| FaceSource::Box { producer: Some(_), plane: p, .. }
| FaceSource::ShellPlanar { producer: Some(_), plane: p, .. } => Some(*p),
FaceSource::Imported { producer: Some(_), payload_sha256: Some(_), .. } => Some(*actual),
_ => None,
}
}
pub(super) fn certified_plane(source: &FaceSource) -> Option<Plane> {
match source {
FaceSource::CertifiedExtrudeSide { plane: Some(p), .. }
| FaceSource::CertifiedExtrudeCap { plane: p, .. }
| FaceSource::Box { producer: Some(_), plane: p, .. }
| FaceSource::ShellPlanar { producer: Some(_), plane: p, .. } => Some(*p),
_ => None,
}
}
fn boolean_source(source: &FaceSource, constructed: bool) -> bool {
matches!(source, FaceSource::ShellPlanar { producer: Some(_), .. }) || (constructed && certified_plane(source).is_some())
}
pub(super) fn descriptor_cost(source: &FaceSource) -> Option<usize> {
let mut cost = 256usize;
match source {
FaceSource::CertifiedExtrudeCap { outer, holes, .. } => {
if holes.len() > 4096 {
return None;
}
cost = cost.checked_add(holes.len().checked_mul(64)?)?;
let mut entries = 4096usize;
for id in outer.iter().chain(holes.iter().flatten()) {
entries = entries.checked_sub(1)?;
if id.len() > 128 {
return None;
}
cost = cost.checked_add(id.len().checked_add(64)?)?;
}
}
FaceSource::CertifiedExtrudeSide { curve, .. } => {
if curve.len() > 128 {
return None;
}
cost = cost.checked_add(curve.len())?;
}
FaceSource::Imported { payload_sha256: Some(digest), .. } => {
if digest.len() > 64 {
return None;
}
cost = cost.checked_add(digest.len())?;
}
FaceSource::ShellPlanar { source, .. } if !matches!(source.as_ref(), FaceSource::ShellPlanar { .. }) => {
cost = cost.checked_add(descriptor_cost(source)?)?;
}
FaceSource::ExtrudeCap { region, .. } => {
if region.holes.len() > 8192 {
return None;
}
cost = cost.checked_add(region.holes.len().checked_mul(64)?)?;
let count = region.holes.iter().try_fold(region.outer.segs.len(), |sum, h| sum.checked_add(h.segs.len()))?;
if count > 8192 {
return None;
}
cost = cost.checked_add(count.checked_mul(128)?)?;
}
FaceSource::ExtrudeSide { .. } | FaceSource::Imported { .. } | FaceSource::Box { .. } => {}
_ => return None,
}
Some(cost)
}
impl Body {
/// Preserve uniquely certified unchanged planar construction supports.
pub fn with_preserved_planar_sources(self, input: &Body) -> Result<Self> {
self.map_planar_sources(input, None, None, &[], false)
}
/// Generate distinct outer/inner roles from the exact original supports of
/// an inside shell. Generated rims and curved walls are intentionally absent.
pub(crate) fn with_shell_planar_sources(self, input: &Body, thickness: f64, open: &[Vec3]) -> Result<Self> {
self.map_planar_sources(input, Some(thickness), None, open, false)
}
/// Retain exact shell roles, and construction roles for Cut, only on unchanged
/// supports without a competing coplanar tool. No imported or fitted role is inferred.
pub(crate) fn with_preserved_boolean_planar_sources(self, input: &Body, tool: &Body, constructed: bool) -> Result<Self> {
if !input.face_sources().is_some_and(|sources| sources.iter().flatten().any(|s| boolean_source(s, constructed))) {
return Ok(self);
}
self.map_planar_sources(input, None, Some(tool), &[], constructed)
}
fn map_planar_sources(mut self, input: &Body, shell: Option<f64>, blocker: Option<&Body>, open: &[Vec3], constructed: bool) -> Result<Self> {
let Some(sources) = input.face_sources() else { return Ok(self) };
if input.solid.face_iter().count() > 16_384
|| self.solid.face_iter().count() > 16_384
|| blocker.is_some_and(|b| b.solid.face_iter().count() > 16_384)
{
return Err(KernelError::Invalid("planar source face budget exceeded".into()));
}
if sources.len() != input.solid.face_iter().count() {
return Err(KernelError::Invalid("planar source alignment mismatch".into()));
}
if shell.is_some_and(|t| !t.is_finite() || t <= 0.) {
return Err(KernelError::Invalid("invalid shell source thickness".into()));
}
if open.len() > 256 {
return Ok(self);
}
let opening_tolerance = if shell.is_some() { input.size() * 1e-4 + 1e-6 } else { 0. };
let mapped = guard("exact planar support lineage", || {
let competing: Vec<_> = blocker.into_iter().flat_map(|b| b.solid.face_iter()).filter_map(actual_plane).collect();
let mut candidates = Vec::new();
let mut unknown_supports = Vec::new();
for (face, source) in input.solid.face_iter().zip(sources) {
let Some(actual) = actual_plane(face) else { continue };
let Some(source) = source.as_ref() else {
unknown_supports.push(actual);
continue;
};
if (blocker.is_some() && !boolean_source(source, constructed))
|| (shell.is_some() && matches!(source, FaceSource::ShellPlanar { .. }))
{
unknown_supports.push(actual);
continue;
}
let Some(plane) = base_plane(source, &actual) else {
unknown_supports.push(actual);
continue;
};
if !same_support(&plane, &actual) || (shell.is_some() && open.iter().any(|p| actual.height(*p).abs() <= opening_tolerance)) {
unknown_supports.push(actual);
continue;
}
let Some(cost) = descriptor_cost(source) else {
unknown_supports.push(actual);
continue;
};
candidates.push((plane, source, false, cost));
if let Some(t) = shell {
let inner = Plane::new(plane.origin - plane.normal() * t, plane.x, -plane.y)
.ok_or_else(|| KernelError::Invalid("invalid shell inner source plane".into()))?;
candidates.push((inner, source, true, cost));
}
}
let mut work = 2_000_000usize;
let mut metadata = 2_000_000usize;
let mut identity_bytes = 16_000_000usize;
let mut result = Vec::new();
for (index, face) in self.solid.face_iter().enumerate() {
let mut found = None;
if let Some(actual) = actual_plane(face) {
let mut blocked = false;
for plane in &unknown_supports {
work = work.checked_sub(1).ok_or_else(|| KernelError::Invalid("unknown planar source comparison budget exceeded".into()))?;
if same_support(plane, &actual) {
blocked = true;
break;
}
}
for plane in &competing {
work = work.checked_sub(1).ok_or_else(|| KernelError::Invalid("planar tool comparison budget exceeded".into()))?;
if same_support(plane, &actual)
|| same_support(
&Plane::new(plane.origin, plane.x, -plane.y).ok_or_else(|| KernelError::Invalid("invalid opposing plane".into()))?,
&actual,
)
{
blocked = true;
break;
}
}
if blocked {
let existing = self
.face_sources()
.and_then(|sources| sources.get(index))
.and_then(|s| s.as_ref())
.filter(|s| (blocker.is_some() || self.face_was_coalesced(index)) && !boolean_source(s, constructed));
let keep = existing.and_then(|s| {
descriptor_cost(s).and_then(|cost| {
metadata.checked_sub(cost).map(|remaining| {
metadata = remaining;
s.clone()
})
})
});
result.push(keep);
continue;
}
let mut chosen: Option<(Plane, &FaceSource, bool, usize)> = None;
for (plane, source, inner, cost) in &candidates {
work = work.checked_sub(1).ok_or_else(|| KernelError::Invalid("planar source comparison budget exceeded".into()))?;
if same_support(plane, &actual) {
if let Some((previous_plane, previous_source, previous_inner, _)) = chosen {
let Some(remaining) = identity_bytes.checked_sub(*cost) else {
chosen = None;
break;
};
identity_bytes = remaining;
// Several current pieces of one exact source are one
// logical face; distinct source identities remain ambiguous.
if previous_plane != *plane || previous_source != *source || previous_inner != *inner {
chosen = None;
break;
}
} else {
chosen = Some((*plane, *source, *inner, *cost));
}
}
}
if let Some((plane, source, inner, cost)) = chosen
&& let Some(remaining) = metadata.checked_sub(cost)
{
metadata = remaining;
found = Some(if shell.is_some() {
FaceSource::ShellPlanar { producer: None, source: Box::new(source.clone()), inner, plane }
} else {
source.clone()
});
}
}
if found.is_none() && (blocker.is_some() || self.face_was_coalesced(index)) {
found = self
.face_sources()
.and_then(|sources| sources.get(index))
.and_then(|s| s.as_ref())
.filter(|s| !boolean_source(s, constructed))
.and_then(|s| {
descriptor_cost(s).and_then(|cost| {
metadata.checked_sub(cost).map(|remaining| {
metadata = remaining;
s.clone()
})
})
});
}
result.push(found);
}
Ok(result)
})?;
self.face_sources = Some(Arc::new(mapped));
self.regularize_certified_planar_faces()
}
pub fn with_shell_certificate(mut self, producer: u64) -> Result<Self> {
if producer == 0 {
return Err(KernelError::Invalid("invalid shell source producer".into()));
}
if let Some(sources) = &self.face_sources {
let mut sources = sources.as_ref().clone();
for source in sources.iter_mut().flatten() {
if let FaceSource::ShellPlanar { producer: id, .. } = source {
*id = Some(producer);
}
}
self.face_sources = Some(Arc::new(sources));
}
self.regularize_certified_planar_faces()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{box_solid, fillet, shell, transform_matrix};
#[test]
fn rounded_shell_planar_roles_are_exact_distinct_and_transformable() {
for (width, thickness) in [(80., 2.), (100., 3.)] {
let box_body = box_solid(Vec3::new(-width / 2., -30., 0.), Vec3::new(width / 2., 30., 30.)).unwrap().with_box_certificate(7).unwrap();
let rounded = fillet(
&box_body,
&[
Vec3::new(width / 2., 30., 15.),
Vec3::new(-width / 2., 30., 15.),
Vec3::new(-width / 2., -30., 15.),
Vec3::new(width / 2., -30., 15.),
],
6.,
)
.unwrap()
.with_preserved_planar_sources(&box_body)
.unwrap();
assert_eq!(rounded.face_sources().unwrap().iter().flatten().count(), 6);
let cup = shell(&rounded, &[Vec3::new(0., 0., 30.)], thickness).unwrap().with_shell_certificate(9).unwrap();
let sources = cup.face_sources().unwrap();
assert!(
!sources
.iter()
.flatten()
.any(|s| matches!(s,FaceSource::ShellPlanar {source, ..} if matches!(source.as_ref(),FaceSource::Box {axis:2,high:true,..})))
);
let right = sources.iter().position(|s| matches!(s, Some(FaceSource::ShellPlanar { inner:false, source, .. }) if matches!(source.as_ref(),FaceSource::Box {axis:0,high:true,..}))).unwrap();
let floor = sources.iter().position(|s| matches!(s, Some(FaceSource::ShellPlanar { inner:true, source, .. }) if matches!(source.as_ref(),FaceSource::Box {axis:2,high:false,..}))).unwrap();
assert!((cup.face_plane(right).unwrap().origin.x - width / 2.).abs() < 1e-9);
let floor_plane = cup.face_plane(floor).unwrap();
assert!((floor_plane.origin.z - thickness).abs() < 1e-9);
assert!(floor_plane.normal().dot(Vec3::Z) > 0.999999);
for (i, s) in sources.iter().enumerate() {
if matches!(s, Some(FaceSource::ExtrudeSide { coalesced: true, plane: None, .. })) {
assert!(cup.face_plane(i).is_none(), "loss witness is not source authority");
} else if s.is_some() {
assert!(same_support(&cup.face_plane(i).unwrap(), &cup.planar_face(i).unwrap()));
}
}
let moved = transform_matrix(&cup, [[0., 1., 0., 0.], [-1., 0., 0., 0.], [0., 0., 1., 0.], [100., 200., 300., 1.]]).unwrap();
assert!((moved.face_plane(floor).unwrap().origin.z - (300. + thickness)).abs() < 1e-9);
assert!(matches!(moved.face_sources().unwrap().get(right), Some(Some(FaceSource::ShellPlanar { producer: Some(9), inner: false, .. }))));
assert!(moved.face_plane(right).unwrap().normal().dot(Vec3::Y) > 0.999999);
let raw = cup.tessellate(0.01).unwrap();
let display = cup.display_mesh(0.01).unwrap();
let cylindrical: Vec<_> = cup
.solid
.face_iter()
.enumerate()
.filter_map(|(i, f)| {
let crate::FaceSurface::Cylinder { radius, .. } = crate::face_surface(&cup, i)? else { return None };
let lo = f.vertex_iter().map(|v| v.point().z).fold(f64::INFINITY, f64::min);
let hi = f.vertex_iter().map(|v| v.point().z).fold(f64::NEG_INFINITY, f64::max);
Some((i, radius, lo, hi))
})
.collect();
eprintln!("roundedShell width{width} thickness{thickness} raw{} cylindrical roles{cylindrical:?}", cup.solid.face_iter().count());
let mut corner_joins = 0;
for (edge, adjacent) in raw.edge_faces.iter().enumerate() {
let [a, b] = adjacent.as_slice() else { continue };
if raw.edges[edge].iter().all(|p| (p.z - thickness).abs() < 1e-8)
&& cylindrical.iter().any(|(i, r, _, _)| *i == *a as usize && (*r - 6.).abs() < 1e-6)
&& cylindrical.iter().any(|(i, r, _, _)| *i == *b as usize && (*r - 6.).abs() < 1e-6)
{
corner_joins += 1;
assert!(display.seams[edge], "existing cylindrical corner joins must not draw a floor-height arc");
}
}
assert_eq!(corner_joins, 0, "no physical lower corner strip remains");
assert_eq!(cup.face_count(), 19);
let outer: Vec<_> = cylindrical.iter().filter(|(_, r, _, _)| (*r - 6.).abs() < 1e-6).collect();
assert_eq!(outer.len(), 4);
assert!(outer.iter().all(|(i, _, _, _)| cup.face_was_coalesced(*i) && moved.face_was_coalesced(*i)));
assert!(outer.iter().all(|(_, _, lo, hi)| lo.abs() < 1e-9 && (*hi - 30.).abs() < 1e-9));
for axis in 0..2 {
for high in [false, true] {
let matching: Vec<_> = sources.iter().enumerate().filter_map(|(i,s)| matches!(s,Some(FaceSource::ShellPlanar {inner:false,source,..}) if matches!(source.as_ref(),FaceSource::Box {axis:a,high:h,..} if *a==axis && *h==high)).then_some(i)).collect();
assert_eq!(matching.len(), 1, "each straight exterior wall is one actual face");
let face = cup.solid.face_iter().nth(matching[0]).unwrap();
let z: Vec<_> = face.vertex_iter().map(|v| v.point().z).collect();
assert!(z.iter().any(|z| z.abs() < 1e-9));
assert!(z.iter().any(|z| (*z - 30.).abs() < 1e-9));
}
}
assert!(display.contains(Vec3::new(width / 2. - thickness / 2., 0., thickness / 2.)));
assert!(display.contains(Vec3::new(width / 2. - thickness / 2., 0., 15.)));
assert!(!display.contains(Vec3::new(0., 0., 15.)));
assert_eq!(raw.measure().volume, display.measure().volume);
let expected = (width * 60. - (4. - std::f64::consts::PI) * 36.) * 30.
- ((width - 2. * thickness) * (60. - 2. * thickness) - (4. - std::f64::consts::PI) * (6. - thickness).powi(2)) * (30. - thickness);
assert!((cup.tessellate(1e-4).unwrap().measure().volume - expected).abs() < 0.2);
let imported = crate::step_import(&crate::step_export(&[&cup], "regularized rounded shell").unwrap()).unwrap();
assert!(imported.warnings.is_empty());
let copy = &imported.bodies[0].body;
assert_eq!(copy.face_count(), 19);
// STEP may additionally coalesce cylindrical corner patches. The
// certified planar wall topology itself must survive unchanged.
for (axis, coordinate) in [(Vec3::X, width / 2.), (-Vec3::X, width / 2.), (Vec3::Y, 30.), (-Vec3::Y, 30.)] {
let walls: Vec<_> = copy
.solid
.face_iter()
.filter(|f| actual_plane(f).is_some_and(|p| p.normal().dot(axis) > 0.999 && (p.origin.dot(axis) - coordinate).abs() < 1e-8))
.collect();
assert_eq!(walls.len(), 1);
let z: Vec<_> = walls[0].vertex_iter().map(|v| v.point().z).collect();
assert!(z.iter().any(|z| z.abs() < 1e-8));
assert!(z.iter().any(|z| (*z - 30.).abs() < 1e-8));
}
let copy_mesh = copy.tessellate(1e-4).unwrap();
assert!((copy_mesh.measure().volume - expected).abs() < 0.2);
assert!(copy_mesh.contains(Vec3::new(width / 2. - thickness / 2., 0., 15.)));
assert!(!copy_mesh.contains(Vec3::new(0., 0., 15.)));
}
}
#[test]
fn simple_shell_has_one_full_height_face_per_outer_wall_and_exports_that_topology() {
let source = box_solid(Vec3::ZERO, Vec3::new(40., 30., 20.)).unwrap().with_box_certificate(1).unwrap();
let pending = shell(&source, &[Vec3::new(20., 15., 20.)], 2.).unwrap();
let cup = pending.clone().with_shell_certificate(2).unwrap();
assert_eq!(cup.solid.face_iter().count(), 11);
let mesh = cup.tessellate(0.01).unwrap();
assert!((mesh.measure().volume - (40. * 30. * 20. - 36. * 26. * 18.)).abs() < 1e-6);
assert!(mesh.contains(Vec3::new(20., 1., 1.)));
assert!(mesh.contains(Vec3::new(20., 1., 10.)));
assert!(!mesh.contains(Vec3::new(20., 15., 10.)));
let imported = crate::step_import(&crate::step_export(&[&cup], "regularized shell").unwrap()).unwrap();
assert!(imported.warnings.is_empty());
let copy = &imported.bodies[0].body;
assert_eq!(copy.solid.face_iter().count(), 11);
assert!((copy.tessellate(0.01).unwrap().measure().volume - mesh.measure().volume).abs() < 1e-6);
}
#[test]
fn coincident_competing_source_planes_and_tool_planes_stay_unknown() {
let a = box_solid(Vec3::ZERO, Vec3::new(10., 10., 10.)).unwrap().with_box_certificate(1).unwrap();
let b = box_solid(Vec3::new(20., 0., 0.), Vec3::new(30., 10., 10.)).unwrap().with_box_certificate(2).unwrap();
let mut shells = a.solid.boundaries().to_vec();
shells.extend(b.solid.boundaries().iter().cloned());
let mut combined = Body::new(mt::Solid::try_new(shells).unwrap()).unwrap();
combined.face_sources = Some(Arc::new(a.face_sources().unwrap().iter().chain(b.face_sources().unwrap()).cloned().collect()));
let result = a.clone().with_preserved_planar_sources(&combined).unwrap();
for (i, face) in result.solid.face_iter().enumerate() {
if actual_plane(face).unwrap().normal().dot(Vec3::Z).abs() > 0.999 {
assert!(result.face_sources().unwrap()[i].is_none());
}
}
let mut mixed = combined.clone();
mixed.face_sources =
Some(Arc::new(a.face_sources().unwrap().iter().cloned().chain(std::iter::repeat_n(None, b.solid.face_iter().count())).collect()));
let guarded = a.clone().with_preserved_planar_sources(&mixed).unwrap();
for (i, face) in guarded.solid.face_iter().enumerate() {
if actual_plane(face).unwrap().normal().dot(Vec3::Z).abs() > 0.999 {
assert!(guarded.face_sources().unwrap()[i].is_none(), "uncertified competing support must not gain identity");
}
}
let cup = shell(&a, &[Vec3::new(5., 5., 10.)], 1.).unwrap().with_shell_certificate(3).unwrap();
let tool = box_solid(Vec3::new(2., 2., 1.), Vec3::new(4., 4., 3.)).unwrap();
let preserved = cup.clone().with_preserved_boolean_planar_sources(&cup, &tool, false).unwrap();
for (i, face) in preserved.solid.face_iter().enumerate() {
let Some(p) = actual_plane(face) else { continue };
if p.normal().dot(Vec3::Z) > 0.999 && (p.origin.z - 1.).abs() < 1e-9 {
assert!(preserved.face_sources().unwrap()[i].is_none());
}
}
}
#[test]
fn pocket_cut_removes_internal_wall_faces_in_actual_topology() {
use solvecraft_geom::{Loop2, Region2, Vec2};
let region = Region2 {
outer: Loop2::polygon(&[Vec2::new(-30., -27.), Vec2::new(30., -27.), Vec2::new(30., 27.), Vec2::new(-30., 27.)]),
holes: vec![],
};
let original = crate::extrude(&Plane::XY, std::slice::from_ref(&region), 0., 10.).unwrap().pop().unwrap();
let curves: Vec<_> = original
.face_sources()
.unwrap()
.iter()
.enumerate()
.map(|(i, source)| matches!(source, Some(FaceSource::ExtrudeSide { .. })).then(|| format!("l{i}")))
.collect();
let original = original.with_extrude_certificates(2, 1, &curves).unwrap();
let (x, y) = (24.73317426966596, 21.733174269665962);
let cutter = box_solid(Vec3::new(-x, -y, 1.), Vec3::new(x, y, 10.)).unwrap();
let pocket = crate::boolean(&original, &cutter, crate::BoolOp::Cut).unwrap().unwrap();
let raw = pocket.tessellate(0.01).unwrap();
let display = pocket.display_mesh(0.01).unwrap();
let sources = pocket.face_sources().unwrap();
assert_eq!(pocket.solid.face_iter().count(), 11, "four outer walls, four inner walls, rim, floor, bottom");
let sides: Vec<_> =
sources.iter().enumerate().filter_map(|(i, s)| matches!(s, Some(FaceSource::CertifiedExtrudeSide { .. })).then_some(i)).collect();
assert_eq!(sides.len(), 4);
for i in sides {
let face = pocket.solid.face_iter().nth(i).unwrap();
let z: Vec<_> = face.vertex_iter().map(|v| v.point().z).collect();
assert!(z.iter().any(|z| z.abs() < 1e-9));
assert!(z.iter().any(|z| (*z - 10.).abs() < 1e-9));
assert!(!face.edge_iter().any(|e| (e.front().point().z - 1.).abs() < 1e-9 && (e.back().point().z - 1.).abs() < 1e-9));
}
assert!(display.contains(Vec3::new(0., 0., 0.5)));
assert!(!display.contains(Vec3::new(0., 0., 5.)));
assert!(display.contains(Vec3::new(28., 0., 5.)));
assert!((display.measure().volume - (60. * 54. * 10. - 4. * x * y * 9.)).abs() < 1e-7);
assert_eq!(raw.measure().volume, display.measure().volume);
let imported = crate::step_import(&crate::step_export(&[&pocket], "regularized pocket").unwrap()).unwrap();
assert!(imported.warnings.is_empty());
let copy = &imported.bodies[0].body;
assert_eq!(copy.solid.face_iter().count(), 11);
let copy_mesh = copy.tessellate(0.01).unwrap();
assert!((copy_mesh.measure().volume - raw.measure().volume).abs() < 1e-6);
assert!(copy_mesh.contains(Vec3::new(0., 0., 0.5)));
assert!(!copy_mesh.contains(Vec3::new(0., 0., 5.)));
let competing = box_solid(Vec3::new(30., -1., 0.), Vec3::new(31., 1., 2.)).unwrap();
let guarded = pocket.clone().with_preserved_boolean_planar_sources(&pocket, &competing, true).unwrap();
for (index, source) in guarded.face_sources().unwrap().iter().enumerate() {
if pocket.face_plane(index).is_some_and(|p| p.normal().dot(Vec3::X) > 0.999 && (p.origin.x - 30.).abs() < 1e-9) {
assert!(source.is_none(), "competing tool plane cannot retain source identity");
}
}
}
}
+4
View File
@@ -130,6 +130,10 @@ pub fn planar_convex(b: &Body) -> Result<Vec<(HalfSpace, Vec3)>> {
/// Hollow a convex planar body: walls of `thickness` inside, faces near `open` removed.
pub fn shell(b: &Body, open: &[Vec3], thickness: f64) -> Result<Body> {
shell_impl(b, open, thickness)?.with_shell_planar_sources(b, thickness, open)
}
fn shell_impl(b: &Body, open: &[Vec3], thickness: f64) -> Result<Body> {
b.require_brep("shell")?;
if !(thickness.is_finite() && thickness > 1e-6) {
return Err(KernelError::Invalid("shell thickness must be positive".into()));
+156 -26
View File
@@ -683,12 +683,17 @@ fn sew_stack(plane: &Plane, d: Vec3, pa: &Prism, pb: &Prism, tol: f64) -> Option
};
slabs.push(oriented(&s));
}
sew_sections(plane, d, &levels, &slabs, tol)
sew_sections(plane, d, &levels, &slabs, tol, false)
}
/// Sew the exact sections between successive levels, including empty gaps.
fn sew_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<Region2>], tol: f64) -> Option<Result<Body>> {
if levels.len() != slabs.len() + 1 || slabs.len() > 128 || plane.normal().dot(d) < 1. - 1e-12 {
fn sew_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<Region2>], tol: f64, coalesce: bool) -> Option<Result<Body>> {
if levels.len() != slabs.len() + 1
|| slabs.len() > 128
|| plane.normal().dot(d) < 1. - 1e-12
|| levels.iter().any(|z| !z.is_finite())
|| levels.windows(2).any(|z| z[0] >= z[1])
{
return None;
}
let mut blocks = Vec::new();
@@ -732,7 +737,7 @@ fn sew_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<Region2>],
pieces.push(piece);
}
} else {
let piece = sew_connected_sections(plane, d, &levels[first..=end], &slabs[first..end], tol)
let piece = sew_connected_sections(plane, d, &levels[first..=end], &slabs[first..end], tol, coalesce)
.ok_or_else(|| KernelError::Failed("stepped prism construction failed".into()))??;
remaining_faces =
remaining_faces.checked_sub(piece.face_count()).ok_or_else(|| KernelError::Failed("prism output face budget".into()))?;
@@ -765,8 +770,18 @@ fn sew_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<Region2>],
})())
}
/// Stable exact identity for the bounded analytic segment subset.
fn wall_key(s: &Seg2) -> Option<[u64; 6]> {
let bits = |v: f64| v.is_finite().then(|| if v == 0. { 0 } else { v.to_bits() });
match s {
Seg2::Line { a, b } => Some([0, bits(a.x)?, bits(a.y)?, bits(b.x)?, bits(b.y)?, 0]),
Seg2::Arc { center, radius, start, sweep } => Some([1, bits(center.x)?, bits(center.y)?, bits(*radius)?, bits(*start)?, bits(*sweep)?]),
_ => None,
}
}
/// A genuinely stepped connected axial block has no single extrusion footprint.
fn sew_connected_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<Region2>], tol: f64) -> Option<Result<Body>> {
fn sew_connected_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<Region2>], tol: f64, coalesce: bool) -> Option<Result<Body>> {
use crate::sew::{EdgeSpec, FaceSpec, SurfSpec};
// Faces at each level: up where the slab below sticks out, down where the one above does.
let diff = |x: &[Region2], y: &[Region2]| -> Option<Vec<Region2>> {
@@ -800,30 +815,61 @@ fn sew_connected_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<R
_ => EdgeSpec::Line { a: at(sg.start(), z), b: at(sg.end(), z) },
}
};
let mut faces: Vec<FaceSpec> = Vec::new();
for (i, s) in slabs.iter().enumerate() {
let (z0, z1) = (*levels.get(i)?, *levels.get(i + 1)?);
for lp in loops_of(s) {
// Exact oriented generated segments alone define a run. No support fitting,
// proximity matching, or old face lineage is used here.
let mut runs: Vec<(usize, usize, Seg2)> = Vec::new();
let mut last = std::collections::BTreeMap::new();
let mut remaining = MAX_PLANAR_SEGMENTS * 2;
for (i, slab) in slabs.iter().enumerate() {
for lp in loops_of(slab) {
for sg0 in &lp.segs {
for sg in split_seg(sg0, &pts, tol)? {
let (a, b) = (sg.start(), sg.end());
let lp3 = vec![
edge(&sg, z0),
EdgeSpec::Line { a: at(b, z0), b: at(b, z1) },
edge(&sg.reversed(), z1),
EdgeSpec::Line { a: at(a, z1), b: at(a, z0) },
];
let tangent = plane.dir_to_world(sg.derivative(0.5));
let outward = tangent.cross(d).normalized()?;
let surface = match sg {
Seg2::Arc { .. } => SurfSpec::Extruded { a: at(a, z0), b: at(b, z0), mid: at(sg.point_at(0.5), z0), dir: d * (z1 - z0) },
_ => SurfSpec::Plane,
};
faces.push(FaceSpec { loops: vec![lp3], surface, probe: Some((at(sg.point_at(0.5), (z0 + z1) / 2.0), outward)) });
remaining = remaining.checked_sub(1)?;
let key = wall_key(&sg)?;
if coalesce {
if let Some(&index) = last.get(&key) {
let run: &mut (usize, usize, Seg2) = runs.get_mut(index)?;
if run.1 == i {
run.1 = i + 1;
continue;
}
// Duplicate oriented boundaries in one slab are not
// a valid reason to reconstruct a larger wall.
if run.1 == i + 1 {
return None;
}
}
last.insert(key, runs.len());
}
runs.push((i, i + 1, sg));
}
}
}
}
let mut faces: Vec<FaceSpec> = Vec::new();
let mut sources = Vec::new();
for (first, end, sg) in runs {
let (z0, z1) = (levels[first], levels[end]);
let (a, b) = (sg.start(), sg.end());
let mut lp3 = Vec::with_capacity(2 + 2 * (end - first));
lp3.push(edge(&sg, z0));
// Keep station vertices at both ends: neighboring stepped walls/caps
// must share complete edges rather than create T-junctions.
for k in first..end {
lp3.push(EdgeSpec::Line { a: at(b, levels[k]), b: at(b, levels[k + 1]) });
}
lp3.push(edge(&sg.reversed(), z1));
for k in (first..end).rev() {
lp3.push(EdgeSpec::Line { a: at(a, levels[k + 1]), b: at(a, levels[k]) });
}
let outward = plane.dir_to_world(sg.derivative(0.5)).cross(d).normalized()?;
let surface = match sg {
Seg2::Arc { .. } => SurfSpec::Extruded { a: at(a, z0), b: at(b, z0), mid: at(sg.point_at(0.5), z0), dir: d * (z1 - z0) },
_ => SurfSpec::Plane,
};
faces.push(FaceSpec { loops: vec![lp3], surface, probe: Some((at(sg.point_at(0.5), (z0 + z1) / 2.0), outward)) });
sources.push((end > first + 1).then_some(crate::FaceSource::ExtrudeSide { segment: sg, plane: None, coalesced: true }));
}
for (z, up, rs) in &caps {
for r in rs {
let mut loops = Vec::new();
@@ -836,9 +882,10 @@ fn sew_connected_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<R
loops.push(segs.iter().map(|sg| edge(sg, *z)).collect::<Vec<_>>());
}
faces.push(FaceSpec { loops, surface: SurfSpec::Plane, probe: None });
sources.push(None);
}
}
Some(crate::sew::sew_touching(&faces))
Some(crate::sew::sew_touching_with_sources(&faces, &sources))
}
fn simple_prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
@@ -1055,7 +1102,19 @@ fn stack_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>>
if slabs.iter().all(Vec::is_empty) {
return Some(Ok(None));
}
sew_sections(&plane, d, &levels, &slabs, tol).map(|r| r.map(Some))
sew_sections(
&plane,
d,
&levels,
&slabs,
tol,
op == BoolOp::Cut
&& a.split_keep().is_empty()
&& b.split_keep().is_empty()
&& a.paint().is_none_or(|p| p.faces.is_empty())
&& b.paint().is_none_or(|p| p.faces.is_empty()),
)
.map(|r| r.map(Some))
}
pub(crate) fn prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
@@ -1144,7 +1203,12 @@ mod planar_tests {
assert!(mesh.contains(Vec3::new(21., 21., 3.)));
assert!(!mesh.contains(Vec3::new(25., 25., 3.)));
assert!(mesh.contains(Vec3::new(25., 25., 22.)));
assert!(lump.face_sources().is_none_or(|s| s.iter().all(Option::is_none)), "genuinely stepped geometry remains uncertified");
assert!(
lump.face_sources().is_none_or(|s| s
.iter()
.all(|s| s.is_none() || matches!(s, Some(crate::FaceSource::ExtrudeSide { coalesced: true, plane: None, .. })))),
"genuinely stepped geometry has no durable certificate"
);
assert!((mesh.measure().volume - (18. * (100. - 4. * std::f64::consts::PI) + 500.)).abs() < 0.02);
}
}
@@ -1152,6 +1216,72 @@ mod planar_tests {
assert_eq!(crate::step_export(&[&tool], "tool").unwrap(), before_tool);
assert_eq!(crate::step_export(&[&roof], "roof").unwrap(), before_roof);
}
#[test]
fn contiguous_analytic_walls_remove_faces_but_retain_vertical_stations() {
for curved in [false, true] {
let outer = if curved {
Region2 { outer: Loop2::circle(Vec2::ZERO, 10.), holes: vec![] }
} else {
rectangle(Vec2::new(-10., -10.), Vec2::new(10., 10.))
};
let mut upper = outer.clone();
upper.holes.push(if curved {
Loop2::circle(Vec2::ZERO, 5.).reversed()
} else {
rectangle(Vec2::new(-5., -5.), Vec2::new(5., 5.)).outer.reversed()
});
let slabs = vec![vec![outer], vec![upper]];
let old = sew_sections(&Plane::XY, Vec3::Z, &[0., 2., 20.], &slabs, 1e-7, false).unwrap().unwrap();
let merged = sew_sections(&Plane::XY, Vec3::Z, &[0., 2., 20.], &slabs, 1e-7, true).unwrap().unwrap();
let count = if curved { 2 } else { 4 };
assert_eq!(old.face_count() - merged.face_count(), count);
let marked: Vec<_> = merged.solid.face_iter().enumerate().filter(|(i, _)| merged.face_was_coalesced(*i)).collect();
assert_eq!(marked.len(), count);
for (_, face) in marked {
assert_eq!(face.boundaries()[0].len(), 6, "all axial station vertices are retained");
let heights: Vec<_> = face.vertex_iter().map(|v| v.point().z).collect();
for z in [0., 2., 20.] {
assert!(heights.contains(&z));
}
}
assert!(old.face_sources().is_none_or(|s| s.iter().all(Option::is_none)));
let expected = if curved { std::f64::consts::PI * (100. * 20. - 25. * 18.) } else { 400. * 20. - 100. * 18. };
let mesh = merged.tessellate(1e-5).unwrap();
assert!((mesh.measure().volume - expected).abs() < 0.03);
assert!(mesh.contains(Vec3::new(0., 0., 1.)));
assert!(!mesh.contains(Vec3::new(0., 0., 10.)));
assert!(merged.validity().is_empty());
assert_ne!(wall_key(&slabs[0][0].outer.segs[0]), wall_key(&slabs[0][0].outer.segs[0].reversed()));
}
}
#[test]
fn cut_run_coalescing_respects_authored_splits_and_face_paint() {
let region = Region2 { outer: Loop2::circle(Vec2::ZERO, 10.), holes: vec![] };
let tool_region = Region2 { outer: Loop2::circle(Vec2::ZERO, 5.), holes: vec![] };
let stock = crate::extrude(&Plane::XY, &[region], 0., 20.).unwrap().pop().unwrap();
let cutter = crate::extrude(&Plane::XY, &[tool_region], 2., 22.).unwrap().pop().unwrap();
let merged = stack_boolean(&stock, &cutter, BoolOp::Cut).unwrap().unwrap().unwrap();
let protected = [
stock.clone().with_split_lines(Some(std::sync::Arc::new(vec![vec![Vec3::new(10., 0., 0.), Vec3::new(10., 0., 20.)]]))),
stock.with_paint(Some(crate::Paint { opacity: 1., faces: vec![crate::FacePaint { face: 0, color: [1., 0., 0.], opacity: 1. }] })),
];
for input in protected {
let output = stack_boolean(&input, &cutter, BoolOp::Cut).unwrap().unwrap().unwrap();
assert_eq!(output.face_count(), merged.face_count() + 2);
assert!((0..output.face_count()).all(|i| !output.face_was_coalesced(i)));
// Independent circular chord-volume bound: 2*pi*(10*20+5*18)*1e-5 < .019.
let expected = std::f64::consts::PI * (100. * 20. - 25. * 18.);
for body in [&output, &merged] {
let mesh = body.tessellate(1e-5).unwrap();
assert!((mesh.measure().volume - expected).abs() < 0.03);
assert!(mesh.contains(Vec3::new(0., 0., 1.)));
assert!(!mesh.contains(Vec3::new(0., 0., 10.)));
assert!(mesh.contains(Vec3::new(7., 0., 10.)));
}
}
}
#[test]
fn annular_inward_tool_recovers_exact_stacks_before_cap_policy() {
let plane = Plane::XY;
+17 -6
View File
@@ -97,13 +97,16 @@ impl Cache {
/// Sew the faces into a closed solid.
pub fn sew(faces: &[FaceSpec]) -> Result<Body> {
sew_impl(faces, false)
sew_impl(faces, false, None)
}
/// Separate boundary sheets at a shared geometric edge without moving it.
/// The face adjacency must still describe one connected solid boundary.
pub(crate) fn sew_touching(faces: &[FaceSpec]) -> Result<Body> {
sew_impl(faces, true)
/// Optional transient sources follow only unchanged produced face identities.
pub(crate) fn sew_touching_with_sources(faces: &[FaceSpec], sources: &[Option<crate::FaceSource>]) -> Result<Body> {
if sources.len() != faces.len() {
return Err(KernelError::Invalid("sew source alignment".into()));
}
sew_impl(faces, true, Some(sources))
}
type EdgeUse = (usize, usize, usize);
@@ -347,7 +350,7 @@ fn split_vertex_fans(faces: Vec<mt::Face>) -> Result<Vec<mt::Face>> {
.collect()
}
fn sew_impl(faces: &[FaceSpec], touching: bool) -> Result<Body> {
fn sew_impl(faces: &[FaceSpec], touching: bool, sources: Option<&[Option<crate::FaceSource>]>) -> Result<Body> {
if faces.len() < 4 || faces.len() > 200_000 {
return Err(KernelError::Invalid("a solid needs 4…200000 faces".into()));
}
@@ -396,6 +399,11 @@ fn sew_impl(faces: &[FaceSpec], touching: bool) -> Result<Body> {
};
out.push(face);
}
let source_ids: HashMap<_, _> = out
.iter()
.enumerate()
.filter_map(|(i, f)| sources.and_then(|s| s.get(i)).and_then(Option::as_ref).map(|s| (f.id(), (f.orientation(), s))))
.collect();
if !sheets.is_empty() && !faces_connected(&out) {
return Err(KernelError::Invalid("sew: bodies touch only along an edge".into()));
}
@@ -404,7 +412,10 @@ fn sew_impl(faces: &[FaceSpec], touching: bool) -> Result<Body> {
}
let shells = closed_components(out)?;
let solid = Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("sew: the faces do not close: {e}")))?;
Body::new(solid)
let body = Body::new(solid)?;
let mapped =
body.solid.face_iter().map(|f| source_ids.get(&f.id()).filter(|(o, _)| *o == f.orientation()).map(|(_, s)| (*s).clone())).collect();
Ok(body.with_face_sources(sources.map(|_| std::sync::Arc::new(mapped))))
})
}
File diff suppressed because it is too large. Load diff
+33 -4
View File
@@ -1023,6 +1023,29 @@ mod tests {
assert_eq!(cell_value("param:width", "90 mm"), json!("90 mm"));
}
fn click_rule_button(h: &mut crate::scenario::Harness, name: &str) {
// The table header also contains this name. Its TextRun may be emitted
// after the popup, so select the actual choice rather than matching text.
let bounds = h
.output
.accesskit_update
.as_ref()
.unwrap()
.nodes
.iter()
.rev()
.find_map(|(_, n)| {
(n.role() == egui::accesskit::Role::Button && !n.is_disabled() && n.label() == Some(name)).then(|| n.bounds()).flatten()
})
.expect("rule button");
h.step(&json!({"click": [(bounds.x0 + bounds.x1) / 2.0, (bounds.y0 + bounds.y1) / 2.0]})).unwrap();
}
fn select_rule_parameter(h: &mut crate::scenario::Harness, name: &str) {
click_rule_button(h, name);
assert!(h.widget("Choose…").is_none(), "parameter choice should close and update the dropdown");
}
#[test]
fn configuration_compound_condition_and_rule_order_are_editable_in_dialog() {
let mut h = crate::scenario::Harness::new();
@@ -1032,9 +1055,11 @@ mod tests {
{"call":"engine.execute","params":{"command":"config.configure","params":{"params":["length"]}}},
{"start":"config.table"},{"widget":"Add or edit rule"},
{"widget":"Always"},{"widget":"All"},{"widget":"Always"},{"widget":"Parameter"},
{"widget":"Choose…"},{"widget":"length"},{"widget":"Save Rule"}
{"widget":"Choose…"}
]))
.unwrap();
select_rule_parameter(&mut h, "length");
h.step(&json!({"widget":"Save Rule"})).unwrap();
assert!(
matches!(&h.app.session.doc.configs.rules[0].condition,solvecraft_engine::doc::config::RuleCondition::All{conditions} if matches!(&conditions[0],solvecraft_engine::doc::config::RuleCondition::Parameter{..}))
);
@@ -1044,7 +1069,8 @@ mod tests {
])).unwrap();
assert!(h.widget("length = 20 mm").is_some());
assert_eq!(h.app.session.doc.param("length").unwrap().expr, "40 mm");
h.run(&json!([{"widget":"↑"},{"widget":"Preview Row"}])).unwrap();
click_rule_button(&mut h, "↑");
h.step(&json!({"widget":"Preview Row"})).unwrap();
assert_eq!(h.app.session.doc.configs.rules[0].id, "last");
assert!(h.widget("length = 10 mm").is_some());
assert_eq!(h.app.session.doc.param("length").unwrap().expr, "40 mm");
@@ -1060,8 +1086,11 @@ mod tests {
{"call":"engine.execute","params":{"command":"config.configure","params":{"params":["length"]}}},
{"start":"config.table"},
{"widget":"Add or edit rule"},
{"widget":"Choose…"},
{"widget":"length"},
{"widget":"Choose…"}
]))
.unwrap();
select_rule_parameter(&mut h, "length");
h.run(&json!([
{"widget":"Save Rule"}
]))
.unwrap();
+45
View File
@@ -89,6 +89,51 @@ pub fn right_aligned<R>(ui: &mut Ui, total: f32, width: f32, add: impl FnOnce(&m
#[cfg(test)]
mod tests {
#[test]
fn feature_forms_use_available_height_and_keep_actions_visible() {
use crate::scenario::Harness;
use serde_json::json;
for (width, height, scale) in [(1400.0, 900.0, 1.0), (1400.0, 900.0, 2.0)] {
let mut h = Harness::new();
h.app.preferences.ui_scale = scale;
h.call("ui.resize", json!({"width":width / scale,"height":height / scale}));
h.app.run("sketch.create", json!({"plane":"XY","name":"Base"})).unwrap();
h.app.run("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[10,10]})).unwrap();
h.app.run("sketch.finish", json!({})).unwrap();
h.step(&json!({"start":"solid.extrude"})).unwrap();
h.frames(10);
let dialog = h.app.viewport.dialog_rect.unwrap();
let viewport = h.app.viewport.rect.unwrap();
assert!(dialog.height() > 230.0, "scale={scale}: {dialog:?}");
assert!(viewport.expand(1.0).contains_rect(dialog), "scale={scale}: {dialog:?} outside {viewport:?}");
assert!(h.widget("Distance").is_some(), "distance must be visible without scrolling");
if scale == 1.0 {
assert!(h.widget("Operation").is_some(), "operation must be visible without scrolling");
}
assert!(dialog.contains(crate::scenario::handle_at("dialog:OK").unwrap()));
assert!(h.widget("Cancel").is_some(), "cancel must be visible without scrolling");
h.step(&json!({"widget":"Cancel"})).unwrap();
assert!(h.app.dialog.is_none());
h.step(&json!({"start":"solid.shell"})).unwrap();
h.frames(8);
let short = h.app.viewport.dialog_rect.unwrap();
assert!(short.height() < dialog.height(), "short forms should shrink: {short:?}, {dialog:?}");
assert!(h.widget("Cancel").is_some());
h.step(&json!({"widget":"Cancel"})).unwrap();
h.step(&json!({"start":"solid.hole"})).unwrap();
if let Some(crate::dialogs::Dialog { kind: crate::dialogs::Kind::Hole { kind, opts, .. }, .. }) = h.app.dialog.as_mut() {
*kind = 1;
opts.tapped = true;
}
h.frames(8);
let long = h.app.viewport.dialog_rect.unwrap();
assert!(viewport.expand(1.0).contains_rect(long), "long form clipped: {long:?}");
assert!(h.widget("Cancel").is_some(), "long-form actions must stay visible");
assert!(long.contains(crate::scenario::handle_at("dialog:OK").unwrap()));
h.step(&json!({"widget":"Cancel"})).unwrap();
assert!(h.app.dialog.is_none());
}
}
/// Windows are made in this file only, so none can skip the width limits.
#[test]
fn windows_come_from_the_frame_helper() {
+2
View File
@@ -78,6 +78,8 @@ mod revolve_edit_tests;
mod revolve_manipulator_tests;
pub mod scenario;
pub mod selection;
#[cfg(test)]
mod shell_face_sketch_ui_tests;
pub mod shortcut_box;
pub mod sketch3d;
#[cfg(test)]
@@ -0,0 +1,103 @@
use crate::scenario::Harness;
use serde_json::json;
use solvecraft_engine::{
Sel,
doc::{FaceAttachmentStatus, FeatureKind, PlaneRef},
};
fn shell() -> Harness {
let mut h = Harness::new();
h.app.ui.show_origin = false;
h.app.run("solid.box", json!({"length":40,"width":30,"height":20,"body_name":"Case"})).unwrap();
h.app.run("solid.shell", json!({"faces":[[20,15,20]],"thickness":2})).unwrap();
h.call("ui.view", json!({"view":"front"}));
h.call("ui.view", json!({"view":"fit"}));
h.settle();
h
}
fn assert_attached(h: &Harness) {
let id = h.app.session.active_sketch.expect("a combined certified wall remains a sketch target");
let FeatureKind::Sketch { plane: PlaneRef::Face { anchor: Some(anchor), .. }, .. } = &h.app.session.doc.feature(id).unwrap().kind else {
panic!("wall sketch must have a durable attachment");
};
assert_eq!(anchor.target.body, "Case");
assert_eq!(serde_json::to_value(&anchor.target.certificate).unwrap()["kind"], "shell_planar");
assert!(matches!(h.app.session.doc.sketch_face_attachment(id, &h.app.session.model), FaceAttachmentStatus::Healthy { .. }));
let world = h.app.session.world_state();
let solved = world.sketch(id).unwrap();
assert!(solved.plane.origin.y.abs() < 1e-8);
assert!(solved.plane.normal().y.abs() > 0.999);
assert!(h.app.session.model.result(id).unwrap().error.is_none());
}
fn selected_wall(h: &Harness) -> usize {
assert_eq!(h.app.session.selection.len(), 1, "the exterior wall is one physical face, not a grouped lower strip");
let Sel::Face { body, index, .. } = &h.app.session.selection[0] else {
panic!("actual wall click must select a face");
};
assert_eq!(body, "Case");
*index
}
fn assert_one_physical_wall(h: &mut Harness) -> usize {
h.step(&json!({"click":{"world":[20,0,1]}})).unwrap();
let lower = selected_wall(h);
h.call("ui.click", json!({"x":300,"y":200}));
assert!(h.app.session.selection.is_empty());
h.step(&json!({"click":{"world":[20,0,10]}})).unwrap();
assert_eq!(selected_wall(h), lower, "below and above the floor height must hit the same BRep face index");
lower
}
#[test]
fn selected_shell_wall_patch_still_creates_a_certified_sketch() {
let mut h = shell();
assert_one_physical_wall(&mut h);
let undo = h.app.session.undo.len();
h.step(&json!({"start":"sketch.create"})).unwrap();
h.settle();
assert_attached(&h);
assert_eq!(h.app.session.undo.len(), undo + 1);
}
#[test]
fn pending_create_sketch_accepts_an_actual_shell_wall_patch_click() {
let mut h = shell();
assert_one_physical_wall(&mut h);
h.call("ui.click", json!({"x":300,"y":200}));
assert!(h.app.session.selection.is_empty());
let undo = h.app.session.undo.len();
h.step(&json!({"start":"sketch.create"})).unwrap();
assert!(h.app.session.active_sketch.is_none());
h.step(&json!({"click":{"world":[20,0,1]}})).unwrap();
h.settle();
assert_attached(&h);
assert_eq!(h.app.session.undo.len(), undo + 1);
}
#[test]
fn rounded_shell_corner_is_one_physical_face_above_and_below_the_floor() {
let mut h = Harness::new();
h.app.ui.show_origin = false;
h.app.run("solid.box", json!({"length":80,"width":60,"height":30,"corner":[-40,-30,0],"body_name":"Case"})).unwrap();
h.app.run("solid.fillet", json!({"edges":[[40,30,15],[-40,30,15],[-40,-30,15],[40,-30,15]],"radius":6})).unwrap();
h.app.run("solid.shell", json!({"faces":[[0,0,30]],"thickness":2})).unwrap();
h.call("ui.view", json!({"view":"iso"}));
h.call("ui.view", json!({"view":"fit"}));
h.settle();
let state = h.app.session.world_state();
assert_eq!(state.body("Case").unwrap().body.face_count(), 19, "no artificial lower corner faces remain in the BRep");
let document = h.app.session.doc.to_json();
let undo = h.app.session.undo.len();
let redo = h.app.session.redo.len();
h.step(&json!({"click":{"world":[38.242640687,-28.242640687,1]}})).unwrap();
let lower = selected_wall(&h);
h.call("ui.click", json!({"x":300,"y":200}));
assert!(h.app.session.selection.is_empty());
h.step(&json!({"click":{"world":[38.242640687,-28.242640687,15]}})).unwrap();
assert_eq!(selected_wall(&h), lower, "lower and upper corner clicks hit the same actual face, not two grouped pieces");
assert_eq!(h.app.session.doc.to_json(), document);
assert_eq!(h.app.session.undo.len(), undo);
assert_eq!(h.app.session.redo.len(), redo);
}
@@ -142,14 +142,16 @@ fn unsupported_modified_face_offers_explicit_fixed_plane_without_live_links() {
let mut h = Harness::new();
h.app.ui.show_origin = false;
h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Modified"})).unwrap();
h.app.run("solid.fillet", json!({"edges":[[0,0,5]],"radius":1})).unwrap();
// An intentional split gives the +z source two distinct planar pieces. A
// strict attachment cannot claim the original whole-face certificate.
h.app.run("solid.split_face", json!({"body":"Modified","faces":[[5,5,10]],"plane":{"origin":[5,0,0],"normal":[1,0,0]}})).unwrap();
let before = h.app.session.doc.to_json();
let undo = h.app.session.undo.len();
h.call("ui.view", json!({"view":"top"}));
h.call("ui.view", json!({"view":"fit"}));
h.settle();
h.step(&json!({"start":"sketch.create"})).unwrap();
h.step(&json!({"click":{"world":[5,5,10]}})).unwrap();
h.step(&json!({"click":{"world":[2.5,5,10]}})).unwrap();
assert!(h.app.session.active_sketch.is_none());
assert!(h.app.dialog.as_ref().unwrap().error.is_some());
assert_eq!(h.app.session.doc.to_json(), before);
+12 -6
View File
@@ -42,16 +42,14 @@ impl Layout {
pub fn new(viewport: Rect, docks: &[Rect], scale: f32) -> Self {
let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
let mut obstacles: Vec<_> = docks.iter().take(6).copied().filter(|r| r.is_finite()).map(|r| r.expand(4.0)).collect();
let available = free_rect(viewport, &obstacles);
let axis_view = if available.width() >= 116.0 && available.height() >= 116.0 {
// Navigation is anchored to the viewport, never to transient dialog space.
let available = viewport;
let mut axis_view = if available.width() >= 116.0 && available.height() >= 116.0 {
Rect::from_min_size(pos2(available.right() - 116.0, available.top()), vec2(116.0, 116.0))
} else {
Rect::NOTHING
};
if axis_view.is_positive() {
obstacles.push(axis_view);
}
let nav_space = free_rect(viewport, &obstacles);
let nav_space = viewport;
let columns = ((nav_space.width() - 10.0) / 30.0).floor().clamp(1.0, 10.0);
let nav_height = (10.0 / columns).ceil() * 30.0;
let navigation = if nav_space.width() >= columns * 30.0 + 10.0 && nav_space.height() >= nav_height + 7.0 {
@@ -59,6 +57,14 @@ impl Layout {
} else {
Rect::NOTHING
};
// On tiny viewports keep the bottom controls usable rather than overlapping them.
// This decision depends only on viewport size, never on an overlay.
if axis_view.intersect(navigation).is_positive() {
axis_view = Rect::NOTHING;
}
if axis_view.is_positive() {
obstacles.push(axis_view);
}
if navigation.is_positive() {
obstacles.push(navigation);
}
+89 -1
View File
@@ -142,7 +142,19 @@ fn measured_axis_view_targets_home_and_drag_work_beside_the_palette_at_hidpi() {
assert_eq!(outer, layout.axis_view);
assert!(outer.contains_rect(home));
assert!(layout.viewport.contains_rect(outer));
assert!(!outer.intersect(palette).is_positive());
assert_eq!(outer.right_top(), layout.viewport.right_top());
if outer.intersect(palette).is_positive() {
// Short palettes may cover the fixed anchor; covered controls never steal input.
let covered = outer.intersect(palette).center();
let camera = h.app.cam;
h.call("ui.click", json!({"x":covered.x,"y":covered.y}));
assert_eq!(h.app.cam, camera);
assert!(h.app.viewport.axis_drag.is_none());
assert_eq!(crate::scenario::rect_of("axis_view:outer").unwrap(), outer);
assert_eq!(h.app.session.doc.to_json(), baseline);
assert_eq!(h.app.session.undo.len(), undo);
continue;
}
assert!(!layout.navigation.intersect(outer).is_positive());
for name in ["x", "-x", "y", "-y", "z", "-z"] {
let p = crate::scenario::handle_at(&format!("axis_view:{name}")).unwrap();
@@ -194,3 +206,79 @@ fn immediate_standard_view_cancels_fit_before_the_measured_frame() {
assert_eq!(h.app.session.doc.to_json(), baseline);
assert_eq!(h.app.session.undo.len(), undo);
}
#[test]
fn navigation_anchors_ignore_positioned_resized_and_covering_dialogs() {
for (width, height, scale) in [(1400.0, 900.0, 1.0), (700.0, 450.0, 2.0), (700.0, 360.0, 2.0)] {
let vp = Rect::from_min_max(pos2(250.0, 126.0), pos2(width, height - 30.0));
let baseline = Layout::new(vp, &[], scale);
assert!(baseline.axis_view.is_positive() && baseline.navigation.is_positive());
assert_eq!(baseline.axis_view.right_top(), vp.right_top());
assert_eq!(baseline.navigation.center().x, vp.center().x);
assert_eq!(baseline.navigation.bottom(), vp.bottom() - 7.0);
let cases = [
vec![Rect::from_min_max(pos2(width - 220.0, vp.top() + 120.0), vp.max)],
vec![Rect::from_min_size(vp.min, vec2(210.0, 170.0))],
vec![Rect::from_center_size(vp.center(), vec2(250.0, 180.0)), Rect::from_min_size(vp.min, vec2(70.0, 120.0))],
vec![vp],
];
for docks in cases {
let measured = Layout::new(vp, &docks, scale);
assert_eq!(measured.axis_view, baseline.axis_view, "{docks:?}");
assert_eq!(measured.navigation, baseline.navigation, "{docks:?}");
assert!(vp.contains_rect(measured.axis_view));
assert!(vp.contains_rect(measured.navigation));
}
}
}
#[test]
fn opening_different_command_dialogs_keeps_published_navigation_positions() {
let mut h = Harness::new();
h.app.run("solid.box", json!({"length":40,"width":30,"height":20})).unwrap();
h.settle();
let axis = crate::scenario::rect_of("axis_view:outer").unwrap();
let nav = crate::scenario::rect_of("nav:outer").unwrap();
let camera = h.app.cam;
let baseline = h.app.session.doc.to_json();
let undo = h.app.session.undo.len();
for command in ["solid.extrude", "solid.fillet", "inspect.measure"] {
h.app.start(command);
h.settle();
assert!(h.app.dialog.is_some(), "{command}");
assert!(h.app.viewport.dialog_rect.is_some(), "dialog must be measured");
assert_eq!(crate::scenario::rect_of("axis_view:outer").unwrap(), axis, "{command}");
assert_eq!(crate::scenario::rect_of("nav:outer").unwrap(), nav, "{command}");
assert_eq!(h.app.cam, camera);
let pan = pos2(nav.left() + 5.0 + 2.0 * 30.0 + 13.0, nav.top() + 15.0);
assert!(!h.app.viewport.dialog_rect.unwrap().contains(pan));
h.app.viewport.nav = None;
h.call("ui.click", json!({"x":pan.x,"y":pan.y}));
assert_eq!(h.app.viewport.nav, Some(crate::viewport::NavMode::Pan), "fixed navigation stays usable beside {command}");
h.app.dialog = None;
h.settle();
assert_eq!(crate::scenario::rect_of("axis_view:outer").unwrap(), axis);
assert_eq!(crate::scenario::rect_of("nav:outer").unwrap(), nav);
}
assert_eq!(h.app.session.doc.to_json(), baseline);
assert_eq!(h.app.session.undo.len(), undo);
}
#[test]
fn tiny_viewports_never_overlap_navigation_and_axis_view() {
for width in [39.0, 40.0, 90.0, 120.0, 160.0, 310.0] {
for height in [30.0, 80.0, 130.0, 160.0, 240.0] {
let vp = Rect::from_min_size(pos2(20.0, 30.0), vec2(width, height));
let clear = Layout::new(vp, &[], 1.0);
let covered = Layout::new(vp, &[vp], 1.0);
assert_eq!(clear.axis_view, covered.axis_view);
assert_eq!(clear.navigation, covered.navigation);
assert!(!clear.axis_view.intersect(clear.navigation).is_positive());
for control in [clear.axis_view, clear.navigation] {
if control.is_positive() {
assert!(vp.contains_rect(control), "{clear:?}");
}
}
}
}
}
@@ -61,20 +61,13 @@
}
},
{
"note": "The shelled floor is picked rather than the origin plane. Its uncertified mesh face requires an explicit fixed-plane choice."
"note": "The certified Shell floor is picked rather than the origin plane and starts a healthy attached sketch directly."
},
{
"expect": {
"sketching": false,
"dialog": "this face has no exact planar source"
}
},
{
"widget": "Use fixed plane"
},
{
"expect": {
"sketching": true
"sketching": true,
"dialog": null,
"errors": 0
}
},
{